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
2 changes: 2 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@
"owner-registry-acl.test.ts": "config",
"minimax-combo-image-input.test.ts": "providers",
"web-search-forced-declaration.test.ts": "web-search",
"web-search-loop-iteration-usage.test.ts": "web-search",
"web-search-loop-error-usage.test.ts": "web-search",
"claude-devin-output-order.test.ts": "claude-integration",
"codex-config-preservation.test.ts": "codex-integration",
"codex-credits.test.ts": "codex-integration",
Expand Down
3 changes: 3 additions & 0 deletions src/images/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
// runTurn adapters signal errors via {type:"error"} events, not HTTP status codes.
const errorEvent = events.find(e => e.type === "error");
if (errorEvent && errorEvent.type === "error") {
hiddenUsage = addUsage(hiddenUsage, errorEvent.usage);
if (errorEvent.code === "translation_buffer_limit") {
throw new TranslatorBudgetExceededError("retained_collectors", TRANSLATOR_MAX_TURN_BYTES);
}
Expand Down Expand Up @@ -778,6 +779,8 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
signal,
inactivityTimeoutMs: stallTimeoutMs,
translatorBudget,
// A pass that fails was still billed; the error terminal below reports it with the hidden passes.
onErrorUsage: usage => { hiddenUsage = addUsage(hiddenUsage, usage); },
})) {
if (event.type === "heartbeat") yield event;
else {
Expand Down
28 changes: 27 additions & 1 deletion src/web-search/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { AttemptRecoveryKind } from "../usage/log";
import { isTruncatedStopReason } from "../responses/truncated-stop-reason";
import { bridgeToResponsesSSE } from "../bridge";
import { guardEmptyToolCatalogStream, type ClientToolCatalogRestriction } from "../lib/empty-tool-catalog";
import { mergeUsage } from "../server/responses/empty-completion-guard";
import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor";
import { runAnthropicWebSearch } from "./anthropic-executor";
import { runXaiWebSearch, type XaiSearchOptions } from "./xai-executor";
Expand Down Expand Up @@ -144,6 +145,18 @@ async function* replay(events: AdapterEvent[]): AsyncGenerator<AdapterEvent> {
for (const e of events) yield e;
}

function terminalUsage(events: AdapterEvent[]): OcxUsage | undefined {
for (const e of events) if (e.type === "done" || e.type === "incomplete") return e.usage;
return undefined;
}

/** Hidden iterations add counters only: their checkpoint and raw wire usage are not the final terminal's. */
function countersOnly(usage: OcxUsage | undefined): OcxUsage | undefined {
if (!usage) return undefined;
const { contextTotalTokens: _context, rawUsage: _raw, ...counters } = usage;
return counters;
}

/**
* Collect the thinking block that preceded a web_search call in this iteration's events, so the
* replayed assistant turn can carry it. Anthropic extended thinking REQUIRES the assistant
Expand Down Expand Up @@ -413,6 +426,12 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
// One iteration beyond the forced answer is reserved for its empty-answer recovery below.
const HARD_CAP = maxSearches + 3;
let emptyAnswerRetries = 0;
// Every iteration re-sends the full context, but only the last one's terminal reaches the bridge.
// Search-calling passes and the discarded empty forced answer are summed here and added to what
// onUsage reports, so the request log meters what was actually sent. The client-facing terminal
// keeps the last iteration's usage: Codex and Claude Code size the context window from it, and
// a sum would read as a context several times larger and trigger early auto-compaction.
let hiddenUsage: OcxUsage | undefined;
let accountRefusalOutputStarted = false;
const connectTimeoutMs = deps.connectTimeoutMs ?? 200_000;
const routedModelStallTimeoutMs = deps.routedModelStallTimeoutMs ?? 200_000;
Expand Down Expand Up @@ -687,6 +706,8 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
signal,
inactivityTimeoutMs: routedModelStallTimeoutMs,
translatorBudget,
// A pass that fails was still billed; the error path reports it with the hidden passes.
onErrorUsage: usage => { hiddenUsage = mergeUsage(hiddenUsage, usage); },
})) {
if (event.type === "heartbeat") yield event;
// Kiro's explicit-completion protocol marks ordinary assistant text as commentary while
Expand Down Expand Up @@ -950,6 +971,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
stopReason: terminalEvent.stopReason,
eventTypes: [...new Set(split.passthrough.map(event => event.type))],
}));
hiddenUsage = mergeUsage(hiddenUsage, terminalEvent.usage);
if (!split.hasMalformedToolCall && !split.hasRealToolCall && emptyAnswerRetries === 0) {
emptyAnswerRetries++;
console.warn("[web-search-loop] empty forced answer — retrying once without tools");
Expand All @@ -972,6 +994,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
yield* replay(split.passthrough.slice(split.streamedPassthroughCount));
return;
}
hiddenUsage = mergeUsage(hiddenUsage, terminalUsage(split.passthrough));
// The thinking that led to the search belongs to the FIRST call's assistant replay turn.
for (const event of split.passthrough.slice(split.streamedPassthroughCount)) {
if (event.type === "text_delta" && event.anthropicThinkingSignature) yield event;
Expand All @@ -982,6 +1005,8 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
yield* runSearchCall(call, callIndex === 0 ? iterationThinking : []);
}
} catch (e) {
// The bridge reports no usage for either error; the iterations already sent still count.
if (hiddenUsage) deps.onUsage?.(hiddenUsage);
if (isTranslatorBudgetExceededError(e)) {
yield {
type: "error",
Expand All @@ -996,6 +1021,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
return;
}
}
if (hiddenUsage) deps.onUsage?.(hiddenUsage);
yield { type: "error", message: "web-search loop exceeded its iteration cap" };
} finally {
if (abortSignal) abortSignal.removeEventListener("abort", linkAbort);
Expand All @@ -1019,7 +1045,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
hideRawReasoning: parsed.options.hideRawReasoning,
...(deps.stallTimeoutSec !== undefined ? { stallTimeoutSec: deps.stallTimeoutSec } : {}),
...(deps.onFirstOutput ? { onFirstOutput: deps.onFirstOutput } : {}),
...(deps.onUsage ? { onUsage: deps.onUsage } : {}),
...(deps.onUsage ? { onUsage: (usage: OcxUsage | undefined) => deps.onUsage?.(mergeUsage(countersOnly(hiddenUsage), usage)) } : {}),
...(deps.onCompletedResponse ? { onCompletedResponse: deps.onCompletedResponse } : {}),
},
);
Expand Down
5 changes: 4 additions & 1 deletion src/web-search/progress-stream.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ProviderAdapter } from "../adapters/base";
import type { AdapterEvent } from "../types";
import type { AdapterEvent, OcxUsage } from "../types";
import {
isTranslatorBudgetExceededError,
TRANSLATOR_MAX_TURN_BYTES,
Expand Down Expand Up @@ -32,6 +32,8 @@ export interface ParseStreamWithProgressOptions {
signal?: AbortSignal;
/** Kept configurable for focused tests; production callers should use the 5 second default. */
postTerminalDrainTimeoutMs?: number;
/** Receives the usage an adapter `error` event carries before the stream fails on it. */
onErrorUsage?: (usage: OcxUsage) => void;
}

type ParseStream = ProviderAdapter["parseStream"];
Expand Down Expand Up @@ -296,6 +298,7 @@ export async function* parseStreamWithProgress(
);
}
if (event.type === "error") {
if (event.usage) options.onErrorUsage?.(event.usage);
fail(event.code === "translation_buffer_limit"
? new TranslatorBudgetExceededError("live_transient", TRANSLATOR_MAX_TURN_BYTES)
: new Error(event.message));
Expand Down
2 changes: 1 addition & 1 deletion structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ Automatic Codex pool selection and account status share the [plan exclusion cont

### Empty forced search answers

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.
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. Every iteration re-sends the full context, so `src/web-search/loop.ts` adds the usage of each iteration whose terminal the client never sees (search-calling passes and the discarded empty answer) to what `onUsage` reports for the request log, including on an in-stream or translator-budget error; token and cache counts add, while `contextTotalTokens` and `rawUsage` come only from the final terminal, never an earlier iteration's. The client-facing terminal keeps the last iteration's usage, because Codex and Claude Code size the context window from it (`tests/web-search/web-search-loop-iteration-usage.test.ts`). A pass that ends in an adapter `error` event was billed too: `parseStreamWithProgress` hands that event's usage to `onErrorUsage` before failing, so `src/web-search/loop.ts` and `src/images/loop.ts` (which already puts its hidden iterations' usage on the failed terminal) add it to the usage they report, while the turn still fails with the same error (`tests/web-search/web-search-loop-error-usage.test.ts`, `tests/images/loop.test.ts`). `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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

`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
Expand Down
3 changes: 2 additions & 1 deletion tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@
"cli-combo-partial-update.test.ts": "cli", "desktop-app-server-shim.test.ts": "clients",
"desktop-app-server-shim-launcher.test.ts": "clients", "desktop-chatgpt-config.test.ts": "clients",
"owner-registry-acl.test.ts": "config", "minimax-combo-image-input.test.ts": "providers",
"web-search-forced-declaration.test.ts": "web-search", "claude-devin-output-order.test.ts": "claude-integration",
"web-search-forced-declaration.test.ts": "web-search", "web-search-loop-iteration-usage.test.ts": "web-search",
"web-search-loop-error-usage.test.ts": "web-search", "claude-devin-output-order.test.ts": "claude-integration",
"codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration",
"codex-credits-settings.test.ts": "codex-integration", "codex-credits-probes.test.ts": "codex-integration",
"codex-credits-after-limit.test.ts": "codex-integration",
Expand Down
35 changes: 35 additions & 0 deletions tests/images/loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1299,3 +1299,38 @@ describe.each(["runTurn", "parseStream"] as const)("image-loop provider identity
expect(seen).toEqual(["github-copilot", "github-copilot"]);
});
});

