From 9c0ca634f36a969bb7b6afd465379e3bf64783c2 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 17:41:34 +0900 Subject: [PATCH 01/10] fix(anthropic): cap the server-tool input buffer on the incoming fragment (carry #7232) Carried from #7232. Co-authored-by: Vadym O --- src/adapters/anthropic.ts | 2 +- .../claude-translated-server-tools.test.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 653d91593ce..140bd336f3b 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1416,7 +1416,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && serverSearch?.streaming && serverSearch.json.length < 4096) { // Capped: an input past 4 KiB loses its label (query / url), never the call. - serverSearch.json += delta.partial_json; + serverSearch.json += delta.partial_json.slice(0, 4096 - serverSearch.json.length); } break; } diff --git a/tests/claude-integration/claude-translated-server-tools.test.ts b/tests/claude-integration/claude-translated-server-tools.test.ts index 75e45c572cf..35b385f3d0a 100644 --- a/tests/claude-integration/claude-translated-server-tools.test.ts +++ b/tests/claude-integration/claude-translated-server-tools.test.ts @@ -175,6 +175,17 @@ describe("server-side search across responses", () => { expect(starts[1]).toMatchObject({ tool_use_id: starts[0]!.id, content: [{ type: "web_search_result", url: "https://bun.sh/blog" }] }); }); + test("a single input fragment past 4 KiB is not retained beyond the cap", async () => { + const query = "q".repeat(5000); + const { clientSse } = await claudeTurn("https://api.anthropic.com", [ + ...SEARCH_TURN.slice(0, 2), + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: JSON.stringify({ query }) } }, + ...SEARCH_TURN.slice(4), + ]); + expect(blockStarts(clientSse).map(b => b.type)).toEqual(["server_tool_use", "web_search_tool_result", "text"]); + expect(clientSse).not.toContain(query); + }); + test("a replayed search pair without encrypted_content never reaches Anthropic", async () => { const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", [ { role: "user", content: "What did Bun release?" }, From 638d15778a8d5df3d8a500aaf32b3d40b47cf3f8 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 17:41:45 +0900 Subject: [PATCH 02/10] fix(combos): peek the round-robin ring in the combo's sibling check (carry #7230) Carried from #7230. Co-authored-by: Vadym O --- src/oauth/generic-account-failover.ts | 11 +++++++---- src/server/responses/core-combo.ts | 5 +++-- ...eneric-oauth-combo-account-cooldown.test.ts | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/oauth/generic-account-failover.ts b/src/oauth/generic-account-failover.ts index c8cbd8e042c..9fca39601c3 100644 --- a/src/oauth/generic-account-failover.ts +++ b/src/oauth/generic-account-failover.ts @@ -483,7 +483,8 @@ export function noteGenericPoolSelection( * Cool the account that actually 429'd and name the next eligible one, or null. * * Returns the id only; the caller mints the credential so a failed refresh does not leave the - * cooldown applied to an account we then could not use. + * cooldown applied to an account we then could not use. `commit: false` still cools the failed + * account but only peeks the round-robin ring, for callers that will not dispatch the answer. */ export function rotateGenericOAuthAccountOn429( config: OcxConfig, @@ -492,9 +493,10 @@ export function rotateGenericOAuthAccountOn429( retryAfterHeader: string | null | undefined, now = Date.now(), requestedModelId?: string | null, + commit = true, ): string | null { return rotateGenericOAuthAccountOnRefusal(config, providerName, failedAccountId, "rate", - retryAfterHeader, now, requestedModelId); + retryAfterHeader, now, requestedModelId, undefined, commit); } /** Kiro adds account-scoped refusal classes; all other providers retain their 429 policy. */ @@ -507,6 +509,7 @@ export function rotateGenericOAuthAccountOnRefusal( now = Date.now(), requestedModelId?: string | null, monthlyCooldownMs?: number, + commit = true, ): string | null { if (kind === "other" || (providerName !== "kiro" && kind !== "rate")) return null; if (!isGenericOAuthFailoverEnabled(config, providerName)) return null; @@ -566,8 +569,8 @@ export function rotateGenericOAuthAccountOnRefusal( } if (strategy === "round-robin") { // PICK here, not peek: the failure already happened and this answer is the one being used, - // so the ring genuinely advances. - return pickRoundRobinAccount( + // so the ring genuinely advances. A caller that only asks whether a sibling exists peeks. + return (commit ? pickRoundRobinAccount : peekRoundRobinAccount)( genericPoolKey(providerName), candidates, genericStickyLimit(config, providerName), diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 3104433d4c7..ce76e36637c 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -144,7 +144,8 @@ function cooledPoolAccountLabel(config: OcxConfig, providerName: string, modelId * through `accountLogLabel`, but the per-request account budget can end the pool's own rotation * before the last refused account is recorded. Hand that account to the pool's rotator, which * cools it under the pool's own Retry-After policy, and spare the target only when the pool still - * has an eligible sibling. A lone account or an all-cooled pool returns null and cools the target. + * has an eligible sibling. The rotator only peeks round-robin here: nothing is sent to that sibling. + * A lone account or an all-cooled pool returns null and cools the target. */ function cooledGenericOAuthAccountLabel( config: OcxConfig, providerName: string, modelId: string, label: string | undefined, retryAfter: string | undefined, @@ -155,7 +156,7 @@ function cooledGenericOAuthAccountLabel( const matches = getAccountSet(providerName)?.accounts.filter(account => oauthAccountLogLabel(account.id, base) === label) ?? []; return matches.length === 1 - && rotateGenericOAuthAccountOn429(config, providerName, matches[0]!.id, retryAfter, Date.now(), modelId) + && rotateGenericOAuthAccountOn429(config, providerName, matches[0]!.id, retryAfter, Date.now(), modelId, false) ? label : undefined; } diff --git a/tests/oauth/generic-oauth-combo-account-cooldown.test.ts b/tests/oauth/generic-oauth-combo-account-cooldown.test.ts index 0ec326ed748..b8ec163c75d 100644 --- a/tests/oauth/generic-oauth-combo-account-cooldown.test.ts +++ b/tests/oauth/generic-oauth-combo-account-cooldown.test.ts @@ -12,6 +12,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { clearComboSelectionState, clearComboTargetCooldowns, isComboTargetInCooldown } from "../../src/combos"; import { clearGenericFailoverHealth, eligibleFailoverAccounts } from "../../src/oauth/generic-account-failover"; +import { genericPoolKey, peekRoundRobinAccount } from "../../src/oauth/pool-kernel"; import { getAccountSet, saveCredential, setActiveAccount } from "../../src/oauth/store"; import { clearAccountQuotaCache } from "../../src/providers/quota"; import { clearResponseStateForTests, flushResponseState } from "../../src/responses/state"; @@ -129,3 +130,20 @@ test("every generic OAuth account spent still cools the combo target", async () expect(sends).toEqual(["1", "2", "B"]); expect(isComboTargetInCooldown("pooled", TARGET)).toBe(true); }, 20_000); + +test("the combo's sibling check does not advance a round-robin pool onto an unsent account", async () => { + await seed(5); + upstream(4); + const roundRobin: OcxConfig = { ...config, pool: { kernel: true }, providers: { ...config.providers, + xai: { ...config.providers.xai!, oauthAccountFailover: { strategy: "round-robin", stickyLimit: 3 } } } }; + const ids = getAccountSet("xai")!.accounts.map(account => account.id); + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/pooled", input: "Answer briefly", stream: false }), + }), roundRobin, { model: "", provider: "" }); + expect(await response.text()).toContain("fallback answer"); + expect(sends).not.toContain("5"); + expect(isComboTargetInCooldown("pooled", TARGET)).toBe(false); + // Account 5 was only probed for eligibility, never sent; the ring must not stick to it. + expect(peekRoundRobinAccount(genericPoolKey("xai"), ids, 3)).not.toBe(ids[4]); +}, 20_000); From b2c630830c5fff651f526d2ee077b71564ca60cd Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 17:42:01 +0900 Subject: [PATCH 03/10] fix(codex): record a 402 account move as a quota recovery (carry #7231) Carried from #7231. Co-authored-by: Vadym O --- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/pt.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/vi.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/Logs.tsx | 1 + src/lib/request-failure-model.ts | 2 ++ src/routing/analytics.ts | 1 + src/server/responses/core-codex-account.ts | 4 +-- src/usage/telemetry-contract.ts | 1 + tests/lib/failure-stage-model.test.ts | 4 ++- .../responses/responses-account-label.test.ts | 34 ++++++++++++++++++- 18 files changed, 54 insertions(+), 4 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 3b784e086f7..22c5edac19d 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1148,6 +1148,7 @@ export const de: Record = { "logs.detail.attempt.recovery.key401": "API-Schlüssel erneut authentifiziert", "logs.detail.attempt.recovery.oauthAccount403": "Kontovalidierung erforderlich (403)", "logs.detail.attempt.recovery.oauthAccount429": "Konto rate-limitiert (429)", + "logs.detail.attempt.recovery.oauthAccount402": "Kontingent des Kontos erschöpft (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "Veralteter verschlüsselter Zustand verworfen", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "Reasoning-Aufwand reduziert", "logs.detail.attempt.recovery.anthropicFastDowngrade": "Schnellmodus abgelehnt, erneut mit Standardtempo versucht", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index a10c82ade62..350a21304ae 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1206,6 +1206,7 @@ export const en = { "logs.detail.attempt.recovery.key401": "API key re-authentication", "logs.detail.attempt.recovery.oauthAccount403": "Account validation required (403)", "logs.detail.attempt.recovery.oauthAccount429": "Account rate-limited (429)", + "logs.detail.attempt.recovery.oauthAccount402": "Account quota exhausted (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "Stale encrypted state dropped", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "Reasoning effort downgraded", "logs.detail.attempt.recovery.anthropicFastDowngrade": "Fast mode refused, retried at standard speed", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index d61681fcfc9..a17e39b0bd0 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1182,6 +1182,7 @@ export const fr: Record = { "logs.detail.attempt.recovery.key401": "Ré-authentification de la clé API", "logs.detail.attempt.recovery.oauthAccount403": "Validation du compte requise (403)", "logs.detail.attempt.recovery.oauthAccount429": "Compte limité (429)", + "logs.detail.attempt.recovery.oauthAccount402": "Quota du compte épuisé (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "État chiffré obsolète abandonné", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "Effort de raisonnement réduit", "logs.detail.attempt.recovery.anthropicFastDowngrade": "Mode rapide refusé, nouvel essai à vitesse standard", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 1ae152fdee0..08799fa8f3f 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1089,6 +1089,7 @@ export const ja: Record = { "logs.detail.attempt.recovery.key401": "API キーの再認証", "logs.detail.attempt.recovery.oauthAccount403": "アカウントの検証が必要 (403)", "logs.detail.attempt.recovery.oauthAccount429": "アカウントのレート制限 (429)", + "logs.detail.attempt.recovery.oauthAccount402": "アカウントのクォータ枯渇 (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "古い暗号化状態を破棄", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "推論強度を下げて再試行", "logs.detail.attempt.recovery.anthropicFastDowngrade": "高速モードが拒否され、標準速度で再試行", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index a7faa3c9e47..0781408ca97 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1187,6 +1187,7 @@ export const ko: Record = { "logs.detail.attempt.recovery.key401": "API 키 재인증", "logs.detail.attempt.recovery.oauthAccount403": "계정 확인 필요 (403)", "logs.detail.attempt.recovery.oauthAccount429": "계정 속도 제한 (429)", + "logs.detail.attempt.recovery.oauthAccount402": "계정 할당량 소진 (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "만료된 암호화 상태 제거", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "추론 강도 하향", "logs.detail.attempt.recovery.anthropicFastDowngrade": "빠른 모드 거부, 표준 속도로 재시도", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index dac0f93fdc8..5d0f1dd1761 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -1201,6 +1201,7 @@ export const pt: Record = { "logs.detail.attempt.recovery.key401": "Reautenticação da chave de API", "logs.detail.attempt.recovery.oauthAccount403": "Validação da conta necessária (403)", "logs.detail.attempt.recovery.oauthAccount429": "Conta com limite de taxa (429)", + "logs.detail.attempt.recovery.oauthAccount402": "Cota da conta esgotada (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "Estado criptografado obsoleto descartado", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "Esforço de raciocínio reduzido", "logs.detail.attempt.recovery.anthropicFastDowngrade": "Modo rápido recusado, repetido em velocidade padrão", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 8603d20b578..ad8863b0cce 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1165,6 +1165,7 @@ export const ru: Record = { "logs.detail.attempt.recovery.key401": "Повторная аутентификация API-ключа", "logs.detail.attempt.recovery.oauthAccount403": "Требуется проверка аккаунта (403)", "logs.detail.attempt.recovery.oauthAccount429": "Ограничение частоты для аккаунта (429)", + "logs.detail.attempt.recovery.oauthAccount402": "Квота аккаунта исчерпана (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "Устаревшее зашифрованное состояние отброшено", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "Уровень рассуждения понижен", "logs.detail.attempt.recovery.anthropicFastDowngrade": "Быстрый режим отклонён, повтор со стандартной скоростью", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 008e8be47e4..147e954d1c9 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1192,6 +1192,7 @@ export const tr: Record = { "logs.detail.attempt.recovery.key401": "API anahtarı yeniden doğrulandı", "logs.detail.attempt.recovery.oauthAccount403": "Hesap doğrulaması gerekli (403)", "logs.detail.attempt.recovery.oauthAccount429": "Hesap hız sınırına takıldı (429)", + "logs.detail.attempt.recovery.oauthAccount402": "Hesap kotası tükendi (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "Eski şifreli durum bırakıldı", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "Akıl yürütme düzeyi düşürüldü", "logs.detail.attempt.recovery.anthropicFastDowngrade": "Hızlı mod reddedildi, standart hızda yeniden denendi", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 0f432ac4923..18fa8b93963 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -1186,6 +1186,7 @@ export const vi: Record = { "logs.detail.attempt.recovery.key401": "Xác thực lại khóa API", "logs.detail.attempt.recovery.oauthAccount403": "Cần xác minh tài khoản (403)", "logs.detail.attempt.recovery.oauthAccount429": "Tài khoản bị giới hạn tần suất (429)", + "logs.detail.attempt.recovery.oauthAccount402": "Tài khoản đã hết hạn mức (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "Đã bỏ trạng thái mã hóa cũ", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "Đã giảm mức suy luận", "logs.detail.attempt.recovery.anthropicFastDowngrade": "Chế độ nhanh bị từ chối, thử lại ở tốc độ chuẩn", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 1bc86ff154c..783ea79a4fc 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3002,6 +3002,7 @@ export const zhTW: Record = { "logs.detail.attempt.recovery.key401": "API 金鑰重新驗證", "logs.detail.attempt.recovery.oauthAccount403": "需要驗證帳號 (403)", "logs.detail.attempt.recovery.oauthAccount429": "帳號速率受限 (429)", + "logs.detail.attempt.recovery.oauthAccount402": "帳號配額已用盡 (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "已捨棄過期加密狀態", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "已降低推理強度", "logs.detail.attempt.recovery.anthropicFastDowngrade": "快速模式遭拒,改以標準速度重試", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index a0240c48491..dacb18d70ea 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1160,6 +1160,7 @@ export const zh: Record = { "logs.detail.attempt.recovery.key401": "API 密钥重新认证", "logs.detail.attempt.recovery.oauthAccount403": "需要验证账号 (403)", "logs.detail.attempt.recovery.oauthAccount429": "账号速率受限 (429)", + "logs.detail.attempt.recovery.oauthAccount402": "账号配额已用尽 (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "已丢弃过期加密状态", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "已降低推理强度", "logs.detail.attempt.recovery.anthropicFastDowngrade": "快速模式被拒,按标准速度重试", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 922634b69ec..a1f5858f394 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -355,6 +355,7 @@ const RECOVERY_KIND_KEYS = { "rate-limit-429": "logs.detail.attempt.recovery.rateLimit429", "anthropic-oauth-429": "logs.detail.attempt.recovery.anthropicOauth429", "oauth-account-429": "logs.detail.attempt.recovery.oauthAccount429", + "oauth-account-402": "logs.detail.attempt.recovery.oauthAccount402", "image-413": "logs.detail.attempt.recovery.image413", "empty-completion": "logs.detail.attempt.recovery.emptyCompletion", "console-go-upload-retry": "logs.detail.attempt.recovery.consoleGoUpload", diff --git a/src/lib/request-failure-model.ts b/src/lib/request-failure-model.ts index 7ac0cb9ba61..dc924ac2874 100644 --- a/src/lib/request-failure-model.ts +++ b/src/lib/request-failure-model.ts @@ -223,6 +223,8 @@ const RECOVERY_KIND_CAUSE = { "rate-limit-429": "rate-limit", "anthropic-oauth-429": "rate-limit", "oauth-account-429": "rate-limit", + // A 402 is the account's quota, not a transient limit; only another account can serve it. + "oauth-account-402": "quota-exhausted", "image-413": "payload-too-large", // The gateway rejects a body it accepts seconds later and the replay is byte-identical, so // nothing about the payload was wrong; the origin declined to take it at that moment. diff --git a/src/routing/analytics.ts b/src/routing/analytics.ts index 5c4411c3b8c..3eb2e540a14 100644 --- a/src/routing/analytics.ts +++ b/src/routing/analytics.ts @@ -122,6 +122,7 @@ const COOLDOWN_RECOVERY_KINDS = new Set([ "oauth-account-403", "anthropic-oauth-429", "oauth-account-429", + "oauth-account-402", ]); function percentile(sorted: number[], p: number): number | undefined { diff --git a/src/server/responses/core-codex-account.ts b/src/server/responses/core-codex-account.ts index de3152da035..7f73f04c4ce 100644 --- a/src/server/responses/core-codex-account.ts +++ b/src/server/responses/core-codex-account.ts @@ -975,8 +975,8 @@ export async function retryCodexPoolOnAlternateAccount( const maxRetrySends = retrySameConfirmedAccount ? 7 : 1; let retrySendCount = 0; // The gated-model 400 has no roster kind; it stays unattributed as before. - const moveRecovery: AttemptRecoveryKind | undefined = outcomeStatus === 429 || outcomeStatus === 402 - ? "oauth-account-429" + const moveRecovery: AttemptRecoveryKind | undefined = outcomeStatus === 429 ? "oauth-account-429" + : outcomeStatus === 402 ? "oauth-account-402" : isTransientUpstreamStatus(outcomeStatus) ? "transient-5xx" : undefined; let upstreamResponse: Response; try { diff --git a/src/usage/telemetry-contract.ts b/src/usage/telemetry-contract.ts index 157d372f7c7..69247569cdf 100644 --- a/src/usage/telemetry-contract.ts +++ b/src/usage/telemetry-contract.ts @@ -32,6 +32,7 @@ export const ATTEMPT_RECOVERY_KIND_ROSTER = Object.freeze([ "rate-limit-429", "anthropic-oauth-429", "oauth-account-429", + "oauth-account-402", "image-413", "console-go-upload-retry", "opaque-blob-rejection", diff --git a/tests/lib/failure-stage-model.test.ts b/tests/lib/failure-stage-model.test.ts index 893f85b6178..078942991fd 100644 --- a/tests/lib/failure-stage-model.test.ts +++ b/tests/lib/failure-stage-model.test.ts @@ -199,8 +199,10 @@ describe("recovery kinds speak the shared dictionary", () => { * the durable log were forbidden and unfunded. */ test("no recorded recovery classifies as a resend that cannot help", () => { + // Except the Codex 402 account move: re-asking the exhausted account is futile, and the move + // to another account is a route decision this table explicitly does not make. const futile = ATTEMPT_RECOVERY_KIND_ROSTER .filter(kind => causeDisposition(causeForRecoveryKind(kind)) === "resend-is-futile"); - expect(futile).toEqual([]); + expect(futile).toEqual(["oauth-account-402"]); }); }); diff --git a/tests/responses/responses-account-label.test.ts b/tests/responses/responses-account-label.test.ts index c127c0ae2c0..a88745c707c 100644 --- a/tests/responses/responses-account-label.test.ts +++ b/tests/responses/responses-account-label.test.ts @@ -11,7 +11,8 @@ import { clearThreadAccountMap, getCodexUpstreamHealth, } from "../../src/codex/routing"; -import type { RequestLogContext } from "../../src/server/request-log"; +import { addFinalRequestLog, type RequestLogContext } from "../../src/server/request-log"; +import { createRequestMetricsOwner } from "../../src/server/request-metrics"; import { handleResponses } from "../../src/server/responses"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -336,6 +337,37 @@ describe("Responses account usage attribution", () => { }); }); + test("a pre-stream 402 move is recorded and counted as a quota recovery, not a rate limit", async () => { + await withPoolHome(async () => { + takeSpendHome(); + const config = poolConfig(["pool-a", "pool-b"]); + for (const id of ["pool-a", "pool-b"]) { + savePoolCredential(id); + updateAccountQuota(id, id === "pool-a" ? 10 : 20); + } + let sends = 0; + globalThis.fetch = (async () => ++sends === 1 + ? Response.json({ error: { message: "payment required" } }, { status: 402 }) + : completedResponse("pool-b-response")) as typeof fetch; + + const metrics = createRequestMetricsOwner(123); + const logCtx: RequestLogContext = { model: "", provider: "", inboundProtocol: "responses", requestMetricsRecorder: metrics }; + const response = await handleResponses(request(), config, logCtx, {}); + expect(response.status).toBe(200); + expect(logCtx.attempts?.map(attempt => [attempt.status, attempt.recoveryKinds])).toEqual([ + [402, []], + [0, ["oauth-account-402"]], + ]); + + addFinalRequestLog("codex-402-move", Date.now() - 1_000, logCtx, 200, { + terminalStatus: "completed", closeReason: "terminal", + }, () => {}); + const recoveries = metrics.snapshot().split("\n").filter(line => line.startsWith("opencodex_recoveries_total{")); + expect(recoveries.find(line => line.includes('recovery="quota"'))?.endsWith(" 1")).toBe(true); + expect(recoveries.find(line => line.includes('recovery="rate_limit"'))?.endsWith(" 0") ?? true).toBe(true); + }); + }); + test("a quota message wrapped in HTTP 502 cools the account and retries an alternate", async () => { await withPoolHome(async () => { takeSpendHome(); From 1b241ea8816d96e00a076c8b203eb83a3daa21d4 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 17:42:03 +0900 Subject: [PATCH 04/10] fix(usage): price refused-then-served rows and keep client cancels unknown (carry #7233) Carried from #7233. Co-authored-by: Vadym O --- src/usage/summary.ts | 12 +++++-- structure/dashboard-and-usage.md | 2 +- .../usage/usage-partly-measured-cost.test.ts | 34 ++++++++++++++++++- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/usage/summary.ts b/src/usage/summary.ts index cc1406a7688..eefdf747bdf 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -665,8 +665,12 @@ function addEstimatedCost( entry: Pick, costInfo: EntryCostInfo, ): void { - if (entry.usageStatus === "unreported" || entry.usageStatus === "unsupported" - || (!entry.usage && !entry.attempts?.length)) { + // A row of attempts reads `unreported` when any leg lacks usage. Once one leg is measured the row + // is metered and the attempt-level pricing decides, matching the model and provider breakdowns. + const measuredAttempt = entry.attempts?.some(attempt => attempt.usage && isMeasuredStatus(attempt.usageStatus)); + if (!measuredAttempt + && (entry.usageStatus === "unreported" || entry.usageStatus === "unsupported" + || (!entry.usage && !entry.attempts?.length))) { totals.unmeteredRequests += 1; return; } @@ -822,7 +826,9 @@ function blankRequestCounts(): UsageRequestCounts { function spentNothing(attempt: NonNullable[number] | undefined): boolean { if (!attempt || attempt.usage) return false; if (attempt.sendCount === 0 || attempt.locallyAnswered === true) return true; - return attempt.status >= 400 && attempt.streamAborted !== true && attempt.firstOutputMs === undefined; + // A 499 is the client hanging up on a send upstream may already be generating for. + return attempt.status >= 400 && attempt.status !== 499 + && attempt.streamAborted !== true && attempt.firstOutputMs === undefined; } function bumpRequestCounts(counts: UsageRequestCounts, facts: number, amount = 1): void { diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 2752f7534c8..6b68dd0ae64 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -290,7 +290,7 @@ neither the old byte window nor the parsed-entry cap can discard an earlier pref surface filtering. `managementUsageMaxReadBytes` remains a recognized compatibility setting for bounded legacy readers, but it is not an accuracy limit or tuning knob for `GET /api/usage`. A Codex-surface response includes an `accounts` breakdown keyed by stable non-PII `accountLogLabel`; cards join it to the management account DTO for 30-day tokens, API-equivalent cost and coverage. New main-pool rows use `main`; legacy bare `openai` rows remain ambiguous. -A missing `usage.jsonl` returns a zeroed summary with 200 because a fresh install has no usage. Unmeasured requests remain distinct from measured zero through `measured / reported / unreported / unsupported / estimated` counts and their coverage totals. Cost estimates in `src/usage/cost.ts` price only `reported` or `estimated` usage, so a partly measured row is never given a confident, understated cost; per-row `pricedRequests` / `unpricedRequests` count a request once, unpriced when any attempt is unpriceable, while a usage-less attempt that made no physical send (`sendCount === 0` or `locallyAnswered`) or was refused with an HTTP status >= 400 before any output (no `streamAborted`, no `firstOutputMs`) is a known zero. A usage-less attempt whose response began, aborted, or has no error status may have been billed and stays unpriced. +A missing `usage.jsonl` returns a zeroed summary with 200 because a fresh install has no usage. Unmeasured requests remain distinct from measured zero through `measured / reported / unreported / unsupported / estimated` counts and their coverage totals. Cost estimates in `src/usage/cost.ts` price only `reported` or `estimated` usage, so a partly measured row is never given a confident, understated cost; per-row `pricedRequests` / `unpricedRequests` count a request once, unpriced when any attempt is unpriceable, while a usage-less attempt that made no physical send (`sendCount === 0` or `locallyAnswered`) or was refused with an HTTP status >= 400 other than a 499 client cancel before any output (no `streamAborted`, no `firstOutputMs`) is a known zero, and a row with any measured attempt counts priced or unpriced, never unmetered, whatever its aggregate status. A usage-less attempt whose response began, aborted, was cancelled by the client, or has no error status may have been billed and stays unpriced. Normal dashboard pages use the document as their vertical scroller. In `gui/src/styles.css`, body horizontal overflow is clipped without creating a second scrollport; the mobile open-drawer state also locks document overflow. The shared sidebar stays viewport-height through the end of Codex and Claude account pages. `gui/tests/shared-scroll-browser.ts` checks the built entry and App styles with short/long content, both themes, desktop chrome, collapsed navigation and mobile widths. The Usage tab renders that shape and the main Dashboard shows its 30-day summary. The 200-entry in-memory `requestLog` is not the aggregation source; the JSONL ledger is. Usage table scrollports in `gui/src/styles-usage-workspace.css` contain absolute screen-reader captions so long tables do not extend the outer document beyond the report; `gui/tests/usage-scroll-browser.ts` measures that boundary and last-row reachability at desktop and mobile widths. Ledger read failures instead return `500 { error: "read_failed" }`. Shared GUI usage admission reads that body before classifying HTTP failure and also rejects the legacy HTTP-200 envelope, so every shared cache retains its last valid report rather than fabricating zero totals. > Decision record: [ADR-0106](decisions/ADR-0106-usage-read-failure-contract.md) diff --git a/tests/usage/usage-partly-measured-cost.test.ts b/tests/usage/usage-partly-measured-cost.test.ts index 2bafc408a0d..2de764501e3 100644 --- a/tests/usage/usage-partly-measured-cost.test.ts +++ b/tests/usage/usage-partly-measured-cost.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; -import { computeEntryCost } from "../../src/usage/summary"; +import { computeEntryCost, summarizeUsage } from "../../src/usage/summary"; +import { aggregateAttemptUsage } from "../../src/server/request-log"; import type { PersistedUsageEntry } from "../../src/usage/log"; type Attempt = NonNullable[number]; @@ -31,3 +32,34 @@ test("an upstream refusal without usage remains a known zero alongside measured expect(result.isPriced).toBe(true); expect(result.costTotal).toBeGreaterThan(0); }); + +test("a refused retry and a measured send total the measured cost on the summary", () => { + const { usage: _usage, ...withoutUsage } = measured; + const attempts: Attempt[] = [{ ...withoutUsage, status: 429, usageStatus: "unreported" }, { ...measured, ordinal: 2 }]; + // The row request-log writes for a non-combo retry: status and usage aggregate its attempts. + const aggregate = aggregateAttemptUsage(attempts); + const entry = { ...row(attempts), timestamp: Date.UTC(2026, 5, 28), usageStatus: aggregate.status, usage: aggregate.usage }; + const { costTotal } = computeEntryCost(entry); + expect(summarizeUsage([entry], "30d", Date.UTC(2026, 5, 28, 12)).summary).toMatchObject({ + requests: 1, pricedRequests: 1, unpricedRequests: 0, unmeteredRequests: 0, estimatedCostUsd: costTotal, + }); +}); + +test("a send the client cancelled before output is an unknown cost, not a known zero", () => { + const { usage: _usage, ...withoutUsage } = measured; + const attempts: Attempt[] = [measured, { ...withoutUsage, ordinal: 2, status: 499, usageStatus: "unreported" }]; + const aggregate = aggregateAttemptUsage(attempts); + const entry = { ...row(attempts), timestamp: Date.UTC(2026, 5, 28), usageStatus: aggregate.status, usage: aggregate.usage }; + expect(computeEntryCost(entry).isPriced).toBe(false); + const { summary, models } = summarizeUsage([entry], "30d", Date.UTC(2026, 5, 28, 12)); + expect(summary).toMatchObject({ requests: 1, pricedRequests: 0, unpricedRequests: 1, unmeteredRequests: 0 }); + expect(models).toEqual([expect.objectContaining({ model: "gpt-5.5", pricedRequests: 0, unpricedRequests: 1 })]); +}); + +test("a row of known-zero refusals stays unmetered", () => { + const { usage: _usage, ...withoutUsage } = measured; + const attempts: Attempt[] = [{ ...withoutUsage, status: 429, usageStatus: "unreported" }]; + const entry = { ...row(attempts), timestamp: Date.UTC(2026, 5, 28) }; + expect(summarizeUsage([entry], "30d", Date.UTC(2026, 5, 28, 12)).summary) + .toMatchObject({ requests: 1, pricedRequests: 0, unpricedRequests: 0, unmeteredRequests: 1 }); +}); From 46117b33bd0ec5a0ad0b287c9264c5620be6bb99 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 17:43:18 +0900 Subject: [PATCH 05/10] docs: align carried account recovery and usage contracts Document the combo sibling peek, bounded server-tool input, Codex 402 quota recovery, and refused-then-served cost coverage. Co-authored-by: Vadym O --- docs-site/src/content/docs/guides/web-dashboard.md | 9 +++++++-- structure/dashboard-and-usage.md | 2 +- structure/providers-and-adapters.md | 2 +- structure/providers/generic-account-pool.md | 8 ++++++++ 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 77d8d4fcd7a..252f7650d39 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -149,6 +149,9 @@ its request and attempt failure cause is `transport-ambiguous`, with resend perm `refused-ambiguous`: the upstream may already have processed the turn. A genuine provider 429 remains `rate-limit` with resend permission `permitted`. +A Codex move to another account after HTTP 402 appears as **Account quota exhausted (402)** +in the attempt's recovery details and counts as a quota recovery in exported metrics. + Logs filters combine surface, intercepted requests, provider, exact model, status, time, speed, and conversation ID over the currently loaded request ring. Provider and model choices also include fallback attempts; model matching ignores case and surrounding spaces @@ -207,8 +210,10 @@ without a matching price or usable usage are counted as excluded beside that amo reports pricing coverage fields. Usage that was only partly measured (`unreported` or `unsupported`) is not priced, and one excluded part excludes the whole request from that row's priced count. A leg that reached no upstream, or that upstream refused with an HTTP error before any output, spent -nothing and does not; a leg whose response began (or ended without a status) but reported no usage -may have been billed, so it does. A row with only excluded requests shows an em dash with that count +nothing and does not; a client cancellation (499), or a leg whose response began or ended without +a status but reported no usage, may have been billed, so it does. A refusal followed by a measured +successful send contributes that send's cost to the summary even when the aggregate usage status +is `unreported`. A row with only excluded requests shows an em dash with that count instead of a zero-dollar estimate, and a displayed zero-dollar value is a confirmed priced estimate. An older proxy that reports no coverage fields keeps a bare em dash with no count, so missing coverage stays distinct from an all-excluded row and from a confirmed zero estimate. diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 6b68dd0ae64..e4023469732 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -381,7 +381,7 @@ roster the exporter itself iterates. The count is deliberately not restated here: it was written as eight, a bounded label value was added, and the documentation then contradicted the output it describes. A logical request increments once, physical sends sum the finalized attempt counts, and each distinct -recovery kind already retained on an attempt contributes once to its coarse class. The Antigravity validation-refusal sibling resend records `oauth-account-403`, which Logs labels from the shared recovery roster. +recovery kind already retained on an attempt contributes once to its coarse class. The Antigravity validation-refusal sibling resend records `oauth-account-403`, which Logs labels from the shared recovery roster. A Codex HTTP 402 account move records `oauth-account-402`, maps to `quota-exhausted` and the `quota` metric class, and has a localized Logs label; HTTP 429 remains a rate-limit recovery. `opencodex_request_failures_total` counts the cause the recorder derived and never re-derives one, and it labels a counter only: no histogram carries a cause. HTTP 200 never overrides a failed terminal event. Duration observes every valid finalized duration; TTFT observes diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 80adbf549b4..94e7e46ddfc 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -203,7 +203,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/openai-responses/transport.ts` | Shared Responses URL, configured auth and caller-header construction for ordinary requests and explicitly enabled custom native v1 compact. Its compact-handoff metadata copier uses the existing client-header allowlist, never credentials, through internal request construction and Codex auth materialization. Protocol capability never grants canonical identity; v2 trigger and native blob replay remain independently declared. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | | `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `reasoning-wire.ts`, `serialized-tool-call-content.ts`, `tool-call-validation.ts`, `tool-call-id-remint.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. `reasoning-wire.ts` applies explicit gateway-object and tool-bearing effort-omission declarations to both builders; absent declarations leave native raw forwarding unchanged. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | -| `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. For a Responses client the bridge delivers that event, and the same one from the Kiro adapter, as a terminal `response.failed` ([Adapter-to-Responses bridge](transports/responses.md#adapter-to-responses-bridge)). It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. Anthropic Fast is opt-in: the registry marks both Anthropic entries `fastOptIn`, and `src/providers/fast-opt-in.ts` (`providerFastSwitchOff`) keeps Fast off until `providers..fastEnabled` is `true`. An off switch is provider capability `false`, applied in the FastPolicy authority (`service-tier.ts`), `resolveModelPolicy`, and router registry enrichment, so no model-level Fast toggle, `--fast` row, or proxy-generated `speed` field is produced. Native Claude Messages passthrough still forwards a `speed` field the caller sends itself, outside the proxy Fast policy. `tests/adapters/anthropic/anthropic-fast-opt-in.test.ts` pins the default, the switch, and the management PATCH/GET. For `api.anthropic.com` only, the Claude client's typed web tools (`parsed._anthropicServerTools`) follow the function tools unchanged, minus the caller's `cache_control`; a function tool of the same name wins, so the web-search sidecar's `web_search` keeps priority and `web_fetch` is still carried. No beta header is added: both tools are GA. Each streamed `web_search_tool_result` becomes a `web_search_call_begin`/`web_search_call_end` pair (the query of its `server_tool_use` in the same response, hit URLs and titles, or `failed` for an error result), which the Claude outbound renders back as the same block pair; a search Anthropic defers beside a client `tool_use` has no result in that response and surfaces nothing, and the replayed pair is dropped by the inbound translation, so a result without `encrypted_content` never reaches Anthropic. A streamed `web_fetch_tool_result` opens and closes the same cell with `fetch` set (the fetched URL and Anthropic's result content, verbatim); the bridge records it as an `open_page` `web_search_call` carrying `fetch_result`, and the Claude outbound renders it back as `server_tool_use` `web_fetch` + that `web_fetch_tool_result`, not counted in `web_search_requests`. Citations are not relayed. `tests/claude-integration/claude-translated-server-tools.test.ts` pins the wire, the sidecar priority, the bridge's client stream and JSON, and the direct encoder's stream. | +| `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. For a Responses client the bridge delivers that event, and the same one from the Kiro adapter, as a terminal `response.failed` ([Adapter-to-Responses bridge](transports/responses.md#adapter-to-responses-bridge)). It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. Anthropic Fast is opt-in: the registry marks both Anthropic entries `fastOptIn`, and `src/providers/fast-opt-in.ts` (`providerFastSwitchOff`) keeps Fast off until `providers..fastEnabled` is `true`. An off switch is provider capability `false`, applied in the FastPolicy authority (`service-tier.ts`), `resolveModelPolicy`, and router registry enrichment, so no model-level Fast toggle, `--fast` row, or proxy-generated `speed` field is produced. Native Claude Messages passthrough still forwards a `speed` field the caller sends itself, outside the proxy Fast policy. `tests/adapters/anthropic/anthropic-fast-opt-in.test.ts` pins the default, the switch, and the management PATCH/GET. For `api.anthropic.com` only, the Claude client's typed web tools (`parsed._anthropicServerTools`) follow the function tools unchanged, minus the caller's `cache_control`; a function tool of the same name wins, so the web-search sidecar's `web_search` keeps priority and `web_fetch` is still carried. No beta header is added: both tools are GA. Each streamed `web_search_tool_result` becomes a `web_search_call_begin`/`web_search_call_end` pair (the query of its `server_tool_use` in the same response, hit URLs and titles, or `failed` for an error result), which the Claude outbound renders back as the same block pair; a search Anthropic defers beside a client `tool_use` has no result in that response and surfaces nothing, and the replayed pair is dropped by the inbound translation, so a result without `encrypted_content` never reaches Anthropic. A streamed `web_fetch_tool_result` opens and closes the same cell with `fetch` set (the fetched URL and Anthropic's result content, verbatim); the bridge records it as an `open_page` `web_search_call` carrying `fetch_result`, and the Claude outbound renders it back as `server_tool_use` `web_fetch` + that `web_fetch_tool_result`, not counted in `web_search_requests`. Streamed server-tool input retains at most 4,096 characters, slicing each incoming fragment to the remaining capacity; an oversized input loses its query or URL label without losing the result. Citations are not relayed. `tests/claude-integration/claude-translated-server-tools.test.ts` pins the wire, the sidecar priority, the bridge's client stream and JSON, and the direct encoder's stream. | | `src/adapters/anthropic-model-contract.ts` | Per-family Anthropic Messages wire rules, shared by the adapter and the web-search and vision sidecars, measured live on 2026-09-29, with Haiku 5.5 documented on 2026-10-08: adaptive vs budget thinking (Haiku 5.5+ is adaptive); explicit `thinking: disabled` (Sonnet 5.0 up to but excluding 5.5, Haiku 5.5+ without effort); the `between_tools` floor that replaces it on Sonnet 5.5+ (sent without an effort, since xhigh/max reject it); forced `tool_choice` downgraded to `auto` on Opus 5.5, Fable 5.1+ and Sonnet 5.5+; `temperature`/`top_p` dropped on Opus 4.7+, Sonnet 5+, Haiku 5.5+ and every Fable, which 400 on any non-default value; `top_p` dropped when sent with `temperature` on the 4.5/4.6 families, which take either alone but not both; and sidecar thinking-off fields, a low effort with no `thinking` for Opus 5.5 and Fable, which reject both off switches. Dotted Bedrock ids do not parse as a family, and the Messages-native passthrough forwards caller fields unchanged. `tests/adapters/anthropic/anthropic-sonnet-5-5-contract.test.ts` and `tests/adapters/anthropic/anthropic-haiku-5-5-contract.test.ts` pin the family table. | | `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. A caller-declared strict tool selects `functionCallingConfig.mode: "VALIDATED"` in place of the absent-choice default; `NONE`, `ANY` and a forced-name choice are stronger constraints the caller asked for and are never overwritten. | | `src/adapters/unique-tool-call-ids.ts` | Request-scoped tool-call-id uniqueness for every `openai-chat` provider. An upstream that mints an id from the call's position in its response repeats `call-0-0` on every turn; a Messages client has already paired that id, drops the duplicate, and is left with a call that has no result, so the turn reads as empty and the model re-issues it indefinitely. Only a **repeat** is rewritten — the first occurrence stays byte-identical, leaving prompt-cache keys, reasoning-replay lookups and already-unique upstreams untouched. The ids to avoid come from the caller's history, captured in `buildRequest` (the only point that sees it) and applied at emission, never at ingestion: ingestion matches streamed deltas against the id upstream sent, so rewriting there would strip a pending call of its identity mid-stream. A repeat takes a `-` suffix, never `_`, because `_` reads as a batch sub-call of ``; occupied-set search resumes by suffix width and retained base prefix, including when siblings converge as `-9` becomes `-10`. Covered by `tests/adapters/openai/openai-chat-tool-call-id-remint.test.ts`. | diff --git a/structure/providers/generic-account-pool.md b/structure/providers/generic-account-pool.md index 1ba25dc9ce3..26846e9af1b 100644 --- a/structure/providers/generic-account-pool.md +++ b/structure/providers/generic-account-pool.md @@ -23,3 +23,11 @@ are excluded. `tests/oauth/generic-session-affinity.test.ts` and `tests/server/server-generic-oauth-affinity.test.ts` exercise these boundaries with synthetic accounts. `src/types/provider.ts` declares the optional `sessionAffinity` mode; omission keeps existing behavior. + +## Combo sibling probes + +`src/server/responses/core-combo.ts` asks `src/oauth/generic-account-failover.ts` whether a refused +account has an eligible sibling before cooling the whole target. This probe records the refused +account's cooldown but passes `commit: false`, so it only peeks at a round-robin pool. An unsent +sibling does not consume a sticky turn. Dispatching callers retain the default committed pick. +`tests/oauth/generic-oauth-combo-account-cooldown.test.ts` covers this distinction. From b158e8cc10545ad3827d5ec0557301f65dbaa91b Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 17:52:17 +0900 Subject: [PATCH 06/10] docs: distinguish Codex quota and rate recovery kinds Correct the Responses transport owner contract for the carried HTTP 402 recovery label. Co-authored-by: Vadym O --- structure/transports/responses.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structure/transports/responses.md b/structure/transports/responses.md index b1e586d3e89..771530b8d2f 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -434,7 +434,7 @@ The continuation-note and continuation-wire tests establish prompt and wire cont Reusable helpers live in `core-auth.ts`, `core-codex-account.ts`, `core-combo.ts`, `core-combo-failure.ts`, `core-combo-native.ts`, `combo-requested-effort.ts`, `core-errors.ts`, `core-lifetime.ts`, `core-normalize.ts`, `core-opaque-recovery.ts` and `core-replay.ts`. `core-options.ts` owns the public option types -and small composition contracts, including [finite model-refusal evidence](../providers-and-adapters.md#combo-model-refusal-evidence). Existing public helper names are re-exported by `core.ts`. After an admitted Anthropic OAuth pool rotation, `adapter-dispatch.ts` rebinds reasoning replay to the committed credential snapshot; `adapter-continuation.ts` rebinds both its continuation clone and the outer request that persists the response. The replacement send strips the previous account's private state, while persisted ownership and the next turn follow the serving account. Codex alternate-account sends in `core-codex-account.ts` record `oauth-account-429` for quota recovery and `transient-5xx` for transient recovery, preserving the first response's wire status when sealing its attempt; gated-model moves keep their existing unattributed kind. +and small composition contracts, including [finite model-refusal evidence](../providers-and-adapters.md#combo-model-refusal-evidence). Existing public helper names are re-exported by `core.ts`. After an admitted Anthropic OAuth pool rotation, `adapter-dispatch.ts` rebinds reasoning replay to the committed credential snapshot; `adapter-continuation.ts` rebinds both its continuation clone and the outer request that persists the response. The replacement send strips the previous account's private state, while persisted ownership and the next turn follow the serving account. Codex alternate-account sends in `core-codex-account.ts` record `oauth-account-402` for HTTP 402 quota recovery, `oauth-account-429` for HTTP 429 rate-limit recovery, and `transient-5xx` for transient recovery, preserving the first response's wire status when sealing its attempt; gated-model moves keep their existing unattributed kind. Adapter construction remains with the existing registry; `fetch-helpers.ts` remains a leaf. For Kiro OAuth with load settings, `request-transport.ts` acquires a lease on the admitted account and transfers it before a reactive replacement send; cancellation permanently fences the request holder so recovery cannot install a late lease after abort cleanup. `core.ts` and `core-lifetime.ts` release the lease on returned-body completion, error, or cancellation, outside the inner admission `finally`. Mutable values are not copied across phases. A phase exposes only the values consumed by later From 96366590e1a913c5696c0e8042701cdd330d10fb Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 18:10:53 +0900 Subject: [PATCH 07/10] test(usage): cover missing price after a refused attempt Co-authored-by: Vadym O --- .../usage/usage-partly-measured-cost.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/usage/usage-partly-measured-cost.test.ts b/tests/usage/usage-partly-measured-cost.test.ts index 2de764501e3..2bcbc708648 100644 --- a/tests/usage/usage-partly-measured-cost.test.ts +++ b/tests/usage/usage-partly-measured-cost.test.ts @@ -56,6 +56,26 @@ test("a send the client cancelled before output is an unknown cost, not a known expect(models).toEqual([expect.objectContaining({ model: "gpt-5.5", pricedRequests: 0, unpricedRequests: 1 })]); }); +test("a refusal cannot hide a measured attempt with no matching price", () => { + const { usage: _usage, ...withoutUsage } = measured; + const attempts: Attempt[] = [ + { ...withoutUsage, status: 429, usageStatus: "unreported" }, + { ...measured, ordinal: 2, provider: "unknown-provider", model: "unknown-model" }, + { ...measured, ordinal: 3 }, + ]; + const aggregate = aggregateAttemptUsage(attempts); + expect(aggregate.status).toBe("unreported"); + const entry = { ...row(attempts), timestamp: Date.UTC(2026, 5, 28), usageStatus: aggregate.status, usage: aggregate.usage }; + const cost = computeEntryCost(entry); + expect(cost.attemptEstimates?.map(Boolean)).toEqual([false, false, true]); + expect(cost.costTotal).toBeGreaterThan(0); + const { summary } = summarizeUsage([entry], "30d", Date.UTC(2026, 5, 28, 12)); + expect(summary).toMatchObject({ + requests: 1, pricedRequests: 0, unpricedRequests: 1, unmeteredRequests: 0, estimatedCostUsd: 0, + }); + expect(cost.isPriced).toBe(false); +}); + test("a row of known-zero refusals stays unmetered", () => { const { usage: _usage, ...withoutUsage } = measured; const attempts: Attempt[] = [{ ...withoutUsage, status: 429, usageStatus: "unreported" }]; From 9f2de496727f24e51abbc2369ca829860d370288 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 23:33:25 +0900 Subject: [PATCH 08/10] fix(usage): keep measured attempts without prices unpriced Co-authored-by: Vadym O --- src/usage/summary.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/usage/summary.ts b/src/usage/summary.ts index eefdf747bdf..70bb6e67d86 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -303,9 +303,9 @@ export function computeEntryCost(entry: PersistedUsageEntry): EntryCostInfo { isPriced = true; } } - // An unmeasured leg that may have generated billed tokens leaves the total unknown, not short. + // Any leg without a price leaves the total unknown unless it provably cost nothing. const unknownCost = entry.attempts.some((attempt, index) => - !attemptEstimates[index] && (!attempt.usage || !isMeasuredStatus(attempt.usageStatus)) && !spentNothing(attempt)); + !attemptEstimates[index] && !spentNothing(attempt)); return { tier, estimate: null, attemptEstimates, costTotal, isPriced: isPriced && !unknownCost }; } const estimate = estimateRequestCost({ From 97bc2247600f70ade10cd3797fe3bcf2330e355b Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 23:53:17 +0900 Subject: [PATCH 09/10] test(usage): preserve partial-cost attribution across refused attempts Restore the established mixed measured-attempt pricing contract and compare the same usage with and without a leading refusal. Co-authored-by: Vadym O --- src/usage/summary.ts | 4 +-- .../usage/usage-partly-measured-cost.test.ts | 34 ++++++++++++++----- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/usage/summary.ts b/src/usage/summary.ts index 70bb6e67d86..eefdf747bdf 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -303,9 +303,9 @@ export function computeEntryCost(entry: PersistedUsageEntry): EntryCostInfo { isPriced = true; } } - // Any leg without a price leaves the total unknown unless it provably cost nothing. + // An unmeasured leg that may have generated billed tokens leaves the total unknown, not short. const unknownCost = entry.attempts.some((attempt, index) => - !attemptEstimates[index] && !spentNothing(attempt)); + !attemptEstimates[index] && (!attempt.usage || !isMeasuredStatus(attempt.usageStatus)) && !spentNothing(attempt)); return { tier, estimate: null, attemptEstimates, costTotal, isPriced: isPriced && !unknownCost }; } const estimate = estimateRequestCost({ diff --git a/tests/usage/usage-partly-measured-cost.test.ts b/tests/usage/usage-partly-measured-cost.test.ts index 2bcbc708648..4340fa271ce 100644 --- a/tests/usage/usage-partly-measured-cost.test.ts +++ b/tests/usage/usage-partly-measured-cost.test.ts @@ -63,17 +63,33 @@ test("a refusal cannot hide a measured attempt with no matching price", () => { { ...measured, ordinal: 2, provider: "unknown-provider", model: "unknown-model" }, { ...measured, ordinal: 3 }, ]; - const aggregate = aggregateAttemptUsage(attempts); - expect(aggregate.status).toBe("unreported"); - const entry = { ...row(attempts), timestamp: Date.UTC(2026, 5, 28), usageStatus: aggregate.status, usage: aggregate.usage }; - const cost = computeEntryCost(entry); + const entryFor = (legs: Attempt[]): PersistedUsageEntry => { + const aggregate = aggregateAttemptUsage(legs); + return { ...row(legs), timestamp: Date.UTC(2026, 5, 28), usageStatus: aggregate.status, usage: aggregate.usage }; + }; + const withRefusal = entryFor(attempts); + const withoutRefusal = entryFor(attempts.slice(1)); + expect(withRefusal.usageStatus).toBe("unreported"); + expect(withoutRefusal.usageStatus).toBe("reported"); + const cost = computeEntryCost(withRefusal); + const baselineCost = computeEntryCost(withoutRefusal); + expect(baselineCost.isPriced).toBe(true); + expect(baselineCost.costTotal).toBeGreaterThan(0); expect(cost.attemptEstimates?.map(Boolean)).toEqual([false, false, true]); - expect(cost.costTotal).toBeGreaterThan(0); - const { summary } = summarizeUsage([entry], "30d", Date.UTC(2026, 5, 28, 12)); - expect(summary).toMatchObject({ - requests: 1, pricedRequests: 0, unpricedRequests: 1, unmeteredRequests: 0, estimatedCostUsd: 0, + expect({ ...cost, attemptEstimates: cost.attemptEstimates?.slice(1) }).toEqual(baselineCost); + const summarize = (entry: PersistedUsageEntry) => summarizeUsage([entry], "30d", Date.UTC(2026, 5, 28, 12)); + const actual = summarize(withRefusal); + const baseline = summarize(withoutRefusal); + const classification = ({ pricedRequests, unpricedRequests, unmeteredRequests, estimatedCostUsd }: typeof actual.summary) => + ({ pricedRequests, unpricedRequests, unmeteredRequests, estimatedCostUsd }); + expect(classification(baseline.summary)).toEqual({ + pricedRequests: 1, unpricedRequests: 0, unmeteredRequests: 0, estimatedCostUsd: baselineCost.costTotal, }); - expect(cost.isPriced).toBe(false); + expect(classification(actual.summary)).toEqual(classification(baseline.summary)); + // The refusal adds a physical attempt; every other per-model field stays the same. + const models = (result: typeof actual) => result.models.map(({ attemptCount: _attemptCount, ...model }) => model) + .sort((a, b) => `${a.provider}/${a.model}`.localeCompare(`${b.provider}/${b.model}`)); + expect(models(actual)).toEqual(models(baseline)); }); test("a row of known-zero refusals stays unmetered", () => { From 069c1da25565ed0024d5a22e93d2d9099e824ec3 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 17 Oct 2026 00:04:09 +0900 Subject: [PATCH 10/10] test(usage): compare refusal-invariant cost and token attribution Keep attempt-observation counters outside the pricing equivalence check. Co-authored-by: Vadym O --- tests/usage/usage-partly-measured-cost.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/usage/usage-partly-measured-cost.test.ts b/tests/usage/usage-partly-measured-cost.test.ts index 4340fa271ce..29bb87f49f9 100644 --- a/tests/usage/usage-partly-measured-cost.test.ts +++ b/tests/usage/usage-partly-measured-cost.test.ts @@ -86,8 +86,11 @@ test("a refusal cannot hide a measured attempt with no matching price", () => { pricedRequests: 1, unpricedRequests: 0, unmeteredRequests: 0, estimatedCostUsd: baselineCost.costTotal, }); expect(classification(actual.summary)).toEqual(classification(baseline.summary)); - // The refusal adds a physical attempt; every other per-model field stays the same. - const models = (result: typeof actual) => result.models.map(({ attemptCount: _attemptCount, ...model }) => model) + // Compare model usage and pricing; the refusal adds an unreported physical attempt. + const models = (result: typeof actual) => result.models.map(({ provider, model, requests, inputTokens, + outputTokens, totalTokens, pricedRequests, unpricedRequests, priceCoverageRatio, estimatedCostUsd }) => + ({ provider, model, requests, inputTokens, outputTokens, totalTokens, + pricedRequests, unpricedRequests, priceCoverageRatio, estimatedCostUsd })) .sort((a, b) => `${a.provider}/${a.model}`.localeCompare(`${b.provider}/${b.model}`)); expect(models(actual)).toEqual(models(baseline)); });