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
8 changes: 4 additions & 4 deletions src/adapters/openai-responses/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,11 +329,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
Expand Down
30 changes: 20 additions & 10 deletions src/responses/bridge-search-replay-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 3 additions & 4 deletions src/server/responses/passthrough-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,10 +417,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) => {
Expand Down
9 changes: 9 additions & 0 deletions src/server/responses/request-prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
sessionIdHeaderFromRequest,
reasoningReplayConversationIdFromResponsesRequest,
} from "../request-log-conversation";
import { resolveContextPrincipal } from "../auth-cors";
import {
isShadowSourceModel,
shadowSourceModelPrefix,
Expand Down Expand Up @@ -407,6 +408,14 @@ export async function prepareResponsesRequest(
parsed._reasoningReplayScope = { clientThreadId: reasoningReplayConversationId };
}
}
if (parsed._reasoningReplayScope) {
// Scope replay cells to the caller principal. On loopback, admission carries no identity,
// so resolve it from an opencodex API key the caller volunteered (same rule as context
// history ownership); keyless loopback callers still share the "loopback" bucket.
const clientPrincipalId = resolveContextPrincipal(req, config, 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) {
Expand Down
7 changes: 5 additions & 2 deletions src/server/responses/request-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,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 } : {}),
Expand All @@ -467,8 +472,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");
Expand Down
2 changes: 2 additions & 0 deletions src/types/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 20 additions & 8 deletions src/web-search/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,12 @@ export type SidecarOutcome = WebSearchResult & { error?: string };
* The forward backend throttles burst sidecar traffic, and without a replay the 429 becomes a
* failed tool result that poisons the query for the whole turn (see failedQueries in loop.ts).
* 1 initial send + 2 replays; Retry-After is honored as a lower bound and capped by
* RETRY_AFTER_CEILING_MS (an instruction past the ceiling ends with the 429 instead of
* parking the search). Each wait releases the unread 429 body first so sockets do not
* accumulate under a rate-limit storm. Abort or timeout ends the wait through the existing
* catch, exactly like an abort during the SSE parse.
* RETRY_AFTER_CEILING_MS and the remaining sidecar deadline (an instruction past either
* ends with the 429 instead of parking the search). Each wait releases the unread 429 body first so sockets do not
* accumulate under a rate-limit storm. The release itself may take up to a second, so a
* deadline landing during release or backoff ends with the 429 already in hand rather than
* a timeout; a caller abort still ends the wait through the shared catch, exactly like an
* abort during the SSE parse.
*/
const SIDECAR_429_MAX_ATTEMPTS = 3;
const SIDECAR_429_BASE_DELAY_MS = 1_000;
Expand Down Expand Up @@ -98,9 +100,10 @@ export async function runWebSearch(
stream: true,
};
const url = `${forwardProvider.baseUrl}/responses`;
// t0 precedes the deadline timer's start so the remaining-time check stays conservative.
const t0 = Date.now();
const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal);
const sidecarExit = sidecarEnter("web-search");
const t0 = Date.now();
try {
const sendOnce = () => fetchWithResetRetry(
// Recovery nests INSIDE the version helper: applyUpstreamRecoveryInit then always receives a
Expand Down Expand Up @@ -129,10 +132,19 @@ export async function runWebSearch(
});
// A deadline, not a clamp: an instruction past the ceiling ends the search with the
// 429 instead of parking it at a provider that already said it would refuse.
if (delay > RETRY_AFTER_CEILING_MS) break;
if (delay > RETRY_AFTER_CEILING_MS || delay >= settings.timeoutMs - (Date.now() - t0)) break;
console.warn(`[web-search] sidecar HTTP 429 — retrying (${attempt + 2}/${SIDECAR_429_MAX_ATTEMPTS}) after ${delay}ms`);
await releaseResponseBodyBestEffort(res.body, linkedSignal.signal);
await sleepWithAbort(delay, linkedSignal.signal);
try {
await releaseResponseBodyBestEffort(res.body, linkedSignal.signal);
await sleepWithAbort(delay, linkedSignal.signal);
} catch (e) {
// The release above may consume up to 1s, so the sidecar deadline can land during
// cleanup or mid-backoff — before the replay is dispatched. The observed 429 is
// already in hand: end with it rather than laundering it into a timeout. A caller
// abort (or a non-deadline throw) still propagates to the shared catch below.
if (!linkedSignal.signal.aborted || linkedSignal.signal.reason === abortSignal?.reason) throw e;
break;
}
res = await sendOnce();
}
// Attach the body guard before ANY branch reads it. The success path guarded itself below,
Expand Down
15 changes: 10 additions & 5 deletions structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,17 +172,22 @@ 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 forward OpenAI search sidecar retries a 429 only when the requested delay fits both its retry ceiling and the remaining overall sidecar deadline. A delay that cannot fit returns and records the original 429 so pool routing retains quota evidence.
A leg whose
upstream terminal is `response.failed` or `response.incomplete` runs no search at all and closes
any cell it opened rather than leaving it in progress. Assistant text is not treated as a search
Expand Down
2 changes: 2 additions & 0 deletions structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,8 @@ Automatic Codex pool selection and account status share the [plan exclusion cont
### Empty forced search answers

`src/web-search/loop.ts` makes at most one extra answer attempt after a clean forced-answer terminal with no visible output or tool call. The recovery has no tools and reuses gathered search results. Malformed calls fail before refusal/truncation passthrough, and well-formed recognized refusal/truncation terminals pass through unchanged, including empty or partial answers. The extra generation may incur provider usage.

OpenAI sidecar 429 replays run only when their backoff fits the remaining sidecar deadline; otherwise the original 429 remains the routing-health outcome rather than becoming a timeout.
## Scoped provider quota for Combo selection

`src/providers/quota/report-cache.ts` publishes routing evidence only when a producer explicitly supplies its
Expand Down
15 changes: 11 additions & 4 deletions tests/responses/chat-inline-document-bytes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,12 +180,19 @@ describe("inline document bytes reach a wire that can hold them", () => {
stream: false,
options: {},
} as unknown as OcxParsedRequest;
const outbound = JSON.parse(createOpenAIChatAdapter(chatProvider).buildRequest(parsed).body) as {
messages: Array<{ role: string; content: unknown }>;
};
expect(outbound.messages).toEqual([{
const buildBody = (provider: OcxProviderConfig) => JSON.parse(
createOpenAIChatAdapter(provider).buildRequest(parsed).body,
) as { messages: Array<{ role: string; content: unknown }> };
// Carrying a document must not demote the turn to `user`; which role the slot shows on the
// wire is the destination's recorded answer, so the accepting destination keeps `developer`
// and the unrecorded one folds to `system` in place.
expect(buildBody({ ...chatProvider, foldDeveloperRoleToSystem: false }).messages).toEqual([{
role: "developer",
content: [{ type: "file", file: { file_data: PDF_DATA_URL, filename: "spec" } }],
}]);
expect(buildBody({ ...chatProvider, foldDeveloperRoleToSystem: true }).messages).toEqual([{
role: "system",
content: [{ type: "file", file: { file_data: PDF_DATA_URL, filename: "spec" } }],
}]);
});
});
Loading
Loading