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
8 changes: 8 additions & 0 deletions src/adapters/base.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AdapterEvent, OcxParsedRequest } from "../types";
import type { TranslatorBudget } from "../lib/translator-budget";
import type { RequestExecutionBudget } from "../lib/request-execution-budget";
import type { AdapterTierMetadata } from "../providers/fastwire";

/** Metadata about the caller's incoming request, for auth-forwarding adapters. */
Expand Down Expand Up @@ -139,6 +140,13 @@ export interface AdapterFetchContext {
stream?: boolean;
/** Custom fetch executor to use for physical upstream network requests (defaults to globalThis.fetch). */
executor?: typeof globalThis.fetch;
/**
* The logical request's send budget (#4546). Optional and unlimited when absent, so an
* adapter unit test that calls a transport context-free keeps its own retry shape. An
* adapter that retries internally must admit EVERY physical send against it: counting one
* adapter entry as one send is how a nested 3x3 ladder stayed invisible to a request cap.
*/
sendBudget?: RequestExecutionBudget;
Comment on lines +143 to +149

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add focused regression coverage for the shared send budget

This commit changes physical-send admission in both Kiro's nested retry ladder and native compact's refresh, alternate-account, and recursive-handoff paths, but it adds no tests. Existing Kiro retry tests omit sendBudget, so they continue exercising the explicitly unlimited compatibility path and cannot detect regressions such as legacy-endpoint throttling or compact budget exhaustion; add focused budgeted cases to the existing Kiro and compaction test files.

AGENTS.md reference: src/AGENTS.md:L22-L26

Useful? React with 👍 / 👎.

}

/**
Expand Down
8 changes: 8 additions & 0 deletions src/adapters/kiro-retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { readBoundedResponseBody } from "../lib/bounded-body";
import { resolveClientRetryAfter } from "../lib/retry-after";
import { parseRetryAfterMs } from "../combos";
import {
SendBudgetExhaustedError,
abortError,
cancelResponseBodyBestEffort,
fetchWithAttemptDeadline,
Expand Down Expand Up @@ -162,6 +163,13 @@ async function fetchWithResetRecovery(
let lastError: unknown;
for (let attempt = 0; attempt < RESET_ATTEMPTS; attempt++) {
if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
// Every physical send is admitted, not just the adapter entry. Kiro nests a throttle loop
// over this ladder and can run the ladder twice per throttle round, so counting one entry
// as one send hid up to eighteen upstream requests from the per-request cap (#4546).
const decision = ctx.sendBudget?.reserveDispatch({ sendClass: "transient", targetKey: url });
if (decision && (!decision.allowed || !decision.permit.use())) {
throw new SendBudgetExhaustedError(url);
Comment on lines +169 to +171

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep Kiro on the legacy endpoint after fallback

When the canonical endpoint returns an endpoint-specific failure and the legacy endpoint then returns a transient 429, these URL-based target keys consume the policy's sole target transition (canonical → legacy). The next throttle round starts from the canonical URL again, so reserveDispatch rejects legacy → canonical before the third physical send, and a request that could recover instead fails with SendBudgetExhaustedError. Persist the selected legacy endpoint for subsequent throttle rounds, or otherwise avoid re-entering the rejected canonical endpoint.

AGENTS.md reference: src/AGENTS.md:L17-L19

Useful? React with 👍 / 👎.

}
try {
const headers = new Headers(request.headers);
const recovered = attempt > 0;
Expand Down
8 changes: 8 additions & 0 deletions src/adapters/kiro/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
} from "../../types";
import type { ProviderAdapter } from "../base";
import type { AdapterFetchContext, AdapterRequest } from "../base";
import type { RequestExecutionBudget } from "../../lib/request-execution-budget";
import { safeKiroHttpErrorMessage } from "../kiro-errors";
import { calibrateKiroEstimate } from "../kiro-calibration";
import { normalizeKiroImages } from "../kiro-images";
Expand Down Expand Up @@ -58,6 +59,9 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
let requestSnapshot: OcxParsedRequest | undefined;
let firstRequestBodyBytes = 0;
let requestAbortSignal: AbortSignal | undefined;
// Captured the same way as the abort signal, because the text-fallback rebuild below runs
// outside the fetchResponse frame and used to construct a context without either (#4546).
let requestSendBudget: RequestExecutionBudget | undefined;

const build = async (
parsed: OcxParsedRequest,
Expand Down Expand Up @@ -208,6 +212,9 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
abortSignal: requestAbortSignal,
returnRawErrors: true,
stream: true,
// The text-fallback rebuild used to construct a fresh context and drop the budget,
// so everything after the first send escaped the per-request cap.
...(requestSendBudget ? { sendBudget: requestSendBudget } : {}),
});
return {
response,
Expand Down Expand Up @@ -278,6 +285,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
// Keep it for the adapter-owned bounded continuation so cancelling the client turn aborts
// both the first Kiro request and its one allowed completion retry.
if (ctx?.abortSignal) requestAbortSignal = ctx.abortSignal;
if (ctx?.sendBudget) requestSendBudget = ctx.sendBudget;
return fetchKiroWithRetry(request, ctx);
},

Expand Down
54 changes: 51 additions & 3 deletions src/server/responses/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,14 @@ import {
fetchWithResetRetry,
fetchWithTransientRetry,
applyUpstreamRecoveryInit,
SendBudgetExhaustedError,
TRANSIENT_RETRY_MAX_ATTEMPTS,
type UpstreamSendRecovery,
} from "../../lib/upstream-retry";
import {
createRequestExecutionBudget,
type RequestExecutionBudget,
} from "../../lib/request-execution-budget";
import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upstream-reachability";
import {
acquireUpstreamHostAdmission,
Expand Down Expand Up @@ -224,6 +230,14 @@ export interface HandleResponsesCompactOptions {
nativeMainRefreshDependencies?: NativeMainRefreshDependencies;
/** Release the listener's idle guard only after the complete request body is accepted. */
onRequestBodyRead?: () => void;
/**
* The logical request's send budget (#4546). Compact used to hold its own: the normal send
* took a fresh transient allowance of three, the 401 replay and the 429 alternate each added
* one -- and the guard that was supposed to make those two mutually exclusive keys on
* `kind === "pool"`, so a main-pool credential could spend all five. The recursive handoff
* child then started over, so one compact could reach ten.
*/
sendBudget?: RequestExecutionBudget;
}

