diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index c1ce136ca4..36a7d86a4d 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -4254,6 +4254,15 @@ async function handleResponsesInner( throw new Error("OAuth account selection changed repeatedly before dispatch"); }; }; + const selectionBoundContinuationDispatch = ( + wireRequest: AdapterRequest, + ): ProviderFetchOptions["dispatchOverride"] => async (input, init, execute) => { + if (!selectionIsCurrent(requestBindings.get(wireRequest))) { + throw new Error("API key selection changed during a request continuation"); + } + const fetchImpl = (route.provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? execute; + return fetchImpl(input, { ...init, redirect: "manual" }); + }; const anthropicSessionKey = route.providerName === "anthropic" && route.provider.authMode === "oauth" ? anthropicSessionKeyFromParts({ sessionIdHeader: sessionIdHeaderFromRequest(req.headers), @@ -5851,7 +5860,9 @@ async function handleResponsesInner( connectMs, true, providerFetch(route.provider, options.codexWsRuntimeIdentity, { - dispatchOverride: oauthDispatch(request), + // The first leg has already been served by this key. Unlike a pre-dispatch + // retry, a continuation must never rebuild the turn under a newly selected key. + dispatchOverride: selectionBoundContinuationDispatch(request), providerName: route.providerName, modelId: route.modelId, }), diff --git a/tests/web-search/web-search-passthrough-bridge.test.ts b/tests/web-search/web-search-passthrough-bridge.test.ts index 9e7029bb53..b26e791e27 100644 --- a/tests/web-search/web-search-passthrough-bridge.test.ts +++ b/tests/web-search/web-search-passthrough-bridge.test.ts @@ -572,6 +572,7 @@ describe("the reported turn, end to end through handleResponses", () => { async function post( ocxConfig: OcxConfig, legs: string[], + onSearch?: () => void, ): Promise<{ body: string; outbound: string[]; searches: number }> { const savedFetch = globalThis.fetch; const outbound: string[] = []; @@ -583,6 +584,7 @@ describe("the reported turn, end to end through handleResponses", () => { : input instanceof URL ? input.href : (input as Request).url; if (url.includes("/api/web_search")) { searches += 1; + onSearch?.(); return new Response(JSON.stringify({ results: [{ title: "Releases", url: "https://example.test/rel", content: "opencodex 2.50.0" }], }), { headers: { "content-type": "application/json" } }); @@ -632,6 +634,20 @@ describe("the reported turn, end to end through handleResponses", () => { item.type === "function_call" && item.name === "web_search")).toBe(true); }); + test("fails closed when the selected API key changes before a continuation", async () => { + const ocxConfig = config(armed); + const result = await post(ocxConfig, [searchLeg(), answerLeg()], () => { + ocxConfig.providers.fixture!.apiKey = "replacement-key"; + ocxConfig.providers.fixture!.apiKeySelectionRevision = "replacement-selection"; + }); + + expect(result.searches).toBe(1); + expect(result.outbound).toHaveLength(1); + expect(result.outbound[0]).toContain("what is the latest release?"); + expect(result.body).toContain(WEB_SEARCH_BRIDGE_ERROR_CODE); + expect(result.body).not.toContain("The current release is 2.50.0."); + }); + test("an unrelated undeclared tool still fails closed through the bridged stream", async () => { const strayCall = { type: "function_call",