diff --git a/src/server/images.ts b/src/server/images.ts index fdf944b1c3f..215ca46aa3d 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -659,21 +659,25 @@ export async function handleImages( } const explicitKeyedProvider = config.images?.provider !== undefined && candidates.keyed !== undefined; // Admission bearer is valid proxy auth (requireApiAuth already passed) but must never be - // forwarded as OpenAI ChatGPT credentials. When the caller sent it, skip OpenAI forward - // and allow CCA / keyed paths instead of rejecting the whole request. - let skipOpenAiForwardForAdmissionBearer = false; + // forwarded as OpenAI ChatGPT credentials. When the caller sent it, direct candidates + // that would forward caller credentials are skipped, but managed pool candidates remain + // eligible because resolveFirstUsableOpenAiSidecar substitutes them with local OAuth credentials. + let callerBearerMayBeForwarded = true; if (!explicitKeyedProvider) { try { validateForwardAdmissionCredential(req.headers, config); } catch (err) { if (err instanceof ForwardAdmissionCredentialError) { - skipOpenAiForwardForAdmissionBearer = true; + callerBearerMayBeForwarded = false; } else { throw err; } } } - const canUseOpenAiForward = !skipOpenAiForwardForAdmissionBearer && candidates.forwardCandidates.length > 0; + const eligibleForwardCandidates = callerBearerMayBeForwarded + ? candidates.forwardCandidates + : candidates.forwardCandidates.filter(c => c.accountMode !== "direct"); + const canUseOpenAiForward = eligibleForwardCandidates.length > 0; if (!canUseOpenAiForward && !candidates.keyed) { const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint, admission); @@ -696,7 +700,7 @@ export async function handleImages( let forwardAuthError: Response | undefined; if (canUseOpenAiForward) { try { - forward = await resolveFirstUsableOpenAiSidecar(candidates.forwardCandidates, req.headers, config, { + forward = await resolveFirstUsableOpenAiSidecar(eligibleForwardCandidates, req.headers, config, { beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease), }); if (forward) logCtx.provider = formatCodexProviderForLog(forward.providerName, codexLogAccountId(forward.authContext), config); @@ -742,6 +746,27 @@ export async function handleImages( for (const [name, value] of forward.headers) headers[name] = value; // The ChatGPT codex backend takes bare paths (matches the adapter's `${baseUrl}/responses`). url = `${provider.baseUrl}/images/${endpoint}`; + try { + validateForwardAdmissionCredential(new Headers(headers), config); + } catch (err) { + try { + forward.releaseProbeLease?.(); + } catch (releaseErr) { + console.error("[images] Failed to release probe lease:", releaseErr); + } + if (err instanceof ForwardAdmissionCredentialError) { + return formatErrorResponse( + 500, + "internal_error", + "proxy admission secret cannot be forwarded upstream", + ); + } + return formatErrorResponse( + 500, + "internal_error", + "image generation request failed forward credential validation", + ); + } } else if (forwardAuthError) { // Before surfacing the OpenAI auth failure, try CCA — the user may have a // valid Google Antigravity login even though their OpenAI pool is broken. diff --git a/tests/server/server-images.test.ts b/tests/server/server-images.test.ts index f08dfac858f..a8f70044e39 100644 --- a/tests/server/server-images.test.ts +++ b/tests/server/server-images.test.ts @@ -8,7 +8,7 @@ import { existsSync, mkdirSync} from "node:fs"; import { join } from "node:path"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; import { clearAccountNeedsReauth, clearAccountQuota } from "../../src/codex/auth-api"; -import { clearCodexUpstreamHealth, clearThreadAccountMap, getCodexUpstreamHealth } from "../../src/codex/routing"; +import { clearCodexUpstreamHealth, clearThreadAccountMap, getCodexUpstreamHealth, recordCodexUpstreamOutcome } from "../../src/codex/routing"; import { loadConfig, saveConfig } from "../../src/config"; import { clearKeyCooldowns, rotateKeyOn429 } from "../../src/providers/key-failover"; import { selectImagesProvider } from "../../src/providers/openai-sidecar"; @@ -1682,6 +1682,96 @@ test("the proxy admission secret is never relayed to the forward upstream", asyn } }); +test("proxy admission bearer with pool forward candidate routes to OpenAI using pool credentials", async () => { + process.env.OPENCODEX_API_AUTH_TOKEN = "proxy-secret-pool"; + const captured: CapturedRequest[] = []; + const upstream = fakeImagesUpstream(captured); + saveConfig({ + ...forwardConfig(upstream.url.toString().replace(/\/$/, "")), + port: 0, + hostname: "0.0.0.0", + defaultProvider: "openai", + providers: { + openai: { ...canonicalOpenAiProvider, codexAccountMode: "pool" }, + }, + codexAccounts: [ + { id: "main", email: "main@example.test", isMain: true }, + { id: "pool-img", email: "pool-img@example.test", isMain: false, chatgptAccountId: "acct-pool-img" }, + ], + activeCodexAccountId: "pool-img", + } as OcxConfig); + saveCodexAccountCredential("pool-img", { + accessToken: "pool-img-access-token", + refreshToken: "pool-img-refresh-token", + expiresAt: Date.now() + 3_600_000, + chatgptAccountId: "acct-pool-img", + }); + + const server = startServer(0); + try { + // Standard client authenticates with the proxy admission secret in Authorization: Bearer. + // In pool mode, this must route to OpenAI forward and replace the bearer with the managed pool token. + const response = await fetch(`http://127.0.0.1:${server.port}/v1/images/generations`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer proxy-secret-pool", + }, + body: JSON.stringify({ prompt: "editorial cartoon in ink", model: "gpt-image-2" }), + }); + expect(response.status).toBe(200); + expect(captured).toHaveLength(1); + expect(captured[0].path).toBe("/images/generations"); + expect(captured[0].headers.get("authorization")).toBe("Bearer pool-img-access-token"); + expect(captured[0].headers.get("chatgpt-account-id")).toBe("acct-pool-img"); + expect([...captured[0].headers.values()].some(v => v.includes("proxy-secret-pool"))).toBe(false); + } finally { + await server.stop(true); + await upstream.stop(true); + } +}); + +test("proxy admission bearer with pool auth failure surfaces pool error without falling back to keyed provider", async () => { + process.env.OPENCODEX_API_AUTH_TOKEN = "proxy-secret-pool"; + const captured: CapturedRequest[] = []; + const upstream = fakeImagesUpstream(captured); + saveConfig({ + port: 0, + hostname: "0.0.0.0", + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: { ...canonicalOpenAiProvider, codexAccountMode: "pool" }, + "openai-apikey": keyedProvider(upstream.url.toString().replace(/\/$/, "")), + }, + codexAccounts: [ + { id: "main", email: "main@example.test", isMain: true }, + { id: "pool-a", email: "pool@example.test", isMain: false, chatgptAccountId: "acct-pool-a" }, + ], + // pool-a has NO stored credential, so forward-auth resolution throws CodexAuthContextError. + activeCodexAccountId: "pool-a", + } as OcxConfig); + + const server = startServer(0); + try { + const response = await fetch(`http://127.0.0.1:${server.port}/v1/images/generations`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer proxy-secret-pool", + }, + body: JSON.stringify({ prompt: "test prompt", model: "gpt-image-2" }), + }); + expect(response.status).toBe(401); + expect(captured).toHaveLength(0); + const json = (await response.json()) as { error: { message: string } }; + expect(json.error.message).toContain("reauthentication"); + } finally { + await server.stop(true); + await upstream.stop(true); + } +}); + // ── Google Antigravity (CCA) image generation fallback ── /**