describe("image-loop adapter error usage", () => {
const imageCall = (usage: { inputTokens: number; outputTokens: number }): AdapterEvent[] => [
{ type: "tool_call_start", id: "call_1", name: "image_gen" },
{ type: "tool_call_delta", arguments: '{"prompt":"a cat"}' },
{ type: "tool_call_end" },
{ type: "done", usage },
];
const errored: AdapterEvent = { type: "error", message: "provider blew up", usage: { inputTokens: 3, outputTokens: 2 } };

test("parseStream: an errored iteration's usage is added to the hidden iterations'", async () => {
useRealProgressStream = true;
streamQueue = [imageCall({ inputTokens: 10, outputTokens: 4 }), [{ type: "text_delta", text: "partial" }, errored]];
let seen: unknown = "unset";
const response = await runWithImageBridge({ parsed: makeParsed(), adapter: mockAdapter, plan, onUsage: usage => { seen = usage; } });
const sse = await response.text();
expect(sse).toContain("Provider stream error: provider blew up");
expect(seen).toEqual({ inputTokens: 13, outputTokens: 6 });
});

test("runTurn: an errored iteration's usage is reported", async () => {
const queue = [imageCall({ inputTokens: 10, outputTokens: 4 }), [errored]];
const adapter: ProviderAdapter = {
...mockAdapter,
runTurn: async (_parsed: OcxParsedRequest, _incoming: IncomingMeta, emit: (e: AdapterEvent) => void) => {
for (const e of queue.shift() ?? []) emit(e);
},
};
let seen: unknown = "unset";
const response = await runWithImageBridge({ parsed: makeParsed(), adapter, plan, onUsage: usage => { seen = usage; } });
const sse = await response.text();
expect(sse).toContain("provider blew up");
expect(seen).toEqual({ inputTokens: 13, outputTokens: 6 });
});
});
44 changes: 44 additions & 0 deletions tests/web-search/web-search-loop-error-usage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { afterEach, expect, spyOn, test } from "bun:test";
import type { ProviderAdapter } from "../../src/adapters/base";
import type { AdapterEvent, OcxParsedRequest } from "../../src/types";
import { runWithWebSearch } from "../../src/web-search/loop";
import { buildWebSearchTool } from "../../src/web-search/synthetic-tool";
import { createTestTranslatorBudget } from "../helpers/translator-budget";

