From 04bfce62c696805c6bb75c79240297b8af818aaa Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 25 Jun 2026 01:34:07 +0000 Subject: [PATCH] fix(ai): disabled codex lite for image prompts Codex Responses Lite only accepts text input, so GPT image prompts with input_image failed before reaching the full multimodal transport. The request transformer and Codex headers now fall back to full Responses whenever a request body contains input_image, with regression coverage for the lite header. Fixes #3421 --- packages/ai/CHANGELOG.md | 4 ++ .../src/providers/openai-codex-responses.ts | 3 +- .../openai-codex/request-transformer.ts | 26 ++++++++- .../test/openai-codex-responses-lite.test.ts | 58 ++++++++++++++++++- 4 files changed, 85 insertions(+), 6 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 17926f4bb..26df2ad41 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Codex Responses Lite staying enabled for image prompts, which caused GPT/Codex image turns to be rejected as `Invalid value: 'input_image'`; image-bearing Codex requests now fall back to the full Responses transport. ([#3421](https://github.com/can1357/oh-my-pi/issues/3421)) + ## [16.1.17] - 2026-06-24 ### Added diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index b7838fd18..4a7f911fb 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -61,6 +61,7 @@ import { type CodexRequestOptions, type InputItem, type RequestBody, + shouldUseCodexResponsesLite, transformRequestBody, } from "./openai-codex/request-transformer"; import { CodexApiError } from "./openai-codex/response-handler"; @@ -697,7 +698,7 @@ async function buildCodexRequestContext( }; const providerSessionState = getCodexProviderSessionState(options?.providerSessionState); - const responsesLite = options?.responsesLite === true; + const responsesLite = shouldUseCodexResponsesLite(transformedBody, options?.responsesLite); const sessionKey = getCodexWebSocketSessionKey(transportSessionId, model, accountId, baseUrl, responsesLite); const publicSessionKey = transportSessionId ? `${baseUrl}:${model.id}:${transportSessionId}` : undefined; if (sessionKey && publicSessionKey) { diff --git a/packages/ai/src/providers/openai-codex/request-transformer.ts b/packages/ai/src/providers/openai-codex/request-transformer.ts index 288f6f3ca..38ea1d54c 100644 --- a/packages/ai/src/providers/openai-codex/request-transformer.ts +++ b/packages/ai/src/providers/openai-codex/request-transformer.ts @@ -59,6 +59,27 @@ export interface RequestBody { [key: string]: unknown; } +function containsInputImage(value: unknown): boolean { + if (!value || typeof value !== "object") return false; + if ((value as { type?: unknown }).type === "input_image") return true; + if (Array.isArray(value)) { + for (const item of value) { + if (containsInputImage(item)) return true; + } + return false; + } + for (const item of Object.values(value)) { + if (containsInputImage(item)) return true; + } + return false; +} + +/** Returns whether a Codex request can use the text-only Responses Lite transport. */ +export function shouldUseCodexResponsesLite(body: RequestBody, requested: boolean | undefined): boolean { + return requested === true && !containsInputImage(body.input); +} + + function getReasoningConfig(model: Model, options: CodexRequestOptions): ReasoningConfig { const config: ReasoningConfig = { effort: @@ -211,7 +232,8 @@ export async function transformRequestBody( body.input = [...developerMessages, ...body.input]; } - if (options.responsesLite) { + const responsesLite = shouldUseCodexResponsesLite(body, options.responsesLite); + if (responsesLite) { if (Array.isArray(body.input)) { stripImageDetails(body.input); } @@ -231,7 +253,7 @@ export async function transformRequestBody( // Responses Lite keeps reasoning replay server-side; codex-rs requests // `all_turns` there and otherwise omits context so the server default // (currently `current_turn`) applies. - const reasoningContext = options.reasoningContext ?? (options.responsesLite ? "all_turns" : undefined); + const reasoningContext = options.reasoningContext ?? (responsesLite ? "all_turns" : undefined); if (reasoningContext !== undefined) { body.reasoning.context = reasoningContext; } diff --git a/packages/ai/test/openai-codex-responses-lite.test.ts b/packages/ai/test/openai-codex-responses-lite.test.ts index 72083ada6..9cc5e9c0f 100644 --- a/packages/ai/test/openai-codex-responses-lite.test.ts +++ b/packages/ai/test/openai-codex-responses-lite.test.ts @@ -115,7 +115,7 @@ describe("openai-codex reasoning.context", () => { }); describe("openai-codex Responses Lite input shaping", () => { - it("strips image detail from message content and tool outputs only under lite", async () => { + it("keeps full Responses image details when a requested lite body contains images", async () => { const model = createCodexModel("gpt-5.1-codex"); const makeInput = (): InputItem[] => [ { @@ -137,8 +137,8 @@ describe("openai-codex Responses Lite input shaping", () => { const lite = await transformRequestBody({ model: model.id, input: makeInput() }, model, { responsesLite: true }); const liteMessage = lite.input?.[0]?.content as Array>; const liteOutput = lite.input?.[2]?.output as Array>; - expect(liteMessage[1]).toEqual({ type: "input_image", image_url: "data:image/png;base64,AAAA" }); - expect(liteOutput[0]).toEqual({ type: "input_image", image_url: "data:image/png;base64,BBBB" }); + expect(liteMessage[1]).toEqual({ type: "input_image", detail: "auto", image_url: "data:image/png;base64,AAAA" }); + expect(liteOutput[0]).toEqual({ type: "input_image", detail: "high", image_url: "data:image/png;base64,BBBB" }); const plain = await transformRequestBody({ model: model.id, input: makeInput() }, model, {}); const plainMessage = plain.input?.[0]?.content as Array>; @@ -214,6 +214,58 @@ describe("openai-codex Responses Lite and client metadata wire format", () => { expect(captured?.headers.get("x-openai-internal-codex-responses-lite")).toBe("true"); expect(captured?.body.client_metadata).toEqual(clientMetadata); }); + it("falls back to full Responses when a lite request contains images", async () => { + const model = buildModel({ + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 272_000, + maxTokens: 128_000, + }); + let captured: CapturedCodexRequest | undefined; + const fetchMock = createCodexFetchMock(createCodexSse(COMPLETED_CODEX_EVENTS), request => { + captured = request; + }); + + const result = await streamOpenAICodexResponses( + model, + { + messages: [ + { + role: "user", + timestamp: Date.now(), + content: [ + { type: "text", text: "read this image" }, + { type: "image", mimeType: "image/png", data: "AAAA" }, + ], + }, + ], + }, + { + apiKey: createCodexTestToken(), + fetch: fetchMock, + responsesLite: true, + }, + ).result(); + + expect(result.stopReason).toBe("stop"); + expect(captured?.headers.get("x-openai-internal-codex-responses-lite")).toBeNull(); + expect(captured?.body.input).toEqual([ + { + role: "user", + content: [ + { type: "input_text", text: "read this image" }, + { type: "input_image", detail: "auto", image_url: "data:image/png;base64,AAAA" }, + ], + }, + ]); + }); + it("omits the lite header and client_metadata when not requested", async () => { const model = createCodexModel("gpt-5.1-codex");