export function compactResponseTooLargeError(): Response {
Expand Down Expand Up @@ -760,6 +774,10 @@ export async function handleResponsesCompact(
// so routed-model reasoning items (reasoning_text content) don't 400 the ChatGPT backend.
const compactBody = sanitizeReasoningInputContent(compactBodyRaw) as typeof compactBodyRaw;
const compactUrl = `${base}/responses/compact`;
// One holder for this logical compact, inherited by the handoff child so a second model
// does not start over with a fresh four.
const sendBudget: RequestExecutionBudget = options.sendBudget ?? createRequestExecutionBudget();
const compactTargetKey = `${route.providerName}|${route.modelId}|compact`;
Comment on lines +777 to +780

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the owned structure documentation

Introducing a request-wide adapter transport contract and changing Kiro/compact retry ownership modifies shared adapters and server transport behavior, but the commit leaves all mapped structure documents untouched, including structure/runtime.md, structure/transports/responses.md, structure/transports/inventory.md, and structure/adapters/registry.md. Update the applicable documents alongside this source change so the retry and send-budget invariants remain authoritative.

AGENTS.md reference: src/AGENTS.md:L10-L11

Useful? React with 👍 / 👎.

const actualCompactHostKey = upstreamHostHealthKey(
route.providerName,
safeOriginLabel(compactUrl),
Expand Down Expand Up @@ -834,6 +852,27 @@ export async function handleResponsesCompact(
// wrapping reset retry — because those retries happen before any alternate is even
// considered. The alternate is one bounded send: a second ladder would multiply the
// work an already-rejecting pool is doing.
//
// Both modes now draw one shared budget. The comment below used to say the 401 replay
// spends the account budget so the 429 alternate is skipped, but that guard is keyed on
// `kind === "pool"` and a main-pool credential left it false -- so 401 then 429 really did
// reach five. The single sends spend the base allowance first and then the one shared
// final-recovery reserve, which is the same rule the Responses path follows.
const sendSingleCompactAttempt = (
doFetch: () => Promise<Response>,
): Promise<Response> => {
if (sendBudget.remainingBaseSends(TRANSIENT_RETRY_MAX_ATTEMPTS) > 0) {
sendBudget.used += 1;
return doFetch();
}
const decision = sendBudget.reserveDispatch({
sendClass: "auth-recovery",
targetKey: compactTargetKey,
});
if (!decision.allowed) return Promise.reject(new SendBudgetExhaustedError(safeHostLabel(compactUrl)));
if (!decision.permit.use()) return Promise.reject(new SendBudgetExhaustedError(safeHostLabel(compactUrl)));
Comment on lines +872 to +873

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the last compact response when the budget refuses

When the initial three-send ladder ends in a 401 for a main-pool credential, the refresh replay spends the fourth send and can return 429; the subsequent alternate attempt reaches this refusal after that 429 body has already been cancelled. localDispatchRefusal does not recognize SendBudgetExhaustedError, so the surrounding catch converts the quota response into a misleading 502 upstream_error. Check budget availability before cancelling the reusable response and preserve the 429, or map the refusal to the structured 429 contract used by core.ts.

AGENTS.md reference: src/AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

return doFetch();
};
const sendCompactAttempt = (
sendProvider: OcxProviderConfig,
sendHeaders: Headers,
Expand Down Expand Up @@ -866,8 +905,15 @@ export async function handleResponsesCompact(
return res;
});
return recovery === "single"
? doFetch()
: fetchWithTransientRetry(doFetch, { abortSignal: req.signal, label: safeHostLabel(compactUrl) });
? sendSingleCompactAttempt(doFetch)
: fetchWithTransientRetry(doFetch, {
abortSignal: req.signal,
label: safeHostLabel(compactUrl),
// Draws the shared remainder instead of a fresh three. Compact is a native endpoint
// of the same logical turn, so its sends belong to the same cap.
attempts: sendBudget.remainingBaseSends(TRANSIENT_RETRY_MAX_ATTEMPTS),
onSendsConsumed: (used: number) => { sendBudget.used += Math.max(0, used); },
});
};

// The account each outcome belongs to. Reassigned only when the alternate send below
Expand Down Expand Up @@ -1133,7 +1179,9 @@ export async function handleResponsesCompact(
logCtx,
turnAdmissionLease,
admission,
options,
// The handoff child is the same logical compact on a second model, so it inherits
// the holder. Forwarding `options` alone was not enough: the child minted its own.
{ ...options, sendBudget },
);
if (fallback.ok || fallback.status === 499) return fallback;
await fallback.body?.cancel().catch(() => undefined);
Expand Down
3 changes: 3 additions & 0 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7718,6 +7718,7 @@ async function handleResponsesInner(
upstreamResponse = await activeAdapter.fetchResponse(builtInitialRequest, {
abortSignal: upstream.signal,
timeoutMs: connectMs,
sendBudget,
stream: parsed.stream,
executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
dispatchOverride: oauthDispatch(builtInitialRequest),
Expand Down Expand Up @@ -7852,6 +7853,7 @@ async function handleResponsesInner(
return await activeAdapter.fetchResponse(retryRequest, {
abortSignal: upstream.signal,
timeoutMs: connectMs,
sendBudget,
stream: parsed.stream,
executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
dispatchOverride: oauthDispatch(retryRequest),
Expand Down Expand Up @@ -8409,6 +8411,7 @@ async function handleResponsesInner(
return await activeAdapter.fetchResponse(builtContinuationRequest, {
abortSignal: upstream.signal,
timeoutMs: connectMs,
sendBudget,
stream: nextParsed.stream,
executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
dispatchOverride: oauthDispatch(builtContinuationRequest, nextParsed),
Expand Down
Loading