const originalFetch = globalThis.fetch;
afterEach(() => { globalThis.fetch = originalFetch; });

// A pass that ends in an adapter error was billed; the request log must meter it, and the turn
// must still fail with the same error.
test("an errored iteration's usage reaches the request log beside the search pass", async () => {
spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ results: [] }));
const passes: AdapterEvent[][] = [
[
{ type: "tool_call_start", id: "ws1", name: "web_search" },
{ type: "tool_call_delta", arguments: JSON.stringify({ query: "docs" }) },
{ type: "tool_call_end" },
{ type: "done", usage: { inputTokens: 1000, outputTokens: 50 } },
],
[{ type: "text_delta", text: "partial" }, { type: "error", message: "provider blew up", usage: { inputTokens: 1400, outputTokens: 30 } }],
];
const adapter: ProviderAdapter = {
name: "errored-iteration",
buildRequest: () => ({ url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }),
fetchResponse: async () => new Response("wire"),
async *parseStream() { yield* passes.shift()!; },
};
const parsed: OcxParsedRequest = {
modelId: "fixture", stream: true, options: {}, context: { messages: [], tools: [buildWebSearchTool()] },
};
const reported: unknown[] = [];
const response = await runWithWebSearch({
parsed, adapter, backend: "exa", exaApiKey: "fixture", hostedTool: { type: "web_search" },
settings: { model: "fixture", reasoning: "low", timeoutMs: 1000 }, maxSearches: 1,
incomingMeta: { headers: new Headers(), translatorBudget: createTestTranslatorBudget() },
onUsage: usage => reported.push(usage),
});
const text = await response.text();
expect(text).toContain("response.failed");
expect(text).toContain("Provider stream error: provider blew up");
expect(reported).toEqual([expect.objectContaining({ inputTokens: 2400, outputTokens: 80 })]);
});
Loading
Loading