Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
01dbf42
fix(web-search): combine replay-cache isolation with deadline-safe qu…
luvs01 Sep 21, 2026
31c9d01
fix(web-search): scope replay cells to key-resolved loopback principals
luvs01 Sep 21, 2026
07d1341
fix(retries): refuse transient 5xx after an operator-authorized reset…
luvs01 Sep 21, 2026
94df9e2
fix(retries): word the replay refusal for the post-response path too
luvs01 Sep 21, 2026
c66ddc1
test(responses): keep the inline-document role fixture on its dev con…
lidge-jun Sep 22, 2026
808fd7d
fix(web-search): fail closed when a replay caller has no principal
lidge-jun Sep 22, 2026
fce93a6
fix(retries): keep every resend-inducing answer under replay suppression
lidge-jun Sep 22, 2026
b3c6993
fix(web-search): clear a stale replay principal and document its absence
lidge-jun Sep 22, 2026
118d5c5
fix(retries): refuse a redirect that would resend a spent replacement
lidge-jun Sep 22, 2026
47a6712
fix(web-search): share one physical-send budget across sidecar reset …
lidge-jun Sep 22, 2026
52c1778
docs(structure): record replay ownership, the sidecar send budget, an…
lidge-jun Sep 22, 2026
211dbfa
fix(codex): keep a non-replayable 400 out of the gated-model account …
lidge-jun Sep 22, 2026
26ed40a
fix(combos): stop on a spent replacement's answer instead of hopping
lidge-jun Sep 22, 2026
58105e5
fix(errors): match the whole replay refusal sentence
lidge-jun Sep 22, 2026
2a4eb57
docs: state that bridged-search replay needs a caller key
lidge-jun Sep 22, 2026
e7f0820
docs(structure): add 413 and the combo stop to the replacement fence
lidge-jun Sep 22, 2026
f05b4be
Merge remote-tracking branch 'origin/dev' into codex/260922-next-sear…
lidge-jun Sep 22, 2026
9c0ac49
Merge remote-tracking branch 'origin/dev' into codex/260922-next-sear…
lidge-jun Sep 22, 2026
8a78611
Merge remote-tracking branch 'origin/dev' into codex/260922-next-sear…
lidge-jun Sep 22, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,11 @@ mode, or base URL during search or provider pacing ends the turn with a bridge e
provider request is sent. Changing away and back also ends that continuation. Start a new turn to
use the new selection. Selection changes before the first provider send retain normal reselection.

A bridged search result is shown to the provider again on the conversation's next turn only for
the same caller, conversation, provider, model and selected key. The caller is identified by the
opencodex API key it presents, so a client that sends no opencodex API key gets no such replay:
its earlier search cells reach the provider unchanged, as they do for a provider without the bridge.

