diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index db5d7ca2c..f3b90901d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `openai-codex-responses` forwarding sampling controls (`temperature`, `top_p`, `top_k`, `min_p`, `presence_penalty`, `repetition_penalty`) into the Codex request body — the ChatGPT-subscription Codex backend rejects each of them with a 400 `{"detail":"Unsupported parameter: temperature"}`, so any caller setting non-default `StreamOptions` saw every turn fail. The provider now drops the full sampling set (matching codex-rs), and the auth-gateway's defensive strip on both `buildStreamOptions` and the pi-native path was widened from `{temperature, topP}` to the same set plus `stopSequences`/`frequencyPenalty`. ([#3117](https://github.com/can1357/oh-my-pi/issues/3117)) + ## [16.1.4] - 2026-06-19 ### Added diff --git a/packages/ai/src/auth-gateway/server.ts b/packages/ai/src/auth-gateway/server.ts index 67ee56265..a05d00bf7 100644 --- a/packages/ai/src/auth-gateway/server.ts +++ b/packages/ai/src/auth-gateway/server.ts @@ -120,20 +120,20 @@ function deriveSessionId(modelId: string, context: Context): string { function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: AbortSignal): SimpleStreamOptions { const opts: SimpleStreamOptions = { signal }; const { options } = parsed; - // Codex backend rejects `temperature` / `top_p` (per-model defaults only), - // so we drop them silently for that one provider. Every other unsupported - // option is just ignored by `streamSimple` if the underlying provider - // doesn't honour it. + // Codex backend rejects every sampling control with + // `Unsupported parameter: …` (#3117). Strip the full set for that one + // provider; everything else is harmless to forward — `streamSimple` ignores + // what the underlying provider doesn't honour. const isCodex = api === "openai-codex-responses"; if (options.maxOutputTokens !== undefined) opts.maxTokens = options.maxOutputTokens; if (options.temperature !== undefined && !isCodex) opts.temperature = options.temperature; if (options.topP !== undefined && !isCodex) opts.topP = options.topP; - if (options.topK !== undefined) opts.topK = options.topK; - if (options.minP !== undefined) opts.minP = options.minP; - if (options.stopSequences !== undefined) opts.stopSequences = options.stopSequences; - if (options.presencePenalty !== undefined) opts.presencePenalty = options.presencePenalty; - if (options.frequencyPenalty !== undefined) opts.frequencyPenalty = options.frequencyPenalty; - if (options.repetitionPenalty !== undefined) opts.repetitionPenalty = options.repetitionPenalty; + if (options.topK !== undefined && !isCodex) opts.topK = options.topK; + if (options.minP !== undefined && !isCodex) opts.minP = options.minP; + if (options.stopSequences !== undefined && !isCodex) opts.stopSequences = options.stopSequences; + if (options.presencePenalty !== undefined && !isCodex) opts.presencePenalty = options.presencePenalty; + if (options.frequencyPenalty !== undefined && !isCodex) opts.frequencyPenalty = options.frequencyPenalty; + if (options.repetitionPenalty !== undefined && !isCodex) opts.repetitionPenalty = options.repetitionPenalty; if (options.metadata !== undefined) opts.metadata = options.metadata; if (options.headers !== undefined) opts.headers = { ...(opts.headers ?? {}), ...options.headers }; if (options.toolChoice !== undefined) { @@ -662,8 +662,8 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe // Build the SimpleStreamOptions actually handed to `streamSimple`. We // trust the client's options (already allow-listed by `parseRequest`) and - // only inject server-controlled fields. The codex temperature/topP strip - // matches `buildStreamOptions` — Codex rejects them with a 400. + // only inject server-controlled fields. The codex sampling strip mirrors + // `buildStreamOptions` — Codex rejects every one with a 400 (#3117). const streamOpts: SimpleStreamOptions = { ...parsed.options, apiKey, signal: controller.signal }; streamOpts.apiKey = buildGatewayApiKeyResolver( bootOpts.storage, @@ -677,6 +677,12 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe if (model.api === "openai-codex-responses") { delete streamOpts.temperature; delete streamOpts.topP; + delete streamOpts.topK; + delete streamOpts.minP; + delete streamOpts.stopSequences; + delete streamOpts.presencePenalty; + delete streamOpts.frequencyPenalty; + delete streamOpts.repetitionPenalty; } // Merge gateway-captured passthrough headers under the client's own // headers — the client's values win when they collide. diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 4ac3ed632..37eafbefc 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -729,25 +729,12 @@ async function buildTransformedCodexRequestBody( // `maxTokens` is intentionally not forwarded: transformRequestBody strips // `max_output_tokens`/`max_completion_tokens` (the Codex backend rejects - // caller-supplied output caps). - if (options?.temperature !== undefined) { - params.temperature = options.temperature; - } - if (options?.topP !== undefined) { - params.top_p = options.topP; - } - if (options?.topK !== undefined) { - params.top_k = options.topK; - } - if (options?.minP !== undefined) { - params.min_p = options.minP; - } - if (options?.presencePenalty !== undefined) { - params.presence_penalty = options.presencePenalty; - } - if (options?.repetitionPenalty !== undefined) { - params.repetition_penalty = options.repetitionPenalty; - } + // caller-supplied output caps). Sampling controls (`temperature`, `top_p`, + // `top_k`, `min_p`, `presence_penalty`, `repetition_penalty`, + // `frequency_penalty`, `stop`) are likewise refused with + // `{"detail":"Unsupported parameter: temperature"}` etc., so we drop + // everything from `StreamOptions` rather than forwarding any of them. + // (#3117 — codex-rs sends none of these either.) applyOpenAIServiceTier(params, options?.serviceTier, model.provider); if (context.tools && context.tools.length > 0) { params.tools = convertOpenAICodexResponsesTools(context.tools, model); diff --git a/packages/ai/src/providers/openai-codex/request-transformer.ts b/packages/ai/src/providers/openai-codex/request-transformer.ts index e6060132a..288f6f3ca 100644 --- a/packages/ai/src/providers/openai-codex/request-transformer.ts +++ b/packages/ai/src/providers/openai-codex/request-transformer.ts @@ -41,12 +41,10 @@ export interface RequestBody { input?: InputItem[]; tools?: unknown; tool_choice?: unknown; - temperature?: number; - top_p?: number; - top_k?: number; - min_p?: number; - presence_penalty?: number; - repetition_penalty?: number; + // Sampling controls (temperature/top_p/top_k/min_p/presence_penalty/ + // repetition_penalty/frequency_penalty/stop) are intentionally absent: the + // Codex backend rejects every one with a 400 `Unsupported parameter`, so + // the transformer never sets them (#3117). reasoning?: Partial; text?: { verbosity?: "low" | "medium" | "high"; diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 4eba24565..66f1fc54a 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -1586,6 +1586,56 @@ describe("openai-codex streaming", () => { expect(capturedBody?.prompt_cache_key).toBe(promptCacheKey); }); + it("omits unsupported sampling keys (temperature/top_p/top_k/min_p/penalties) from the Codex Responses body", async () => { + // Regression for #3117 — Codex backend returns + // `{"detail":"Unsupported parameter: temperature"}` 400 for any of + // these keys, so the provider MUST drop them even when the caller's + // `StreamOptions` carries non-default values. + const tempDir = TempDir.createSync("@pi-codex-stream-"); + setAgentDir(tempDir.path()); + + const token = createCodexTestToken(); + const model = { ...createCodexTestModel("https://chatgpt.com/backend-api"), preferWebsockets: false }; + let capturedBody: Record | undefined; + + const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "https://chatgpt.com/backend-api/codex/responses") { + capturedBody = + typeof init?.body === "string" ? (JSON.parse(init.body) as Record) : undefined; + return new Response(createCompletedCodexSse("Hello"), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + } + return new Response("not found", { status: 404 }); + }); + + await streamOpenAICodexResponses(model, createCodexTestContext(), { + fetch: fetchMock as FetchImpl, + apiKey: token, + temperature: 0.2, + topP: 0.9, + topK: 40, + minP: 0.05, + presencePenalty: 0.1, + frequencyPenalty: 0.1, + repetitionPenalty: 1.1, + stopSequences: ["STOP"], + }).result(); + + expect(capturedBody).toBeDefined(); + expect(capturedBody?.temperature).toBeUndefined(); + expect(capturedBody?.top_p).toBeUndefined(); + expect(capturedBody?.top_k).toBeUndefined(); + expect(capturedBody?.min_p).toBeUndefined(); + expect(capturedBody?.presence_penalty).toBeUndefined(); + expect(capturedBody?.frequency_penalty).toBeUndefined(); + expect(capturedBody?.repetition_penalty).toBeUndefined(); + expect(capturedBody?.stop).toBeUndefined(); + expect(capturedBody?.stop_sequences).toBeUndefined(); + }); + it("rejects gpt-5.3-codex minimal reasoning effort instead of clamping", async () => { const tempDir = TempDir.createSync("@pi-codex-stream-"); setAgentDir(tempDir.path());