diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 7aa3fa8ec8..4203c87c1a 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -6,7 +6,7 @@ import { COMPACT_PROMPT, decodeCompactionSummary, SUMMARY_PREFIX } from "../resp import { collectResponsesToolGroups } from "../responses/tool-groups"; import { isHostedToolUnsupportedForModel } from "../responses/hosted-tool-policy"; import { decodeServerSentEvents } from "../lib/sse-decoder"; -import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; +import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; import { modelRecordValue } from "../reasoning-effort"; import type { TranslatorBudget } from "../lib/translator-budget"; @@ -1171,8 +1171,12 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): let url: string; if (provider.authMode === "forward") { + const mayForwardCallerCredentials = isCanonicalOpenAiForwardProvider(provider); // OAuth passthrough: ChatGPT backend path is `${baseUrl}/responses` (no /v1). - url = `${provider.baseUrl}/responses`; + const baseUrl = mayForwardCallerCredentials + ? CODEX_FORWARD_BASE_URL + : provider.baseUrl.replace(/\/+$/, ""); + url = `${baseUrl}/responses`; if (provider.headers) Object.assign(headers, provider.headers); // static headers first… const runtimeProvider = provider as { _codexAccountOverride?: { accessToken: string; chatgptAccountId: string }; @@ -1181,12 +1185,14 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (runtimeProvider._codexAccountRequired && !runtimeProvider._codexAccountOverride) { throw new Error("Codex pool account auth is required but unavailable"); } - for (const h of FORWARD_HEADERS) { - const v = incoming?.headers.get(h); - if (v) headers[h] = v; // …so forwarded auth always wins. + if (mayForwardCallerCredentials) { + for (const h of FORWARD_HEADERS) { + const v = incoming?.headers.get(h); + if (v) headers[h] = v; // …so forwarded auth always wins. + } } const override = runtimeProvider._codexAccountOverride; - if (override) { + if (override && mayForwardCallerCredentials) { headers["authorization"] = `Bearer ${override.accessToken}`; headers["chatgpt-account-id"] = override.chatgptAccountId; } diff --git a/src/providers/openai-sidecar.ts b/src/providers/openai-sidecar.ts index 43b4e2396a..e1ffc397fb 100644 --- a/src/providers/openai-sidecar.ts +++ b/src/providers/openai-sidecar.ts @@ -13,6 +13,7 @@ import { extractAccountId } from "../oauth/chatgpt"; import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../server/auth-cors"; import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types"; import { + CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID, @@ -62,10 +63,16 @@ export function listOpenAiForwardSidecarCandidates(config: OcxConfig): OpenAiFor ? { ...provider, authMode: "forward" as const } : provider; if (!isCanonicalOpenAiForwardProvider(canonicalProvider)) return []; + // The predicate accepts harmless trailing-slash variants. Pin the provider returned + // to credential-bearing sidecars so every consumer builds one exact ChatGPT path + // instead of independently concatenating the operator's equivalent spelling. + const pinnedProvider = canonicalProvider.baseUrl === CODEX_FORWARD_BASE_URL + ? canonicalProvider + : { ...canonicalProvider, baseUrl: CODEX_FORWARD_BASE_URL }; return [{ providerName: OPENAI_CODEX_PROVIDER_ID, - provider: canonicalProvider, - accountMode: providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, canonicalProvider) ?? "pool", + provider: pinnedProvider, + accountMode: providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, pinnedProvider) ?? "pool", }]; } diff --git a/src/server/live.ts b/src/server/live.ts index af5d19498b..ffe90a21d0 100644 --- a/src/server/live.ts +++ b/src/server/live.ts @@ -168,7 +168,7 @@ export function keyedLiveUrl(baseUrl: string): string { } export function forwardLiveUrl(baseUrl: string, usesBackendShape: boolean): string { - const root = baseUrl.replace(/\/$/, ""); + const root = baseUrl.replace(/\/+$/, ""); if (usesBackendShape) return withAvasQuery(`${root}/realtime/calls`); // Frameless API shape posts to /live without the AVAS query (codex RealtimeCallClient). return `${root}/live`; diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 8846cd8a58..59ffb7b22e 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -82,7 +82,7 @@ import { } from "../../codex/upstream-host-health"; import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; -import { isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers"; +import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers"; import { slugsEquivalent } from "../../providers/slug-codec"; import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models"; import { isUsageDebugEnabled } from "../../usage/debug"; @@ -390,7 +390,9 @@ export async function handleResponsesCompact( } throw err; } - const base = (compactProvider.baseUrl ?? "").replace(/\/$/, ""); + const base = isCanonicalOpenAiForwardProvider(compactProvider) + ? CODEX_FORWARD_BASE_URL + : (compactProvider.baseUrl ?? "").replace(/\/+$/, ""); if (compactProvider.authMode !== "forward" && compactProvider.apiKey) { headers.set("authorization", `Bearer ${resolveEnvValue(compactProvider.apiKey)}`); } diff --git a/tests/codex-metadata-integrity.test.ts b/tests/codex-metadata-integrity.test.ts index c9357029f2..4182770d38 100644 --- a/tests/codex-metadata-integrity.test.ts +++ b/tests/codex-metadata-integrity.test.ts @@ -117,7 +117,7 @@ describe("Codex metadata integrity", () => { _codexAccountRequired: boolean; } = { adapter: "openai-responses", - baseUrl: "https://chatgpt.test/backend-api/codex", + baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", _codexAccountRequired: true, _codexAccountOverride: { @@ -142,7 +142,7 @@ describe("Codex metadata integrity", () => { test("adapter forward mode preserves genuine client metadata", () => { const provider: OcxProviderConfig = { adapter: "openai-responses", - baseUrl: "https://chatgpt.test/backend-api/codex", + baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", }; const adapter = createResponsesPassthroughAdapter(provider); diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index f5db97fa06..e8e5aa84f0 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -11,10 +11,78 @@ const createResponsesPassthroughAdapter = (...args: Parameters { + const userInfoUrl = new URL("https://chatgpt.com/backend-api/codex"); + userInfoUrl.username = "user"; + userInfoUrl.password = "secret"; + for (const baseUrl of [ + "https://provider.example/v1/", + "https://chatgpt.com/backend-api/not-codex", + "https://chatgpt.example/backend-api/codex", + "https://chatgpt.com/backend-api/codex?target=custom", + "https://chatgpt.com/backend-api/codex#custom", + userInfoUrl.toString(), + ]) { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl, + authMode: "forward", + headers: { "x-provider-option": "enabled" }, + _codexAccountRequired: true, + _codexAccountOverride: { + accessToken: "runtime-secret", + chatgptAccountId: "runtime-account", + }, + } as Parameters[0]); + const request = adapter.buildRequest({ + modelId: "test-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "test-model", input: "ping" }, + }, { + headers: new Headers({ + authorization: "Bearer caller-secret", + "chatgpt-account-id": "caller-account", + session_id: "caller-session", + }), + }); + + expect(request.url).toBe(`${baseUrl.replace(/\/+$/, "")}/responses`); + expect(request.headers["x-provider-option"]).toBe("enabled"); + expect(request.headers.authorization).toBeUndefined(); + expect(request.headers["chatgpt-account-id"]).toBeUndefined(); + expect(request.headers.session_id).toBeUndefined(); + } +}); + +test("canonical forward providers normalize trailing slashes and let the pool override win", () => { + const adapter = createResponsesPassthroughAdapter({ + ...provider, + baseUrl: "https://chatgpt.com/backend-api/codex///", + _codexAccountRequired: true, + _codexAccountOverride: { + accessToken: "runtime-secret", + chatgptAccountId: "runtime-account", + }, + } as Parameters[0]); + const request = adapter.buildRequest({ + modelId: "test-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "test-model", input: "ping" }, + }, { headers: new Headers({ authorization: "Bearer caller-secret" }) }); + + expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses"); + expect(request.headers.authorization).toBe("Bearer runtime-secret"); + expect(request.headers["chatgpt-account-id"]).toBe("runtime-account"); +}); + test("passthrough serialized-body observation releases after the request settles", () => { const budget = createTranslatorBudget(); const request = createResponsesPassthroughAdapter(provider).buildRequest({ diff --git a/tests/passthrough-override.test.ts b/tests/passthrough-override.test.ts index c6435ce264..85a8630889 100644 --- a/tests/passthrough-override.test.ts +++ b/tests/passthrough-override.test.ts @@ -6,7 +6,7 @@ import { withTestTranslatorBudget } from "./helpers/translator-budget"; const createResponsesPassthroughAdapter = (...args: Parameters) => withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); -const forwardProvider = { adapter: "openai-responses", baseUrl: "https://chat.openai.com/backend-api/codex", authMode: "forward" as const }; +const forwardProvider = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }; describe("passthrough token override", () => { test("buildRequest uses original auth when no override", () => { diff --git a/tests/responses-compaction-routing.test.ts b/tests/responses-compaction-routing.test.ts index fdb1830517..5a2ed0baa1 100644 --- a/tests/responses-compaction-routing.test.ts +++ b/tests/responses-compaction-routing.test.ts @@ -626,6 +626,30 @@ describe("compact alternate-account attempt (#913)", () => { }); } + test("canonical trailing slashes are pinned before native compact sends pool credentials", async () => { + await withPoolEnv("ocx-compact-canonical-url-", async config => { + config.providers.openai!.baseUrl = "https://chatgpt.com/backend-api/codex///"; + let observedUrl = ""; + let observedHeaders = new Headers(); + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + observedUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + observedHeaders = new Headers(init?.headers); + return jsonResponse(completedPayload("canonical compact response")); + }) as typeof fetch; + + const res = await handleResponsesCompact( + compactionRequest(baseCompactionBody({ model: "gpt-5.5" })), + config, + { model: "", provider: "" }, + ); + + expect(res.status).toBe(200); + expect(observedUrl).toBe("https://chatgpt.com/backend-api/codex/responses/compact"); + expect(observedHeaders.get("authorization")).toBe("Bearer pool-a-access-token"); + expect(observedHeaders.get("chatgpt-account-id")).toBe("pool_acc_a"); + }); + }); + for (const rejection of [429, 402] as const) { test(`a pre-body ${rejection} tries exactly one alternate account`, async () => { await withPoolEnv(`ocx-compact-alt-${rejection}-`, async config => { diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index d286c192fd..9499de4d85 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -158,7 +158,9 @@ test("image response byte reader enforces the stream cap when Content-Length is test("POST /v1/images/generations relays to the ChatGPT forward provider with forwarded auth", async () => { const captured: CapturedRequest[] = []; const upstream = fakeImagesUpstream(captured); - saveConfig(forwardConfig(upstream.url.toString().replace(/\/$/, ""))); + const config = forwardConfig(); + config.providers.openai!.baseUrl = "https://chatgpt.com/backend-api/codex///"; + saveConfig(config); const server = startServer(0); try { diff --git a/tests/server-live.test.ts b/tests/server-live.test.ts index fac24ddea5..ac93b400c1 100644 --- a/tests/server-live.test.ts +++ b/tests/server-live.test.ts @@ -152,7 +152,9 @@ function multipartLiveBody( test("POST /v1/live rewrites ChatGPT multipart into backend realtime/calls JSON", async () => { const captured: CapturedRequest[] = []; const upstream = fakeLiveUpstream(captured); - saveConfig(forwardConfig()); + const config = forwardConfig(); + config.providers.openai!.baseUrl = "https://chatgpt.com/backend-api/codex///"; + saveConfig(config); const server = startServer(0); try { @@ -612,6 +614,9 @@ test("buildLiveSidebandUpstreamWsUrl maps Frameless and Realtime join shapes", a expect(forwardLiveUrl("https://chatgpt.com/backend-api/codex", true)).toBe( "https://chatgpt.com/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas", ); + expect(forwardLiveUrl("https://chatgpt.com/backend-api/codex///", true)).toBe( + "https://chatgpt.com/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas", + ); expect(keyedLiveUrl("https://api.openai.com/v1")).toBe( "https://api.openai.com/v1/realtime/calls?intent=quicksilver&architecture=avas", ); diff --git a/tests/vision-sidecar-e2e.test.ts b/tests/vision-sidecar-e2e.test.ts index a504b91c4d..421de5b3ef 100644 --- a/tests/vision-sidecar-e2e.test.ts +++ b/tests/vision-sidecar-e2e.test.ts @@ -154,6 +154,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { let sidecarBody = ""; let sidecarAuth: string | null = null; let sidecarAccount: string | null = null; + let sidecarPath = ""; let sidecarHits = 0; upstream = serveUpstream(b => { upstreamBody = b; }); sidecar = serveSidecar((req, b) => { @@ -161,6 +162,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { sidecarBody = b; sidecarAuth = req.headers.get("authorization"); sidecarAccount = req.headers.get("chatgpt-account-id"); + sidecarPath = new URL(req.url).pathname; }); globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; @@ -185,7 +187,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { openai: { adapter: "openai-responses", authMode: "forward", - baseUrl: "https://chatgpt.com/backend-api/codex", + baseUrl: "https://chatgpt.com/backend-api/codex///", codexAccountMode: "direct", }, }, @@ -207,6 +209,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { // Activation evidence: the sidecar actually ran, got the image + OAuth passthrough. expect(sidecarHits).toBe(1); + expect(sidecarPath).toBe("/responses"); expect(sidecarAuth).toBe(`Bearer ${token}`); expect(sidecarAccount).toBe("acct-vision-sidecar"); expect(sidecarBody).toContain("input_image"); diff --git a/tests/web-search.test.ts b/tests/web-search.test.ts index 8050f0c1f6..062a5bde37 100644 --- a/tests/web-search.test.ts +++ b/tests/web-search.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { parseRequest } from "../src/responses/parser"; import { planWebSearch, shouldResolveOpenAiWebSearchSidecar, webSearchStallTimeoutSec } from "../src/web-search"; import { runWithWebSearch as runWithWebSearchProduction, type WebSearchLoopDeps } from "../src/web-search/loop"; +import { runWebSearch as runOpenAiWebSearch } from "../src/web-search/executor"; import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; import { headersForCodexAuthContext } from "../src/codex/auth-context"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar } from "../src/providers/openai-sidecar"; @@ -162,14 +163,17 @@ describe("web-search sidecar planning", () => { providers: { openai: { adapter: "openai-responses", - baseUrl: "https://chatgpt.com/backend-api/codex", + baseUrl: "https://chatgpt.com/backend-api/codex///", codexAccountMode: "direct", }, }, }; expect(listOpenAiForwardSidecarCandidates(canonicalWithoutAuthMode)).toMatchObject([{ providerName: "openai", - provider: { authMode: "forward" }, + provider: { + authMode: "forward", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, accountMode: "direct", }]); @@ -378,6 +382,40 @@ describe("web-search sidecar planning", () => { const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); +test("OpenAI web-search execution uses the pinned canonical URL and selected credentials", async () => { + const cfg = config({ + providers: { + routed: routedProvider, + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex///", + authMode: "forward", + codexAccountMode: "direct", + }, + }, + }); + const candidate = listOpenAiForwardSidecarCandidates(cfg)[0]!; + let observedUrl = ""; + let observedHeaders = new Headers(); + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + observedUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + observedHeaders = new Headers(init?.headers); + return new Response("data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + + await runOpenAiWebSearch( + "current docs", + { type: "web_search" }, + candidate.provider, + new Headers({ authorization: "Bearer selected-token", "chatgpt-account-id": "selected-account" }), + { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 1_000 }, + ); + + expect(observedUrl).toBe("https://chatgpt.com/backend-api/codex/responses"); + expect(observedHeaders.get("authorization")).toBe("Bearer selected-token"); + expect(observedHeaders.get("chatgpt-account-id")).toBe("selected-account"); +}); + async function collectSse(stream: ReadableStream): Promise<{ event?: string; data: Record }[]> { const reader = stream.getReader(); const decoder = new TextDecoder();