From 78957f72f08832b0fab2fe5fae37ee7b1dcd0d58 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:14:38 +0900 Subject: [PATCH 01/23] fix(responses): bound API-key 429 rotations across continuations --- .../fr/reference/configuration/providers.md | 2 + .../ja/reference/configuration/providers.md | 2 + .../ko/reference/configuration/providers.md | 2 + .../docs/reference/configuration/providers.md | 2 + .../ru/reference/configuration/providers.md | 2 + .../tr/reference/configuration/providers.md | 2 + .../reference/configuration/providers.md | 2 + .../reference/configuration/providers.md | 2 + src/providers/key-failover.ts | 15 ++- src/server/responses/core.ts | 33 +++++++ structure/adapters/registry.md | 2 +- structure/catalog.md | 2 +- structure/clients/claude-desktop.md | 2 +- structure/data-planes/images.md | 2 +- structure/data-planes/inbound-compat.md | 2 +- structure/gui-and-management-api.md | 2 +- structure/ops/service-and-sidecars.md | 2 +- structure/providers/xai-grok.md | 2 +- structure/runtime.md | 2 +- structure/subagents.md | 2 +- structure/transports/byte-accounting.md | 2 +- structure/transports/inventory.md | 2 +- structure/transports/responses.md | 13 ++- structure/transports/streaming-health.md | 2 +- tests/adapters/key-failover.test.ts | 25 ++++- tests/server/server-key-failover-e2e.test.ts | 96 ++++++++++++++++++- 26 files changed, 199 insertions(+), 25 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 2b8545629c..80580a596c 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -154,6 +154,8 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `unsafeAllowNativeLocalExec?` | `boolean` | Ancien booléen de Cursor, équivalent à `nativeLocalExec: "on"` uniquement lorsque le champ plus récent n'est pas défini. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Politique d'exécution locale de Cursor. `off` est la valeur par défaut ; actuellement, `codex-sandbox` échoue de manière sûre comme `off`. | +Pour les requêtes Responses traduites utilisant un pool de plusieurs clés, chaque invocation du fournisseur routé partage au maximum `N - 1` rotations de clés API entre la récupération initiale et les requêtes de continuation jusqu’à la fin de la réponse, où `N` est la taille du pool avant le premier envoi. L’expiration d’un délai de refroidissement ou l’agrandissement ultérieur du pool ne renouvelle pas cette limite. D’autres budgets d’envoi peuvent arrêter les tentatives plus tôt. Si la rotation est refusée, le dernier 429 enregistre toujours le délai de refroidissement de la clé en échec, mais aucune clé de remplacement n’est sélectionnée et la réponse suit le traitement d’erreur existant. + Les fournisseurs à clé API peuvent détenir une clé littérale ou une référence à une variable d'environnement. Les fournisseurs OAuth utilisent le magasin d'identifiants alimenté par `ocx login` ; le comportement de lancement de Claude Code avec abonnement est configuré sous [`claudeCode.authMode`](/fr/reference/configuration/server/#claude-code-claudecode). diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 9b5acfa274..dbd8b3ef70 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -146,6 +146,8 @@ account を削除しても mapping は保持され、同じ id を再追加す | `unsafeAllowNativeLocalExec?` | `boolean` |カーソルのレガシー ブール値。新しいフィールドが設定されていない場合のみ、`nativeLocalExec: "on"` と同等です。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` |カーソルのローカル実行ポリシー。 `off` がデフォルトです。 `codex-sandbox` は現在、`off` と同様にフェールクローズされます。 | +複数キーのプールを使用する変換済み Responses リクエストでは、ルーティング先プロバイダーの呼び出しごとに、初期リカバリーと応答完了のための後続リクエストで API キーの切り替え上限 `N - 1` 回を共有し、`N` は最初の送信前のプールサイズに固定されます。クールダウンの満了や後からのプール拡張で、この上限が補充されることはありません。他の送信予算によって、再試行がさらに早く停止する場合もあります。切り替えが拒否された場合も、最後の 429 に対する失敗したキーのクールダウンは記録されますが、代わりのキーは選択されず、応答には既存のエラー処理が適用されます。 + API キープロバイダーは、リテラルキーまたは環境参照を保持する場合があります。 OAuth プロバイダーは、`ocx login` によって設定された資格情報ストアを使用します。サブスクリプションに基づくクロード コードの起動動作は、[`claudeCode.authMode`](/reference/configuration/server/#claude-code) で構成されます。 ## プロバイダーによるアウトバウンドの安全性診断 diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index b6657ba58d..bf1a220260 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -146,6 +146,8 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 레거시 불리언입니다. 더 새로운 필드가 설정되지 않았을 때만 `nativeLocalExec: "on"`과 같습니다. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 로컬 실행 정책입니다. 기본값은 `off`입니다. `codex-sandbox`는 현재 `off`처럼 실패를 닫습니다. | +여러 키가 있는 풀을 사용하는 변환된 Responses 요청에서는 라우팅된 프로바이더 호출마다 최초 복구와 응답 완료를 위한 후속 요청이 최대 `N - 1`회의 API 키 회전 한도를 공유하며, `N`은 첫 전송 전의 풀 크기로 고정합니다. 쿨다운이 만료되거나 이후 풀이 커져도 이 한도는 충전되지 않습니다. 다른 전송 예산에 따라 재시도가 더 일찍 멈출 수 있습니다. 회전이 거부되면 마지막 429에 따른 실패 키의 쿨다운은 기록하지만 대체 키를 선택하지 않으며, 응답은 기존 오류 처리 방식으로 마무리합니다. + API 키 공급자는 리터럴 키나 환경 참조를 둘 수 있습니다. OAuth 공급자는 `ocx login`으로 채워지는 자격 증명 저장소를 사용합니다. 구독 기반 Claude Code 실행 동작은 [`claudeCode.authMode`](/reference/configuration/server/#claude-code)에서 설정합니다. ## 공급자 진단용 외부 요청 안전성 diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 7eb8a12524..21e99d3123 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -224,6 +224,8 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. | +For translated Responses requests using a multi-key pool, each routed provider invocation shares a maximum of `N - 1` API-key rotations between its initial recovery and terminal continuations, where `N` is the pool size before the first send. Cooldown expiry or later pool growth does not replenish this allowance. Other send budgets may stop retries sooner. Once rotation is refused, the last 429 still records the failed key's cooldown, but no replacement key is selected and the response follows the existing error handling. + With `webSearchBridge` enabled, a search continuation stays bound to the API-key selection that served the first request. Changing the selected key, its reference or resolved value, authentication mode, or base URL during search or provider pacing ends the turn with a bridge error before another diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index ca389d9ffd..6d5def9edb 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -159,6 +159,8 @@ cross-route credential fallback не существует. Строки API GPT- | `unsafeAllowNativeLocalExec?` | `boolean` | Legacy boolean Cursor, эквивалентен `nativeLocalExec: "on"` только если новое поле не задано. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Политика local-exec для Cursor. `off` — дефолт; `codex-sandbox` сейчас ведёт себя fail-closed как `off`. | +Для преобразованных запросов Responses с пулом из нескольких API-ключей каждый вызов выбранного маршрутизацией провайдера использует общий предел в `N - 1` переключений API-ключей для первоначального восстановления и последующих запросов, завершающих ответ; `N` фиксируется как размер пула до первой отправки. Истечение периода ожидания или последующее расширение пула не восстанавливает этот лимит. Другие бюджеты отправки могут остановить повторы раньше. Если переключение запрещено, для последнего 429 всё равно записывается период ожидания отказавшего ключа, но другой ключ не выбирается, а ответ обрабатывается существующим механизмом обработки ошибок. + Провайдеры с API-key могут хранить literal key или environment-reference. OAuth-провайдеры используют credential store, заполняемый через `ocx login`; поведение subscription-backed launcher'а Claude Code настраивается через diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 3126b9046c..d853327396 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -160,6 +160,8 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor eski boolean değeri, yalnızca daha yeni alan ayarlanmadığında `nativeLocalExec: "on"` değerine eşdeğerdir. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor yerel yürütme politikası. `off` varsayılandır; `codex-sandbox` şu anda `off` gibi kapalı olarak başarısız olur. | +Birden çok anahtar içeren havuz kullanan dönüştürülmüş Responses isteklerinde, yönlendirilen sağlayıcının her çağrısı ilk kurtarma ile yanıtı tamamlayan devam istekleri arasında en fazla `N - 1` API anahtarı değişimini paylaşır; `N`, ilk gönderimden önceki havuz boyutuna sabitlenir. Bekleme süresinin dolması veya havuzun sonradan büyümesi bu hakkı yenilemez. Diğer gönderim bütçeleri yeniden denemeleri daha erken durdurabilir. Anahtar değişimi reddedildiğinde son 429 için başarısız anahtarın bekleme süresi yine kaydedilir, ancak yerine başka bir anahtar seçilmez ve yanıt mevcut hata işleme yolunu izler. + API anahtarı sağlayıcıları değişmez bir anahtar veya bir ortam referansı tutabilir. OAuth sağlayıcıları `ocx login` tarafından doldurulan kimlik bilgisi deposunu kullanır; abonelik destekli Claude Code başlatma davranışı diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index b28a5a2af4..d25b1e72a3 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -146,6 +146,8 @@ selector,而不是分配一个新名称。 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 旧布尔值;仅当更新字段未设置时,等同于 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本地执行策略。`off` 是默认值;`codex-sandbox` 目前会像 `off` 一样失败关闭。 | +对于使用多密钥池的转换后 Responses 请求,每次路由到提供商的调用,其初始恢复和用于完成响应的后续请求共同使用最多 `N - 1` 次 API 密钥轮换额度,其中 `N` 固定为首次发送前的密钥池大小。冷却期结束或随后扩大密钥池都不会补充此额度。其他发送预算可能让重试更早停止。轮换被拒绝时,仍会根据最后一个 429 记录失败密钥的冷却期,但不会选择替代密钥,响应继续按现有错误处理方式处理。 + API key 提供者可以持有字面量 key,或环境引用。OAuth 提供者使用由 `ocx login` 填充的凭据存储;基于订阅的 Claude Code 启动行为在 [`claudeCode.authMode`](/reference/configuration/server/#claude-code) 下配置。 ## 提供者诊断出站安全性 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 0680fa35aa..0857707f81 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -116,6 +116,8 @@ ocx models provider openrouter on | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 舊版布林值,僅在較新欄位未設定時等同於 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本機執行政策。`off` 為預設;`codex-sandbox` 目前像 `off` 般 fail closed。 | +對於使用多金鑰集區的轉換後 Responses 要求,每次路由至提供者的呼叫,其初始復原和用於完成回應的後續要求共同使用最多 `N - 1` 次 API 金鑰輪替額度,其中 `N` 固定為首次傳送前的金鑰池大小。冷卻期結束或之後擴大金鑰池都不會補充此額度。其他傳送預算可能讓重試更早停止。輪替遭拒時,仍會根據最後一個 429 記錄失敗金鑰的冷卻期,但不會選取替代金鑰,回應繼續依現有錯誤處理方式處理。 + API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用由 `ocx login` 填入的憑證存放;訂閱支援的 Claude Code 啟動行為在 [`claudeCode.authMode`](/zh-tw/reference/configuration/server/#claude-code) 下設定。 ## 供應商診斷對外安全 diff --git a/src/providers/key-failover.ts b/src/providers/key-failover.ts index d5ccd759e3..ef35b2c505 100644 --- a/src/providers/key-failover.ts +++ b/src/providers/key-failover.ts @@ -363,6 +363,7 @@ function rotateKeyAfterFailure( now = Date.now(), attemptedKey?: string, attemptedSelection?: ProviderApiKeySelection, + allowRotation = true, ): OcxProviderConfig | null { const provider = config.providers[providerName]; if (!provider) return null; @@ -382,6 +383,10 @@ function rotateKeyAfterFailure( ? pool.find(entry => entry.id === attemptedSelection.entryId && entry.key === failedKey) : pool.find(entry => entry.key === failedKey); + // A spent request still records the failed key, but must not select or persist an + // unattempted replacement. Keep the fresh identity check and the changed:false path. + if (!allowRotation) return { changed: false, value: { failedId: failedEntry?.id } }; + if (freshProvider.apiKey !== failedKey) { const activeEntry = pool.find(entry => entry.key === freshProvider.apiKey); if (activeEntry && !isKeyInCooldown(providerName, activeEntry.id, now)) { @@ -410,6 +415,7 @@ function rotateKeyAfterFailure( }, attemptedSelection); if (outcome.status === "unavailable") return null; if (outcome.status === "superseded") { + if (!allowRotation) return null; // A newer manual selection (including A→B→A) owns subsequent dispatch. Reusing the // same failed key here would loop forever; preserve its original failure instead. return outcome.provider.apiKey !== failedKey ? structuredClone(outcome.provider) : null; @@ -425,6 +431,7 @@ function rotateKeyAfterFailure( keyCooldowns.set(cooldownKey(providerName, outcome.value.failedId), { cooldownUntil: now + cooldownMs }); sweepExpiredOnWrite(now); } + if (!allowRotation) return null; if ("exhaustedCount" in outcome.value) { console.warn(`[key-failover] ${providerName}: all ${outcome.value.exhaustedCount} keys in cooldown after ${failureStatus}; returning the upstream status to the client`); return null; @@ -448,8 +455,9 @@ export function rotateKeyOn429( now = Date.now(), attemptedKey?: string, attemptedSelection?: ProviderApiKeySelection, + allowRotation = true, ): OcxProviderConfig | null { - return rotateKeyAfterFailure(config, providerName, 429, retryAfterHeader, now, attemptedKey, attemptedSelection); + return rotateKeyAfterFailure(config, providerName, 429, retryAfterHeader, now, attemptedKey, attemptedSelection, allowRotation); } /** @@ -486,6 +494,8 @@ interface RotateProviderTransportOptions { attemptedKey?: string; attemptedSelection?: ProviderApiKeySelection; promptCacheKey?: string; + /** False records a proven 429 cooldown without changing the selected key or returning a retry. */ + allowRotation?: boolean; } /** @@ -507,6 +517,7 @@ export function rotateProviderTransportOn429( options.now, options.attemptedKey, options.attemptedSelection ?? routedProvider._apiKeyAttempt, + options.allowRotation, ); if (!rotated) return null; return applyRotatedTransport(providerName, routedProvider, rotated, options.promptCacheKey); @@ -517,7 +528,7 @@ export function rotateProviderTransportOn401( config: OcxConfig, providerName: string, routedProvider: OcxProviderTransport, - options: Omit = {}, + options: Omit = {}, ): OcxProviderTransport | null { const rotated = rotateKeyOn401(config, providerName, options.now, options.attemptedKey, options.attemptedSelection ?? routedProvider._apiKeyAttempt); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 57c808401f..840065f308 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -7644,6 +7644,34 @@ async function handleResponsesInner( : 300_000; activeAdapter = adapter; + // Bound 429 rotations independently of cooldown expiry. Capture the pool before the first + // send; a later provider refresh cannot enlarge this invocation's allowance. The initial + // recovery and terminal continuations share it; it is a failover count, not a distinct-key set. + const maxKeyPoolFailovers = Math.max(0, (route.provider.apiKeyPool?.length ?? 0) - 1); + let keyPoolFailovers = 0; + const keyPool429RetryAllowed = (continuation: boolean): boolean => { + if (keyPoolFailovers >= maxKeyPoolFailovers) return false; + // Adapter-owned sends retain their existing base-only admission (for example Kiro). + if (activeAdapter.fetchResponse) { + return !adapterSendBudget + || adapterSendBudget.remainingBaseSends(adapterSendBudget.policy.baseSendAllowance) > 0; + } + const policy = transientRetryPolicyFor(route.provider); + // Reset-only transports do not opt into the shared transient policy; the rotation cap + // still bounds them without granting a new retry policy or changing their reset limit. + if (!policy) return true; + if (!Number.isInteger(policy.attempts) || policy.attempts <= 0) return false; + if (remainingTransientSendBudget(policy.attempts) > 0) return true; + // Continuations currently draw base sends only. The initial recovery can use the existing + // auth-recovery reserve; checking this decision does not consume a permit or add allowance. + if (continuation || !isRequestExecutionBudget(sendBudget)) return false; + return sendBudget.reserveDispatch({ + sendClass: "auth-recovery", + targetKey: `${route.providerName}|${route.modelId}|key-429`, + countedExternally: true, + }).allowed; + }; + // One immutable, body-safe outbound request per same-target sequence (URL, serialized body, // auth headers, generated compat headers). Same-target 429 replays reuse it verbatim; the // builder runs again only after a key/account/adapter rotation, an oauth refresh, or an @@ -8093,8 +8121,10 @@ async function handleResponsesInner( now: Date.now(), attemptedKey: route.provider.apiKey, promptCacheKey: parsed.options.promptCacheKey, + allowRotation: keyPool429RetryAllowed(false), }); if (!rotated) break; + keyPoolFailovers += 1; // Release the failed response's socket before retrying; unread bodies otherwise linger // until runtime cleanup (one per rotated key under a rate-limit storm). try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } @@ -8516,6 +8546,7 @@ async function handleResponsesInner( response.status === 429 && rateLimitPolicy !== null && rateLimitRetries < rateLimitPolicy.attempts + && !sendBudgetExhausted() ) { rateLimitRetries += 1; // Release unread body + heartbeat-fed wait via the shared same-target helper. @@ -8562,8 +8593,10 @@ async function handleResponsesInner( now: Date.now(), attemptedKey: route.provider.apiKey, promptCacheKey: nextParsed.options.promptCacheKey, + allowRotation: keyPool429RetryAllowed(true), }); if (rotated) { + keyPoolFailovers += 1; try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } route.provider = rotated; invalidateSameTargetRequest(); diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 908633f265..852c92f591 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -1,7 +1,7 @@ # Adapter Registry Authority The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. diff --git a/structure/catalog.md b/structure/catalog.md index 47a827426b..e6ad815d6a 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -1,7 +1,7 @@ # Model Catalog The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index f64a278757..ffe0dabd31 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -1,7 +1,7 @@ # Claude Desktop Integration The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Codex-native model discovery follows the [shared retirement policy](../catalog.md#shared-catalog). That projection does not migrate existing user-selected Desktop configuration or usage history. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index fdbdee6b32..c8daaa7711 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -1,7 +1,7 @@ # Images Data Plane The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Hosted Responses image-tool eligibility uses the shared compatibility policy without a Codex Spark exception; standalone Images retain the separate relay contract below. See diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index aa9aa15f52..3647be046b 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -1,7 +1,7 @@ # Inbound Compatibility Surfaces The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). ## Standalone file transcription diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 0496735405..d927152d99 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,7 +1,7 @@ # GUI And Management API The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). ## Dashboard serving diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index e9d8fb7c00..0b9712d656 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -1,7 +1,7 @@ # Background Service And Sidecars The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Service startup and restore use the [catalog retirement policy](../catalog.md#shared-catalog); retirement does not itself change service registration or user-selected model configuration. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 0ca554f4e0..a3b829967f 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -1,7 +1,7 @@ # xAI Grok Provider The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Codex-native retirement is scoped to OpenAI catalog/quota evidence. Shared Responses handling retains xAI provider behavior; see diff --git a/structure/runtime.md b/structure/runtime.md index e4a50ba3f8..4d40573a92 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,7 +1,7 @@ # Runtime The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Chat request serialization owns the destination-scoped [OpenCode Go instruction ordering](providers/chat-compat.md#opencode-go-chronological-instructions); diff --git a/structure/subagents.md b/structure/subagents.md index 69047b076b..b44fd6522c 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -7,7 +7,7 @@ only explicit true on Responses ingress to the final canonical ChatGPT forward route activates it. A default top-level collaboration catalog is required. The compiler preserves caller objects, aliases the namespace and three message functions, and removes only their true encryption marker. -Declaration/reference collisions refuse the whole rewrite without changing the request. +Declaration/reference collisions refuse the whole rewrite without changing the request. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). `src/adapters/openai-responses.ts` returns request-local alias capabilities. The Responses core refreshes them after every request rebuild and restores JSON, SSE and WebSocket identities after diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index e758afeaf2..21a5a3aeb3 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -2,7 +2,7 @@ How opencodex measures request and stream bytes without allocating copies solely to count them. These contracts are shared by request parsing, SSE rewriting, the provider adapters and -the translator budget, which is why so many documents link here rather than restating them. +the translator budget, which is why so many documents link here rather than restating them. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). ## Request-copy accounting diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 8c04f7b633..d50d957914 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -1,7 +1,7 @@ # Transport Inventory The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). The Chat adapter's [OpenCode Go instruction ordering](../providers/chat-compat.md#opencode-go-chronological-instructions) changes translated message placement only; endpoint selection and transport stay with their existing owners. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 1bc2b4de0d..a373a19489 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -113,13 +113,7 @@ within their route; neither route falls through to the other. See ### Pre-dispatch API-key pool pick -Key-auth routes with a configured `apiKeyPoolStrategy` and two or more pool entries pick a -warm key before the first send (`selectProactiveApiKeyTransport` in -`src/providers/key-failover.ts`). The pick is inert unless that strategy is set and the -committed key is already cooling or missing from the pool: a healthy committed key, including -a manual selection, is left alone and the common path returns null without a config write. -`forgetApiKeyRotationCursor` drops the process-local round-robin cursor when the operator -edits the pool, so a later pick cannot second-guess that choice. +Key-auth routes with a configured `apiKeyPoolStrategy` and two or more pool entries pick a warm key before the first send (`selectProactiveApiKeyTransport` in `src/providers/key-failover.ts`). The pick is inert unless that strategy is set and the committed key is already cooling or missing from the pool: a healthy committed key, including a manual selection, is left alone and the common path returns null without a config write. `forgetApiKeyRotationCursor` drops the process-local round-robin cursor when the operator edits the pool, so a later pick cannot second-guess that choice. On the shared Responses path the assignment lands in `src/server/responses/core.ts` immediately before `resolveProviderTransport`. `route.provider` is copied into @@ -142,6 +136,11 @@ are not at risk on a stored row, because the config schema requires both. Reactive 429 rotation (`rotateProviderTransportOn429`) remains the recovery path after a send has already earned a throttle. +### Bounded API-key 429 rotation + +Each generic `handleResponsesInner` invocation in `src/server/responses/core.ts` captures `max(0, initial apiKeyPool.length - 1)` before its first send and shares that failover count with its terminal continuations. Cooldown expiry and later pool growth do not refill it. This is a failover count, not a distinct-key guarantee or a new combo-wide budget; existing shared physical-send admission remains authoritative. Before cancelling a superseded response or choosing another key, the path checks both limits. On refusal, `src/providers/key-failover.ts` accepts `allowRotation: false` to record the proven failed key's cooldown through the existing selection-identity fence, without selecting, persisting or announcing an unattempted replacement. The original failure stays available to normal response handling, including an already-started streaming continuation. `tests/server/server-key-failover-e2e.test.ts` covers expiry, shared continuation limits and earlier send-budget exhaustion; `tests/adapters/key-failover.test.ts` verifies health-only recording, unchanged disk bytes and manual-selection ownership. + + ### Routed service-tier capability OpenAI-compatible service-tier support is resolved only after the final provider/model wire is diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 0b54c97058..fb259b798d 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -1,7 +1,7 @@ # Streaming Health And WebSocket The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Codex WebSocket quota-family normalization remains generic; retired-model evidence is filtered by the [OpenAI quota owner](../providers/openai-tiers.md#public-provider-contract), not by diff --git a/tests/adapters/key-failover.test.ts b/tests/adapters/key-failover.test.ts index 561c1cefa9..757b1df82f 100644 --- a/tests/adapters/key-failover.test.ts +++ b/tests/adapters/key-failover.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, unlinkSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; @@ -95,6 +95,29 @@ describe("hasKeyPoolFailover", () => { }); describe("rotateKeyOn429", () => { + test.each([false, true])("health-only 429 preserves selection ownership and disk bytes (superseded=%s)", superseded => { + const config = makeConfig({ apiKey: "key-alpha-000111222333", apiKeyPool: pool3() }); + const routed = routedProviderConfig("p", config.providers.p); + if (superseded) { + expect(setActiveProviderApiKey(config, "p", "k2")).toBe(true); + expect(setActiveProviderApiKey(config, "p", "k1")).toBe(true); + } + const before = readFileSync(getConfigPath(), "utf8"); + const events: unknown[] = []; + const unsubscribe = subscribeAccountSelections(event => { events.push(event); }); + const now = 10_000; + try { + expect(rotateProviderTransportOn429(config, "p", routed, { + allowRotation: false, attemptedKey: routed.apiKey, retryAfter: "2", now, + })).toBeNull(); + expect(readFileSync(getConfigPath(), "utf8")).toBe(before); + expect(loadConfig().providers.p.apiKey).toBe("key-alpha-000111222333"); + expect(events).toEqual([]); + expect(getKeyCooldownUntil("p", "k1", now)).toBe(superseded ? null : now + 2_000); + expect(getKeyCooldownUntil("p", "k2", now)).toBeNull(); + } finally { unsubscribe(); } + }); + test("an old attempt cannot overwrite a newer manual key selection or its ABA revision", () => { const config = makeConfig({ apiKey: "key-alpha-000111222333", apiKeyPool: pool3() }); const routed = routedProviderConfig("p", config.providers.p); diff --git a/tests/server/server-key-failover-e2e.test.ts b/tests/server/server-key-failover-e2e.test.ts index 418ef993ca..81bd677da8 100644 --- a/tests/server/server-key-failover-e2e.test.ts +++ b/tests/server/server-key-failover-e2e.test.ts @@ -1,9 +1,9 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig, saveConfig } from "../../src/config"; -import { clearKeyCooldowns, rotateKeyOn429 } from "../../src/providers/key-failover"; +import { clearKeyCooldowns, getKeyCooldownUntil, rotateKeyOn429 } from "../../src/providers/key-failover"; import { deriveXaiConvId } from "../../src/providers/xai-transport"; import { clearReasoningReplayCacheForTests } from "../../src/responses/reasoning-replay-cache"; import { startServer } from "../../src/server"; @@ -43,6 +43,98 @@ afterEach(() => { }); describe("server 429 key failover (end-to-end)", () => { + test.each(["exhausted", "continuation", "transient", "budget-exhausted"] as const)( + "429 rotation stays request-bounded after every earlier cooldown expires (%s)", + async mode => { + const originalFetch = globalThis.fetch; + const endpoint = "https://key429-fixture.invalid/v1/chat/completions"; + const expectedSends = mode === "exhausted" ? 2 : mode === "budget-exhausted" ? 4 : 3; + const seen: string[] = []; + const cancelled: number[] = []; + let now = Date.now(); + let restoreClock: (() => void) | undefined; + let server: ReturnType | undefined; + const config = { + port: 0, hostname: "127.0.0.1", defaultProvider: "key429fixture", + providers: { key429fixture: { + adapter: "openai-chat", baseUrl: "https://key429-fixture.invalid/v1", authMode: "key", + apiKey: "synthetic-key-a", apiKeyPool: [ + { id: "a", key: "synthetic-key-a" }, { id: "b", key: "synthetic-key-b" }, + ...(mode === "budget-exhausted" ? [{ id: "c", key: "synthetic-key-c" }] : []), + ], + ...(mode === "continuation" ? { terminalContinuationGuard: true } : {}), + ...(mode === "transient" || mode === "budget-exhausted" ? { transientRetryOn5xx: { enabled: true, attempts: 3 } } : {}), + } }, + } as OcxConfig; + try { + saveConfig(config); + server = startServer(0); + const clock = spyOn(Date, "now").mockImplementation(() => now); + restoreClock = () => clock.mockRestore(); + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url !== endpoint) throw new Error("unexpected outbound request in key-rotation fixture"); + seen.push(new Headers(init?.headers).get("authorization") ?? ""); + // The old loop must terminate deterministically instead of waiting for a test timeout. + if (seen.length > 6) throw new Error("key-rotation fixture send ceiling exceeded"); + const send = seen.length; + // Retry-After: 0 means a 1ms cooldown. Every subsequent response arrives after it. + now += 1_000; + if (mode === "continuation" && send === 2) { + return Response.json({ id: "chatcmpl-plan", object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "I will edit the file now." }, finish_reason: "stop" }], + usage: { prompt_tokens: 2, completion_tokens: 2, total_tokens: 4 }, + }); + } + const status = (mode === "transient" && send === 1) || (mode === "budget-exhausted" && send < 3) ? 503 : 429; + const text = JSON.stringify({ error: { message: `key429-final-${send}`, type: "rate_limit_error" } }); + const bytes = new TextEncoder().encode(text); + const body = new ReadableStream({ + start(controller) { controller.enqueue(bytes); controller.close(); }, + cancel() { cancelled.push(send); }, + }); + return new Response(body, { status, headers: { + "content-type": "application/json", "retry-after": "0", + } }); + }) as typeof fetch; + const result = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "key429fixture/test", stream: false, + input: mode === "continuation" ? "Edit the file using the tool." : "hello", + ...(mode === "continuation" ? { tools: [{ type: "function", name: "edit_file", + description: "Synthetic edit tool; never executed", parameters: { type: "object", properties: {} }, + }] } : {}), + }), + }); + const text = await result.text(); + expect(seen).toHaveLength(expectedSends); + expect(seen).toEqual(mode === "budget-exhausted" + ? ["Bearer synthetic-key-a", "Bearer synthetic-key-a", "Bearer synthetic-key-a", "Bearer synthetic-key-b"] + : mode === "transient" + ? ["Bearer synthetic-key-a", "Bearer synthetic-key-a", "Bearer synthetic-key-b"] + : mode === "continuation" + ? ["Bearer synthetic-key-a", "Bearer synthetic-key-b", "Bearer synthetic-key-b"] + : ["Bearer synthetic-key-a", "Bearer synthetic-key-b"]); + // Exhausted initial rotation must not cancel the final error it returns to the caller. + if (mode !== "continuation") { + expect(result.status).toBe(429); + expect(result.headers.get("retry-after")).toBe("0"); + expect(text).toContain(`key429-final-${expectedSends}`); + expect(cancelled).not.toContain(expectedSends); + } else { + // A continuation error is represented inside the already-started Responses result. + expect(text).toContain("key429-final-3"); + } + expect(cancelled).toContain(mode === "budget-exhausted" ? 3 : mode === "transient" ? 2 : 1); + expect(getKeyCooldownUntil("key429fixture", "b", now)).toBe(now + 1); + } finally { + globalThis.fetch = originalFetch; + restoreClock?.(); + await server?.stop(true); + } + }, 15_000, + ); + test("physical key selection rejects disabled, removed, and changed-auth providers", () => { const provider = { adapter: "openai-chat", baseUrl: "https://example.test/v1", authMode: "key", apiKey: "synthetic-first" } as const; const config = { providers: { current: { ...provider } } } as unknown as OcxConfig; From e7dc2d73435b0158922af1eefcb5ed52dddd558f Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:24:15 +0900 Subject: [PATCH 02/23] fix(responses): retain every candidate after an unpooled key --- .../content/docs/fr/reference/configuration/providers.md | 2 +- .../content/docs/ja/reference/configuration/providers.md | 2 +- .../content/docs/ko/reference/configuration/providers.md | 2 +- .../src/content/docs/reference/configuration/providers.md | 2 +- .../content/docs/ru/reference/configuration/providers.md | 2 +- .../content/docs/tr/reference/configuration/providers.md | 2 +- .../docs/zh-cn/reference/configuration/providers.md | 2 +- .../docs/zh-tw/reference/configuration/providers.md | 2 +- src/server/responses/core.ts | 5 ++++- structure/transports/responses.md | 2 +- tests/server/server-key-failover-e2e.test.ts | 8 +++++--- 11 files changed, 18 insertions(+), 13 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 80580a596c..b19d2f4890 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -154,7 +154,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `unsafeAllowNativeLocalExec?` | `boolean` | Ancien booléen de Cursor, équivalent à `nativeLocalExec: "on"` uniquement lorsque le champ plus récent n'est pas défini. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Politique d'exécution locale de Cursor. `off` est la valeur par défaut ; actuellement, `codex-sandbox` échoue de manière sûre comme `off`. | -Pour les requêtes Responses traduites utilisant un pool de plusieurs clés, chaque invocation du fournisseur routé partage au maximum `N - 1` rotations de clés API entre la récupération initiale et les requêtes de continuation jusqu’à la fin de la réponse, où `N` est la taille du pool avant le premier envoi. L’expiration d’un délai de refroidissement ou l’agrandissement ultérieur du pool ne renouvelle pas cette limite. D’autres budgets d’envoi peuvent arrêter les tentatives plus tôt. Si la rotation est refusée, le dernier 429 enregistre toujours le délai de refroidissement de la clé en échec, mais aucune clé de remplacement n’est sélectionnée et la réponse suit le traitement d’erreur existant. +Pour les requêtes Responses traduites utilisant un pool de plusieurs clés, chaque invocation du fournisseur routé partage une limite de rotations de clés API entre la récupération initiale et les requêtes de continuation jusqu’à la fin de la réponse : au maximum `N - 1` rotations si la clé initiale appartient au pool, ou `N` si elle est extérieure au pool, où `N` est la taille du pool avant le premier envoi. L’expiration d’un délai de refroidissement ou l’agrandissement ultérieur du pool ne renouvelle pas cette limite. D’autres budgets d’envoi peuvent arrêter les tentatives plus tôt. Si la rotation est refusée, le dernier 429 enregistre toujours le délai de refroidissement de la clé en échec, mais aucune clé de remplacement n’est sélectionnée et la réponse suit le traitement d’erreur existant. Les fournisseurs à clé API peuvent détenir une clé littérale ou une référence à une variable d'environnement. Les fournisseurs OAuth utilisent le magasin d'identifiants alimenté par `ocx login` ; le comportement de lancement de Claude Code avec abonnement est diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index dbd8b3ef70..a3584bd771 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -146,7 +146,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `unsafeAllowNativeLocalExec?` | `boolean` |カーソルのレガシー ブール値。新しいフィールドが設定されていない場合のみ、`nativeLocalExec: "on"` と同等です。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` |カーソルのローカル実行ポリシー。 `off` がデフォルトです。 `codex-sandbox` は現在、`off` と同様にフェールクローズされます。 | -複数キーのプールを使用する変換済み Responses リクエストでは、ルーティング先プロバイダーの呼び出しごとに、初期リカバリーと応答完了のための後続リクエストで API キーの切り替え上限 `N - 1` 回を共有し、`N` は最初の送信前のプールサイズに固定されます。クールダウンの満了や後からのプール拡張で、この上限が補充されることはありません。他の送信予算によって、再試行がさらに早く停止する場合もあります。切り替えが拒否された場合も、最後の 429 に対する失敗したキーのクールダウンは記録されますが、代わりのキーは選択されず、応答には既存のエラー処理が適用されます。 +複数キーのプールを使用する変換済み Responses リクエストでは、ルーティング先プロバイダーの呼び出しごとに、初期リカバリーと応答完了のための後続リクエストで API キーの切り替え上限を共有し、最初の送信前のプールサイズを `N` として、最初のキーがそのプール内にある場合は最大 `N - 1` 回、プール外にある場合は最大 `N` 回に固定されます。クールダウンの満了や後からのプール拡張で、この上限が補充されることはありません。他の送信予算によって、再試行がさらに早く停止する場合もあります。切り替えが拒否された場合も、最後の 429 に対する失敗したキーのクールダウンは記録されますが、代わりのキーは選択されず、応答には既存のエラー処理が適用されます。 API キープロバイダーは、リテラルキーまたは環境参照を保持する場合があります。 OAuth プロバイダーは、`ocx login` によって設定された資格情報ストアを使用します。サブスクリプションに基づくクロード コードの起動動作は、[`claudeCode.authMode`](/reference/configuration/server/#claude-code) で構成されます。 diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index bf1a220260..226c6e099b 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -146,7 +146,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 레거시 불리언입니다. 더 새로운 필드가 설정되지 않았을 때만 `nativeLocalExec: "on"`과 같습니다. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 로컬 실행 정책입니다. 기본값은 `off`입니다. `codex-sandbox`는 현재 `off`처럼 실패를 닫습니다. | -여러 키가 있는 풀을 사용하는 변환된 Responses 요청에서는 라우팅된 프로바이더 호출마다 최초 복구와 응답 완료를 위한 후속 요청이 최대 `N - 1`회의 API 키 회전 한도를 공유하며, `N`은 첫 전송 전의 풀 크기로 고정합니다. 쿨다운이 만료되거나 이후 풀이 커져도 이 한도는 충전되지 않습니다. 다른 전송 예산에 따라 재시도가 더 일찍 멈출 수 있습니다. 회전이 거부되면 마지막 429에 따른 실패 키의 쿨다운은 기록하지만 대체 키를 선택하지 않으며, 응답은 기존 오류 처리 방식으로 마무리합니다. +여러 키가 있는 풀을 사용하는 변환된 Responses 요청에서는 라우팅된 프로바이더 호출마다 최초 복구와 응답 완료를 위한 후속 요청이 API 키 회전 한도를 공유하며, 첫 전송 전의 풀 크기를 `N`으로 고정하고 최초 키가 해당 풀에 있으면 최대 `N - 1`회, 풀 밖에 있으면 최대 `N`회 회전할 수 있습니다. 쿨다운이 만료되거나 이후 풀이 커져도 이 한도는 충전되지 않습니다. 다른 전송 예산에 따라 재시도가 더 일찍 멈출 수 있습니다. 회전이 거부되면 마지막 429에 따른 실패 키의 쿨다운은 기록하지만 대체 키를 선택하지 않으며, 응답은 기존 오류 처리 방식으로 마무리합니다. API 키 공급자는 리터럴 키나 환경 참조를 둘 수 있습니다. OAuth 공급자는 `ocx login`으로 채워지는 자격 증명 저장소를 사용합니다. 구독 기반 Claude Code 실행 동작은 [`claudeCode.authMode`](/reference/configuration/server/#claude-code)에서 설정합니다. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 21e99d3123..ee6310fc44 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -224,7 +224,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. | -For translated Responses requests using a multi-key pool, each routed provider invocation shares a maximum of `N - 1` API-key rotations between its initial recovery and terminal continuations, where `N` is the pool size before the first send. Cooldown expiry or later pool growth does not replenish this allowance. Other send budgets may stop retries sooner. Once rotation is refused, the last 429 still records the failed key's cooldown, but no replacement key is selected and the response follows the existing error handling. +For translated Responses requests using a multi-key pool, each routed provider invocation shares its API-key rotation allowance between initial recovery and terminal continuations: at most `N - 1` rotations if the initial key belongs to the pool, or `N` if it is outside the pool, where `N` is the pool size before the first send. Cooldown expiry or later pool growth does not replenish this allowance. Other send budgets may stop retries sooner. Once rotation is refused, the last 429 still records the failed key's cooldown, but no replacement key is selected and the response follows the existing error handling. With `webSearchBridge` enabled, a search continuation stays bound to the API-key selection that served the first request. Changing the selected key, its reference or resolved value, authentication diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 6d5def9edb..892e336de2 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -159,7 +159,7 @@ cross-route credential fallback не существует. Строки API GPT- | `unsafeAllowNativeLocalExec?` | `boolean` | Legacy boolean Cursor, эквивалентен `nativeLocalExec: "on"` только если новое поле не задано. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Политика local-exec для Cursor. `off` — дефолт; `codex-sandbox` сейчас ведёт себя fail-closed как `off`. | -Для преобразованных запросов Responses с пулом из нескольких API-ключей каждый вызов выбранного маршрутизацией провайдера использует общий предел в `N - 1` переключений API-ключей для первоначального восстановления и последующих запросов, завершающих ответ; `N` фиксируется как размер пула до первой отправки. Истечение периода ожидания или последующее расширение пула не восстанавливает этот лимит. Другие бюджеты отправки могут остановить повторы раньше. Если переключение запрещено, для последнего 429 всё равно записывается период ожидания отказавшего ключа, но другой ключ не выбирается, а ответ обрабатывается существующим механизмом обработки ошибок. +Для преобразованных запросов Responses с пулом из нескольких API-ключей каждый вызов выбранного маршрутизацией провайдера использует общий предел переключений для первоначального восстановления и последующих запросов, завершающих ответ: не более `N - 1`, если начальный ключ входит в пул, или `N`, если он находится вне пула; `N` фиксируется как размер пула до первой отправки. Истечение периода ожидания или последующее расширение пула не восстанавливает этот лимит. Другие бюджеты отправки могут остановить повторы раньше. Если переключение запрещено, для последнего 429 всё равно записывается период ожидания отказавшего ключа, но другой ключ не выбирается, а ответ обрабатывается существующим механизмом обработки ошибок. Провайдеры с API-key могут хранить literal key или environment-reference. OAuth-провайдеры используют credential store, заполняемый через `ocx login`; поведение subscription-backed launcher'а diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index d853327396..6a7cc59ebd 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -160,7 +160,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor eski boolean değeri, yalnızca daha yeni alan ayarlanmadığında `nativeLocalExec: "on"` değerine eşdeğerdir. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor yerel yürütme politikası. `off` varsayılandır; `codex-sandbox` şu anda `off` gibi kapalı olarak başarısız olur. | -Birden çok anahtar içeren havuz kullanan dönüştürülmüş Responses isteklerinde, yönlendirilen sağlayıcının her çağrısı ilk kurtarma ile yanıtı tamamlayan devam istekleri arasında en fazla `N - 1` API anahtarı değişimini paylaşır; `N`, ilk gönderimden önceki havuz boyutuna sabitlenir. Bekleme süresinin dolması veya havuzun sonradan büyümesi bu hakkı yenilemez. Diğer gönderim bütçeleri yeniden denemeleri daha erken durdurabilir. Anahtar değişimi reddedildiğinde son 429 için başarısız anahtarın bekleme süresi yine kaydedilir, ancak yerine başka bir anahtar seçilmez ve yanıt mevcut hata işleme yolunu izler. +Birden çok anahtar içeren havuz kullanan dönüştürülmüş Responses isteklerinde, yönlendirilen sağlayıcının her çağrısı ilk kurtarma ile yanıtı tamamlayan devam istekleri arasında bir API anahtarı değiştirme sınırını paylaşır: başlangıç anahtarı havuzdaysa en fazla `N - 1`, havuz dışındaysa en fazla `N` değişim yapılabilir; `N`, ilk gönderimden önceki havuz boyutuna sabitlenir. Bekleme süresinin dolması veya havuzun sonradan büyümesi bu hakkı yenilemez. Diğer gönderim bütçeleri yeniden denemeleri daha erken durdurabilir. Anahtar değişimi reddedildiğinde son 429 için başarısız anahtarın bekleme süresi yine kaydedilir, ancak yerine başka bir anahtar seçilmez ve yanıt mevcut hata işleme yolunu izler. API anahtarı sağlayıcıları değişmez bir anahtar veya bir ortam referansı tutabilir. OAuth sağlayıcıları `ocx login` tarafından doldurulan kimlik bilgisi diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index d25b1e72a3..326c9890e2 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -146,7 +146,7 @@ selector,而不是分配一个新名称。 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 旧布尔值;仅当更新字段未设置时,等同于 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本地执行策略。`off` 是默认值;`codex-sandbox` 目前会像 `off` 一样失败关闭。 | -对于使用多密钥池的转换后 Responses 请求,每次路由到提供商的调用,其初始恢复和用于完成响应的后续请求共同使用最多 `N - 1` 次 API 密钥轮换额度,其中 `N` 固定为首次发送前的密钥池大小。冷却期结束或随后扩大密钥池都不会补充此额度。其他发送预算可能让重试更早停止。轮换被拒绝时,仍会根据最后一个 429 记录失败密钥的冷却期,但不会选择替代密钥,响应继续按现有错误处理方式处理。 +对于使用多密钥池的转换后 Responses 请求,每次路由到提供商的调用,其初始恢复和用于完成响应的后续请求共同使用 API 密钥轮换额度:初始密钥在池中时最多轮换 `N - 1` 次,不在池中时最多轮换 `N` 次,其中 `N` 固定为首次发送前的密钥池大小。冷却期结束或随后扩大密钥池都不会补充此额度。其他发送预算可能让重试更早停止。轮换被拒绝时,仍会根据最后一个 429 记录失败密钥的冷却期,但不会选择替代密钥,响应继续按现有错误处理方式处理。 API key 提供者可以持有字面量 key,或环境引用。OAuth 提供者使用由 `ocx login` 填充的凭据存储;基于订阅的 Claude Code 启动行为在 [`claudeCode.authMode`](/reference/configuration/server/#claude-code) 下配置。 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 0857707f81..31571d243a 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -116,7 +116,7 @@ ocx models provider openrouter on | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 舊版布林值,僅在較新欄位未設定時等同於 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本機執行政策。`off` 為預設;`codex-sandbox` 目前像 `off` 般 fail closed。 | -對於使用多金鑰集區的轉換後 Responses 要求,每次路由至提供者的呼叫,其初始復原和用於完成回應的後續要求共同使用最多 `N - 1` 次 API 金鑰輪替額度,其中 `N` 固定為首次傳送前的金鑰池大小。冷卻期結束或之後擴大金鑰池都不會補充此額度。其他傳送預算可能讓重試更早停止。輪替遭拒時,仍會根據最後一個 429 記錄失敗金鑰的冷卻期,但不會選取替代金鑰,回應繼續依現有錯誤處理方式處理。 +對於使用多金鑰集區的轉換後 Responses 要求,每次路由至提供者的呼叫,其初始復原和用於完成回應的後續要求共同使用 API 金鑰輪替額度:初始金鑰在集區中時最多輪替 `N - 1` 次,不在集區中時最多輪替 `N` 次,其中 `N` 固定為首次傳送前的金鑰池大小。冷卻期結束或之後擴大金鑰池都不會補充此額度。其他傳送預算可能讓重試更早停止。輪替遭拒時,仍會根據最後一個 429 記錄失敗金鑰的冷卻期,但不會選取替代金鑰,回應繼續依現有錯誤處理方式處理。 API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用由 `ocx login` 填入的憑證存放;訂閱支援的 Claude Code 啟動行為在 [`claudeCode.authMode`](/zh-tw/reference/configuration/server/#claude-code) 下設定。 diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 840065f308..7cec43f5a8 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -7647,7 +7647,10 @@ async function handleResponsesInner( // Bound 429 rotations independently of cooldown expiry. Capture the pool before the first // send; a later provider refresh cannot enlarge this invocation's allowance. The initial // recovery and terminal continuations share it; it is a failover count, not a distinct-key set. - const maxKeyPoolFailovers = Math.max(0, (route.provider.apiKeyPool?.length ?? 0) - 1); + const initialKeyPool = route.provider.apiKeyPool ?? []; + const initialKeyReference = route.provider._apiKeyAttempt?.reference ?? route.provider.apiKey; + const initialKeyIsPooled = initialKeyPool.some(entry => entry.key === initialKeyReference); + const maxKeyPoolFailovers = Math.max(0, initialKeyPool.length - (initialKeyIsPooled ? 1 : 0)); let keyPoolFailovers = 0; const keyPool429RetryAllowed = (continuation: boolean): boolean => { if (keyPoolFailovers >= maxKeyPoolFailovers) return false; diff --git a/structure/transports/responses.md b/structure/transports/responses.md index a373a19489..f0ba4d0e90 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -138,7 +138,7 @@ send has already earned a throttle. ### Bounded API-key 429 rotation -Each generic `handleResponsesInner` invocation in `src/server/responses/core.ts` captures `max(0, initial apiKeyPool.length - 1)` before its first send and shares that failover count with its terminal continuations. Cooldown expiry and later pool growth do not refill it. This is a failover count, not a distinct-key guarantee or a new combo-wide budget; existing shared physical-send admission remains authoritative. Before cancelling a superseded response or choosing another key, the path checks both limits. On refusal, `src/providers/key-failover.ts` accepts `allowRotation: false` to record the proven failed key's cooldown through the existing selection-identity fence, without selecting, persisting or announcing an unattempted replacement. The original failure stays available to normal response handling, including an already-started streaming continuation. `tests/server/server-key-failover-e2e.test.ts` covers expiry, shared continuation limits and earlier send-budget exhaustion; `tests/adapters/key-failover.test.ts` verifies health-only recording, unchanged disk bytes and manual-selection ownership. +Each generic `handleResponsesInner` invocation in `src/server/responses/core.ts` captures a failover allowance before its first send: the initial pool size minus one when the initially selected key reference belongs to that pool, or the full initial pool size when it is outside the pool and shares that failover count with its terminal continuations. Cooldown expiry and later pool growth do not refill it. This is a failover count, not a distinct-key guarantee or a new combo-wide budget; existing shared physical-send admission remains authoritative. Before cancelling a superseded response or choosing another key, the path checks both limits. On refusal, `src/providers/key-failover.ts` accepts `allowRotation: false` to record the proven failed key's cooldown through the existing selection-identity fence, without selecting, persisting or announcing an unattempted replacement. The original failure stays available to normal response handling, including an already-started streaming continuation. `tests/server/server-key-failover-e2e.test.ts` covers expiry, an initially unpooled key, shared continuation limits and earlier send-budget exhaustion; `tests/adapters/key-failover.test.ts` verifies health-only recording, unchanged disk bytes and manual-selection ownership. ### Routed service-tier capability diff --git a/tests/server/server-key-failover-e2e.test.ts b/tests/server/server-key-failover-e2e.test.ts index 81bd677da8..25da3d831a 100644 --- a/tests/server/server-key-failover-e2e.test.ts +++ b/tests/server/server-key-failover-e2e.test.ts @@ -43,7 +43,7 @@ afterEach(() => { }); describe("server 429 key failover (end-to-end)", () => { - test.each(["exhausted", "continuation", "transient", "budget-exhausted"] as const)( + test.each(["exhausted", "continuation", "transient", "budget-exhausted", "unpooled"] as const)( "429 rotation stays request-bounded after every earlier cooldown expires (%s)", async mode => { const originalFetch = globalThis.fetch; @@ -58,7 +58,7 @@ describe("server 429 key failover (end-to-end)", () => { port: 0, hostname: "127.0.0.1", defaultProvider: "key429fixture", providers: { key429fixture: { adapter: "openai-chat", baseUrl: "https://key429-fixture.invalid/v1", authMode: "key", - apiKey: "synthetic-key-a", apiKeyPool: [ + apiKey: mode === "unpooled" ? "synthetic-key-outside" : "synthetic-key-a", apiKeyPool: [ { id: "a", key: "synthetic-key-a" }, { id: "b", key: "synthetic-key-b" }, ...(mode === "budget-exhausted" ? [{ id: "c", key: "synthetic-key-c" }] : []), ], @@ -108,7 +108,9 @@ describe("server 429 key failover (end-to-end)", () => { }); const text = await result.text(); expect(seen).toHaveLength(expectedSends); - expect(seen).toEqual(mode === "budget-exhausted" + expect(seen).toEqual(mode === "unpooled" + ? ["Bearer synthetic-key-outside", "Bearer synthetic-key-a", "Bearer synthetic-key-b"] + : mode === "budget-exhausted" ? ["Bearer synthetic-key-a", "Bearer synthetic-key-a", "Bearer synthetic-key-a", "Bearer synthetic-key-b"] : mode === "transient" ? ["Bearer synthetic-key-a", "Bearer synthetic-key-a", "Bearer synthetic-key-b"] From 9336e38f99b2a65a062113d5ccba4eb559974956 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:40:18 +0900 Subject: [PATCH 03/23] fix(responses): enforce key rotation allowance in sidecar bridges --- .../fr/reference/configuration/providers.md | 2 +- .../ja/reference/configuration/providers.md | 2 +- .../ko/reference/configuration/providers.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- .../ru/reference/configuration/providers.md | 2 +- .../tr/reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- src/server/responses/core.ts | 19 ++--- structure/transports/responses.md | 2 +- tests/server/server-key-failover-e2e.test.ts | 69 +++++++++++++++++++ 11 files changed, 89 insertions(+), 17 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index b19d2f4890..c9c3094097 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -154,7 +154,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `unsafeAllowNativeLocalExec?` | `boolean` | Ancien booléen de Cursor, équivalent à `nativeLocalExec: "on"` uniquement lorsque le champ plus récent n'est pas défini. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Politique d'exécution locale de Cursor. `off` est la valeur par défaut ; actuellement, `codex-sandbox` échoue de manière sûre comme `off`. | -Pour les requêtes Responses traduites utilisant un pool de plusieurs clés, chaque invocation du fournisseur routé partage une limite de rotations de clés API entre la récupération initiale et les requêtes de continuation jusqu’à la fin de la réponse : au maximum `N - 1` rotations si la clé initiale appartient au pool, ou `N` si elle est extérieure au pool, où `N` est la taille du pool avant le premier envoi. L’expiration d’un délai de refroidissement ou l’agrandissement ultérieur du pool ne renouvelle pas cette limite. D’autres budgets d’envoi peuvent arrêter les tentatives plus tôt. Si la rotation est refusée, le dernier 429 enregistre toujours le délai de refroidissement de la clé en échec, mais aucune clé de remplacement n’est sélectionnée et la réponse suit le traitement d’erreur existant. +Pour les requêtes Responses traduites utilisant un pool de plusieurs clés, chaque invocation du fournisseur routé partage une limite de rotations de clés API entre la récupération initiale, les tours des ponts de recherche web, d’image et de vidéo, et les requêtes de continuation jusqu’à la fin de la réponse : au maximum `N - 1` rotations si la clé initiale appartient au pool, ou `N` si elle est extérieure au pool, où `N` est la taille du pool avant le premier envoi. L’expiration d’un délai de refroidissement ou l’agrandissement ultérieur du pool ne renouvelle pas cette limite. Les budgets d’envoi existants applicables peuvent arrêter les tentatives plus tôt ; les ponts sidecar conservent leurs propres limites de reprise après réinitialisation de connexion et de nouvelle tentative vers la même cible, sans que cette limite de rotation leur impose le budget d’envois physiques du core. Si la rotation est refusée, le dernier 429 enregistre toujours le délai de refroidissement de la clé en échec, mais aucune clé de remplacement n’est sélectionnée et la réponse suit le traitement d’erreur existant. Les fournisseurs à clé API peuvent détenir une clé littérale ou une référence à une variable d'environnement. Les fournisseurs OAuth utilisent le magasin d'identifiants alimenté par `ocx login` ; le comportement de lancement de Claude Code avec abonnement est diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index a3584bd771..ee9d15459a 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -146,7 +146,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `unsafeAllowNativeLocalExec?` | `boolean` |カーソルのレガシー ブール値。新しいフィールドが設定されていない場合のみ、`nativeLocalExec: "on"` と同等です。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` |カーソルのローカル実行ポリシー。 `off` がデフォルトです。 `codex-sandbox` は現在、`off` と同様にフェールクローズされます。 | -複数キーのプールを使用する変換済み Responses リクエストでは、ルーティング先プロバイダーの呼び出しごとに、初期リカバリーと応答完了のための後続リクエストで API キーの切り替え上限を共有し、最初の送信前のプールサイズを `N` として、最初のキーがそのプール内にある場合は最大 `N - 1` 回、プール外にある場合は最大 `N` 回に固定されます。クールダウンの満了や後からのプール拡張で、この上限が補充されることはありません。他の送信予算によって、再試行がさらに早く停止する場合もあります。切り替えが拒否された場合も、最後の 429 に対する失敗したキーのクールダウンは記録されますが、代わりのキーは選択されず、応答には既存のエラー処理が適用されます。 +複数キーのプールを使用する変換済み Responses リクエストでは、ルーティング先プロバイダーの呼び出しごとに、初期リカバリー、ウェブ検索・画像・動画ブリッジの各ラウンド、応答完了のための後続リクエストで API キーの切り替え上限を共有し、最初の送信前のプールサイズを `N` として、最初のキーがそのプール内にある場合は最大 `N - 1` 回、プール外にある場合は最大 `N` 回に固定されます。クールダウンの満了や後からのプール拡張で、この上限が補充されることはありません。適用対象となる既存の送信予算によって、再試行がさらに早く停止する場合もあります。サイドカーブリッジは接続リセット時と同一対象への再試行にそれぞれ既存の上限を維持し、この切り替え上限によって core の物理送信予算がブリッジに新たに適用されることはありません。切り替えが拒否された場合も、最後の 429 に対する失敗したキーのクールダウンは記録されますが、代わりのキーは選択されず、応答には既存のエラー処理が適用されます。 API キープロバイダーは、リテラルキーまたは環境参照を保持する場合があります。 OAuth プロバイダーは、`ocx login` によって設定された資格情報ストアを使用します。サブスクリプションに基づくクロード コードの起動動作は、[`claudeCode.authMode`](/reference/configuration/server/#claude-code) で構成されます。 diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 226c6e099b..7cdd7d7280 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -146,7 +146,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 레거시 불리언입니다. 더 새로운 필드가 설정되지 않았을 때만 `nativeLocalExec: "on"`과 같습니다. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 로컬 실행 정책입니다. 기본값은 `off`입니다. `codex-sandbox`는 현재 `off`처럼 실패를 닫습니다. | -여러 키가 있는 풀을 사용하는 변환된 Responses 요청에서는 라우팅된 프로바이더 호출마다 최초 복구와 응답 완료를 위한 후속 요청이 API 키 회전 한도를 공유하며, 첫 전송 전의 풀 크기를 `N`으로 고정하고 최초 키가 해당 풀에 있으면 최대 `N - 1`회, 풀 밖에 있으면 최대 `N`회 회전할 수 있습니다. 쿨다운이 만료되거나 이후 풀이 커져도 이 한도는 충전되지 않습니다. 다른 전송 예산에 따라 재시도가 더 일찍 멈출 수 있습니다. 회전이 거부되면 마지막 429에 따른 실패 키의 쿨다운은 기록하지만 대체 키를 선택하지 않으며, 응답은 기존 오류 처리 방식으로 마무리합니다. +여러 키가 있는 풀을 사용하는 변환된 Responses 요청에서는 라우팅된 프로바이더 호출마다 최초 복구, 웹 검색·이미지·비디오 브리지의 각 라운드, 응답 완료를 위한 후속 요청이 API 키 회전 한도를 공유하며, 첫 전송 전의 풀 크기를 `N`으로 고정하고 최초 키가 해당 풀에 있으면 최대 `N - 1`회, 풀 밖에 있으면 최대 `N`회 회전할 수 있습니다. 쿨다운이 만료되거나 이후 풀이 커져도 이 한도는 충전되지 않습니다. 적용 대상인 기존 전송 예산에 따라 재시도가 더 일찍 멈출 수 있습니다. 사이드카 브리지는 자체 연결 재설정 및 동일 대상 재시도 한도를 유지하며, 이 회전 한도가 브리지에 core의 물리적 전송 예산을 새로 적용하지는 않습니다. 회전이 거부되면 마지막 429에 따른 실패 키의 쿨다운은 기록하지만 대체 키를 선택하지 않으며, 응답은 기존 오류 처리 방식으로 마무리합니다. API 키 공급자는 리터럴 키나 환경 참조를 둘 수 있습니다. OAuth 공급자는 `ocx login`으로 채워지는 자격 증명 저장소를 사용합니다. 구독 기반 Claude Code 실행 동작은 [`claudeCode.authMode`](/reference/configuration/server/#claude-code)에서 설정합니다. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index ee6310fc44..2b7b21f1fc 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -224,7 +224,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. | -For translated Responses requests using a multi-key pool, each routed provider invocation shares its API-key rotation allowance between initial recovery and terminal continuations: at most `N - 1` rotations if the initial key belongs to the pool, or `N` if it is outside the pool, where `N` is the pool size before the first send. Cooldown expiry or later pool growth does not replenish this allowance. Other send budgets may stop retries sooner. Once rotation is refused, the last 429 still records the failed key's cooldown, but no replacement key is selected and the response follows the existing error handling. +For translated Responses requests using a multi-key pool, each routed provider invocation shares its API-key rotation allowance between initial recovery, web-search/image/video bridge rounds, and terminal continuations: at most `N - 1` rotations if the initial key belongs to the pool, or `N` if it is outside the pool, where `N` is the pool size before the first send. Cooldown expiry or later pool growth does not replenish this allowance. Existing applicable send budgets may stop retries sooner; sidecar bridges retain their own connection-reset and same-target retry limits, and this rotation cap does not impose the core's physical-send budget on those bridges. Once rotation is refused, the last 429 still records the failed key's cooldown, but no replacement key is selected and the response follows the existing error handling. With `webSearchBridge` enabled, a search continuation stays bound to the API-key selection that served the first request. Changing the selected key, its reference or resolved value, authentication diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 892e336de2..a0f5936360 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -159,7 +159,7 @@ cross-route credential fallback не существует. Строки API GPT- | `unsafeAllowNativeLocalExec?` | `boolean` | Legacy boolean Cursor, эквивалентен `nativeLocalExec: "on"` только если новое поле не задано. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Политика local-exec для Cursor. `off` — дефолт; `codex-sandbox` сейчас ведёт себя fail-closed как `off`. | -Для преобразованных запросов Responses с пулом из нескольких API-ключей каждый вызов выбранного маршрутизацией провайдера использует общий предел переключений для первоначального восстановления и последующих запросов, завершающих ответ: не более `N - 1`, если начальный ключ входит в пул, или `N`, если он находится вне пула; `N` фиксируется как размер пула до первой отправки. Истечение периода ожидания или последующее расширение пула не восстанавливает этот лимит. Другие бюджеты отправки могут остановить повторы раньше. Если переключение запрещено, для последнего 429 всё равно записывается период ожидания отказавшего ключа, но другой ключ не выбирается, а ответ обрабатывается существующим механизмом обработки ошибок. +Для преобразованных запросов Responses с пулом из нескольких API-ключей каждый вызов выбранного маршрутизацией провайдера использует общий предел переключений для первоначального восстановления, раундов мостов веб-поиска, изображений и видео, а также последующих запросов, завершающих ответ: не более `N - 1`, если начальный ключ входит в пул, или `N`, если он находится вне пула; `N` фиксируется как размер пула до первой отправки. Истечение периода ожидания или последующее расширение пула не восстанавливает этот лимит. Применимые существующие бюджеты отправки могут остановить повторы раньше; мосты sidecar сохраняют собственные пределы повторов после сброса соединения и повторных запросов к той же цели, а этот предел переключений не распространяет на них бюджет физических отправок core. Если переключение запрещено, для последнего 429 всё равно записывается период ожидания отказавшего ключа, но другой ключ не выбирается, а ответ обрабатывается существующим механизмом обработки ошибок. Провайдеры с API-key могут хранить literal key или environment-reference. OAuth-провайдеры используют credential store, заполняемый через `ocx login`; поведение subscription-backed launcher'а diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 6a7cc59ebd..778175d2fd 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -160,7 +160,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor eski boolean değeri, yalnızca daha yeni alan ayarlanmadığında `nativeLocalExec: "on"` değerine eşdeğerdir. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor yerel yürütme politikası. `off` varsayılandır; `codex-sandbox` şu anda `off` gibi kapalı olarak başarısız olur. | -Birden çok anahtar içeren havuz kullanan dönüştürülmüş Responses isteklerinde, yönlendirilen sağlayıcının her çağrısı ilk kurtarma ile yanıtı tamamlayan devam istekleri arasında bir API anahtarı değiştirme sınırını paylaşır: başlangıç anahtarı havuzdaysa en fazla `N - 1`, havuz dışındaysa en fazla `N` değişim yapılabilir; `N`, ilk gönderimden önceki havuz boyutuna sabitlenir. Bekleme süresinin dolması veya havuzun sonradan büyümesi bu hakkı yenilemez. Diğer gönderim bütçeleri yeniden denemeleri daha erken durdurabilir. Anahtar değişimi reddedildiğinde son 429 için başarısız anahtarın bekleme süresi yine kaydedilir, ancak yerine başka bir anahtar seçilmez ve yanıt mevcut hata işleme yolunu izler. +Birden çok anahtar içeren havuz kullanan dönüştürülmüş Responses isteklerinde, yönlendirilen sağlayıcının her çağrısı ilk kurtarma, web araması/görüntü/video köprüsü turları ve yanıtı tamamlayan devam istekleri arasında bir API anahtarı değiştirme sınırını paylaşır: başlangıç anahtarı havuzdaysa en fazla `N - 1`, havuz dışındaysa en fazla `N` değişim yapılabilir; `N`, ilk gönderimden önceki havuz boyutuna sabitlenir. Bekleme süresinin dolması veya havuzun sonradan büyümesi bu hakkı yenilemez. Geçerli mevcut gönderim bütçeleri yeniden denemeleri daha erken durdurabilir; sidecar köprüleri bağlantı sıfırlaması ve aynı hedefe yeniden deneme için kendi sınırlarını korur ve bu anahtar değiştirme sınırı, core'un fiziksel gönderim bütçesini bu köprülere uygulamaz. Anahtar değişimi reddedildiğinde son 429 için başarısız anahtarın bekleme süresi yine kaydedilir, ancak yerine başka bir anahtar seçilmez ve yanıt mevcut hata işleme yolunu izler. API anahtarı sağlayıcıları değişmez bir anahtar veya bir ortam referansı tutabilir. OAuth sağlayıcıları `ocx login` tarafından doldurulan kimlik bilgisi diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 326c9890e2..96f58746ea 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -146,7 +146,7 @@ selector,而不是分配一个新名称。 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 旧布尔值;仅当更新字段未设置时,等同于 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本地执行策略。`off` 是默认值;`codex-sandbox` 目前会像 `off` 一样失败关闭。 | -对于使用多密钥池的转换后 Responses 请求,每次路由到提供商的调用,其初始恢复和用于完成响应的后续请求共同使用 API 密钥轮换额度:初始密钥在池中时最多轮换 `N - 1` 次,不在池中时最多轮换 `N` 次,其中 `N` 固定为首次发送前的密钥池大小。冷却期结束或随后扩大密钥池都不会补充此额度。其他发送预算可能让重试更早停止。轮换被拒绝时,仍会根据最后一个 429 记录失败密钥的冷却期,但不会选择替代密钥,响应继续按现有错误处理方式处理。 +对于使用多密钥池的转换后 Responses 请求,每次路由到提供商的调用,其初始恢复、网页搜索/图像/视频桥接的各轮调用以及用于完成响应的后续请求共同使用 API 密钥轮换额度:初始密钥在池中时最多轮换 `N - 1` 次,不在池中时最多轮换 `N` 次,其中 `N` 固定为首次发送前的密钥池大小。冷却期结束或随后扩大密钥池都不会补充此额度。现有且适用的发送预算可能让重试更早停止;sidecar 桥接仍保留自身的连接重置重试和同目标重试限制,此轮换额度不会将 core 的实际发送预算新增应用于这些桥接。轮换被拒绝时,仍会根据最后一个 429 记录失败密钥的冷却期,但不会选择替代密钥,响应继续按现有错误处理方式处理。 API key 提供者可以持有字面量 key,或环境引用。OAuth 提供者使用由 `ocx login` 填充的凭据存储;基于订阅的 Claude Code 启动行为在 [`claudeCode.authMode`](/reference/configuration/server/#claude-code) 下配置。 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 31571d243a..5626ef27c5 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -116,7 +116,7 @@ ocx models provider openrouter on | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 舊版布林值,僅在較新欄位未設定時等同於 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本機執行政策。`off` 為預設;`codex-sandbox` 目前像 `off` 般 fail closed。 | -對於使用多金鑰集區的轉換後 Responses 要求,每次路由至提供者的呼叫,其初始復原和用於完成回應的後續要求共同使用 API 金鑰輪替額度:初始金鑰在集區中時最多輪替 `N - 1` 次,不在集區中時最多輪替 `N` 次,其中 `N` 固定為首次傳送前的金鑰池大小。冷卻期結束或之後擴大金鑰池都不會補充此額度。其他傳送預算可能讓重試更早停止。輪替遭拒時,仍會根據最後一個 429 記錄失敗金鑰的冷卻期,但不會選取替代金鑰,回應繼續依現有錯誤處理方式處理。 +對於使用多金鑰集區的轉換後 Responses 要求,每次路由至提供者的呼叫,其初始復原、網頁搜尋/影像/影片橋接的各輪呼叫以及用於完成回應的後續要求共同使用 API 金鑰輪替額度:初始金鑰在集區中時最多輪替 `N - 1` 次,不在集區中時最多輪替 `N` 次,其中 `N` 固定為首次傳送前的金鑰池大小。冷卻期結束或之後擴大金鑰池都不會補充此額度。現有且適用的傳送預算可能讓重試更早停止;sidecar 橋接仍保留自身的連線重設重試和同目標重試限制,此輪替額度不會將 core 的實際傳送預算新增套用至這些橋接。輪替遭拒時,仍會根據最後一個 429 記錄失敗金鑰的冷卻期,但不會選取替代金鑰,回應繼續依現有錯誤處理方式處理。 API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用由 `ocx login` 填入的憑證存放;訂閱支援的 Claude Code 啟動行為在 [`claudeCode.authMode`](/zh-tw/reference/configuration/server/#claude-code) 下設定。 diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 7cec43f5a8..d486a7fec9 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -6996,6 +6996,15 @@ async function handleResponsesInner( } } + // Capture one API-key rotation allowance before either sidecar or ordinary dispatch. + // Cooldown expiry and later pool growth cannot refill it; all bridge rounds and terminal + // continuations share the count. This is a failover count, not a distinct-key set. + const initialKeyPool = route.provider.apiKeyPool ?? []; + const initialKeyReference = route.provider._apiKeyAttempt?.reference ?? route.provider.apiKey; + const initialKeyIsPooled = initialKeyPool.some(entry => entry.key === initialKeyReference); + const maxKeyPoolFailovers = Math.max(0, initialKeyPool.length - (initialKeyIsPooled ? 1 : 0)); + let keyPoolFailovers = 0; + // Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority. // Routed-compaction turns must NOT hit the image bridge: compaction clears tools/_webSearch but // leaves _imageGeneration, so planImageBridge would activate and return a normal Responses @@ -7023,8 +7032,10 @@ async function handleResponsesInner( now: Date.now(), attemptedKey: route.provider.apiKey, promptCacheKey: parsed.options.promptCacheKey, + allowRotation: keyPoolFailovers < maxKeyPoolFailovers, }); if (rotated) { + keyPoolFailovers += 1; route.provider = rotated; } else if ( // A POSITIVE gate, not an early return. An early `return null` here made every later arm @@ -7644,14 +7655,6 @@ async function handleResponsesInner( : 300_000; activeAdapter = adapter; - // Bound 429 rotations independently of cooldown expiry. Capture the pool before the first - // send; a later provider refresh cannot enlarge this invocation's allowance. The initial - // recovery and terminal continuations share it; it is a failover count, not a distinct-key set. - const initialKeyPool = route.provider.apiKeyPool ?? []; - const initialKeyReference = route.provider._apiKeyAttempt?.reference ?? route.provider.apiKey; - const initialKeyIsPooled = initialKeyPool.some(entry => entry.key === initialKeyReference); - const maxKeyPoolFailovers = Math.max(0, initialKeyPool.length - (initialKeyIsPooled ? 1 : 0)); - let keyPoolFailovers = 0; const keyPool429RetryAllowed = (continuation: boolean): boolean => { if (keyPoolFailovers >= maxKeyPoolFailovers) return false; // Adapter-owned sends retain their existing base-only admission (for example Kiro). diff --git a/structure/transports/responses.md b/structure/transports/responses.md index f0ba4d0e90..77b32828b2 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -138,7 +138,7 @@ send has already earned a throttle. ### Bounded API-key 429 rotation -Each generic `handleResponsesInner` invocation in `src/server/responses/core.ts` captures a failover allowance before its first send: the initial pool size minus one when the initially selected key reference belongs to that pool, or the full initial pool size when it is outside the pool and shares that failover count with its terminal continuations. Cooldown expiry and later pool growth do not refill it. This is a failover count, not a distinct-key guarantee or a new combo-wide budget; existing shared physical-send admission remains authoritative. Before cancelling a superseded response or choosing another key, the path checks both limits. On refusal, `src/providers/key-failover.ts` accepts `allowRotation: false` to record the proven failed key's cooldown through the existing selection-identity fence, without selecting, persisting or announcing an unattempted replacement. The original failure stays available to normal response handling, including an already-started streaming continuation. `tests/server/server-key-failover-e2e.test.ts` covers expiry, an initially unpooled key, shared continuation limits and earlier send-budget exhaustion; `tests/adapters/key-failover.test.ts` verifies health-only recording, unchanged disk bytes and manual-selection ownership. +Each generic `handleResponsesInner` invocation in `src/server/responses/core.ts` captures a failover allowance before its first send: the initial pool size minus one when the initially selected key reference belongs to that pool, or the full initial pool size when it is outside the pool and shares that failover count across ordinary recovery, web-search/image/video bridge rounds and terminal continuations. Initialization precedes sidecar dispatch. Sidecars enforce this count only at their API-key rotation callback, retaining their own reset and same-target retry policies; the ordinary core physical-send budget is not newly applied to them, and OAuth/Anthropic account fallback remains available independently. Cooldown expiry and later pool growth do not refill it. This is a failover count, not a distinct-key guarantee or a new combo-wide budget; existing shared physical-send admission remains authoritative. Before cancelling a superseded response or choosing another key, the ordinary response path checks both limits. On refusal, `src/providers/key-failover.ts` accepts `allowRotation: false` to record the proven failed key's cooldown through the existing selection-identity fence, without selecting, persisting or announcing an unattempted replacement. The original failure stays available to normal response handling, including an already-started streaming continuation. `tests/server/server-key-failover-e2e.test.ts` covers expiry, an initially unpooled key, actual web-search/image bridge dispatch, shared continuation limits and earlier send-budget exhaustion; `tests/adapters/key-failover.test.ts` verifies health-only recording, unchanged disk bytes and manual-selection ownership. ### Routed service-tier capability diff --git a/tests/server/server-key-failover-e2e.test.ts b/tests/server/server-key-failover-e2e.test.ts index 25da3d831a..0792c6df75 100644 --- a/tests/server/server-key-failover-e2e.test.ts +++ b/tests/server/server-key-failover-e2e.test.ts @@ -43,6 +43,75 @@ afterEach(() => { }); describe("server 429 key failover (end-to-end)", () => { + test.each(["web-search", "image"] as const)( + "%s bridge bounds rotations after short cooldowns expire", + async bridge => { + const originalFetch = globalThis.fetch; + const endpoint = "https://sidecar-key429-fixture.invalid/v1/chat/completions"; + const seen: Array<{ authorization: string | null; tools: string[] }> = []; + let now = Date.now(); + let restoreClock: (() => void) | undefined; + let server: ReturnType | undefined; + const config = { + port: 0, hostname: "127.0.0.1", defaultProvider: "sidecar429", + providers: { + sidecar429: { + adapter: "openai-chat", authMode: "key", baseUrl: "https://sidecar-key429-fixture.invalid/v1", + apiKey: "synthetic-sidecar-a", apiKeyPool: [ + { id: "a", key: "synthetic-sidecar-a" }, { id: "b", key: "synthetic-sidecar-b" }, + ], + }, + // Arms image planning without OAuth or an actual image-service request. + ...(bridge === "image" ? { xai: { + adapter: "openai-chat", authMode: "key", baseUrl: "https://image-plan-fixture.invalid/v1", + apiKey: "synthetic-image-plan-token", + } } : {}), + }, + ...(bridge === "web-search" + ? { webSearchSidecar: { enabled: true, backend: "exa", exaApiKey: "synthetic-exa-plan-token" } } + : { images: { bridgeEnabled: true } }), + } as OcxConfig; + try { + saveConfig(config); + server = startServer(0); + const clock = spyOn(Date, "now").mockImplementation(() => now); + restoreClock = () => clock.mockRestore(); + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url !== endpoint) throw new Error("unexpected outbound request in sidecar 429 fixture"); + const body = JSON.parse(String(init?.body)) as { tools?: Array<{ function?: { name?: string } }> }; + seen.push({ authorization: new Headers(init?.headers).get("authorization"), + tools: (body.tools ?? []).map(tool => tool.function?.name ?? ""), + }); + // A broken rotation loop is bounded by six mocked sends, never by an infinite wait. + if (seen.length >= 6) throw new Error("sidecar 429 fixture send ceiling exceeded"); + now += 1_000; + return Response.json({ error: { message: `sidecar429-final-${seen.length}`, type: "rate_limit_error" } }, + { status: 429, headers: { "retry-after": "0" } }); + }) as typeof fetch; + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + signal: AbortSignal.timeout(10_000), + body: JSON.stringify({ model: "sidecar429/test", input: "Use the provided tool.", stream: true, + tools: [{ type: bridge === "web-search" ? "web_search" : "image_generation" }], + }), + }); + const text = await response.text(); + expect(seen.map(call => call.authorization)).toEqual(["Bearer synthetic-sidecar-a", "Bearer synthetic-sidecar-b"]); + // Verifies bridge activation rather than accidentally exercising the generic 429 loop. + for (const call of seen) expect(call.tools).toContain(bridge === "web-search" ? "web_search" : "image_gen"); + expect(response.status).toBe(429); + expect(text).toContain("Provider error 429"); + expect(text).toContain("sidecar429-final-2"); + expect(getKeyCooldownUntil("sidecar429", "b", now)).toBe(now + 1); + } finally { + globalThis.fetch = originalFetch; + restoreClock?.(); + await server?.stop(true); + } + }, 15_000, + ); + test.each(["exhausted", "continuation", "transient", "budget-exhausted", "unpooled"] as const)( "429 rotation stays request-bounded after every earlier cooldown expires (%s)", async mode => { From 855c435ee5253162589f0851c2391f47f1c1d21e Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:16:34 +0900 Subject: [PATCH 04/23] fix(responses): refund unused key recovery admission reservations --- src/server/responses/core.ts | 11 +++++--- structure/transports/responses.md | 2 +- tests/server/server-key-failover-e2e.test.ts | 29 ++++++++++++++------ 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e59a672a54..e515a2ad76 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -8107,14 +8107,17 @@ async function handleResponsesInner( if (!policy) return true; if (!Number.isInteger(policy.attempts) || policy.attempts <= 0) return false; if (remainingTransientSendBudget(policy.attempts) > 0) return true; - // Continuations currently draw base sends only. The initial recovery can use the existing - // auth-recovery reserve; checking this decision does not consume a permit or add allowance. + // Continuations currently draw base sends only. Probe the initial recovery reserve, + // then release its unused reservation: rebuildAndRefetch owns the actual send permit. if (continuation || !isRequestExecutionBudget(sendBudget)) return false; - return sendBudget.reserveDispatch({ + const decision = sendBudget.reserveDispatch({ sendClass: "auth-recovery", targetKey: `${route.providerName}|${route.modelId}|key-429`, countedExternally: true, - }).allowed; + }); + if (!decision.allowed) return false; + decision.permit.release(); + return true; }; // One immutable, body-safe outbound request per same-target sequence (URL, serialized body, diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 1bc68817cd..56cae780c2 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -138,7 +138,7 @@ send has already earned a throttle. ### Bounded API-key 429 rotation -Each generic `handleResponsesInner` invocation in `src/server/responses/core.ts` captures a failover allowance before its first send: the initial pool size minus one when the initially selected key reference belongs to that pool, or the full initial pool size when it is outside the pool and shares that failover count across ordinary recovery, web-search/image/video bridge rounds and terminal continuations. Initialization precedes sidecar dispatch. Sidecars enforce this count only at their API-key rotation callback, retaining their own reset and same-target retry policies; the ordinary core physical-send budget is not newly applied to them, and OAuth/Anthropic account fallback remains available independently. Cooldown expiry and later pool growth do not refill it. This is a failover count, not a distinct-key guarantee or a new combo-wide budget; existing shared physical-send admission remains authoritative. Before cancelling a superseded response or choosing another key, the ordinary response path checks both limits. On refusal, `src/providers/key-failover.ts` accepts `allowRotation: false` to record the proven failed key's cooldown through the existing selection-identity fence, without selecting, persisting or announcing an unattempted replacement. The original failure stays available to normal response handling, including an already-started streaming continuation. `tests/server/server-key-failover-e2e.test.ts` covers expiry, an initially unpooled key, actual web-search/image bridge dispatch, shared continuation limits and earlier send-budget exhaustion; `tests/adapters/key-failover.test.ts` verifies health-only recording, unchanged disk bytes and manual-selection ownership. +Each generic `handleResponsesInner` invocation in `src/server/responses/core.ts` captures a failover allowance before its first send: the initial pool size minus one when the initially selected key reference belongs to that pool, or the full initial pool size when it is outside the pool and shares that failover count across ordinary recovery, web-search/image/video bridge rounds and terminal continuations. Initialization precedes sidecar dispatch. Sidecars enforce this count only at their API-key rotation callback, retaining their own reset and same-target retry policies; the ordinary core physical-send budget is not newly applied to them, and OAuth/Anthropic account fallback remains available independently. Cooldown expiry and later pool growth do not refill it. This is a failover count, not a distinct-key guarantee or a new combo-wide budget; existing shared physical-send admission remains authoritative. Before cancelling a superseded response or choosing another key, the ordinary response path checks both limits. An initial recovery admission probe releases its unused dispatch reservation immediately; the actual rebuild owns the charged send permit, so merely checking the final reserve cannot spend it. On refusal, `src/providers/key-failover.ts` accepts `allowRotation: false` to record the proven failed key's cooldown through the existing selection-identity fence, without selecting, persisting or announcing an unattempted replacement. The original failure stays available to normal response handling, including an already-started streaming continuation. `tests/server/server-key-failover-e2e.test.ts` covers expiry, an initially unpooled key, actual web-search/image bridge dispatch, shared continuation limits and earlier send-budget exhaustion; `tests/adapters/key-failover.test.ts` verifies health-only recording, unchanged disk bytes and manual-selection ownership. ### Routed service-tier capability diff --git a/tests/server/server-key-failover-e2e.test.ts b/tests/server/server-key-failover-e2e.test.ts index 0792c6df75..c5abeb97ba 100644 --- a/tests/server/server-key-failover-e2e.test.ts +++ b/tests/server/server-key-failover-e2e.test.ts @@ -112,12 +112,13 @@ describe("server 429 key failover (end-to-end)", () => { }, 15_000, ); - test.each(["exhausted", "continuation", "transient", "budget-exhausted", "unpooled"] as const)( + test.each(["exhausted", "continuation", "transient", "budget-exhausted", "recovery-success", "unpooled"] as const)( "429 rotation stays request-bounded after every earlier cooldown expires (%s)", async mode => { const originalFetch = globalThis.fetch; const endpoint = "https://key429-fixture.invalid/v1/chat/completions"; - const expectedSends = mode === "exhausted" ? 2 : mode === "budget-exhausted" ? 4 : 3; + const usesFinalReserve = mode === "budget-exhausted" || mode === "recovery-success"; + const expectedSends = mode === "exhausted" ? 2 : usesFinalReserve ? 4 : 3; const seen: string[] = []; const cancelled: number[] = []; let now = Date.now(); @@ -129,10 +130,10 @@ describe("server 429 key failover (end-to-end)", () => { adapter: "openai-chat", baseUrl: "https://key429-fixture.invalid/v1", authMode: "key", apiKey: mode === "unpooled" ? "synthetic-key-outside" : "synthetic-key-a", apiKeyPool: [ { id: "a", key: "synthetic-key-a" }, { id: "b", key: "synthetic-key-b" }, - ...(mode === "budget-exhausted" ? [{ id: "c", key: "synthetic-key-c" }] : []), + ...(usesFinalReserve ? [{ id: "c", key: "synthetic-key-c" }] : []), ], ...(mode === "continuation" ? { terminalContinuationGuard: true } : {}), - ...(mode === "transient" || mode === "budget-exhausted" ? { transientRetryOn5xx: { enabled: true, attempts: 3 } } : {}), + ...(mode === "transient" || usesFinalReserve ? { transientRetryOn5xx: { enabled: true, attempts: 3 } } : {}), } }, } as OcxConfig; try { @@ -149,13 +150,19 @@ describe("server 429 key failover (end-to-end)", () => { const send = seen.length; // Retry-After: 0 means a 1ms cooldown. Every subsequent response arrives after it. now += 1_000; + if (mode === "recovery-success" && send === 4) { + return Response.json({ id: "chatcmpl-final-recovery", object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "recovered-with-final-send" }, finish_reason: "stop" }], + usage: { prompt_tokens: 2, completion_tokens: 2, total_tokens: 4 }, + }); + } if (mode === "continuation" && send === 2) { return Response.json({ id: "chatcmpl-plan", object: "chat.completion", choices: [{ index: 0, message: { role: "assistant", content: "I will edit the file now." }, finish_reason: "stop" }], usage: { prompt_tokens: 2, completion_tokens: 2, total_tokens: 4 }, }); } - const status = (mode === "transient" && send === 1) || (mode === "budget-exhausted" && send < 3) ? 503 : 429; + const status = (mode === "transient" && send === 1) || (usesFinalReserve && send < 3) ? 503 : 429; const text = JSON.stringify({ error: { message: `key429-final-${send}`, type: "rate_limit_error" } }); const bytes = new TextEncoder().encode(text); const body = new ReadableStream({ @@ -179,7 +186,7 @@ describe("server 429 key failover (end-to-end)", () => { expect(seen).toHaveLength(expectedSends); expect(seen).toEqual(mode === "unpooled" ? ["Bearer synthetic-key-outside", "Bearer synthetic-key-a", "Bearer synthetic-key-b"] - : mode === "budget-exhausted" + : usesFinalReserve ? ["Bearer synthetic-key-a", "Bearer synthetic-key-a", "Bearer synthetic-key-a", "Bearer synthetic-key-b"] : mode === "transient" ? ["Bearer synthetic-key-a", "Bearer synthetic-key-a", "Bearer synthetic-key-b"] @@ -187,7 +194,11 @@ describe("server 429 key failover (end-to-end)", () => { ? ["Bearer synthetic-key-a", "Bearer synthetic-key-b", "Bearer synthetic-key-b"] : ["Bearer synthetic-key-a", "Bearer synthetic-key-b"]); // Exhausted initial rotation must not cancel the final error it returns to the caller. - if (mode !== "continuation") { + if (mode === "recovery-success") { + // The admission probe must not spend the reserve before the real B dispatch. + expect(result.status).toBe(200); + expect(text).toContain("recovered-with-final-send"); + } else if (mode !== "continuation") { expect(result.status).toBe(429); expect(result.headers.get("retry-after")).toBe("0"); expect(text).toContain(`key429-final-${expectedSends}`); @@ -196,8 +207,8 @@ describe("server 429 key failover (end-to-end)", () => { // A continuation error is represented inside the already-started Responses result. expect(text).toContain("key429-final-3"); } - expect(cancelled).toContain(mode === "budget-exhausted" ? 3 : mode === "transient" ? 2 : 1); - expect(getKeyCooldownUntil("key429fixture", "b", now)).toBe(now + 1); + expect(cancelled).toContain(usesFinalReserve ? 3 : mode === "transient" ? 2 : 1); + expect(getKeyCooldownUntil("key429fixture", "b", now)).toBe(mode === "recovery-success" ? null : now + 1); } finally { globalThis.fetch = originalFetch; restoreClock?.(); From 5efbb309948272694420fdc8a71a835f59755551 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:33:30 +0900 Subject: [PATCH 05/23] refactor: split changed contracts to respect the file-size ratchet --- src/server/responses/combo-send-budget.ts | 86 ++++++++++++++++++++++ src/server/responses/core.ts | 87 +---------------------- structure/transports/responses.md | 2 +- 3 files changed, 88 insertions(+), 87 deletions(-) create mode 100644 src/server/responses/combo-send-budget.ts diff --git a/src/server/responses/combo-send-budget.ts b/src/server/responses/combo-send-budget.ts new file mode 100644 index 0000000000..5d5f641327 --- /dev/null +++ b/src/server/responses/combo-send-budget.ts @@ -0,0 +1,86 @@ +import { CODEX_TEXT_GUARDED_BUDGET_POLICY, createRequestExecutionBudget, type RequestExecutionBudget, type RequestExecutionBudgetPolicy } from "../../lib/request-execution-budget"; + +/** + * Sends one combo target may run on its own before the ladder moves on. A target is a whole + * request as far as its own provider is concerned, so this is the guarded profile's base + * allowance rather than a separate number to keep in sync. + */ +const COMBO_TARGET_BASE_SENDS = CODEX_TEXT_GUARDED_BUDGET_POLICY.baseSendAllowance; + +/** + * A combo's execution policy is DECLARED by the combo, not inherited from the single-target + * profile. + * + * `maxTargetTransitions: 1` and `maxAlternateTargetSends: 1` describe an account move, and + * applying them to a combo would refuse the second hop of a three-target combo -- which is why + * combo was left off `reserveDispatch` when the per-request split landed. The transitions a + * combo may make are exactly the targets it declares minus the one it starts on. What stays + * capped is the TOTAL: the first target's full ladder, one send for every further declared + * target, and the one shared final-recovery reserve. A one-target combo reduces to the guarded + * profile exactly, and a three-target combo whose every target fails hard reaches upstream six + * times instead of the twelve #4546 measured. + */ +export function comboExecutionBudgetPolicy(declaredTargets: number): RequestExecutionBudgetPolicy { + const targets = Math.max(1, Math.trunc(declaredTargets)); + const hops = targets - 1; + const reserve = CODEX_TEXT_GUARDED_BUDGET_POLICY.finalRecoveryAllowance; + const total = COMBO_TARGET_BASE_SENDS + hops + reserve; + return { + maxTotalModelSends: total, + baseSendAllowance: total - reserve, + finalRecoveryAllowance: reserve, + maxAlternateTargetSends: Math.max(1, hops), + maxTargetTransitions: Math.max(1, hops), + }; +} + +/** + * A budget scope that keeps its own recovery ledgers but spends the SAME request-wide counter. + * + * `used` is redefined as an accessor onto the parent because the factory reads it back off this + * object -- `remainingBaseSends` and the total check both do -- so a copied number would let a + * combo target run its ladder against a stale total, which is precisely the per-layer counting + * this work exists to remove. The reserve, alternate-target and transition ledgers stay + * per-scope on purpose: a combo target's account failover is its own recovery decision, while + * the request total still bounds every target together. + */ +export function deriveSendBudgetScope( + parent: RequestExecutionBudget, + policy: RequestExecutionBudgetPolicy, +): RequestExecutionBudget { + const scope = createRequestExecutionBudget(policy, parent.logicalRequestId); + Object.defineProperty(scope, "used", { + get: () => parent.used, + set: (value: number) => { parent.used = value; }, + enumerable: true, + configurable: true, + }); + return scope; +} + +/** + * The ladder one combo target may run, expressed as an allowance on the request-wide counter. + * + * `used + COMBO_TARGET_BASE_SENDS` gives this target its own ladder from wherever the request + * already stands, and the clamp holds back one send for each target still declared after it: a + * first target that 5xx-streaks must not eat the send the last declared target is entitled to. + * That guarantee is the difference between a per-target policy and a shared pool the first + * target drains. + */ +export function comboTargetSendBudget( + comboScope: RequestExecutionBudget, + targetsDeclaredAfterThisOne: number, +): RequestExecutionBudget { + const policy = comboScope.policy; + const heldForLaterTargets = Math.max(0, targetsDeclaredAfterThisOne); + const ceiling = Math.max(1, policy.maxTotalModelSends - heldForLaterTargets); + return deriveSendBudgetScope(comboScope, { + maxTotalModelSends: policy.maxTotalModelSends, + baseSendAllowance: Math.min(ceiling, comboScope.used + COMBO_TARGET_BASE_SENDS), + finalRecoveryAllowance: policy.finalRecoveryAllowance, + // Within one target the account-move shape is unchanged: three same-account sends plus one + // alternate is the recovery live traffic depends on, and a combo does not widen it. + maxAlternateTargetSends: CODEX_TEXT_GUARDED_BUDGET_POLICY.maxAlternateTargetSends, + maxTargetTransitions: CODEX_TEXT_GUARDED_BUDGET_POLICY.maxTargetTransitions, + }); +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e515a2ad76..9a11e088a7 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1,3 +1,4 @@ +import { comboExecutionBudgetPolicy, deriveSendBudgetScope, comboTargetSendBudget } from "./combo-send-budget"; import { capturePoolQuotaWriter } from "../../codex/account-store"; import { CODEX_POOL_REFRESH_INCOMPLETE_LOG_REASON } from "../../codex/pool-refresh-backoff"; import type { Server } from "bun"; @@ -235,7 +236,6 @@ import { isRequestExecutionBudget, CODEX_TEXT_GUARDED_BUDGET_POLICY, type RequestExecutionBudget, - type RequestExecutionBudgetPolicy, type SendClass, type SingleUseDispatchPermit, } from "../../lib/request-execution-budget"; @@ -3012,91 +3012,6 @@ async function applyFinalRouteRequestNormalization(args: { -/** - * Sends one combo target may run on its own before the ladder moves on. A target is a whole - * request as far as its own provider is concerned, so this is the guarded profile's base - * allowance rather than a separate number to keep in sync. - */ -const COMBO_TARGET_BASE_SENDS = CODEX_TEXT_GUARDED_BUDGET_POLICY.baseSendAllowance; - -/** - * A combo's execution policy is DECLARED by the combo, not inherited from the single-target - * profile. - * - * `maxTargetTransitions: 1` and `maxAlternateTargetSends: 1` describe an account move, and - * applying them to a combo would refuse the second hop of a three-target combo -- which is why - * combo was left off `reserveDispatch` when the per-request split landed. The transitions a - * combo may make are exactly the targets it declares minus the one it starts on. What stays - * capped is the TOTAL: the first target's full ladder, one send for every further declared - * target, and the one shared final-recovery reserve. A one-target combo reduces to the guarded - * profile exactly, and a three-target combo whose every target fails hard reaches upstream six - * times instead of the twelve #4546 measured. - */ -function comboExecutionBudgetPolicy(declaredTargets: number): RequestExecutionBudgetPolicy { - const targets = Math.max(1, Math.trunc(declaredTargets)); - const hops = targets - 1; - const reserve = CODEX_TEXT_GUARDED_BUDGET_POLICY.finalRecoveryAllowance; - const total = COMBO_TARGET_BASE_SENDS + hops + reserve; - return { - maxTotalModelSends: total, - baseSendAllowance: total - reserve, - finalRecoveryAllowance: reserve, - maxAlternateTargetSends: Math.max(1, hops), - maxTargetTransitions: Math.max(1, hops), - }; -} - -/** - * A budget scope that keeps its own recovery ledgers but spends the SAME request-wide counter. - * - * `used` is redefined as an accessor onto the parent because the factory reads it back off this - * object -- `remainingBaseSends` and the total check both do -- so a copied number would let a - * combo target run its ladder against a stale total, which is precisely the per-layer counting - * this work exists to remove. The reserve, alternate-target and transition ledgers stay - * per-scope on purpose: a combo target's account failover is its own recovery decision, while - * the request total still bounds every target together. - */ -function deriveSendBudgetScope( - parent: RequestExecutionBudget, - policy: RequestExecutionBudgetPolicy, -): RequestExecutionBudget { - const scope = createRequestExecutionBudget(policy, parent.logicalRequestId); - Object.defineProperty(scope, "used", { - get: () => parent.used, - set: (value: number) => { parent.used = value; }, - enumerable: true, - configurable: true, - }); - return scope; -} - -/** - * The ladder one combo target may run, expressed as an allowance on the request-wide counter. - * - * `used + COMBO_TARGET_BASE_SENDS` gives this target its own ladder from wherever the request - * already stands, and the clamp holds back one send for each target still declared after it: a - * first target that 5xx-streaks must not eat the send the last declared target is entitled to. - * That guarantee is the difference between a per-target policy and a shared pool the first - * target drains. - */ -function comboTargetSendBudget( - comboScope: RequestExecutionBudget, - targetsDeclaredAfterThisOne: number, -): RequestExecutionBudget { - const policy = comboScope.policy; - const heldForLaterTargets = Math.max(0, targetsDeclaredAfterThisOne); - const ceiling = Math.max(1, policy.maxTotalModelSends - heldForLaterTargets); - return deriveSendBudgetScope(comboScope, { - maxTotalModelSends: policy.maxTotalModelSends, - baseSendAllowance: Math.min(ceiling, comboScope.used + COMBO_TARGET_BASE_SENDS), - finalRecoveryAllowance: policy.finalRecoveryAllowance, - // Within one target the account-move shape is unchanged: three same-account sends plus one - // alternate is the recovery live traffic depends on, and a combo does not widen it. - maxAlternateTargetSends: CODEX_TEXT_GUARDED_BUDGET_POLICY.maxAlternateTargetSends, - maxTargetTransitions: CODEX_TEXT_GUARDED_BUDGET_POLICY.maxTargetTransitions, - }); -} - export async function handleComboResponses( req: Request, rawBody: unknown, diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 56cae780c2..a5fa3503f0 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -138,7 +138,7 @@ send has already earned a throttle. ### Bounded API-key 429 rotation -Each generic `handleResponsesInner` invocation in `src/server/responses/core.ts` captures a failover allowance before its first send: the initial pool size minus one when the initially selected key reference belongs to that pool, or the full initial pool size when it is outside the pool and shares that failover count across ordinary recovery, web-search/image/video bridge rounds and terminal continuations. Initialization precedes sidecar dispatch. Sidecars enforce this count only at their API-key rotation callback, retaining their own reset and same-target retry policies; the ordinary core physical-send budget is not newly applied to them, and OAuth/Anthropic account fallback remains available independently. Cooldown expiry and later pool growth do not refill it. This is a failover count, not a distinct-key guarantee or a new combo-wide budget; existing shared physical-send admission remains authoritative. Before cancelling a superseded response or choosing another key, the ordinary response path checks both limits. An initial recovery admission probe releases its unused dispatch reservation immediately; the actual rebuild owns the charged send permit, so merely checking the final reserve cannot spend it. On refusal, `src/providers/key-failover.ts` accepts `allowRotation: false` to record the proven failed key's cooldown through the existing selection-identity fence, without selecting, persisting or announcing an unattempted replacement. The original failure stays available to normal response handling, including an already-started streaming continuation. `tests/server/server-key-failover-e2e.test.ts` covers expiry, an initially unpooled key, actual web-search/image bridge dispatch, shared continuation limits and earlier send-budget exhaustion; `tests/adapters/key-failover.test.ts` verifies health-only recording, unchanged disk bytes and manual-selection ownership. +Each generic `handleResponsesInner` invocation in `src/server/responses/core.ts` captures a failover allowance before its first send: the initial pool size minus one when the initially selected key reference belongs to that pool, or the full initial pool size when it is outside the pool and shares that failover count across ordinary recovery, web-search/image/video bridge rounds and terminal continuations. Initialization precedes sidecar dispatch. Sidecars enforce this count only at their API-key rotation callback, retaining their own reset and same-target retry policies; the ordinary core physical-send budget is not newly applied to them, and OAuth/Anthropic account fallback remains available independently. Cooldown expiry and later pool growth do not refill it. This is a failover count, not a distinct-key guarantee or a new combo-wide budget; existing shared physical-send admission remains authoritative. Before cancelling a superseded response or choosing another key, the ordinary response path checks both limits. An initial recovery admission probe releases its unused dispatch reservation immediately; the actual rebuild owns the charged send permit, so merely checking the final reserve cannot spend it. Combo policy and child-scope construction live in `src/server/responses/combo-send-budget.ts`; extracting them leaves their shared-counter and reserve semantics unchanged. On refusal, `src/providers/key-failover.ts` accepts `allowRotation: false` to record the proven failed key's cooldown through the existing selection-identity fence, without selecting, persisting or announcing an unattempted replacement. The original failure stays available to normal response handling, including an already-started streaming continuation. `tests/server/server-key-failover-e2e.test.ts` covers expiry, an initially unpooled key, actual web-search/image bridge dispatch, shared continuation limits and earlier send-budget exhaustion; `tests/adapters/key-failover.test.ts` verifies health-only recording, unchanged disk bytes and manual-selection ownership. ### Routed service-tier capability From 613cea47a8386e7d4bc0a631eef660699354de86 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:44:28 +0900 Subject: [PATCH 06/23] fix(responses): share reservation accounting across combo scopes --- src/lib/request-execution-budget.ts | 45 ++++++++----- src/server/responses/combo-send-budget.ts | 18 ++---- structure/adapters/registry.md | 2 +- structure/catalog.md | 2 +- structure/clients/claude-desktop.md | 2 +- structure/clients/integrations.md | 2 +- structure/data-planes/images.md | 2 +- structure/data-planes/inbound-compat.md | 2 +- structure/gui-and-management-api.md | 2 +- structure/ops/docs-and-release.md | 2 +- structure/ops/service-and-sidecars.md | 2 +- structure/overview.md | 2 +- structure/providers/xai-grok.md | 2 +- structure/runtime.md | 2 +- structure/subagents.md | 2 +- structure/transports/byte-accounting.md | 2 +- structure/transports/inventory.md | 2 +- structure/transports/responses.md | 2 +- structure/transports/streaming-health.md | 2 +- tests/lib/execution-budget-permits.test.ts | 63 +++++++++++++++++++ .../responses-send-budget-counts.test.ts | 21 +++---- 21 files changed, 120 insertions(+), 61 deletions(-) diff --git a/src/lib/request-execution-budget.ts b/src/lib/request-execution-budget.ts index 80654b0a94..1891f4fcf9 100644 --- a/src/lib/request-execution-budget.ts +++ b/src/lib/request-execution-budget.ts @@ -109,6 +109,8 @@ export interface RequestExecutionBudget extends TransientSendBudget { readonly logicalRequestId: string; readonly policyVersion: string; readonly policy: RequestExecutionBudgetPolicy; + /** Share charged/pending sends while keeping recovery and target ledgers local. */ + deriveScope(policy: RequestExecutionBudgetPolicy): RequestExecutionBudget; reserveDispatch(intent: DispatchIntent): DispatchDecision; /** * Sends still available from the base allowance, capped by a layer's own maximum. @@ -138,44 +140,53 @@ export function createRequestExecutionBudget( policy: RequestExecutionBudgetPolicy = CODEX_TEXT_GUARDED_BUDGET_POLICY, logicalRequestId?: string, ): RequestExecutionBudget { - let spent = 0; + return createBudgetScope(policy, + logicalRequestId ?? `lr-${Date.now().toString(36)}-${(logicalRequestSeq += 1).toString(36)}`, + { spent: 0, pendingExternalSends: 0 }); +} + +function createBudgetScope( + policy: RequestExecutionBudgetPolicy, + logicalRequestId: string, + ledger: { spent: number; pendingExternalSends: number }, +): RequestExecutionBudget { // Reservations whose physical send is reported by a retry helper rather than by the permit. // They are already charged; the reporter's first send settles one instead of charging again. - let pendingExternalSends = 0; let reserveSpent = false; let alternateTargetSends = 0; let targetTransitions = 0; let lastTargetKey: string | undefined; const budget: RequestExecutionBudget = { - get used(): number { return spent; }, + get used(): number { return ledger.spent; }, set used(next: number) { // The retry helpers report their real send count by assigning through this field. A // reservation taken with `countedExternally` has already booked one of those sends, so // the report settles the pending booking first and only the surplus is charged. - const delta = next - spent; + const delta = next - ledger.spent; if (delta <= 0) { - spent = Math.max(0, next); + ledger.spent = Math.max(0, next); return; } - const settled = Math.min(delta, pendingExternalSends); - pendingExternalSends -= settled; - spent += delta - settled; + const settled = Math.min(delta, ledger.pendingExternalSends); + ledger.pendingExternalSends -= settled; + ledger.spent += delta - settled; }, - logicalRequestId: logicalRequestId ?? `lr-${Date.now().toString(36)}-${(logicalRequestSeq += 1).toString(36)}`, + logicalRequestId, policyVersion: REQUEST_BUDGET_POLICY_VERSION, policy, + deriveScope: (scopePolicy) => createBudgetScope(scopePolicy, logicalRequestId, ledger), get reserveSpent() { return reserveSpent; }, get alternateTargetSends() { return alternateTargetSends; }, get targetTransitions() { return targetTransitions; }, get lastTargetKey() { return lastTargetKey; }, remainingBaseSends(cap: number): number { const capped = Number.isFinite(cap) ? Math.trunc(cap) : 0; - return Math.max(0, Math.min(capped, policy.baseSendAllowance - spent)); + return Math.max(0, Math.min(capped, policy.baseSendAllowance - ledger.spent)); }, reserveDispatch(intent: DispatchIntent): DispatchDecision { if (intent.replaySafe === false) return { allowed: false, reason: "not-replay-safe" }; - if (spent >= policy.maxTotalModelSends) return { allowed: false, reason: "total-exhausted" }; + if (ledger.spent >= policy.maxTotalModelSends) return { allowed: false, reason: "total-exhausted" }; const changesTarget = lastTargetKey !== undefined && lastTargetKey !== intent.targetKey; const isAlternateTarget = changesTarget || intent.sendClass === "account-failover" @@ -190,7 +201,7 @@ export function createRequestExecutionBudget( // The base allowance is spent first. Only once it is gone does a recovery class reach // for the single shared reserve -- an account move and a validated rebuild cannot each // take one. - const drawsReserve = policy.baseSendAllowance - spent <= 0; + const drawsReserve = policy.baseSendAllowance - ledger.spent <= 0; if (drawsReserve) { if (!RESERVE_FUNDED_CLASSES.has(intent.sendClass)) { return { allowed: false, reason: "base-allowance-exhausted" }; @@ -205,8 +216,8 @@ export function createRequestExecutionBudget( // one remaining send admitted two physical sends, which is the per-request multiplication // this budget exists to stop. Everything is booked now; `release()` is the way back. const previousTargetKey = lastTargetKey; - spent += 1; - if (intent.countedExternally === true) pendingExternalSends += 1; + ledger.spent += 1; + if (intent.countedExternally === true) ledger.pendingExternalSends += 1; if (drawsReserve) reserveSpent = true; if (isAlternateTarget) alternateTargetSends += 1; if (changesTarget) targetTransitions += 1; @@ -228,10 +239,10 @@ export function createRequestExecutionBudget( // An externally counted reservation the reporter already settled paid for a send // that physically happened. Refunding it would hand the request a free send back. if (intent.countedExternally === true) { - if (pendingExternalSends === 0) return; - pendingExternalSends -= 1; + if (ledger.pendingExternalSends === 0) return; + ledger.pendingExternalSends -= 1; } - spent -= 1; + ledger.spent -= 1; if (drawsReserve) reserveSpent = false; if (isAlternateTarget) alternateTargetSends -= 1; if (changesTarget) targetTransitions -= 1; diff --git a/src/server/responses/combo-send-budget.ts b/src/server/responses/combo-send-budget.ts index 5d5f641327..e9051c4a0d 100644 --- a/src/server/responses/combo-send-budget.ts +++ b/src/server/responses/combo-send-budget.ts @@ -1,4 +1,4 @@ -import { CODEX_TEXT_GUARDED_BUDGET_POLICY, createRequestExecutionBudget, type RequestExecutionBudget, type RequestExecutionBudgetPolicy } from "../../lib/request-execution-budget"; +import { CODEX_TEXT_GUARDED_BUDGET_POLICY, type RequestExecutionBudget, type RequestExecutionBudgetPolicy } from "../../lib/request-execution-budget"; /** * Sends one combo target may run on its own before the ladder moves on. A target is a whole @@ -37,10 +37,9 @@ export function comboExecutionBudgetPolicy(declaredTargets: number): RequestExec /** * A budget scope that keeps its own recovery ledgers but spends the SAME request-wide counter. * - * `used` is redefined as an accessor onto the parent because the factory reads it back off this - * object -- `remainingBaseSends` and the total check both do -- so a copied number would let a - * combo target run its ladder against a stale total, which is precisely the per-layer counting - * this work exists to remove. The reserve, alternate-target and transition ledgers stay + * The factory shares both charged sends and pending external reports. Forwarding `used` alone + * cannot share reservation checks held in the factory closure, and would let each target + * refill its allowance. The reserve, alternate-target and transition ledgers stay * per-scope on purpose: a combo target's account failover is its own recovery decision, while * the request total still bounds every target together. */ @@ -48,14 +47,7 @@ export function deriveSendBudgetScope( parent: RequestExecutionBudget, policy: RequestExecutionBudgetPolicy, ): RequestExecutionBudget { - const scope = createRequestExecutionBudget(policy, parent.logicalRequestId); - Object.defineProperty(scope, "used", { - get: () => parent.used, - set: (value: number) => { parent.used = value; }, - enumerable: true, - configurable: true, - }); - return scope; + return parent.deriveScope(policy); } /** diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 852c92f591..f0a7a168f1 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -1,7 +1,7 @@ # Adapter Registry Authority The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. diff --git a/structure/catalog.md b/structure/catalog.md index 93c68c075c..88e90d016f 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -1,7 +1,7 @@ # Model Catalog The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index ffe0dabd31..9180f1e2f1 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -1,7 +1,7 @@ # Claude Desktop Integration The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. Codex-native model discovery follows the [shared retirement policy](../catalog.md#shared-catalog). That projection does not migrate existing user-selected Desktop configuration or usage history. diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index eb4ace5975..40f22f9046 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -1,6 +1,6 @@ # Client Integrations -Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. +Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. The client-integration subsystem writes one generated OpenCodex provider contribution into a third-party client's existing config without taking ownership of the rest of that file. Its core diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index c8daaa7711..50f4732025 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -1,7 +1,7 @@ # Images Data Plane The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. Hosted Responses image-tool eligibility uses the shared compatibility policy without a Codex Spark exception; standalone Images retain the separate relay contract below. See diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 3647be046b..9a1d325452 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -1,7 +1,7 @@ # Inbound Compatibility Surfaces The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. ## Standalone file transcription diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 6ea83cf7c9..7152a6d9b6 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,7 +1,7 @@ # GUI And Management API The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. ## Dashboard serving diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index f4474c6419..cb484b73d0 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -1,7 +1,7 @@ # Docs And Release The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 0b9712d656..77007b5a7d 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -1,7 +1,7 @@ # Background Service And Sidecars The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. Service startup and restore use the [catalog retirement policy](../catalog.md#shared-catalog); retirement does not itself change service registration or user-selected model configuration. diff --git a/structure/overview.md b/structure/overview.md index 0151115adc..bee5fe9b63 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -1,7 +1,7 @@ # Overview The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 0f32e50de7..2751675e99 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -1,7 +1,7 @@ # xAI Grok Provider The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. Codex-native retirement is scoped to OpenAI catalog/quota evidence. Shared Responses handling retains xAI provider behavior; see diff --git a/structure/runtime.md b/structure/runtime.md index 67d36ab93e..906ee94a43 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,7 +1,7 @@ # Runtime The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. Chat request serialization owns the destination-scoped [OpenCode Go instruction ordering](providers/chat-compat.md#opencode-go-chronological-instructions); diff --git a/structure/subagents.md b/structure/subagents.md index 9aac5a3c22..c4f8ff7ac3 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -7,7 +7,7 @@ only explicit true on Responses ingress to the final canonical ChatGPT forward route activates it. A default top-level collaboration catalog is required. The compiler preserves caller objects, aliases the namespace and three message functions, and removes only their true encryption marker. -Declaration/reference collisions refuse the whole rewrite without changing the request. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). +Declaration/reference collisions refuse the whole rewrite without changing the request. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. `src/adapters/openai-responses.ts` returns request-local alias capabilities. The Responses core refreshes them after every request rebuild and restores JSON, SSE and WebSocket identities after diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 21a5a3aeb3..58c38843be 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -2,7 +2,7 @@ How opencodex measures request and stream bytes without allocating copies solely to count them. These contracts are shared by request parsing, SSE rewriting, the provider adapters and -the translator budget, which is why so many documents link here rather than restating them. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). +the translator budget, which is why so many documents link here rather than restating them. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. ## Request-copy accounting diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index d50d957914..f8487adf56 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -1,7 +1,7 @@ # Transport Inventory The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. The Chat adapter's [OpenCode Go instruction ordering](../providers/chat-compat.md#opencode-go-chronological-instructions) changes translated message placement only; endpoint selection and transport stay with their existing owners. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index a5fa3503f0..3504698635 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -138,7 +138,7 @@ send has already earned a throttle. ### Bounded API-key 429 rotation -Each generic `handleResponsesInner` invocation in `src/server/responses/core.ts` captures a failover allowance before its first send: the initial pool size minus one when the initially selected key reference belongs to that pool, or the full initial pool size when it is outside the pool and shares that failover count across ordinary recovery, web-search/image/video bridge rounds and terminal continuations. Initialization precedes sidecar dispatch. Sidecars enforce this count only at their API-key rotation callback, retaining their own reset and same-target retry policies; the ordinary core physical-send budget is not newly applied to them, and OAuth/Anthropic account fallback remains available independently. Cooldown expiry and later pool growth do not refill it. This is a failover count, not a distinct-key guarantee or a new combo-wide budget; existing shared physical-send admission remains authoritative. Before cancelling a superseded response or choosing another key, the ordinary response path checks both limits. An initial recovery admission probe releases its unused dispatch reservation immediately; the actual rebuild owns the charged send permit, so merely checking the final reserve cannot spend it. Combo policy and child-scope construction live in `src/server/responses/combo-send-budget.ts`; extracting them leaves their shared-counter and reserve semantics unchanged. On refusal, `src/providers/key-failover.ts` accepts `allowRotation: false` to record the proven failed key's cooldown through the existing selection-identity fence, without selecting, persisting or announcing an unattempted replacement. The original failure stays available to normal response handling, including an already-started streaming continuation. `tests/server/server-key-failover-e2e.test.ts` covers expiry, an initially unpooled key, actual web-search/image bridge dispatch, shared continuation limits and earlier send-budget exhaustion; `tests/adapters/key-failover.test.ts` verifies health-only recording, unchanged disk bytes and manual-selection ownership. +Each generic `handleResponsesInner` invocation in `src/server/responses/core.ts` captures a failover allowance before its first send: the initial pool size minus one when the initially selected key reference belongs to that pool, or the full initial pool size when it is outside the pool and shares that failover count across ordinary recovery, web-search/image/video bridge rounds and terminal continuations. Initialization precedes sidecar dispatch. Sidecars enforce this count only at their API-key rotation callback, retaining their own reset and same-target retry policies; the ordinary core physical-send budget is not newly applied to them, and OAuth/Anthropic account fallback remains available independently. Cooldown expiry and later pool growth do not refill it. This is a failover count, not a distinct-key guarantee or a new combo-wide budget; existing shared physical-send admission remains authoritative. Before cancelling a superseded response or choosing another key, the ordinary response path checks both limits. An initial recovery admission probe releases its unused dispatch reservation immediately; the actual rebuild owns the charged send permit, so merely checking the final reserve cannot spend it. Combo policy and child-scope construction live in `src/server/responses/combo-send-budget.ts`; their scopes derive through `src/lib/request-execution-budget.ts`, sharing both charged reservations and pending external-send settlement. Remaining-allowance checks and unused-permit refunds use that same ledger, while recovery and target-transition ledgers remain scope-local. Three targets with only 5xx failures consume five base sends and retain the separate final-recovery allowance. `tests/lib/execution-budget-permits.test.ts` checks interleaved scopes and refunds; `tests/responses/responses-send-budget-counts.test.ts` counts physical sends and verifies that every target is reached. On refusal, `src/providers/key-failover.ts` accepts `allowRotation: false` to record the proven failed key's cooldown through the existing selection-identity fence, without selecting, persisting or announcing an unattempted replacement. The original failure stays available to normal response handling, including an already-started streaming continuation. `tests/server/server-key-failover-e2e.test.ts` covers expiry, an initially unpooled key, actual web-search/image bridge dispatch, shared continuation limits and earlier send-budget exhaustion; `tests/adapters/key-failover.test.ts` verifies health-only recording, unchanged disk bytes and manual-selection ownership. ### Routed service-tier capability diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index fb259b798d..b5d2f735ff 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -1,7 +1,7 @@ # Streaming Health And WebSocket The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. Codex WebSocket quota-family normalization remains generic; retired-model evidence is filtered by the [OpenAI quota owner](../providers/openai-tiers.md#public-provider-contract), not by diff --git a/tests/lib/execution-budget-permits.test.ts b/tests/lib/execution-budget-permits.test.ts index 2276c921ae..4b2a93475a 100644 --- a/tests/lib/execution-budget-permits.test.ts +++ b/tests/lib/execution-budget-permits.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { comboExecutionBudgetPolicy, comboTargetSendBudget, deriveSendBudgetScope } from "../../src/server/responses/combo-send-budget"; import { CODEX_TEXT_GUARDED_BUDGET_POLICY, createRequestExecutionBudget, @@ -125,6 +126,68 @@ describe("atomic dispatch permits", () => { }); }); +describe("combo scopes share reservation accounting", () => { + test("a child sees the last send reserved by its parent before dispatch", () => { + const parent = createRequestExecutionBudget(ONE_SEND_LEFT); + const child = deriveSendBudgetScope(parent, ONE_SEND_LEFT); + const reserved = parent.reserveDispatch({ sendClass: "initial", targetKey: "parent" }); + expect(reserved.allowed).toBe(true); + expect(child.remainingBaseSends(5)).toBe(0); + expect(child.reserveDispatch({ sendClass: "initial", targetKey: "child" }).allowed).toBe(false); + }); + + test("child release refunds the shared booking and external reports settle it once", () => { + const parent = createRequestExecutionBudget(); + const child = deriveSendBudgetScope(parent, parent.policy); + const first = child.reserveDispatch({ sendClass: "initial", targetKey: "child", countedExternally: true }); + if (!first.allowed) throw new Error("expected first permit"); + expect(parent.used).toBe(1); + first.permit.release(); + expect(parent.used).toBe(0); + const sent = child.reserveDispatch({ sendClass: "initial", targetKey: "child", countedExternally: true }); + if (!sent.allowed) throw new Error("expected second permit"); + parent.used += 1; + expect(child.used).toBe(1); + sent.permit.release(); + expect(parent.used).toBe(1); + child.used += 1; + expect(parent.used).toBe(2); + }); + + test("three failed targets cannot each refill the request-wide ladder", () => { + const parent = createRequestExecutionBudget(); + const combo = deriveSendBudgetScope(parent, comboExecutionBudgetPolicy(3)); + let sends = 0; + for (let target = 0; target < 3; target++) { + const scope = comboTargetSendBudget(combo, 2 - target); + for (let attempt = 0; attempt < 8; attempt++) { + const decision = scope.reserveDispatch({ sendClass: attempt === 0 ? "initial" : "auth-recovery", targetKey: `target-${target}` }); + if (!decision.allowed) break; + expect(decision.permit.use()).toBe(true); + sends++; + } + } + expect(sends).toBe(combo.policy.maxTotalModelSends); + expect(parent.used).toBe(sends); + expect(combo.remainingBaseSends(100)).toBe(0); + }); + + test("target transition ledgers remain local to each scope", () => { + const parent = createRequestExecutionBudget(comboExecutionBudgetPolicy(4)); + for (const name of ["a", "b"]) { + const child = deriveSendBudgetScope(parent, parent.policy); + for (const key of [name, `${name}-alternate`]) { + const decision = child.reserveDispatch({ sendClass: "auth-recovery", targetKey: key }); + expect(decision.allowed).toBe(true); + if (decision.allowed) decision.permit.use(); + } + expect(child.targetTransitions).toBe(1); + } + expect(parent.targetTransitions).toBe(0); + expect(parent.used).toBe(4); + }); +}); + describe("layer caps intersect the shared budget", () => { test("a roster credential hop walks within the shared total; a cross-pool move does not", () => { // The two classes answer different questions and must not be conflated. A credential diff --git a/tests/responses/responses-send-budget-counts.test.ts b/tests/responses/responses-send-budget-counts.test.ts index 78f5a42856..733efc9aef 100644 --- a/tests/responses/responses-send-budget-counts.test.ts +++ b/tests/responses/responses-send-budget-counts.test.ts @@ -120,7 +120,7 @@ describe("upstream sends per logical request", () => { expect(sendCounts(logCtx)).toEqual([3]); }); - test("a three-target combo fan-out gives every declared target a send and totals six", async () => { + test("a three-target 5xx combo reaches every target within five base sends", async () => { const upstream = alwaysFailing(502, "upstream busy"); const logCtx: RequestLogContext = { model: "", provider: "" }; @@ -133,10 +133,8 @@ describe("upstream sends per logical request", () => { // the later targets to zero. The first target runs its own ladder, each later target draws // what is left, and the clamp holds back one send for every target still declared, so the // last target is still reached. - // Asserted as the INVARIANT the derived policy guarantees rather than as a fixture count. - // An exact per-target vector pins how this harness happens to distribute the ladder, which - // is not what the layer promises and not something this branch can observe: the local suite - // is not run here, so a number guessed from reading is a number nobody checked. + // Count physical sends through the adapter; unit-only shared-counter assertions are not + // enough to show that retries and later targets consume the same reservation ledger. const bearers = upstream.authorizations; // Every declared target is still reached. Starving the last target is the failure mode that // sharing one counter WITHOUT a per-target policy produces. @@ -147,15 +145,10 @@ describe("upstream sends per logical request", () => { // Bounded by the derived total: the first target's ladder, one send per further declared // target, and the single shared final-recovery reserve. The measured regression in #4546 was // twelve, four per target, because each child drew a fresh full allowance. - // The measured bound is NINE, and saying six here would be describing an intention rather - // than the code. #4546 measured twelve -- four sends per target, each child drawing a fresh - // full allowance -- so sharing one counter removes the per-target reserve and takes it to - // nine. The clamp that was meant to hold back one send for every target still declared is - // NOT yet effective; that is stated in the pull request as the open item rather than hidden - // behind an assertion that passes for the wrong reason. - expect(bearers.length).toBeLessThanOrEqual(9); - expect(bearers.length).toBeLessThan(12); - expect(bearers.length).toBeGreaterThanOrEqual(3); + // A plain 5xx streak has no qualifying final recovery, so it uses only the five base + // sends. The sixth is reserved for an explicitly admitted recovery, covered by permits. + expect(bearers).toHaveLength(5); + expect(totalSends(logCtx)).toBe(5); }); // REMOVED: "a 401 before the 5xx streak spends one of the same three sends". From 90f6c2b39bda6883637b3598a67cc9048424e1d8 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:03:14 +0900 Subject: [PATCH 07/23] fix(responses): settle prepaid combo sends and preserve later targets --- src/lib/request-execution-budget.ts | 60 +++++++++++++------ src/server/responses/combo-send-budget.ts | 12 ++-- src/server/responses/core.ts | 2 +- structure/transports/responses.md | 2 +- .../adapter-inner-send-budget.test.ts | 18 ++++++ tests/lib/execution-budget-permits.test.ts | 36 +++++++++++ .../responses-send-budget-counts.test.ts | 10 ++-- 7 files changed, 109 insertions(+), 31 deletions(-) diff --git a/src/lib/request-execution-budget.ts b/src/lib/request-execution-budget.ts index 1891f4fcf9..823c0dfeac 100644 --- a/src/lib/request-execution-budget.ts +++ b/src/lib/request-execution-budget.ts @@ -109,8 +109,8 @@ export interface RequestExecutionBudget extends TransientSendBudget { readonly logicalRequestId: string; readonly policyVersion: string; readonly policy: RequestExecutionBudgetPolicy; - /** Share charged/pending sends while keeping recovery and target ledgers local. */ - deriveScope(policy: RequestExecutionBudgetPolicy): RequestExecutionBudget; + /** Share charged/pending sends; an exact prepaid hop may fund the child's initial send. */ + deriveScope(policy: RequestExecutionBudgetPolicy, prepaid?: SingleUseDispatchPermit): RequestExecutionBudget; reserveDispatch(intent: DispatchIntent): DispatchDecision; /** * Sends still available from the base allowance, capped by a layer's own maximum. @@ -135,6 +135,8 @@ const RESERVE_FUNDED_CLASSES: ReadonlySet = new Set([ ]); let logicalRequestSeq = 0; +interface SharedSendLedger { spent: number; pendingExternalSends: Set; } +const permitBookings = new WeakMap(); export function createRequestExecutionBudget( policy: RequestExecutionBudgetPolicy = CODEX_TEXT_GUARDED_BUDGET_POLICY, @@ -142,13 +144,14 @@ export function createRequestExecutionBudget( ): RequestExecutionBudget { return createBudgetScope(policy, logicalRequestId ?? `lr-${Date.now().toString(36)}-${(logicalRequestSeq += 1).toString(36)}`, - { spent: 0, pendingExternalSends: 0 }); + { spent: 0, pendingExternalSends: new Set() }); } function createBudgetScope( policy: RequestExecutionBudgetPolicy, logicalRequestId: string, - ledger: { spent: number; pendingExternalSends: number }, + ledger: SharedSendLedger, + prepaidBooking?: object, ): RequestExecutionBudget { // Reservations whose physical send is reported by a retry helper rather than by the permit. // They are already charged; the reporter's first send settles one instead of charging again. @@ -168,25 +171,38 @@ function createBudgetScope( ledger.spent = Math.max(0, next); return; } - const settled = Math.min(delta, ledger.pendingExternalSends); - ledger.pendingExternalSends -= settled; + const settled = Math.min(delta, ledger.pendingExternalSends.size); + for (let i = 0; i < settled; i++) { + const ticket = ledger.pendingExternalSends.values().next().value; + if (ticket) ledger.pendingExternalSends.delete(ticket); + } ledger.spent += delta - settled; }, logicalRequestId, policyVersion: REQUEST_BUDGET_POLICY_VERSION, policy, - deriveScope: (scopePolicy) => createBudgetScope(scopePolicy, logicalRequestId, ledger), + deriveScope(scopePolicy, prepaid) { + const booking = prepaid && permitBookings.get(prepaid); + return createBudgetScope(scopePolicy, logicalRequestId, ledger, + booking?.ledger === ledger ? booking.ticket : undefined); + }, get reserveSpent() { return reserveSpent; }, get alternateTargetSends() { return alternateTargetSends; }, get targetTransitions() { return targetTransitions; }, get lastTargetKey() { return lastTargetKey; }, remainingBaseSends(cap: number): number { const capped = Number.isFinite(cap) ? Math.trunc(cap) : 0; - return Math.max(0, Math.min(capped, policy.baseSendAllowance - ledger.spent)); + const prepaid = prepaidBooking && ledger.pendingExternalSends.has(prepaidBooking) ? 1 : 0; + return Math.max(0, Math.min(capped, policy.baseSendAllowance - ledger.spent + prepaid)); }, reserveDispatch(intent: DispatchIntent): DispatchDecision { if (intent.replaySafe === false) return { allowed: false, reason: "not-replay-safe" }; - if (ledger.spent >= policy.maxTotalModelSends) return { allowed: false, reason: "total-exhausted" }; + // Inner adapters call even their first physical attempt "transient". The explicit hop + // permit identifies the prepaid send; the recovery class does not identify its owner. + const inherited = prepaidBooking + && ledger.pendingExternalSends.has(prepaidBooking) ? prepaidBooking : undefined; + const spentBeforeSend = ledger.spent - (inherited ? 1 : 0); + if (spentBeforeSend >= policy.maxTotalModelSends) return { allowed: false, reason: "total-exhausted" }; const changesTarget = lastTargetKey !== undefined && lastTargetKey !== intent.targetKey; const isAlternateTarget = changesTarget || intent.sendClass === "account-failover" @@ -201,7 +217,7 @@ function createBudgetScope( // The base allowance is spent first. Only once it is gone does a recovery class reach // for the single shared reserve -- an account move and a validated rebuild cannot each // take one. - const drawsReserve = policy.baseSendAllowance - ledger.spent <= 0; + const drawsReserve = policy.baseSendAllowance - spentBeforeSend <= 0; if (drawsReserve) { if (!RESERVE_FUNDED_CLASSES.has(intent.sendClass)) { return { allowed: false, reason: "base-allowance-exhausted" }; @@ -216,17 +232,19 @@ function createBudgetScope( // one remaining send admitted two physical sends, which is the per-request multiplication // this budget exists to stop. Everything is booked now; `release()` is the way back. const previousTargetKey = lastTargetKey; - ledger.spent += 1; - if (intent.countedExternally === true) ledger.pendingExternalSends += 1; + const ticket = {}; + if (inherited) { + prepaidBooking = undefined; + ledger.pendingExternalSends.delete(inherited); + } else ledger.spent += 1; + if (intent.countedExternally === true) ledger.pendingExternalSends.add(ticket); if (drawsReserve) reserveSpent = true; if (isAlternateTarget) alternateTargetSends += 1; if (changesTarget) targetTransitions += 1; lastTargetKey = intent.targetKey; let settled: "open" | "used" | "released" = "open"; - return { - allowed: true, - permit: { + const permit: SingleUseDispatchPermit = { sendClass: intent.sendClass, use(): boolean { if (settled !== "open") return false; @@ -239,17 +257,21 @@ function createBudgetScope( // An externally counted reservation the reporter already settled paid for a send // that physically happened. Refunding it would hand the request a free send back. if (intent.countedExternally === true) { - if (ledger.pendingExternalSends === 0) return; - ledger.pendingExternalSends -= 1; + if (!ledger.pendingExternalSends.has(ticket)) return; + ledger.pendingExternalSends.delete(ticket); } - ledger.spent -= 1; + if (inherited) { + ledger.pendingExternalSends.add(inherited); + prepaidBooking = inherited; + } else ledger.spent -= 1; if (drawsReserve) reserveSpent = false; if (isAlternateTarget) alternateTargetSends -= 1; if (changesTarget) targetTransitions -= 1; lastTargetKey = previousTargetKey; }, - }, }; + if (intent.countedExternally === true) permitBookings.set(permit, { ledger, ticket }); + return { allowed: true, permit }; }, }; return budget; diff --git a/src/server/responses/combo-send-budget.ts b/src/server/responses/combo-send-budget.ts index e9051c4a0d..6217de3617 100644 --- a/src/server/responses/combo-send-budget.ts +++ b/src/server/responses/combo-send-budget.ts @@ -1,4 +1,4 @@ -import { CODEX_TEXT_GUARDED_BUDGET_POLICY, type RequestExecutionBudget, type RequestExecutionBudgetPolicy } from "../../lib/request-execution-budget"; +import { CODEX_TEXT_GUARDED_BUDGET_POLICY, type RequestExecutionBudget, type RequestExecutionBudgetPolicy, type SingleUseDispatchPermit } from "../../lib/request-execution-budget"; /** * Sends one combo target may run on its own before the ladder moves on. A target is a whole @@ -46,8 +46,9 @@ export function comboExecutionBudgetPolicy(declaredTargets: number): RequestExec export function deriveSendBudgetScope( parent: RequestExecutionBudget, policy: RequestExecutionBudgetPolicy, + prepaid?: SingleUseDispatchPermit, ): RequestExecutionBudget { - return parent.deriveScope(policy); + return parent.deriveScope(policy, prepaid); } /** @@ -62,17 +63,18 @@ export function deriveSendBudgetScope( export function comboTargetSendBudget( comboScope: RequestExecutionBudget, targetsDeclaredAfterThisOne: number, + prepaid?: SingleUseDispatchPermit, ): RequestExecutionBudget { const policy = comboScope.policy; const heldForLaterTargets = Math.max(0, targetsDeclaredAfterThisOne); const ceiling = Math.max(1, policy.maxTotalModelSends - heldForLaterTargets); return deriveSendBudgetScope(comboScope, { - maxTotalModelSends: policy.maxTotalModelSends, - baseSendAllowance: Math.min(ceiling, comboScope.used + COMBO_TARGET_BASE_SENDS), + maxTotalModelSends: ceiling, + baseSendAllowance: Math.min(ceiling, comboScope.used - (prepaid ? 1 : 0) + COMBO_TARGET_BASE_SENDS), finalRecoveryAllowance: policy.finalRecoveryAllowance, // Within one target the account-move shape is unchanged: three same-account sends plus one // alternate is the recovery live traffic depends on, and a combo does not widen it. maxAlternateTargetSends: CODEX_TEXT_GUARDED_BUDGET_POLICY.maxAlternateTargetSends, maxTargetTransitions: CODEX_TEXT_GUARDED_BUDGET_POLICY.maxTargetTransitions, - }); + }, prepaid); } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 9a11e088a7..ab39c0a491 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -3247,7 +3247,7 @@ export async function handleComboResponses( break; } const targetSendBudget = comboSendScope - ? comboTargetSendBudget(comboSendScope, combo.targets.length - 1 - comboTargetsDispatched) + ? comboTargetSendBudget(comboSendScope, combo.targets.length - 1 - comboTargetsDispatched, hopDecision?.allowed ? hopDecision.permit : undefined) : options.sendBudget; comboTargetsDispatched += 1; const childLog: RequestLogContext = { diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 3504698635..61bfbc4c10 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -138,7 +138,7 @@ send has already earned a throttle. ### Bounded API-key 429 rotation -Each generic `handleResponsesInner` invocation in `src/server/responses/core.ts` captures a failover allowance before its first send: the initial pool size minus one when the initially selected key reference belongs to that pool, or the full initial pool size when it is outside the pool and shares that failover count across ordinary recovery, web-search/image/video bridge rounds and terminal continuations. Initialization precedes sidecar dispatch. Sidecars enforce this count only at their API-key rotation callback, retaining their own reset and same-target retry policies; the ordinary core physical-send budget is not newly applied to them, and OAuth/Anthropic account fallback remains available independently. Cooldown expiry and later pool growth do not refill it. This is a failover count, not a distinct-key guarantee or a new combo-wide budget; existing shared physical-send admission remains authoritative. Before cancelling a superseded response or choosing another key, the ordinary response path checks both limits. An initial recovery admission probe releases its unused dispatch reservation immediately; the actual rebuild owns the charged send permit, so merely checking the final reserve cannot spend it. Combo policy and child-scope construction live in `src/server/responses/combo-send-budget.ts`; their scopes derive through `src/lib/request-execution-budget.ts`, sharing both charged reservations and pending external-send settlement. Remaining-allowance checks and unused-permit refunds use that same ledger, while recovery and target-transition ledgers remain scope-local. Three targets with only 5xx failures consume five base sends and retain the separate final-recovery allowance. `tests/lib/execution-budget-permits.test.ts` checks interleaved scopes and refunds; `tests/responses/responses-send-budget-counts.test.ts` counts physical sends and verifies that every target is reached. On refusal, `src/providers/key-failover.ts` accepts `allowRotation: false` to record the proven failed key's cooldown through the existing selection-identity fence, without selecting, persisting or announcing an unattempted replacement. The original failure stays available to normal response handling, including an already-started streaming continuation. `tests/server/server-key-failover-e2e.test.ts` covers expiry, an initially unpooled key, actual web-search/image bridge dispatch, shared continuation limits and earlier send-budget exhaustion; `tests/adapters/key-failover.test.ts` verifies health-only recording, unchanged disk bytes and manual-selection ownership. +Each generic `handleResponsesInner` invocation in `src/server/responses/core.ts` captures a failover allowance before its first send: the initial pool size minus one when the initially selected key reference belongs to that pool, or the full initial pool size when it is outside the pool and shares that failover count across ordinary recovery, web-search/image/video bridge rounds and terminal continuations. Initialization precedes sidecar dispatch. Sidecars enforce this count only at their API-key rotation callback, retaining their own reset and same-target retry policies; the ordinary core physical-send budget is not newly applied to them, and OAuth/Anthropic account fallback remains available independently. Cooldown expiry and later pool growth do not refill it. This is a failover count, not a distinct-key guarantee or a new combo-wide budget; existing shared physical-send admission remains authoritative. Before cancelling a superseded response or choosing another key, the ordinary response path checks both limits. An initial recovery admission probe releases its unused dispatch reservation immediately; the actual rebuild owns the charged send permit, so merely checking the final reserve cannot spend it. Combo policy and child-scope construction live in `src/server/responses/combo-send-budget.ts`; their scopes derive through `src/lib/request-execution-budget.ts`, sharing both charged reservations and pending external-send settlement. Remaining-allowance checks and unused-permit refunds use that same ledger, while recovery and target-transition ledgers remain scope-local. Each child has a hard total ceiling that preserves one physical send for every later declared target, including when an earlier target uses recovery. An exact parent hop permit supplies the child's prepaid initial send; adapter-owned reservations and external send reports settle that booking once, rather than charging the hop and the physical send twice. Three plain-5xx targets use six physical sends, with the final combo hop eligible for recovery allowance. `tests/lib/execution-budget-permits.test.ts` checks interleaved scopes and refunds; `tests/responses/responses-send-budget-counts.test.ts` counts physical sends and verifies that every target is reached. On refusal, `src/providers/key-failover.ts` accepts `allowRotation: false` to record the proven failed key's cooldown through the existing selection-identity fence, without selecting, persisting or announcing an unattempted replacement. The original failure stays available to normal response handling, including an already-started streaming continuation. `tests/server/server-key-failover-e2e.test.ts` covers expiry, an initially unpooled key, actual web-search/image bridge dispatch, shared continuation limits and earlier send-budget exhaustion; `tests/adapters/key-failover.test.ts` verifies health-only recording, unchanged disk bytes and manual-selection ownership. ### Routed service-tier capability diff --git a/tests/adapters/adapter-inner-send-budget.test.ts b/tests/adapters/adapter-inner-send-budget.test.ts index 09406cb506..2ba454295d 100644 --- a/tests/adapters/adapter-inner-send-budget.test.ts +++ b/tests/adapters/adapter-inner-send-budget.test.ts @@ -6,6 +6,7 @@ import type { CursorRunRequest, CursorServerMessage } from "../../src/adapters/c import type { CursorTransport } from "../../src/adapters/cursor/transport"; import { createRequestExecutionBudget, type RequestExecutionBudgetPolicy } from "../../src/lib/request-execution-budget"; import { SendBudgetExhaustedError } from "../../src/lib/upstream-retry"; +import { comboExecutionBudgetPolicy, comboTargetSendBudget } from "../../src/server/responses/combo-send-budget"; /** * Adapters that retry INSIDE one adapter call are the layer a per-request cap cannot see from @@ -52,6 +53,23 @@ function alwaysResets(): { calls: number } { } describe("Kiro inner retries and the request send budget", () => { + test("combo hop bookings fund actual Kiro sends once and leave every target reachable", async () => { + const upstream = alwaysResets(); + const combo = createRequestExecutionBudget(comboExecutionBudgetPolicy(3)); + const counts: number[] = []; + for (let target = 0; target < 3; target++) { + const hop = combo.reserveDispatch({ sendClass: target === 0 ? "initial" : "combo-failover", targetKey: `target-${target}`, countedExternally: true }); + if (!hop.allowed) throw new Error(`target ${target} was starved`); + hop.permit.use(); + const child = comboTargetSendBudget(combo, 2 - target, hop.permit); + const before = upstream.calls; + await expect(fetchKiroWithRetry(kiroRequest, { timeoutMs: 5_000, sendBudget: child })).rejects.toBeDefined(); + counts.push(upstream.calls - before); + } + expect(counts).toEqual([3, 2, 1]); + expect(combo.used).toBe(upstream.calls); + expect(upstream.calls).toBe(6); + }); test("a context without a budget keeps the adapter's own reset ladder", async () => { const upstream = alwaysResets(); const observed: Array<{ ordinal: number; recovery?: string }> = []; diff --git a/tests/lib/execution-budget-permits.test.ts b/tests/lib/execution-budget-permits.test.ts index 4b2a93475a..60aa8a0e01 100644 --- a/tests/lib/execution-budget-permits.test.ts +++ b/tests/lib/execution-budget-permits.test.ts @@ -158,15 +158,19 @@ describe("combo scopes share reservation accounting", () => { const parent = createRequestExecutionBudget(); const combo = deriveSendBudgetScope(parent, comboExecutionBudgetPolicy(3)); let sends = 0; + const byTarget: number[] = []; for (let target = 0; target < 3; target++) { const scope = comboTargetSendBudget(combo, 2 - target); + const before = sends; for (let attempt = 0; attempt < 8; attempt++) { const decision = scope.reserveDispatch({ sendClass: attempt === 0 ? "initial" : "auth-recovery", targetKey: `target-${target}` }); if (!decision.allowed) break; expect(decision.permit.use()).toBe(true); sends++; } + byTarget.push(sends - before); } + expect(byTarget).toEqual([4, 1, 1]); expect(sends).toBe(combo.policy.maxTotalModelSends); expect(parent.used).toBe(sends); expect(combo.remainingBaseSends(100)).toBe(0); @@ -186,6 +190,38 @@ describe("combo scopes share reservation accounting", () => { expect(parent.targetTransitions).toBe(0); expect(parent.used).toBe(4); }); + + test("two external reporters cannot adopt the same prepaid hop", () => { + const parent = createRequestExecutionBudget(ONE_SEND_LEFT); + const hop = parent.reserveDispatch({ sendClass: "initial", targetKey: "combo", countedExternally: true }); + if (!hop.allowed) throw new Error("expected hop"); + hop.permit.use(); + const first = deriveSendBudgetScope(parent, ONE_SEND_LEFT, hop.permit); + const second = deriveSendBudgetScope(parent, ONE_SEND_LEFT, hop.permit); + expect(first.reserveDispatch({ sendClass: "transient", targetKey: "a", countedExternally: true }).allowed).toBe(true); + expect(second.reserveDispatch({ sendClass: "transient", targetKey: "b", countedExternally: true }).allowed).toBe(false); + parent.used += 1; + expect(parent.used).toBe(1); + }); + + test("an adapter adopts its exact prepaid hop once and can return it before dispatch", () => { + const parent = createRequestExecutionBudget(ONE_SEND_LEFT); + const hop = parent.reserveDispatch({ sendClass: "initial", targetKey: "combo", countedExternally: true }); + if (!hop.allowed) throw new Error("expected hop"); + hop.permit.use(); + const child = deriveSendBudgetScope(parent, ONE_SEND_LEFT, hop.permit); + expect(child.remainingBaseSends(3)).toBe(1); + const first = child.reserveDispatch({ sendClass: "initial", targetKey: "adapter" }); + if (!first.allowed) throw new Error("expected prepaid initial send"); + expect(parent.used).toBe(1); + first.permit.release(); + expect(child.remainingBaseSends(3)).toBe(1); + const retry = child.reserveDispatch({ sendClass: "initial", targetKey: "adapter" }); + if (!retry.allowed) throw new Error("expected returned booking"); + expect(retry.permit.use()).toBe(true); + expect(parent.used).toBe(1); + expect(child.reserveDispatch({ sendClass: "transient", targetKey: "adapter" }).allowed).toBe(false); + }); }); describe("layer caps intersect the shared budget", () => { diff --git a/tests/responses/responses-send-budget-counts.test.ts b/tests/responses/responses-send-budget-counts.test.ts index 733efc9aef..41d1f74bad 100644 --- a/tests/responses/responses-send-budget-counts.test.ts +++ b/tests/responses/responses-send-budget-counts.test.ts @@ -120,7 +120,7 @@ describe("upstream sends per logical request", () => { expect(sendCounts(logCtx)).toEqual([3]); }); - test("a three-target 5xx combo reaches every target within five base sends", async () => { + test("a three-target 5xx combo reaches every target within six physical sends", async () => { const upstream = alwaysFailing(502, "upstream busy"); const logCtx: RequestLogContext = { model: "", provider: "" }; @@ -145,10 +145,10 @@ describe("upstream sends per logical request", () => { // Bounded by the derived total: the first target's ladder, one send per further declared // target, and the single shared final-recovery reserve. The measured regression in #4546 was // twelve, four per target, because each child drew a fresh full allowance. - // A plain 5xx streak has no qualifying final recovery, so it uses only the five base - // sends. The sixth is reserved for an explicitly admitted recovery, covered by permits. - expect(bearers).toHaveLength(5); - expect(totalSends(logCtx)).toBe(5); + // The final combo hop can use the shared recovery allowance. A prepaid hop must settle + // against its first physical send rather than shrinking the next target's ladder twice. + expect(bearers).toHaveLength(6); + expect(totalSends(logCtx)).toBe(6); }); // REMOVED: "a 401 before the 5xx streak spends one of the same three sends". From 63807e330f76fe152ae9fcd948a736f3f2c0d8db Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:29:35 +0900 Subject: [PATCH 08/23] fix(responses): count reset-only key recovery sends --- src/server/responses/core.ts | 41 +++++++------------ structure/adapters/registry.md | 2 +- structure/catalog.md | 2 +- structure/clients/claude-desktop.md | 2 +- structure/data-planes/images.md | 2 +- structure/data-planes/inbound-compat.md | 2 +- structure/gui-and-management-api.md | 2 +- structure/ops/service-and-sidecars.md | 2 +- structure/providers/xai-grok.md | 2 +- structure/runtime.md | 2 +- structure/subagents.md | 2 +- structure/transports/byte-accounting.md | 2 +- structure/transports/inventory.md | 2 +- structure/transports/responses.md | 2 +- structure/transports/streaming-health.md | 2 +- .../responses-send-budget-counts.test.ts | 9 ++-- tests/server/server-key-failover-e2e.test.ts | 40 ++++++++++++++++++ 17 files changed, 72 insertions(+), 46 deletions(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index ab39c0a491..6402d59ad0 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -8017,11 +8017,10 @@ async function handleResponsesInner( || adapterSendBudget.remainingBaseSends(adapterSendBudget.policy.baseSendAllowance) > 0; } const policy = transientRetryPolicyFor(route.provider); - // Reset-only transports do not opt into the shared transient policy; the rotation cap - // still bounds them without granting a new retry policy or changing their reset limit. - if (!policy) return true; - if (!Number.isInteger(policy.attempts) || policy.attempts <= 0) return false; - if (remainingTransientSendBudget(policy.attempts) > 0) return true; + // Reset-only transports spend the same physical budget without gaining 5xx retries. + const attempts = policy?.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS; + if (!Number.isInteger(attempts) || attempts <= 0) return false; + if (remainingTransientSendBudget(attempts) > 0) return true; // Continuations currently draw base sends only. Probe the initial recovery reserve, // then release its unused reservation: rebuildAndRefetch owns the actual send permit. if (continuation || !isRequestExecutionBudget(sendBudget)) return false; @@ -8178,15 +8177,9 @@ async function handleResponsesInner( { abortSignal: upstream.signal, label: safeHostLabel(builtInitialRequest.url), - ...(transientPolicy - // Draws the remainder, not the raw policy. A combo child inherits the parent's - // holder but used to take a fresh full allowance on its own first send, so the - // shared counter was inherited without ever being read as a limit. - ? { - attempts: remainingTransientSendBudget(transientPolicy.attempts), - onSendsConsumed: noteTransientSends, - } - : {}), + // Count both retry policies; reset-only still hops immediately on HTTP 5xx. + attempts: remainingTransientSendBudget(transientPolicy?.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS), + onSendsConsumed: noteTransientSends, }, ); } @@ -8299,13 +8292,11 @@ async function handleResponsesInner( // Same rule as the passthrough rebuild: spend the base allowance first, then the one // shared final-recovery reserve, so a recovery that follows a spent streak still gets // its single send instead of dying at three. - const refetchAllowance = refetchTransientPolicy - ? recoverySendAllowance( - refetchTransientPolicy.attempts, - recoveryClassFor(recovery), - `${route.providerName}|${route.modelId}|${recovery}`, - ) - : undefined; + const refetchAllowance = recoverySendAllowance( + refetchTransientPolicy?.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS, + recoveryClassFor(recovery), + `${route.providerName}|${route.modelId}|${recovery}`, + ); try { return await refetchWithPolicy( recoveryKind => { @@ -8900,12 +8891,8 @@ async function handleResponsesInner( // Same request-scoped budget as the initial send and the 429/rotation refetches: // a terminal-guard continuation is another leg of ONE request, so handing it a // fresh `attempts` would let one request exceed the configured total-send ceiling. - ...(continuationTransientPolicy - ? { - attempts: remainingTransientSendBudget(continuationTransientPolicy.attempts), - onSendsConsumed: noteTransientSends, - } - : {}), + attempts: remainingTransientSendBudget(continuationTransientPolicy?.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS), + onSendsConsumed: noteTransientSends, }, ); } finally { diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index f0a7a168f1..0f203af62a 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -1,7 +1,7 @@ # Adapter Registry Authority The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. diff --git a/structure/catalog.md b/structure/catalog.md index 88e90d016f..e62caf9bc4 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -1,7 +1,7 @@ # Model Catalog The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 9180f1e2f1..a5e42d34b9 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -1,7 +1,7 @@ # Claude Desktop Integration The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. Codex-native model discovery follows the [shared retirement policy](../catalog.md#shared-catalog). That projection does not migrate existing user-selected Desktop configuration or usage history. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 50f4732025..e2cd1a02f5 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -1,7 +1,7 @@ # Images Data Plane The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. Hosted Responses image-tool eligibility uses the shared compatibility policy without a Codex Spark exception; standalone Images retain the separate relay contract below. See diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 9a1d325452..9dfe7033b1 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -1,7 +1,7 @@ # Inbound Compatibility Surfaces The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. ## Standalone file transcription diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 7152a6d9b6..ccc496b6c6 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,7 +1,7 @@ # GUI And Management API The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. ## Dashboard serving diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 77007b5a7d..4300d41c84 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -1,7 +1,7 @@ # Background Service And Sidecars The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. Service startup and restore use the [catalog retirement policy](../catalog.md#shared-catalog); retirement does not itself change service registration or user-selected model configuration. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 2751675e99..5035cb708c 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -1,7 +1,7 @@ # xAI Grok Provider The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. Codex-native retirement is scoped to OpenAI catalog/quota evidence. Shared Responses handling retains xAI provider behavior; see diff --git a/structure/runtime.md b/structure/runtime.md index 906ee94a43..48c8a6c737 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,7 +1,7 @@ # Runtime The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. Chat request serialization owns the destination-scoped [OpenCode Go instruction ordering](providers/chat-compat.md#opencode-go-chronological-instructions); diff --git a/structure/subagents.md b/structure/subagents.md index c4f8ff7ac3..397ea1d90c 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -7,7 +7,7 @@ only explicit true on Responses ingress to the final canonical ChatGPT forward route activates it. A default top-level collaboration catalog is required. The compiler preserves caller objects, aliases the namespace and three message functions, and removes only their true encryption marker. -Declaration/reference collisions refuse the whole rewrite without changing the request. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. +Declaration/reference collisions refuse the whole rewrite without changing the request. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. `src/adapters/openai-responses.ts` returns request-local alias capabilities. The Responses core refreshes them after every request rebuild and restores JSON, SSE and WebSocket identities after diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 58c38843be..62c08ae67e 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -2,7 +2,7 @@ How opencodex measures request and stream bytes without allocating copies solely to count them. These contracts are shared by request parsing, SSE rewriting, the provider adapters and -the translator budget, which is why so many documents link here rather than restating them. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. +the translator budget, which is why so many documents link here rather than restating them. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. ## Request-copy accounting diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index f8487adf56..563310ab45 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -1,7 +1,7 @@ # Transport Inventory The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. The Chat adapter's [OpenCode Go instruction ordering](../providers/chat-compat.md#opencode-go-chronological-instructions) changes translated message placement only; endpoint selection and transport stay with their existing owners. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 61bfbc4c10..d199d7280a 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -138,7 +138,7 @@ send has already earned a throttle. ### Bounded API-key 429 rotation -Each generic `handleResponsesInner` invocation in `src/server/responses/core.ts` captures a failover allowance before its first send: the initial pool size minus one when the initially selected key reference belongs to that pool, or the full initial pool size when it is outside the pool and shares that failover count across ordinary recovery, web-search/image/video bridge rounds and terminal continuations. Initialization precedes sidecar dispatch. Sidecars enforce this count only at their API-key rotation callback, retaining their own reset and same-target retry policies; the ordinary core physical-send budget is not newly applied to them, and OAuth/Anthropic account fallback remains available independently. Cooldown expiry and later pool growth do not refill it. This is a failover count, not a distinct-key guarantee or a new combo-wide budget; existing shared physical-send admission remains authoritative. Before cancelling a superseded response or choosing another key, the ordinary response path checks both limits. An initial recovery admission probe releases its unused dispatch reservation immediately; the actual rebuild owns the charged send permit, so merely checking the final reserve cannot spend it. Combo policy and child-scope construction live in `src/server/responses/combo-send-budget.ts`; their scopes derive through `src/lib/request-execution-budget.ts`, sharing both charged reservations and pending external-send settlement. Remaining-allowance checks and unused-permit refunds use that same ledger, while recovery and target-transition ledgers remain scope-local. Each child has a hard total ceiling that preserves one physical send for every later declared target, including when an earlier target uses recovery. An exact parent hop permit supplies the child's prepaid initial send; adapter-owned reservations and external send reports settle that booking once, rather than charging the hop and the physical send twice. Three plain-5xx targets use six physical sends, with the final combo hop eligible for recovery allowance. `tests/lib/execution-budget-permits.test.ts` checks interleaved scopes and refunds; `tests/responses/responses-send-budget-counts.test.ts` counts physical sends and verifies that every target is reached. On refusal, `src/providers/key-failover.ts` accepts `allowRotation: false` to record the proven failed key's cooldown through the existing selection-identity fence, without selecting, persisting or announcing an unattempted replacement. The original failure stays available to normal response handling, including an already-started streaming continuation. `tests/server/server-key-failover-e2e.test.ts` covers expiry, an initially unpooled key, actual web-search/image bridge dispatch, shared continuation limits and earlier send-budget exhaustion; `tests/adapters/key-failover.test.ts` verifies health-only recording, unchanged disk bytes and manual-selection ownership. +Each generic `handleResponsesInner` invocation in `src/server/responses/core.ts` captures a failover allowance before its first send: the initial pool size minus one when the initially selected key reference belongs to that pool, or the full initial pool size when it is outside the pool and shares that failover count across ordinary recovery, web-search/image/video bridge rounds and terminal continuations. Initialization precedes sidecar dispatch. Sidecars enforce this count only at their API-key rotation callback, retaining their own reset and same-target retry policies; the ordinary core physical-send budget is not newly applied to them, and OAuth/Anthropic account fallback remains available independently. Cooldown expiry and later pool growth do not refill it. This is a failover count, not a distinct-key guarantee or a new combo-wide budget; existing shared physical-send admission remains authoritative. Before cancelling a superseded response or choosing another key, the ordinary response path checks both limits. Generic reset-only initial fetches, key-rotation rebuilds and terminal continuations consume the same physical-send budget; opting out of transient-5xx retries does not bypass the child ceiling or enable 5xx retries. An initial recovery admission probe releases its unused dispatch reservation immediately; the actual rebuild owns the charged send permit, so merely checking the final reserve cannot spend it. Combo policy and child-scope construction live in `src/server/responses/combo-send-budget.ts`; their scopes derive through `src/lib/request-execution-budget.ts`, sharing both charged reservations and pending external-send settlement. Remaining-allowance checks and unused-permit refunds use that same ledger, while recovery and target-transition ledgers remain scope-local. Each child has a hard total ceiling that preserves one physical send for every later declared target, including when an earlier target uses recovery. An exact parent hop permit supplies the child's prepaid initial send; adapter-owned reservations and external send reports settle that booking once, rather than charging the hop and the physical send twice. Three plain-5xx targets use six physical sends, with the final combo hop eligible for recovery allowance. `tests/lib/execution-budget-permits.test.ts` checks interleaved scopes and refunds; `tests/responses/responses-send-budget-counts.test.ts` counts physical sends and verifies that every target is reached. On refusal, `src/providers/key-failover.ts` accepts `allowRotation: false` to record the proven failed key's cooldown through the existing selection-identity fence, without selecting, persisting or announcing an unattempted replacement. The original failure stays available to normal response handling, including an already-started streaming continuation. `tests/server/server-key-failover-e2e.test.ts` covers expiry, an initially unpooled key, actual web-search/image bridge dispatch, shared continuation limits and earlier send-budget exhaustion; `tests/adapters/key-failover.test.ts` verifies health-only recording, unchanged disk bytes and manual-selection ownership. ### Routed service-tier capability diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index b5d2f735ff..b7f5fa463b 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -1,7 +1,7 @@ # Streaming Health And WebSocket The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. Codex WebSocket quota-family normalization remains generic; retired-model evidence is filtered by the [OpenAI quota owner](../providers/openai-tiers.md#public-provider-contract), not by diff --git a/tests/responses/responses-send-budget-counts.test.ts b/tests/responses/responses-send-budget-counts.test.ts index 41d1f74bad..b1eb4ff7b8 100644 --- a/tests/responses/responses-send-budget-counts.test.ts +++ b/tests/responses/responses-send-budget-counts.test.ts @@ -11,11 +11,10 @@ import type { OcxConfig } from "../../src/types"; * only assertion that catches a regression here is the exact number of times the proxy reached * upstream for one client turn. * - * These rows use a key-auth `openai-chat` provider with `transientRetryOn5xx` because that is the - * counted path: the generic adapter branch draws `attempts` from the request budget and reports - * every physical send back through `onSendsConsumed`, and `noteAttemptSend` records the same send - * on the attempt. An adapter without an opted-in transient policy keeps reset-only semantics and - * hops on the first 5xx, so it would pin a 1 for every shape and prove nothing. + * These 5xx rows opt into `transientRetryOn5xx` to exercise same-target retries. Both generic + * retry policies draw from the request budget and report physical sends through + * `onSendsConsumed`. Reset-only still hops on the first HTTP 5xx; its key-rotation and socket + * reset counts are covered by the server-key-failover end-to-end fixture instead. */ const originalFetch = globalThis.fetch; diff --git a/tests/server/server-key-failover-e2e.test.ts b/tests/server/server-key-failover-e2e.test.ts index c5abeb97ba..be60c66155 100644 --- a/tests/server/server-key-failover-e2e.test.ts +++ b/tests/server/server-key-failover-e2e.test.ts @@ -43,6 +43,46 @@ afterEach(() => { }); describe("server 429 key failover (end-to-end)", () => { + test.each(["429", "5xx", "reset"] as const)("reset-only combo key rotations preserve later targets (%s)", async mode => { + const originalFetch = globalThis.fetch; + const counts = [0, 0, 0]; + const targets = counts.map((_, index) => ({ provider: `reset-key-t${index}`, model: "test" })); + const providers = Object.fromEntries(targets.map(({ provider }) => [provider, { + adapter: "openai-chat", authMode: "key", baseUrl: `https://${provider}.invalid/v1`, + apiKey: `synthetic-${provider}-0`, + apiKeyPool: Array.from({ length: 6 }, (_, key) => ({ id: `k${key}`, key: `synthetic-${provider}-${key}` })), + }])); + const config = { port: 0, hostname: "127.0.0.1", defaultProvider: targets[0]!.provider, + providers, combos: { fan: { strategy: "failover", targets } }, + } as OcxConfig; + let server: ReturnType | undefined; + try { + saveConfig(config); + server = startServer(0); + globalThis.fetch = (async (input) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const index = targets.findIndex(target => url.hostname === `${target.provider}.invalid`); + if (index < 0) throw new Error("unexpected reset-only combo fixture request"); + counts[index]!++; + if (counts.reduce((a, b) => a + b, 0) > 18) throw new Error("fixture send ceiling exceeded"); + if (mode === "reset" && (index !== 0 || counts[index]! > 1)) { + throw Object.assign(new Error("socket reset fixture"), { code: "ECONNRESET" }); + } + return Response.json({ error: { message: "key quota exhausted", type: "rate_limit_error" } }, + { status: mode === "5xx" ? 502 : 429, headers: { "retry-after": "0" } }); + }) as typeof fetch; + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + signal: AbortSignal.timeout(10_000), + body: JSON.stringify({ model: "combo/fan", input: "hello", stream: false }), + }); + await response.text(); + expect(response.ok).toBe(false); + expect(counts).toEqual(mode === "5xx" ? [1, 1, 1] : mode === "reset" ? [3, 2, 1] : [4, 1, 1]); + expect(counts.reduce((a, b) => a + b, 0)).toBe(mode === "5xx" ? 3 : 6); + } finally { globalThis.fetch = originalFetch; await server?.stop(true); } + }, 15_000); + test.each(["web-search", "image"] as const)( "%s bridge bounds rotations after short cooldowns expire", async bridge => { From 0833f9fc354f2be148ab6c2579f2dde1df62bc75 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:11:38 +0900 Subject: [PATCH 09/23] fix(responses): settle OAuth replays and compact handoff sends once --- src/server/responses/compact.ts | 18 ++++++- src/server/responses/core.ts | 48 ++++++++++++++----- structure/adapters/registry.md | 2 +- structure/catalog.md | 2 +- structure/clients/claude-desktop.md | 2 +- structure/data-planes/images.md | 2 +- structure/data-planes/inbound-compat.md | 2 +- structure/gui-and-management-api.md | 2 +- structure/ops/service-and-sidecars.md | 2 +- structure/providers/xai-grok.md | 2 +- structure/runtime.md | 2 +- structure/subagents.md | 2 +- structure/transports/byte-accounting.md | 2 +- structure/transports/inventory.md | 2 +- structure/transports/responses.md | 2 +- structure/transports/streaming-health.md | 2 +- .../lib/transient-budget-scope-source.test.ts | 12 +++-- .../responses-compaction-routing.test.ts | 2 +- tests/server/server-key-failover-e2e.test.ts | 39 +++++++++++++++ 19 files changed, 115 insertions(+), 32 deletions(-) diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 6b5b979d37..2b9967a33a 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -1218,6 +1218,20 @@ export async function handleResponsesCompact( } else if (quotaFailure && !storedPool401ReplayAttempted) { const fallbackModel = compactHandoffRoute(req, raw.model); if (fallbackModel && !req.signal.aborted) { + const hop = sendBudget.reserveDispatch({ + sendClass: "account-failover", + targetKey: `compact-handoff:${fallbackModel}`, + countedExternally: true, + }); + if (!hop.allowed) return buffered; + // The routed child's initial send spends this exact recovery booking. Its base + // exposes the prepaid send even after native compact used the three-send ladder; + // it shares the total ledger and receives no additional recovery reserve. + const handoffBudget = sendBudget.deriveScope({ + ...sendBudget.policy, + baseSendAllowance: Math.max(sendBudget.policy.baseSendAllowance, sendBudget.used), + finalRecoveryAllowance: 0, + }, hop.permit); const fallbackReq = new Request(req.url, { method: "POST", headers: req.headers, @@ -1233,13 +1247,15 @@ export async function handleResponsesCompact( admission, // 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 }, + { ...options, sendBudget: handoffBudget }, ); if (fallback.ok || fallback.status === 499) return fallback; await fallback.body?.cancel().catch(() => undefined); } catch { // The previous-model rejection is the authoritative failure when the // remembered handoff route can no longer compact this thread. + } finally { + hop.permit.release(); } } } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 6402d59ad0..ac3715e420 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -8009,7 +8009,7 @@ async function handleResponsesInner( : 300_000; activeAdapter = adapter; - const keyPool429RetryAllowed = (continuation: boolean): boolean => { + const keyPool429RetryAllowed = (): boolean => { if (keyPoolFailovers >= maxKeyPoolFailovers) return false; // Adapter-owned sends retain their existing base-only admission (for example Kiro). if (activeAdapter.fetchResponse) { @@ -8021,9 +8021,9 @@ async function handleResponsesInner( const attempts = policy?.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS; if (!Number.isInteger(attempts) || attempts <= 0) return false; if (remainingTransientSendBudget(attempts) > 0) return true; - // Continuations currently draw base sends only. Probe the initial recovery reserve, - // then release its unused reservation: rebuildAndRefetch owns the actual send permit. - if (continuation || !isRequestExecutionBudget(sendBudget)) return false; + // Probe the shared recovery reserve, then release the unused reservation: + // the initial or continuation refetch owns the actual send permit. + if (!isRequestExecutionBudget(sendBudget)) return false; const decision = sendBudget.reserveDispatch({ sendClass: "auth-recovery", targetKey: `${route.providerName}|${route.modelId}|key-429`, @@ -8483,7 +8483,7 @@ async function handleResponsesInner( now: Date.now(), attemptedKey: route.provider.apiKey, promptCacheKey: parsed.options.promptCacheKey, - allowRotation: keyPool429RetryAllowed(false), + allowRotation: keyPool429RetryAllowed(), }); if (!rotated) break; keyPoolFailovers += 1; @@ -8566,6 +8566,7 @@ async function handleResponsesInner( const hop = reserveCredentialHop( "auth-recovery", `${route.providerName}|${route.modelId}|adapter-recovery-oauth-429`, + !activeAdapter.fetchResponse, ); if (!hop.allowed) break; const nextAccountId = rotateGenericOAuthAccountOn429( @@ -8596,10 +8597,17 @@ async function handleResponsesInner( ); sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel); recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, activeAdapter.name); - const result = await rebuildAndRefetch("oauth-account-429"); - if ("failed" in result) return result.failed; - upstreamResponse = result; + pendingHopPermit = activeAdapter.fetchResponse ? undefined : hop.permit; + try { + const result = await rebuildAndRefetch("oauth-account-429"); + if ("failed" in result) return result.failed; + upstreamResponse = result; + } finally { + pendingHopPermit = undefined; + if (!activeAdapter.fetchResponse) hop.permit?.release(); + } } catch { + if (!activeAdapter.fetchResponse) hop.permit?.release(); break; } } @@ -8865,8 +8873,17 @@ async function handleResponsesInner( const fetchContinuationWithRetryPolicy = (route.provider.adapter === "google" || continuationTransientPolicy) ? fetchWithTransientRetry : fetchWithResetRetry; - return await fetchContinuationWithRetryPolicy( + const continuationCap = continuationTransientPolicy?.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS; + const allowance = replayKind + ? recoverySendAllowance(continuationCap, recoveryClassFor(replayKind), + `${route.providerName}|${route.modelId}|${replayKind}`) + : { attempts: remainingTransientSendBudget(continuationCap), permit: undefined }; + try { + return await fetchContinuationWithRetryPolicy( recovery => { + if (allowance.permit && !allowance.permit.use()) { + throw new SendBudgetExhaustedError(safeHostLabel(builtContinuationRequest.url)); + } noteAttemptSend(logCtx.activeAttempt, continuationEstimate, recovery ?? replayKind); return fetchWithHeaderTimeout( builtContinuationRequest.url, @@ -8891,15 +8908,19 @@ async function handleResponsesInner( // Same request-scoped budget as the initial send and the 429/rotation refetches: // a terminal-guard continuation is another leg of ONE request, so handing it a // fresh `attempts` would let one request exceed the configured total-send ceiling. - attempts: remainingTransientSendBudget(continuationTransientPolicy?.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS), + attempts: allowance.attempts, onSendsConsumed: noteTransientSends, }, ); + } finally { + allowance.permit?.release(); + } } finally { builtContinuationRequest.releaseBodyObservation?.(); } }; while (true) { + const continuationHop = pendingHopPermit; try { const recoveryKind = nextContinuationRecoveryKind; nextContinuationRecoveryKind = undefined; @@ -8911,6 +8932,9 @@ async function handleResponsesInner( yield { type: "error", message: `Provider continuation failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}` }; } return; + } finally { + pendingHopPermit = undefined; + continuationHop?.release(); } // Same-target 429 wait-and-retry (opt-in `retryOn429`) before key/account failover: @@ -8967,7 +8991,7 @@ async function handleResponsesInner( now: Date.now(), attemptedKey: route.provider.apiKey, promptCacheKey: nextParsed.options.promptCacheKey, - allowRotation: keyPool429RetryAllowed(true), + allowRotation: keyPool429RetryAllowed(), }); if (rotated) { keyPoolFailovers += 1; @@ -9049,6 +9073,7 @@ async function handleResponsesInner( const hop = reserveCredentialHop( "auth-recovery", `${route.providerName}|${route.modelId}|continuation-oauth-429`, + !activeAdapter.fetchResponse, ); const nextAccountId = hop.allowed ? rotateGenericOAuthAccountOn429( @@ -9079,6 +9104,7 @@ async function handleResponsesInner( sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel); recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, activeAdapter.name); nextContinuationRecoveryKind = "oauth-account-429"; + pendingHopPermit = activeAdapter.fetchResponse ? undefined : hop.permit; continue; } } catch { diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 0f203af62a..1d53b1858c 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -1,7 +1,7 @@ # Adapter Registry Authority The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. diff --git a/structure/catalog.md b/structure/catalog.md index e62caf9bc4..db56e1a762 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -1,7 +1,7 @@ # Model Catalog The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index a5e42d34b9..f9485b3f32 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -1,7 +1,7 @@ # Claude Desktop Integration The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. Codex-native model discovery follows the [shared retirement policy](../catalog.md#shared-catalog). That projection does not migrate existing user-selected Desktop configuration or usage history. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index e2cd1a02f5..233a80cb0d 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -1,7 +1,7 @@ # Images Data Plane The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. Hosted Responses image-tool eligibility uses the shared compatibility policy without a Codex Spark exception; standalone Images retain the separate relay contract below. See diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 9dfe7033b1..74ebbf6e00 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -1,7 +1,7 @@ # Inbound Compatibility Surfaces The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. ## Standalone file transcription diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index ccc496b6c6..192883646c 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,7 +1,7 @@ # GUI And Management API The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. ## Dashboard serving diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 4300d41c84..70910f2797 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -1,7 +1,7 @@ # Background Service And Sidecars The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. Service startup and restore use the [catalog retirement policy](../catalog.md#shared-catalog); retirement does not itself change service registration or user-selected model configuration. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 5035cb708c..eb7d83b5da 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -1,7 +1,7 @@ # xAI Grok Provider The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. Codex-native retirement is scoped to OpenAI catalog/quota evidence. Shared Responses handling retains xAI provider behavior; see diff --git a/structure/runtime.md b/structure/runtime.md index 48c8a6c737..1b1f429b22 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,7 +1,7 @@ # Runtime The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. Chat request serialization owns the destination-scoped [OpenCode Go instruction ordering](providers/chat-compat.md#opencode-go-chronological-instructions); diff --git a/structure/subagents.md b/structure/subagents.md index 397ea1d90c..6b297b16e8 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -7,7 +7,7 @@ only explicit true on Responses ingress to the final canonical ChatGPT forward route activates it. A default top-level collaboration catalog is required. The compiler preserves caller objects, aliases the namespace and three message functions, and removes only their true encryption marker. -Declaration/reference collisions refuse the whole rewrite without changing the request. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. +Declaration/reference collisions refuse the whole rewrite without changing the request. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. `src/adapters/openai-responses.ts` returns request-local alias capabilities. The Responses core refreshes them after every request rebuild and restores JSON, SSE and WebSocket identities after diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 62c08ae67e..af3a75fd74 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -2,7 +2,7 @@ How opencodex measures request and stream bytes without allocating copies solely to count them. These contracts are shared by request parsing, SSE rewriting, the provider adapters and -the translator budget, which is why so many documents link here rather than restating them. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. +the translator budget, which is why so many documents link here rather than restating them. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. ## Request-copy accounting diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 563310ab45..10f771ba54 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -1,7 +1,7 @@ # Transport Inventory The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. The Chat adapter's [OpenCode Go instruction ordering](../providers/chat-compat.md#opencode-go-chronological-instructions) changes translated message placement only; endpoint selection and transport stay with their existing owners. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index d199d7280a..807c88e517 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -138,7 +138,7 @@ send has already earned a throttle. ### Bounded API-key 429 rotation -Each generic `handleResponsesInner` invocation in `src/server/responses/core.ts` captures a failover allowance before its first send: the initial pool size minus one when the initially selected key reference belongs to that pool, or the full initial pool size when it is outside the pool and shares that failover count across ordinary recovery, web-search/image/video bridge rounds and terminal continuations. Initialization precedes sidecar dispatch. Sidecars enforce this count only at their API-key rotation callback, retaining their own reset and same-target retry policies; the ordinary core physical-send budget is not newly applied to them, and OAuth/Anthropic account fallback remains available independently. Cooldown expiry and later pool growth do not refill it. This is a failover count, not a distinct-key guarantee or a new combo-wide budget; existing shared physical-send admission remains authoritative. Before cancelling a superseded response or choosing another key, the ordinary response path checks both limits. Generic reset-only initial fetches, key-rotation rebuilds and terminal continuations consume the same physical-send budget; opting out of transient-5xx retries does not bypass the child ceiling or enable 5xx retries. An initial recovery admission probe releases its unused dispatch reservation immediately; the actual rebuild owns the charged send permit, so merely checking the final reserve cannot spend it. Combo policy and child-scope construction live in `src/server/responses/combo-send-budget.ts`; their scopes derive through `src/lib/request-execution-budget.ts`, sharing both charged reservations and pending external-send settlement. Remaining-allowance checks and unused-permit refunds use that same ledger, while recovery and target-transition ledgers remain scope-local. Each child has a hard total ceiling that preserves one physical send for every later declared target, including when an earlier target uses recovery. An exact parent hop permit supplies the child's prepaid initial send; adapter-owned reservations and external send reports settle that booking once, rather than charging the hop and the physical send twice. Three plain-5xx targets use six physical sends, with the final combo hop eligible for recovery allowance. `tests/lib/execution-budget-permits.test.ts` checks interleaved scopes and refunds; `tests/responses/responses-send-budget-counts.test.ts` counts physical sends and verifies that every target is reached. On refusal, `src/providers/key-failover.ts` accepts `allowRotation: false` to record the proven failed key's cooldown through the existing selection-identity fence, without selecting, persisting or announcing an unattempted replacement. The original failure stays available to normal response handling, including an already-started streaming continuation. `tests/server/server-key-failover-e2e.test.ts` covers expiry, an initially unpooled key, actual web-search/image bridge dispatch, shared continuation limits and earlier send-budget exhaustion; `tests/adapters/key-failover.test.ts` verifies health-only recording, unchanged disk bytes and manual-selection ownership. +Each generic `handleResponsesInner` invocation in `src/server/responses/core.ts` captures a failover allowance before its first send: the initial pool size minus one when the initially selected key reference belongs to that pool, or the full initial pool size when it is outside the pool and shares that failover count across ordinary recovery, web-search/image/video bridge rounds and terminal continuations. Initialization precedes sidecar dispatch. Sidecars enforce this count only at their API-key rotation callback, retaining their own reset and same-target retry policies; the ordinary core physical-send budget is not newly applied to them, and OAuth/Anthropic account fallback remains available independently. Cooldown expiry and later pool growth do not refill it. This is a failover count, not a distinct-key guarantee or a new combo-wide budget; existing shared physical-send admission remains authoritative. Before cancelling a superseded response or choosing another key, the ordinary response path checks both limits. Generic reset-only initial fetches, key-rotation rebuilds and terminal continuations consume the same physical-send budget; opting out of transient-5xx retries does not bypass the child ceiling or enable 5xx retries. Initial and continuation recovery admission probes release their unused dispatch reservations immediately; the actual refetch owns the charged send permit. Generic OAuth hops replayed through the retry helpers mark their reservation as externally counted and pass it into the refetch, so its first physical send settles the booking once. An unused retry-helper hop is refunded on failure or abort. Terminal-continuation key recovery can draw the same single final reserve after the base allowance is spent. Combo policy and child-scope construction live in `src/server/responses/combo-send-budget.ts`; their scopes derive through `src/lib/request-execution-budget.ts`, sharing both charged reservations and pending external-send settlement. Remaining-allowance checks and unused-permit refunds use that same ledger, while recovery and target-transition ledgers remain scope-local. Each child has a hard total ceiling that preserves one physical send for every later declared target, including when an earlier target uses recovery. An exact parent hop permit supplies the child's prepaid initial send; adapter-owned reservations and external send reports settle that booking once, rather than charging the hop and the physical send twice. Three plain-5xx targets use six physical sends, with the final combo hop eligible for recovery allowance. `tests/lib/execution-budget-permits.test.ts` checks interleaved scopes and refunds; `tests/responses/responses-send-budget-counts.test.ts` counts physical sends and verifies that every target is reached. On refusal, `src/providers/key-failover.ts` accepts `allowRotation: false` to record the proven failed key's cooldown through the existing selection-identity fence, without selecting, persisting or announcing an unattempted replacement. The original failure stays available to normal response handling, including an already-started streaming continuation. `tests/server/server-key-failover-e2e.test.ts` covers expiry, an initially unpooled key, actual web-search/image bridge dispatch, shared continuation limits and earlier send-budget exhaustion; `tests/adapters/key-failover.test.ts` verifies health-only recording, unchanged disk bytes and manual-selection ownership. ### Routed service-tier capability diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index b7f5fa463b..bb5e986521 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -1,7 +1,7 @@ # Streaming Health And WebSocket The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches, key-rotation rebuilds and terminal continuations are also counted without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. Codex WebSocket quota-family normalization remains generic; retired-model evidence is filtered by the [OpenAI quota owner](../providers/openai-tiers.md#public-provider-contract), not by diff --git a/tests/lib/transient-budget-scope-source.test.ts b/tests/lib/transient-budget-scope-source.test.ts index 4a192a43a3..90cfe46a8f 100644 --- a/tests/lib/transient-budget-scope-source.test.ts +++ b/tests/lib/transient-budget-scope-source.test.ts @@ -65,15 +65,15 @@ describe("transient send budget stays request-scoped", () => { // EVERY leg asks for the remainder now, including the adapter initial send. That one used // to pass the raw policy on the argument that nothing had been spent yet -- true for a first // turn, false for a combo child, which inherits the parent's holder and then took a fresh - // full allowance on its own first send. Five sites spell it directly; the two rebuild legs + // full allowance on its own first send. Five sites spell it directly; rebuild and recovery legs // go through recoverySendAllowance, which spends the base allowance first and only then // draws the single shared final-recovery reserve. expect(core.match(/attempts: remainingTransientSendBudget\(/g)).toHaveLength(5); - expect(core).toContain("attempts: remainingTransientSendBudget(transientPolicy.attempts)"); - expect(core).toContain("attempts: remainingTransientSendBudget(continuationTransientPolicy.attempts)"); + expect(core).toContain("attempts: remainingTransientSendBudget(transientPolicy?.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS)"); + expect(core).toContain("attempts: remainingTransientSendBudget(continuationCap)"); // The reserve path: an account move and a validated rebuild share ONE final send, so a // request cannot take both and reach five. - expect(core.match(/recoverySendAllowance\(/g)).toHaveLength(2); + expect(core.match(/recoverySendAllowance\(/g)).toHaveLength(3); expect(core).toContain("countedExternally: true"); // The passthrough legs have no adapter policy to draw from, so they name the helper's own // ceiling rather than re-spelling the number. @@ -140,7 +140,9 @@ describe("every dispatch path reports into the shared budget", () => { // fresh four. expect(compact).toContain("turnAdmissionLease, sendBudget,"); // The handoff child already inherited; both paths must keep doing so. - expect(compact).toContain("{ ...options, sendBudget }"); + expect(compact).toContain("{ ...options, sendBudget: handoffBudget }"); + expect(compact).toContain("sendBudget.deriveScope({"); + expect(compact).toContain("}, hop.permit)"); }); test("credential hops keep their roster cap AND reserve from the shared budget", () => { diff --git a/tests/responses/responses-compaction-routing.test.ts b/tests/responses/responses-compaction-routing.test.ts index c612aed502..1ffbab5ed6 100644 --- a/tests/responses/responses-compaction-routing.test.ts +++ b/tests/responses/responses-compaction-routing.test.ts @@ -1542,7 +1542,7 @@ describe("compact alternate-account attempt (#913)", () => { expect(output.output?.length).toBeGreaterThan(0); expect(logCtx.provider).toBe("deepseek"); expect(calls.at(-1)).toEqual({ model: "deepseek-v4-flash", nativeCompact: false }); - expect(calls.slice(0, -1).length).toBeGreaterThan(0); + expect(calls.slice(0, -1)).toHaveLength(3); // Native ladder + one handoff = four sends. expect(calls.slice(0, -1).every(call => ( call.model === "gpt-5.6-sol" && call.nativeCompact ))).toBe(true); diff --git a/tests/server/server-key-failover-e2e.test.ts b/tests/server/server-key-failover-e2e.test.ts index be60c66155..8ac9f72877 100644 --- a/tests/server/server-key-failover-e2e.test.ts +++ b/tests/server/server-key-failover-e2e.test.ts @@ -15,6 +15,8 @@ import { resetProviderRequestPacingForTest, setProviderRequestPacingRuntimeForTe import { providerApiKeySelectionIsCurrent, resolveCurrentProviderApiKeyTransport } from "../../src/providers/api-key-selection"; import { routedProviderConfig } from "../../src/router"; import type { OcxProviderTransport } from "../../src/providers/xai-transport"; +import { getAccountSet, saveCredential, setActiveAccount } from "../../src/oauth/store"; +import { clearGenericFailoverHealth } from "../../src/oauth/generic-account-failover"; let testDir = ""; let previousHome: string | undefined; @@ -43,6 +45,43 @@ afterEach(() => { }); describe("server 429 key failover (end-to-end)", () => { + test.each([false, true])("OAuth hops charge each reset-only refetch once (third succeeds: %s)", async succeeds => { + const originalFetch = globalThis.fetch; + const authorizations: string[] = []; + let server: ReturnType | undefined; + clearGenericFailoverHealth(); + try { + for (let i = 0; i < 3; i++) { + await saveCredential("nous", { access: `synthetic-oauth-${i}`, refresh: `synthetic-refresh-${i}`, + expires: Date.now() + 3_600_000, accountId: `fixture-account-${i}` }, { addAccount: true }); + } + await setActiveAccount("nous", getAccountSet("nous")!.accounts[0]!.id); + saveConfig({ port: 0, hostname: "127.0.0.1", defaultProvider: "nous", providers: { nous: { + adapter: "openai-chat", authMode: "oauth", baseUrl: "https://oauth-refetch.invalid/v1", models: ["test"], + } } } as OcxConfig); + server = startServer(0); + globalThis.fetch = (async (_input, init) => { + authorizations.push(new Headers(init?.headers).get("authorization") ?? ""); + if (succeeds && authorizations.length === 3) return Response.json({ id: "third-account", object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "third account works" }, finish_reason: "stop" }] }); + return Response.json({ error: { message: `oauth-quota-${authorizations.length}`, type: "rate_limit_error" } }, + { status: 429, headers: { "retry-after": "30" } }); + }) as typeof fetch; + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "nous/test", stream: false, input: "hello" }), + }); + const text = await response.text(); + expect(authorizations).toEqual(["Bearer synthetic-oauth-0", "Bearer synthetic-oauth-1", "Bearer synthetic-oauth-2"]); + expect(response.status).toBe(succeeds ? 200 : 429); + expect(text).toContain(succeeds ? "third account works" : "oauth-quota-3"); + } finally { + globalThis.fetch = originalFetch; + await server?.stop(true); + clearGenericFailoverHealth(); + } + }); + test.each(["429", "5xx", "reset"] as const)("reset-only combo key rotations preserve later targets (%s)", async mode => { const originalFetch = globalThis.fetch; const counts = [0, 0, 0]; From 6da3d84bc33ad99bbd4a8dd69a7cdd8cf294253c Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 15 Sep 2026 19:59:33 +0900 Subject: [PATCH 10/23] fix(responses): preserve extracted budget wiring and pre-dispatch refunds Port the PR final dispatch deltas to current owners. Retry helpers settle the prepaid hop, while adapter sends retain the current dev dispatch-boundary confirmation and unused-hop refund. Product tests and CI not run here. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- src/server/responses/adapter-continuation.ts | 42 ++++++-- src/server/responses/adapter-dispatch.ts | 59 +++++----- src/server/responses/combo-send-budget.ts | 2 +- src/server/responses/core-combo.ts | 101 +----------------- src/server/responses/request-send-budget.ts | 32 ++++++ src/server/responses/sidecar-execution.ts | 4 +- structure/transports/responses.md | 2 +- tests/helpers/responses-core-source.ts | 1 + .../responses/responses-core-modules.test.ts | 4 +- 9 files changed, 110 insertions(+), 137 deletions(-) diff --git a/src/server/responses/adapter-continuation.ts b/src/server/responses/adapter-continuation.ts index a1db9398d5..f87514d901 100644 --- a/src/server/responses/adapter-continuation.ts +++ b/src/server/responses/adapter-continuation.ts @@ -24,6 +24,8 @@ import { } from "../../providers/key-failover"; import { fetchWithTransientRetry, + TRANSIENT_RETRY_MAX_ATTEMPTS, + SendBudgetExhaustedError, fetchWithResetRetry, applyUpstreamRecoveryInit, prepareSameTarget429Wait, @@ -87,6 +89,11 @@ export function createAdapterContinuations( | "remainingTransientSendBudget" | "noteTransientSends" | "reserveCredentialHop" + | "pendingHopPermit" + | "keyPoolFailovers" + | "recoverySendAllowance" + | "recoveryClassFor" + | "sendBudgetExhausted" >, adapterExchange: Pick< AdapterExchange, @@ -95,6 +102,7 @@ export function createAdapterContinuations( | "rateLimitPolicy" | "rateLimitRetries" | "stallTimeoutMs" + | "keyPool429RetryAllowed" >, ) { const { options, logCtx, config } = requestContext; @@ -115,6 +123,9 @@ export function createAdapterContinuations( remainingTransientSendBudget, noteTransientSends, reserveCredentialHop, + recoverySendAllowance, + recoveryClassFor, + sendBudgetExhausted, } = sendBudgetState; @@ -203,8 +214,17 @@ export function createAdapterContinuations( const fetchContinuationWithRetryPolicy = (route.provider.adapter === "google" || continuationTransientPolicy) ? fetchWithTransientRetry : fetchWithResetRetry; - return await fetchContinuationWithRetryPolicy( + const continuationCap = continuationTransientPolicy?.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS; + const allowance = replayKind + ? recoverySendAllowance(continuationCap, recoveryClassFor(replayKind), + `${route.providerName}|${route.modelId}|${replayKind}`) + : { attempts: remainingTransientSendBudget(continuationCap), permit: undefined }; + try { + return await fetchContinuationWithRetryPolicy( recovery => { + if (allowance.permit && !allowance.permit.use()) { + throw new SendBudgetExhaustedError(safeHostLabel(builtContinuationRequest.url)); + } noteAttemptSend(logCtx.activeAttempt, continuationEstimate, recovery ?? replayKind); return fetchWithHeaderTimeout( builtContinuationRequest.url, @@ -229,19 +249,19 @@ export function createAdapterContinuations( // Same request-scoped budget as the initial send and the 429/rotation refetches: // a terminal-guard continuation is another leg of ONE request, so handing it a // fresh `attempts` would let one request exceed the configured total-send ceiling. - ...(continuationTransientPolicy - ? { - attempts: remainingTransientSendBudget(continuationTransientPolicy.attempts), - onSendsConsumed: noteTransientSends, - } - : {}), + attempts: allowance.attempts, + onSendsConsumed: noteTransientSends, }, ); + } finally { + allowance.permit?.release(); + } } finally { builtContinuationRequest.releaseBodyObservation?.(); } }; while (true) { + const continuationHop = sendBudgetState.pendingHopPermit; try { const recoveryKind = nextContinuationRecoveryKind; nextContinuationRecoveryKind = undefined; @@ -253,6 +273,9 @@ export function createAdapterContinuations( yield { type: "error", message: `Provider continuation failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}` }; } return; + } finally { + sendBudgetState.pendingHopPermit = undefined; + continuationHop?.release(); } // Same-target 429 wait-and-retry (opt-in `retryOn429`) before key/account failover: @@ -262,6 +285,7 @@ export function createAdapterContinuations( response.status === 429 && rateLimitPolicy !== null && adapterExchange.rateLimitRetries < rateLimitPolicy.attempts + && !sendBudgetExhausted() ) { adapterExchange.rateLimitRetries += 1; // Release unread body + heartbeat-fed wait via the shared same-target helper. @@ -308,8 +332,10 @@ export function createAdapterContinuations( now: Date.now(), attemptedKey: route.provider.apiKey, promptCacheKey: nextParsed.options.promptCacheKey, + allowRotation: adapterExchange.keyPool429RetryAllowed(), }); if (rotated) { + sendBudgetState.keyPoolFailovers += 1; try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } route.provider = rotated; invalidateSameTargetRequest(); @@ -388,6 +414,7 @@ export function createAdapterContinuations( const hop = reserveCredentialHop( "auth-recovery", `${route.providerName}|${route.modelId}|continuation-oauth-429`, + !transportState.activeAdapter.fetchResponse, ); const nextAccountId = hop.allowed ? rotateGenericOAuthAccountOn429( @@ -418,6 +445,7 @@ export function createAdapterContinuations( sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel); recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name); nextContinuationRecoveryKind = "oauth-account-429"; + sendBudgetState.pendingHopPermit = transportState.activeAdapter.fetchResponse ? undefined : hop.permit; continue; } } catch { diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index 57eadf6a28..b5fd739c0c 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -29,6 +29,7 @@ import { } from "../../providers/key-failover"; import { fetchWithTransientRetry, + TRANSIENT_RETRY_MAX_ATTEMPTS, fetchWithResetRetry, applyUpstreamRecoveryInit, SendBudgetExhaustedError, @@ -127,6 +128,9 @@ export async function prepareAdapterExchange( | "recoveryClassFor" | "sendBudgetExhausted" | "reserveCredentialHop" + | "pendingHopPermit" + | "keyPoolFailovers" + | "keyPool429RetryAllowed" >, ) { const { options, config, logCtx, req } = requestContext; @@ -171,6 +175,7 @@ export async function prepareAdapterExchange( ? Math.floor(config.stallTimeoutSec * 1000) : 300_000; transportState.activeAdapter = transportState.adapter; + const keyPool429RetryAllowed = () => sendBudgetState.keyPool429RetryAllowed(!!transportState.activeAdapter.fetchResponse); // One immutable, body-safe outbound request per same-target sequence (URL, serialized body, // auth headers, generated compat headers). Same-target 429 replays reuse it verbatim; the @@ -315,15 +320,9 @@ export async function prepareAdapterExchange( { abortSignal: upstream.signal, label: safeHostLabel(builtInitialRequest.url), - ...(transientPolicy - // Draws the remainder, not the raw policy. A combo child inherits the parent's - // holder but used to take a fresh full allowance on its own first send, so the - // shared counter was inherited without ever being read as a limit. - ? { - attempts: remainingTransientSendBudget(transientPolicy.attempts), - onSendsConsumed: noteTransientSends, - } - : {}), + // Count both retry policies; reset-only still hops immediately on HTTP 5xx. + attempts: remainingTransientSendBudget(transientPolicy?.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS), + onSendsConsumed: noteTransientSends, }, ); } @@ -448,13 +447,11 @@ export async function prepareAdapterExchange( // Same rule as the passthrough rebuild: spend the base allowance first, then the one // shared final-recovery reserve, so a recovery that follows a spent streak still gets // its single send instead of dying at three. - const refetchAllowance = refetchTransientPolicy - ? recoverySendAllowance( - refetchTransientPolicy.attempts, - recoveryClassFor(recovery), - `${route.providerName}|${route.modelId}|${recovery}`, - ) - : undefined; + const refetchAllowance = recoverySendAllowance( + refetchTransientPolicy?.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS, + recoveryClassFor(recovery), + `${route.providerName}|${route.modelId}|${recovery}`, + ); try { return await refetchWithPolicy( recoveryKind => { @@ -644,8 +641,10 @@ export async function prepareAdapterExchange( now: Date.now(), attemptedKey: route.provider.apiKey, promptCacheKey: parsed.options.promptCacheKey, + allowRotation: keyPool429RetryAllowed(), }); if (!rotated) break; + sendBudgetState.keyPoolFailovers += 1; // Release the failed response's socket before retrying; unread bodies otherwise linger // until runtime cleanup (one per rotated key under a rate-limit storm). try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } @@ -725,6 +724,7 @@ export async function prepareAdapterExchange( const hop = reserveCredentialHop( "auth-recovery", `${route.providerName}|${route.modelId}|adapter-recovery-oauth-429`, + !transportState.activeAdapter.fetchResponse, ); if (!hop.allowed) break; const nextAccountId = rotateGenericOAuthAccountOn429( @@ -755,22 +755,22 @@ export async function prepareAdapterExchange( ); sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel); recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name); - // Confirm at the dispatch boundary, not here: a rebuild can fail while shaping the - // request and return `{ failed }` without reaching the wire, and a permit confirmed - // before that would hold the charge for a send that never happened. - const result = await rebuildAndRefetch("oauth-account-429", () => { hop.permit?.use(); }); - if ("failed" in result) { - // A no-op if the boundary was reached; a refund if the rebuild died before it. + // Retry helpers settle the externally counted hop themselves. Adapter-owned + // sends confirm only after pacing, preserving the release-on-rebuild-failure fix. + sendBudgetState.pendingHopPermit = transportState.activeAdapter.fetchResponse ? undefined : hop.permit; + try { + const result = await rebuildAndRefetch("oauth-account-429", + transportState.activeAdapter.fetchResponse ? () => { hop.permit?.use(); } : undefined); + if ("failed" in result) return result.failed; + upstreamResponse = result; + } finally { + sendBudgetState.pendingHopPermit = undefined; + // Idempotent: refund only an unused reservation, never a dispatched send. hop.permit?.release(); - return result.failed; } - upstreamResponse = result; } catch { - // A throw before the send — snapshot fetch, credential application, adapter - // resolution — must hand the reservation back. Without this the ladder charges the - // request for a send it never made, and a later recovery in the same request is - // refused on an allowance nothing spent. release() is idempotent and a no-op once - // used, so a throw from the rebuild keeps its charge. + // Snapshot, credential and adapter-resolution failures may precede dispatch. + // Both adapter and retry-helper paths must refund an unused reservation. hop.permit?.release(); break; } @@ -954,6 +954,7 @@ export async function prepareAdapterExchange( return { upstream, + keyPool429RetryAllowed, cleanupUpstreamAbort, connectMs, stallTimeoutMs, diff --git a/src/server/responses/combo-send-budget.ts b/src/server/responses/combo-send-budget.ts index 6217de3617..371afebbe5 100644 --- a/src/server/responses/combo-send-budget.ts +++ b/src/server/responses/combo-send-budget.ts @@ -5,7 +5,7 @@ import { CODEX_TEXT_GUARDED_BUDGET_POLICY, type RequestExecutionBudget, type Req * request as far as its own provider is concerned, so this is the guarded profile's base * allowance rather than a separate number to keep in sync. */ -const COMBO_TARGET_BASE_SENDS = CODEX_TEXT_GUARDED_BUDGET_POLICY.baseSendAllowance; +export const COMBO_TARGET_BASE_SENDS = CODEX_TEXT_GUARDED_BUDGET_POLICY.baseSendAllowance; /** * A combo's execution policy is DECLARED by the combo, not inherited from the single-target diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 43863fc294..3f4325ce43 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -1,12 +1,4 @@ -import { - CODEX_TEXT_GUARDED_BUDGET_POLICY, - createRequestExecutionBudget, - isRequestExecutionBudget, -} from "../../lib/request-execution-budget"; -import type { - RequestExecutionBudgetPolicy, - RequestExecutionBudget, -} from "../../lib/request-execution-budget"; +import { isRequestExecutionBudget } from "../../lib/request-execution-budget"; import type { OcxConfig } from "../../types"; import type { RequestLogContext } from "../request-log"; import type { HandleResponsesOptions, ResponsesDispatchers, ConsumedComboFailure } from "./core-options"; @@ -66,93 +58,8 @@ import { import { preflightComboStreamResponse } from "./combo-stream-preflight"; import { streamingContextOverflowResponse, jsonContextOverflowResponse } from "./context-overflow"; -/** - * Sends one combo target may run on its own before the ladder moves on. A target is a whole - * request as far as its own provider is concerned, so this is the guarded profile's base - * allowance rather than a separate number to keep in sync. - */ -export const COMBO_TARGET_BASE_SENDS = CODEX_TEXT_GUARDED_BUDGET_POLICY.baseSendAllowance; - - -/** - * A combo's execution policy is DECLARED by the combo, not inherited from the single-target - * profile. - * - * `maxTargetTransitions: 1` and `maxAlternateTargetSends: 1` describe an account move, and - * applying them to a combo would refuse the second hop of a three-target combo -- which is why - * combo was left off `reserveDispatch` when the per-request split landed. The transitions a - * combo may make are exactly the targets it declares minus the one it starts on. What stays - * capped is the TOTAL: the first target's full ladder, one send for every further declared - * target, and the one shared final-recovery reserve. A one-target combo reduces to the guarded - * profile exactly, and a three-target combo whose every target fails hard reaches upstream six - * times instead of the twelve #4546 measured. - */ -export function comboExecutionBudgetPolicy(declaredTargets: number): RequestExecutionBudgetPolicy { - const targets = Math.max(1, Math.trunc(declaredTargets)); - const hops = targets - 1; - const reserve = CODEX_TEXT_GUARDED_BUDGET_POLICY.finalRecoveryAllowance; - const total = COMBO_TARGET_BASE_SENDS + hops + reserve; - return { - maxTotalModelSends: total, - baseSendAllowance: total - reserve, - finalRecoveryAllowance: reserve, - maxAlternateTargetSends: Math.max(1, hops), - maxTargetTransitions: Math.max(1, hops), - }; -} - - -/** - * A budget scope that keeps its own recovery ledgers but spends the SAME request-wide counter. - * - * `used` is redefined as an accessor onto the parent because the factory reads it back off this - * object -- `remainingBaseSends` and the total check both do -- so a copied number would let a - * combo target run its ladder against a stale total, which is precisely the per-layer counting - * this work exists to remove. The reserve, alternate-target and transition ledgers stay - * per-scope on purpose: a combo target's account failover is its own recovery decision, while - * the request total still bounds every target together. - */ -export function deriveSendBudgetScope( - parent: RequestExecutionBudget, - policy: RequestExecutionBudgetPolicy, -): RequestExecutionBudget { - const scope = createRequestExecutionBudget(policy, parent.logicalRequestId); - Object.defineProperty(scope, "used", { - get: () => parent.used, - set: (value: number) => { parent.used = value; }, - enumerable: true, - configurable: true, - }); - return scope; -} - - -/** - * The ladder one combo target may run, expressed as an allowance on the request-wide counter. - * - * `used + COMBO_TARGET_BASE_SENDS` gives this target its own ladder from wherever the request - * already stands, and the clamp holds back one send for each target still declared after it: a - * first target that 5xx-streaks must not eat the send the last declared target is entitled to. - * That guarantee is the difference between a per-target policy and a shared pool the first - * target drains. - */ -export function comboTargetSendBudget( - comboScope: RequestExecutionBudget, - targetsDeclaredAfterThisOne: number, -): RequestExecutionBudget { - const policy = comboScope.policy; - const heldForLaterTargets = Math.max(0, targetsDeclaredAfterThisOne); - const ceiling = Math.max(1, policy.maxTotalModelSends - heldForLaterTargets); - return deriveSendBudgetScope(comboScope, { - maxTotalModelSends: policy.maxTotalModelSends, - baseSendAllowance: Math.min(ceiling, comboScope.used + COMBO_TARGET_BASE_SENDS), - finalRecoveryAllowance: policy.finalRecoveryAllowance, - // Within one target the account-move shape is unchanged: three same-account sends plus one - // alternate is the recovery live traffic depends on, and a combo does not widen it. - maxAlternateTargetSends: CODEX_TEXT_GUARDED_BUDGET_POLICY.maxAlternateTargetSends, - maxTargetTransitions: CODEX_TEXT_GUARDED_BUDGET_POLICY.maxTargetTransitions, - }); -} +import { comboExecutionBudgetPolicy, deriveSendBudgetScope, comboTargetSendBudget } from "./combo-send-budget"; +export { COMBO_TARGET_BASE_SENDS, comboExecutionBudgetPolicy, deriveSendBudgetScope, comboTargetSendBudget } from "./combo-send-budget"; export async function executeComboResponses( @@ -391,7 +298,7 @@ export async function executeComboResponses( break; } const targetSendBudget = comboSendScope - ? comboTargetSendBudget(comboSendScope, combo.targets.length - 1 - comboTargetsDispatched) + ? comboTargetSendBudget(comboSendScope, combo.targets.length - 1 - comboTargetsDispatched, hopDecision?.allowed ? hopDecision.permit : undefined) : options.sendBudget; comboTargetsDispatched += 1; const childLog: RequestLogContext = { diff --git a/src/server/responses/request-send-budget.ts b/src/server/responses/request-send-budget.ts index c5879e106f..202e2992c6 100644 --- a/src/server/responses/request-send-budget.ts +++ b/src/server/responses/request-send-budget.ts @@ -1,4 +1,6 @@ import type { ResponsesRequestContext } from "./core-options"; +import type { PreparedResponsesRequest } from "./request-prepare"; +import { transientRetryPolicyFor } from "../../providers/key-failover"; import { createRequestExecutionBudget, isRequestExecutionBudget } from "../../lib/request-execution-budget"; import { chargeWorkflowSends, workflowSendCeilingReached } from "../../lib/workflow-budget"; import { workflowRefusalResponse } from "../workflow-refusal"; @@ -10,8 +12,16 @@ import type { SingleUseDispatchPermit, SendClass } from "../../lib/request-execu /** Owns the shared request send counter and recovery permits. */ export function createResponsesSendBudget( requestContext: Pick, + requestState: Pick, ) { const { options, req, logCtx } = requestContext; + const { route } = requestState; + // Capture before sidecar dispatch; every phase shares this rotation count. + const initialKeyPool = route.provider.apiKeyPool ?? []; + const initialKeyReference = route.provider._apiKeyAttempt?.reference ?? route.provider.apiKey; + const initialKeyIsPooled = initialKeyPool.some(entry => entry.key === initialKeyReference); + const maxKeyPoolFailovers = Math.max(0, initialKeyPool.length - (initialKeyIsPooled ? 1 : 0)); + let keyPoolFailovers = 0; // One transient-retry budget for the whole LOGICAL request, read ABOVE the passthrough branch @@ -142,7 +152,29 @@ export function createResponsesSendBudget( const recoveryClassFor = (recovery: AttemptRecoveryKind): SendClass => /401|429|oauth|rate-limit|key/.test(recovery) ? "auth-recovery" : "repair"; + const keyPool429RetryAllowed = (adapterOwnsSends: boolean): boolean => { + if (keyPoolFailovers >= maxKeyPoolFailovers) return false; + if (adapterOwnsSends) return !adapterSendBudget + || adapterSendBudget.remainingBaseSends(adapterSendBudget.policy.baseSendAllowance) > 0; + const policy = transientRetryPolicyFor(route.provider); + const attempts = policy?.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS; + if (!Number.isInteger(attempts) || attempts <= 0) return false; + if (remainingTransientSendBudget(attempts) > 0) return true; + if (!isRequestExecutionBudget(sendBudget)) return false; + const decision = sendBudget.reserveDispatch({ + sendClass: "auth-recovery", targetKey: `${route.providerName}|${route.modelId}|key-429`, + countedExternally: true, + }); + if (!decision.allowed) return false; + decision.permit.release(); + return true; + }; + return { + maxKeyPoolFailovers, + get keyPoolFailovers() { return keyPoolFailovers; }, + set keyPoolFailovers(value: number) { keyPoolFailovers = value; }, + keyPool429RetryAllowed, workflowRootId, noteTransientSends, remainingTransientSendBudget, diff --git a/src/server/responses/sidecar-execution.ts b/src/server/responses/sidecar-execution.ts index 7987d5ca93..36af7605e6 100644 --- a/src/server/responses/sidecar-execution.ts +++ b/src/server/responses/sidecar-execution.ts @@ -74,7 +74,7 @@ export async function executeResponsesSidecars( | "notifyResponseComplete" | "cancelResponseCompletion" >, - sendBudgetState: Pick, + sendBudgetState: Pick, ) { const { config, options, logCtx } = requestContext; const { @@ -159,8 +159,10 @@ export async function executeResponsesSidecars( now: Date.now(), attemptedKey: route.provider.apiKey, promptCacheKey: parsed.options.promptCacheKey, + allowRotation: sendBudgetState.keyPoolFailovers < sendBudgetState.maxKeyPoolFailovers, }); if (rotated) { + sendBudgetState.keyPoolFailovers += 1; route.provider = rotated; } else if ( // A POSITIVE gate, not an early return. An early `return null` here made every later arm diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 57f6948c80..604d393097 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -138,7 +138,7 @@ send has already earned a throttle. ### Bounded API-key 429 rotation -Each generic `handleResponsesInner` invocation in `src/server/responses/core.ts` captures a failover allowance before its first send: the initial pool size minus one when the initially selected key reference belongs to that pool, or the full initial pool size when it is outside the pool and shares that failover count across ordinary recovery, web-search/image/video bridge rounds and terminal continuations. Initialization precedes sidecar dispatch. Sidecars enforce this count only at their API-key rotation callback, retaining their own reset and same-target retry policies; the ordinary core physical-send budget is not newly applied to them, and OAuth/Anthropic account fallback remains available independently. Cooldown expiry and later pool growth do not refill it. This is a failover count, not a distinct-key guarantee or a new combo-wide budget; existing shared physical-send admission remains authoritative. Before cancelling a superseded response or choosing another key, the ordinary response path checks both limits. Generic reset-only initial fetches, key-rotation rebuilds and terminal continuations consume the same physical-send budget; opting out of transient-5xx retries does not bypass the child ceiling or enable 5xx retries. Initial and continuation recovery admission probes release their unused dispatch reservations immediately; the actual refetch owns the charged send permit. Generic OAuth hops replayed through the retry helpers mark their reservation as externally counted and pass it into the refetch, so its first physical send settles the booking once. An unused retry-helper hop is refunded on failure or abort. Terminal-continuation key recovery can draw the same single final reserve after the base allowance is spent. Combo policy and child-scope construction live in `src/server/responses/combo-send-budget.ts`; their scopes derive through `src/lib/request-execution-budget.ts`, sharing both charged reservations and pending external-send settlement. Remaining-allowance checks and unused-permit refunds use that same ledger, while recovery and target-transition ledgers remain scope-local. Each child has a hard total ceiling that preserves one physical send for every later declared target, including when an earlier target uses recovery. An exact parent hop permit supplies the child's prepaid initial send; adapter-owned reservations and external send reports settle that booking once, rather than charging the hop and the physical send twice. Three plain-5xx targets use six physical sends, with the final combo hop eligible for recovery allowance. `tests/lib/execution-budget-permits.test.ts` checks interleaved scopes and refunds; `tests/responses/responses-send-budget-counts.test.ts` counts physical sends and verifies that every target is reached. On refusal, `src/providers/key-failover.ts` accepts `allowRotation: false` to record the proven failed key's cooldown through the existing selection-identity fence, without selecting, persisting or announcing an unattempted replacement. The original failure stays available to normal response handling, including an already-started streaming continuation. `tests/server/server-key-failover-e2e.test.ts` covers expiry, an initially unpooled key, actual web-search/image bridge dispatch, shared continuation limits and earlier send-budget exhaustion; `tests/adapters/key-failover.test.ts` verifies health-only recording, unchanged disk bytes and manual-selection ownership. +The request-owned budget in `src/server/responses/request-send-budget.ts` captures a failover allowance before sidecar or adapter dispatch: the initial pool size minus one when the initially selected key reference belongs to that pool, or the full initial pool size when it is outside the pool and shares that failover count across ordinary recovery, web-search/image/video bridge rounds and terminal continuations. Initialization precedes sidecar dispatch. Sidecars enforce this count only at their API-key rotation callback, retaining their own reset and same-target retry policies; the ordinary core physical-send budget is not newly applied to them, and OAuth/Anthropic account fallback remains available independently. Cooldown expiry and later pool growth do not refill it. This is a failover count, not a distinct-key guarantee or a new combo-wide budget; existing shared physical-send admission remains authoritative. Before cancelling a superseded response or choosing another key, the ordinary response path checks both limits. Generic reset-only initial fetches, key-rotation rebuilds and terminal continuations consume the same physical-send budget; opting out of transient-5xx retries does not bypass the child ceiling or enable 5xx retries. Initial and continuation recovery admission probes release their unused dispatch reservations immediately; the actual refetch owns the charged send permit. Generic OAuth hops replayed through the retry helpers mark their reservation as externally counted and pass it into the refetch, so its first physical send settles the booking once. An unused retry-helper hop is refunded on failure or abort. Terminal-continuation key recovery in `src/server/responses/adapter-continuation.ts` can draw the same single final reserve after the base allowance is spent. The quota-confirmed compact handoff in `src/server/responses/compact.ts` also transfers an exact prepaid reservation into its routed child; native three-send exhaustion still leaves the one allowed recovery send, without minting a second budget. Combo policy and child-scope construction live in `src/server/responses/combo-send-budget.ts`; their scopes derive through `src/lib/request-execution-budget.ts`, sharing both charged reservations and pending external-send settlement. Remaining-allowance checks and unused-permit refunds use that same ledger, while recovery and target-transition ledgers remain scope-local. Each child has a hard total ceiling that preserves one physical send for every later declared target, including when an earlier target uses recovery. An exact parent hop permit supplies the child's prepaid initial send; adapter-owned reservations and external send reports settle that booking once, rather than charging the hop and the physical send twice. Three plain-5xx targets use six physical sends, with the final combo hop eligible for recovery allowance. `tests/lib/execution-budget-permits.test.ts` checks interleaved scopes and refunds; `tests/responses/responses-send-budget-counts.test.ts` counts physical sends and verifies that every target is reached. On refusal, `src/providers/key-failover.ts` accepts `allowRotation: false` to record the proven failed key's cooldown through the existing selection-identity fence, without selecting, persisting or announcing an unattempted replacement. The original failure stays available to normal response handling, including an already-started streaming continuation. `tests/server/server-key-failover-e2e.test.ts` covers expiry, an initially unpooled key, actual web-search/image bridge dispatch, shared continuation limits and earlier send-budget exhaustion; `tests/adapters/key-failover.test.ts` verifies health-only recording, unchanged disk bytes and manual-selection ownership. ### Routed service-tier capability diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index 35fea3be44..52e31367f0 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -17,6 +17,7 @@ export const RESPONSES_CORE_MODULES = [ "core-auth.ts", "core-normalize.ts", "core-combo.ts", + "combo-send-budget.ts", "request-prepare.ts", "request-transport.ts", "request-sidecar-auth.ts", diff --git a/tests/responses/responses-core-modules.test.ts b/tests/responses/responses-core-modules.test.ts index 2d94f02f98..b13733ed1b 100644 --- a/tests/responses/responses-core-modules.test.ts +++ b/tests/responses/responses-core-modules.test.ts @@ -118,7 +118,9 @@ function budgetOwner(sendBudget: TransientSendBudget) { req: new Request("http://localhost/v1/responses"), logCtx: { model: "test", provider: "test" }, options: { translatorBudget, sendBudget }, - }); + }, { route: { providerName: "test", modelId: "test", provider: { + adapter: "openai-chat", authMode: "key", baseUrl: "https://budget-fixture.invalid/v1", + } } } as Parameters[1]); if (result instanceof Response) { translatorBudget.dispose(); throw new Error("Unexpected workflow refusal without a workflow root"); From 3b08288733089f07944ecd75547ad7608831717f Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 15 Sep 2026 22:22:26 +0900 Subject: [PATCH 11/23] test(responses): match the oauth-429 dispatch ladder by shape, not by exact source text The source-oracle assertions in tests/lib/execution-budget-permits.test.ts were written against an earlier form of the adapter-recovery-oauth-429 block and did not survive this branch's own refactor of it, so Cross-platform CI was red on test 3/4 and macos 1/2 with an identical failure on both. Two things moved. The confirm callback became conditional on the adapter owning its own fetchResponse, so the exact substring rebuildAndRefetch("oauth-account-429", () => { hop.permit?.use(); }) no longer appears anywhere. And the refund moved out of the failed arm into a finally, so one idempotent release now covers the failed return, the success path and a throw, which means the per-branch release the old assertion looked for is gone by design. Both assertions are rewritten to bind the behaviour instead of the formatting: that rebuildAndRefetch is called with the oauth-account-429 tag and reaches hop.permit?.use() within a bounded window, that the reservation is parked in pendingHopPermit when the retry helper rather than the adapter will settle it, that the failed arm returns, and that the finally releases. The neighbouring assertions in this file already use indexOf and toMatch for exactly this reason; this line was the only exact multi-line substring left, and it is why an otherwise sound change went red. No runtime code changed. 14 pass / 0 fail on bun test tests/lib/execution-budget-permits.test.ts. Co-authored-by: luvs01 --- tests/lib/execution-budget-permits.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/lib/execution-budget-permits.test.ts b/tests/lib/execution-budget-permits.test.ts index 54e5843f38..93da4fbd2a 100644 --- a/tests/lib/execution-budget-permits.test.ts +++ b/tests/lib/execution-budget-permits.test.ts @@ -348,8 +348,18 @@ describe("generic-OAuth hop reservations are handed back when no send happens", "adapter-recovery-oauth-429", "attemptOpaqueBlobRecovery", ); - expect(block).toContain('rebuildAndRefetch("oauth-account-429", () => { hop.permit?.use(); })'); - expect(block).toMatch(/if \("failed" in result\) \{[^}]*hop\.permit\?\.release\(\)/); + // The confirm callback is adapter-owned: when the adapter carries its own fetchResponse the + // dispatch boundary lives inside it, so the callback is handed down; otherwise the reservation + // is parked in pendingHopPermit for the retry helper to settle. Match the shape rather than an + // exact source substring, because an exact multi-line substring breaks on any reformatting in + // this block without the behaviour having changed. + expect(block).toMatch(/rebuildAndRefetch\(\s*"oauth-account-429",[\s\S]{0,240}?hop\.permit\?\.use\(\)/); + expect(block).toMatch(/pendingHopPermit = [^;]*fetchResponse \? undefined : hop\.permit/); + // The refund moved out of the failed arm and into a finally, so one idempotent release now + // covers the failed return, the success path and a throw. Assert that shape rather than a + // per-branch release that no longer exists. + expect(block).toMatch(/if \("failed" in result\) return result\.failed;/); + expect(block).toMatch(/finally \{[^}]*hop\.permit\?\.release\(\)/); expect(block).toMatch(refundsOnThrow); }); From 9c749cc4c8c377d05f4521310d64602d8c7ebe9c Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:25:03 +0900 Subject: [PATCH 12/23] fix(responses): retain prepaid compact recovery through combo scopes --- .../docs/reference/configuration/providers.md | 2 +- src/lib/request-execution-budget.ts | 15 ++++++--- src/server/responses/combo-send-budget.ts | 3 +- structure/adapters/registry.md | 2 +- structure/catalog.md | 2 +- structure/clients/claude-desktop.md | 2 +- structure/clients/integrations.md | 2 +- structure/data-planes/images.md | 2 +- structure/data-planes/inbound-compat.md | 2 +- structure/gui-and-management-api.md | 2 +- structure/ops/docs-and-release.md | 2 +- structure/ops/service-and-sidecars.md | 2 +- structure/overview.md | 2 +- structure/providers/xai-grok.md | 2 +- structure/runtime.md | 2 +- structure/subagents.md | 2 +- structure/transports/byte-accounting.md | 2 +- structure/transports/inventory.md | 2 +- structure/transports/responses.md | 2 +- structure/transports/streaming-health.md | 2 +- tests/lib/execution-budget-permits.test.ts | 32 +++++++++++++++++++ .../responses-compaction-routing.test.ts | 14 ++++---- 22 files changed, 70 insertions(+), 30 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index e44bfc7125..8fd590d0e9 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -225,7 +225,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. | -For translated Responses requests using a multi-key pool, each routed provider invocation shares its API-key rotation allowance between initial recovery, web-search/image/video bridge rounds, and terminal continuations: at most `N - 1` rotations if the initial key belongs to the pool, or `N` if it is outside the pool, where `N` is the pool size before the first send. Cooldown expiry or later pool growth does not replenish this allowance. Existing applicable send budgets may stop retries sooner; sidecar bridges retain their own connection-reset and same-target retry limits, and this rotation cap does not impose the core's physical-send budget on those bridges. Once rotation is refused, the last 429 still records the failed key's cooldown, but no replacement key is selected and the response follows the existing error handling. +For translated Responses requests using a multi-key pool with `authMode: "key"` (excluding OAuth and forward-auth account pools), each routed provider invocation shares its API-key rotation allowance between initial recovery, web-search/image/video bridge rounds, and terminal continuations: at most `N - 1` rotations if the initial key belongs to the pool, or `N` if it is outside the pool, where `N` is the pool size before the first send. Cooldown expiry or later pool growth does not replenish this allowance. Existing applicable send budgets may stop retries sooner; sidecar bridges retain their own connection-reset and same-target retry limits, and this rotation cap does not impose the core's physical-send budget on those bridges. Once rotation is refused, the last 429 still records the failed key's cooldown, but no replacement key is selected and the response follows the existing error handling. Same-key `retryOn429` replays use their separate request-wide retry counter; this allowance counts key rotations only. With `webSearchBridge` enabled, a search continuation stays bound to the API-key selection that served the first request. Changing the selected key, its reference or resolved value, authentication diff --git a/src/lib/request-execution-budget.ts b/src/lib/request-execution-budget.ts index 823c0dfeac..a0b868ddd8 100644 --- a/src/lib/request-execution-budget.ts +++ b/src/lib/request-execution-budget.ts @@ -183,8 +183,15 @@ function createBudgetScope( policy, deriveScope(scopePolicy, prepaid) { const booking = prepaid && permitBookings.get(prepaid); - return createBudgetScope(scopePolicy, logicalRequestId, ledger, - booking?.ledger === ledger ? booking.ticket : undefined); + // Intermediate scopes retain the exact prepaid booking. A dispatched child consumes + // it from the shared ledger, so sibling scopes cannot turn it into a second send. + const inherited = prepaid === undefined ? prepaidBooking : booking?.ledger === ledger ? booking.ticket : undefined; + const inheritedPolicy = prepaid === undefined && inherited && ledger.pendingExternalSends.has(inherited) + ? { ...scopePolicy, maxTotalModelSends: Math.min(scopePolicy.maxTotalModelSends, policy.maxTotalModelSends), + baseSendAllowance: Math.min(scopePolicy.baseSendAllowance, policy.baseSendAllowance), + finalRecoveryAllowance: Math.min(scopePolicy.finalRecoveryAllowance, policy.finalRecoveryAllowance) } + : scopePolicy; + return createBudgetScope(inheritedPolicy, logicalRequestId, ledger, inherited); }, get reserveSpent() { return reserveSpent; }, get alternateTargetSends() { return alternateTargetSends; }, @@ -193,7 +200,7 @@ function createBudgetScope( remainingBaseSends(cap: number): number { const capped = Number.isFinite(cap) ? Math.trunc(cap) : 0; const prepaid = prepaidBooking && ledger.pendingExternalSends.has(prepaidBooking) ? 1 : 0; - return Math.max(0, Math.min(capped, policy.baseSendAllowance - ledger.spent + prepaid)); + return Math.max(0, Math.min(capped, Math.max(prepaid, policy.baseSendAllowance - ledger.spent + prepaid))); }, reserveDispatch(intent: DispatchIntent): DispatchDecision { if (intent.replaySafe === false) return { allowed: false, reason: "not-replay-safe" }; @@ -217,7 +224,7 @@ function createBudgetScope( // The base allowance is spent first. Only once it is gone does a recovery class reach // for the single shared reserve -- an account move and a validated rebuild cannot each // take one. - const drawsReserve = policy.baseSendAllowance - spentBeforeSend <= 0; + const drawsReserve = !inherited && policy.baseSendAllowance - spentBeforeSend <= 0; if (drawsReserve) { if (!RESERVE_FUNDED_CLASSES.has(intent.sendClass)) { return { allowed: false, reason: "base-allowance-exhausted" }; diff --git a/src/server/responses/combo-send-budget.ts b/src/server/responses/combo-send-budget.ts index 371afebbe5..7ec4b94539 100644 --- a/src/server/responses/combo-send-budget.ts +++ b/src/server/responses/combo-send-budget.ts @@ -67,7 +67,8 @@ export function comboTargetSendBudget( ): RequestExecutionBudget { const policy = comboScope.policy; const heldForLaterTargets = Math.max(0, targetsDeclaredAfterThisOne); - const ceiling = Math.max(1, policy.maxTotalModelSends - heldForLaterTargets); + const ceiling = Math.min(policy.maxTotalModelSends, + Math.max(comboScope.used, 1, policy.maxTotalModelSends - heldForLaterTargets)); return deriveSendBudgetScope(comboScope, { maxTotalModelSends: ceiling, baseSendAllowance: Math.min(ceiling, comboScope.used - (prepaid ? 1 : 0) + COMBO_TARGET_BASE_SENDS), diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 24826e9f03..dc39725b44 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -4,7 +4,7 @@ Request-local adapter bindings are separate from registry authority in the Respo [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](../transports/responses.md#passthrough-sse-stream-shapes-314). diff --git a/structure/catalog.md b/structure/catalog.md index 30aa7642c0..0b5b4e872b 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -4,7 +4,7 @@ Catalog discovery remains separate from the Responses final-route [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 6229bd8002..28cb0f6ed1 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -4,7 +4,7 @@ Desktop callers retain their existing ingress through the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Codex-native model discovery follows the [shared retirement policy](../catalog.md#shared-catalog). That projection does not migrate existing user-selected Desktop configuration or usage history. diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 40f22f9046..fc84009944 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -1,6 +1,6 @@ # Client Integrations -Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. +Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. Nested compact/combo recovery follows the [prepaid send-budget contract](../transports/responses.md#bounded-api-key-429-rotation). The client-integration subsystem writes one generated OpenCodex provider contribution into a third-party client's existing config without taking ownership of the rest of that file. Its core diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index fd88e529b5..fc15593bc2 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -4,7 +4,7 @@ Vision preprocessing and image/video/search execution use the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Hosted Responses image-tool eligibility uses the shared compatibility policy without a Codex Spark exception; standalone Images retain the separate relay contract below. See diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 95584bd382..811eb2971a 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -4,7 +4,7 @@ Compatibility callers retain the public Responses ingress described by the [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. ## Standalone file transcription diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index c54f194098..7709788aa7 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -4,7 +4,7 @@ The shared server request path follows the Responses [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. ## Dashboard serving diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 9208ae519b..aec7c5323d 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -1,7 +1,7 @@ # Docs And Release The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. Nested compact/combo recovery follows the [prepaid send-budget contract](../transports/responses.md#bounded-api-key-429-rotation). Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index a17c7a1802..96f2e968d6 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -4,7 +4,7 @@ Service endpoints are unchanged by the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Service startup and restore use the [catalog retirement policy](../catalog.md#shared-catalog); retirement does not itself change service registration or user-selected model configuration. diff --git a/structure/overview.md b/structure/overview.md index bee5fe9b63..d80872ccc8 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -1,7 +1,7 @@ # Overview The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local. Nested compact/combo recovery follows the [prepaid send-budget contract](transports/responses.md#bounded-api-key-429-rotation). Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 436de99fdc..64bec93299 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -4,7 +4,7 @@ xAI uses the same shared credential and delivery policies through the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Codex-native retirement is scoped to OpenAI catalog/quota evidence. Shared Responses handling retains xAI provider behavior; see diff --git a/structure/runtime.md b/structure/runtime.md index 33e6c185e8..f8d0044efb 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -4,7 +4,7 @@ Responses admission and finalization are composed through the [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Chat request serialization owns the destination-scoped [OpenCode Go instruction ordering](providers/chat-compat.md#opencode-go-chronological-instructions); diff --git a/structure/subagents.md b/structure/subagents.md index eb6c909887..5e32fdf423 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -10,7 +10,7 @@ Encrypted-task and fallback request handling follow the Responses only explicit true on Responses ingress to the final canonical ChatGPT forward route activates it. A default top-level collaboration catalog is required. The compiler preserves caller objects, aliases the namespace and three message functions, and removes only their true encryption marker. -Declaration/reference collisions refuse the whole rewrite without changing the request. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. +Declaration/reference collisions refuse the whole rewrite without changing the request. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. `src/adapters/openai-responses.ts` returns request-local alias capabilities. The Responses core refreshes them after every request rebuild and restores JSON, SSE and WebSocket identities after diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 68e150f139..218cc14ebd 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -5,7 +5,7 @@ Responses body-reader limits and lifetime handling follow the How opencodex measures request and stream bytes without allocating copies solely to count them. These contracts are shared by request parsing, SSE rewriting, the provider adapters and -the translator budget, which is why so many documents link here rather than restating them. Response-attached WebSocket telemetry follows the [stage record identity contract](responses.md#passthrough-sse-stream-shapes-314). Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. +the translator budget, which is why so many documents link here rather than restating them. Response-attached WebSocket telemetry follows the [stage record identity contract](responses.md#passthrough-sse-stream-shapes-314). Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. ## Request-copy accounting diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index b4397ffc6c..67540ee3b1 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -4,7 +4,7 @@ The existing Responses transport is divided by responsibility in the [core module ownership](responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. The Chat adapter's [OpenCode Go instruction ordering](../providers/chat-compat.md#opencode-go-chronological-instructions) changes translated message placement only; endpoint selection and transport stay with their existing owners. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 604d393097..e02c1d5e6d 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -138,7 +138,7 @@ send has already earned a throttle. ### Bounded API-key 429 rotation -The request-owned budget in `src/server/responses/request-send-budget.ts` captures a failover allowance before sidecar or adapter dispatch: the initial pool size minus one when the initially selected key reference belongs to that pool, or the full initial pool size when it is outside the pool and shares that failover count across ordinary recovery, web-search/image/video bridge rounds and terminal continuations. Initialization precedes sidecar dispatch. Sidecars enforce this count only at their API-key rotation callback, retaining their own reset and same-target retry policies; the ordinary core physical-send budget is not newly applied to them, and OAuth/Anthropic account fallback remains available independently. Cooldown expiry and later pool growth do not refill it. This is a failover count, not a distinct-key guarantee or a new combo-wide budget; existing shared physical-send admission remains authoritative. Before cancelling a superseded response or choosing another key, the ordinary response path checks both limits. Generic reset-only initial fetches, key-rotation rebuilds and terminal continuations consume the same physical-send budget; opting out of transient-5xx retries does not bypass the child ceiling or enable 5xx retries. Initial and continuation recovery admission probes release their unused dispatch reservations immediately; the actual refetch owns the charged send permit. Generic OAuth hops replayed through the retry helpers mark their reservation as externally counted and pass it into the refetch, so its first physical send settles the booking once. An unused retry-helper hop is refunded on failure or abort. Terminal-continuation key recovery in `src/server/responses/adapter-continuation.ts` can draw the same single final reserve after the base allowance is spent. The quota-confirmed compact handoff in `src/server/responses/compact.ts` also transfers an exact prepaid reservation into its routed child; native three-send exhaustion still leaves the one allowed recovery send, without minting a second budget. Combo policy and child-scope construction live in `src/server/responses/combo-send-budget.ts`; their scopes derive through `src/lib/request-execution-budget.ts`, sharing both charged reservations and pending external-send settlement. Remaining-allowance checks and unused-permit refunds use that same ledger, while recovery and target-transition ledgers remain scope-local. Each child has a hard total ceiling that preserves one physical send for every later declared target, including when an earlier target uses recovery. An exact parent hop permit supplies the child's prepaid initial send; adapter-owned reservations and external send reports settle that booking once, rather than charging the hop and the physical send twice. Three plain-5xx targets use six physical sends, with the final combo hop eligible for recovery allowance. `tests/lib/execution-budget-permits.test.ts` checks interleaved scopes and refunds; `tests/responses/responses-send-budget-counts.test.ts` counts physical sends and verifies that every target is reached. On refusal, `src/providers/key-failover.ts` accepts `allowRotation: false` to record the proven failed key's cooldown through the existing selection-identity fence, without selecting, persisting or announcing an unattempted replacement. The original failure stays available to normal response handling, including an already-started streaming continuation. `tests/server/server-key-failover-e2e.test.ts` covers expiry, an initially unpooled key, actual web-search/image bridge dispatch, shared continuation limits and earlier send-budget exhaustion; `tests/adapters/key-failover.test.ts` verifies health-only recording, unchanged disk bytes and manual-selection ownership. +The request-owned budget in `src/server/responses/request-send-budget.ts` captures a failover allowance before sidecar or adapter dispatch: the initial pool size minus one when the initially selected key reference belongs to that pool, or the full initial pool size when it is outside the pool and shares that failover count across ordinary recovery, web-search/image/video bridge rounds and terminal continuations. Initialization precedes sidecar dispatch. Sidecars enforce this count only at their API-key rotation callback, retaining their own reset and same-target retry policies; the ordinary core physical-send budget is not newly applied to them, and OAuth/Anthropic account fallback remains available independently. Cooldown expiry and later pool growth do not refill it. This is a failover count, not a distinct-key guarantee or a new combo-wide budget; existing shared physical-send admission remains authoritative. Before cancelling a superseded response or choosing another key, the ordinary response path checks both limits. Generic reset-only initial fetches, key-rotation rebuilds and terminal continuations consume the same physical-send budget; opting out of transient-5xx retries does not bypass the child ceiling or enable 5xx retries. Initial and continuation recovery admission probes release their unused dispatch reservations immediately; the actual refetch owns the charged send permit. Generic OAuth hops replayed through the retry helpers mark their reservation as externally counted and pass it into the refetch, so its first physical send settles the booking once. An unused retry-helper hop is refunded on failure or abort. Terminal-continuation key recovery in `src/server/responses/adapter-continuation.ts` can draw the same single final reserve after the base allowance is spent. The quota-confirmed compact handoff in `src/server/responses/compact.ts` also transfers an exact prepaid reservation into its routed child; native three-send exhaustion still leaves the one allowed recovery send, without minting a second budget. An intermediate combo scope retains that exact booking and cannot enlarge its inherited total or recovery allowance; an already prepaid first send is preserved even when the remaining budget cannot fund later declared targets. Combo policy and child-scope construction live in `src/server/responses/combo-send-budget.ts`; their scopes derive through `src/lib/request-execution-budget.ts`, sharing both charged reservations and pending external-send settlement. Remaining-allowance checks and unused-permit refunds use that same ledger, while recovery and target-transition ledgers remain scope-local. Each child has a hard total ceiling that preserves one physical send for every later declared target, including when an earlier target uses recovery. An exact parent hop permit supplies the child's prepaid initial send; adapter-owned reservations and external send reports settle that booking once, rather than charging the hop and the physical send twice. Three plain-5xx targets use six physical sends, with the final combo hop eligible for recovery allowance. `tests/lib/execution-budget-permits.test.ts` checks interleaved scopes and refunds; `tests/responses/responses-send-budget-counts.test.ts` counts physical sends and verifies that every target is reached. On refusal, `src/providers/key-failover.ts` accepts `allowRotation: false` to record the proven failed key's cooldown through the existing selection-identity fence, without selecting, persisting or announcing an unattempted replacement. The original failure stays available to normal response handling, including an already-started streaming continuation. `tests/server/server-key-failover-e2e.test.ts` covers expiry, an initially unpooled key, actual web-search/image bridge dispatch, shared continuation limits and earlier send-budget exhaustion; `tests/adapters/key-failover.test.ts` verifies health-only recording, unchanged disk bytes and manual-selection ownership. ### Routed service-tier capability diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 7986e6b7f6..b60bffcba0 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -4,7 +4,7 @@ Native and translated delivery now have separate owners in the [core module ownership](responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once, and terminal-continuation recovery shares the single final reserve without enabling transient-5xx retries. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Codex WebSocket quota-family normalization remains generic; retired-model evidence is filtered by the [OpenAI quota owner](../providers/openai-tiers.md#public-provider-contract), not by diff --git a/tests/lib/execution-budget-permits.test.ts b/tests/lib/execution-budget-permits.test.ts index 93da4fbd2a..e3cc28f558 100644 --- a/tests/lib/execution-budget-permits.test.ts +++ b/tests/lib/execution-budget-permits.test.ts @@ -128,6 +128,38 @@ describe("atomic dispatch permits", () => { }); describe("combo scopes share reservation accounting", () => { + test("a prepaid compact handoff survives an intermediate scope without a second charge", () => { + const parent = createRequestExecutionBudget(ONE_SEND_LEFT); + const hop = parent.reserveDispatch({ sendClass: "initial", targetKey: "handoff", countedExternally: true }); + if (!hop.allowed) throw new Error("expected hop"); + const handoff = deriveSendBudgetScope(parent, ONE_SEND_LEFT, hop.permit); + const combo = deriveSendBudgetScope(handoff, ONE_SEND_LEFT); + expect(combo.remainingBaseSends(3)).toBe(1); + const first = combo.reserveDispatch({ sendClass: "initial", targetKey: "combo", countedExternally: true }); + expect(first.allowed).toBe(true); + if (!first.allowed) throw new Error("prepaid send lost"); + first.permit.use(); parent.used += 1; + expect(parent.used).toBe(1); + expect(handoff.reserveDispatch({ sendClass: "initial", targetKey: "duplicate" }).allowed).toBe(false); + }); + + test("a combo handoff keeps its prepaid recovery and cannot enlarge the compact ceiling", () => { + const parent = createRequestExecutionBudget(); parent.used = 3; + const hop = parent.reserveDispatch({ sendClass: "account-failover", targetKey: "handoff", countedExternally: true }); + if (!hop.allowed) throw new Error("expected recovery"); + const handoff = deriveSendBudgetScope(parent, { ...parent.policy, baseSendAllowance: 4, finalRecoveryAllowance: 0 }, hop.permit); + const combo = deriveSendBudgetScope(handoff, comboExecutionBudgetPolicy(3)); + expect(combo.policy.maxTotalModelSends).toBe(4); + const booked = combo.reserveDispatch({ sendClass: "initial", targetKey: "first", countedExternally: true }); + if (!booked.allowed) throw new Error("lost prepaid recovery"); + const target = comboTargetSendBudget(combo, 2, booked.permit); + expect(target.remainingBaseSends(3)).toBe(1); + const physical = target.reserveDispatch({ sendClass: "transient", targetKey: "first" }); + expect(physical.allowed).toBe(true); + expect(parent.used).toBe(4); + expect(combo.reserveDispatch({ sendClass: "combo-failover", targetKey: "second" }).allowed).toBe(false); + }); + test("a child sees the last send reserved by its parent before dispatch", () => { const parent = createRequestExecutionBudget(ONE_SEND_LEFT); const child = deriveSendBudgetScope(parent, ONE_SEND_LEFT); diff --git a/tests/responses/responses-compaction-routing.test.ts b/tests/responses/responses-compaction-routing.test.ts index 1ffbab5ed6..78852e0665 100644 --- a/tests/responses/responses-compaction-routing.test.ts +++ b/tests/responses/responses-compaction-routing.test.ts @@ -1464,10 +1464,10 @@ describe("compact alternate-account attempt (#913)", () => { }); }); - test("a quota-blocked previous-model compact retries the same thread's successful routed handoff target (#2723)", async () => { + test.each([false, true])("a quota-blocked previous-model compact retries its routed handoff (combo: %s)", async combo => { await withPoolEnv("ocx-compact-routed-handoff-", async config => { config.providers.deepseek = { - adapter: "openai-chat", + adapter: combo ? "openai-responses" : "openai-chat", baseUrl: "https://api.deepseek.com", authMode: "key", apiKey: "deepseek-test-key", @@ -1481,6 +1481,7 @@ describe("compact alternate-account attempt (#913)", () => { models: ["gpt-5.6-sol"], }; const headers = { "x-codex-parent-thread-id": "compact-routed-handoff-thread" }; + config.combos = { summarizer: { strategy: "failover", targets: [{ provider: "deepseek", model: "deepseek-v4-flash" }] } }; const calls: Array<{ model: string; nativeCompact: boolean }> = []; globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { const url = typeof input === "string" @@ -1496,12 +1497,12 @@ describe("compact alternate-account attempt (#913)", () => { status: 502, }); } - return jsonResponse(completedPayload("DeepSeek handoff summary")); + return combo ? sseResponse([{ type: "response.completed", response: completedPayload("DeepSeek handoff summary") }]) + : jsonResponse(completedPayload("DeepSeek handoff summary")); }) as typeof fetch; - const manual = await handleResponsesCompact( compactionRequest( - baseCompactionBody({ model: "deepseek/deepseek-v4-flash" }), + baseCompactionBody({ model: combo ? "combo/summarizer" : "deepseek/deepseek-v4-flash" }), undefined, headers, ), @@ -1525,7 +1526,6 @@ describe("compact alternate-account attempt (#913)", () => { expect(calls.length).toBeGreaterThan(0); expect(calls.every(call => call.model === "gpt-5.6-sol" && call.nativeCompact)).toBe(true); calls.length = 0; - const logCtx: RequestLogContext = { model: "", provider: "" }; const automatic = await handleResponsesCompact( compactionRequest( @@ -1540,7 +1540,7 @@ describe("compact alternate-account attempt (#913)", () => { expect(automatic.status).toBe(200); const output = await automatic.json() as { output?: unknown[] }; expect(output.output?.length).toBeGreaterThan(0); - expect(logCtx.provider).toBe("deepseek"); + expect(logCtx.provider).toBe(combo ? "combo" : "deepseek"); expect(calls.at(-1)).toEqual({ model: "deepseek-v4-flash", nativeCompact: false }); expect(calls.slice(0, -1)).toHaveLength(3); // Native ladder + one handoff = four sends. expect(calls.slice(0, -1).every(call => ( From 1c37fc266d987fb76e9ae068bcbb5990a63d1f5f Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:16:34 +0900 Subject: [PATCH 13/23] fix(responses): transfer OAuth hop bookings into adapter send budgets Declare when an HTTP adapter accounts for its own physical sends. Kiro receives the exact prepaid OAuth hop in a derived scope instead of charging a second reservation. Other fetchResponse implementations retain caller-owned confirmation after pacing. Carry the same ownership through queued continuations and refund unused hops. Actual Kiro server regressions preserve all three credential attempts, including third-account success and a preceding reset. Before the change they stopped after the first account with a budget-exhaustion 502. The Kiro, permit and inner-budget run passed 30 tests/160 assertions. Two existing retry-helper OAuth cases passed with an explicit 20-second timeout after one default-5-second timeout. Typecheck, structure, privacy, file-size and diff checks pass; hosted CI remains separate. --- src/adapters/base.ts | 2 + src/adapters/kiro/adapter.ts | 1 + src/server/responses/adapter-continuation.ts | 11 ++-- src/server/responses/adapter-dispatch.ts | 16 ++++-- structure/adapters/registry.md | 2 +- structure/catalog.md | 2 +- structure/clients/claude-desktop.md | 2 +- structure/data-planes/images.md | 2 +- structure/data-planes/inbound-compat.md | 2 +- structure/gui-and-management-api.md | 2 +- structure/ops/service-and-sidecars.md | 2 +- structure/providers/chat-compat.md | 2 + structure/providers/cursor.md | 2 + structure/providers/xai-grok.md | 2 +- structure/runtime.md | 2 +- structure/subagents.md | 2 +- structure/transports/byte-accounting.md | 2 +- structure/transports/inventory.md | 2 +- structure/transports/responses.md | 19 ++++--- structure/transports/streaming-health.md | 2 +- .../server-kiro-oauth-401-replay.test.ts | 51 ++++++++++++++++++- 21 files changed, 101 insertions(+), 29 deletions(-) diff --git a/src/adapters/base.ts b/src/adapters/base.ts index f4cf7ab2ef..40ceb97c1e 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -65,6 +65,8 @@ export interface ProviderAdapter { localTerminal?(parsed: OcxParsedRequest): AdapterLocalTerminal | undefined; fetchResponse?(request: AdapterRequest, ctx?: AdapterFetchContext): Promise; + /** fetchResponse admits every physical send through ctx.sendBudget when supplied. */ + fetchResponseUsesSendBudget?: boolean; /** * Parse one upstream response. `tierMetadata` is the same live observer returned on the diff --git a/src/adapters/kiro/adapter.ts b/src/adapters/kiro/adapter.ts index b6a374cf4c..07922525a1 100644 --- a/src/adapters/kiro/adapter.ts +++ b/src/adapters/kiro/adapter.ts @@ -312,6 +312,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter ); }, + fetchResponseUsesSendBudget: true, fetchResponse(request: AdapterRequest, ctx?: AdapterFetchContext): Promise { // The normal Responses path supplies cancellation at fetch time rather than build time. // Keep it for the adapter-owned bounded continuation so cancelling the client turn aborts diff --git a/src/server/responses/adapter-continuation.ts b/src/server/responses/adapter-continuation.ts index f87514d901..b4a10e7adb 100644 --- a/src/server/responses/adapter-continuation.ts +++ b/src/server/responses/adapter-continuation.ts @@ -195,10 +195,14 @@ export function createAdapterContinuations( if (transportState.activeAdapter.fetchResponse) { noteAttemptSend(logCtx.activeAttempt, continuationEstimate, replayKind); await waitForProviderRequestSlot(route.providerName, route.provider, nextParsed.modelId, upstream.signal); + const hop = sendBudgetState.pendingHopPermit; + const replaySendBudget = transportState.activeAdapter.fetchResponseUsesSendBudget && adapterSendBudget && hop + ? adapterSendBudget.deriveScope({ ...adapterSendBudget.policy, finalRecoveryAllowance: 0 }, hop) + : adapterSendBudget; return await transportState.activeAdapter.fetchResponse(builtContinuationRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, - sendBudget: adapterSendBudget, + sendBudget: replaySendBudget, onPhysicalSend: send => noteAdapterPhysicalSend(continuationEstimate, send), stream: nextParsed.stream, executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, { @@ -414,7 +418,7 @@ export function createAdapterContinuations( const hop = reserveCredentialHop( "auth-recovery", `${route.providerName}|${route.modelId}|continuation-oauth-429`, - !transportState.activeAdapter.fetchResponse, + !transportState.activeAdapter.fetchResponse || transportState.activeAdapter.fetchResponseUsesSendBudget === true, ); const nextAccountId = hop.allowed ? rotateGenericOAuthAccountOn429( @@ -445,7 +449,8 @@ export function createAdapterContinuations( sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel); recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name); nextContinuationRecoveryKind = "oauth-account-429"; - sendBudgetState.pendingHopPermit = transportState.activeAdapter.fetchResponse ? undefined : hop.permit; + sendBudgetState.pendingHopPermit = !transportState.activeAdapter.fetchResponse + || transportState.activeAdapter.fetchResponseUsesSendBudget ? hop.permit : undefined; continue; } } catch { diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index b5fd739c0c..89d3bba0a7 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -375,6 +375,7 @@ export async function prepareAdapterExchange( * a permit confirmed earlier would keep the charge for a send that never happened. */ onDispatch?: () => void, + replaySendBudget = adapterSendBudget, ): Promise => { let retryRequest: AdapterRequest; if (transportState.sameTargetRequest !== undefined && transportState.sameTargetParsed === parsed && transportState.sameTargetToken === transportState.transportToken) { @@ -424,7 +425,7 @@ export async function prepareAdapterExchange( return await transportState.activeAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, - sendBudget: adapterSendBudget, + sendBudget: replaySendBudget, onPhysicalSend: send => noteAdapterPhysicalSend(retryEstimate, send), stream: parsed.stream, executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, { @@ -724,7 +725,7 @@ export async function prepareAdapterExchange( const hop = reserveCredentialHop( "auth-recovery", `${route.providerName}|${route.modelId}|adapter-recovery-oauth-429`, - !transportState.activeAdapter.fetchResponse, + !transportState.activeAdapter.fetchResponse || transportState.activeAdapter.fetchResponseUsesSendBudget === true, ); if (!hop.allowed) break; const nextAccountId = rotateGenericOAuthAccountOn429( @@ -755,12 +756,17 @@ export async function prepareAdapterExchange( ); sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel); recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name); - // Retry helpers settle the externally counted hop themselves. Adapter-owned - // sends confirm only after pacing, preserving the release-on-rebuild-failure fix. + // Budget-aware adapters spend this same booking at their physical-send boundary. + // Other fetchResponse implementations retain caller-owned confirmation after pacing. sendBudgetState.pendingHopPermit = transportState.activeAdapter.fetchResponse ? undefined : hop.permit; + const adapterSettlesHop = transportState.activeAdapter.fetchResponseUsesSendBudget === true; + const replaySendBudget = adapterSettlesHop && adapterSendBudget && hop.permit + ? adapterSendBudget.deriveScope({ ...adapterSendBudget.policy, finalRecoveryAllowance: 0 }, hop.permit) + : adapterSendBudget; try { const result = await rebuildAndRefetch("oauth-account-429", - transportState.activeAdapter.fetchResponse ? () => { hop.permit?.use(); } : undefined); + transportState.activeAdapter.fetchResponse && !adapterSettlesHop ? () => { hop.permit?.use(); } : undefined, + replaySendBudget); if ("failed" in result) return result.failed; upstreamResponse = result; } finally { diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index dc39725b44..7ac0e26d4a 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -4,7 +4,7 @@ Request-local adapter bindings are separate from registry authority in the Respo [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](../transports/responses.md#passthrough-sse-stream-shapes-314). diff --git a/structure/catalog.md b/structure/catalog.md index 0b5b4e872b..4082b94742 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -4,7 +4,7 @@ Catalog discovery remains separate from the Responses final-route [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 28cb0f6ed1..14b7b887a4 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -4,7 +4,7 @@ Desktop callers retain their existing ingress through the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Codex-native model discovery follows the [shared retirement policy](../catalog.md#shared-catalog). That projection does not migrate existing user-selected Desktop configuration or usage history. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index fc15593bc2..1ba98ccae7 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -4,7 +4,7 @@ Vision preprocessing and image/video/search execution use the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Hosted Responses image-tool eligibility uses the shared compatibility policy without a Codex Spark exception; standalone Images retain the separate relay contract below. See diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 811eb2971a..ac19dcd45b 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -4,7 +4,7 @@ Compatibility callers retain the public Responses ingress described by the [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. ## Standalone file transcription diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 7709788aa7..d600950369 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -4,7 +4,7 @@ The shared server request path follows the Responses [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. ## Dashboard serving diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 96f2e968d6..093aaea570 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -4,7 +4,7 @@ Service endpoints are unchanged by the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Service startup and restore use the [catalog retirement policy](../catalog.md#shared-catalog); retirement does not itself change service registration or user-selected model configuration. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 5ee17ed807..54a20a83a6 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -1,5 +1,7 @@ # Chat Provider Compatibility +HTTP adapters declare their own send-budget consumption under the [credential-hop contract](../transports/responses.md#credential-hop-reservations); ordinary Chat retry helpers retain external-send settlement. + The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 5ae38028d8..d30f9b4623 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -1,5 +1,7 @@ # Cursor Provider +HTTP adapters declare their own send-budget consumption under the [credential-hop contract](../transports/responses.md#credential-hop-reservations); Cursor's run-turn transport retains its existing ownership. + The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 64bec93299..09fa123a3a 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -4,7 +4,7 @@ xAI uses the same shared credential and delivery policies through the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Codex-native retirement is scoped to OpenAI catalog/quota evidence. Shared Responses handling retains xAI provider behavior; see diff --git a/structure/runtime.md b/structure/runtime.md index f8d0044efb..cf7a777121 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -4,7 +4,7 @@ Responses admission and finalization are composed through the [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Chat request serialization owns the destination-scoped [OpenCode Go instruction ordering](providers/chat-compat.md#opencode-go-chronological-instructions); diff --git a/structure/subagents.md b/structure/subagents.md index 5e32fdf423..0f0ba92fde 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -10,7 +10,7 @@ Encrypted-task and fallback request handling follow the Responses only explicit true on Responses ingress to the final canonical ChatGPT forward route activates it. A default top-level collaboration catalog is required. The compiler preserves caller objects, aliases the namespace and three message functions, and removes only their true encryption marker. -Declaration/reference collisions refuse the whole rewrite without changing the request. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +Declaration/reference collisions refuse the whole rewrite without changing the request. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. `src/adapters/openai-responses.ts` returns request-local alias capabilities. The Responses core refreshes them after every request rebuild and restores JSON, SSE and WebSocket identities after diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 218cc14ebd..dec1c4592c 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -5,7 +5,7 @@ Responses body-reader limits and lifetime handling follow the How opencodex measures request and stream bytes without allocating copies solely to count them. These contracts are shared by request parsing, SSE rewriting, the provider adapters and -the translator budget, which is why so many documents link here rather than restating them. Response-attached WebSocket telemetry follows the [stage record identity contract](responses.md#passthrough-sse-stream-shapes-314). Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +the translator budget, which is why so many documents link here rather than restating them. Response-attached WebSocket telemetry follows the [stage record identity contract](responses.md#passthrough-sse-stream-shapes-314). Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. ## Request-copy accounting diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 67540ee3b1..86015fe09c 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -4,7 +4,7 @@ The existing Responses transport is divided by responsibility in the [core module ownership](responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. The Chat adapter's [OpenCode Go instruction ordering](../providers/chat-compat.md#opencode-go-chronological-instructions) changes translated message placement only; endpoint selection and transport stay with their existing owners. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index e02c1d5e6d..4f2a7a900f 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -680,13 +680,18 @@ budget before it knows whether a rotation is even possible, because the reservat `reserveDispatch` spends, `permit.use()` only confirms which leg sent, and `permit.release()` is idempotent and a no-op once used. Every ladder therefore owes the budget an answer on every exit. -Two shapes are correct and both are in the tree. Where the ladder dispatches inside its own `try` -— `adapter-dispatch.ts`, `run-turn-execution.ts` — it confirms with `use()` immediately before the -send and releases in its `catch`, so one catch covers a pre-dispatch throw and a throw from the -send alike. Where the replay happens after the loop continues — `adapter-continuation.ts` — it must -not confirm, because the send has not happened yet; it only releases. The passthrough ladder is a -third shape: it reserves with `countedExternally: true` and hands the permit to the rebuild through -`pendingHopPermit`, because there the retry helper reports the same physical send. +Caller-counted adapter dispatch confirms its hop after request shaping and pacing. Retry-helper +dispatch reserves with `countedExternally: true` and passes the booking through `pendingHopPermit`. +An adapter declaring `fetchResponseUsesSendBudget`, currently Kiro, also receives an externally +counted booking, but adopts it through a derived budget at its own physical-send boundary. +That scope shares the request ceiling and adds no recovery reserve. Other `fetchResponse` +implementations retain caller-owned confirmation; having that method alone does not establish +that the adapter consumes budget permits. A queued continuation carries an unused booking into +its next dispatch and releases it if shaping, pacing or admission fails before a send. + +`tests/server/server-kiro-oauth-401-replay.test.ts` counts actual Kiro requests across three stored +OAuth accounts, including an earlier connection reset and the third account's success or quota +response. Hop booking and adapter dispatch charge one physical send, not two. What must not happen is a ladder that charges and then returns through a path that neither confirms nor releases. That is not a lost send; it is a send the request never made, spending an allowance a diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index b60bffcba0..34597d7166 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -4,7 +4,7 @@ Native and translated delivery now have separate owners in the [core module ownership](responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Codex WebSocket quota-family normalization remains generic; retired-model evidence is filtered by the [OpenAI quota owner](../providers/openai-tiers.md#public-provider-contract), not by diff --git a/tests/server/server-kiro-oauth-401-replay.test.ts b/tests/server/server-kiro-oauth-401-replay.test.ts index 80c39e6f3c..a89de5a787 100644 --- a/tests/server/server-kiro-oauth-401-replay.test.ts +++ b/tests/server/server-kiro-oauth-401-replay.test.ts @@ -5,7 +5,9 @@ import { join } from "node:path"; import { KIRO_COMPLETION_TOOL_NAME } from "../../src/adapters/kiro-constants"; import { encodeMessage } from "../../src/lib/eventstream-decoder"; import { saveConfig } from "../../src/config"; -import { saveCredential } from "../../src/oauth/store"; +import { getAccountSet, saveCredential, setActiveAccount } from "../../src/oauth/store"; +import { clearGenericFailoverHealth } from "../../src/oauth/generic-account-failover"; +import { resetKiroThrottleStateForTests } from "../../src/adapters/kiro-retry"; import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; @@ -142,6 +144,53 @@ function installFetch(chatStatuses: number[]): { chatAuth: string[]; refreshCall } describe("Kiro OAuth upstream 401 replay", () => { + test.each(["quota", "success", "reset-success"] as const)("Kiro OAuth hops charge adapter-owned physical sends once (%s)", async mode => { + const authorizations: string[] = []; + clearGenericFailoverHealth(); + resetKiroThrottleStateForTests(); + for (let index = 0; index < 3; index++) { + await saveCredential("kiro", { + access: `synthetic-kiro-${index}`, refresh: `synthetic-refresh-${index}`, + expires: Date.now() + 3_600_000, accountId: `kiro-fixture-${index}`, source: "oauth", + }, { addAccount: true }); + } + await setActiveAccount("kiro", getAccountSet("kiro")!.accounts[0]!.id); + expect(getAccountSet("kiro")!.accounts).toHaveLength(3); + saveConfig(config()); + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url !== CHAT_ENDPOINT) throw new Error(`Unexpected fixture request: ${url}`); + const bearer = new Headers(init?.headers).get("authorization") ?? ""; + authorizations.push(bearer); + if (mode === "reset-success" && authorizations.length === 1) { + throw Object.assign(new Error("fixture ECONNRESET"), { code: "ECONNRESET" }); + } + if (mode !== "quota" && bearer === "Bearer synthetic-kiro-2") { + return new Response(eventStream("third Kiro account works"), { + headers: { "content-type": "application/vnd.amazon.eventstream" }, + }); + } + // Exhausted quota bypasses Kiro's same-account throttle ladder and reaches OAuth rotation. + return Response.json({ message: "insufficient_quota", code: "insufficient_quota" }, { status: 429 }); + }) as typeof fetch; + const server = startServer(0); + try { + const response = await post(server); + const body = await response.text(); + expect(authorizations).toEqual([ + ...(mode === "reset-success" ? ["Bearer synthetic-kiro-0"] : []), + "Bearer synthetic-kiro-0", "Bearer synthetic-kiro-1", "Bearer synthetic-kiro-2", + ]); + expect(response.status).toBe(mode === "quota" ? 429 : 200); + expect(body).toContain(mode === "quota" ? "insufficient_quota" : "third Kiro account works"); + } finally { + globalThis.fetch = originalFetch; + await server.stop(true); + clearGenericFailoverHealth(); + resetKiroThrottleStateForTests(); + } + }, 20_000); + test("selected OAuth account supplies its own Kiro runtime region and profile", async () => { const profileArn = "arn:aws:codewhisperer:eu-west-1:123456789012:profile/account-b"; await saveCredential("kiro", { From 77134192fe5972dd8708d3066267eddc76aa4c3f Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:23:35 +0900 Subject: [PATCH 14/23] test(responses): cover Kiro OAuth rotation during empty-completion retry Use a reasoning-only Kiro completion with the explicit emptyCompletionRetry opt-in. The public server takes one initial send, retries the first account, then follows two quota-driven OAuth hops and accepts the third account within four physical sends. With only adapter-continuation restored to its pre-fix version, the fixture stops after the two first-account sends and fails. Restoring the prepaid continuation transfer passes this one test with four assertions. The previously passing suite was not repeated. --- tests/server/server-kiro-oauth-401-replay.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/server/server-kiro-oauth-401-replay.test.ts b/tests/server/server-kiro-oauth-401-replay.test.ts index a89de5a787..4a1c724b5c 100644 --- a/tests/server/server-kiro-oauth-401-replay.test.ts +++ b/tests/server/server-kiro-oauth-401-replay.test.ts @@ -144,7 +144,7 @@ function installFetch(chatStatuses: number[]): { chatAuth: string[]; refreshCall } describe("Kiro OAuth upstream 401 replay", () => { - test.each(["quota", "success", "reset-success"] as const)("Kiro OAuth hops charge adapter-owned physical sends once (%s)", async mode => { + test.each(["quota", "success", "reset-success", "empty-retry"] as const)("Kiro OAuth hops charge adapter-owned physical sends once (%s)", async mode => { const authorizations: string[] = []; clearGenericFailoverHealth(); resetKiroThrottleStateForTests(); @@ -156,12 +156,18 @@ describe("Kiro OAuth upstream 401 replay", () => { } await setActiveAccount("kiro", getAccountSet("kiro")!.accounts[0]!.id); expect(getAccountSet("kiro")!.accounts).toHaveLength(3); - saveConfig(config()); + saveConfig({ ...config(), ...(mode === "empty-retry" ? { emptyCompletionRetry: true } : {}) }); globalThis.fetch = (async (input, init) => { const url = input instanceof Request ? input.url : String(input); if (url !== CHAT_ENDPOINT) throw new Error(`Unexpected fixture request: ${url}`); const bearer = new Headers(init?.headers).get("authorization") ?? ""; authorizations.push(bearer); + if (mode === "empty-retry" && authorizations.length === 1) { + // Reasoning-only ends with done but no user content; a truly empty Kiro stream is incomplete. + return new Response(reasoningStream("Thinking without an answer"), { + headers: { "content-type": "application/vnd.amazon.eventstream" }, + }); + } if (mode === "reset-success" && authorizations.length === 1) { throw Object.assign(new Error("fixture ECONNRESET"), { code: "ECONNRESET" }); } @@ -178,7 +184,7 @@ describe("Kiro OAuth upstream 401 replay", () => { const response = await post(server); const body = await response.text(); expect(authorizations).toEqual([ - ...(mode === "reset-success" ? ["Bearer synthetic-kiro-0"] : []), + ...(mode === "reset-success" || mode === "empty-retry" ? ["Bearer synthetic-kiro-0"] : []), "Bearer synthetic-kiro-0", "Bearer synthetic-kiro-1", "Bearer synthetic-kiro-2", ]); expect(response.status).toBe(mode === "quota" ? 429 : 200); From 67846b95e0993aef7ad1fce229019b16c5d514ad Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:53:38 +0900 Subject: [PATCH 15/23] fix(responses): fund initial terminal repair from shared reserve --- .../docs/reference/configuration/providers.md | 2 +- src/server/responses/adapter-continuation.ts | 11 ++-- structure/adapters/registry.md | 2 +- structure/catalog.md | 2 +- structure/clients/claude-desktop.md | 2 +- structure/data-planes/images.md | 2 +- structure/data-planes/inbound-compat.md | 2 +- structure/gui-and-management-api.md | 2 +- structure/ops/service-and-sidecars.md | 2 +- structure/providers/xai-grok.md | 2 +- structure/runtime.md | 2 +- structure/subagents.md | 2 +- structure/transports/byte-accounting.md | 2 +- structure/transports/inventory.md | 2 +- structure/transports/responses.md | 6 +++ structure/transports/streaming-health.md | 2 +- tests/server/terminal-guard-server.test.ts | 53 +++++++++++++++++++ 17 files changed, 78 insertions(+), 20 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 8fd590d0e9..37b477ca52 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -199,7 +199,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `noJsonSchemaModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects a `json_schema` `response_format` but still accepts `json_object`. Such a request is downgraded to `json_object` instead of being dropped, so a caller asking for JSON still gets JSON. `noStructuredOutputModels` wins when a model is on both lists. The `opencode go`, `opencode zen`, and `opencode free` presets ship this for their DeepSeek routes. | | `omitReasoningEffortWithToolsModels?` | `string[]` | Exact `openai-chat` model IDs that accept a reasoning-effort field on an ordinary turn but reject it once function tools are present. The model keeps its advertised effort ladder; OpenCodex omits the wire field for tool-bearing requests only and the upstream default applies. Narrower than `noReasoningModels`, which strips reasoning from every request and costs the model its picker entirely. | | `parallelToolCalls?` | `boolean` | Toggle parallel tool calls. OpenAI Chat defaults on; non-chat adapters advertise only on explicit `true`. | -| `terminalContinuationGuard?` | `boolean` | Opt in an `openai-chat` provider to one bounded internal re-ask when an actionable turn announces work, then cleanly stops without a tool call. Defaults to `false`; explicit `false` behaves like omission. Combo attempts and routed compaction turns are excluded, and non-`openai-chat` adapters ignore this option. | +| `terminalContinuationGuard?` | `boolean` | Opt in an `openai-chat` provider to one bounded internal re-ask when an actionable turn announces work, then cleanly stops without a tool call. The re-ask shares the request's send budget, including its one available final-recovery send; it never creates a fresh allowance. Defaults to `false`; explicit `false` behaves like omission. Combo attempts and routed compaction turns are excluded, and non-`openai-chat` adapters ignore this option. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Disabled-by-default downstream SSE repair for exact placeholder ids, missing terminal ids, and (with `repairInvalidIds`) message/reasoning ids missing the canonical `msg_`/`rs_` prefix. Function-call ids are never rewritten. Built-in DeepSeek enables the last two by default. | | `responsesSnapshotRepair?` | `boolean` | Disabled-by-default client-facing repair for sparse Responses lifecycle snapshots in SSE and JSON. Fills missing canonical status, output, and tool metadata while raw inspection and persistence remain unchanged. | | `webSearchBridge?` | `{ enabled?: boolean; backend?: "ollama" \| "openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"; maxSearches?: number; timeoutMs?: number; endpoint?: string }` | Key-auth `openai-responses` passthrough providers only. Off by default. Codex always declares the hosted `web_search` tool, and the passthrough relays it on the assumption the destination executes it. A gateway that does not run hosted search answers with a `function_call` named `web_search` that nothing runs, and the undeclared-tool guard ends the turn. With `enabled: true` and an explicit `backend` OpenCodex intercepts that call, runs the search itself, feeds the result back to the same upstream, and shows Codex a hosted `web_search_call` cell. Never armed for `authMode: "forward"` (ChatGPT already searches) or for a provider that executes hosted search upstream. `backend` is required; there is no implicit default and a missing credential for the named backend leaves the bridge disarmed rather than falling through to another paid search. `ollama` reuses this provider's own API key on `POST /api/web_search`, so the origin must be `https://ollama.com` unless the operator names `endpoint` explicitly. `openai` / `anthropic` / `xai` / `gemini` / `exa` reuse the matching sidecar executor and that executor's own credential (`webSearchSidecar.exaApiKey` for Exa). The search model comes from `webSearchSidecar.model` only when `webSearchSidecar.backend` resolves to the same backend this bridge names; otherwise the bridge runs that backend's own default, because a model chosen for one vendor is rejected by another. An unset `webSearchSidecar.backend` resolves to `openai`, so an unset-backend model reaches an `openai` bridge and no other. There is no per-provider bridge model override. Streaming turns only. A turn that mixes `web_search` with another client tool call still fails closed rather than dropping the client's call. Assistant text such as XML-like `` prose is not executed. Defaults: `maxSearches: 3` (1..10), `timeoutMs: 60000` (1000..600000). | diff --git a/src/server/responses/adapter-continuation.ts b/src/server/responses/adapter-continuation.ts index b4a10e7adb..3ee4b497a2 100644 --- a/src/server/responses/adapter-continuation.ts +++ b/src/server/responses/adapter-continuation.ts @@ -86,7 +86,6 @@ export function createAdapterContinuations( ResponsesSendBudget, | "adapterSendBudget" | "noteAdapterPhysicalSend" - | "remainingTransientSendBudget" | "noteTransientSends" | "reserveCredentialHop" | "pendingHopPermit" @@ -120,7 +119,6 @@ export function createAdapterContinuations( const { adapterSendBudget, noteAdapterPhysicalSend, - remainingTransientSendBudget, noteTransientSends, reserveCredentialHop, recoverySendAllowance, @@ -219,10 +217,11 @@ export function createAdapterContinuations( ? fetchWithTransientRetry : fetchWithResetRetry; const continuationCap = continuationTransientPolicy?.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS; - const allowance = replayKind - ? recoverySendAllowance(continuationCap, recoveryClassFor(replayKind), - `${route.providerName}|${route.modelId}|${replayKind}`) - : { attempts: remainingTransientSendBudget(continuationCap), permit: undefined }; + // The first terminal-guard re-ask repairs a no-tool completion even without a + // recovery log label. It shares the one final reserve with subsequent recovery. + const allowance = recoverySendAllowance(continuationCap, + replayKind ? recoveryClassFor(replayKind) : "repair", + `${route.providerName}|${route.modelId}|${replayKind ?? "terminal-continuation"}`); try { return await fetchContinuationWithRetryPolicy( recovery => { diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 7ac0e26d4a..e4152000a1 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -4,7 +4,7 @@ Request-local adapter bindings are separate from registry authority in the Respo [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](../transports/responses.md#passthrough-sse-stream-shapes-314). diff --git a/structure/catalog.md b/structure/catalog.md index 4082b94742..43d02ca7cf 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -4,7 +4,7 @@ Catalog discovery remains separate from the Responses final-route [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 14b7b887a4..104194bedb 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -4,7 +4,7 @@ Desktop callers retain their existing ingress through the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. Codex-native model discovery follows the [shared retirement policy](../catalog.md#shared-catalog). That projection does not migrate existing user-selected Desktop configuration or usage history. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 1ba98ccae7..f7248ce417 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -4,7 +4,7 @@ Vision preprocessing and image/video/search execution use the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. Hosted Responses image-tool eligibility uses the shared compatibility policy without a Codex Spark exception; standalone Images retain the separate relay contract below. See diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index ac19dcd45b..59d7d60920 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -4,7 +4,7 @@ Compatibility callers retain the public Responses ingress described by the [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. ## Standalone file transcription diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index d600950369..f68d4b5d19 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -4,7 +4,7 @@ The shared server request path follows the Responses [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. ## Dashboard serving diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 093aaea570..a19dbb91a7 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -4,7 +4,7 @@ Service endpoints are unchanged by the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. Service startup and restore use the [catalog retirement policy](../catalog.md#shared-catalog); retirement does not itself change service registration or user-selected model configuration. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 09fa123a3a..3e7190c02c 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -4,7 +4,7 @@ xAI uses the same shared credential and delivery policies through the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. Codex-native retirement is scoped to OpenAI catalog/quota evidence. Shared Responses handling retains xAI provider behavior; see diff --git a/structure/runtime.md b/structure/runtime.md index cf7a777121..0e3fe96e43 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -4,7 +4,7 @@ Responses admission and finalization are composed through the [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. Chat request serialization owns the destination-scoped [OpenCode Go instruction ordering](providers/chat-compat.md#opencode-go-chronological-instructions); diff --git a/structure/subagents.md b/structure/subagents.md index 0f0ba92fde..ff11bb4872 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -10,7 +10,7 @@ Encrypted-task and fallback request handling follow the Responses only explicit true on Responses ingress to the final canonical ChatGPT forward route activates it. A default top-level collaboration catalog is required. The compiler preserves caller objects, aliases the namespace and three message functions, and removes only their true encryption marker. -Declaration/reference collisions refuse the whole rewrite without changing the request. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +Declaration/reference collisions refuse the whole rewrite without changing the request. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. `src/adapters/openai-responses.ts` returns request-local alias capabilities. The Responses core refreshes them after every request rebuild and restores JSON, SSE and WebSocket identities after diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index dec1c4592c..58f0677fbd 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -5,7 +5,7 @@ Responses body-reader limits and lifetime handling follow the How opencodex measures request and stream bytes without allocating copies solely to count them. These contracts are shared by request parsing, SSE rewriting, the provider adapters and -the translator budget, which is why so many documents link here rather than restating them. Response-attached WebSocket telemetry follows the [stage record identity contract](responses.md#passthrough-sse-stream-shapes-314). Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +the translator budget, which is why so many documents link here rather than restating them. Response-attached WebSocket telemetry follows the [stage record identity contract](responses.md#passthrough-sse-stream-shapes-314). Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. ## Request-copy accounting diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 86015fe09c..33da133feb 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -4,7 +4,7 @@ The existing Responses transport is divided by responsibility in the [core module ownership](responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. The Chat adapter's [OpenCode Go instruction ordering](../providers/chat-compat.md#opencode-go-chronological-instructions) changes translated message placement only; endpoint selection and transport stay with their existing owners. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 4f2a7a900f..12797a6bf3 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -680,6 +680,12 @@ budget before it knows whether a rotation is even possible, because the reservat `reserveDispatch` spends, `permit.use()` only confirms which leg sent, and `permit.release()` is idempotent and a no-op once used. Every ladder therefore owes the budget an answer on every exit. +An initial terminal-guard continuation repairs a no-tool completion, even without a recovery +log label. It may spend the shared final reserve after base sends are exhausted. The retry helper +settles that permit once; a missing or spent reserve prevents another dispatch or credential hop. +`tests/server/terminal-guard-server.test.ts` verifies reset exhaustion on Anthropic and opted-in +OpenAI Chat, no-reserve refusal, and a final continuation 429 without an unfunded key change. + Caller-counted adapter dispatch confirms its hop after request shaping and pacing. Retry-helper dispatch reserves with `countedExternally: true` and passes the booking through `pendingHopPermit`. An adapter declaring `fetchResponseUsesSendBudget`, currently Kiro, also receives an externally diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 34597d7166..3f4404bc35 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -4,7 +4,7 @@ Native and translated delivery now have separate owners in the [core module ownership](responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. Codex WebSocket quota-family normalization remains generic; retired-model evidence is filtered by the [OpenAI quota owner](../providers/openai-tiers.md#public-provider-contract), not by diff --git a/tests/server/terminal-guard-server.test.ts b/tests/server/terminal-guard-server.test.ts index c2c889c3d8..d725c21fa1 100644 --- a/tests/server/terminal-guard-server.test.ts +++ b/tests/server/terminal-guard-server.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; import { clearKeyCooldowns } from "../../src/providers/key-failover"; +import { createRequestExecutionBudget, CODEX_TEXT_GUARDED_BUDGET_POLICY } from "../../src/lib/request-execution-budget"; import { handleResponses } from "../../src/server/responses"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -99,6 +100,7 @@ describe("server terminal guard integration", () => { afterEach(() => { globalThis.fetch = originalFetch; + clearKeyCooldowns("claude-se"); }); test("re-asks Claude once inside the same Responses turn and forwards the tool call", async () => { @@ -123,6 +125,57 @@ describe("server terminal guard integration", () => { expect(messages.at(-1)?.content?.[0]?.text).toContain("你刚才只描述了计划"); }); + test.each([ + { adapter: "anthropic", reserve: 1, rateLimited: false }, + { adapter: "openai-chat", reserve: 1, rateLimited: false }, + { adapter: "anthropic", reserve: 0, rateLimited: false }, + { adapter: "anthropic", reserve: 1, rateLimited: true }, + ] as const)("terminal repair after reset exhaustion uses only the available reserve: %j", async ({ adapter, reserve, rateLimited }) => { + const isChat = adapter === "openai-chat"; + const requestConfig = isChat ? openAiChatConfig(true) : structuredClone(config); + if (rateLimited) requestConfig.providers["claude-se"]!.apiKeyPool = [ + { id: "k1", key: "sk-test", addedAt: 1 }, + { id: "k2", key: "sk-test-2", addedAt: 2 }, + ]; + const budget = createRequestExecutionBudget({ ...CODEX_TEXT_GUARDED_BUDGET_POLICY, finalRecoveryAllowance: reserve }); + let sends = 0; + const keys: Array = []; + globalThis.fetch = (async (_input, init) => { + sends += 1; + keys.push(new Headers(init?.headers).get("x-api-key")); + if (sends <= 2) throw Object.assign(new Error("connection reset by peer"), { code: "ECONNRESET" }); + if (sends > 3 && rateLimited) return new Response("rate limited", { status: 429 }); + return isChat + ? chatSse(sends === 3 ? chatFirstTurn : chatContinuationTurn) + : anthropicSse(sends === 3 ? firstTurn : continuationTurn); + }) as typeof fetch; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: isChat ? "glm-gw/glm-5.2" : "se-claude-opus-4.8", + input: "请检查这个问题并修复代码", + stream: true, + tools: [{ type: "function", name: "exec_command", parameters: { type: "object" } }], + }), + }), requestConfig, { model: "", provider: "" }, { sendBudget: budget }); + const text = await response.text(); + expect(sends).toBe(3 + reserve); + expect(budget.used).toBe(sends); + expect(budget.reserveSpent).toBe(reserve === 1); + if (rateLimited) { + expect(text).toContain("Provider continuation error 429"); + expect(keys).toEqual(["sk-test", "sk-test", "sk-test", "sk-test"]); + expect(budget.alternateTargetSends).toBe(0); + } else if (reserve) { + expect(text).toContain("response.function_call_arguments.done"); + expect(text).not.toContain("Provider continuation failed"); + } else { + expect(text).toContain("Provider continuation failed"); + expect(text).not.toContain("response.function_call_arguments.done"); + } + }); + test("terminal-guard continuation 429 replays on the same key before surfacing", async () => { const retryConfig = { ...config, From 6ce3aee226b3ff53cb02c27f01729b099f2100f3 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:15:54 +0900 Subject: [PATCH 16/23] test(responses): verify retry budget wiring structurally --- .../lib/transient-budget-scope-source.test.ts | 146 ++++++++++++++++-- 1 file changed, 129 insertions(+), 17 deletions(-) diff --git a/tests/lib/transient-budget-scope-source.test.ts b/tests/lib/transient-budget-scope-source.test.ts index 26cb2f79ec..43d27892ba 100644 --- a/tests/lib/transient-budget-scope-source.test.ts +++ b/tests/lib/transient-budget-scope-source.test.ts @@ -19,10 +19,104 @@ import { readResponsesCoreSource } from "../helpers/responses-core-source"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { repoPath } from "../helpers/repo-root"; +import { createScanner, SyntaxKind } from "typescript/unstable/ast"; const source = (relative: string): string => readFileSync(repoPath("src", ...relative.split("/")), "utf8"); +/** Check balanced option objects and their local allowance declarations, ignoring textual decoys. */ +function retryBudgetWiring(text: string): { reporters: number; invalid: string[] } { + // TypeScript 7's lexical scanner is in-process: no compiler server or fixture files. + const scanner = createScanner(true, undefined, text); + const tokens: string[] = []; + const skipTemplate = (): void => { + let depth = 0; + for (let kind = scanner.scan(); kind !== SyntaxKind.EndOfFile; kind = scanner.scan()) { + if (kind === SyntaxKind.TemplateHead) skipTemplate(); + else if (kind === SyntaxKind.OpenBraceToken) depth++; + else if (kind === SyntaxKind.CloseBraceToken) { + if (depth) depth--; + else if (scanner.reScanTemplateToken(false) === SyntaxKind.TemplateTail) return; + } + } + }; + for (let kind = scanner.scan(); kind !== SyntaxKind.EndOfFile; kind = scanner.scan()) { + if (kind === SyntaxKind.TemplateHead) { skipTemplate(); tokens.push("#literal"); } + else if (kind === SyntaxKind.StringLiteral) tokens.push(JSON.stringify(scanner.getTokenValue())); + else if (kind === SyntaxKind.NoSubstitutionTemplateLiteral) tokens.push("#literal"); + else tokens.push(scanner.getTokenText()); + } + const close = new Map(); + const parent = new Map(); + const scopeAt: number[] = []; + const stack: number[] = []; + let scope = -1; + tokens.forEach((token, index) => { + scopeAt[index] = scope; + if (["(", "[", "{"].includes(token)) { + stack.push(index); + if (token === "{") { parent.set(index, scope); scope = index; } + } else if ([")", "]", "}"].includes(token)) { + const start = stack.pop(); + if (start !== undefined) close.set(start, index); + if (token === "}") scope = parent.get(scope) ?? -1; + } + }); + const bindings = new Map>(); + tokens.forEach((token, index) => { + if ((token === "const" || token === "let") && tokens[index + 2] === "=") { + const scope = scopeAt[index]!; + if (!bindings.has(scope)) bindings.set(scope, new Map()); + bindings.get(scope)!.set(tokens[index + 1]!, index + 3); + } + }); + const unwrap = (start: number, end: number): [number, number] => { + while (tokens[start] === "(" && close.get(start) === end - 1) { start++; end--; } + return [start, end]; + }; + const shared = (start: number, end: number, scope: number): boolean => { + [start, end] = unwrap(start, end); + if (tokens[start] === "remainingTransientSendBudget" && tokens[start + 1] === "(" + && close.get(start + 1) === end - 1) return true; + if (end - start !== 3 || tokens[start + 1] !== "." || tokens[start + 2] !== "attempts") return false; + while (scope >= -1) { + const declaration = bindings.get(scope)?.get(tokens[start]!); + if (declaration !== undefined) { + const callEnd = close.get(declaration + 1); + return tokens[declaration] === "recoverySendAllowance" && tokens[declaration + 1] === "(" + && callEnd !== undefined && [";", ","].includes(tokens[callEnd + 1]!); + } + if (scope === -1) break; + scope = parent.get(scope) ?? -1; + } + return false; + }; + let reporters = 0; + const invalid: string[] = []; + for (const [object, end] of close) { + if (tokens[object] !== "{") continue; + const properties = new Map(); + for (let index = object + 1; index < end; index++) { + if (tokens[index + 1] === ":") { + const name = tokens[index]!.replace(/^"|"$/g, ""); + const start = index + 2; + index = start; + while (index < end && tokens[index] !== ",") index = (close.get(index) ?? index) + 1; + if (!properties.has(name)) properties.set(name, []); + properties.get(name)!.push(unwrap(start, index)); + } else if (close.has(index)) index = close.get(index)!; + } + if (properties.get("onSendsConsumed")?.some(([start, end]) => end - start === 1 && tokens[start] === "noteTransientSends")) { + reporters++; + const attempts = properties.get("attempts") ?? []; + if (attempts.length !== 1 || !shared(...attempts[0]!, object)) { + invalid.push(`reporter ${reporters}: unchecked attempts`); + } + } + } + return { reporters, invalid }; +} + /** * `transientRetryOn5xx.attempts` is ONE request-wide total-send budget, not a per-leg * allowance. A Responses request can reach upstream on several legs — the initial send, a @@ -61,24 +155,14 @@ describe("transient send budget stays request-scoped", () => { // rebuild refetch, OAuth 401 replay, rate-limit 429 replay). The passthrough four were added // for #4546: the owner used to be declared BELOW that branch, which put it in the temporal // dead zone there, so each of those legs silently took the helper's fresh default of 3. - expect(core.match(/onSendsConsumed: noteTransientSends/g)).toHaveLength(7); - - // EVERY leg asks for the remainder now, including the adapter initial send. That one used - // to pass the raw policy on the argument that nothing had been spent yet -- true for a first - // turn, false for a combo child, which inherits the parent's holder and then took a fresh - // full allowance on its own first send. Five sites spell it directly; rebuild and recovery legs - // go through recoverySendAllowance, which spends the base allowance first and only then - // draws the single shared final-recovery reserve. - expect(core.match(/attempts: remainingTransientSendBudget\(/g)).toHaveLength(5); - expect(core).toContain("attempts: remainingTransientSendBudget(transientPolicy?.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS)"); - expect(core).toContain("attempts: remainingTransientSendBudget(continuationCap)"); - // The reserve path: an account move and a validated rebuild share ONE final send, so a - // request cannot take both and reach five. - expect(core.match(/recoverySendAllowance\(/g)).toHaveLength(3); + // Every reporter must draw either directly from the remainder or from the recovery + // allowance declared in its own lexical scope. The split between these forms may change: + // the first terminal repair now draws the final reserve without an explicit recovery label. + // A whole-tree substring count could pass while one site took a fresh policy allowance. + const wiring = retryBudgetWiring(core); + expect(wiring.reporters).toBe(7); + expect(wiring.invalid).toEqual([]); expect(core).toContain("countedExternally: true"); - // The passthrough legs have no adapter policy to draw from, so they name the helper's own - // ceiling rather than re-spelling the number. - expect(core).toContain("attempts: remainingTransientSendBudget(TRANSIENT_RETRY_MAX_ATTEMPTS)"); // The trap that would make the passthrough wiring a silent no-op: transientRetryPolicyFor // returns null for Codex forward auth, so gating these sites on it would restore a fresh 3. expect(core).not.toContain("transientPolicy ? { attempts: remainingTransientSendBudget(TRANSIENT_RETRY_MAX_ATTEMPTS)"); @@ -89,6 +173,34 @@ describe("transient send budget stays request-scoped", () => { expect(core).not.toContain("attempts: transientPolicy.attempts,"); }); + test("the wiring oracle ignores formatting and decoy text but rejects fresh allowances", () => { + const options = (attempts: string): string => `retry(fetch, { + "onSendsConsumed": (noteTransientSends), + /* a comment between the property and its value is not a different budget */ + attempts: (${attempts}), + });`; + const decoy = `// attempts: remainingTransientSendBudget(3), onSendsConsumed: noteTransientSends + const text = "attempts: remainingTransientSendBudget(3), onSendsConsumed: noteTransientSends";`; + const valid = `${decoy} + ${options("remainingTransientSendBudget /* format */ (policy.attempts)")} + const allowance = recoverySendAllowance(cap, "repair", target); + ${options("allowance.attempts")}`; + expect(retryBudgetWiring(valid)).toEqual({ reporters: 2, invalid: [] }); + expect(retryBudgetWiring(valid.replaceAll("\n", "\r\n\t").replaceAll(": (", ":\n ("))) + .toEqual({ reporters: 2, invalid: [] }); + for (const fresh of ["3", "policy.attempts"]) { + expect(retryBudgetWiring(`${decoy}\n${options(fresh)}`).invalid).toHaveLength(1); + // An unrelated valid allowance, even with the same name, cannot hide a fresh local one. + const shadowed = `const allowance = recoverySendAllowance(cap, "repair", target); + function continuation() { const allowance = { attempts: ${fresh} }; ${options("allowance.attempts")} }`; + expect(retryBudgetWiring(shadowed).invalid).toHaveLength(1); + } + expect(retryBudgetWiring(`const allowance = recoverySendAllowance(cap) || policy; + ${options("allowance.attempts")}`).invalid).toHaveLength(1); + expect(retryBudgetWiring("retry(fetch, { onSendsConsumed: noteTransientSends });").invalid) + .toHaveLength(1); + }); + test("the helper still exposes the seam those call sites depend on", () => { const retry = source("lib/upstream-retry.ts"); expect(retry).toContain("onSendsConsumed?: (sends: number) => void;"); From 107f5d82cb932226a1036c81336668d081906521 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:39:32 +0900 Subject: [PATCH 17/23] fix(responses): preserve continuation recovery reservations --- .../docs/reference/configuration/providers.md | 5 + src/server/responses/adapter-continuation.ts | 55 +++++---- src/server/responses/adapter-dispatch.ts | 5 +- src/server/responses/request-send-budget.ts | 5 +- structure/adapters/registry.md | 2 +- structure/catalog.md | 2 +- structure/clients/claude-desktop.md | 2 +- structure/data-planes/images.md | 2 +- structure/data-planes/inbound-compat.md | 2 +- structure/gui-and-management-api.md | 2 +- structure/ops/service-and-sidecars.md | 2 +- structure/providers/xai-grok.md | 2 +- structure/runtime.md | 2 +- structure/subagents.md | 2 +- structure/transports/byte-accounting.md | 2 +- structure/transports/inventory.md | 2 +- structure/transports/responses.md | 9 +- structure/transports/streaming-health.md | 2 +- .../responses/responses-core-modules.test.ts | 105 ++++++++++++++++++ .../server-kiro-oauth-401-replay.test.ts | 31 ++++++ 20 files changed, 204 insertions(+), 37 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 37b477ca52..d31ce10f68 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -227,6 +227,11 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity For translated Responses requests using a multi-key pool with `authMode: "key"` (excluding OAuth and forward-auth account pools), each routed provider invocation shares its API-key rotation allowance between initial recovery, web-search/image/video bridge rounds, and terminal continuations: at most `N - 1` rotations if the initial key belongs to the pool, or `N` if it is outside the pool, where `N` is the pool size before the first send. Cooldown expiry or later pool growth does not replenish this allowance. Existing applicable send budgets may stop retries sooner; sidecar bridges retain their own connection-reset and same-target retry limits, and this rotation cap does not impose the core's physical-send budget on those bridges. Once rotation is refused, the last 429 still records the failed key's cooldown, but no replacement key is selected and the response follows the existing error handling. Same-key `retryOn429` replays use their separate request-wide retry counter; this allowance counts key rotations only. +When `emptyCompletionRetry` is enabled, Kiro can use the request's remaining final-recovery send +after its three base sends finish without visible output. That recovery shares any existing +credential-hop reservation and is charged once. Google Vertex/Antigravity retain their existing +internal retry policy; hop bookkeeping does not impose a new physical-send cap on those adapters. + With `webSearchBridge` enabled, a search continuation stays bound to the API-key selection that served the first request. Changing the selected key, its reference or resolved value, authentication mode, or base URL during search or provider pacing ends the turn with a bridge error before another diff --git a/src/server/responses/adapter-continuation.ts b/src/server/responses/adapter-continuation.ts index 3ee4b497a2..884cdf0a95 100644 --- a/src/server/responses/adapter-continuation.ts +++ b/src/server/responses/adapter-continuation.ts @@ -15,7 +15,7 @@ import { recordAttemptCredentialSource, } from "../request-log"; import { waitForProviderRequestSlot } from "../../providers/request-pacing"; -import { providerFetch, fetchWithHeaderTimeout, safeHostLabel } from "./fetch-helpers"; +import { providerFetch, fetchWithHeaderTimeout, safeHostLabel, storedPoolReplayDispatchNotifier } from "./fetch-helpers"; import { transientRetryPolicyFor, rateLimitRetryDelayMs, @@ -193,22 +193,35 @@ export function createAdapterContinuations( if (transportState.activeAdapter.fetchResponse) { noteAttemptSend(logCtx.activeAttempt, continuationEstimate, replayKind); await waitForProviderRequestSlot(route.providerName, route.provider, nextParsed.modelId, upstream.signal); - const hop = sendBudgetState.pendingHopPermit; - const replaySendBudget = transportState.activeAdapter.fetchResponseUsesSendBudget && adapterSendBudget && hop - ? adapterSendBudget.deriveScope({ ...adapterSendBudget.policy, finalRecoveryAllowance: 0 }, hop) - : adapterSendBudget; - return await transportState.activeAdapter.fetchResponse(builtContinuationRequest, { - abortSignal: upstream.signal, - timeoutMs: connectMs, - sendBudget: replaySendBudget, - onPhysicalSend: send => noteAdapterPhysicalSend(continuationEstimate, send), - stream: nextParsed.stream, - executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, { - dispatchOverride: oauthDispatch(builtContinuationRequest, nextParsed), - providerName: route.providerName, - modelId: nextParsed.modelId, - }), - }); + // An adapter-owned repair needs the same prepaid reserve as a credential hop. + // Acquire after shaping/pacing, reuse an existing hop, and refund only if unused. + const ownedPermit = transportState.activeAdapter.fetchResponseUsesSendBudget && adapterSendBudget + ? sendBudgetState.pendingHopPermit ?? recoverySendAllowance(adapterSendBudget.policy.baseSendAllowance, + replayKind ? recoveryClassFor(replayKind) : "repair", builtContinuationRequest.url).permit + : undefined; + const callerHop = transportState.activeAdapter.fetchResponseUsesSendBudget + ? undefined : sendBudgetState.pendingHopPermit; + try { + const replaySendBudget = adapterSendBudget && ownedPermit + ? adapterSendBudget.deriveScope({ ...adapterSendBudget.policy, finalRecoveryAllowance: 0 }, ownedPermit) + : adapterSendBudget; + return await transportState.activeAdapter.fetchResponse(builtContinuationRequest, { + abortSignal: upstream.signal, + timeoutMs: connectMs, + sendBudget: replaySendBudget, + onPhysicalSend: send => noteAdapterPhysicalSend(continuationEstimate, send), + stream: nextParsed.stream, + executor: storedPoolReplayDispatchNotifier(providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(builtContinuationRequest, nextParsed), + providerName: route.providerName, + modelId: nextParsed.modelId, + }), callerHop ? () => { + if (!callerHop.use()) throw new SendBudgetExhaustedError(safeHostLabel(builtContinuationRequest.url)); + } : undefined), + }); + } finally { + ownedPermit?.release(); + } } // Same #1851 scope guard as the initial send: transient-5xx retry only for direct // Google AI Studio; every other adapter keeps reset-only semantics here. @@ -222,10 +235,13 @@ export function createAdapterContinuations( const allowance = recoverySendAllowance(continuationCap, replayKind ? recoveryClassFor(replayKind) : "repair", `${route.providerName}|${route.modelId}|${replayKind ?? "terminal-continuation"}`); + let firstPermit = allowance.permit; try { return await fetchContinuationWithRetryPolicy( recovery => { - if (allowance.permit && !allowance.permit.use()) { + const permit = firstPermit; + firstPermit = undefined; + if (permit && !permit.use()) { throw new SendBudgetExhaustedError(safeHostLabel(builtContinuationRequest.url)); } noteAttemptSend(logCtx.activeAttempt, continuationEstimate, recovery ?? replayKind); @@ -448,8 +464,7 @@ export function createAdapterContinuations( sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel); recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name); nextContinuationRecoveryKind = "oauth-account-429"; - sendBudgetState.pendingHopPermit = !transportState.activeAdapter.fetchResponse - || transportState.activeAdapter.fetchResponseUsesSendBudget ? hop.permit : undefined; + sendBudgetState.pendingHopPermit = hop.permit; continue; } } catch { diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index 89d3bba0a7..e02fc66ab2 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -453,10 +453,13 @@ export async function prepareAdapterExchange( recoveryClassFor(recovery), `${route.providerName}|${route.modelId}|${recovery}`, ); + let firstPermit = refetchAllowance.permit; try { return await refetchWithPolicy( recoveryKind => { - if (refetchAllowance?.permit && !refetchAllowance.permit.use()) { + const permit = firstPermit; + firstPermit = undefined; + if (permit && !permit.use()) { throw new SendBudgetExhaustedError(safeHostLabel(retryRequest.url)); } // Same boundary on the helper path: the thunk is what reaches the wire, and it diff --git a/src/server/responses/request-send-budget.ts b/src/server/responses/request-send-budget.ts index 202e2992c6..fa0f50af33 100644 --- a/src/server/responses/request-send-budget.ts +++ b/src/server/responses/request-send-budget.ts @@ -100,12 +100,13 @@ export function createResponsesSendBudget( targetKey: string, ): { attempts: number; permit?: SingleUseDispatchPermit } => { const base = remainingTransientSendBudget(cap); - if (base > 0) return { attempts: base }; if (pendingHopPermit) { const hopPermit = pendingHopPermit; pendingHopPermit = undefined; - return { attempts: 1, permit: hopPermit }; + // The prepaid first send already reduced base; retain the unspent ordinary retries. + return { attempts: Math.min(cap, base + 1), permit: hopPermit }; } + if (base > 0) return { attempts: base }; if (!isRequestExecutionBudget(sendBudget)) return { attempts: 0 }; const decision = sendBudget.reserveDispatch({ sendClass, targetKey, countedExternally: true }); return decision.allowed ? { attempts: 1, permit: decision.permit } : { attempts: 0 }; diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index e4152000a1..9ff9044888 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -4,7 +4,7 @@ Request-local adapter bindings are separate from registry authority in the Respo [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](../transports/responses.md#passthrough-sse-stream-shapes-314). diff --git a/structure/catalog.md b/structure/catalog.md index 43d02ca7cf..9afa72e39d 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -4,7 +4,7 @@ Catalog discovery remains separate from the Responses final-route [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 104194bedb..ecc70310cb 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -4,7 +4,7 @@ Desktop callers retain their existing ingress through the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. Codex-native model discovery follows the [shared retirement policy](../catalog.md#shared-catalog). That projection does not migrate existing user-selected Desktop configuration or usage history. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index f7248ce417..84af841123 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -4,7 +4,7 @@ Vision preprocessing and image/video/search execution use the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. Hosted Responses image-tool eligibility uses the shared compatibility policy without a Codex Spark exception; standalone Images retain the separate relay contract below. See diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 59d7d60920..1cff303368 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -4,7 +4,7 @@ Compatibility callers retain the public Responses ingress described by the [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. ## Standalone file transcription diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index f68d4b5d19..db136d56ad 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -4,7 +4,7 @@ The shared server request path follows the Responses [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. ## Dashboard serving diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index a19dbb91a7..4867162316 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -4,7 +4,7 @@ Service endpoints are unchanged by the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. Service startup and restore use the [catalog retirement policy](../catalog.md#shared-catalog); retirement does not itself change service registration or user-selected model configuration. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 3e7190c02c..a2c8d87607 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -4,7 +4,7 @@ xAI uses the same shared credential and delivery policies through the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. Codex-native retirement is scoped to OpenAI catalog/quota evidence. Shared Responses handling retains xAI provider behavior; see diff --git a/structure/runtime.md b/structure/runtime.md index 0e3fe96e43..1e123c38cd 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -4,7 +4,7 @@ Responses admission and finalization are composed through the [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. Chat request serialization owns the destination-scoped [OpenCode Go instruction ordering](providers/chat-compat.md#opencode-go-chronological-instructions); diff --git a/structure/subagents.md b/structure/subagents.md index ff11bb4872..b14399fc8e 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -10,7 +10,7 @@ Encrypted-task and fallback request handling follow the Responses only explicit true on Responses ingress to the final canonical ChatGPT forward route activates it. A default top-level collaboration catalog is required. The compiler preserves caller objects, aliases the namespace and three message functions, and removes only their true encryption marker. -Declaration/reference collisions refuse the whole rewrite without changing the request. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. +Declaration/reference collisions refuse the whole rewrite without changing the request. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. `src/adapters/openai-responses.ts` returns request-local alias capabilities. The Responses core refreshes them after every request rebuild and restores JSON, SSE and WebSocket identities after diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 58f0677fbd..81ee3a0af2 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -5,7 +5,7 @@ Responses body-reader limits and lifetime handling follow the How opencodex measures request and stream bytes without allocating copies solely to count them. These contracts are shared by request parsing, SSE rewriting, the provider adapters and -the translator budget, which is why so many documents link here rather than restating them. Response-attached WebSocket telemetry follows the [stage record identity contract](responses.md#passthrough-sse-stream-shapes-314). Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. +the translator budget, which is why so many documents link here rather than restating them. Response-attached WebSocket telemetry follows the [stage record identity contract](responses.md#passthrough-sse-stream-shapes-314). Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. ## Request-copy accounting diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 33da133feb..f1ac33f5f1 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -4,7 +4,7 @@ The existing Responses transport is divided by responsibility in the [core module ownership](responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. The Chat adapter's [OpenCode Go instruction ordering](../providers/chat-compat.md#opencode-go-chronological-instructions) changes translated message placement only; endpoint selection and transport stay with their existing owners. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 12797a6bf3..c007e2c1c9 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -690,10 +690,17 @@ Caller-counted adapter dispatch confirms its hop after request shaping and pacin dispatch reserves with `countedExternally: true` and passes the booking through `pendingHopPermit`. An adapter declaring `fetchResponseUsesSendBudget`, currently Kiro, also receives an externally counted booking, but adopts it through a derived budget at its own physical-send boundary. -That scope shares the request ceiling and adds no recovery reserve. Other `fetchResponse` +An initial Kiro empty-completion repair reserves the same final send when no credential hop is +pending. Its derived scope shares the request ceiling and adds no recovery reserve. Retry-helper +replays include the prepaid first send alongside remaining base retries, confirming that permit +only on their first dispatch. Other `fetchResponse` implementations retain caller-owned confirmation; having that method alone does not establish that the adapter consumes budget permits. A queued continuation carries an unused booking into its next dispatch and releases it if shaping, pacing or admission fails before a send. +Caller-owned continuation hops confirm on the first executor invocation, so an adapter that +throws before dispatch also refunds its booking. Google Vertex/Antigravity internal retries do +not yet consume this physical-send budget; preserving their hop bookkeeping does not impose a +new total cap on those adapters. `tests/server/server-kiro-oauth-401-replay.test.ts` counts actual Kiro requests across three stored OAuth accounts, including an earlier connection reset and the third account's success or quota diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 3f4404bc35..44f910502c 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -4,7 +4,7 @@ Native and translated delivery now have separate owners in the [core module ownership](responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Initial terminal-guard repairs use that same shared final reserve. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. Codex WebSocket quota-family normalization remains generic; retired-model evidence is filtered by the [OpenAI quota owner](../providers/openai-tiers.md#public-provider-contract), not by diff --git a/tests/responses/responses-core-modules.test.ts b/tests/responses/responses-core-modules.test.ts index b13733ed1b..7a13cfb07b 100644 --- a/tests/responses/responses-core-modules.test.ts +++ b/tests/responses/responses-core-modules.test.ts @@ -6,6 +6,8 @@ import { readResponsesCoreModule, } from "../helpers/responses-core-source"; import { createResponsesSendBudget } from "../../src/server/responses/request-send-budget"; +import { createAdapterContinuations } from "../../src/server/responses/adapter-continuation"; +import type { AdapterFetchContext, AdapterRequest } from "../../src/adapters/base"; import { createRequestExecutionBudget } from "../../src/lib/request-execution-budget"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; import type { TransientSendBudget } from "../../src/lib/upstream-retry"; @@ -129,6 +131,109 @@ function budgetOwner(sendBudget: TransientSendBudget) { } describe("Responses request-owned send budget after extraction", () => { + test("a prepaid retry-helper continuation retains the remaining base retry after a reset", async () => { + const holder = createRequestExecutionBudget(); + holder.used = 1; + const { owner, dispose } = budgetOwner(holder); + const hop = owner.reserveCredentialHop("auth-recovery", "fixture", true); + expect(hop.allowed).toBe(true); + owner.pendingHopPermit = hop.permit; + const translatorBudget = createTranslatorBudget(); + const parsed = { modelId: "fixture", stream: false }; + let sends = 0; + const adapter = { + name: "openai-chat", + async buildRequest() { return { url: "https://fixture.invalid/", method: "POST", headers: {}, body: "{}" }; }, + async parseResponse() { return []; }, + }; + type Args = Parameters; + const continuation = createAdapterContinuations( + { options: {}, config: {}, logCtx: { model: "fixture", provider: "fixture" } } as Args[0], + { route: { providerName: "fixture", modelId: "fixture", provider: { + adapter: "openai-chat", baseUrl: "https://fixture.invalid/", + } }, parsed, translatorBudget } as Args[1], + { activeAdapter: adapter, oauthDispatch: () => async () => { + sends += 1; + if (sends === 1) throw Object.assign(new Error("fixture ECONNRESET"), { code: "ECONNRESET" }); + return new Response("{}"); + } } as unknown as Args[2], + { routedCompaction: false }, owner, + { upstream: new AbortController(), connectMs: 100, rateLimitPolicy: null, rateLimitRetries: 0, + stallTimeoutMs: 100, keyPool429RetryAllowed: () => false }, + ); + try { + const events = []; + for await (const event of continuation.fetchTerminalGuardContinuation(parsed as Args[1]["parsed"], "oauth-account-429")) events.push(event); + expect(sends).toBe(2); + expect(holder.used).toBe(3); + expect(holder.reserveSpent).toBe(false); + expect(owner.pendingHopPermit).toBeUndefined(); + expect(events).toEqual([]); + hop.permit?.release(); + expect(holder.used).toBe(3); + } finally { translatorBudget.dispose(); dispose(); } + }); + + for (const [adapterOwned, prepaid] of [[true, false], [true, true], [false, true]]) { + test.each(["build", "pacing", "adapter", "sent"] as const)(`continuation refunds only unused permits (adapterOwned=${adapterOwned}, prepaid=${prepaid}, %s)`, async failure => { + const holder = createRequestExecutionBudget(); + holder.used = 3; + const { owner, dispose } = budgetOwner(holder); + if (prepaid) { + const hop = owner.reserveCredentialHop("auth-recovery", "fixture", adapterOwned); + expect(hop.allowed).toBe(true); + owner.pendingHopPermit = hop.permit; + } + const translatorBudget = createTranslatorBudget(); + const upstream = new AbortController(); + if (failure === "pacing") upstream.abort(); + let sends = 0; + const parsed = { modelId: "fixture", stream: false }; + const adapter = { + name: adapterOwned ? "kiro" : "google", fetchResponseUsesSendBudget: adapterOwned, + async buildRequest() { + if (failure === "build") throw new Error("fixture build failure"); + return { url: "https://fixture.invalid/", method: "POST", headers: {}, body: "{}" }; + }, + async fetchResponse(request: AdapterRequest, ctx: AdapterFetchContext) { + if (failure === "adapter") throw new Error("fixture before physical send"); + if (adapterOwned) { + const send = ctx.sendBudget!.reserveDispatch({ sendClass: "transient", targetKey: request.url }); + expect(send.allowed).toBe(true); + if (!send.allowed) throw new Error("Expected funded physical send"); + expect(send.permit.use()).toBe(true); + } else { + await ctx.executor!(request.url, { method: request.method, body: request.body }); + } + sends += 1; + return new Response("{}"); + }, + async parseResponse() { return []; }, + }; + type Args = Parameters; + const continuation = createAdapterContinuations( + { options: {}, config: {}, logCtx: { model: "fixture", provider: "fixture" } } as Args[0], + { route: { providerName: "fixture", modelId: "fixture", provider: { + adapter: "kiro", baseUrl: "https://fixture.invalid/", + requestPacing: { enabled: true, minIntervalMs: failure === "pacing" ? 1 : 0 }, + } }, parsed, translatorBudget } as Args[1], + { activeAdapter: adapter, oauthDispatch: () => async () => new Response("{}") } as unknown as Args[2], + { routedCompaction: false }, owner, + { upstream, connectMs: 100, rateLimitPolicy: null, rateLimitRetries: 0, stallTimeoutMs: 100, + keyPool429RetryAllowed: () => false }, + ); + try { + const events = []; + for await (const event of continuation.fetchTerminalGuardContinuation(parsed as Args[1]["parsed"], "empty-completion")) events.push(event); + expect(sends).toBe(failure === "sent" ? 1 : 0); + expect(holder.used).toBe(failure === "sent" ? 4 : 3); + expect(holder.reserveSpent).toBe(failure === "sent"); + expect(owner.pendingHopPermit).toBeUndefined(); + expect(events.some(event => event.type === "error")).toBe(failure !== "sent"); + } finally { translatorBudget.dispose(); dispose(); } + }); + } + test("legacy holders retain identity and an exhausted remainder stays zero", () => { const holder = { used: 2 }; const { owner, dispose } = budgetOwner(holder); diff --git a/tests/server/server-kiro-oauth-401-replay.test.ts b/tests/server/server-kiro-oauth-401-replay.test.ts index 4a1c724b5c..2dc27b6b94 100644 --- a/tests/server/server-kiro-oauth-401-replay.test.ts +++ b/tests/server/server-kiro-oauth-401-replay.test.ts @@ -9,6 +9,8 @@ import { getAccountSet, saveCredential, setActiveAccount } from "../../src/oauth import { clearGenericFailoverHealth } from "../../src/oauth/generic-account-failover"; import { resetKiroThrottleStateForTests } from "../../src/adapters/kiro-retry"; import { startServer } from "../../src/server"; +import { handleResponses } from "../../src/server/responses"; +import { createRequestExecutionBudget, CODEX_TEXT_GUARDED_BUDGET_POLICY } from "../../src/lib/request-execution-budget"; import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -144,6 +146,35 @@ function installFetch(chatStatuses: number[]): { chatAuth: string[]; refreshCall } describe("Kiro OAuth upstream 401 replay", () => { + test.each([1, 0])("Kiro empty completion after two resets uses only its available repair reserve (%i)", async reserve => { + resetKiroThrottleStateForTests(); + await seedOAuth(); + const cfg = { ...config(), emptyCompletionRetry: true }; + const budget = createRequestExecutionBudget({ ...CODEX_TEXT_GUARDED_BUDGET_POLICY, finalRecoveryAllowance: reserve }); + let sends = 0; + globalThis.fetch = (async input => { + const url = input instanceof Request ? input.url : String(input); + if (url !== CHAT_ENDPOINT) throw new Error(`Unexpected fixture request: ${url}`); + sends += 1; + if (sends <= 2) throw Object.assign(new Error("fixture ECONNRESET"), { code: "ECONNRESET" }); + return new Response(sends === 3 ? reasoningStream("Thinking without an answer") : eventStream("repair completed"), { + headers: { "content-type": "application/vnd.amazon.eventstream" }, + }); + }) as typeof fetch; + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "kiro/claude-sonnet-4.5", input: "hello", stream: false }), + }), cfg, { model: "", provider: "" }, { sendBudget: budget }); + const text = await response.text(); + expect(sends).toBe(3 + reserve); + expect(budget.used).toBe(sends); + expect(budget.reserveSpent).toBe(reserve === 1); + if (reserve) expect(text).toContain("repair completed"); + else expect(text).not.toContain("repair completed"); + } finally { resetKiroThrottleStateForTests(); } + }, 20_000); + test.each(["quota", "success", "reset-success", "empty-retry"] as const)("Kiro OAuth hops charge adapter-owned physical sends once (%s)", async mode => { const authorizations: string[] = []; clearGenericFailoverHealth(); From 8885847bf52010c1b3f8a90036f21fdb14c51f61 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:48:18 +0900 Subject: [PATCH 18/23] fix(responses): settle passthrough recovery permit once --- src/server/responses/passthrough-dispatch.ts | 8 ++-- structure/adapters/registry.md | 2 +- structure/catalog.md | 2 +- structure/clients/claude-desktop.md | 2 +- structure/data-planes/images.md | 2 +- structure/data-planes/inbound-compat.md | 2 +- structure/gui-and-management-api.md | 2 +- structure/ops/service-and-sidecars.md | 2 +- structure/providers/xai-grok.md | 2 +- structure/runtime.md | 2 +- structure/subagents.md | 2 +- structure/transports/byte-accounting.md | 2 +- structure/transports/inventory.md | 2 +- structure/transports/responses.md | 4 +- structure/transports/streaming-health.md | 2 +- .../server-xai-oauth-401-replay.test.ts | 38 ++++++++++++++++++- 16 files changed, 57 insertions(+), 19 deletions(-) diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index f5c5b94694..5d95d3bffe 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -840,12 +840,14 @@ export async function preparePassthroughExchange( recoveryClassFor(recovery), `${route.providerName}|${route.modelId}|${recovery}`, ); + let firstPermit = allowance.permit; try { return await fetchWithTransientRetry( innerRecovery => { - // Gated on the return, not fire-and-forget: a consumed permit means this leg - // already sent once, and letting the second call through would be a free send. - if (allowance.permit && !allowance.permit.use()) { + // The prepaid hop funds the first send; later attempts use the remaining base. + const permit = firstPermit; + firstPermit = undefined; + if (permit && !permit.use()) { throw new SendBudgetExhaustedError(safeHostLabel(request.url)); } noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, innerRecovery ?? recovery); diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 9ff9044888..a71b0cd7ca 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -4,7 +4,7 @@ Request-local adapter bindings are separate from registry authority in the Respo [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid native Responses and translated helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](../transports/responses.md#passthrough-sse-stream-shapes-314). diff --git a/structure/catalog.md b/structure/catalog.md index 9afa72e39d..4ab72248ce 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -4,7 +4,7 @@ Catalog discovery remains separate from the Responses final-route [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid native Responses and translated helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index ecc70310cb..7fb720e49e 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -4,7 +4,7 @@ Desktop callers retain their existing ingress through the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid native Responses and translated helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. Codex-native model discovery follows the [shared retirement policy](../catalog.md#shared-catalog). That projection does not migrate existing user-selected Desktop configuration or usage history. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 84af841123..1af33721b6 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -4,7 +4,7 @@ Vision preprocessing and image/video/search execution use the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid native Responses and translated helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. Hosted Responses image-tool eligibility uses the shared compatibility policy without a Codex Spark exception; standalone Images retain the separate relay contract below. See diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 1cff303368..610fdab159 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -4,7 +4,7 @@ Compatibility callers retain the public Responses ingress described by the [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid native Responses and translated helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. ## Standalone file transcription diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index db136d56ad..9f410a88d7 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -4,7 +4,7 @@ The shared server request path follows the Responses [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid native Responses and translated helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. ## Dashboard serving diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 4867162316..f856b557bc 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -4,7 +4,7 @@ Service endpoints are unchanged by the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid native Responses and translated helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. Service startup and restore use the [catalog retirement policy](../catalog.md#shared-catalog); retirement does not itself change service registration or user-selected model configuration. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index a2c8d87607..9c977d3c4f 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -4,7 +4,7 @@ xAI uses the same shared credential and delivery policies through the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](../transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](../transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid native Responses and translated helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. Codex-native retirement is scoped to OpenAI catalog/quota evidence. Shared Responses handling retains xAI provider behavior; see diff --git a/structure/runtime.md b/structure/runtime.md index 1e123c38cd..331c3c3031 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -4,7 +4,7 @@ Responses admission and finalization are composed through the [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid native Responses and translated helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. Chat request serialization owns the destination-scoped [OpenCode Go instruction ordering](providers/chat-compat.md#opencode-go-chronological-instructions); diff --git a/structure/subagents.md b/structure/subagents.md index b14399fc8e..9fe4c45d02 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -10,7 +10,7 @@ Encrypted-task and fallback request handling follow the Responses only explicit true on Responses ingress to the final canonical ChatGPT forward route activates it. A default top-level collaboration catalog is required. The compiler preserves caller objects, aliases the namespace and three message functions, and removes only their true encryption marker. -Declaration/reference collisions refuse the whole rewrite without changing the request. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. +Declaration/reference collisions refuse the whole rewrite without changing the request. Generic Responses API-key failover follows the [bounded rotation contract](transports/responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](transports/responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid native Responses and translated helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. `src/adapters/openai-responses.ts` returns request-local alias capabilities. The Responses core refreshes them after every request rebuild and restores JSON, SSE and WebSocket identities after diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 81ee3a0af2..5866943848 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -5,7 +5,7 @@ Responses body-reader limits and lifetime handling follow the How opencodex measures request and stream bytes without allocating copies solely to count them. These contracts are shared by request parsing, SSE rewriting, the provider adapters and -the translator budget, which is why so many documents link here rather than restating them. Response-attached WebSocket telemetry follows the [stage record identity contract](responses.md#passthrough-sse-stream-shapes-314). Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. +the translator budget, which is why so many documents link here rather than restating them. Response-attached WebSocket telemetry follows the [stage record identity contract](responses.md#passthrough-sse-stream-shapes-314). Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid native Responses and translated helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. ## Request-copy accounting diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index f1ac33f5f1..5080b8d448 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -4,7 +4,7 @@ The existing Responses transport is divided by responsibility in the [core module ownership](responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid native Responses and translated helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. The Chat adapter's [OpenCode Go instruction ordering](../providers/chat-compat.md#opencode-go-chronological-instructions) changes translated message placement only; endpoint selection and transport stay with their existing owners. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index c007e2c1c9..5bfe55598f 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -691,8 +691,8 @@ dispatch reserves with `countedExternally: true` and passes the booking through An adapter declaring `fetchResponseUsesSendBudget`, currently Kiro, also receives an externally counted booking, but adopts it through a derived budget at its own physical-send boundary. An initial Kiro empty-completion repair reserves the same final send when no credential hop is -pending. Its derived scope shares the request ceiling and adds no recovery reserve. Retry-helper -replays include the prepaid first send alongside remaining base retries, confirming that permit +pending. Its derived scope shares the request ceiling and adds no recovery reserve. Native Responses +and translated retry-helper replays include the prepaid first send alongside remaining base retries, confirming that permit only on their first dispatch. Other `fetchResponse` implementations retain caller-owned confirmation; having that method alone does not establish that the adapter consumes budget permits. A queued continuation carries an unused booking into diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 44f910502c..3eee6221f9 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -4,7 +4,7 @@ Native and translated delivery now have separate owners in the [core module ownership](responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Generic Responses API-key failover follows the [bounded rotation contract](responses.md#bounded-api-key-429-rotation). Responses dispatch scopes share [reservation and settlement accounting](responses.md#bounded-api-key-429-rotation) while keeping recovery ledgers local; generic reset-only fetches and OAuth replays, including budget-aware adapters, settle physical sends once; nested compact/combo scopes retain the prepaid recovery without enlarging its ceiling or charging twice. Terminal and budget-aware Kiro empty-completion repairs share that final reserve; prepaid native Responses and translated helper retries retain their remaining base allowance, and caller-owned continuation hops refund if no dispatch occurs. Codex WebSocket quota-family normalization remains generic; retired-model evidence is filtered by the [OpenAI quota owner](../providers/openai-tiers.md#public-provider-contract), not by diff --git a/tests/server/server-xai-oauth-401-replay.test.ts b/tests/server/server-xai-oauth-401-replay.test.ts index aab488ccb5..f5ed0b2125 100644 --- a/tests/server/server-xai-oauth-401-replay.test.ts +++ b/tests/server/server-xai-oauth-401-replay.test.ts @@ -4,7 +4,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; import { XAI_OAUTH_DISCOVERY_URL } from "../../src/oauth/xai"; -import { saveCredential } from "../../src/oauth/store"; +import { saveCredential, getAccountSet, setActiveAccount } from "../../src/oauth/store"; +import { clearGenericFailoverHealth } from "../../src/oauth/generic-account-failover"; +import { createRequestExecutionBudget } from "../../src/lib/request-execution-budget"; +import { handleResponses } from "../../src/server/responses"; import { XAI_GROK_CLI_BASE_URL } from "../../src/providers/xai-transport"; import { readUsageEntries, usageLogPath } from "../../src/usage/log"; import { startServer } from "../../src/server"; @@ -146,6 +149,39 @@ function installOAuthFetch( } describe("xAI OAuth Responses opt-in upstream 401 replay", () => { + test("passthrough OAuth hop retains a base retry after its prepaid first send resets", async () => { + clearGenericFailoverHealth(); + await seedOAuth(); + const firstId = getAccountSet("xai")!.activeAccountId; + await saveCredential("xai", { + access: "alternate-access", refresh: "alternate-refresh", expires: Date.now() + 3_600_000, + accountId: "xai-alternate-account", source: "oauth", + }, { addAccount: true }); + await setActiveAccount("xai", firstId); + const budget = createRequestExecutionBudget(); + const authorizations: string[] = []; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url !== OAUTH_RESPONSES_ENDPOINT) throw new Error(`Unexpected fixture request: ${url}`); + authorizations.push(new Headers(init?.headers).get("authorization") ?? ""); + if (authorizations.length === 1) return Response.json({ error: { message: "rate limited" } }, { status: 429 }); + if (authorizations.length === 2) throw Object.assign(new Error("fixture ECONNRESET"), { code: "ECONNRESET" }); + return new Response(successBody("alternate retry completed"), { headers: { "content-type": "application/json" } }); + }) as typeof fetch; + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "xai/grok-4.5", input: "hello", stream: false }), + }), xaiConfig(), { model: "", provider: "" }, { sendBudget: budget }); + const body = await response.text(); + expect(authorizations).toEqual(["Bearer rejected-access", "Bearer alternate-access", "Bearer alternate-access"]); + expect(response.status).toBe(200); + expect(body).toContain("alternate retry completed"); + expect(budget.used).toBe(3); + expect(budget.reserveSpent).toBe(false); + } finally { clearGenericFailoverHealth(); } + }); + test("initial OAuth refresh projects raw provider failures before responding", async () => { await seedOAuth(0); saveConfig(xaiConfig()); From bdf3dd9c6fa6468061e06fc6c4506ed828c71780 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:00:43 +0900 Subject: [PATCH 19/23] fix(adapters): route caller-owned inference through supplied executor --- src/adapters/base.ts | 4 +- src/adapters/command-code.ts | 2 +- src/adapters/mimo-free.ts | 5 +- structure/adapters/registry.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/providers/chat-compat.md | 2 + structure/providers/cursor.md | 2 + structure/runtime.md | 2 + structure/transports/byte-accounting.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 6 ++ tests/providers/command-code-provider.test.ts | 64 ++++++++++++++++++- tests/providers/mimo-free-provider.test.ts | 11 +++- 13 files changed, 98 insertions(+), 8 deletions(-) diff --git a/src/adapters/base.ts b/src/adapters/base.ts index 40ceb97c1e..595c853f5d 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -151,7 +151,9 @@ export interface AdapterFetchContext { returnRawErrors?: boolean; /** Whether the upstream response will be consumed as a stream; adapters may select low-latency transport settings. */ stream?: boolean; - /** Custom fetch executor to use for physical upstream network requests (defaults to globalThis.fetch). */ + /** Caller-owned fetchResponse transports must use this for inference and replays when supplied. + * Retains hop confirmation and pacing; absent uses the adapter's default fetch. + * Separate credential/catalog discovery does not count as inference. */ executor?: typeof globalThis.fetch; /** * The logical request's send budget (#4546). Optional and unlimited when absent, so an diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index 4b7c707d7e..3c466829e2 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -469,7 +469,7 @@ async function fetchCommandCode(request: AdapterRequest, ctx: AdapterFetchContex const timer = setTimeout(() => timeout.abort(new DOMException("Timeout elapsed", "TimeoutError")), ctx?.timeoutMs ?? 200_000); const callerSignal = ctx?.abortSignal ?? new AbortController().signal; try { - return await executor(request.url, { + return await (ctx?.executor ?? executor)(request.url, { method: request.method, headers: request.headers, body: request.body, diff --git a/src/adapters/mimo-free.ts b/src/adapters/mimo-free.ts index 6500e023b5..0689f2ebde 100644 --- a/src/adapters/mimo-free.ts +++ b/src/adapters/mimo-free.ts @@ -248,7 +248,8 @@ export function createMimoFreeAdapter(provider: OcxProviderConfig): ProviderAdap }, async fetchResponse(request: AdapterRequest, ctx): Promise { - const response = await fetch(request.url, { + const executor = ctx?.executor ?? fetch; + const response = await executor(request.url, { method: request.method, redirect: "manual", headers: request.headers as Record, @@ -268,7 +269,7 @@ export function createMimoFreeAdapter(provider: OcxProviderConfig): ProviderAdap ...(request.headers as Record), "Authorization": `Bearer ${freshJwt}`, }; - return fetch(request.url, { + return executor(request.url, { method: request.method, redirect: "manual", headers: retryHeaders, diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index a71b0cd7ca..869fa1baad 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -1,5 +1,7 @@ # Adapter Registry Authority +Caller-owned Command Code and MiMo inference replays honor the supplied executor under the [hop confirmation contract](../transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. + Request-local adapter bindings are separate from registry authority in the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 610fdab159..fa9b8dd6ca 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -1,5 +1,7 @@ # Inbound Compatibility Surfaces +Caller-owned Command Code and MiMo inference replays honor the supplied executor under the [hop confirmation contract](../transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. + Compatibility callers retain the public Responses ingress described by the [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 54a20a83a6..942b378c8b 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -1,5 +1,7 @@ # Chat Provider Compatibility +Caller-owned Command Code and MiMo inference replays honor the supplied executor under the [hop confirmation contract](../transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. + HTTP adapters declare their own send-budget consumption under the [credential-hop contract](../transports/responses.md#credential-hop-reservations); ordinary Chat retry helpers retain external-send settlement. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index d30f9b4623..854e0be77e 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -1,5 +1,7 @@ # Cursor Provider +Caller-owned Command Code and MiMo inference replays honor the supplied executor under the [hop confirmation contract](../transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. + HTTP adapters declare their own send-budget consumption under the [credential-hop contract](../transports/responses.md#credential-hop-reservations); Cursor's run-turn transport retains its existing ownership. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) diff --git a/structure/runtime.md b/structure/runtime.md index 331c3c3031..9808e2b9d6 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,5 +1,7 @@ # Runtime +Caller-owned Command Code and MiMo inference replays honor the supplied executor under the [hop confirmation contract](transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. + Responses admission and finalization are composed through the [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 5866943848..f8bde4a962 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -1,5 +1,7 @@ # Byte Accounting +Caller-owned Command Code and MiMo inference replays honor the supplied executor under the [hop confirmation contract](responses.md#credential-hop-reservations); credential and catalog discovery remain separate. + Responses body-reader limits and lifetime handling follow the [core module ownership](responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 5080b8d448..b22faec747 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -1,5 +1,7 @@ # Transport Inventory +Caller-owned Command Code and MiMo inference replays honor the supplied executor under the [hop confirmation contract](responses.md#credential-hop-reservations); credential and catalog discovery remain separate. + The existing Responses transport is divided by responsibility in the [core module ownership](responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 5bfe55598f..899cb46420 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -701,6 +701,12 @@ Caller-owned continuation hops confirm on the first executor invocation, so an a throws before dispatch also refunds its booking. Google Vertex/Antigravity internal retries do not yet consume this physical-send budget; preserving their hop bookkeeping does not impose a new total cap on those adapters. +Command Code and MiMo honor the supplied executor for every inference attempt, including +reasoning-effort or fresh-JWT replays. Their model-catalog and credential discovery stay separate, +so those auxiliary requests cannot confirm an inference hop. The real Command Code OAuth +empty-completion regression in `tests/providers/command-code-provider.test.ts` bounds subsequent +credential rotations after a dispatched hop; MiMo executor and bootstrap separation is covered +in `tests/providers/mimo-free-provider.test.ts`. `tests/server/server-kiro-oauth-401-replay.test.ts` counts actual Kiro requests across three stored OAuth accounts, including an earlier connection reset and the third account's success or quota diff --git a/tests/providers/command-code-provider.test.ts b/tests/providers/command-code-provider.test.ts index d588c550ee..9052f86479 100644 --- a/tests/providers/command-code-provider.test.ts +++ b/tests/providers/command-code-provider.test.ts @@ -1,4 +1,13 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveCredential, getAccountSet, setActiveAccount } from "../../src/oauth/store"; +import { clearGenericFailoverHealth } from "../../src/oauth/generic-account-failover"; +import { createRequestExecutionBudget, CODEX_TEXT_GUARDED_BUDGET_POLICY } from "../../src/lib/request-execution-budget"; +import { handleResponses } from "../../src/server/responses"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import type { OcxConfig } from "../../src/types"; import { commandCodeSessionId, createCommandCodeAdapter } from "../../src/adapters/command-code"; import { loginCommandCode, parseCommandCodeCallback, shouldImportLocalCommandCodeAuth } from "../../src/oauth/command-code"; import { buildModelsRequest, OAUTH_PROVIDERS } from "../../src/oauth"; @@ -39,6 +48,50 @@ async function builtRequest(...args: Parameters resetCommandCodeReasoningEffortsForTest()); describe("Command Code provider", () => { + test("empty-completion OAuth continuation keeps a dispatched caller-owned hop charged", async () => { + const previousHome = process.env.OPENCODEX_HOME; + const fixtureHome = mkdtempSync(join(tmpdir(), "ocx-command-hop-")); + process.env.OPENCODEX_HOME = fixtureHome; + const originalFetch = globalThis.fetch; + clearGenericFailoverHealth(); + try { + for (let index = 0; index < 4; index++) await saveCredential("command-code", { + access: `synthetic-command-${index}`, refresh: `synthetic-refresh-${index}`, + expires: Date.now() + 3_600_000, accountId: `fixture-${index}`, source: "oauth", + }, { addAccount: true }); + await setActiveAccount("command-code", getAccountSet("command-code")!.accounts[0]!.id); + // Caller-owned initial/internal sends retain their existing policy; bound credential hops. + const budget = createRequestExecutionBudget({ ...CODEX_TEXT_GUARDED_BUDGET_POLICY, + maxTotalModelSends: 1, baseSendAllowance: 1, finalRecoveryAllowance: 0 }); + const authorizations: string[] = []; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url !== "https://api.commandcode.ai/alpha/generate") throw new Error(`Unexpected fixture request: ${url}`); + authorizations.push(new Headers(init?.headers).get("authorization") ?? ""); + if (authorizations.length === 1) return new Response('{"type":"finish","finishReason":"stop"}\n'); + return Response.json({ error: { message: "rate limited" } }, { status: 429 }); + }) as typeof fetch; + const cfg = { defaultProvider: "command-code", emptyCompletionRetry: true, providers: { + "command-code": { adapter: "command-code", baseUrl: "https://api.commandcode.ai", authMode: "oauth", + models: ["deepseek/deepseek-v4-flash"] }, + } } as OcxConfig; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "command-code/deepseek/deepseek-v4-flash", input: "hello", stream: false }), + }), cfg, { model: "", provider: "" }, { sendBudget: budget }); + await response.text(); + expect(authorizations).toEqual(["Bearer synthetic-command-0", "Bearer synthetic-command-0", "Bearer synthetic-command-1"]); + expect(budget.used).toBe(1); + expect(getAccountSet("command-code")!.activeAccountId).toBe(getAccountSet("command-code")!.accounts[1]!.id); + } finally { + globalThis.fetch = originalFetch; + clearGenericFailoverHealth(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(fixtureHome); + } + }, 20_000); + test("registry and OAuth surfaces stay in parity", () => { const registry = PROVIDER_REGISTRY.find(row => row.id === "command-code"); expect(registry).toMatchObject({ @@ -650,7 +703,7 @@ describe("Command Code provider", () => { expect(JSON.parse(bareBuilt.body).params.tools).toEqual(tools); }); - test("refreshes a stale official effort record only after a reasoning rejection and retries without it", async () => { + test.each([false, true])("refreshes stale effort metadata separately from inference executor (supplied=%s)", async supplied => { const requests: Array<{ url: string; body?: string }> = []; const fetch = (async (url: string | URL | Request, init?: RequestInit) => { const href = String(url); @@ -664,7 +717,14 @@ describe("Command Code provider", () => { }) as typeof globalThis.fetch; const adapter = createCommandCodeAdapter({ ...provider, fetch } as OcxProviderConfig); const request = await adapter.buildRequest({ ...parsed(), options: { reasoning: "max" } }); - const response = await adapter.fetchResponse!(request); + let suppliedCalls = 0; + const executor = (async (input, init) => { + expect(String(input).endsWith("/alpha/generate")).toBe(true); + suppliedCalls += 1; + return fetch(input, init); + }) as typeof globalThis.fetch; + const response = await adapter.fetchResponse!(request, supplied ? { executor } : undefined); + expect(suppliedCalls).toBe(supplied ? 2 : 0); expect(response.ok).toBe(true); expect(commandCodeReasoningEfforts("deepseek/deepseek-v4-flash")).toEqual(["high"]); const generated = requests.filter(request => request.url.endsWith("/alpha/generate")); diff --git a/tests/providers/mimo-free-provider.test.ts b/tests/providers/mimo-free-provider.test.ts index 00176d2589..dca9238844 100644 --- a/tests/providers/mimo-free-provider.test.ts +++ b/tests/providers/mimo-free-provider.test.ts @@ -319,7 +319,7 @@ describe("mimo-free auth retry predicate", () => { return createMimoFreeAdapter(provider); } - test("401 retries exactly once with a fresh JWT after draining the first body", async () => { + test.each([false, true])("401 retries once through the inference executor and keeps bootstrap separate (supplied=%s)", async supplied => { const fakeJwt = "h." + Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 })).toString("base64") + ".s"; const calls: string[] = []; const originalFetch = globalThis.fetch; @@ -338,10 +338,17 @@ describe("mimo-free auth retry predicate", () => { }) as unknown as typeof fetch; try { const adapter = adapterForRetry(); + let suppliedCalls = 0; + const executor = (async (input, init) => { + expect(String(input)).toBe(MIMO_CHAT_URL); + suppliedCalls += 1; + return globalThis.fetch(input, init); + }) as typeof fetch; const res = await adapter.fetchResponse!( { url: MIMO_CHAT_URL, method: "POST", headers: { "Authorization": "Bearer stale" }, body: "{}" }, - {} as never, + supplied ? { executor } : undefined, ); + expect(suppliedCalls).toBe(supplied ? 2 : 0); expect(res.status).toBe(200); // Sequence: first chat with stale token -> 401 -> bootstrap -> retry with fresh JWT. expect(calls[0]).toBe("chat:Bearer stale"); From 1e3f1e1dd60233ed8d498fd24104481af2812996 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:24:33 +0900 Subject: [PATCH 20/23] fix(adapters): admit each physical inference against shared budget --- .../docs/reference/configuration/providers.md | 7 +- scripts/test-layout/layout.json | 1 + src/adapters/command-code.ts | 18 +++-- src/adapters/google-http.ts | 51 ++++++++++---- src/adapters/google.ts | 1 + src/adapters/mimo-free.ts | 34 ++++++---- src/adapters/physical-send.ts | 49 ++++++++++++++ structure/adapters/registry.md | 2 +- structure/data-planes/inbound-compat.md | 2 +- structure/providers/chat-compat.md | 2 +- structure/providers/cursor.md | 2 +- structure/runtime.md | 2 +- structure/transports/byte-accounting.md | 2 +- structure/transports/inventory.md | 2 +- structure/transports/responses.md | 20 +++--- .../google/google-vertex-http.test.ts | 42 +++++++++++- tests/adapters/physical-send.test.ts | 67 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + tests/providers/command-code-provider.test.ts | 31 ++++++--- tests/providers/mimo-free-provider.test.ts | 26 +++++-- 20 files changed, 298 insertions(+), 64 deletions(-) create mode 100644 src/adapters/physical-send.ts create mode 100644 tests/adapters/physical-send.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index d31ce10f68..74becf2196 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -229,8 +229,11 @@ For translated Responses requests using a multi-key pool with `authMode: "key"` When `emptyCompletionRetry` is enabled, Kiro can use the request's remaining final-recovery send after its three base sends finish without visible output. That recovery shares any existing -credential-hop reservation and is charged once. Google Vertex/Antigravity retain their existing -internal retry policy; hop bookkeeping does not impose a new physical-send cap on those adapters. +credential-hop reservation and is charged once. Command Code, MiMo and Google Vertex/Antigravity +also charge each inference attempt, including internal retries, to the shared request budget. +When no retry fits, they retain the prior HTTP failure instead of sending again. JWT bootstrap and +model-catalog lookup are separate from inference accounting. Ordinary Google AI Studio continues +to use the server's existing retry helper. With `webSearchBridge` enabled, a search continuation stays bound to the API-key selection that served the first request. Changing the selected key, its reference or resolved value, authentication diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 885d2abcd4..8ece2ed53d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -167,6 +167,7 @@ } }, "explicit": { + "physical-send.test.ts": "adapters", "responses-core-modules.test.ts": "responses", "chat-responses-control-integration.test.ts": "responses", "coding-agent-tool-result-images.test.ts": "adapters", diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index 3c466829e2..c80eb94ebd 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -13,6 +13,8 @@ import { commandCodeReasoningEfforts, refreshCommandCodeReasoningEfforts } from import { identifyRoutedModel } from "./identity"; import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; import { parseDataUrl } from "./image"; +import { createAdapterPhysicalSend } from "./physical-send"; +import { SendBudgetExhaustedError } from "../lib/upstream-retry"; // Retain the short ids emitted by the first local integration. New requests use the live catalog's // provider-native IDs directly; this map is compatibility-only and is not a model fallback list. @@ -469,7 +471,7 @@ async function fetchCommandCode(request: AdapterRequest, ctx: AdapterFetchContex const timer = setTimeout(() => timeout.abort(new DOMException("Timeout elapsed", "TimeoutError")), ctx?.timeoutMs ?? 200_000); const callerSignal = ctx?.abortSignal ?? new AbortController().signal; try { - return await (ctx?.executor ?? executor)(request.url, { + return await executor(request.url, { method: request.method, headers: request.headers, body: request.body, @@ -555,8 +557,10 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA ...(reasoningEffort ? { reasoningLog: { effectiveEffort: reasoningEffort, wireField: "reasoning_effort" as const, wireValue: reasoningEffort } } : {}), }; }, + fetchResponseUsesSendBudget: true, async fetchResponse(request: AdapterRequest, ctx?: AdapterFetchContext): Promise { - const response = await fetchCommandCode(request, ctx, executor); + const send = createAdapterPhysicalSend(ctx, executor); + const response = await send({ url: request.url, dispatch: physical => fetchCommandCode(request, ctx, physical) }); if (response.ok) return response; const currentEffort = (() => { try { return (JSON.parse(request.body) as { params?: { reasoning_effort?: unknown } }).params?.reasoning_effort; } catch { return undefined; } @@ -577,8 +581,14 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA if (!refreshed || refreshed.includes(currentEffort)) return response; const retry = requestWithoutReasoningEffort(request); if (!retry) return response; - try { void response.body?.cancel(); } catch { /* already closed */ } - return fetchCommandCode(retry, ctx, executor); + try { + return await send({ url: retry.url, sendClass: "repair", recovery: "reasoning-effort-downgrade", + beforeDispatch: () => { try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } }, + dispatch: physical => fetchCommandCode(retry, ctx, physical) }); + } catch (error) { + if (error instanceof SendBudgetExhaustedError) return response; + throw error; + } }, async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator { let sawFinish = false; diff --git a/src/adapters/google-http.ts b/src/adapters/google-http.ts index f7b90de87e..de1332dae7 100644 --- a/src/adapters/google-http.ts +++ b/src/adapters/google-http.ts @@ -1,4 +1,7 @@ import type { AdapterFetchContext, AdapterRequest } from "./base"; +import { createAdapterPhysicalSend } from "./physical-send"; +import type { SendClass } from "../lib/request-execution-budget"; +import type { AttemptRecoveryKind } from "../usage/log"; import { isQuotaExhaustedBody, retryableGoogleStatus, safeGoogleHttpErrorMessage } from "./google-errors"; import { repairGoogleInvalidRequestBody } from "./google-wire-compiler"; import { normalizeUpstreamHttpErrorResponse, readDisplaySafeErrorPayloadText } from "./upstream-http-error"; @@ -8,6 +11,8 @@ import { fetchWithAttemptDeadline, retryBackoffDelayMs, sleepWithAbort, + SendBudgetExhaustedError, + isConnectionResetError, } from "../lib/upstream-retry"; const GOOGLE_RETRY_ATTEMPTS = 3; @@ -41,18 +46,30 @@ export async function fetchGoogleWithRetry( ): Promise { const repairInvalid400 = opts.repairInvalid400 ?? true; const timeoutMs = ctx.timeoutMs ?? 200_000; - const executor = ctx.executor ?? globalThis.fetch; + const send = createAdapterPhysicalSend(ctx); let lastError: unknown; let activeRequest = request; let compatibilityReplayUsed = false; + let pendingResponse: Response | undefined; + let retryDelayMs = 0; + let sendClass: SendClass = "transient"; + let recovery: AttemptRecoveryKind | undefined; for (let attempt = 0; attempt < GOOGLE_RETRY_ATTEMPTS; attempt++) { if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal); try { - const res = await fetchWithAttemptDeadline(activeRequest.url, { - method: activeRequest.method, - headers: activeRequest.headers, - body: activeRequest.body, - }, timeoutMs, ctx.abortSignal, ctx.stream, executor); + const res = await send({ url: activeRequest.url, sendClass, recovery, + beforeDispatch: async () => { + if (retryDelayMs > 0) await sleepWithAbort(retryDelayMs, ctx.abortSignal); + if (pendingResponse) cancelResponseBodyBestEffort(pendingResponse); + pendingResponse = undefined; + }, + dispatch: executor => fetchWithAttemptDeadline(activeRequest.url, { + method: activeRequest.method, headers: activeRequest.headers, body: activeRequest.body, + }, timeoutMs, ctx.abortSignal, ctx.stream, executor), + }); + retryDelayMs = 0; + sendClass = "transient"; + recovery = undefined; if (res.status === 400 && repairInvalid400 && !compatibilityReplayUsed) { let payloadText = ""; try { @@ -64,7 +81,8 @@ export async function fetchGoogleWithRetry( if (repairedBody !== undefined) { compatibilityReplayUsed = true; activeRequest = { ...activeRequest, body: repairedBody }; - cancelResponseBodyBestEffort(res); + pendingResponse = res; + sendClass = "repair"; attempt--; // The changed-request replay is separate from transient retry accounting. continue; } @@ -75,7 +93,7 @@ export async function fetchGoogleWithRetry( // A 429 may be a transient rate limit (retry) or hard quota exhaustion (do NOT retry — // it won't recover for hours and burns retries). Peek the body to tell them apart. if (res.status === 429) { - const peekTarget = ctx.returnRawErrors ? res.clone() : res; + const peekTarget = res.clone(); const peek = await readDisplaySafeErrorPayloadText(peekTarget, ctx.abortSignal); if (isQuotaExhaustedBody(peek)) { return ctx.returnRawErrors ? res : normalizeUpstreamHttpErrorResponse(res, { @@ -84,20 +102,27 @@ export async function fetchGoogleWithRetry( }); } } - cancelResponseBodyBestEffort(res); - await sleepWithAbort(retryBackoffDelayMs(attempt, { + pendingResponse = res; + recovery = res.status === 429 ? "rate-limit-429" : "transient-5xx"; + retryDelayMs = retryBackoffDelayMs(attempt, { baseDelayMs: GOOGLE_RETRY_BASE_MS, maxDelayMs: GOOGLE_RETRY_MAX_MS, headers: res.headers, - }), ctx.abortSignal); + }); } catch (err) { if (ctx.abortSignal?.aborted) throw err; + if (err instanceof SendBudgetExhaustedError) { + if (pendingResponse) return ctx.returnRawErrors ? pendingResponse : normalizeFinalGoogleError(label, pendingResponse, ctx.abortSignal); + throw err; + } lastError = err; if (attempt === GOOGLE_RETRY_ATTEMPTS - 1) throw err; - await sleepWithAbort(retryBackoffDelayMs(attempt, { + sendClass = "transient"; + recovery = isConnectionResetError(err) ? "connection-reset" : undefined; + retryDelayMs = retryBackoffDelayMs(attempt, { baseDelayMs: GOOGLE_RETRY_BASE_MS, maxDelayMs: GOOGLE_RETRY_MAX_MS, - }), ctx.abortSignal); + }); } } throw lastError ?? new Error(`${label} fetch failed`); diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 9617c1ac10..b6154995dd 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -781,6 +781,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // 429 rotation and raw error formatting. ...(provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist" ? { + fetchResponseUsesSendBudget: true, fetchResponse: (request: AdapterRequest, ctx?: AdapterFetchContext): Promise => (provider.googleMode === "cloud-code-assist" ? fetchAntigravityWithRetry : fetchVertexWithRetry)(request, ctx), formatErrorBody: (status: number, _headers: Headers, payloadText: string): string => diff --git a/src/adapters/mimo-free.ts b/src/adapters/mimo-free.ts index 0689f2ebde..eb89452272 100644 --- a/src/adapters/mimo-free.ts +++ b/src/adapters/mimo-free.ts @@ -6,6 +6,8 @@ import { recordOwnedConfigPath } from "../lib/config-ownership"; import type { OcxProviderConfig, OcxParsedRequest } from "../types"; import { createOpenAIChatAdapter } from "./openai-chat"; import type { ProviderAdapter, AdapterRequest, IncomingMeta } from "./base"; +import { createAdapterPhysicalSend } from "./physical-send"; +import { SendBudgetExhaustedError } from "../lib/upstream-retry"; const BOOTSTRAP_URL = "https://api.xiaomimimo.com/api/free-ai/bootstrap"; export const MIMO_CHAT_URL = "https://api.xiaomimimo.com/api/free-ai/openai/chat"; @@ -247,35 +249,41 @@ export function createMimoFreeAdapter(provider: OcxProviderConfig): ProviderAdap }; }, + fetchResponseUsesSendBudget: true, async fetchResponse(request: AdapterRequest, ctx): Promise { - const executor = ctx?.executor ?? fetch; - const response = await executor(request.url, { + const send = createAdapterPhysicalSend(ctx); + const response = await send({ url: request.url, dispatch: executor => executor(request.url, { method: request.method, redirect: "manual", headers: request.headers as Record, body: request.body, signal: ctx?.abortSignal, - }); + }) }); // Retry predicate: 401 (expired/invalid JWT) retries ONCE with a fresh token. // 403 is NOT retried — Xiaomi uses it for anti-abuse "Illegal access" and there is // no documented token-expiry signature that would mark a 403 as retryable. if (response.status === 401) { - // Drain the first response body before issuing the retry. - try { await response.body?.cancel(); } catch { /* already consumed */ } - resetMimoJwtCache(); - const freshJwt = await getMimoJwt(ctx?.abortSignal); - const retryHeaders = { - ...(request.headers as Record), - "Authorization": `Bearer ${freshJwt}`, - }; - return executor(request.url, { + let retryHeaders = request.headers; + try { + return await send({ url: request.url, sendClass: "auth-recovery", recovery: "oauth-401", + beforeDispatch: async () => { + resetMimoJwtCache(); + const freshJwt = await getMimoJwt(ctx?.abortSignal); + retryHeaders = { ...request.headers, "Authorization": `Bearer ${freshJwt}` }; + try { void response.body?.cancel().catch(() => {}); } catch { /* already consumed */ } + }, + dispatch: executor => executor(request.url, { method: request.method, redirect: "manual", headers: retryHeaders, body: request.body, signal: ctx?.abortSignal, - }); + }) }); + } catch (error) { + if (error instanceof SendBudgetExhaustedError) return response; + throw error; + } } return response; diff --git a/src/adapters/physical-send.ts b/src/adapters/physical-send.ts new file mode 100644 index 0000000000..81da953ca1 --- /dev/null +++ b/src/adapters/physical-send.ts @@ -0,0 +1,49 @@ +import type { AdapterFetchContext } from "./base"; +import type { SendClass } from "../lib/request-execution-budget"; +import type { AttemptRecoveryKind } from "../usage/log"; +import { abortError, SendBudgetExhaustedError } from "../lib/upstream-retry"; + +type PacedFetch = typeof globalThis.fetch & { + waitForPacing?: (signal?: AbortSignal) => Promise; + unpacedFetch?: typeof globalThis.fetch; +}; + +/** One ordinal sequence per adapter fetchResponse call, across all of its inference retries. + * Consumption starts at underlying executor invocation; its own later preflight may still fail. */ +export function createAdapterPhysicalSend(ctx: AdapterFetchContext = {}, fallback = globalThis.fetch) { + const executor = (ctx.executor ?? fallback) as PacedFetch; + let ordinal = 0; + return async (options: { + url: string; + sendClass?: SendClass; + recovery?: AttemptRecoveryKind; + /** Runs only after admission, e.g. backoff and cancellation of a superseded response. */ + beforeDispatch?: () => void | Promise; + dispatch: (executor: typeof globalThis.fetch) => Promise; + }): Promise => { + await executor.waitForPacing?.(ctx.abortSignal); + if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal); + const decision = ctx.sendBudget?.reserveDispatch({ + sendClass: options.sendClass ?? "transient", targetKey: options.url, + }); + if (decision && !decision.allowed) throw new SendBudgetExhaustedError(options.url); + const permit = decision?.allowed ? decision.permit : undefined; + let dispatched = false; + const physicalExecutor = (async (input, init) => { + if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal); + if (init?.signal?.aborted) throw abortError(init.signal); + if (dispatched || (permit && !permit.use())) throw new SendBudgetExhaustedError(options.url); + dispatched = true; + ordinal += 1; + ctx.onPhysicalSend?.({ ordinal, ...(options.recovery ? { recovery: options.recovery } : {}) }); + return (executor.unpacedFetch ?? executor)(input, init); + }) as typeof globalThis.fetch; + try { + await options.beforeDispatch?.(); + if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal); + return await options.dispatch(physicalExecutor); + } finally { + permit?.release(); + } + }; +} diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 869fa1baad..2cadbde851 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -1,6 +1,6 @@ # Adapter Registry Authority -Caller-owned Command Code and MiMo inference replays honor the supplied executor under the [hop confirmation contract](../transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. +Command Code, MiMo and Google Vertex/Antigravity admit each inference attempt against the shared budget under the [hop confirmation contract](../transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. Request-local adapter bindings are separate from registry authority in the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index fa9b8dd6ca..7febb5a331 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -1,6 +1,6 @@ # Inbound Compatibility Surfaces -Caller-owned Command Code and MiMo inference replays honor the supplied executor under the [hop confirmation contract](../transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. +Command Code, MiMo and Google Vertex/Antigravity admit each inference attempt against the shared budget under the [hop confirmation contract](../transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. Compatibility callers retain the public Responses ingress described by the [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 942b378c8b..6b38dfd9ef 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -1,6 +1,6 @@ # Chat Provider Compatibility -Caller-owned Command Code and MiMo inference replays honor the supplied executor under the [hop confirmation contract](../transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. +Command Code, MiMo and Google Vertex/Antigravity admit each inference attempt against the shared budget under the [hop confirmation contract](../transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. HTTP adapters declare their own send-budget consumption under the [credential-hop contract](../transports/responses.md#credential-hop-reservations); ordinary Chat retry helpers retain external-send settlement. diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 854e0be77e..e93d210d66 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -1,6 +1,6 @@ # Cursor Provider -Caller-owned Command Code and MiMo inference replays honor the supplied executor under the [hop confirmation contract](../transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. +Command Code, MiMo and Google Vertex/Antigravity admit each inference attempt against the shared budget under the [hop confirmation contract](../transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. HTTP adapters declare their own send-budget consumption under the [credential-hop contract](../transports/responses.md#credential-hop-reservations); Cursor's run-turn transport retains its existing ownership. diff --git a/structure/runtime.md b/structure/runtime.md index 9808e2b9d6..3163d92e8a 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,6 +1,6 @@ # Runtime -Caller-owned Command Code and MiMo inference replays honor the supplied executor under the [hop confirmation contract](transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. +Command Code, MiMo and Google Vertex/Antigravity admit each inference attempt against the shared budget under the [hop confirmation contract](transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. Responses admission and finalization are composed through the [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index f8bde4a962..292dc84d24 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -1,6 +1,6 @@ # Byte Accounting -Caller-owned Command Code and MiMo inference replays honor the supplied executor under the [hop confirmation contract](responses.md#credential-hop-reservations); credential and catalog discovery remain separate. +Command Code, MiMo and Google Vertex/Antigravity admit each inference attempt against the shared budget under the [hop confirmation contract](responses.md#credential-hop-reservations); credential and catalog discovery remain separate. Responses body-reader limits and lifetime handling follow the [core module ownership](responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index b22faec747..70759075f3 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -1,6 +1,6 @@ # Transport Inventory -Caller-owned Command Code and MiMo inference replays honor the supplied executor under the [hop confirmation contract](responses.md#credential-hop-reservations); credential and catalog discovery remain separate. +Command Code, MiMo and Google Vertex/Antigravity admit each inference attempt against the shared budget under the [hop confirmation contract](responses.md#credential-hop-reservations); credential and catalog discovery remain separate. The existing Responses transport is divided by responsibility in the [core module ownership](responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 899cb46420..cb1ccd3dbf 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -688,7 +688,8 @@ OpenAI Chat, no-reserve refusal, and a final continuation 429 without an unfunde Caller-counted adapter dispatch confirms its hop after request shaping and pacing. Retry-helper dispatch reserves with `countedExternally: true` and passes the booking through `pendingHopPermit`. -An adapter declaring `fetchResponseUsesSendBudget`, currently Kiro, also receives an externally +An adapter declaring `fetchResponseUsesSendBudget` (Kiro, Command Code, MiMo and Google +Vertex/Antigravity) also receives an externally counted booking, but adopts it through a derived budget at its own physical-send boundary. An initial Kiro empty-completion repair reserves the same final send when no credential hop is pending. Its derived scope shares the request ceiling and adds no recovery reserve. Native Responses @@ -697,16 +698,19 @@ only on their first dispatch. Other `fetchResponse` implementations retain caller-owned confirmation; having that method alone does not establish that the adapter consumes budget permits. A queued continuation carries an unused booking into its next dispatch and releases it if shaping, pacing or admission fails before a send. -Caller-owned continuation hops confirm on the first executor invocation, so an adapter that -throws before dispatch also refunds its booking. Google Vertex/Antigravity internal retries do -not yet consume this physical-send budget; preserving their hop bookkeeping does not impose a -new total cap on those adapters. -Command Code and MiMo honor the supplied executor for every inference attempt, including -reasoning-effort or fresh-JWT replays. Their model-catalog and credential discovery stay separate, -so those auxiliary requests cannot confirm an inference hop. The real Command Code OAuth +Caller-owned fallback continuation hops confirm on the first executor invocation. Budget-aware +Command Code, MiMo and Google use `src/adapters/physical-send.ts` to admit every inference +attempt, including effort removal, JWT retry and Google reset/5xx/400-repair paths. An unused +reservation refunds on helper pacing, abort, backoff or adapter setup failure. Consumption starts +at the underlying executor invocation: its own subsequent preflight can still reject, conservatively +consuming that attempt. Budget refusal preserves an available prior HTTP response and performs +no retry backoff. Model-catalog and credential discovery stay separate from inference accounting. +Ordinary Google AI Studio remains on the canonical server retry-helper path. The real Command Code OAuth empty-completion regression in `tests/providers/command-code-provider.test.ts` bounds subsequent credential rotations after a dispatched hop; MiMo executor and bootstrap separation is covered in `tests/providers/mimo-free-provider.test.ts`. +`tests/adapters/physical-send.test.ts` verifies prepaid adoption, refusal and refund; +`tests/adapters/google/google-vertex-http.test.ts` bounds Google repair and retry sends. `tests/server/server-kiro-oauth-401-replay.test.ts` counts actual Kiro requests across three stored OAuth accounts, including an earlier connection reset and the third account's success or quota diff --git a/tests/adapters/google/google-vertex-http.test.ts b/tests/adapters/google/google-vertex-http.test.ts index 7d91793226..d396348fbb 100644 --- a/tests/adapters/google/google-vertex-http.test.ts +++ b/tests/adapters/google/google-vertex-http.test.ts @@ -1,4 +1,6 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as retry from "../../../src/lib/upstream-retry"; +import { createRequestExecutionBudget } from "../../../src/lib/request-execution-budget"; import type { AdapterRequest } from "../../../src/adapters/base"; import { fetchAntigravityWithRetry, fetchDirectGeminiWithRetry, fetchVertexWithRetry } from "../../../src/adapters/google-http"; import { safeVertexHttpErrorMessage, retryableGoogleStatus } from "../../../src/adapters/google-errors"; @@ -30,6 +32,41 @@ function vertexError(code: number, status: string, message: string): string { } describe("vertex retry fetch", () => { + for (const [name, fetchResponse] of [["Vertex", fetchVertexWithRetry], ["Antigravity", fetchAntigravityWithRetry]] as const) { + test.each([400, 429, 503, "reset"] as const)(`${name} prepaid final send prevents another inference or backoff (%s)`, async status => { + const parent = createRequestExecutionBudget(); + parent.used = 3; + const hop = parent.reserveDispatch({ sendClass: "auth-recovery", targetKey: request.url, countedExternally: true }); + if (!hop.allowed) throw new Error("Expected final prepaid send"); + const scope = parent.deriveScope({ ...parent.policy, finalRecoveryAllowance: 0 }, hop.permit); + const raw = status === 400 ? vertexError(400, "INVALID_ARGUMENT", "tools.0.custom.input_schema: JSON schema is invalid") : `fixture ${status}`; + const first = status === "reset" ? Object.assign(new Error("fixture reset"), { code: "ECONNRESET" }) + : new Response(raw, { status, headers: { "Retry-After": "60" } }); + const fixture = mockFetch([first, new Response("unexpected replay")]); + const waits = spyOn(retry, "sleepWithAbort").mockImplementation(async () => {}); + const ordinals: number[] = []; + try { + const result = fetchResponse({ ...request, body: JSON.stringify({ request: { + contents: [{ role: "user", parts: [{ text: "hi" }] }], + tools: [{ functionDeclarations: [{ name: "replace_in_files", parameters: { + type: "object", properties: { occurrence_ids: { type: "array", items: { type: "string" } } }, + } }] }], + } }) }, { sendBudget: scope, returnRawErrors: true, onPhysicalSend: send => ordinals.push(send.ordinal) }); + if (status === "reset") await expect(result).rejects.toBeInstanceOf(retry.SendBudgetExhaustedError); + else { + const response = await result; + expect(response).toBe(first); + expect(await response.text()).toBe(raw); + } + hop.permit.release(); + expect(fixture.calls).toHaveLength(1); + expect(waits).not.toHaveBeenCalled(); + expect(parent.used).toBe(4); + expect(ordinals).toEqual([1]); + } finally { waits.mockRestore(); } + }); + } + test("successful response bodies survive beyond the response-header timeout", async () => { globalThis.fetch = (async () => new Response(new ReadableStream({ async start(controller) { @@ -331,10 +368,13 @@ describe("adapter fetchResponse wiring", () => { const aistudio = createGoogleAdapter({ adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "k" } as never); const antigravity = createGoogleAdapter({ adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", googleMode: "cloud-code-assist" } as never); expect(typeof vertex.fetchResponse).toBe("function"); + expect(vertex.fetchResponseUsesSendBudget).toBe(true); expect(typeof vertex.formatErrorBody).toBe("function"); expect(typeof antigravity.fetchResponse).toBe("function"); + expect(antigravity.fetchResponseUsesSendBudget).toBe(true); expect(typeof antigravity.formatErrorBody).toBe("function"); expect(aistudio.fetchResponse).toBeUndefined(); + expect(aistudio.fetchResponseUsesSendBudget).toBeUndefined(); expect(aistudio.formatErrorBody).toBeUndefined(); }); diff --git a/tests/adapters/physical-send.test.ts b/tests/adapters/physical-send.test.ts new file mode 100644 index 0000000000..fa44be4844 --- /dev/null +++ b/tests/adapters/physical-send.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import { createAdapterPhysicalSend } from "../../src/adapters/physical-send"; +import { createRequestExecutionBudget } from "../../src/lib/request-execution-budget"; +import { SendBudgetExhaustedError } from "../../src/lib/upstream-retry"; + +const url = "https://adapter-fixture.invalid/inference"; +function prepaid() { + const parent = createRequestExecutionBudget(); + parent.used = 3; + const hop = parent.reserveDispatch({ sendClass: "auth-recovery", targetKey: url, countedExternally: true }); + if (!hop.allowed) throw new Error("Expected prepaid final send"); + const scope = parent.deriveScope({ ...parent.policy, finalRecoveryAllowance: 0 }, hop.permit); + return { parent, scope, hop: hop.permit }; +} + +describe("adapter physical inference admission", () => { + test("a prepaid scope admits exactly one physical send and rejects replay before backoff", async () => { + const { parent, scope, hop } = prepaid(); + let sends = 0, waits = 0; + const ordinals: number[] = []; + const send = createAdapterPhysicalSend({ sendBudget: scope, onPhysicalSend: event => ordinals.push(event.ordinal) }, + (async () => { sends += 1; return new Response("ok"); }) as typeof fetch); + await send({ url, dispatch: executor => executor(url) }); + await expect(send({ url, sendClass: "repair", beforeDispatch: () => { waits += 1; }, + dispatch: executor => executor(url) })).rejects.toBeInstanceOf(SendBudgetExhaustedError); + hop.release(); + expect(sends).toBe(1); + expect(waits).toBe(0); + expect(ordinals).toEqual([1]); + expect(parent.used).toBe(4); + }); + + test.each(["pacing", "backoff", "abort", "adapter"] as const)("unused prepaid send refunds after %s refusal", async phase => { + const { parent, scope, hop } = prepaid(); + let sends = 0; + const controller = new AbortController(); + const failure = new Error(`fixture ${phase} refusal`); + const executor = Object.assign(async () => { sends += 1; return new Response("unexpected"); }, { + waitForPacing: async () => { if (phase === "pacing") throw failure; }, + }) as typeof fetch; + const send = createAdapterPhysicalSend({ sendBudget: scope, abortSignal: controller.signal }, executor); + await expect(send({ url, beforeDispatch: () => { + if (phase === "backoff") throw failure; + if (phase === "abort") controller.abort(failure); + }, dispatch: physical => { + if (phase === "adapter") throw failure; + return physical(url); + } })).rejects.toBe(failure); + hop.release(); + expect(parent.used).toBe(3); + expect(parent.reserveSpent).toBe(false); + expect(sends).toBe(0); + }); + + test("an exhausted initial send performs no inference or retry preparation", async () => { + const budget = createRequestExecutionBudget(); + budget.used = 4; + let prepared = false, sends = 0; + const send = createAdapterPhysicalSend({ sendBudget: budget }, + (async () => { sends += 1; return new Response("unexpected"); }) as typeof fetch); + await expect(send({ url, beforeDispatch: () => { prepared = true; }, + dispatch: physical => physical(url) })).rejects.toBeInstanceOf(SendBudgetExhaustedError); + expect(prepared).toBe(false); + expect(sends).toBe(0); + expect(budget.used).toBe(4); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d2eb5d244b..b3bb92c4fd 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,5 @@ { + "physical-send.test.ts": "adapters", "responses-core-modules.test.ts": "responses", "chat-responses-control-integration.test.ts": "responses", "coding-agent-tool-result-images.test.ts": "adapters", diff --git a/tests/providers/command-code-provider.test.ts b/tests/providers/command-code-provider.test.ts index 9052f86479..b41f75b54b 100644 --- a/tests/providers/command-code-provider.test.ts +++ b/tests/providers/command-code-provider.test.ts @@ -48,7 +48,7 @@ async function builtRequest(...args: Parameters resetCommandCodeReasoningEffortsForTest()); describe("Command Code provider", () => { - test("empty-completion OAuth continuation keeps a dispatched caller-owned hop charged", async () => { + test("empty-completion OAuth continuation counts initial sends and keeps its prepaid hop charged", async () => { const previousHome = process.env.OPENCODEX_HOME; const fixtureHome = mkdtempSync(join(tmpdir(), "ocx-command-hop-")); process.env.OPENCODEX_HOME = fixtureHome; @@ -60,9 +60,9 @@ describe("Command Code provider", () => { expires: Date.now() + 3_600_000, accountId: `fixture-${index}`, source: "oauth", }, { addAccount: true }); await setActiveAccount("command-code", getAccountSet("command-code")!.accounts[0]!.id); - // Caller-owned initial/internal sends retain their existing policy; bound credential hops. + // Every physical inference send, including the initial and continuation, shares this cap. const budget = createRequestExecutionBudget({ ...CODEX_TEXT_GUARDED_BUDGET_POLICY, - maxTotalModelSends: 1, baseSendAllowance: 1, finalRecoveryAllowance: 0 }); + maxTotalModelSends: 3, baseSendAllowance: 3, finalRecoveryAllowance: 0 }); const authorizations: string[] = []; globalThis.fetch = (async (input, init) => { const url = input instanceof Request ? input.url : String(input); @@ -81,7 +81,7 @@ describe("Command Code provider", () => { }), cfg, { model: "", provider: "" }, { sendBudget: budget }); await response.text(); expect(authorizations).toEqual(["Bearer synthetic-command-0", "Bearer synthetic-command-0", "Bearer synthetic-command-1"]); - expect(budget.used).toBe(1); + expect(budget.used).toBe(3); expect(getAccountSet("command-code")!.activeAccountId).toBe(getAccountSet("command-code")!.accounts[1]!.id); } finally { globalThis.fetch = originalFetch; @@ -703,7 +703,8 @@ describe("Command Code provider", () => { expect(JSON.parse(bareBuilt.body).params.tools).toEqual(tools); }); - test.each([false, true])("refreshes stale effort metadata separately from inference executor (supplied=%s)", async supplied => { + test.each(["fallback", "supplied", "prepaid"] as const)("refreshes stale effort metadata separately from inference executor (%s)", async mode => { + const supplied = mode !== "fallback"; const requests: Array<{ url: string; body?: string }> = []; const fetch = (async (url: string | URL | Request, init?: RequestInit) => { const href = String(url); @@ -723,12 +724,24 @@ describe("Command Code provider", () => { suppliedCalls += 1; return fetch(input, init); }) as typeof globalThis.fetch; - const response = await adapter.fetchResponse!(request, supplied ? { executor } : undefined); - expect(suppliedCalls).toBe(supplied ? 2 : 0); - expect(response.ok).toBe(true); + const budget = createRequestExecutionBudget(); + if (mode === "prepaid") budget.used = 3; + const hop = mode === "prepaid" ? budget.reserveDispatch({ sendClass: "auth-recovery", targetKey: request.url, countedExternally: true }) : undefined; + if (hop && !hop.allowed) throw new Error("Expected final prepaid send"); + const scope = hop?.allowed ? budget.deriveScope({ ...budget.policy, finalRecoveryAllowance: 0 }, hop.permit) : budget; + const observed: number[] = []; + const response = await adapter.fetchResponse!(request, { ...(supplied ? { executor } : {}), sendBudget: scope, + onPhysicalSend: send => observed.push(send.ordinal) }); + if (hop?.allowed) hop.permit.release(); + expect(suppliedCalls).toBe(supplied ? mode === "prepaid" ? 1 : 2 : 0); + expect(response.ok).toBe(mode !== "prepaid"); + expect(budget.used).toBe(mode === "prepaid" ? 4 : 2); + expect(observed).toEqual(mode === "prepaid" ? [1] : [1, 2]); expect(commandCodeReasoningEfforts("deepseek/deepseek-v4-flash")).toEqual(["high"]); const generated = requests.filter(request => request.url.endsWith("/alpha/generate")); - expect(JSON.parse(generated[1]!.body!).params).not.toHaveProperty("reasoning_effort"); + expect(generated).toHaveLength(mode === "prepaid" ? 1 : 2); + if (mode === "prepaid") expect(await response.text()).toContain("unsupported reasoning_effort"); + else expect(JSON.parse(generated[1]!.body!).params).not.toHaveProperty("reasoning_effort"); }); // Pins the profileUrl of each id added for #2647 — nothing more. diff --git a/tests/providers/mimo-free-provider.test.ts b/tests/providers/mimo-free-provider.test.ts index dca9238844..a6e63274a0 100644 --- a/tests/providers/mimo-free-provider.test.ts +++ b/tests/providers/mimo-free-provider.test.ts @@ -16,6 +16,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { createRequestExecutionBudget } from "../../src/lib/request-execution-budget"; for (const phase of ["bootstrap", "chat", "401-replay"] as const) test.each([307, 308])(`MiMo ${phase} never follows %i`, async status => { const nativeFetch = globalThis.fetch; @@ -319,7 +320,8 @@ describe("mimo-free auth retry predicate", () => { return createMimoFreeAdapter(provider); } - test.each([false, true])("401 retries once through the inference executor and keeps bootstrap separate (supplied=%s)", async supplied => { + test.each(["fallback", "supplied", "prepaid"] as const)("401 retry preserves inference admission and separate bootstrap (%s)", async mode => { + const supplied = mode !== "fallback"; const fakeJwt = "h." + Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 })).toString("base64") + ".s"; const calls: string[] = []; const originalFetch = globalThis.fetch; @@ -344,17 +346,27 @@ describe("mimo-free auth retry predicate", () => { suppliedCalls += 1; return globalThis.fetch(input, init); }) as typeof fetch; + const budget = createRequestExecutionBudget(); + if (mode === "prepaid") budget.used = 3; + const hop = mode === "prepaid" ? budget.reserveDispatch({ sendClass: "auth-recovery", targetKey: MIMO_CHAT_URL, countedExternally: true }) : undefined; + if (hop && !hop.allowed) throw new Error("Expected final prepaid send"); + const scope = hop?.allowed ? budget.deriveScope({ ...budget.policy, finalRecoveryAllowance: 0 }, hop.permit) : budget; + const observed: number[] = []; const res = await adapter.fetchResponse!( { url: MIMO_CHAT_URL, method: "POST", headers: { "Authorization": "Bearer stale" }, body: "{}" }, - supplied ? { executor } : undefined, + { ...(supplied ? { executor } : {}), sendBudget: scope, onPhysicalSend: send => observed.push(send.ordinal) }, ); - expect(suppliedCalls).toBe(supplied ? 2 : 0); - expect(res.status).toBe(200); + if (hop?.allowed) hop.permit.release(); + expect(suppliedCalls).toBe(supplied ? mode === "prepaid" ? 1 : 2 : 0); + expect(res.status).toBe(mode === "prepaid" ? 401 : 200); + expect(budget.used).toBe(mode === "prepaid" ? 4 : 2); + expect(observed).toEqual(mode === "prepaid" ? [1] : [1, 2]); // Sequence: first chat with stale token -> 401 -> bootstrap -> retry with fresh JWT. expect(calls[0]).toBe("chat:Bearer stale"); - expect(calls[1]).toBe("bootstrap"); - expect(calls[2]).toBe(`chat:Bearer ${fakeJwt}`); - expect(calls.length).toBe(3); + if (mode !== "prepaid") expect(calls[1]).toBe("bootstrap"); + if (mode === "prepaid") expect(await res.text()).toBe("expired"); + else expect(calls[2]).toBe(`chat:Bearer ${fakeJwt}`); + expect(calls.length).toBe(mode === "prepaid" ? 1 : 3); } finally { globalThis.fetch = originalFetch; resetMimoJwtCache(); From 7941c47d6543385065a25099d63f44a6cdd4b336 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:44:36 +0900 Subject: [PATCH 21/23] fix(responses): fund adapter recovery and reuse pacing slots --- .../docs/reference/configuration/providers.md | 3 ++ src/adapters/physical-send.ts | 3 +- src/server/responses/adapter-continuation.ts | 1 + src/server/responses/adapter-dispatch.ts | 50 ++++++++++------- src/server/responses/request-send-budget.ts | 9 ++-- structure/adapters/registry.md | 2 + structure/catalog.md | 2 + structure/clients/claude-desktop.md | 2 + structure/data-planes/images.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/gui-and-management-api.md | 2 + structure/ops/service-and-sidecars.md | 2 + structure/providers/chat-compat.md | 2 + structure/providers/cursor.md | 2 + structure/providers/xai-grok.md | 2 + structure/runtime.md | 2 + structure/subagents.md | 2 + structure/transports/byte-accounting.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 9 ++++ structure/transports/streaming-health.md | 2 + tests/adapters/physical-send.test.ts | 7 ++- tests/server/server-key-failover-e2e.test.ts | 53 +++++++++++++++++++ 23 files changed, 138 insertions(+), 27 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 74becf2196..8261098236 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -231,6 +231,9 @@ When `emptyCompletionRetry` is enabled, Kiro can use the request's remaining fin after its three base sends finish without visible output. That recovery shares any existing credential-hop reservation and is charged once. Command Code, MiMo and Google Vertex/Antigravity also charge each inference attempt, including internal retries, to the shared request budget. +After base sends are exhausted, a key or OAuth recovery can still use the one available final +reserve. These transports reuse the server's pacing slot for their first attempt; each internal +retry takes one new slot, while a budget-refused internal retry takes none. When no retry fits, they retain the prior HTTP failure instead of sending again. JWT bootstrap and model-catalog lookup are separate from inference accounting. Ordinary Google AI Studio continues to use the server's existing retry helper. diff --git a/src/adapters/physical-send.ts b/src/adapters/physical-send.ts index 81da953ca1..9f7c2a9f93 100644 --- a/src/adapters/physical-send.ts +++ b/src/adapters/physical-send.ts @@ -21,7 +21,6 @@ export function createAdapterPhysicalSend(ctx: AdapterFetchContext = {}, fallbac beforeDispatch?: () => void | Promise; dispatch: (executor: typeof globalThis.fetch) => Promise; }): Promise => { - await executor.waitForPacing?.(ctx.abortSignal); if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal); const decision = ctx.sendBudget?.reserveDispatch({ sendClass: options.sendClass ?? "transient", targetKey: options.url, @@ -39,6 +38,8 @@ export function createAdapterPhysicalSend(ctx: AdapterFetchContext = {}, fallbac return (executor.unpacedFetch ?? executor)(input, init); }) as typeof globalThis.fetch; try { + await executor.waitForPacing?.(ctx.abortSignal); + if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal); await options.beforeDispatch?.(); if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal); return await options.dispatch(physicalExecutor); diff --git a/src/server/responses/adapter-continuation.ts b/src/server/responses/adapter-continuation.ts index 884cdf0a95..b16af1f1d2 100644 --- a/src/server/responses/adapter-continuation.ts +++ b/src/server/responses/adapter-continuation.ts @@ -212,6 +212,7 @@ export function createAdapterContinuations( onPhysicalSend: send => noteAdapterPhysicalSend(continuationEstimate, send), stream: nextParsed.stream, executor: storedPoolReplayDispatchNotifier(providerFetch(route.provider, options.codexWsRuntimeIdentity, { + pacingSlotAcquired: true, dispatchOverride: oauthDispatch(builtContinuationRequest, nextParsed), providerName: route.providerName, modelId: nextParsed.modelId, diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index e02fc66ab2..946fca114b 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -175,7 +175,7 @@ export async function prepareAdapterExchange( ? Math.floor(config.stallTimeoutSec * 1000) : 300_000; transportState.activeAdapter = transportState.adapter; - const keyPool429RetryAllowed = () => sendBudgetState.keyPool429RetryAllowed(!!transportState.activeAdapter.fetchResponse); + const keyPool429RetryAllowed = () => sendBudgetState.keyPool429RetryAllowed(transportState.activeAdapter.fetchResponseUsesSendBudget === true); // One immutable, body-safe outbound request per same-target sequence (URL, serialized body, // auth headers, generated compat headers). Same-target 429 replays reuse it verbatim; the @@ -286,7 +286,8 @@ export async function prepareAdapterExchange( onPhysicalSend: send => noteAdapterPhysicalSend(inputTokenEstimate, send), stream: parsed.stream, executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, { - dispatchOverride: oauthDispatch(builtInitialRequest), + pacingSlotAcquired: true, + dispatchOverride: oauthDispatch(builtInitialRequest), providerName: route.providerName, modelId: route.modelId, }), @@ -416,24 +417,33 @@ export async function prepareAdapterExchange( try { try { if (transportState.activeAdapter.fetchResponse) { - await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal); - // The dispatch boundary is HERE, not before the pacing wait: that wait can reject for - // an abort, a saturated queue, an expired slot or a removed provider, and none of - // those reach the wire. Confirming earlier would hold the charge for a send that the - // pacer refused. - onDispatch?.(); - return await transportState.activeAdapter.fetchResponse(retryRequest, { - abortSignal: upstream.signal, - timeoutMs: connectMs, - sendBudget: replaySendBudget, - onPhysicalSend: send => noteAdapterPhysicalSend(retryEstimate, send), - stream: parsed.stream, - executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, { - dispatchOverride: oauthDispatch(retryRequest), - providerName: route.providerName, - modelId: route.modelId, - }), - }); + // Existing derived OAuth scopes already own their booking. Otherwise an adapter + // recovery can fund its first send from the shared reserve, like a continuation. + const ownedPermit = transportState.activeAdapter.fetchResponseUsesSendBudget && adapterSendBudget + && replaySendBudget === adapterSendBudget + ? recoverySendAllowance(adapterSendBudget.policy.baseSendAllowance, recoveryClassFor(recovery), retryRequest.url).permit + : undefined; + try { + await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal); + // Confirm only after pacing: an abort, rejected queue or removed provider + // has not reached the executor and must not consume the reservation. + onDispatch?.(); + return await transportState.activeAdapter.fetchResponse(retryRequest, { + abortSignal: upstream.signal, + timeoutMs: connectMs, + sendBudget: ownedPermit && adapterSendBudget + ? adapterSendBudget.deriveScope({ ...adapterSendBudget.policy, finalRecoveryAllowance: 0 }, ownedPermit) + : replaySendBudget, + onPhysicalSend: send => noteAdapterPhysicalSend(retryEstimate, send), + stream: parsed.stream, + executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, { + pacingSlotAcquired: true, + dispatchOverride: oauthDispatch(retryRequest), + providerName: route.providerName, + modelId: route.modelId, + }), + }); + } finally { ownedPermit?.release(); } } // #2643 review: this leg used to call fetchWithHeaderTimeout directly, so an // opted-in provider's transient-5xx policy applied to the initial send and to diff --git a/src/server/responses/request-send-budget.ts b/src/server/responses/request-send-budget.ts index fa0f50af33..82c2226c31 100644 --- a/src/server/responses/request-send-budget.ts +++ b/src/server/responses/request-send-budget.ts @@ -155,15 +155,16 @@ export function createResponsesSendBudget( const keyPool429RetryAllowed = (adapterOwnsSends: boolean): boolean => { if (keyPoolFailovers >= maxKeyPoolFailovers) return false; - if (adapterOwnsSends) return !adapterSendBudget - || adapterSendBudget.remainingBaseSends(adapterSendBudget.policy.baseSendAllowance) > 0; + if (adapterOwnsSends && !adapterSendBudget) return true; const policy = transientRetryPolicyFor(route.provider); - const attempts = policy?.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS; + const attempts = adapterOwnsSends ? adapterSendBudget!.policy.baseSendAllowance + : policy?.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS; if (!Number.isInteger(attempts) || attempts <= 0) return false; if (remainingTransientSendBudget(attempts) > 0) return true; if (!isRequestExecutionBudget(sendBudget)) return false; const decision = sendBudget.reserveDispatch({ - sendClass: "auth-recovery", targetKey: `${route.providerName}|${route.modelId}|key-429`, + sendClass: "auth-recovery", targetKey: adapterOwnsSends && adapterSendBudget?.lastTargetKey + ? adapterSendBudget.lastTargetKey : `${route.providerName}|${route.modelId}|key-429`, countedExternally: true, }); if (!decision.allowed) return false; diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 2cadbde851..4e0d6b72bf 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -1,5 +1,7 @@ # Adapter Registry Authority +Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](../transports/responses.md#credential-hop-reservations). + Command Code, MiMo and Google Vertex/Antigravity admit each inference attempt against the shared budget under the [hop confirmation contract](../transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. Request-local adapter bindings are separate from registry authority in the Responses diff --git a/structure/catalog.md b/structure/catalog.md index 4ab72248ce..e8c2ee7ee0 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -1,5 +1,7 @@ # Model Catalog +Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](transports/responses.md#credential-hop-reservations). + Catalog discovery remains separate from the Responses final-route [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 7fb720e49e..362ab73662 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -1,5 +1,7 @@ # Claude Desktop Integration +Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](../transports/responses.md#credential-hop-reservations). + Desktop callers retain their existing ingress through the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 1af33721b6..c4cd73ce97 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -1,5 +1,7 @@ # Images Data Plane +Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](../transports/responses.md#credential-hop-reservations). + Vision preprocessing and image/video/search execution use the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 7febb5a331..1ca3b5c079 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -1,5 +1,7 @@ # Inbound Compatibility Surfaces +Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](../transports/responses.md#credential-hop-reservations). + Command Code, MiMo and Google Vertex/Antigravity admit each inference attempt against the shared budget under the [hop confirmation contract](../transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. Compatibility callers retain the public Responses ingress described by the diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 9f410a88d7..1d6ef12043 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,5 +1,7 @@ # GUI And Management API +Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](transports/responses.md#credential-hop-reservations). + The shared server request path follows the Responses [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index f856b557bc..ac2ee4f03c 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -1,5 +1,7 @@ # Background Service And Sidecars +Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](../transports/responses.md#credential-hop-reservations). + Service endpoints are unchanged by the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 6b38dfd9ef..581e663d0b 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -1,5 +1,7 @@ # Chat Provider Compatibility +Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](../transports/responses.md#credential-hop-reservations). + Command Code, MiMo and Google Vertex/Antigravity admit each inference attempt against the shared budget under the [hop confirmation contract](../transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. HTTP adapters declare their own send-budget consumption under the [credential-hop contract](../transports/responses.md#credential-hop-reservations); ordinary Chat retry helpers retain external-send settlement. diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index e93d210d66..8eb7e92d77 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -1,5 +1,7 @@ # Cursor Provider +Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](../transports/responses.md#credential-hop-reservations). + Command Code, MiMo and Google Vertex/Antigravity admit each inference attempt against the shared budget under the [hop confirmation contract](../transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. HTTP adapters declare their own send-budget consumption under the [credential-hop contract](../transports/responses.md#credential-hop-reservations); Cursor's run-turn transport retains its existing ownership. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 9c977d3c4f..8114483fcb 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -1,5 +1,7 @@ # xAI Grok Provider +Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](../transports/responses.md#credential-hop-reservations). + xAI uses the same shared credential and delivery policies through the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/runtime.md b/structure/runtime.md index 3163d92e8a..26dcf03b5f 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,5 +1,7 @@ # Runtime +Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](transports/responses.md#credential-hop-reservations). + Command Code, MiMo and Google Vertex/Antigravity admit each inference attempt against the shared budget under the [hop confirmation contract](transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. Responses admission and finalization are composed through the diff --git a/structure/subagents.md b/structure/subagents.md index 9fe4c45d02..024e01d3fb 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -1,5 +1,7 @@ # Subagents And Multi-Agent Surface +Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](transports/responses.md#credential-hop-reservations). + Encrypted-task and fallback request handling follow the Responses [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 292dc84d24..64bfe4ee03 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -1,5 +1,7 @@ # Byte Accounting +Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](responses.md#credential-hop-reservations). + Command Code, MiMo and Google Vertex/Antigravity admit each inference attempt against the shared budget under the [hop confirmation contract](responses.md#credential-hop-reservations); credential and catalog discovery remain separate. Responses body-reader limits and lifetime handling follow the diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 70759075f3..c50681d2fc 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -1,5 +1,7 @@ # Transport Inventory +Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](responses.md#credential-hop-reservations). + Command Code, MiMo and Google Vertex/Antigravity admit each inference attempt against the shared budget under the [hop confirmation contract](responses.md#credential-hop-reservations); credential and catalog discovery remain separate. The existing Responses transport is divided by responsibility in the diff --git a/structure/transports/responses.md b/structure/transports/responses.md index cb1ccd3dbf..46579be46a 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -711,6 +711,15 @@ credential rotations after a dispatched hop; MiMo executor and bootstrap separat in `tests/providers/mimo-free-provider.test.ts`. `tests/adapters/physical-send.test.ts` verifies prepaid adoption, refusal and refund; `tests/adapters/google/google-vertex-http.test.ts` bounds Google repair and retry sends. +Budget-aware adapter rebuilds can fund a key/OAuth recovery from the remaining final reserve; +an explicitly supplied prepaid scope is reused without another reservation. Key-429 admission +probes the same reserve and releases that probe; the actual rebuild owns the funded scope. +Initial, rebuilt and continuation `fetchResponse` calls pass their already-acquired pacing slot +to the executor. The physical-send helper consumes that slot once, then obtains one new slot +per internal retry. It reserves before pacing, so a refused internal retry takes no extra slot +or backoff; this does not remove the outer entry-point pacing wait. +`tests/server/server-key-failover-e2e.test.ts` checks Vertex key-401/key-429 and empty continuations, +unchanged selection without a reserve, and one pacing interval per actual inference. `tests/server/server-kiro-oauth-401-replay.test.ts` counts actual Kiro requests across three stored OAuth accounts, including an earlier connection reset and the third account's success or quota diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 3eee6221f9..d139841b2c 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -1,5 +1,7 @@ # Streaming Health And WebSocket +Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](responses.md#credential-hop-reservations). + Native and translated delivery now have separate owners in the [core module ownership](responses.md#core-module-ownership). This surface retains its existing behavior. diff --git a/tests/adapters/physical-send.test.ts b/tests/adapters/physical-send.test.ts index fa44be4844..c1ca9360c1 100644 --- a/tests/adapters/physical-send.test.ts +++ b/tests/adapters/physical-send.test.ts @@ -16,16 +16,19 @@ function prepaid() { describe("adapter physical inference admission", () => { test("a prepaid scope admits exactly one physical send and rejects replay before backoff", async () => { const { parent, scope, hop } = prepaid(); - let sends = 0, waits = 0; + let sends = 0, waits = 0, pacingSlots = 0; const ordinals: number[] = []; const send = createAdapterPhysicalSend({ sendBudget: scope, onPhysicalSend: event => ordinals.push(event.ordinal) }, - (async () => { sends += 1; return new Response("ok"); }) as typeof fetch); + Object.assign(async () => { sends += 1; return new Response("ok"); }, { + waitForPacing: async () => { pacingSlots += 1; }, + }) as typeof fetch); await send({ url, dispatch: executor => executor(url) }); await expect(send({ url, sendClass: "repair", beforeDispatch: () => { waits += 1; }, dispatch: executor => executor(url) })).rejects.toBeInstanceOf(SendBudgetExhaustedError); hop.release(); expect(sends).toBe(1); expect(waits).toBe(0); + expect(pacingSlots).toBe(1); expect(ordinals).toEqual([1]); expect(parent.used).toBe(4); }); diff --git a/tests/server/server-key-failover-e2e.test.ts b/tests/server/server-key-failover-e2e.test.ts index 8ac9f72877..1478cc42c5 100644 --- a/tests/server/server-key-failover-e2e.test.ts +++ b/tests/server/server-key-failover-e2e.test.ts @@ -17,6 +17,8 @@ import { routedProviderConfig } from "../../src/router"; import type { OcxProviderTransport } from "../../src/providers/xai-transport"; import { getAccountSet, saveCredential, setActiveAccount } from "../../src/oauth/store"; import { clearGenericFailoverHealth } from "../../src/oauth/generic-account-failover"; +import { handleResponses } from "../../src/server/responses"; +import { createRequestExecutionBudget, CODEX_TEXT_GUARDED_BUDGET_POLICY } from "../../src/lib/request-execution-budget"; let testDir = ""; let previousHome: string | undefined; @@ -45,6 +47,57 @@ afterEach(() => { }); describe("server 429 key failover (end-to-end)", () => { + test.each(["initial-success", "retry-success", "initial-429", "initial-401", "continuation-429", "reserve-refused"] as const)( + "Vertex budget-aware recovery uses its final send and one pacing slot per inference (%s)", async mode => { + let now = 0, timerId = 0; + const timers = new Set(); + setProviderRequestPacingRuntimeForTest({ now: () => now, setTimer(callback, delayMs) { + const id = ++timerId; timers.add(id); + queueMicrotask(() => { if (timers.delete(id)) { now += delayMs; callback(); } }); + return id; + }, clearTimer(id) { timers.delete(id as number); }, enqueueMicrotask: queueMicrotask }); + const cfg = { defaultProvider: "vertex-budget", emptyCompletionRetry: mode === "continuation-429", providers: { + "vertex-budget": { adapter: "google", googleMode: "vertex", authMode: "key", + baseUrl: "https://aiplatform.googleapis.com", models: ["gemini-3-pro"], apiKey: "vertex-key-a", + apiKeyPool: [{ id: "a", key: "vertex-key-a" }, { id: "b", key: "vertex-key-b" }], + requestPacing: { enabled: true, minIntervalMs: 100 }, + }, + } } as OcxConfig; + saveConfig(cfg); + const originalFetch = globalThis.fetch; + const keys: Array = [], times: number[] = []; + const budget = createRequestExecutionBudget({ ...CODEX_TEXT_GUARDED_BUDGET_POLICY, + finalRecoveryAllowance: mode === "reserve-refused" ? 0 : 1 }); + const expectedSends = mode === "initial-success" ? 1 : mode === "retry-success" ? 2 : mode === "reserve-refused" ? 3 : 4; + globalThis.fetch = (async (_input, init) => { + keys.push(new Headers(init?.headers).get("x-goog-api-key")); times.push(now); + if (keys.length > 4) throw new Error("fixture send ceiling exceeded"); + if (mode === "continuation-429" && keys.length === 1) return Response.json({ + candidates: [{ content: { role: "model", parts: [] }, finishReason: "STOP" }], + }); + if (mode !== "reserve-refused" && keys.length === expectedSends) return Response.json({ + candidates: [{ content: { role: "model", parts: [{ text: "vertex recovered" }] }, finishReason: "STOP" }], + }); + const status = keys.length < 3 ? 503 : mode === "initial-401" ? 401 : 429; + return Response.json({ error: { code: status, message: "temporary failure" } }, + { status, headers: { "Retry-After": "0" } }); + }) as typeof fetch; + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "vertex-budget/gemini-3-pro", input: "hello", stream: false }), + }), cfg, { model: "", provider: "" }, { sendBudget: budget }); + const body = await response.text(); + expect(keys).toEqual(Array.from({ length: expectedSends }, (_, index) => index === 3 ? "vertex-key-b" : "vertex-key-a")); + expect(response.status).toBe(mode === "reserve-refused" ? 429 : 200); + expect(body).toContain(mode === "reserve-refused" ? "temporary failure" : "vertex recovered"); + expect(budget.used).toBe(expectedSends); + expect(times).toEqual(Array.from({ length: expectedSends }, (_, index) => index * 100)); + if (mode === "reserve-refused") expect(loadConfig().providers["vertex-budget"]!.apiKey).toBe("vertex-key-a"); + } finally { globalThis.fetch = originalFetch; resetProviderRequestPacingForTest(); } + }, 20_000, + ); + test.each([false, true])("OAuth hops charge each reset-only refetch once (third succeeds: %s)", async succeeds => { const originalFetch = globalThis.fetch; const authorizations: string[] = []; From 5eb1193f1a1a7ef43fbd5298ab755feb6c71eb26 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:12:05 +0900 Subject: [PATCH 22/23] fix(responses): admit 401 recovery before credential mutation --- .../docs/reference/configuration/providers.md | 3 + src/server/responses/adapter-dispatch.ts | 171 +++++---- src/server/responses/passthrough-dispatch.ts | 355 ++++++++---------- structure/adapters/registry.md | 2 + structure/catalog.md | 2 + structure/clients/claude-desktop.md | 2 + structure/data-planes/images.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/gui-and-management-api.md | 2 + structure/ops/service-and-sidecars.md | 2 + structure/providers/xai-grok.md | 2 + structure/runtime.md | 2 + structure/subagents.md | 2 + structure/transports/byte-accounting.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 9 + structure/transports/streaming-health.md | 2 + .../lib/transient-budget-scope-source.test.ts | 11 +- .../responses-native-main-refresh.test.ts | 30 ++ ...oogle-antigravity-oauth-401-replay.test.ts | 24 ++ tests/server/server-key-failover-e2e.test.ts | 51 +++ 21 files changed, 409 insertions(+), 271 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 8261098236..c2449b4600 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -234,6 +234,9 @@ also charge each inference attempt, including internal retries, to the shared re After base sends are exhausted, a key or OAuth recovery can still use the one available final reserve. These transports reuse the server's pacing slot for their first attempt; each internal retry takes one new slot, while a budget-refused internal retry takes none. +401 recovery reserves its next inference before refreshing a token or selecting another key. +If no send remains, the original 401 is returned and no replacement credential is selected. +Native Codex main/stored-account one-shot refreshes also honor the shared send ceiling. When no retry fits, they retain the prior HTTP failure instead of sending again. JWT bootstrap and model-catalog lookup are separate from inference accounting. Ordinary Google AI Studio continues to use the server's existing retry helper. diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index 946fca114b..2b339bc7fe 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -514,6 +514,16 @@ export async function prepareAdapterExchange( return { failed: formatErrorResponse(502, "upstream_error", msg) }; } }; + const rebuildWithCredentialHop = (recovery: AttemptRecoveryKind, permit: ReturnType["permit"]) => { + const adapterSettlesHop = transportState.activeAdapter.fetchResponseUsesSendBudget === true; + sendBudgetState.pendingHopPermit = transportState.activeAdapter.fetchResponse ? undefined : permit; + const replayBudget = adapterSettlesHop && adapterSendBudget && permit + ? adapterSendBudget.deriveScope({ ...adapterSendBudget.policy, finalRecoveryAllowance: 0 }, permit) + : adapterSendBudget; + return rebuildAndRefetch(recovery, + transportState.activeAdapter.fetchResponse && !adapterSettlesHop ? () => { permit?.use(); } : undefined, + replayBudget); + }; // Keep recovery kinds in sync with the native Responses `passthroughRecovery:` loop above. recovery: for (;;) { if ( @@ -521,58 +531,65 @@ export async function prepareAdapterExchange( && isOAuth401ReplayProvider && transportState.sentOAuthSnapshot && !oauth401ReplayAttempted - && !sendBudgetExhausted() ) { - oauth401ReplayAttempted = true; - try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } - let refreshed: OAuthAccessSnapshot; + const hop = reserveCredentialHop("auth-recovery", transportState.sameTargetRequest?.url ?? builtInitialRequest.url, + !transportState.activeAdapter.fetchResponse || transportState.activeAdapter.fetchResponseUsesSendBudget === true); + if (!hop.allowed || (!hop.permit && sendBudgetExhausted())) break recovery; try { - refreshed = await refreshResolvedOAuthSelection(transportState.sentOAuthSnapshot); - } catch (err) { - cleanupUpstreamAbort(); - return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err)); - } - if (route.provider.googleMode === "cloud-code-assist" && !refreshed.projectId) { - cleanupUpstreamAbort(); - return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(new Error("Cloud Code Assist project is required"))); - } - transportState.sentOAuthSnapshot = refreshed; - transportState.replayOAuthCredentialSnapshot = { - accountId: refreshed.accountId, - generation: refreshed.generation, - }; - if (route.providerName === "kiro") { - parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) }; + oauth401ReplayAttempted = true; + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + let refreshed: OAuthAccessSnapshot; + try { + refreshed = await refreshResolvedOAuthSelection(transportState.sentOAuthSnapshot); + } catch (err) { + cleanupUpstreamAbort(); + return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err)); + } + if (route.provider.googleMode === "cloud-code-assist" && !refreshed.projectId) { + cleanupUpstreamAbort(); + return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(new Error("Cloud Code Assist project is required"))); + } + transportState.sentOAuthSnapshot = refreshed; + transportState.replayOAuthCredentialSnapshot = { + accountId: refreshed.accountId, + generation: refreshed.generation, + }; + if (route.providerName === "kiro") { + parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) }; + } + const refreshedProvider = resolveProviderTransport( + route.providerName, + { + ...route.provider, + apiKey: refreshed.accessToken, + ...(refreshed.projectId ? { project: refreshed.projectId } : {}), + }, + parsed.options.promptCacheKey, + route.providerName === "github-copilot" + ? resolveCopilotApiBaseUrl(refreshed.apiBaseUrl) + : undefined, + ); + route.provider = refreshedProvider; + invalidateSameTargetRequest(); + transportState.activeAdapter = resolveSelectionAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire), + config.cacheRetention, + ); + bindRouteReasoningReplayScope({ + parsed, + providerName: route.providerName, + provider: refreshedProvider, + adapterName: transportState.activeAdapter.name, + oauthCredentialSnapshot: transportState.replayOAuthCredentialSnapshot, + }); + const result = await rebuildWithCredentialHop("oauth-401", hop.permit); + if ("failed" in result) return result.failed; + upstreamResponse = result; + continue recovery; + } finally { + sendBudgetState.pendingHopPermit = undefined; + hop.permit?.release(); } - const refreshedProvider = resolveProviderTransport( - route.providerName, - { - ...route.provider, - apiKey: refreshed.accessToken, - ...(refreshed.projectId ? { project: refreshed.projectId } : {}), - }, - parsed.options.promptCacheKey, - route.providerName === "github-copilot" - ? resolveCopilotApiBaseUrl(refreshed.apiBaseUrl) - : undefined, - ); - route.provider = refreshedProvider; - invalidateSameTargetRequest(); - transportState.activeAdapter = resolveSelectionAdapter( - resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire), - config.cacheRetention, - ); - bindRouteReasoningReplayScope({ - parsed, - providerName: route.providerName, - provider: refreshedProvider, - adapterName: transportState.activeAdapter.name, - oauthCredentialSnapshot: transportState.replayOAuthCredentialSnapshot, - }); - const result = await rebuildAndRefetch("oauth-401"); - if ("failed" in result) return result.failed; - upstreamResponse = result; - continue recovery; } // Static API-key pools can recover a credential-scoped 401 without abandoning the @@ -580,30 +597,38 @@ export async function prepareAdapterExchange( // refresh above and never enter here — `hasKeyPoolFailover` rejects oauth/forward modes. // Runs after the OAuth replay so a refreshable token is never treated as a dead key. while (upstreamResponse.status === 401 && hasKeyPoolFailover(route.provider)) { - const rotated = rotateProviderTransportOn401(config, route.providerName, route.provider, { - now: Date.now(), - attemptedKey: route.provider.apiKey, - promptCacheKey: parsed.options.promptCacheKey, - }); - if (!rotated) break; - // Release the failed response's socket before retrying; unread bodies otherwise linger - // until runtime cleanup (one per rotated key). - try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } - route.provider = rotated; - invalidateSameTargetRequest(); - transportState.activeAdapter = resolveSelectionAdapter( - resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), - config.cacheRetention, - ); - bindRouteReasoningReplayScope({ - parsed, - providerName: route.providerName, - provider: route.provider, - adapterName: transportState.activeAdapter.name, - }); - const result = await rebuildAndRefetch("key-401"); - if ("failed" in result) return result.failed; - upstreamResponse = result; + const hop = reserveCredentialHop("auth-recovery", transportState.sameTargetRequest?.url ?? builtInitialRequest.url, + !transportState.activeAdapter.fetchResponse || transportState.activeAdapter.fetchResponseUsesSendBudget === true); + if (!hop.allowed || (!hop.permit && sendBudgetExhausted())) break; + try { + const rotated = rotateProviderTransportOn401(config, route.providerName, route.provider, { + now: Date.now(), + attemptedKey: route.provider.apiKey, + promptCacheKey: parsed.options.promptCacheKey, + }); + if (!rotated) break; + // Release the failed response's socket before retrying; unread bodies otherwise linger + // until runtime cleanup (one per rotated key). + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + route.provider = rotated; + invalidateSameTargetRequest(); + transportState.activeAdapter = resolveSelectionAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), + config.cacheRetention, + ); + bindRouteReasoningReplayScope({ + parsed, + providerName: route.providerName, + provider: route.provider, + adapterName: transportState.activeAdapter.name, + }); + const result = await rebuildWithCredentialHop("key-401", hop.permit); + if ("failed" in result) return result.failed; + upstreamResponse = result; + } finally { + sendBudgetState.pendingHopPermit = undefined; + hop.permit?.release(); + } } // Same-target 429 wait-and-retry (opt-in `retryOn429`, issue #487). Codex never retries diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index 5d95d3bffe..655a8dc6f8 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -888,98 +888,106 @@ export async function preparePassthroughExchange( && usesCodexForwardPoolAuth(admissionState.authCtx, route.provider) && codex401ReplayKind === null ) { - codex401ReplayKind = admissionState.authCtx.kind === "pool" ? "stored" : "main"; - try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed */ } - const poolAuthCtx = admissionState.authCtx.kind === "pool" ? admissionState.authCtx : undefined; - const poolReplay = poolAuthCtx - ? await refreshPoolForwardAuth({ req, config, route, authCtx: poolAuthCtx, substituteMainCredential, options, logCtx }) - : undefined; - const replay = poolReplay - ?? await refreshNativeMainForwardAuth({ req, config, route, authCtx: admissionState.authCtx, substituteMainCredential, options }); - if (!replay.ok) { - // Compact already records this; core historically returned without recording, - // so a dead grant stayed selectable and every request repeated the same doomed - // refresh. Fenced by the generation the 401 belongs to (#2887). - if (poolAuthCtx && poolReplay && !poolReplay.ok && poolReplay.quarantine) { - recordCodexUpstreamOutcome(config, poolAuthCtx.accountId, 401, { - threadId: poolAuthCtx.affinityKey, - fixedAccount: poolAuthCtx.fixedAccount, - modelId: route.modelId, - writerGeneration: poolAuthCtx.writerGeneration, - credentialGeneration: poolReplay.quarantineGeneration ?? poolAuthCtx.generation, - }); - } - upstream.abort(); - releaseCodexAuthContextProbeLease(admissionState.authCtx); - return replay.response; - } - admissionState.authCtx = replay.authCtx; - route.provider = replay.provider; - requestState.selectedForwardHeaders = withClaudeNativeSession(replay.headers, replay.provider, options.claudeNativeSessionId); - const replayAdapter = resolveSelectionAdapter( - resolveWireProtocolOverride(route.providerName, route.modelId, replay.provider, inboundWire), - config.cacheRetention, - ); - if (!("passthrough" in replayAdapter) || !replayAdapter.passthrough) { - upstream.abort(); - return formatErrorResponse(502, "upstream_error", "Native main refresh changed the provider wire unexpectedly"); - } - bindRouteReasoningReplayScope({ - parsed, - providerName: route.providerName, - provider: replay.provider, - adapterName: replayAdapter.name, - codexAuthContext: admissionState.authCtx, - forwardHeaders: requestState.selectedForwardHeaders, - }); - logCtx.providerAdapter = replayAdapter.name; - sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, replayAdapter.name, logCtx.accountLogLabel); - recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, replayAdapter.name); + const nativeHop = reserveCredentialHop("auth-recovery", request.url); + if (!nativeHop.allowed || (!nativeHop.permit && sendBudgetExhausted())) break passthroughRecovery; try { - request = await replayAdapter.buildRequest(parsed, { - headers: requestState.selectedForwardHeaders, - translatorBudget, - }); - refreshRequestToolAliases(request); - recordAdapterReasoning(logCtx, request); - recordAdapterTier(logCtx, request); - refreshUndeclaredToolGuard(request); - // The 401 replay rebuilds the body before sending, so it needs the same ceiling as - // every other build site; a replay is exactly when a grown payload reappears. - const replayBodyRefusal = refuseOversizedOutboundBody(request); - if (replayBodyRefusal) return replayBodyRefusal; - noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, "oauth-401"); - upstreamResponse = await fetchWithHeaderTimeout( - request.url, - { method: request.method, headers: request.headers, body: request.body }, - upstream.signal, - connectMs, - parsed.stream, - // The replay-dispatched signal is what bounds the rest of this logical request, so it - // has to describe a send that actually happened. fetchWithHeaderTimeout awaits pacing - // admission BEFORE calling the executor, so signalling at the call site would spend the - // budget even when a rejected pacing wait means nothing reaches the network. Wrapping - // the executor moves the signal to the last moment before the send, where a throw from - // here on is a genuine transport attempt. - storedPoolReplayDispatchNotifier( - providerFetch(route.provider, options.codexWsRuntimeIdentity, { - dispatchOverride: oauthDispatch(request), - providerName: route.providerName, + codex401ReplayKind = admissionState.authCtx.kind === "pool" ? "stored" : "main"; + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed */ } + const poolAuthCtx = admissionState.authCtx.kind === "pool" ? admissionState.authCtx : undefined; + const poolReplay = poolAuthCtx + ? await refreshPoolForwardAuth({ req, config, route, authCtx: poolAuthCtx, substituteMainCredential, options, logCtx }) + : undefined; + const replay = poolReplay + ?? await refreshNativeMainForwardAuth({ req, config, route, authCtx: admissionState.authCtx, substituteMainCredential, options }); + if (!replay.ok) { + // Compact already records this; core historically returned without recording, + // so a dead grant stayed selectable and every request repeated the same doomed + // refresh. Fenced by the generation the 401 belongs to (#2887). + if (poolAuthCtx && poolReplay && !poolReplay.ok && poolReplay.quarantine) { + recordCodexUpstreamOutcome(config, poolAuthCtx.accountId, 401, { + threadId: poolAuthCtx.affinityKey, + fixedAccount: poolAuthCtx.fixedAccount, modelId: route.modelId, - onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId), - beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider) - ? createCodexReserveDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined, - }), - codex401ReplayKind === "stored" ? options.onStoredPool401ReplayDispatched : undefined, - ), - route.provider.authMode === "forward", - ).then(adoptObservedResponse); - } catch (err) { - return transportFailureResponse(err); - } finally { - request.releaseBodyObservation?.(); - } - continue passthroughRecovery; + writerGeneration: poolAuthCtx.writerGeneration, + credentialGeneration: poolReplay.quarantineGeneration ?? poolAuthCtx.generation, + }); + } + upstream.abort(); + releaseCodexAuthContextProbeLease(admissionState.authCtx); + return replay.response; + } + admissionState.authCtx = replay.authCtx; + route.provider = replay.provider; + requestState.selectedForwardHeaders = withClaudeNativeSession(replay.headers, replay.provider, options.claudeNativeSessionId); + const replayAdapter = resolveSelectionAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, replay.provider, inboundWire), + config.cacheRetention, + ); + if (!("passthrough" in replayAdapter) || !replayAdapter.passthrough) { + upstream.abort(); + return formatErrorResponse(502, "upstream_error", "Native main refresh changed the provider wire unexpectedly"); + } + bindRouteReasoningReplayScope({ + parsed, + providerName: route.providerName, + provider: replay.provider, + adapterName: replayAdapter.name, + codexAuthContext: admissionState.authCtx, + forwardHeaders: requestState.selectedForwardHeaders, + }); + logCtx.providerAdapter = replayAdapter.name; + sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, replayAdapter.name, logCtx.accountLogLabel); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, replayAdapter.name); + try { + request = await replayAdapter.buildRequest(parsed, { + headers: requestState.selectedForwardHeaders, + translatorBudget, + }); + refreshRequestToolAliases(request); + recordAdapterReasoning(logCtx, request); + recordAdapterTier(logCtx, request); + refreshUndeclaredToolGuard(request); + // The 401 replay rebuilds the body before sending, so it needs the same ceiling as + // every other build site; a replay is exactly when a grown payload reappears. + const replayBodyRefusal = refuseOversizedOutboundBody(request); + if (replayBodyRefusal) return replayBodyRefusal; + noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, "oauth-401"); + upstreamResponse = await fetchWithHeaderTimeout( + request.url, + { method: request.method, headers: request.headers, body: request.body }, + upstream.signal, + connectMs, + parsed.stream, + // The replay-dispatched signal is what bounds the rest of this logical request, so it + // has to describe a send that actually happened. fetchWithHeaderTimeout awaits pacing + // admission BEFORE calling the executor, so signalling at the call site would spend the + // budget even when a rejected pacing wait means nothing reaches the network. Wrapping + // the executor moves the signal to the last moment before the send, where a throw from + // here on is a genuine transport attempt. + storedPoolReplayDispatchNotifier( + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(request), + providerName: route.providerName, + modelId: route.modelId, + onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId), + beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider) + ? createCodexReserveDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined, + }), + () => { + if (nativeHop.permit && !nativeHop.permit.use()) throw new SendBudgetExhaustedError(safeHostLabel(request.url)); + if (!nativeHop.permit) noteTransientSends(1); // Legacy holders did not pre-charge a permit. + if (codex401ReplayKind === "stored") options.onStoredPool401ReplayDispatched?.(); + }, + ), + route.provider.authMode === "forward", + ).then(adoptObservedResponse); + } catch (err) { + return transportFailureResponse(err); + } finally { + request.releaseBodyObservation?.(); + } + continue passthroughRecovery; + } finally { nativeHop.permit?.release(); } } if (codex401ReplayKind !== null && upstreamResponse.status === 401) break; @@ -993,113 +1001,76 @@ export async function preparePassthroughExchange( && isOAuth401ReplayProvider && transportState.sentOAuthSnapshot && !oauth401ReplayAttempted - // Refused here, before the 401 body is cancelled: once it is gone the request can only - // answer with a synthetic 502, which would report a proxy budget decision as an upstream - // fault and throw away the credential evidence the client needs. - && !sendBudgetExhausted() ) { - oauth401ReplayAttempted = true; - try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } - let refreshed: OAuthAccessSnapshot; - try { - refreshed = await refreshResolvedOAuthSelection(transportState.sentOAuthSnapshot); - } catch (err) { - upstream.abort(); - releaseCodexAuthContextProbeLease(admissionState.authCtx); - return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err)); - } - if (route.provider.googleMode === "cloud-code-assist" && !refreshed.projectId) { - upstream.abort(); - releaseCodexAuthContextProbeLease(admissionState.authCtx); - return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(new Error("Cloud Code Assist project is required"))); - } - transportState.sentOAuthSnapshot = refreshed; - transportState.replayOAuthCredentialSnapshot = { - accountId: refreshed.accountId, - generation: refreshed.generation, - }; - if (route.providerName === "kiro") { - parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) }; - } - const refreshedProvider = resolveProviderTransport( - route.providerName, - { - ...route.provider, - apiKey: refreshed.accessToken, - ...(refreshed.projectId ? { project: refreshed.projectId } : {}), - }, - parsed.options.promptCacheKey, - route.providerName === "github-copilot" - ? resolveCopilotApiBaseUrl(refreshed.apiBaseUrl) - : undefined, - ); - route.provider = refreshedProvider; - const refreshedAdapter = resolveSelectionAdapter( - resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire), - config.cacheRetention, - ); - if (!("passthrough" in refreshedAdapter) || !refreshedAdapter.passthrough) { - upstream.abort(); - return formatErrorResponse(502, "upstream_error", "OAuth refresh changed the provider wire unexpectedly"); - } - bindRouteReasoningReplayScope({ - parsed, - providerName: route.providerName, - provider: refreshedProvider, - adapterName: refreshedAdapter.name, - oauthCredentialSnapshot: transportState.replayOAuthCredentialSnapshot, - }); - logCtx.providerAdapter = refreshedAdapter.name; - sealRequestAttemptIdentity( - logCtx.activeAttempt, - logCtx.provider, - refreshedAdapter.name, - logCtx.accountLogLabel, - ); - recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, refreshedAdapter.name); + const hop = reserveCredentialHop("auth-recovery", request.url, true); + if (!hop.allowed || (!hop.permit && sendBudgetExhausted())) break passthroughRecovery; try { - request = await refreshedAdapter.buildRequest(parsed, { - headers: requestState.selectedForwardHeaders, - translatorBudget, - }); - refreshRequestToolAliases(request); - recordAdapterReasoning(logCtx, request); - recordAdapterTier(logCtx, request); - } catch (err) { - upstream.abort(); - if (options.abortSignal?.aborted) return clientCancelledResponse(); - const msg = err instanceof Error ? err.message : String(err); - return formatErrorResponse(400, "invalid_request_error", redactSecretString(msg)); - } - refreshUndeclaredToolGuard(request); - const refreshedBodyRefusal = refuseOversizedOutboundBody(request); - if (refreshedBodyRefusal) return refreshedBodyRefusal; - try { - upstreamResponse = await fetchWithTransientRetry( - recovery => { - noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery ?? "oauth-401"); - return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({ - method: request.method, - headers: request.headers, - body: request.body, - }, recovery), upstream.signal, connectMs, parsed.stream, - providerFetch(route.provider, options.codexWsRuntimeIdentity, { - dispatchOverride: oauthDispatch(request), - providerName: route.providerName, - modelId: route.modelId, - onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId), - beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider) - ? createCodexReserveDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined, - }), - route.provider.authMode === "forward") - .then(adoptObservedResponse); + oauth401ReplayAttempted = true; + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + let refreshed: OAuthAccessSnapshot; + try { + refreshed = await refreshResolvedOAuthSelection(transportState.sentOAuthSnapshot); + } catch (err) { + upstream.abort(); + releaseCodexAuthContextProbeLease(admissionState.authCtx); + return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err)); + } + if (route.provider.googleMode === "cloud-code-assist" && !refreshed.projectId) { + upstream.abort(); + releaseCodexAuthContextProbeLease(admissionState.authCtx); + return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(new Error("Cloud Code Assist project is required"))); + } + transportState.sentOAuthSnapshot = refreshed; + transportState.replayOAuthCredentialSnapshot = { + accountId: refreshed.accountId, + generation: refreshed.generation, + }; + if (route.providerName === "kiro") { + parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) }; + } + const refreshedProvider = resolveProviderTransport( + route.providerName, + { + ...route.provider, + apiKey: refreshed.accessToken, + ...(refreshed.projectId ? { project: refreshed.projectId } : {}), }, - { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(TRANSIENT_RETRY_MAX_ATTEMPTS), onSendsConsumed: noteTransientSends }, + parsed.options.promptCacheKey, + route.providerName === "github-copilot" + ? resolveCopilotApiBaseUrl(refreshed.apiBaseUrl) + : undefined, ); - } catch (err) { - return transportFailureResponse(err); + route.provider = refreshedProvider; + const refreshedAdapter = resolveSelectionAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire), + config.cacheRetention, + ); + if (!("passthrough" in refreshedAdapter) || !refreshedAdapter.passthrough) { + upstream.abort(); + return formatErrorResponse(502, "upstream_error", "OAuth refresh changed the provider wire unexpectedly"); + } + bindRouteReasoningReplayScope({ + parsed, + providerName: route.providerName, + provider: refreshedProvider, + adapterName: refreshedAdapter.name, + oauthCredentialSnapshot: transportState.replayOAuthCredentialSnapshot, + }); + logCtx.providerAdapter = refreshedAdapter.name; + sealRequestAttemptIdentity( + logCtx.activeAttempt, + logCtx.provider, + refreshedAdapter.name, + logCtx.accountLogLabel, + ); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, refreshedAdapter.name); + sendBudgetState.pendingHopPermit = hop.permit; + const result = await rebuildAndRefetch("oauth-401"); + if ("failed" in result) return result.failed; + upstreamResponse = result; } finally { - request.releaseBodyObservation?.(); + sendBudgetState.pendingHopPermit = undefined; + hop.permit?.release(); } } diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 4e0d6b72bf..d094b8befe 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -1,5 +1,7 @@ # Adapter Registry Authority +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](../transports/responses.md#credential-hop-reservations), including native main/stored-account replay. + Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](../transports/responses.md#credential-hop-reservations). Command Code, MiMo and Google Vertex/Antigravity admit each inference attempt against the shared budget under the [hop confirmation contract](../transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. diff --git a/structure/catalog.md b/structure/catalog.md index e21570d03a..d066267fa3 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -1,5 +1,7 @@ # Model Catalog +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](transports/responses.md#credential-hop-reservations), including native main/stored-account replay. + Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](transports/responses.md#credential-hop-reservations). Catalog discovery remains separate from the Responses final-route diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index a43a440ece..4280810c42 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -1,5 +1,7 @@ # Claude Desktop Integration +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](../transports/responses.md#credential-hop-reservations), including native main/stored-account replay. + Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](../transports/responses.md#credential-hop-reservations). Desktop callers retain their existing ingress through the Responses diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index c4cd73ce97..a087a72a16 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -1,5 +1,7 @@ # Images Data Plane +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](../transports/responses.md#credential-hop-reservations), including native main/stored-account replay. + Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](../transports/responses.md#credential-hop-reservations). Vision preprocessing and image/video/search execution use the Responses diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 1ca3b5c079..67c999530c 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -1,5 +1,7 @@ # Inbound Compatibility Surfaces +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](../transports/responses.md#credential-hop-reservations), including native main/stored-account replay. + Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](../transports/responses.md#credential-hop-reservations). Command Code, MiMo and Google Vertex/Antigravity admit each inference attempt against the shared budget under the [hop confirmation contract](../transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index cc42270126..4237b24b18 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,5 +1,7 @@ # GUI And Management API +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](transports/responses.md#credential-hop-reservations), including native main/stored-account replay. + Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](transports/responses.md#credential-hop-reservations). The shared server request path follows the Responses diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index ac2ee4f03c..65177315ba 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -1,5 +1,7 @@ # Background Service And Sidecars +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](../transports/responses.md#credential-hop-reservations), including native main/stored-account replay. + Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](../transports/responses.md#credential-hop-reservations). Service endpoints are unchanged by the Responses diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 8114483fcb..4a83ede178 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -1,5 +1,7 @@ # xAI Grok Provider +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](../transports/responses.md#credential-hop-reservations), including native main/stored-account replay. + Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](../transports/responses.md#credential-hop-reservations). xAI uses the same shared credential and delivery policies through the Responses diff --git a/structure/runtime.md b/structure/runtime.md index d628cf7221..8034d24efd 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,5 +1,7 @@ # Runtime +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](transports/responses.md#credential-hop-reservations), including native main/stored-account replay. + Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](transports/responses.md#credential-hop-reservations). Command Code, MiMo and Google Vertex/Antigravity admit each inference attempt against the shared budget under the [hop confirmation contract](transports/responses.md#credential-hop-reservations); credential and catalog discovery remain separate. diff --git a/structure/subagents.md b/structure/subagents.md index c575ebd435..9341501669 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -1,5 +1,7 @@ # Subagents And Multi-Agent Surface +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](transports/responses.md#credential-hop-reservations), including native main/stored-account replay. + Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](transports/responses.md#credential-hop-reservations). Encrypted-task and fallback request handling follow the Responses diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 64bfe4ee03..e7e17632c6 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -1,5 +1,7 @@ # Byte Accounting +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](responses.md#credential-hop-reservations), including native main/stored-account replay. + Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](responses.md#credential-hop-reservations). Command Code, MiMo and Google Vertex/Antigravity admit each inference attempt against the shared budget under the [hop confirmation contract](responses.md#credential-hop-reservations); credential and catalog discovery remain separate. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index c50681d2fc..9edbbfdb76 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -1,5 +1,7 @@ # Transport Inventory +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](responses.md#credential-hop-reservations), including native main/stored-account replay. + Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](responses.md#credential-hop-reservations). Command Code, MiMo and Google Vertex/Antigravity admit each inference attempt against the shared budget under the [hop confirmation contract](responses.md#credential-hop-reservations); credential and catalog discovery remain separate. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index dcc13c1645..1c8a9322ce 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -729,6 +729,15 @@ per internal retry. It reserves before pacing, so a refused internal retry takes or backoff; this does not remove the outer entry-point pacing wait. `tests/server/server-key-failover-e2e.test.ts` checks Vertex key-401/key-429 and empty continuations, unchanged selection without a reserve, and one pacing interval per actual inference. +Pre-stream 401 recovery reserves its inference send before token refresh, static-key selection +or response-body cancellation. This applies to translated OAuth, generic Responses passthrough, +and the native main/stored-pool one-shot replay. Refusal retains the original 401 and selection; +refresh/build/pacing failure or an absent alternate refunds the unused reservation. Native pool +replay retains its existing refresh, quarantine, lease and callback rules, confirms its already +charged permit at executor dispatch, and does not report that send a second time. Legacy numeric +holders instead report that direct send once because they did not receive a charged permit. +The native prepaid-child boundary is covered by `tests/responses/responses-native-main-refresh.test.ts`; +generic translated/passthrough parity is covered by `tests/server/server-google-antigravity-oauth-401-replay.test.ts`. `tests/server/server-kiro-oauth-401-replay.test.ts` counts actual Kiro requests across three stored OAuth accounts, including an earlier connection reset and the third account's success or quota diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index d139841b2c..f073f29cff 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -1,5 +1,7 @@ # Streaming Health And WebSocket +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](responses.md#credential-hop-reservations), including native main/stored-account replay. + Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](responses.md#credential-hop-reservations). Native and translated delivery now have separate owners in the diff --git a/tests/lib/transient-budget-scope-source.test.ts b/tests/lib/transient-budget-scope-source.test.ts index 43d27892ba..49055d72a6 100644 --- a/tests/lib/transient-budget-scope-source.test.ts +++ b/tests/lib/transient-budget-scope-source.test.ts @@ -160,7 +160,8 @@ describe("transient send budget stays request-scoped", () => { // the first terminal repair now draws the final reserve without an explicit recovery label. // A whole-tree substring count could pass while one site took a fresh policy allowance. const wiring = retryBudgetWiring(core); - expect(wiring.reporters).toBe(7); + // Generic OAuth401 now shares rebuildAndRefetch instead of duplicating a retry reporter. + expect(wiring.reporters).toBe(6); expect(wiring.invalid).toEqual([]); expect(core).toContain("countedExternally: true"); // The trap that would make the passthrough wiring a silent no-op: transientRetryPolicyFor @@ -260,11 +261,9 @@ describe("every dispatch path reports into the shared budget", () => { test("credential hops keep their roster cap AND reserve from the shared budget", () => { const core = readResponsesCoreSource(); - // Six hop sites: the native passthrough 429, the shared sidecar hook's generic and - // Anthropic arms, the runTurn preflight 429, the adapter recovery loop, and the - // continuation loop. The last two were the arms that actually iterate the roster, so - // leaving them out meant the claim held everywhere except where it mattered most. - expect(core.match(/reserveCredentialHop\(/g)).toHaveLength(6); + // Existing six roster-hop sites plus native main/pool401, generic passthrough OAuth401, + // translated OAuth401 and static-key401 all admit before credential mutation/body disposal. + expect(core.match(/reserveCredentialHop\(/g)).toHaveLength(10); // The per-roster caps are NOT replaced. The effective allowance is the intersection, so // removing either half is a behaviour change that has to be argued for. expect(core).toContain("genericFailovers < GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST"); diff --git a/tests/responses/responses-native-main-refresh.test.ts b/tests/responses/responses-native-main-refresh.test.ts index 3b78ff6402..3f06d0feff 100644 --- a/tests/responses/responses-native-main-refresh.test.ts +++ b/tests/responses/responses-native-main-refresh.test.ts @@ -13,6 +13,7 @@ import { handleResponses, handleResponsesCompact } from "../../src/server/respon import type { RequestLogContext } from "../../src/server/request-log"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { createRequestExecutionBudget } from "../../src/lib/request-execution-budget"; const originalFetch = globalThis.fetch; let home = ""; @@ -109,6 +110,35 @@ function install401ThenRefreshHarness(): { sends: string[]; refreshes: string[] } describe("native main 401 refresh and replay", () => { + for (const account of ["main", "stored"] as const) test.each(["fresh", "prepaid"] as const)( + `native ${account} 401 replay requires a separately admitted send (%s)`, async mode => { + const cfg = config({ secondAccount: account === "stored" }); + if (account === "stored") { + cfg.activeCodexAccountId = OTHER_ACCOUNT_ID; + saveCodexAccountCredential(OTHER_ACCOUNT_ID, { accessToken: "rejected-access", refreshToken: "other-refresh", + expiresAt: Date.now() + 3_600_000, chatgptAccountId: "account-other" }); + } + const harness = install401ThenRefreshHarness(); + const budget = createRequestExecutionBudget(); + if (mode === "prepaid") budget.used = 3; + const hop = mode === "prepaid" ? budget.reserveDispatch({ sendClass: "combo-failover", targetKey: "native", countedExternally: true }) : undefined; + if (hop && !hop.allowed) throw new Error("Expected prepaid initial send"); + const scope = hop?.allowed ? budget.deriveScope({ ...budget.policy, finalRecoveryAllowance: 0 }, hop.permit) : budget; + let storedReplays = 0; + const response = await handleResponses(request("/v1/responses"), cfg, { model: "", provider: "" }, { + sendBudget: scope, onStoredPool401ReplayDispatched: () => { storedReplays += 1; }, + }); + const body = await response.text(); + if (hop?.allowed) hop.permit.release(); + expect(response.status).toBe(mode === "prepaid" ? 401 : 200); + expect(harness.sends).toEqual(mode === "prepaid" ? ["Bearer rejected-access"] : ["Bearer rejected-access", "Bearer refreshed-access"]); + expect(harness.refreshes).toEqual(mode === "prepaid" ? [] : [account === "stored" ? "other-refresh" : "refresh-grant"]); + expect(budget.used).toBe(mode === "prepaid" ? 4 : 2); + expect(storedReplays).toBe(account === "stored" && mode === "fresh" ? 1 : 0); + if (mode === "prepaid") expect(body).toContain("expired bearer"); + }, 20_000, + ); + test("refreshes a refresh-only native main credential before upstream I/O", async () => { writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { refresh_token: "refresh-grant", account_id: "account-main" }, diff --git a/tests/server/server-google-antigravity-oauth-401-replay.test.ts b/tests/server/server-google-antigravity-oauth-401-replay.test.ts index b1f55c238c..3ecca2131a 100644 --- a/tests/server/server-google-antigravity-oauth-401-replay.test.ts +++ b/tests/server/server-google-antigravity-oauth-401-replay.test.ts @@ -9,6 +9,8 @@ import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { handleResponses } from "../../src/server/responses"; +import { createRequestExecutionBudget, CODEX_TEXT_GUARDED_BUDGET_POLICY } from "../../src/lib/request-execution-budget"; const GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; const PROD_API_BASE = "https://cloudcode-pa.googleapis.com"; @@ -274,6 +276,28 @@ function installOAuthFetch( } describe("Google Antigravity OAuth upstream 401 replay", () => { + for (const native of [false, true]) test.each(["allowed", "denied", "refresh-failed"] as const)( + `OAuth401 after base exhaustion reserves before refresh (native=${native}, %s)`, async mode => { + await seedOAuth(); + const cfg = native ? antigravityPassthroughConfig() : antigravityConfig(); + const observed = installOAuthFetch([503, 503, 401, 200], mode === "refresh-failed" ? { tokenErrorDescription: "fixture invalid grant" } : {}); + const budget = createRequestExecutionBudget({ ...CODEX_TEXT_GUARDED_BUDGET_POLICY, + finalRecoveryAllowance: mode === "denied" ? 0 : 1 }); + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "google-antigravity/gemini-3.8-flash", input: "hello", stream: false }), + }), cfg, { model: "", provider: "" }, { sendBudget: budget }); + const body = await response.text(); + const succeeds = mode === "allowed"; + expect({ status: response.status, refreshes: observed.counts.refresh, sends: observed.chatAuth.length, used: budget.used }) + .toEqual({ status: succeeds ? 200 : 401, refreshes: mode === "denied" ? 0 : 1, sends: succeeds ? 4 : 3, used: succeeds ? 4 : 3 }); + expect(budget.reserveSpent).toBe(succeeds); + expect(body).not.toContain("budget exhausted"); + if (succeeds) expect(observed.chatAuth[3]).toBe("Bearer fresh-access"); + else expect(getAccountSet("google-antigravity")!.accounts[0]!.credential.access).toBe("rejected-access"); + }, 20_000, + ); + test.each([200, 401])("native passthrough replays once and returns the second HTTP %i", async secondStatus => { await seedOAuth(); saveConfig(antigravityPassthroughConfig()); diff --git a/tests/server/server-key-failover-e2e.test.ts b/tests/server/server-key-failover-e2e.test.ts index 1478cc42c5..eb3a5a05ef 100644 --- a/tests/server/server-key-failover-e2e.test.ts +++ b/tests/server/server-key-failover-e2e.test.ts @@ -47,6 +47,57 @@ afterEach(() => { }); describe("server 429 key failover (end-to-end)", () => { + test("static-key 401 refunds a reserved hop when no alternate credential remains", async () => { + const cfg = { defaultProvider: "key401-no-alternate", providers: { + "key401-no-alternate": { adapter: "openai-chat", baseUrl: "https://key401-no-alternate.invalid/v1", authMode: "key", + apiKey: "fixture-key-a", models: ["fixture"], apiKeyPool: [{ id: "a", key: "fixture-key-a" }, { id: "b", key: "fixture-key-b" }], + }, + } } as OcxConfig; + saveConfig(cfg); + const budget = createRequestExecutionBudget(); + const originalFetch = globalThis.fetch; + let sends = 0; + globalThis.fetch = (async () => { sends += 1; return Response.json({ error: { message: "no-alternate-401" } }, { status: 401 }); }) as typeof fetch; + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "key401-no-alternate/fixture", input: "hello", stream: false }), + }), cfg, { model: "", provider: "" }, { sendBudget: budget }); + expect({ status: response.status, selected: loadConfig().providers["key401-no-alternate"]!.apiKey, sends, used: budget.used }) + .toEqual({ status: 401, selected: "fixture-key-b", sends: 2, used: 2 }); + expect(await response.text()).toContain("no-alternate-401"); + } finally { globalThis.fetch = originalFetch; } + }); + + test.each([0, 1])("static-key 401 admits replay before selecting another credential (reserve=%i)", async reserve => { + const cfg = { defaultProvider: "key401-reserve", providers: { + "key401-reserve": { adapter: "openai-chat", baseUrl: "https://key401-reserve.invalid/v1", authMode: "key", + apiKey: "fixture-key-a", models: ["fixture"], apiKeyPool: [ + { id: "a", key: "fixture-key-a" }, { id: "b", key: "fixture-key-b" }, { id: "c", key: "fixture-key-c" }, + ], + }, + } } as OcxConfig; + saveConfig(cfg); + const budget = createRequestExecutionBudget({ ...CODEX_TEXT_GUARDED_BUDGET_POLICY, finalRecoveryAllowance: reserve }); + const originalFetch = globalThis.fetch; + let sends = 0; + globalThis.fetch = (async () => { + sends += 1; + if (sends <= 2) throw Object.assign(new Error("fixture ECONNRESET"), { code: "ECONNRESET" }); + return Response.json({ error: { message: "static401-evidence" } }, { status: 401 }); + }) as typeof fetch; + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "key401-reserve/fixture", input: "hello", stream: false }), + }), cfg, { model: "", provider: "" }, { sendBudget: budget }); + const body = await response.text(); + expect({ status: response.status, selected: loadConfig().providers["key401-reserve"]!.apiKey, sends, used: budget.used }) + .toEqual({ status: 401, selected: reserve ? "fixture-key-b" : "fixture-key-a", sends: 3 + reserve, used: 3 + reserve }); + expect(body).toContain("static401-evidence"); + } finally { globalThis.fetch = originalFetch; } + }); + test.each(["initial-success", "retry-success", "initial-429", "initial-401", "continuation-429", "reserve-refused"] as const)( "Vertex budget-aware recovery uses its final send and one pacing slot per inference (%s)", async mode => { let now = 0, timerId = 0; From 83c4b563629023ee723279ac6dc1bae1b3a7cb13 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:41:42 +0900 Subject: [PATCH 23/23] fix(responses): refund combo bookings on local refusal --- .../docs/reference/configuration/providers.md | 5 + src/server/responses/core-combo.ts | 578 +++++++++--------- structure/adapters/registry.md | 2 +- structure/catalog.md | 2 +- structure/clients/claude-desktop.md | 2 +- structure/data-planes/images.md | 2 +- structure/data-planes/inbound-compat.md | 2 +- structure/gui-and-management-api.md | 2 +- structure/ops/service-and-sidecars.md | 2 +- structure/providers/xai-grok.md | 2 +- structure/runtime.md | 2 +- structure/subagents.md | 2 +- structure/transports/byte-accounting.md | 2 +- structure/transports/inventory.md | 2 +- structure/transports/responses.md | 11 + structure/transports/streaming-health.md | 2 +- .../responses-send-budget-counts.test.ts | 75 ++- .../server/server-combo-failover-e2e.test.ts | 12 +- 18 files changed, 415 insertions(+), 292 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index c2449b4600..428c5a3e9b 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -241,6 +241,11 @@ When no retry fits, they retain the prior HTTP failure instead of sending again. model-catalog lookup are separate from inference accounting. Ordinary Google AI Studio continues to use the server's existing retry helper. +A combo child refused locally before any dispatch is recorded returns its unused send reservation, +so later targets keep their remaining allowance. Successful or already-dispatched children retain +their charge; cancellation or an unknown failure after child execution starts remains conservative. +This does not promise a refund for every failure that happens before network bytes are sent. + With `webSearchBridge` enabled, a search continuation stays bound to the API-key selection that served the first request. Changing the selected key, its reference or resolved value, authentication mode, or base URL during search or provider pacing ends the turn with a bridge error before another diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 32a4f3e94a..84f687a117 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -301,17 +301,14 @@ export async function executeComboResponses( while (pick) { if (options.abortSignal?.aborted) return clientCancelledResponse(); const firstComboTarget = comboTargetsDispatched === 0; - // The first target seeds the ledger's target identity and charges nothing; every later one - // is a real transition, refused once the declared hops, the alternate-target ledger or the - // request total are spent. `countedExternally` is required: the child charges its own - // physical sends, and charging here as well would halve the cap without saying so. + // Reserve the first send without confirming dispatch. Children normally settle the booking + // through their physical-send owner; a local refusal must be able to return an unused one. const hopDecision = comboSendScope?.reserveDispatch({ sendClass: firstComboTarget ? "initial" : "combo-failover", targetKey: `${pick.target.provider}/${pick.target.model}`, countedExternally: true, }); - if (hopDecision && hopDecision.allowed) hopDecision.permit.use(); - else if (hopDecision && !firstComboTarget) { + if (hopDecision && !hopDecision.allowed && !firstComboTarget) { // Out of budget is not this target's failure. The established exhaustion contract is to // return the last real upstream answer with its status, headers and any quota body // intact rather than to mint a synthetic error, and a later target only exists because @@ -319,315 +316,344 @@ export async function executeComboResponses( if (lastFailedChildLog) adoptFailedChildLog(lastFailedChildLog); break; } - const targetSendBudget = comboSendScope - ? comboTargetSendBudget(comboSendScope, combo.targets.length - 1 - comboTargetsDispatched, hopDecision?.allowed ? hopDecision.permit : undefined) - : options.sendBudget; - comboTargetsDispatched += 1; - const childLog: RequestLogContext = { - model: pick.target.model, - provider: pick.target.provider, - ...(logCtx.conversationId ? { conversationId: logCtx.conversationId } : {}), - ...(logCtx.surface ? { surface: logCtx.surface } : {}), - }; - const targetRoute = routeConcreteModel(config, `${pick.target.provider}/${pick.target.model}`); - const childBody = concreteComboRequestBody( - body, - pick.target, - comboDefaultEffort(config, comboId), - supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }), - combo.reasoningEffortMode, - combo.defaultEffortMode, - ); - const childHeaders = buildComboChildHeaders(req.headers); - const childRequest = new Request(req.url, { - method: req.method, - headers: childHeaders, - body: JSON.stringify(childBody), - }); - linkRequestSessionLane(req, childRequest); - let resolvedAuth: CodexAuthContext | undefined; - let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined; - const started = Date.now(); - const attempt = beginRequestAttempt( - (logCtx.attempts?.length ?? 0) + 1, - pick.target.provider, - pick.target.model, - config.providers[pick.target.provider]!.adapter, - ); - childLog.activeAttempt = attempt; - if (originalRequestedEffort !== undefined) { - childLog.requestedEffort = originalRequestedEffort; - recordAttemptRequestedEffort(childLog); - } - let attemptRetained = false; - const retainCancelledAttempt = (): void => { - if (attemptRetained) return; - sealRequestAttemptIdentity( - attempt, - childLog.provider, - childLog.providerAdapter ?? attempt.adapter, - childLog.accountLogLabel, - ); - finishRequestAttempt(attempt, 499, Date.now() - started, childLog.usage); - (logCtx.attempts ??= []).push(attempt); - attemptRetained = true; - }; - const completedTarget = { provider: pick.target.provider, model: pick.target.model }; - const writerGeneration = pick.writerGeneration; - let consumedChildFailure: ConsumedComboFailure | undefined; - const callbackGate = createChildPassthroughCallbackGate({ - ...options, - onResponseComplete: model => { - // The live config can change while the child is streaming. Never retain credentials. - const currentCombo = getCombo(config, comboId); - const provider = config.providers[completedTarget.provider]; - if (Object.hasOwn(config.providers, completedTarget.provider) - && provider && provider.disabled !== true - && currentCombo?.targets.some(target => targetKey(target) === targetKey(completedTarget))) { - rememberComboForLane(sessionLaneIdFromRequest(req.headers), comboId, completedTarget, model, writerGeneration); - } - options.onResponseComplete?.(model); - }, - onNativePassthroughTerminal: status => { - // A committed stream can acquire terminal metadata after preflight copied - // the child log. Publish it before the outer logger finalizes, but only - // through the gate: discarded attempts must never affect the parent. - // Undefined child fields must preserve metadata already inspected by WS. - if (childLog.terminalHttpStatus !== undefined) logCtx.terminalHttpStatus = childLog.terminalHttpStatus; - if (childLog.terminalIncompleteReason !== undefined) logCtx.terminalIncompleteReason = childLog.terminalIncompleteReason; - if (childLog.terminalErrorCode !== undefined) logCtx.terminalErrorCode = childLog.terminalErrorCode; - if (childLog.upstreamError !== undefined) logCtx.upstreamError = childLog.upstreamError; - options.onNativePassthroughTerminal?.(status); - }, - }); - let response: Response; + let observedAttempt: ReturnType | undefined; + let observedChildLog: RequestLogContext | undefined; + let childEntered = false; + let returnedStatus: number | undefined; try { - const currentTargetProvider = pick.target.provider; - const deferCodexResetDerivedCooldown = combo.strategy === "failover" - && combo.targets.slice(pick.targetIndex + 1).some(target => - target.provider === currentTargetProvider - && payloadEligible(target) - && !isComboTargetInCooldown(comboId, target), + const targetSendBudget = comboSendScope + ? comboTargetSendBudget(comboSendScope, combo.targets.length - 1 - comboTargetsDispatched, hopDecision?.allowed ? hopDecision.permit : undefined) + : options.sendBudget; + comboTargetsDispatched += 1; + const childLog: RequestLogContext = { + model: pick.target.model, + provider: pick.target.provider, + ...(logCtx.conversationId ? { conversationId: logCtx.conversationId } : {}), + ...(logCtx.surface ? { surface: logCtx.surface } : {}), + }; + observedChildLog = childLog; + const targetRoute = routeConcreteModel(config, `${pick.target.provider}/${pick.target.model}`); + const childBody = concreteComboRequestBody( + body, + pick.target, + comboDefaultEffort(config, comboId), + supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }), + combo.reasoningEffortMode, + combo.defaultEffortMode, + ); + const childHeaders = buildComboChildHeaders(req.headers); + const childRequest = new Request(req.url, { + method: req.method, + headers: childHeaders, + body: JSON.stringify(childBody), + }); + linkRequestSessionLane(req, childRequest); + let resolvedAuth: CodexAuthContext | undefined; + let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined; + const started = Date.now(); + const attempt = beginRequestAttempt( + (logCtx.attempts?.length ?? 0) + 1, + pick.target.provider, + pick.target.model, + config.providers[pick.target.provider]!.adapter, + ); + observedAttempt = attempt; + childLog.activeAttempt = attempt; + if (originalRequestedEffort !== undefined) { + childLog.requestedEffort = originalRequestedEffort; + recordAttemptRequestedEffort(childLog); + } + let attemptRetained = false; + const retainCancelledAttempt = (): void => { + if (attemptRetained) return; + sealRequestAttemptIdentity( + attempt, + childLog.provider, + childLog.providerAdapter ?? attempt.adapter, + childLog.accountLogLabel, ); - response = await requestDispatchers.handleResponses(childRequest, config, childLog, { + finishRequestAttempt(attempt, 499, Date.now() - started, childLog.usage); + (logCtx.attempts ??= []).push(attempt); + attemptRetained = true; + }; + const completedTarget = { provider: pick.target.provider, model: pick.target.model }; + const writerGeneration = pick.writerGeneration; + let consumedChildFailure: ConsumedComboFailure | undefined; + const callbackGate = createChildPassthroughCallbackGate({ ...options, - // After the spread: the child must run on THIS target's ladder, not on the holder the - // parent arrived with. - sendBudget: targetSendBudget, - comboAttempt: true, - comboReplaySnapshot, - deferCodexResetDerivedCooldown, - // Attempt-relative TTFT is recorded HERE (not via childLog.firstOutputMs — a later - // Object.assign(logCtx, childLog) would overwrite the request-relative value). - onFirstOutput: () => { - if (attempt.firstOutputMs === undefined) { - attempt.firstOutputMs = Math.max(0, Date.now() - started); + onResponseComplete: model => { + // The live config can change while the child is streaming. Never retain credentials. + const currentCombo = getCombo(config, comboId); + const provider = config.providers[completedTarget.provider]; + if (Object.hasOwn(config.providers, completedTarget.provider) + && provider && provider.disabled !== true + && currentCombo?.targets.some(target => targetKey(target) === targetKey(completedTarget))) { + rememberComboForLane(sessionLaneIdFromRequest(req.headers), comboId, completedTarget, model, writerGeneration); } - options.onFirstOutput?.(); + options.onResponseComplete?.(model); + }, + onNativePassthroughTerminal: status => { + // A committed stream can acquire terminal metadata after preflight copied + // the child log. Publish it before the outer logger finalizes, but only + // through the gate: discarded attempts must never affect the parent. + // Undefined child fields must preserve metadata already inspected by WS. + if (childLog.terminalHttpStatus !== undefined) logCtx.terminalHttpStatus = childLog.terminalHttpStatus; + if (childLog.terminalIncompleteReason !== undefined) logCtx.terminalIncompleteReason = childLog.terminalIncompleteReason; + if (childLog.terminalErrorCode !== undefined) logCtx.terminalErrorCode = childLog.terminalErrorCode; + if (childLog.upstreamError !== undefined) logCtx.upstreamError = childLog.upstreamError; + options.onNativePassthroughTerminal?.(status); }, - onCodexAuthContextResolved: value => { resolvedAuth = value; }, - setTerminalOutcomeRecorder: value => { terminalRecorder = value; }, - onConsumedComboFailure: value => { consumedChildFailure = value; }, - onStoredPool401ReplayDispatched: () => { storedPool401ReplayDispatched = true; }, - onNativePassthroughTerminal: callbackGate.onTerminal, - onNativePassthroughCancel: callbackGate.onCancel, - onResponseComplete: callbackGate.onResponseComplete, }); - restoreOriginalRequestedEffort(childLog); - } catch (error) { - callbackGate.discard(); + let response: Response; + try { + if (options.abortSignal?.aborted) { + callbackGate.discard(); + retainCancelledAttempt(); + return clientCancelledResponse(); + } + const currentTargetProvider = pick.target.provider; + const deferCodexResetDerivedCooldown = combo.strategy === "failover" + && combo.targets.slice(pick.targetIndex + 1).some(target => + target.provider === currentTargetProvider + && payloadEligible(target) + && !isComboTargetInCooldown(comboId, target), + ); + childEntered = true; + response = await requestDispatchers.handleResponses(childRequest, config, childLog, { + ...options, + // After the spread: the child must run on THIS target's ladder, not on the holder the + // parent arrived with. + sendBudget: targetSendBudget, + comboAttempt: true, + comboReplaySnapshot, + deferCodexResetDerivedCooldown, + // Attempt-relative TTFT is recorded HERE (not via childLog.firstOutputMs — a later + // Object.assign(logCtx, childLog) would overwrite the request-relative value). + onFirstOutput: () => { + if (attempt.firstOutputMs === undefined) { + attempt.firstOutputMs = Math.max(0, Date.now() - started); + } + options.onFirstOutput?.(); + }, + onCodexAuthContextResolved: value => { resolvedAuth = value; }, + setTerminalOutcomeRecorder: value => { terminalRecorder = value; }, + onConsumedComboFailure: value => { consumedChildFailure = value; }, + onStoredPool401ReplayDispatched: () => { storedPool401ReplayDispatched = true; }, + onNativePassthroughTerminal: callbackGate.onTerminal, + onNativePassthroughCancel: callbackGate.onCancel, + onResponseComplete: callbackGate.onResponseComplete, + }); + returnedStatus = response.status; + restoreOriginalRequestedEffort(childLog); + } catch (error) { + callbackGate.discard(); + if (options.abortSignal?.aborted) { + retainCancelledAttempt(); + return clientCancelledResponse(); + } + throw error; + } + if (options.abortSignal?.aborted) { + callbackGate.discard(); retainCancelledAttempt(); return clientCancelledResponse(); } - throw error; - } - if (options.abortSignal?.aborted) { - callbackGate.discard(); - retainCancelledAttempt(); - return clientCancelledResponse(); - } + if (response.ok && !runTurnAdapterSseResponses.has(response)) { + const nativePassthrough = isNativePassthroughSseResponse(response); + const eagerRelay = isEagerRelaySseResponse(response); + let preflight; + try { + preflight = await preflightComboStreamResponse(response, childLog); + } catch (error) { + callbackGate.discard(); + if (options.abortSignal?.aborted) { + retainCancelledAttempt(); + return clientCancelledResponse(); + } + throw error; + } + if (preflight.kind === "failed") { + callbackGate.discard(); + terminalRecorder?.("failed", preflight.response.status); + response = preflight.response; + } else { + response = preflight.response; + if (nativePassthrough) markNativePassthroughSseResponse(response); + if (eagerRelay) markEagerRelaySseResponse(response); + } + } - if (response.ok && !runTurnAdapterSseResponses.has(response)) { - const nativePassthrough = isNativePassthroughSseResponse(response); - const eagerRelay = isEagerRelaySseResponse(response); - let preflight; + if (response.ok) { + sealRequestAttemptIdentity( + attempt, + childLog.provider, + childLog.providerAdapter ?? attempt.adapter, + childLog.accountLogLabel, + ); + (logCtx.attempts ??= []).push(attempt); + attemptRetained = true; + noteComboSuccess(comboId, combo, pick.target, pick.writerGeneration); + Object.assign(logCtx, childLog, { + requestedModel, + model: requestedModel, + provider: "combo", + comboId, + routeDecision: logCtx.routeDecision, + attempts: logCtx.attempts, + activeAttempt: attempt, + activeAttemptStartedAt: started, + resolvedModel: childLog.resolvedModel ?? childLog.model, + }); + options.onCodexAuthContextResolved?.(resolvedAuth); + options.setTerminalOutcomeRecorder?.(terminalRecorder); + callbackGate.commit(); + return response; + } + + callbackGate.discard(); + if (response.status === 499) { + retainCancelledAttempt(); + return clientCancelledResponse(); + } + let failure: ConsumedComboFailure; try { - preflight = await preflightComboStreamResponse(response, childLog); + failure = consumedChildFailure + ?? await consumeComboFailure(response, options.abortSignal); } catch (error) { - callbackGate.discard(); if (options.abortSignal?.aborted) { retainCancelledAttempt(); return clientCancelledResponse(); } throw error; } - if (preflight.kind === "failed") { - callbackGate.discard(); - terminalRecorder?.("failed", preflight.response.status); - response = preflight.response; - } else { - response = preflight.response; - if (nativePassthrough) markNativePassthroughSseResponse(response); - if (eagerRelay) markEagerRelaySseResponse(response); + if (options.abortSignal?.aborted) { + retainCancelledAttempt(); + return clientCancelledResponse(); } - } - - if (response.ok) { sealRequestAttemptIdentity( attempt, childLog.provider, childLog.providerAdapter ?? attempt.adapter, childLog.accountLogLabel, ); + finishRequestAttempt( + attempt, + failure.response.status, + Date.now() - started, + failure.usage, + ); (logCtx.attempts ??= []).push(attempt); attemptRetained = true; - noteComboSuccess(comboId, combo, pick.target, pick.writerGeneration); - Object.assign(logCtx, childLog, { - requestedModel, - model: requestedModel, - provider: "combo", - comboId, - routeDecision: logCtx.routeDecision, - attempts: logCtx.attempts, - activeAttempt: attempt, - activeAttemptStartedAt: started, - resolvedModel: childLog.resolvedModel ?? childLog.model, + lastFailure = failure.response; + lastFailedChildLog = childLog; + const failureDecision = comboFailureDecision(failure.response.status, failure.classificationText, { + code: failure.upstreamCode, }); - options.onCodexAuthContextResolved?.(resolvedAuth); - options.setTerminalOutcomeRecorder?.(terminalRecorder); - callbackGate.commit(); - return response; - } - - callbackGate.discard(); - if (response.status === 499) { - retainCancelledAttempt(); - return clientCancelledResponse(); - } - let failure: ConsumedComboFailure; - try { - failure = consumedChildFailure - ?? await consumeComboFailure(response, options.abortSignal); - } catch (error) { - if (options.abortSignal?.aborted) { - retainCancelledAttempt(); - return clientCancelledResponse(); - } - throw error; - } - if (options.abortSignal?.aborted) { - retainCancelledAttempt(); - return clientCancelledResponse(); - } - sealRequestAttemptIdentity( - attempt, - childLog.provider, - childLog.providerAdapter ?? attempt.adapter, - childLog.accountLogLabel, - ); - finishRequestAttempt( - attempt, - failure.response.status, - Date.now() - started, - failure.usage, - ); - (logCtx.attempts ??= []).push(attempt); - attemptRetained = true; - lastFailure = failure.response; - lastFailedChildLog = childLog; - const failureDecision = 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 - && (wantsStream || (failure.upstreamCode !== "outbound_body_too_large" - && failure.upstreamCode !== "translation_buffer_limit")); - lastFailureClassifiesOverflow = classifyOverflow; - if (storedPool401ReplayDispatched) { - if (failureDecision === "hop" && unreadableEncryptedAgentTask && !comboPayloadReadable) { - const recoveredTarget = await pickWithWait({ - exclude: pick.attempted, - eligible: target => { - try { - const route = routeConcreteModel(config, `${target.provider}/${target.model}`); - return route.codexAccountMode === undefined - && !isCanonicalOpenAiForwardProvider(route.provider); - } catch { - return false; - } - }, - }); - if (options.abortSignal?.aborted) return clientCancelledResponse(); - if (recoveredTarget && await recoverUnreadableEncryptedTask()) { - pick = recoveredTarget; - continue; + 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 + && (wantsStream || (failure.upstreamCode !== "outbound_body_too_large" + && failure.upstreamCode !== "translation_buffer_limit")); + lastFailureClassifiesOverflow = classifyOverflow; + if (storedPool401ReplayDispatched) { + if (failureDecision === "hop" && unreadableEncryptedAgentTask && !comboPayloadReadable) { + const recoveredTarget = await pickWithWait({ + exclude: pick.attempted, + eligible: target => { + try { + const route = routeConcreteModel(config, `${target.provider}/${target.model}`); + return route.codexAccountMode === undefined + && !isCanonicalOpenAiForwardProvider(route.provider); + } catch { + return false; + } + }, + }); + if (options.abortSignal?.aborted) return clientCancelledResponse(); + if (recoveredTarget && await recoverUnreadableEncryptedTask()) { + pick = recoveredTarget; + continue; + } + if (options.abortSignal?.aborted) return clientCancelledResponse(); } - if (options.abortSignal?.aborted) return clientCancelledResponse(); - } - // Keep the spent Pool budget sticky even after a recovered routed child: - // no later failure may reopen ordinary combo/native account hopping. - adoptFailedChildLog(childLog); - if (classifyOverflow && failureDecision === "stop") { - return wantsStream - ? streamingContextOverflowResponse(requestedModel, options.translatorBudget) - : jsonContextOverflowResponse(); + // Keep the spent Pool budget sticky even after a recovered routed child: + // no later failure may reopen ordinary combo/native account hopping. + adoptFailedChildLog(childLog); + if (classifyOverflow && failureDecision === "stop") { + return wantsStream + ? streamingContextOverflowResponse(requestedModel, options.translatorBudget) + : jsonContextOverflowResponse(); + } + return lastFailure; } - return lastFailure; - } - if (failureDecision === "stop") { - adoptFailedChildLog(childLog); - if (classifyOverflow) { - return wantsStream - ? streamingContextOverflowResponse(requestedModel, options.translatorBudget) - : jsonContextOverflowResponse(); + if (failureDecision === "stop") { + adoptFailedChildLog(childLog); + if (classifyOverflow) { + return wantsStream + ? streamingContextOverflowResponse(requestedModel, options.translatorBudget) + : jsonContextOverflowResponse(); + } + return lastFailure; } - return lastFailure; - } - console.warn( - `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${failure.response.status} after ${Date.now() - started}ms`, - ); - const failureNow = Date.now(); - const attemptedTargets = pick.attempted; - const nextPick = advanceComboAfterFailure(config, pick, { - retryAfter: failure.retryAfter, - resetAt: failure.resetAt, - cooldownMs: combo.cooldownMs, - now: failureNow, - cooldownScope: comboFailureCooldownScope(failure.response.status, failure.classificationText, { - code: failure.upstreamCode, - }), - eligible: payloadEligible, - status: failure.response.status, - code: failure.upstreamCode, - message: failure.classificationText, - }); - if (nextPick) { - pick = nextPick; - } else { - pick = await pickWithWait({ - exclude: pick.attempted, - eligible: payloadEligible, + console.warn( + `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${failure.response.status} after ${Date.now() - started}ms`, + ); + const failureNow = Date.now(); + const attemptedTargets = pick.attempted; + const nextPick = advanceComboAfterFailure(config, pick, { + retryAfter: failure.retryAfter, + resetAt: failure.resetAt, + cooldownMs: combo.cooldownMs, now: failureNow, + cooldownScope: comboFailureCooldownScope(failure.response.status, failure.classificationText, { + code: failure.upstreamCode, + }), + eligible: payloadEligible, + status: failure.response.status, + code: failure.upstreamCode, + message: failure.classificationText, }); - } - if (!pick) { - if (options.abortSignal?.aborted) return clientCancelledResponse(); - if (unreadableEncryptedAgentTask && !comboPayloadReadable) { - const recoveredTarget = await pickWithWait({ - exclude: attemptedTargets, + if (nextPick) { + pick = nextPick; + } else { + pick = await pickWithWait({ + exclude: pick.attempted, + eligible: payloadEligible, now: failureNow, }); - if (recoveredTarget && await recoverUnreadableEncryptedTask()) { - pick = recoveredTarget; - continue; + } + if (!pick) { + if (options.abortSignal?.aborted) return clientCancelledResponse(); + if (unreadableEncryptedAgentTask && !comboPayloadReadable) { + const recoveredTarget = await pickWithWait({ + exclude: attemptedTargets, + now: failureNow, + }); + if (recoveredTarget && await recoverUnreadableEncryptedTask()) { + pick = recoveredTarget; + continue; + } + } + // Waiting or recovery may have observed cancellation after the check above. + if (options.abortSignal?.aborted) return clientCancelledResponse(); + adoptFailedChildLog(childLog); + } + } finally { + if (hopDecision?.allowed) { + // Some runTurn/sidecar transports only report an attempt or return a live stream. + // Preserve their existing conservative charge, and an entered child's ambiguous + // throw/abort. Refund preparation failures and failed children with no observed send. + if ((observedAttempt?.sendCount ?? 0) > 0 + || (observedChildLog?.activeAttempt?.sendCount ?? 0) > 0 + || observedChildLog?.attempts?.some(childAttempt => childAttempt.sendCount > 0) + || (returnedStatus !== undefined && returnedStatus >= 200 && returnedStatus < 300) + || (childEntered && (returnedStatus === undefined || returnedStatus === 499))) { + hopDecision.permit.use(); } + hopDecision.permit.release(); } - // Waiting or recovery may have observed cancellation after the check above. - if (options.abortSignal?.aborted) return clientCancelledResponse(); - adoptFailedChildLog(childLog); } } if ( diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index d094b8befe..adad78a1af 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -1,6 +1,6 @@ # Adapter Registry Authority -401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](../transports/responses.md#credential-hop-reservations), including native main/stored-account replay. +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](../transports/responses.md#credential-hop-reservations), including native main/stored-account replay. Combo cleanup refunds proven local refusal while retaining successful, observed or uncertain child-dispatch charges under that contract. Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](../transports/responses.md#credential-hop-reservations). diff --git a/structure/catalog.md b/structure/catalog.md index d066267fa3..fb755c73d2 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -1,6 +1,6 @@ # Model Catalog -401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](transports/responses.md#credential-hop-reservations), including native main/stored-account replay. +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](transports/responses.md#credential-hop-reservations), including native main/stored-account replay. Combo cleanup refunds proven local refusal while retaining successful, observed or uncertain child-dispatch charges under that contract. Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](transports/responses.md#credential-hop-reservations). diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 4280810c42..d9a358005c 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -1,6 +1,6 @@ # Claude Desktop Integration -401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](../transports/responses.md#credential-hop-reservations), including native main/stored-account replay. +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](../transports/responses.md#credential-hop-reservations), including native main/stored-account replay. Combo cleanup refunds proven local refusal while retaining successful, observed or uncertain child-dispatch charges under that contract. Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](../transports/responses.md#credential-hop-reservations). diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index a087a72a16..1eb2b29fb1 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -1,6 +1,6 @@ # Images Data Plane -401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](../transports/responses.md#credential-hop-reservations), including native main/stored-account replay. +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](../transports/responses.md#credential-hop-reservations), including native main/stored-account replay. Combo cleanup refunds proven local refusal while retaining successful, observed or uncertain child-dispatch charges under that contract. Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](../transports/responses.md#credential-hop-reservations). diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 67c999530c..e6a044b551 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -1,6 +1,6 @@ # Inbound Compatibility Surfaces -401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](../transports/responses.md#credential-hop-reservations), including native main/stored-account replay. +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](../transports/responses.md#credential-hop-reservations), including native main/stored-account replay. Combo cleanup refunds proven local refusal while retaining successful, observed or uncertain child-dispatch charges under that contract. Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](../transports/responses.md#credential-hop-reservations). diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 4237b24b18..5370d98046 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,6 +1,6 @@ # GUI And Management API -401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](transports/responses.md#credential-hop-reservations), including native main/stored-account replay. +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](transports/responses.md#credential-hop-reservations), including native main/stored-account replay. Combo cleanup refunds proven local refusal while retaining successful, observed or uncertain child-dispatch charges under that contract. Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](transports/responses.md#credential-hop-reservations). diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 65177315ba..69a47e80b7 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -1,6 +1,6 @@ # Background Service And Sidecars -401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](../transports/responses.md#credential-hop-reservations), including native main/stored-account replay. +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](../transports/responses.md#credential-hop-reservations), including native main/stored-account replay. Combo cleanup refunds proven local refusal while retaining successful, observed or uncertain child-dispatch charges under that contract. Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](../transports/responses.md#credential-hop-reservations). diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 4a83ede178..5f30c0d93e 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -1,6 +1,6 @@ # xAI Grok Provider -401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](../transports/responses.md#credential-hop-reservations), including native main/stored-account replay. +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](../transports/responses.md#credential-hop-reservations), including native main/stored-account replay. Combo cleanup refunds proven local refusal while retaining successful, observed or uncertain child-dispatch charges under that contract. Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](../transports/responses.md#credential-hop-reservations). diff --git a/structure/runtime.md b/structure/runtime.md index 8034d24efd..a007584ba9 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,6 +1,6 @@ # Runtime -401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](transports/responses.md#credential-hop-reservations), including native main/stored-account replay. +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](transports/responses.md#credential-hop-reservations), including native main/stored-account replay. Combo cleanup refunds proven local refusal while retaining successful, observed or uncertain child-dispatch charges under that contract. Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](transports/responses.md#credential-hop-reservations). diff --git a/structure/subagents.md b/structure/subagents.md index 9341501669..0821b7eea4 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -1,6 +1,6 @@ # Subagents And Multi-Agent Surface -401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](transports/responses.md#credential-hop-reservations), including native main/stored-account replay. +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](transports/responses.md#credential-hop-reservations), including native main/stored-account replay. Combo cleanup refunds proven local refusal while retaining successful, observed or uncertain child-dispatch charges under that contract. Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](transports/responses.md#credential-hop-reservations). diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index e7e17632c6..c094e9f40e 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -1,6 +1,6 @@ # Byte Accounting -401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](responses.md#credential-hop-reservations), including native main/stored-account replay. +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](responses.md#credential-hop-reservations), including native main/stored-account replay. Combo cleanup refunds proven local refusal while retaining successful, observed or uncertain child-dispatch charges under that contract. Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](responses.md#credential-hop-reservations). diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 9edbbfdb76..ce76d099ce 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -1,6 +1,6 @@ # Transport Inventory -401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](responses.md#credential-hop-reservations), including native main/stored-account replay. +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](responses.md#credential-hop-reservations), including native main/stored-account replay. Combo cleanup refunds proven local refusal while retaining successful, observed or uncertain child-dispatch charges under that contract. Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](responses.md#credential-hop-reservations). diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 1c8a9322ce..5e08cb656e 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -689,6 +689,17 @@ budget before it knows whether a rotation is even possible, because the reservat `reserveDispatch` spends, `permit.use()` only confirms which leg sent, and `permit.release()` is idempotent and a no-op once used. Every ladder therefore owes the budget an answer on every exit. +Combo bookings remain unconfirmed while constructing and dispatching a child. Preparation failure +or a failed child with no dispatch recorded in its fresh attempt log releases the unused booking; +an adapter-owned reservation or external send report settles it at the existing send boundary. +Successful responses (including deferred streams), positive child/nested dispatch records, and +unknown throws or cancellation after child entry retain the existing conservative one-send charge +when their transport does not settle the booking itself. Dispatch records can precede pacing: +this refunds proven local refusal, not every zero-wire failure. The iteration-wide cleanup covers +preparation, abort, failure and early return. `tests/responses/responses-send-budget-counts.test.ts` +checks local refusal followed by four plus two Vertex sends; the opaque runTurn and hosted search +cases in `tests/server/server-combo-failover-e2e.test.ts` preserve their observed dispatch charges. + An initial terminal-guard continuation repairs a no-tool completion, even without a recovery log label. It may spend the shared final reserve after base sends are exhausted. The retry helper settles that permit once; a missing or spent reserve prevents another dispatch or credential hop. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index f073f29cff..5ecd3bf102 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -1,6 +1,6 @@ # Streaming Health And WebSocket -401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](responses.md#credential-hop-reservations), including native main/stored-account replay. +401 recovery admits its inference before credential mutation or body disposal under the [recovery contract](responses.md#credential-hop-reservations), including native main/stored-account replay. Combo cleanup refunds proven local refusal while retaining successful, observed or uncertain child-dispatch charges under that contract. Budget-aware rebuilds reuse or fund a recovery reservation; the first helper send reuses the outer pacing slot under the [recovery and pacing contract](responses.md#credential-hop-reservations). diff --git a/tests/responses/responses-send-budget-counts.test.ts b/tests/responses/responses-send-budget-counts.test.ts index b1eb4ff7b8..4cfa380360 100644 --- a/tests/responses/responses-send-budget-counts.test.ts +++ b/tests/responses/responses-send-budget-counts.test.ts @@ -1,9 +1,15 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import * as comboRequests from "../../src/combos"; +import { executeComboResponses } from "../../src/server/responses/core-combo"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import type { RequestExecutionBudget } from "../../src/lib/request-execution-budget"; import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; import { clearKeyCooldowns } from "../../src/providers/key-failover"; import { handleResponses } from "../../src/server/responses/core"; import type { RequestLogContext } from "../../src/server/request-log"; import type { OcxConfig } from "../../src/types"; +import { saveConfig } from "../../src/config"; +import { createRequestExecutionBudget } from "../../src/lib/request-execution-budget"; /** * One logical request, one send budget -- asserted as a COUNT, because the defect in #4546 is a @@ -86,6 +92,73 @@ const totalSends = (logCtx: RequestLogContext): number => sendCounts(logCtx).reduce((sum, count) => sum + count, 0); describe("upstream sends per logical request", () => { + test.each(["prepare-throw", "prepare-abort", "child-throw", "child-abort", "settled-send", "opaque-send", "nested-observed", "opaque-success"] as const)( + "combo reservation cleanup preserves observed work and refunds local exits (%s)", async mode => { + const budget = createRequestExecutionBudget(); + const translatorBudget = createTranslatorBudget(); + const controller = new AbortController(); + const failure = new Error("fixture pre-dispatch failure"); + const originalPrepare = comboRequests.concreteComboRequestBody; + const preparation = mode.startsWith("prepare-") + ? spyOn(comboRequests, "concreteComboRequestBody").mockImplementationOnce((...args) => { + if (mode === "prepare-throw") throw failure; + controller.abort(); + return originalPrepare(...args); + }) : undefined; + let children = 0; + try { + const pending = executeComboResponses(responsesRequest("combo/fan"), { model: "combo/fan", input: "hello", stream: false }, + "fan", comboOverTargets(1), { model: "", provider: "" }, { translatorBudget, sendBudget: budget, abortSignal: controller.signal }, { + handleComboResponses: async () => { throw new Error("Unexpected nested combo"); }, + handleResponses: async (_request, _config, log, options) => { + children += 1; + if (mode === "child-throw") throw failure; + if (mode === "child-abort") { controller.abort(); return new Response(null, { status: 499 }); } + if (mode === "settled-send") { + const send = (options!.sendBudget as RequestExecutionBudget).reserveDispatch({ sendClass: "transient", targetKey: "fixture" }); + if (!send.allowed || !send.permit.use()) throw new Error("Expected prepaid child send"); + } + if (mode === "opaque-send") log.activeAttempt!.sendCount = 1; + if (mode === "nested-observed") log.attempts = [{ ...log.activeAttempt!, sendCount: 1 }]; + return Response.json({ error: { message: "fixture provider failure" } }, { status: mode === "opaque-success" ? 200 : 502 }); + }, + }); + if (mode.endsWith("throw")) await expect(pending).rejects.toBe(failure); + else await (await pending).text(); + expect(children).toBe(mode.startsWith("prepare-") ? 0 : 1); + expect(budget.used).toBe(mode.startsWith("prepare-") ? 0 : 1); + } finally { preparation?.mockRestore(); translatorBudget.dispose(); } + }, + ); + + test("a local input refusal refunds its combo booking so later targets keep four plus two sends", async () => { + const cfg = comboOverTargets(3); + cfg.providers.t0!.contextWindow = 1; + for (const name of ["t1", "t2"]) cfg.providers[name] = { + ...cfg.providers[name]!, adapter: "google", googleMode: "vertex", baseUrl: "https://aiplatform.googleapis.com", + }; + cfg.providers.t1!.apiKeyPool = [{ id: "a", key: "sk-t1" }, { id: "b", key: "sk-t1-recovery" }]; + saveConfig(cfg); + const seen: string[] = []; + globalThis.fetch = (async (_input, init) => { + const key = new Headers(init?.headers).get("x-goog-api-key") ?? ""; + seen.push(key); + const status = key === "sk-t1" && seen.length === 3 ? 429 : 502; + return Response.json({ error: { message: "fixture provider failure" } }, { status, headers: { "Retry-After": "0" } }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + const budget = createRequestExecutionBudget(); + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/fan", stream: false, input: "large input ".repeat(100) }), + }), cfg, logCtx, { sendBudget: budget }); + expect(response.status).toBe(502); + await response.text(); + expect(seen).toEqual(["sk-t1", "sk-t1", "sk-t1", "sk-t1-recovery", "sk-t2", "sk-t2"]); + expect(sendCounts(logCtx)).toEqual([0, 4, 2]); + expect(budget.used).toBe(6); + }); + test("a 5xx streak on a single target spends the base allowance and stops", async () => { const upstream = alwaysFailing(502, "upstream busy"); const logCtx: RequestLogContext = { model: "", provider: "" }; diff --git a/tests/server/server-combo-failover-e2e.test.ts b/tests/server/server-combo-failover-e2e.test.ts index bea04074a4..76ec7f06a7 100644 --- a/tests/server/server-combo-failover-e2e.test.ts +++ b/tests/server/server-combo-failover-e2e.test.ts @@ -46,6 +46,7 @@ import { clearKeyCooldowns } from "../../src/providers/key-failover"; import { consumeComboFailure, createChildPassthroughCallbackGate } from "../../src/server/responses/core"; import { clearComboRecallForTests, recallComboForLane, reconcileComboRecall } from "../../src/server/responses/combo-session-recall"; import { captureConfigGeneration } from "../../src/lib/state-store-sweeper"; +import { createRequestExecutionBudget } from "../../src/lib/request-execution-budget"; // Full-suite Windows load: startServer + combo rename/delete management flows exceed the // default 5s per-test budget (same flake class as 810fa115 / claude-management-api). @@ -1937,7 +1938,10 @@ describe("server combo failover 030 activation matrix", () => { }); test("runTurn combo attempts retain requested effort without adapter wire metadata", async () => { + const sendBudget = createRequestExecutionBudget(); + const dispatchedModels: string[] = []; customRunTurn = async (parsed, _incoming, emit) => { + dispatchedModels.push(parsed.modelId); if (parsed.modelId === "m1") { emit({ type: "error", message: "first target unavailable" }); return; @@ -1952,9 +1956,11 @@ describe("server combo failover 030 activation matrix", () => { const response = await postLogged(config, { reasoning: { effort: "high" }, - }); + }, { sendBudget }); expect(response.status).toBe(200); expect(JSON.stringify(await response.json())).toContain("runTurn backup"); + expect(dispatchedModels).toEqual(["m1", "m2"]); + expect(sendBudget.used).toBe(2); const { log, usage } = await latestAttemptReceipts(config); for (const receipt of [log, usage]) { @@ -1973,6 +1979,7 @@ describe("server combo failover 030 activation matrix", () => { }); test("hosted web-search eager model failure hops through the loop path", async () => { + const sendBudget = createRequestExecutionBudget(); const modelHits: Array<{ model?: string; hasWebTool: boolean; authorization: string | null; account: string | null }> = []; const routed = serve(async request => { const body = await request.json() as { model?: string; tools?: Array<{ type?: string }> }; @@ -2005,7 +2012,7 @@ describe("server combo failover 030 activation matrix", () => { const response = await post(config, { stream: true, tools: [{ type: "web_search" }], - }, { onResponseComplete: model => models.push(model) }, { + }, { sendBudget, onResponseComplete: model => models.push(model) }, { session_id: "web-search-recall", authorization: `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "acct-combo-search" })}`, "chatgpt-account-id": "acct-combo-search", @@ -2013,6 +2020,7 @@ describe("server combo failover 030 activation matrix", () => { expect(response.status).toBe(200); expect(JSON.stringify(await collectSse(response))).toContain("web loop backup"); expect(modelHits.map(hit => hit.model)).toEqual(["m1", "m2"]); + expect(sendBudget.used).toBe(2); expect(modelHits.every(hit => hit.hasWebTool)).toBe(true); expect(modelHits.map(hit => hit.authorization)).toEqual(["Bearer key-a", "Bearer key-b"]); expect(modelHits.every(hit => hit.account === null)).toBe(true);