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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user