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
3 changes: 3 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@
"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",
"web-search-rotation-usage-attribution.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
30 changes: 25 additions & 5 deletions src/images/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,12 @@ export interface ImageBridgeDeps {
waitForRequestSlot?: (signal?: AbortSignal) => Promise<ProviderRequestSlot | void>;
/** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */
onUsage?: (usage: OcxUsage | undefined) => void;
/**
* After an `on429` rotation, offered the usage of the hidden iterations the left credential
* served. Returning true means it was billed to that credential's attempt, so `onUsage` will not
* report it again; the client terminal still includes it.
*/
onRotatedUsage?: (usage: OcxUsage) => boolean;
/**
* Optional 429 failover for the routed (non-xAI) model. Return a rebuilt adapter for the
* rotated credential, or null when the pool is exhausted. Async hooks support OAuth refresh;
Expand Down Expand Up @@ -383,6 +389,10 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
let paidImageCalls = 0;
let paidVideoCalls = 0;
let hiddenUsage: OcxUsage | undefined;
// Hidden usage already billed through onRotatedUsage: still on the client terminal, not in
// ledgerUsage, which is what onUsage reports once a rotation has moved some.
let rotatedUsage: OcxUsage | undefined;
let ledgerUsage: OcxUsage | undefined;
let activeProducers = 0;

const addUsage = (a: OcxUsage | undefined, b: OcxUsage | undefined): OcxUsage | undefined => {
Expand Down Expand Up @@ -575,6 +585,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 @@ -715,6 +726,10 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
const rotated = await deps.on429(prepared.response.headers.get("retry-after"), prepared.response.headers,
iterParsed, prepared.response);
if (!rotated) break;
if (hiddenUsage && deps.onRotatedUsage?.(hiddenUsage)) {
rotatedUsage = addUsage(rotatedUsage, hiddenUsage);
hiddenUsage = undefined;
}
try { void prepared.response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
adapter = rotated.adapter;
yield { type: "heartbeat" };
Expand Down Expand Up @@ -778,6 +793,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 Expand Up @@ -871,11 +888,12 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
// Codex. forceFinal also finalizes.
const shouldLoop = split.calls.length > 0 && !split.hasRealToolCall && !forceFinal;
if (!shouldLoop) {
if (hiddenUsage) {
if (hiddenUsage || rotatedUsage) {
for (let i = split.passthrough.length - 1; i >= 0; i--) {
const e = split.passthrough[i];
if (e?.type === "done" || e?.type === "incomplete") {
split.passthrough[i] = { ...e, usage: addUsage(hiddenUsage, e.usage) };
ledgerUsage = addUsage(hiddenUsage, e.usage);
split.passthrough[i] = { ...e, usage: addUsage(rotatedUsage, ledgerUsage) };
break;
}
}
Expand Down Expand Up @@ -1062,21 +1080,23 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
});
}
} catch (e) {
ledgerUsage = hiddenUsage;
const usage = addUsage(rotatedUsage, hiddenUsage);
if (isTranslatorBudgetExceededError(e)) {
yield {
type: "error",
status: 502,
errorType: "upstream_error",
code: e.code,
message: "upstream translation buffer exceeded the safe limit",
...(hiddenUsage ? { usage: hiddenUsage } : {}),
...(usage ? { usage } : {}),
};
} else {
yield {
type: "error",
message: e instanceof LoopError ? e.message : (e instanceof Error ? e.message : String(e)),
...(e instanceof LoopError ? { status: e.status } : {}),
...(hiddenUsage ? { usage: hiddenUsage } : {}),
...(usage ? { usage } : {}),
};
}
return;
Expand Down Expand Up @@ -1104,7 +1124,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
...(deps.onUsage ? {
// Terminal done/incomplete already includes hiddenUsage (merged above). Do not
// add it again here or request logs double-count multi-iteration image turns.
onUsage: (usage: OcxUsage | undefined) => deps.onUsage?.(usage),
onUsage: (usage: OcxUsage | undefined) => deps.onUsage?.(rotatedUsage ? ledgerUsage : usage),
} : {}),
...(deps.onCompletedResponse ? { onCompletedResponse: deps.onCompletedResponse } : {}),
},
Expand Down
14 changes: 13 additions & 1 deletion src/server/responses/sidecar-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
clampImageMaxRounds,
} from "../../images";
import type { ProviderAdapter } from "../../adapters/base";
import type { OcxParsedRequest } from "../../types";
import type { OcxParsedRequest, OcxUsage } from "../../types";
import { rotateProviderTransportOn429, rateLimitRetryPolicyFor } from "../../providers/key-failover";
import {
isGenericOAuthFailoverEnabled,
Expand Down Expand Up @@ -50,6 +50,7 @@ import { providerFetch } from "./fetch-helpers";
import type { AttemptRecoveryKind } from "../../usage/log";
import { recordAdapterReasoning, recordAdapterTier } from "../request-log";
import { normalizeLogConversationId } from "../request-log-conversation";
import { attemptAccountChanged } from "../request-log-account-rotation";
import { rememberResponseState } from "../../responses/state";
import { trackStreamLifetime } from "../lifecycle";
import { isAntigravityRawValidationRefusal } from "./antigravity-validation-refusal";
Expand Down Expand Up @@ -452,6 +453,15 @@ export async function executeResponsesSidecars(
});
return { adapter: rotatedAdapter, recoveryKind };
};
// A loop's hidden rounds before a rotation were spent by the account it left. Bill them to that
// account's attempt while it is still open; the next send seals it. Declined when that send
// keeps the account (no new attempt), so the loop's final total still carries them.
const billRotatedUsage = (usage: OcxUsage): boolean => {
const attempt = logCtx.activeAttempt;
if (!attempt || !attemptAccountChanged(attempt.accountLogLabel, logCtx.accountLogLabel, attempt.provider, logCtx.provider)) return false;
transportState.bindKeyUsageFromBridge(usage);
return true;
};
if ((imgPlan || vidPlan) && wsPlan) {
// Web search takes priority when both are active — the media bridge cannot run
// alongside runWithWebSearch. Surface a runtime signal so the user knows their
Expand Down Expand Up @@ -551,6 +561,7 @@ export async function executeResponsesSidecars(
transportState.bindKeyUsageFromBridge(usage);
},
on429: rotateSidecarProviderOn429,
onRotatedUsage: billRotatedUsage,
retryOn429Policy: route.providerName === "kiro" && isGenericOAuthFailoverEnabled(config, "kiro")
? null : rateLimitRetryPolicyFor(route.provider),
onFirstOutput: noteSidecarOutput,
Expand Down Expand Up @@ -654,6 +665,7 @@ export async function executeResponsesSidecars(
stallTimeoutSec: wsPlan.stallTimeoutSec,
streamRoutedModelOutput: wsPlan.streamRoutedModelOutput,
on429: rotateSidecarProviderOn429,
onRotatedUsage: billRotatedUsage,
...(!options.comboInitialSend && route.providerName === "google-antigravity" ? { onIterationEnd: () => {
// The next Antigravity adapter dispatch claims this permit. If request construction,
// admission or cancellation stopped it first, return the unused send allowance.
Expand Down
35 changes: 34 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 @@ -326,6 +339,12 @@ export interface WebSearchLoopDeps {
onFirstOutput?: () => void;
/** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */
onUsage?: (usage: OcxUsage | undefined) => void;
/**
* After an `on429` rotation, offered the usage of the hidden iterations the left credential
* served. Returning true means it was billed to that credential's attempt, so `onUsage` will not
* report it again.
*/
onRotatedUsage?: (usage: OcxUsage) => boolean;
/** Observe the exact adapter request selected for each routed-model iteration. */
onRequestBuilt?: (request: AdapterRequest) => void;
/** Request-scoped executor retains the core's selection binding across loop retries. */
Expand Down Expand Up @@ -413,6 +432,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 @@ -592,6 +617,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
const rotated = await deps.on429(prepared.response.headers.get("retry-after"), prepared.response.headers,
iterParsed, prepared.response);
if (!rotated) break;
if (hiddenUsage && deps.onRotatedUsage?.(hiddenUsage)) hiddenUsage = undefined;
const retainRefusal = prepared.response.status === 403
&& deps.incomingMeta?.providerName === "google-antigravity" && rotated.recoveryKind === "oauth-account-403";
// Keep the bounded refusal readable until the sibling actually returns headers.
Expand Down Expand Up @@ -687,6 +713,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 +978,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 +1001,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 +1012,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 +1028,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 +1052,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
Loading
Loading