fix(codex): fail over image generation for imported free plans (#11948)

Boarded with #11954/#11953/#11951/#11952 in one combined worktree: typecheck:core, check:file-size, check:changelog-integrity, check:complexity, check:cognitive-complexity, check:cycles all green; 85/85 focused tests pass. Verified both halves of the gap directly: isCodexFreePlan() (open-sse/executors/codex/tools.ts) only checks workspacePlanType, while codexImport.ts normalizes the JWT plan into providerSpecificData.chatgptPlanType — confirmed imported free-plan accounts would bypass the existing guard. Thanks for tracing the full import-to-guard path.
This commit is contained in:
Bl0ck
2026-08-30 11:10:39 +03:00
committed by GitHub
parent e96e40c035
commit 79b2e92c4e
4 changed files with 50 additions and 1 deletions

View File

@@ -26,7 +26,14 @@ export const CODEX_HOSTED_TOOL_TYPES: ReadonlySet<string> = new Set([
// dropped for free-plan accounts (mirrors CLIProxyAPI's isCodexFreePlanAuth).
export function isCodexFreePlan(providerSpecificData: unknown): boolean {
if (!providerSpecificData || typeof providerSpecificData !== "object") return false;
const plan = (providerSpecificData as { workspacePlanType?: unknown }).workspacePlanType;
const data = providerSpecificData as {
workspacePlanType?: unknown;
chatgptPlanType?: unknown;
};
const plan =
typeof data.workspacePlanType === "string"
? data.workspacePlanType
: data.chatgptPlanType;
return typeof plan === "string" && plan.trim().toLowerCase() === "free";
}

View File

@@ -14,6 +14,7 @@ import { getAntigravityEnvelopeUserAgent } from "../services/antigravityIdentity
import { kieExecutor } from "../executors/kie.ts";
import { mapImageSize } from "../translator/image/sizeMapper.ts";
import { getCodexClientVersion, getCodexUserAgent } from "../config/codexClient.ts";
import { isCodexFreePlan } from "../executors/codex/tools.ts";
import { saveCallLog } from "@/lib/usageDb";
import { sleep } from "../utils/sleep.ts";
import {
@@ -2480,6 +2481,18 @@ async function handleCodexImageGeneration({
});
}
if (isCodexFreePlan(credentials?.providerSpecificData)) {
return saveImageErrorResult({
provider,
model,
status: 403,
startTime,
error: "Codex image_generation is unavailable on free-plan accounts",
path: logPath,
retryable: true,
});
}
const workspaceId =
credentials?.providerSpecificData &&
typeof credentials.providerSpecificData === "object" &&

View File

@@ -12,6 +12,9 @@ const { normalizeCodexTools, isCodexFreePlan } = await import("../../open-sse/ex
test("isCodexFreePlan detects workspacePlanType === 'free' (case-insensitive)", () => {
assert.equal(isCodexFreePlan({ workspacePlanType: "free" }), true);
assert.equal(isCodexFreePlan({ workspacePlanType: "FREE" }), true);
assert.equal(isCodexFreePlan({ chatgptPlanType: "free" }), true);
assert.equal(isCodexFreePlan({ chatgptPlanType: "FREE" }), true);
assert.equal(isCodexFreePlan({ workspacePlanType: "team", chatgptPlanType: "free" }), false);
assert.equal(isCodexFreePlan({ workspacePlanType: "team" }), false);
assert.equal(isCodexFreePlan({ workspacePlanType: "" }), false);
assert.equal(isCodexFreePlan({}), false);

View File

@@ -2028,6 +2028,32 @@ test("handleImageGeneration (codex) forwards size and maps GPT-Image quality to
}
});
test("handleImageGeneration (codex) skips imported free-plan accounts and marks them retryable", async () => {
const originalFetch = globalThis.fetch;
let fetchCalls = 0;
globalThis.fetch = async () => {
fetchCalls += 1;
throw new Error("free-plan image request should not reach upstream");
};
try {
const result = await handleImageGeneration({
body: { model: "codex/gpt-5.6-terra", prompt: "kitten" },
credentials: {
accessToken: "codex-token",
providerSpecificData: { chatgptPlanType: "free" },
},
log: null,
});
assert.equal(result.success, false);
assert.equal(result.status, 403);
assert.equal(result.retryable, true);
assert.equal(fetchCalls, 0);
} finally {
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