From 71f3badb714f64349107dcb67b8454b01e249331 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 20 Sep 2026 15:27:31 +0900 Subject: [PATCH 1/2] fix(web-search): isolate replay cache by request context --- src/adapters/openai-responses/passthrough.ts | 8 +-- src/responses/bridge-search-replay-cache.ts | 30 ++++++---- src/server/responses/passthrough-delivery.ts | 7 +-- src/server/responses/request-prepare.ts | 6 ++ src/types/request.ts | 2 + structure/providers-and-adapters.md | 14 +++-- .../web-search-bridge-replay.test.ts | 57 +++++++++++++++---- 7 files changed, 90 insertions(+), 34 deletions(-) diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 7a32406ea9f..74717766e5a 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -328,11 +328,11 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = stripUnsupportedReasoningSummaryDelivery(outBody, parsed.modelId); // #4587: on a bridged provider, hand the destination back the search call and result the // proxy executed on its behalf, in place of the hosted cell the caller replays. Scoped to - // this destination and recorded by the bridge itself, so a provider without the opt-in - // computes no identity and keeps the body reference it already had. This runs before the - // query backfill below because a restored cell is no longer a web_search_call to repair. + // its exact conversation and serving identity and recorded by the bridge itself, so a + // provider without the opt-in computes no identity and keeps the body reference it already + // had. This runs before query backfill because a restored cell is no longer one to repair. if (provider.webSearchBridge?.enabled === true) { - outBody = restoreBridgedWebSearchCalls(outBody, bridgeSearchReplayScope(provider.baseUrl)); + outBody = restoreBridgedWebSearchCalls(outBody, bridgeSearchReplayScope(parsed._reasoningReplayScope)); } // Repair stored history from before the bridge emitted both keys, in either // direction: a conversation that already recorded a web_search_call replays it diff --git a/src/responses/bridge-search-replay-cache.ts b/src/responses/bridge-search-replay-cache.ts index 6de24cdce39..07213682c33 100644 --- a/src/responses/bridge-search-replay-cache.ts +++ b/src/responses/bridge-search-replay-cache.ts @@ -14,9 +14,9 @@ * what `appendBridgeSearchTurn` would have written onto a continuation leg, so a replayed turn * and a continued turn show the destination the same conversation. * - * Scope. Entries are keyed by the upstream destination in addition to the cell id. The cell id is - * a v4 UUID minted here, so it cannot collide across conversations, but an unscoped key would let - * a history replayed against a DIFFERENT provider resurrect a call that provider never made. + * Scope. Entries are keyed by the exact conversation and serving identity in addition to the cell + * id. The cell id is a v4 UUID minted here, but possession of a client-visible id is not authority + * to recover result text under another provider, model, destination, or credential. * * Bounds and privacy. Result text is web content the caller already received, but it is still * request-derived data: it lives in memory only, is never logged, serialized, or exported, and is @@ -26,7 +26,7 @@ * alone. Neither re-running the search nor inventing a result is an acceptable recovery. */ -import { reasoningReplayDestinationIdentity } from "./reasoning-replay-cache"; +import type { OcxReasoningReplayScopeRef } from "../types"; const MAX_ENTRIES = 64; const MAX_TOTAL_BYTES = 512 * 1024; @@ -58,14 +58,24 @@ let clockForTests: (() => number) | null = null; const now = (): number => clockForTests?.() ?? Date.now(); /** - * Identify the upstream destination a bridged search belongs to. + * Identify the exact conversation and upstream binding a bridged search belongs to. * - * Reuses the salted process-local destination digest the reasoning replay cache already defines, - * so both stores agree on what "the same upstream" means and neither invents a second notion of - * destination identity. + * The serving route binds this holder only after provider, model, and physical credential + * selection. A missing conversation or binding fails closed: a cell id is client-visible and is + * not itself authority to recover another request's retained result. */ -export function bridgeSearchReplayScope(baseUrl: string | undefined): string | undefined { - return reasoningReplayDestinationIdentity(baseUrl); +export function bridgeSearchReplayScope(scope: OcxReasoningReplayScopeRef | undefined): string | undefined { + const identity = scope?.current; + if (!scope?.clientPrincipalId || !scope.clientThreadId || !identity) return undefined; + return JSON.stringify([ + scope.clientPrincipalId, + scope.clientThreadId, + identity.providerName, + identity.providerDestinationIdentity, + identity.adapterName, + identity.modelId, + identity.credentialIdentity, + ]); } function keyFor(scope: string, cellItemId: string): string { diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index f6cd0009987..7cc649f160b 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -416,10 +416,9 @@ export async function deliverPassthroughResponse( describeImages: requiresVisionPreprocessing(config, route.provider, route.modelId, route.providerName), sidecar: config.webSearchSidecar, }), - // Scope the executed-search memo to this exact upstream (#4587). The Responses adapter - // derives the same scope from the same base URL before the NEXT turn is dispatched, so - // a replayed hosted cell can be turned back into the destination's own call and result. - destinationScope: bridgeSearchReplayScope(route.provider.baseUrl), + // Snapshot the bound conversation, provider, model, destination, and credential. The + // next turn must match every dimension before its hosted cell can recover this result. + destinationScope: bridgeSearchReplayScope(parsed._reasoningReplayScope), // Appending a search result can push the continuation past the ceiling the first leg // was admitted under, so the same limit is re-applied before every later send. checkOutboundBody: (continuationBody: string) => { diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 3c35bd3a806..7d6e90b77d4 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -20,6 +20,7 @@ import { sessionIdHeaderFromRequest, reasoningReplayConversationIdFromResponsesRequest, } from "../request-log-conversation"; +import { contextPrincipalIdOf } from "../auth-cors"; import { isShadowSourceModel, shadowSourceModelPrefix, @@ -384,6 +385,11 @@ export async function prepareResponsesRequest( parsed._reasoningReplayScope = { clientThreadId: reasoningReplayConversationId }; } } + if (parsed._reasoningReplayScope) { + const clientPrincipalId = contextPrincipalIdOf(options.admission) + ?? (options.admission?.kind === "loopback" ? "loopback" : undefined); + parsed._reasoningReplayScope = { ...parsed._reasoningReplayScope, clientPrincipalId }; + } // Prefer a pre-populated id (routed Claude) over Responses headers that may be // absent or synthetically injected (session_id from prompt_cache_key). if (!logCtx.conversationId) { diff --git a/src/types/request.ts b/src/types/request.ts index 73bc671c8c2..fee84033340 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -30,6 +30,8 @@ export interface OcxReasoningReplayIdentity { * the holder, so late tool-call cache writes see the active physical identity. */ export interface OcxReasoningReplayScopeRef { + /** Process-local caller principal; `loopback` denotes the trusted local-only admission lane. */ + readonly clientPrincipalId?: string; /** * Conversation namespace for replay state. Historically this was always the Codex parent-thread * id; headerless Responses callers use a raw sanitized thread/Cursor/session fallback, never the diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 6f960dbe390..5d9f2fcac3f 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -141,16 +141,20 @@ searches run, their hosted cells complete, the held client calls are released fo execute, and the leg's own terminal closes the turn with no continuation sent upstream. The destination therefore does not receive that search result during the turn. It gets it on the next one: every search the bridge executes is recorded in `src/responses/bridge-search-replay-cache.ts` -under the hosted cell's proxy-minted id, scoped to the upstream destination and bounded by entry -count, total bytes, and a one-hour TTL. When the caller replays that cell, +under the hosted cell's proxy-minted id, scoped to the admitted caller principal, client +conversation, and exact provider, adapter, model, destination, and physical credential binding, and bounded by entry count, total +bytes, and a one-hour TTL. An unavailable scope fails closed. When the caller replays that cell, `restoreBridgedWebSearchCalls` in `src/adapters/openai-responses/tool-output-recovery.ts` puts the destination's own `function_call` and the executed `function_call_output` back in the cell's position before the next turn's first leg is dispatched, recording exactly the text `appendBridgeSearchTurn` would have sent on a continuation leg so a replayed turn and a continued turn show the destination one consistent conversation. The rewrite runs only for a provider with -`webSearchBridge.enabled`, and a miss — unknown id, expired entry, a different destination, or a -`call_id` the body already carries — leaves the replayed item untouched. Re-running the search or -synthesizing result text is not a permitted recovery. The bridge finalizes request-scoped OpenAI sidecar authority on completion, failure, and client cancellation — cancellation releases immediately rather than waiting on an abandoned upstream read — so a recovery probe lease no search consumed is always returned. +`webSearchBridge.enabled`, and a miss — unknown id, expired entry, a different conversation or +serving binding, or a `call_id` the body already carries — leaves the replayed item untouched. +Re-running the search or synthesizing result text is not a permitted recovery. The bridge finalizes +request-scoped OpenAI sidecar authority on completion, failure, and client cancellation — +cancellation releases immediately rather than waiting on an abandoned upstream read — so a +recovery probe lease no search consumed is always returned. `tests/web-search/web-search-bridge-replay.test.ts` pins the restore and each of those refusals. A leg whose upstream terminal is `response.failed` or `response.incomplete` runs no search at all and closes diff --git a/tests/web-search/web-search-bridge-replay.test.ts b/tests/web-search/web-search-bridge-replay.test.ts index 3d5a6aab28c..2b98b3f63e5 100644 --- a/tests/web-search/web-search-bridge-replay.test.ts +++ b/tests/web-search/web-search-bridge-replay.test.ts @@ -20,7 +20,7 @@ import { } from "../../src/responses/bridge-search-replay-cache"; import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../../src/adapters/openai-responses"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; -import type { OcxProviderConfig } from "../../src/types"; +import type { OcxProviderConfig, OcxReasoningReplayScopeRef } from "../../src/types"; const createResponsesPassthroughAdapter = (...args: Parameters) => withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); @@ -28,6 +28,21 @@ const createResponsesPassthroughAdapter = (...args: Parameters = {}): OcxReasoningReplayScopeRef { + return { + clientPrincipalId: "principal-a", + clientThreadId: "thread-a", + current: { + providerName: "bridge-a", + providerDestinationIdentity: overrides.providerDestinationIdentity ?? GATEWAY_BASE_URL, + adapterName: "openai-responses", + modelId: "glm-4.7", + credentialIdentity: "key-a", + ...overrides, + }, + }; +} + function frame(type: string, payload: Record): string { return "event: " + type + "\ndata: " + JSON.stringify({ type, ...payload }); } @@ -115,7 +130,7 @@ async function runBridgedMixedLeg(baseUrl: string, result = "opencodex 2.50.0 sh throw new Error("a mixed leg must not send a continuation"); }, execute: async () => ({ text: result, sources: [{ url: "https://example.test/rel", title: "Releases" }] }), - destinationScope: bridgeSearchReplayScope(baseUrl), + destinationScope: bridgeSearchReplayScope(replayScope({ providerDestinationIdentity: baseUrl })), }); const body = await new Response(stream).text(); const added = clientEvents(body).find(event => @@ -154,7 +169,7 @@ describe("bridged web_search replay to the destination", () => { const restored = restoreBridgedWebSearchCalls( nextTurnBody(cellId), - bridgeSearchReplayScope(GATEWAY_BASE_URL), + bridgeSearchReplayScope(replayScope()), ) as { input: Record[] }; // The item type the destination never produced is gone, replaced in place by the exchange @@ -188,7 +203,7 @@ describe("bridged web_search replay to the destination", () => { throw new Error("a mixed leg must not send a continuation"); }, execute: async () => ({ text: "", sources: [], error: "backend refused" }), - destinationScope: bridgeSearchReplayScope(GATEWAY_BASE_URL), + destinationScope: bridgeSearchReplayScope(replayScope()), }); const body = await new Response(stream).text(); const added = clientEvents(body).find(event => @@ -198,7 +213,7 @@ describe("bridged web_search replay to the destination", () => { const restored = restoreBridgedWebSearchCalls( nextTurnBody(cellId), - bridgeSearchReplayScope(GATEWAY_BASE_URL), + bridgeSearchReplayScope(replayScope()), ) as { input: Record[] }; expect(restored.input[2]).toEqual({ type: "function_call_output", @@ -209,7 +224,7 @@ describe("bridged web_search replay to the destination", () => { test("a cell this proxy never executed is left exactly as the caller sent it", () => { const body = nextTurnBody("ws_never-recorded"); - const restored = restoreBridgedWebSearchCalls(body, bridgeSearchReplayScope(GATEWAY_BASE_URL)); + const restored = restoreBridgedWebSearchCalls(body, bridgeSearchReplayScope(replayScope())); // Same reference: a miss allocates nothing and invents nothing. expect(restored).toBe(body); }); @@ -217,7 +232,26 @@ describe("bridged web_search replay to the destination", () => { test("a search recorded for one destination is not replayed into another", async () => { const cellId = await runBridgedMixedLeg(GATEWAY_BASE_URL); const body = nextTurnBody(cellId); - expect(restoreBridgedWebSearchCalls(body, bridgeSearchReplayScope(OTHER_BASE_URL))).toBe(body); + expect(restoreBridgedWebSearchCalls(body, bridgeSearchReplayScope(replayScope({ providerDestinationIdentity: OTHER_BASE_URL })))).toBe(body); + }); + + test("a cell cannot cross any conversation or serving-identity boundary", async () => { + const cellId = await runBridgedMixedLeg(GATEWAY_BASE_URL); + const body = nextTurnBody(cellId); + const mismatchedScopes: OcxReasoningReplayScopeRef[] = [ + { ...replayScope(), clientPrincipalId: "principal-b" }, + { ...replayScope(), clientThreadId: "thread-b" }, + replayScope({ providerName: "bridge-b" }), + replayScope({ adapterName: "other-adapter" }), + replayScope({ modelId: "other-model" }), + replayScope({ credentialIdentity: "key-b" }), + ]; + for (const scope of mismatchedScopes) { + expect(restoreBridgedWebSearchCalls(body, bridgeSearchReplayScope(scope))).toBe(body); + } + expect(bridgeSearchReplayScope(undefined)).toBeUndefined(); + expect(bridgeSearchReplayScope({ clientThreadId: "thread-a" })).toBeUndefined(); + expect(bridgeSearchReplayScope({ clientPrincipalId: "principal-a", clientThreadId: "thread-a" })).toBeUndefined(); }); test("an expired entry behaves exactly like a miss", async () => { @@ -226,13 +260,13 @@ describe("bridged web_search replay to the destination", () => { const cellId = await runBridgedMixedLeg(GATEWAY_BASE_URL); const body = nextTurnBody(cellId); // Still inside the TTL. - expect(restoreBridgedWebSearchCalls(body, bridgeSearchReplayScope(GATEWAY_BASE_URL))).not.toBe(body); + expect(restoreBridgedWebSearchCalls(body, bridgeSearchReplayScope(replayScope()))).not.toBe(body); clockMs += 61 * 60 * 1000; - expect(restoreBridgedWebSearchCalls(body, bridgeSearchReplayScope(GATEWAY_BASE_URL))).toBe(body); + expect(restoreBridgedWebSearchCalls(body, bridgeSearchReplayScope(replayScope()))).toBe(body); }); test("a call id the body already carries is never duplicated", () => { - const scope = bridgeSearchReplayScope(GATEWAY_BASE_URL); + const scope = bridgeSearchReplayScope(replayScope()); rememberBridgeSearchReplay(scope, "ws_dup", { callId: "call_2", name: "web_search", @@ -244,7 +278,7 @@ describe("bridged web_search replay to the destination", () => { }); test("an unbridged provider is never given a scope to restore from", () => { - const scope = bridgeSearchReplayScope(GATEWAY_BASE_URL); + const scope = bridgeSearchReplayScope(replayScope()); rememberBridgeSearchReplay(scope, "ws_unbridged", { callId: "call_1", name: "web_search", @@ -274,6 +308,7 @@ describe("the Responses passthrough adapter", () => { stream: true, options: {}, _rawBody: nextTurnBody(cellId), + _reasoningReplayScope: replayScope(), }, { headers: new Headers() }); return (JSON.parse(request.body) as { input: Record[] }).input; } From 4202c4ba8f653ad13cbc4e4166c6c1c482ed7834 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:33:05 +0000 Subject: [PATCH 2/2] fix(web-search): rebind replay scope before dispatch-time rebuild The dispatch override refreshed the adapter and rebuilt the request before rebinding _reasoningReplayScope, so the bridged web-search restore in the rebuild ran under the lapsed credential's identity and the result was then sent under the newly selected credential. Rebind the refreshed route first, matching every other rebuild site, and cover the selection-change race with an end-to-end regression test. Co-Authored-By: Epinephrine --- src/server/responses/request-transport.ts | 7 +- tests/server/server-key-failover-e2e.test.ts | 117 ++++++++++++++++++- 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/src/server/responses/request-transport.ts b/src/server/responses/request-transport.ts index 11354c79497..ed804955aeb 100644 --- a/src/server/responses/request-transport.ts +++ b/src/server/responses/request-transport.ts @@ -425,6 +425,11 @@ export async function prepareResponsesTransport( return response; } const nextAdapter = await refreshDispatchAdapter(requestParsed); + // Rebind before rebuilding: the rebuild's bridged-search restore and continuation + // restore key on the serving identity, which must be the refreshed route's, not the + // credential whose selection just lapsed. + bindRouteReasoningReplayScope({ parsed: requestParsed, providerName: route.providerName, provider: route.provider, + adapterName: nextAdapter.name, oauthCredentialSnapshot: replayOAuthCredentialSnapshot }); const rebuilt = await nextAdapter.buildRequest(requestParsed, { headers: requestState.selectedForwardHeaders, translatorBudget, ...(imageTierBias > 0 ? { imageTierBias } : {}), @@ -447,8 +452,6 @@ export async function prepareResponsesTransport( sameTargetToken = transportToken; destination = rebuilt.url; dispatchInit = { ...dispatchInit, method: rebuilt.method, headers, body: rebuilt.body }; - bindRouteReasoningReplayScope({ parsed: requestParsed, providerName: route.providerName, provider: route.provider, - adapterName: nextAdapter.name, oauthCredentialSnapshot: replayOAuthCredentialSnapshot }); // The next iteration validates synchronously and calls fetch in that same turn. } throw new Error("OAuth account selection changed repeatedly before dispatch"); diff --git a/tests/server/server-key-failover-e2e.test.ts b/tests/server/server-key-failover-e2e.test.ts index a9753c6f27e..5279288c90c 100644 --- a/tests/server/server-key-failover-e2e.test.ts +++ b/tests/server/server-key-failover-e2e.test.ts @@ -7,7 +7,16 @@ import { readUsageEntries, resetUsageReadCacheForTests } from "../../src/usage/l import { loadConfig, saveConfig } from "../../src/config"; import { clearKeyCooldowns, getKeyCooldownUntil, rotateKeyOn429 } from "../../src/providers/key-failover"; import { deriveXaiConvId } from "../../src/providers/xai-transport"; -import { clearReasoningReplayCacheForTests } from "../../src/responses/reasoning-replay-cache"; +import { + clearReasoningReplayCacheForTests, + reasoningReplayDestinationIdentity, + reasoningReplayKeyCredentialIdentity, +} from "../../src/responses/reasoning-replay-cache"; +import { + bridgeSearchReplayScope, + clearBridgeSearchReplayCacheForTests, + rememberBridgeSearchReplay, +} from "../../src/responses/bridge-search-replay-cache"; import { startServer } from "../../src/server"; import { handleResponses } from "../../src/server/responses"; import type { OcxConfig } from "../../src/types"; @@ -31,6 +40,7 @@ beforeEach(() => { process.env.OPENCODEX_HOME = testDir; clearKeyCooldowns(); clearReasoningReplayCacheForTests(); + clearBridgeSearchReplayCacheForTests(); }); afterEach(() => { @@ -43,6 +53,7 @@ afterEach(() => { if (testDir) removeTreeWithRetry(testDir); clearKeyCooldowns(); clearReasoningReplayCacheForTests(); + clearBridgeSearchReplayCacheForTests(); }); describe("server 429 key failover (end-to-end)", () => { @@ -1182,3 +1193,107 @@ test.each([false, true])("key refetch retains transient recovery metadata (strea usage: { inputTokens: 12, outputTokens: 2 } }); } finally { await server.stop(true); } }); + +test("a dispatch-time key switch rebuilds the bridged-search restore under the new credential", async () => { + // Regression for the oauthDispatch rebuild order: the Responses adapter restores a replayed + // web_search_call from the memo keyed by _reasoningReplayScope, so the rebuild must rebind + // that scope to the refreshed credential BEFORE buildRequest runs. Restoring under the key + // whose selection just lapsed, then sending under the newly selected key, would hand the + // first credential's recorded result to the second credential's upstream. + let now = 0; + let resumePacing: (() => void) | undefined; + const queued = Promise.withResolvers(); + setProviderRequestPacingRuntimeForTest({ + now: () => now, + setTimer(callback, delayMs) { + resumePacing = () => { now += delayMs; callback(); }; + queued.resolve(); + return callback; + }, + clearTimer() {}, + enqueueMicrotask: queueMicrotask, + }); + const seen: { authorization: string | null; input: Record[] }[] = []; + upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(req) { + const body = await req.json() as { input?: unknown }; + seen.push({ + authorization: req.headers.get("authorization"), + input: Array.isArray(body.input) ? body.input as Record[] : [], + }); + return Response.json({ + id: "resp_keyrace", object: "response", status: "completed", model: "test", + output: [{ type: "message", id: "msg_keyrace", role: "assistant", status: "completed", + content: [{ type: "output_text", text: "done", annotations: [] }] }], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + } }); + const baseUrl = `http://127.0.0.1:${upstream.port}/v1`; + const config = { port: 0, hostname: "127.0.0.1", defaultProvider: "pooled", providers: { pooled: { + adapter: "openai-responses", baseUrl, allowPrivateNetwork: true, + authMode: "key", apiKey: "synthetic-first", + apiKeyPool: [{ id: "first", key: "synthetic-first" }, { id: "second", key: "synthetic-second" }], + webSearchBridge: { enabled: true, backend: "ollama" }, + requestPacing: { enabled: true, minIntervalMs: 100 }, + } } } as OcxConfig; + saveConfig(config); + + // Seed the bridged-search memo under the identity the FIRST key binds: same loopback + // principal and thread the request below carries, but the lapsed credential. + const cellId = "ws_keyrace"; + rememberBridgeSearchReplay( + bridgeSearchReplayScope({ + clientPrincipalId: "loopback", + clientThreadId: "thread-keyrace", + current: { + providerName: "pooled", + providerDestinationIdentity: reasoningReplayDestinationIdentity(baseUrl), + adapterName: "openai-responses", + modelId: "test", + credentialIdentity: reasoningReplayKeyCredentialIdentity({ apiKey: "synthetic-first" }), + }, + }), + cellId, + { callId: "call_ws_1", name: "web_search", + argumentsText: "{\"query\":\"opencodex release\"}", output: "cached bridged result" }, + ); + + const server = startServer(0); + const abort = new AbortController(); + try { + await waitForProviderRequestSlot("pooled", config.providers.pooled); + const pending = fetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json", "thread-id": "thread-keyrace" }, + signal: abort.signal, + body: JSON.stringify({ + model: "pooled/test", stream: false, + input: [ + { role: "user", content: [{ type: "input_text", text: "what is the latest release?" }] }, + { type: "web_search_call", id: cellId, status: "completed", + action: { type: "search", query: "opencodex release", queries: ["opencodex release"] } }, + ], + }), + }); + await queued.promise; + const selected = await managementFetch(new URL("/api/providers/keys/active", server.url), { + method: "PUT", headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "pooled", id: "second" }), + }); + expect(selected.status).toBe(200); + await selected.text(); + resumePacing!(); + const response = await pending; + expect(response.status).toBe(200); + await response.text(); + expect(seen).toHaveLength(1); + expect(seen[0]!.authorization).toBe("Bearer synthetic-second"); + // Rebound before rebuild: the memo lookup misses under the new credential, so the hosted + // cell reaches the second key's upstream verbatim instead of the first key's result. + expect(seen[0]!.input.some(item => item.type === "web_search_call" && item.id === cellId)).toBe(true); + expect(seen[0]!.input.some(item => item.call_id === "call_ws_1")).toBe(false); + } finally { + abort.abort(); + await server.stop(true); + resetProviderRequestPacingForTest(); + } +});