diff --git a/changelog.d/fixes/8307-codex-image-account-fallback-retryable.md b/changelog.d/fixes/8307-codex-image-account-fallback-retryable.md new file mode 100644 index 0000000000..bd4a70a1ab --- /dev/null +++ b/changelog.d/fixes/8307-codex-image-account-fallback-retryable.md @@ -0,0 +1 @@ +- **fix(images):** retry Codex image generation on a sibling ChatGPT account when the requested model isn't entitled on the current account, instead of failing the request outright ([#8307](https://github.com/diegosouzapw/OmniRoute/pull/8307)). diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 7fb6357db1..a8452f0245 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -184,6 +184,29 @@ function sanitizeImageProviderError(errorText: string): unknown { return sanitizeErrorMessage(errorText); } +// #8307 — some ChatGPT accounts can run Codex but lack entitlement for the specific +// requested image model. Upstream signals this as a 400 with an exact, stable message +// (not a generic "invalid request"). Classify it so the caller can mark the failure +// `retryable: true`, which routes it through the same sibling-account fallback that +// already handles 401s (executeImageWithCredentialFallback, src/sse/services/imageCredentialRetry.ts). +function isCodexChatGptModelAccessError(status: number, errorText: string, model: string): boolean { + if (status !== 400) return false; + const parsed = parseJsonOrNull(errorText); + let detail: string | null = null; + if (typeof parsed === "string") { + detail = parsed; + } else if (parsed && typeof parsed === "object") { + const obj = parsed as Record; + if (typeof obj.detail === "string") detail = obj.detail; + else if (typeof obj.message === "string") detail = obj.message; + else if (obj.error && typeof obj.error === "object") { + const nested = (obj.error as Record).message; + if (typeof nested === "string") detail = nested; + } + } + return detail === `The '${model}' model is not supported when using Codex with a ChatGPT account.`; +} + const BFL_MODEL_ENDPOINTS = { "flux-2-max": "/v1/flux-2-max", "flux-2-pro": "/v1/flux-2-pro", @@ -2532,6 +2555,7 @@ async function handleCodexImageGeneration({ const safeErrorLog = typeof safeError === "string" ? safeError : JSON.stringify(safeError ?? {}); if (log) log.error("IMAGE", `${provider} error ${response.status}: ${safeErrorLog}`); + const retryable = isCodexChatGptModelAccessError(response.status, errorText, model); return { ok: false as const, error: { @@ -2542,6 +2566,7 @@ async function handleCodexImageGeneration({ error: safeError, requestBody: requestBodyForLog, path: logPath, + ...(retryable ? { retryable: true } : {}), }, }; } diff --git a/tests/unit/image-generation-handler.test.ts b/tests/unit/image-generation-handler.test.ts index d525e60d4d..49709a13c3 100644 --- a/tests/unit/image-generation-handler.test.ts +++ b/tests/unit/image-generation-handler.test.ts @@ -2026,3 +2026,57 @@ test("handleImageGeneration (codex) forwards size and maps GPT-Image quality to globalThis.fetch = originalFetch; } }); + +// #8307 — some ChatGPT accounts can run Codex but lack entitlement for the specific +// requested image model, and the upstream 400 for that exact case is retryable on a +// sibling account: executeImageWithCredentialFallback (route.ts) already retries on +// this signal when the handler marks the failure `retryable: true` — mirroring the +// existing 401 auto-rotate path, no new retry loop needed in the handler itself. +test("handleImageGeneration (codex) marks the ChatGPT-account model-access 400 as retryable", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + error: { + message: + "The 'gpt-5.6-sol' model is not supported when using Codex with a ChatGPT account.", + }, + }), + { status: 400, headers: { "content-type": "application/json" } } + ); + + try { + const result = await handleImageGeneration({ + body: { model: "codex/gpt-5.6-sol", prompt: "kitten" }, + credentials: { accessToken: "codex-token" }, + log: null, + }); + assert.equal(result.success, false); + assert.equal(result.status, 400); + assert.equal(result.retryable, true); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleImageGeneration (codex) does not mark an ordinary 400 as retryable", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response(JSON.stringify({ error: { message: "Invalid prompt" } }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + + try { + const result = await handleImageGeneration({ + body: { model: "codex/gpt-5.6-sol", prompt: "kitten" }, + credentials: { accessToken: "codex-token" }, + log: null, + }); + assert.equal(result.success, false); + assert.equal(result.status, 400); + assert.equal(result.retryable, undefined); + } finally { + globalThis.fetch = originalFetch; + } +});