Skip to content
Merged
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
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1915,7 +1915,7 @@
"web-search-retry-heartbeat.test.ts": "web-search",
"web-search-sidecar-429.test.ts": "web-search",
"web-search-sources.test.ts": "web-search",
"web-search-timeout-contract.test.ts": "web-search",
"web-search-timeout-contract.test.ts": "web-search", "web-search-pacing-deadline.test.ts": "web-search",
"web-search-timeout-plan.test.ts": "web-search",
"web-search.test.ts": "web-search",
"win-exec.test.ts": "windows",
Expand Down
67 changes: 67 additions & 0 deletions src/web-search/header-deadline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { clearableDeadline } from "../lib/abort";
import { sendTrackingRequestSlot } from "../providers/request-pacing";
import type { ProviderFetch } from "../server/responses/fetch-helpers";

/** One iteration's cumulative header budget; only local pacing admission pauses its clock. */
export function pacingHeaderDeadline(timeoutMs: number, parent: AbortSignal) {
const controller = new AbortController();
const signal = AbortSignal.any([parent, controller.signal]);
let remainingMs = timeoutMs;
let armedAt = performance.now();
let current = clearableDeadline(remainingMs, parent);
const timeoutReason = current.timeoutReason;
let pacingWaits = 0;
let cleared = false;
const relayExpiry = () => {
if (current.didExpire()) controller.abort(timeoutReason);
};
current.signal.addEventListener("abort", relayExpiry, { once: true });

const pause = () => {
if (cleared || pacingWaits++ > 0) return;
remainingMs = Math.max(0, remainingMs - (performance.now() - armedAt));
current.clear();
current.signal.removeEventListener("abort", relayExpiry);
// A delayed timer task must not let an already-spent budget escape into the queue.
if (remainingMs === 0 && !signal.aborted) controller.abort(timeoutReason);
};
const resume = () => {
if (cleared || --pacingWaits > 0 || signal.aborted) return;
armedAt = performance.now();
current = clearableDeadline(remainingMs, parent);
current.signal.addEventListener("abort", relayExpiry, { once: true });
};

return {
signal,
didExpire: () => signal.aborted && signal.reason === timeoutReason,
clear: () => {
cleared = true;
current.clear();
current.signal.removeEventListener("abort", relayExpiry);
},
/** Preserve the pacing seams used by adapter physical-send helpers, without double admission. */
pacedFetch(executor: typeof globalThis.fetch): typeof globalThis.fetch {
const pacing = executor as ProviderFetch;
if (!pacing.waitForPacing) return executor;
const waitForPacing: NonNullable<ProviderFetch["waitForPacing"]> = async (sendSignal) => {
pause();
try {
return await pacing.waitForPacing!(sendSignal ? AbortSignal.any([signal, sendSignal]) : signal);
} finally {
resume();
}
};
const unpacedFetch = pacing.unpacedFetch ?? executor;
const wrapped = async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
const slot = await waitForPacing(init?.signal ?? undefined);
return sendTrackingRequestSlot(slot, () => {
signal.throwIfAborted();
init?.signal?.throwIfAborted();
return unpacedFetch(input, init);
});
};
return Object.assign(wrapped, { preconnect: executor.preconnect, waitForPacing, unpacedFetch }) as ProviderFetch;
},
};
}
10 changes: 5 additions & 5 deletions src/web-search/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { runXaiWebSearch, type XaiSearchOptions } from "./xai-executor";
import { runGeminiWebSearch } from "./gemini-executor";
import { runExaWebSearch } from "./exa-executor";
import type { WebSearchBackendId } from "./index";
import { clearableDeadline } from "../lib/abort";
import { pacingHeaderDeadline } from "./header-deadline";
import { redactSecretString } from "../lib/redact";
import { readBoundedResponseBody } from "../lib/bounded-body";
import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry";
Expand Down Expand Up @@ -455,10 +455,10 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
...(recoveringEmptyAnswer ? { options: { ...parsed.options, toolChoice: "none" as const } } : {}),
context: { ...parsed.context, messages: iterMessages, tools: recoveringEmptyAnswer ? [] : allTools },
};
// One cumulative header deadline spans every pool-key 429 rotation in this model iteration.
// One cumulative header deadline spans key rotations and reset sends, excluding local pacing.
// clear() stops only its timer after final headers; the direct turn signal remains attached to
// the returned response body through AbortSignal.any().
let headerDeadline = clearableDeadline(connectTimeoutMs, signal);
let headerDeadline = pacingHeaderDeadline(connectTimeoutMs, signal);
try {
/**
* Build and fetch one web-search iteration on the given adapter, under the iteration
Expand Down Expand Up @@ -491,7 +491,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
cachedRequest = request;
cachedAdapter = requestAdapter;
}
const requestFetch = deps.fetchForRequest?.(request, iterParsed) ?? routedProviderFetch;
const requestFetch = headerDeadline.pacedFetch(deps.fetchForRequest?.(request, iterParsed) ?? routedProviderFetch);
let response: Response;
try {
if (requestAdapter.fetchResponse) {
Expand Down Expand Up @@ -563,7 +563,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
// The deliberate backoff must not consume the cumulative response-header deadline:
// start a fresh one so the replay gets a new connect budget (504 stays reserved for real
// upstream latency).
headerDeadline = clearableDeadline(connectTimeoutMs, signal);
headerDeadline = pacingHeaderDeadline(connectTimeoutMs, signal);
// Stall-watchdog seam between bounded retry fetches.
yield { type: "heartbeat" };
prepared = await fetchOnce(adapter, "rate-limit-429");
Expand Down
2 changes: 1 addition & 1 deletion structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ Automatic Codex pool selection and account status share the [plan exclusion cont

Both search loops keep `web_search` declared after the search budget is exhausted. Further calls receive a paired limit-reached result without another physical search; at most `maxSearches + 3` model iterations run before a terminal error. Ordinary caller tools and cancellation still end the loop. Empty-answer recovery alone removes all tools. `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, only while an iteration remains. 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. `src/web-search/run-turn-loop.ts` shares this recovery and checks the ceiling before dispatch so it cannot start an unused request; below the search cap, `emptyCompletionRetry` permits one identical empty-answer retry per request with current tools and results. Errors and invalid terminals never authorize another search. Batched results from one call share one 8,000-character budget in `src/web-search/format-result.ts`, planned per query before serialization (#6621): every query keeps its label and status, shortened answers and unlisted sources carry markers with their original counts outside the untrusted boundary, structured output stays one valid JSON document, and a batch whose fixed parts alone overflow is condensed to status lines with a per-status count of any query that still did not fit.

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. Reset recovery and 429 replays share one three-send budget per search, so the two layers cannot multiply physical sends. Direct vision/search helpers and the alpha/search relay retain the resolver's live [stored-account credit policy](codex-account-controls.md#stored-account-authentication-policy) through physical sends and retries; a local hold prevents the send without becoming an upstream connection failure.
`src/web-search/loop.ts` uses `src/web-search/header-deadline.ts` to suspend the cumulative response-header deadline while each physical send awaits local pacing admission. Ordinary fetches, adapter executors, reset retries and pool-key rotations retain the remaining budget after admission; same-target 429 backoff still clears and restarts that budget. The default remains 200,000 ms. Client cancellation remains linked during queued admission and after final headers, and the existing pacing slot wrapper retains leases through body completion or cancellation. Streaming retry heartbeats and body inactivity deadlines keep their existing ownership. `tests/web-search/web-search-pacing-deadline.test.ts` covers queued success, real header stalls, queued cancellation, retry admission and cumulative rotation time. 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. Reset recovery and 429 replays share one three-send budget per search, so the two layers cannot multiply physical sends. Direct vision/search helpers and the alpha/search relay retain the resolver's live [stored-account credit policy](codex-account-controls.md#stored-account-authentication-policy) through physical sends and retries; a local hold prevents the send without becoming an upstream connection failure.
## Scoped provider quota for Combo selection

`src/providers/quota/report-cache.ts` publishes routing evidence only when a producer explicitly supplies its
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -1080,7 +1080,7 @@
"web-search-candidates.test.ts": "web-search", "web-search-parse.test.ts": "web-search",
"web-search-passthrough-bridge.test.ts": "web-search", "web-search-progress-stream.test.ts": "web-search",
"web-search-retry-heartbeat.test.ts": "web-search", "web-search-sidecar-429.test.ts": "web-search",
"web-search-sources.test.ts": "web-search", "web-search-timeout-contract.test.ts": "web-search",
"web-search-sources.test.ts": "web-search", "web-search-timeout-contract.test.ts": "web-search", "web-search-pacing-deadline.test.ts": "web-search",
"web-search-timeout-plan.test.ts": "web-search", "web-search.test.ts": "web-search",
"win-exec.test.ts": "windows", "win-paths.test.ts": "windows",
"windows-acl-start-cost.test.ts": "windows",
Expand Down
Loading
Loading