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 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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".