Custom-model `reasoningEfforts` normally override discovered provider metadata. The bounded
exception is an explicit custom row whose model id has pinned native Codex capabilities,
including Astra or Daybreak on an arbitrary gateway: its advertised list is intersected with
Expand Down
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
8 changes: 5 additions & 3 deletions src/adapters/openai-responses/tool-output-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,9 +289,11 @@ export function backfillWebSearchQueries(body: unknown): unknown {
* - It never restores a call id the body already carries. If the history somehow holds that
* `function_call` too, emitting a second one would be a duplicate the upstream must reject.
*
* Entries are scoped to the upstream destination, so a history replayed against a different
* provider cannot resurrect a call that provider never made. Callers pass `undefined` for any
* provider without the bridge armed, and the common path then returns the original reference.
* Entries are scoped to the caller principal, conversation and exact serving identity, so a
* history replayed by another caller or against a different provider, model, destination or
* credential cannot resurrect a call that pairing never made. Callers pass `undefined` for any
* provider without the bridge armed and for a caller with no principal, and the common path then
* returns the original reference.
*/
export function restoreBridgedWebSearchCalls(body: unknown, destinationScope: string | undefined): unknown {
if (destinationScope === undefined) return body;
Expand Down
10 changes: 6 additions & 4 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,12 +258,14 @@ export function isClientClosedMessage(text: string): boolean {

/**
* Ambiguous-reset refusal wording owned by this proxy (src/lib/upstream-retry.ts):
* the upstream connection closed before any response arrived, so the request may
* already have been processed and automatic replay was stopped. Matched narrowly
* so a provider-sent message is never relabeled by it.
* the upstream exchange did not complete reliably, so the request may already have
* been processed and automatic replay was stopped. Matched narrowly so a
* provider-sent message is never relabeled by it.
*/
export function isUpstreamResetReplayRefusedMessage(text: string): boolean {
return text.toLowerCase().includes("connection closed before a response was received");
return text.toLowerCase().includes(
"the upstream exchange did not complete reliably. the request may already have been processed",
);
}

export function classifyError(status: number, type: string, message: string): OcxErrorPayload {
Expand Down
40 changes: 35 additions & 5 deletions src/lib/upstream-retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,24 @@ export function cancelResponseBodyBestEffort(res: Response): void {
}
}

/**
* Whether an answer to a spent operator replacement would invite yet another send.
*
* Once the one replacement a request may spend has gone out, the first send may already have run
* the turn, so nothing this exchange returns may cause a third send. Two parties would send again:
* the client, whose retry table covers 408, 409, 429 and every 5xx (the Codex client retries 5xx
* whatever the headers say; see {@link REPLAY_REFUSED_STATUS}), and this proxy, whose credential
* and quota recovery resends on 401 (token refresh, key and pool rotation) and on 402/429
* (account rotation). A client that follows a 307 or 308 sends the same POST body again, and a 413
* is answered as a context overflow the client compacts and resends, so those belong here too.
* {@link isTransientUpstreamStatus} is only the gateway subset of that set: 429 and 529 escaped
* it. These statuses settle as the refusal instead.
*/
function invitesResendAfterReplacement(status: number): boolean {
return status === 401 || status === 402 || status === 408 || status === 409 || status === 429
|| status === 307 || status === 308 || status === 413 || status >= 500;
}

export async function fetchWithAttemptDeadline(
url: string,
init: RequestInit,
Expand Down Expand Up @@ -569,7 +587,7 @@ export function replayRefusalResponse(): Response {
const response = new Response(JSON.stringify({ error: {
type: "upstream_error",
code: UPSTREAM_RESET_REPLAY_REFUSED_CODE,
message: "The upstream connection closed before a response was received. The request may already have been processed; automatic replay was stopped.",
message: "The upstream exchange did not complete reliably. The request may already have been processed; automatic replay was stopped.",
} }), {
status: REPLAY_REFUSED_STATUS,
headers: { "content-type": "application/json", ...REPLAY_REFUSAL_CLIENT_HEADERS },
Expand All @@ -594,9 +612,9 @@ export async function fetchWithResetRetry(
if (attempts === 0) throw new SendBudgetExhaustedError(opts.label);
let lastError: unknown;
let sawReset = false;
// True once this leg has spent the request's operator allowance. From that point the leg can
// only settle as the refusal: a second send of a possibly-executed turn is already out, and
// handing the client anything it would retry compounds it.
// True once this leg has spent the request's operator allowance. From that point the leg
// settles as the refusal or an unambiguous answer: a second send of a possibly-executed turn
// is already out, and handing the client anything it would retry compounds it.
let spentOperatorReplacement = false;
for (let attempt = 0; attempt < attempts; attempt++) {
if (opts.abortSignal?.aborted) throw abortError(opts.abortSignal);
Expand All @@ -605,7 +623,19 @@ export async function fetchWithResetRetry(
// rethrow, abort), so a per-send report is the only shape that is correct on all of them.
opts.onSendsConsumed?.(1);
try {
return await doFetch(attempt === 0 ? firstRecovery : "connection-reset");
const response = await doFetch(attempt === 0 ? firstRecovery : "connection-reset");
if (spentOperatorReplacement && !response.ok) {
if (invitesResendAfterReplacement(response.status)) {
cancelResponseBodyBestEffort(response);
return replayRefusalResponse();
}
// Any other answer keeps its real status: no client retries it, and the caller needs the
// evidence (a 400 names the request defect). The marker still stops this process from
// using it as a recovery trigger, such as the opaque-blob rebuild of a 400 or a combo hop
// on a context overflow, because each of those checks it before sending again.
markResponseNonReplayable(response);
}
return response;
} catch (err) {
if (opts.abortSignal?.aborted) throw err;
if (!isConnectionResetError(err)) {
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
4 changes: 4 additions & 0 deletions src/server/responses/core-codex-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,10 @@ export async function codexPoolAccountModel400Denial(
wireModelId?: string,
): Promise<string | undefined> {
if (response.status !== 400) return undefined;
// A response that must not be sent again cannot open an alternate-account retry either. The
// reset helper marks the answer to a spent operator replacement this way, and that turn may
// already have run on the first send. Same rule as the quota and transient ladders below.
if (isNonReplayableResponse(response)) return undefined;
try {
const body = await readBoundedResponseBody(response.clone(), { signal });
if (!body.displaySafe || body.truncated) return undefined;
Expand Down
25 changes: 16 additions & 9 deletions src/server/responses/core-combo-failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { normalizeUpstreamErrorText } from "./core-errors";
import { resolveClientRetryAfter } from "../../lib/retry-after";
import { formatErrorResponse } from "../../bridge";
import { isNonReplayableResponse, markResponseNonReplayable } from "../../lib/upstream-retry";
import { usageFromResponsesPayload } from "../request-log";
import type { ResponsesTerminalStatus } from "../../bridge";

Expand All @@ -30,6 +31,9 @@ export async function consumeComboFailure(
signal?: AbortSignal,
now = Date.now(),
): Promise<ConsumedComboFailure> {
// Read before the body: the marker lives on this Response object, and the failure below is
// rebuilt as a new one that would otherwise lose it.
const nonReplayable = isNonReplayableResponse(response);
const fallback = `Provider error ${response.status}`;
let classificationText = fallback;
let usage: OcxUsage | undefined;
Expand Down Expand Up @@ -97,16 +101,19 @@ export async function consumeComboFailure(
now,
includeDefault: false,
});
const failureResponse = formatErrorResponse(
response.status,
cyberFailure ? (upstreamType ?? CYBER_POLICY_ERROR_CODE) : "upstream_error",
message,
{
...(normalizedUpstreamCode !== undefined ? { code: normalizedUpstreamCode } : {}),
...(clientRetryAfter !== undefined ? { retryAfter: clientRetryAfter } : {}),
},
);
if (nonReplayable) markResponseNonReplayable(failureResponse);
return {
response: formatErrorResponse(
response.status,
cyberFailure ? (upstreamType ?? CYBER_POLICY_ERROR_CODE) : "upstream_error",
message,
{
...(normalizedUpstreamCode !== undefined ? { code: normalizedUpstreamCode } : {}),
...(clientRetryAfter !== undefined ? { retryAfter: clientRetryAfter } : {}),
},
),
response: failureResponse,
...(nonReplayable ? { nonReplayable: true } : {}),
classificationText,
...(normalizedUpstreamCode !== undefined ? { upstreamCode: normalizedUpstreamCode } : {}),
...(!cyberFailure && cooldownRetryAfter !== undefined ? { retryAfter: cooldownRetryAfter } : {}),
Expand Down
10 changes: 7 additions & 3 deletions src/server/responses/core-combo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -678,9 +678,13 @@ export async function executeComboResponses(
attemptRetained = true;
lastFailure = failure.response;
lastFailedChildLog = childLog;
const failureDecision = comboFailureDecision(failure.response.status, failure.classificationText, {
code: failure.upstreamCode,
});
// A non-replayable failure (the answer to a spent ambiguous-reset replacement) may follow a
// send that already ran the turn, so no later target may receive it, whatever its status says.
const failureDecision = failure.nonReplayable
? "stop"
: comboFailureDecision(failure.response.status, failure.classificationText, {
code: failure.upstreamCode,
});
const wantsStream = (rawBody as { stream?: unknown } | null)?.stream === true;
// Local byte admission has its own diagnostic; do not relabel it as an upstream refusal.
const classifyOverflow = failure.response.status === 413
Expand Down
6 changes: 6 additions & 0 deletions src/server/responses/core-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ export interface ConsumedComboFailure {
resetAt?: string[];
/** Reserved for 040 usage attribution without adding another body read. */
usage?: OcxUsage;
/**
* The failed attempt's response was marked non-replayable, such as the answer to a spent
* ambiguous-reset replacement. The re-wrapped {@link response} cannot carry that in-memory
* marker, so the combo loop reads it here and stops instead of sending the turn to a later target.
*/
nonReplayable?: boolean;
}


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
12 changes: 12 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,17 @@ 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). A caller that presents none has no principal, and none is invented:
// every keyless local process would otherwise share one bucket, and a client-visible cell id
// would become enough to read another caller's retained search result. Without a principal
// bridgeSearchReplayScope yields no scope, so nothing is recorded or restored for it. The
// field is always rewritten so an absent principal also clears one a reused holder carried.
const clientPrincipalId = resolveContextPrincipal(req, config, options.admission);
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
Loading
Loading