Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 31 additions & 6 deletions src/server/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
92 changes: 91 additions & 1 deletion tests/server/server-images.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 ──

/**
Expand Down
Loading