fix(images): retry Codex image generation on a sibling ChatGPT account

Some ChatGPT accounts can run Codex but lack entitlement for the specific
requested image model — upstream returns a stable 400 for that exact case.
Today that fails the whole request; #8307 (fenix007) identified this as a
production incident and wrote the original repro/test for it.

Its own implementation predates the general credential-fallback mechanism
this codebase now has (executeImageWithCredentialFallback +
saveImageErrorResult's retryable opt-in, #10494) and duplicated that retry
loop inline in route.ts, which now collides with it. This reimplements the
same fix through the existing mechanism instead: classify the exact upstream
message and mark the failure retryable, letting the already-wired
sibling-account fallback do the rest.

Co-authored-by: fenix007 <4217955+fenix007@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Markus Hartung
2026-08-20 08:31:04 -03:00
parent 0f13fe4221
commit 57b94e743d
3 changed files with 80 additions and 0 deletions

View File

@@ -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)).

View File

@@ -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<string, unknown>;
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<string, unknown>).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 } : {}),
},
};
}

View File

@@ -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;
}
});