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
This commit is contained in:
roboomp
2026-06-25 01:34:07 +00:00
parent 8d60776a6f
commit 04bfce62c6
4 changed files with 85 additions and 6 deletions
+4
View File
@@ -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
@@ -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) {
@@ -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<Api>, 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;
}
@@ -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<Record<string, unknown>>;
const liteOutput = lite.input?.[2]?.output as Array<Record<string, unknown>>;
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<Record<string, unknown>>;
@@ -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");