diff --git a/devlog/_plan/260912_combo_carry/000_plan.md b/devlog/_plan/260912_combo_carry/000_plan.md new file mode 100644 index 0000000000..80e1cd6d07 --- /dev/null +++ b/devlog/_plan/260912_combo_carry/000_plan.md @@ -0,0 +1,15 @@ +# Combo quota carry roadmap + +Carry #4090 followed by the editor delta of #4105 so routing uses explicit inference evidence for the current credential and Combo editing only blocks on fresh confirmed exhaustion. Preserve luvs01 contribution and latest omitted-projection correction. + +Satisfy-spec HOTL requested by the parent coordinator. Scope: runtime quota publication/cache/selection; management projection; Combo editor; existing regression tests; translated guides and structure owners. No merge, original closure, release, service or config changes. No local suites of any size, build, typecheck or install. Existing GitHub account/tool scope only; no user-set token/time/agent bound. Main owns implementation, read-only agents audit. + +Order: roadmap docs-only cycle -> runtime carry -> editor carry -> final cumulative hosted verification. Source commits and executable diffs are in 010 and 020. Current original heads: #4090 c061316722cddf716c830fdc60bd74d237c78af5; #4105 50296f181eb41b582c3f7c04f80335ab7f78bab3. Skip #4105 prerequisite 6292b3c; carry af2ceb8 and 50296f1 above the latest #4090. + +Verification: git diff --check (text only), source inspection, existing .github/workflows/ci.yml workflow_dispatch at final tip with full lane if supported. Local product validation NOT RUN by explicit user instruction. Conditional acceptance: pool/OAuth/changed credential/static auth/display-only -> no provider veto; explicit same-credential exhausted inference -> veto; stale/malformed/missing projection -> editor remains enabled; fresh exhausted every target -> Save/Create disabled; expiry/visibility/refresh -> re-evaluate. + +DONE means PR chain, final head and hosted run evidence, matching GUI capture and explicit security review are durable in .tmp/combo-handoff/HANDOFF.md. Unresolved external gates are reported with evidence, never weakened into success. Parent owns merge and original disposition. Main reclaims failed audit transport but records the independence gap. Architect-specific role is unavailable in exposed schema; no installation or fabricated role is attempted. + +Private review working notes stay in .tmp; public roadmap only cites already-public source changes. SoT: current structure runtime and GUI/management owners replace retired structure/04_transports-and-sidecars.md. Every mapped owner is reviewed for applicability and gets a scoped pointer where necessary. + +Roadmap audit: independent inherited-model reviewer Linnaeus returned VERDICT: PASS, zero blockers. Carry boundaries verified; use genuinely bound evidence for changed-key regression because test-only seeding bypasses binding comparison. Dispatch runner labels must be recorded truthfully. Supported independent design consultation is in flight under the user's clarified instruction; native architect role is not claimed. diff --git a/devlog/_plan/260912_combo_carry/010_runtime.md b/devlog/_plan/260912_combo_carry/010_runtime.md new file mode 100644 index 0000000000..c8778af957 --- /dev/null +++ b/devlog/_plan/260912_combo_carry/010_runtime.md @@ -0,0 +1,884 @@ +# runtime carry + +MODIFY exactly the paths in this public source diff. Apply each original commit in order with attribution. Resolve retired structure document into current runtime.md and gui-and-management-api.md; never restore the retired file. Earlier phase dependency: roadmap. + +Before/after source contract (review against current dev at P; source diff is the executable carry input): + +```diff +diff --git a/docs-site/src/content/docs/fr/guides/combos.md b/docs-site/src/content/docs/fr/guides/combos.md +index 9785135d53..9434a1e98a 100644 +--- a/docs-site/src/content/docs/fr/guides/combos.md ++++ b/docs-site/src/content/docs/fr/guides/combos.md +@@ -190,6 +190,8 @@ indique la réinitialisation de fenêtre à venir la plus proche (cinq heures, h + Le fournisseur dont le quota se renouvelle en premier est ainsi sollicité. Les cibles dépourvues de données de quota + récentes et les égalités conservent l’ordre de configuration. `weight` et `stickyLimit` n’affectent pas cette stratégie. + ++Ce classement et l’exclusion des fournisseurs avant l’envoi exigent des limites récentes d’inférence de modèles applicables dans leur ensemble à l’unique clé API actuelle. Les résumés OAuth ou du compte courant, les routes transmettant les identifiants de l’appelant, les configurations à plusieurs clés et les instantanés dont les identifiants ou la destination ont changé servent uniquement à l’affichage pour cette décision préalable. Il en va de même lorsque les en-têtes `Authorization`, `x-api-key` ou `x-goog-api-key` remplacent les identifiants ; les fenêtres réservées à la recherche ou à MCP sont exclues. Si aucune cible admissible n’a de réinitialisation applicable, l’ordre de configuration prévaut. La sélection des comptes et les nouvelles tentatives appliquent toujours leurs limites habituelles. ++ + ## Que se passe-t-il lorsqu'une cible échoue + + Les échecs d’un combo se répartissent entre ceux qui entraînent un **basculement** et les échecs **terminaux**. +diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md +index db94da045f..c7d076d9b7 100644 +--- a/docs-site/src/content/docs/guides/combos.md ++++ b/docs-site/src/content/docs/guides/combos.md +@@ -202,6 +202,8 @@ shows the soonest upcoming window reset (five-hour, weekly, monthly, or custom). + provider that refreshes first. Targets without fresh quota data, and ties, keep configuration + order. Weights and `stickyLimit` do not affect this strategy. + ++This ranking and provider exclusion before dispatch require fresh model-inference limits that apply to the current single API key as a whole. OAuth/current-account summaries, caller-forward routes, multiple keys, and snapshots with changed credentials or destinations are display-only for this early decision. The same applies when `Authorization`, `x-api-key`, or `x-goog-api-key` headers override credentials; search-only and MCP-only windows are excluded. If no eligible target has an applicable reset, configuration order wins. Account selection and retries still enforce their normal limits. ++ + ## What happens when a target fails + + Combo failures are divided into **hop** failures and **terminal** failures. +diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md +index 655dae2232..f6eca53214 100644 +--- a/docs-site/src/content/docs/ja/guides/combos.md ++++ b/docs-site/src/content/docs/ja/guides/combos.md +@@ -113,6 +113,8 @@ ocx combo set balanced \ + + `reset-window` は、キャッシュされたプロバイダーのクォータスナップショットで、次回のウィンドウリセット(5 時間、週次、月次、またはカスタム)が最も早い適格なターゲットへ、各リクエストをルーティングします。これにより、最初にクォータが補充されるプロバイダーを先に使用します。新しいクォータデータがないターゲットと、リセット時刻が同じターゲットでは、構成順序が維持されます。`weight` と `stickyLimit` はこの戦略に影響しません。 + ++この順位付けと送信前のプロバイダー除外には、現在の単一 API キー全体に適用される最新のモデル推論制限が必要です。OAuth/現在のアカウントの概要、呼び出し元の認証情報を転送するルート、複数キー、認証情報や送信先が変わったスナップショットは、この事前判断では表示専用です。`Authorization`、`x-api-key`、`x-goog-api-key` ヘッダーで認証情報を上書きする場合も同様で、検索専用および MCP 専用ウィンドウは対象外です。適用可能なリセット情報を持つ適格な対象がなければ、設定順序を使用します。アカウント選択と再試行には引き続き通常の制限が適用されます。 ++ + ## ターゲットが失敗すると何が起こるか + + コンボ障害は、**ホップ** 障害と **ターミナル** 障害に分類されます。 +diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md +index 633feb838b..71e557f25c 100644 +--- a/docs-site/src/content/docs/ko/guides/combos.md ++++ b/docs-site/src/content/docs/ko/guides/combos.md +@@ -119,6 +119,8 @@ ocx combo set balanced \ + + `reset-window`는 캐시된 공급자 할당량 스냅샷에서 가장 가까운 다음 기간 재설정(5시간, 주간, 월간 또는 사용자 지정)이 표시되는 적합한 대상으로 각 요청을 라우팅합니다. 이렇게 하면 가장 먼저 새로 충전되는 공급자를 사용합니다. 최신 할당량 데이터가 없는 대상과 동률인 대상은 설정 순서를 유지합니다. `weight`와 `stickyLimit`은 이 전략에 영향을 주지 않습니다. + ++이 순위 결정과 전송 전 공급자 제외에는 현재 단일 API 키의 전체 모델 추론에 적용되는 최신 한도 정보가 필요합니다. OAuth·현재 계정 요약, 호출자 인증을 전달하는 경로, 여러 키, 인증 정보나 목적지가 달라진 스냅샷은 이 사전 판단에서 표시 용도로만 사용합니다. `Authorization`, `x-api-key`, `x-goog-api-key` 헤더로 인증을 덮어쓰는 경우도 같으며, 검색 전용·MCP 전용 기간은 제외합니다. 적격 대상 중 적용 가능한 초기화 정보가 없으면 설정 순서를 따릅니다. 실제 계정 선택과 재시도에는 기존 제한이 계속 적용됩니다. ++ + ## 대상 실패 시 동작 + + 콤보 실패는 **홉** 실패와 **종결** 실패로 나뉩니다. +diff --git a/docs-site/src/content/docs/ru/guides/combos.md b/docs-site/src/content/docs/ru/guides/combos.md +index 3d4820e521..868416ae5b 100644 +--- a/docs-site/src/content/docs/ru/guides/combos.md ++++ b/docs-site/src/content/docs/ru/guides/combos.md +@@ -150,6 +150,8 @@ ocx combo set balanced \ + данных о квоте, а также цели с одинаковым временем сброса сохраняют порядок конфигурации. Значения + `weight` и `stickyLimit` не влияют на эту стратегию. + ++Для этого ранжирования и исключения провайдеров до отправки нужны свежие лимиты инференса моделей, применимые к единственному текущему API-ключу в целом. Сводки OAuth и текущего аккаунта, маршруты с передачей учётных данных вызывающей стороны, несколько ключей и снимки с изменившимися учётными данными или адресом назначения служат только для отображения при этом предварительном решении. То же относится к переопределению учётных данных заголовками `Authorization`, `x-api-key` или `x-goog-api-key`; окна только для поиска или MCP исключаются. Если ни у одной допустимой цели нет подходящего времени сброса, используется порядок конфигурации. При выборе аккаунта и повторных попытках по-прежнему действуют обычные ограничения. ++ + ## Что происходит, когда цель сбоит + + Сбои в combo делятся на **hop**-сбои и **terminal**-сбои. +diff --git a/docs-site/src/content/docs/tr/guides/combos.md b/docs-site/src/content/docs/tr/guides/combos.md +index 8b67170068..520c157e83 100644 +--- a/docs-site/src/content/docs/tr/guides/combos.md ++++ b/docs-site/src/content/docs/tr/guides/combos.md +@@ -218,6 +218,8 @@ kullanılır. Güncel kota verisi bulunmayan hedeflerde ve eşitliklerde + yapılandırma sırası korunur. `weight` değerleri ve `stickyLimit` bu stratejiyi + etkilemez. + ++Bu sıralama ve gönderim öncesi sağlayıcı elemesi, mevcut tek API anahtarının model çıkarımı kullanımının tamamına uygulanan güncel sınırlara dayanır. OAuth veya geçerli hesap özetleri, çağıranın kimlik bilgilerini ileten rotalar, birden fazla anahtar ve kimlik bilgileri ya da hedefi değişmiş anlık görüntüler, bu ön kararda yalnızca görüntüleme amaçlıdır. `Authorization`, `x-api-key` veya `x-goog-api-key` başlıkları kimlik bilgilerini geçersiz kıldığında da aynı kural uygulanır; yalnızca arama veya MCP için olan pencereler hariç tutulur. Uygun hedeflerin hiçbirinde geçerli sıfırlama bilgisi yoksa yapılandırma sırası kullanılır. Hesap seçimi ve yeniden denemelerde normal sınırlar uygulanmaya devam eder. ++ + ## Bir hedef başarısız olduğunda ne olur? + + Kombo hataları **atlama (hop)** hataları ve **uç (terminal)** hatalar olarak +diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md +index fea189deb3..d84efca472 100644 +--- a/docs-site/src/content/docs/zh-cn/guides/combos.md ++++ b/docs-site/src/content/docs/zh-cn/guides/combos.md +@@ -139,6 +139,8 @@ ocx combo set balanced \ + + `reset-window` 会将每个请求路由到合格目标中,其缓存的提供商额度快照显示下一个窗口最早重置者(五小时、每周、每月或自定义窗口)。这样会优先消耗最先刷新额度的提供商。没有最新额度数据的目标以及并列目标会保持配置顺序。`weight` 和 `stickyLimit` 不影响此策略。 + ++此排序和发送前的提供商排除,需要适用于当前单个 API 密钥全部模型推理的最新限额信息。OAuth/当前账户摘要、转发调用方凭据的路由、多密钥以及凭据或目标地址已改变的快照,在这项提前判断中仅供显示。通过 `Authorization`、`x-api-key` 或 `x-goog-api-key` 请求头覆盖凭据时也适用相同规则;仅用于搜索或 MCP 的窗口不参与判断。如果所有符合条件的目标都没有适用的重置时间,则按配置顺序选择。实际账户选择和重试仍执行正常限制。 ++ + ## 目标失败时会发生什么 + + combo 失败分为 **跳转** 失败和 **终止** 失败。 +diff --git a/docs-site/src/content/docs/zh-tw/guides/combos.md b/docs-site/src/content/docs/zh-tw/guides/combos.md +index bb8ef901f9..d82b399e6f 100644 +--- a/docs-site/src/content/docs/zh-tw/guides/combos.md ++++ b/docs-site/src/content/docs/zh-tw/guides/combos.md +@@ -154,6 +154,8 @@ ocx combo set balanced \ + + `reset-window` 將每個請求路由至快取供應商配額快照顯示下一個時段最早重設的合格目標(五小時、每週、每月或自訂)。這會優先使用最早重新取得額度的供應商。沒有最新配額資料的目標,以及發生平手時,皆維持設定順序。`weight` 與 `stickyLimit` 不影響此策略。 + ++此排序與傳送前的供應商排除,需要適用於目前單一 API 金鑰全部模型推論的最新限額資訊。OAuth/目前帳戶摘要、轉送呼叫者憑證的路由、多金鑰,以及憑證或目的地位址已變更的快照,在這項預先判斷中僅供顯示。透過 `Authorization`、`x-api-key` 或 `x-goog-api-key` 標頭覆寫憑證時也適用相同規則;僅供搜尋或 MCP 使用的時段不參與判斷。若所有符合條件的目標都沒有適用的重設時間,則依設定順序選擇。實際帳戶選擇與重試仍套用一般限制。 ++ + ## 目標失敗時會發生什麼 + + Combo 失敗分為**跳轉**失敗與**終端**失敗。 +diff --git a/src/combos/resolve.ts b/src/combos/resolve.ts +index 71ea750b6e..9627bf396d 100644 +--- a/src/combos/resolve.ts ++++ b/src/combos/resolve.ts +@@ -1,7 +1,6 @@ + import type { OcxComboTarget, OcxConfig } from "../types"; +-import { getCachedProviderQuota } from "../providers/quota-routing-cache"; ++import { getCachedProviderRoutingQuota } from "../providers/quota-routing-cache"; + import type { ProviderQuota } from "../providers/quota-types"; +-import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; + import { sleepWithAbort } from "../lib/upstream-retry"; + import { + coolComboTarget, +@@ -65,9 +64,7 @@ function targetProviderIsUsable(config: OcxConfig, target: OcxComboTarget, now: + if (!Object.hasOwn(config.providers, target.provider)) return false; + const provider = config.providers[target.provider]; + if (!provider || provider.disabled === true) return false; +- // Native account selection owns model-scoped quota; a provider summary cannot veto it. +- return isCanonicalOpenAiForwardProvider(provider) +- || !cachedProviderQuotaIsExhausted(getCachedProviderQuota(target.provider, now), now); ++ return !cachedProviderQuotaIsExhausted(getCachedProviderRoutingQuota(target.provider, provider, now), now); + } + + function quotaWindowExhausted(percent: number | undefined, resetAt: number | undefined, now: number): boolean { +@@ -181,6 +178,7 @@ function smoothWeightedIndex( + * unknown (Infinity). + */ + function resetWindowIndex( ++ config: OcxConfig, + targets: Required[], + eligible: (target: Required) => boolean, + now = Date.now(), +@@ -190,7 +188,9 @@ function resetWindowIndex( + for (let index = 0; index < targets.length; index++) { + const target = targets[index]!; + if (!eligible(target)) continue; +- const remaining = quotaResetRemainingMs(getCachedProviderQuota(target.provider, now), now); ++ const remaining = quotaResetRemainingMs( ++ getCachedProviderRoutingQuota(target.provider, config.providers[target.provider], now), now, ++ ); + // Strict comparison deliberately retains configured order for ties, + // including the no-snapshot fallback where every value is Infinity. + if (selected < 0 || remaining < smallestRemaining) { +@@ -276,7 +276,7 @@ export function pickComboTarget( + } + } + } else if (combo.strategy === "reset-window") { +- targetIndex = resetWindowIndex(combo.targets, eligible, now); ++ targetIndex = resetWindowIndex(config, combo.targets, eligible, now); + } else { + targetIndex = combo.targets.findIndex(eligible); + } +diff --git a/src/providers/quota-routing-cache.ts b/src/providers/quota-routing-cache.ts +index 065d7338ca..1e45d60065 100644 +--- a/src/providers/quota-routing-cache.ts ++++ b/src/providers/quota-routing-cache.ts +@@ -1,15 +1,53 @@ ++import { createHash } from "node:crypto"; ++import type { OcxProviderConfig } from "../types"; + import type { ProviderQuota, ProviderQuotaReport } from "./quota"; ++import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./key-store"; ++import { getProviderRegistryEntry } from "./registry"; + +-const quotaCache = new Map(); ++export interface ProviderQuotaRoutingEvidence { ++ quota: ProviderQuota; ++ binding: string; ++} ++ ++type CachedQuota = { ++ quota: ProviderQuota; ++ routing?: ProviderQuotaRoutingEvidence | { quota: ProviderQuota; testOnly: true }; ++}; ++ ++const quotaCache = new Map(); ++ ++/** Private cache identity; neither key material nor this digest enters management reports. */ ++export function providerQuotaRoutingBinding( ++ name: string, ++ provider: OcxProviderConfig, ++ credential = resolveProviderApiKey(provider.apiKey)?.trim(), ++): string | null { ++ if ((provider.authMode ?? "key") !== "key" || !credential) return null; ++ // Registry-owned OAuth/forward rows normalize saved authMode before dispatch. ++ // A key probe must not constrain that later account selection. ++ const entry = getProviderRegistryEntry(name); ++ if (entry && (entry.authKind === "oauth" || entry.authKind === "forward") ++ && !providerUsesKeyAuthOverride(entry, provider, credential)) return null; ++ // Static auth headers can replace or combine with the probed API-key header. ++ // Its semantics belong to the adapter, so it is not provider-wide quota evidence. ++ if (Object.keys(provider.headers ?? {}).some(header => ++ ["authorization", "x-api-key", "x-goog-api-key"].includes(header.toLowerCase()))) return null; ++ return createHash("sha256").update(JSON.stringify([ ++ name, provider.adapter, provider.baseUrl, credential, ++ ])).digest("hex"); ++} + + export function clearCachedProviderQuotas(): void { + quotaCache.clear(); + } + +-export function replaceCachedProviderQuotas(reports: ProviderQuotaReport[]): void { ++export function replaceCachedProviderQuotas( ++ reports: ProviderQuotaReport[], ++ routingEvidence?: WeakMap, ++): void { + quotaCache.clear(); + for (const report of reports) { +- quotaCache.set(report.provider, report.quota); ++ quotaCache.set(report.provider, { quota: report.quota, routing: routingEvidence?.get(report) }); + } + } + +@@ -18,15 +56,34 @@ export function getCachedProviderQuota( + now: number, + maxAgeMs = 30 * 60_000, + ): ProviderQuota | null { +- const quota = quotaCache.get(provider); ++ const quota = quotaCache.get(provider)?.quota; + if (!quota) return null; + if (now - quota.updatedAt > maxAgeMs) return null; + return quota; + } + ++/** Only inference-wide evidence for this sole credential may rank or veto a whole provider. */ ++export function getCachedProviderRoutingQuota( ++ name: string, ++ provider: OcxProviderConfig | undefined, ++ now: number, ++ maxAgeMs = 30 * 60_000, ++): ProviderQuota | null { ++ if (!provider || provider.disabled === true || (provider.authMode ?? "key") !== "key") return null; ++ // An active-key report cannot speak for the other keys the dispatcher may select. ++ if ((provider.apiKeyPool?.length ?? 0) > 1) return null; ++ const routing = quotaCache.get(name)?.routing; ++ if (!routing || now - routing.quota.updatedAt > maxAgeMs) return null; ++ const binding = providerQuotaRoutingBinding(name, provider); ++ if (!binding || (!("testOnly" in routing) && routing.binding !== binding)) return null; ++ return routing.quota; ++} ++ + export function setCachedProviderQuotaForTests( + provider: string, + quota: ProviderQuota, + ): void { +- quotaCache.set(provider, quota); ++ // Unit tests deliberately assert the supplied quota's scope. Production publication ++ // requires the producer's private, credential-bound evidence map above. ++ quotaCache.set(provider, { quota, routing: { quota, testOnly: true } }); + } +diff --git a/src/providers/quota.ts b/src/providers/quota.ts +index 69ee60626c..6909e46131 100644 +--- a/src/providers/quota.ts ++++ b/src/providers/quota.ts +@@ -39,7 +39,9 @@ import { + } from "./quota-wire"; + import { + clearCachedProviderQuotas, ++ providerQuotaRoutingBinding, + replaceCachedProviderQuotas, ++ type ProviderQuotaRoutingEvidence, + } from "./quota-routing-cache"; + import { + aggregateCodexPoolCapacity, +@@ -103,6 +105,7 @@ const XAI_CREDITS_URL = `${XAI_BILLING_URL}?format=credits`; + const LAST_GOOD_MAX_AGE_MS = CODEX_CAPACITY_MAX_QUOTA_AGE_MS; + const nativeMainReportGenerations = new WeakMap(); + const accountReportCurrent = new WeakMap boolean>(); ++const routingEvidence = new WeakMap(); + let providerQuotaBeforePublishForTests: (() => void | Promise) | null = null; + + /** Test-only seam for identity/config invalidation after probes but before publication. */ +@@ -447,7 +450,9 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro + ? { expiresAt: normalizedExpiry } + : {}; + if (unlimited) { +- return report(provider, "a6api:billing", { ++ // Every row is an API-credit constraint on inference, so the display quota is also ++ // the routing projection. Passing it explicitly is the opt-in. ++ const quota: ProviderQuota = { + creditsUsd: { + used: 0, + limit: 0, +@@ -458,7 +463,8 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro + }, + customWindows: [{ label: "Unlimited API credits", percent: 0 }], + updatedAt: Date.now(), +- }); ++ }; ++ return keyReport(provider, "a6api:billing", quota, config, apiKey, quota); + } + const limitUsd = firstFinite(subscription, ["hard_limit_usd"]); + const grantedUnits = firstFinite(token, ["total_granted"]); +@@ -481,7 +487,7 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro + const percent = normalizePercent((usedUsd / limitUsd) * 100); + if (percent === undefined) return TERMINAL_QUOTA_FAILURE; + const label = `API credits ($${remainingUsd.toFixed(2)} of $${limitUsd.toFixed(2)} remaining)`; +- return report(provider, "a6api:billing", { ++ const quota: ProviderQuota = { + creditsUsd: { + used: usedUsd, + limit: limitUsd, +@@ -491,7 +497,9 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro + }, + customWindows: [{ label, percent }], + updatedAt: Date.now(), +- }); ++ }; ++ // The credit balance funds inference itself, so display and routing scope agree. ++ return keyReport(provider, "a6api:billing", quota, config, apiKey, quota); + } + + function parseOpenCodeGoUsageWindow(value: unknown): { percent: number; resetAt?: number } | null { +@@ -539,7 +547,7 @@ async function fetchOpenCodeGoQuota(provider: string, config: OcxProviderConfig) + } : {}), + updatedAt: Date.now(), + }; +- return report(provider, "opencode-go:usage", quota); ++ return keyReport(provider, "opencode-go:usage", quota, config, apiKey, quota); + } + + /** +@@ -583,10 +591,13 @@ async function fetchOpenRouterQuota(provider: string, config: OcxProviderConfig) + if (percent === undefined) return null; + const remaining = Math.max(0, limit - used); + const label = `API credits ($${remaining.toFixed(2)} of $${limit.toFixed(2)} remaining)`; +- return report(provider, "openrouter:key-info", { ++ // The per-key spending cap stops every request this credential can make, so the ++ // whole report is inference-wide routing evidence. ++ const quota: ProviderQuota = { + customWindows: [{ label, percent }], + updatedAt: Date.now(), +- }); ++ }; ++ return keyReport(provider, "openrouter:key-info", quota, config, apiKey, quota); + } + + /** +@@ -685,7 +696,7 @@ async function fetchClineQuota(provider: string, config: OcxProviderConfig): Pro + windows += 1; + } + } +- return windows > 0 ? report(provider, "cline:plan-usage-limits", quota) : null; ++ return windows > 0 ? keyReport(provider, "cline:plan-usage-limits", quota, config, apiKey, quota) : null; + } + + /** +@@ -757,7 +768,7 @@ async function fetchOllamaCloudQuota(provider: string, config: OcxProviderConfig + } + const body = asRecord(await readQuotaJson(response)); + const quota = parseOllamaCloudQuota(body); +- return quota ? report(provider, "ollama-cloud:usage", quota) : null; ++ return quota ? keyReport(provider, "ollama-cloud:usage", quota, config, apiKey, quota) : null; + } + + /** +@@ -887,10 +898,18 @@ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promi + // model window — for example a plan reporting only the monthly MCP `TIME_LIMIT` row. + // Returning `null` here would preserve the previous token windows for up to 30 minutes + // and keep quota-aware routing acting on a report the provider has already superseded. +- return quota ? report(provider, "zai:quota-limit", quota) : AUTHORITATIVE_EMPTY_QUOTA; ++ return quota ++ ? keyReport(provider, "zai:quota-limit", quota, config, apiKey, quota) ++ : AUTHORITATIVE_EMPTY_QUOTA; + } + const legacy = parseZaiQuotaLegacyFields(data); +- return legacy ? report(provider, "zai:quota-limit", legacy) : null; ++ if (!legacy) return null; ++ // The legacy monthly figure also carries MCP usage; it is display evidence, not ++ // proof that model inference is unavailable. Modern TOKEN_LIMIT rows above are scoped. ++ const inferenceQuota = { ...legacy }; ++ delete inferenceQuota.monthlyPercent; ++ delete inferenceQuota.monthlyResetAt; ++ return keyReport(provider, "zai:quota-limit", legacy, config, apiKey, inferenceQuota); + } + + /** +@@ -1073,7 +1092,9 @@ async function fetchSyntheticQuota(provider: string, config: OcxProviderConfig): + quota.customWindows = [...(quota.customWindows ?? []), { label: "Search hourly", percent: searchHourly }]; + windows += 1; + } +- return windows > 0 ? report(provider, "synthetic:quotas", quota) : null; ++ const inferenceQuota = { ...quota }; ++ delete inferenceQuota.customWindows; // search.hourly does not constrain model inference. ++ return windows > 0 ? keyReport(provider, "synthetic:quotas", quota, config, apiKey, inferenceQuota) : null; + } + + /** +@@ -1185,6 +1206,31 @@ function report( + }; + } + ++/** ++ * Publish a credential-bound report, and routing evidence only when the producer ++ * hands over its inference-only projection. ++ * ++ * The projection is deliberately not defaulted to the display quota. A producer must ++ * decide that its rows really do constrain inference on the probed credential; omitting ++ * the argument leaves the report display-only, so a new producer cannot inherit ++ * provider-veto authority merely by calling this helper. Ownership alone is not the ++ * scope decision: providerQuotaRoutingBinding resolving is necessary, never sufficient. ++ */ ++function keyReport( ++ provider: string, ++ source: string, ++ quota: ProviderQuota, ++ config: OcxProviderConfig, ++ probedCredential: string, ++ inferenceQuota?: ProviderQuota, ++): ProviderQuotaReport | null { ++ const result = report(provider, source, quota); ++ if (!result || !inferenceQuota) return result; ++ const binding = providerQuotaRoutingBinding(provider, config, probedCredential); ++ if (binding) routingEvidence.set(result, { quota: inferenceQuota, binding }); ++ return result; ++} ++ + function tagNativeMainReport( + value: ProviderQuotaReport | null, + generation: number, +@@ -1193,6 +1239,27 @@ function tagNativeMainReport( + return value; + } + ++/** ++ * Test-only seam: publish exactly as a credential-bound producer does, and hand back the ++ * routing evidence the publication actually attached. ++ * ++ * Live producers all pass a projection today, so no probe fixture can prove the OTHER half ++ * of the contract: that omitting it stays display-only. Routing an omitted argument through ++ * the real helper keeps that provable, and a re-introduced `= quota` default would be ++ * observed here (a defaulted parameter also fires for an explicitly undefined argument). ++ */ ++export function publishKeyReportForTests( ++ provider: string, ++ source: string, ++ quota: ProviderQuota, ++ config: OcxProviderConfig, ++ probedCredential: string, ++ inferenceQuota?: ProviderQuota, ++): { report: ProviderQuotaReport | null; routing: ProviderQuotaRoutingEvidence | undefined } { ++ const result = keyReport(provider, source, quota, config, probedCredential, inferenceQuota); ++ return { report: result, routing: result ? routingEvidence.get(result) : undefined }; ++} ++ + function isProviderQuotaReportCurrent(value: ProviderQuotaReport): boolean { + const generation = nativeMainReportGenerations.get(value); + return (generation === undefined || isMainAccountIdentityGenerationLive(generation)) +@@ -1888,7 +1955,7 @@ export function reconcileProviderAccountQuotaRows(context: GenerationContext): n + const reports = cache.response.reports.filter(report => context.providerNames.has(report.provider)); + removed += cache.response.reports.length - reports.length; + cache = { ...cache, response: { ...cache.response, reports } }; +- replaceCachedProviderQuotas(reports); ++ replaceCachedProviderQuotas(reports, routingEvidence); + } + liveAccountQuotaKeys = new Set(context.oauthAccountKeys); + liveProviderQuotaKeys = new Set(context.providerNames); +@@ -2317,7 +2384,7 @@ async function fetchKimiQuota(provider: string, config: OcxProviderConfig, acces + }); + if (!response.ok) return null; + const quota = parseKimiQuotaPayload(await readQuotaJson(response)); +- return quota ? report(provider, "kimi:usages", quota) : null; ++ return quota ? keyReport(provider, "kimi:usages", quota, config, accessToken, quota) : null; + } + + /** +@@ -2444,7 +2511,7 @@ async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig + const fiveHour = parseCommandCodeWindow(limits?.fiveHour); + const weekly = parseCommandCodeWindow(limits?.weekly); + const creditsUsd = await fetchCommandCodeSpend(bearer, credits, orgQuery); +- return report(provider, "command-code:credits", { ++ const quota: ProviderQuota = { + ...(fiveHour ? { + fiveHourPercent: fiveHour.percent, + ...(fiveHour.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}), +@@ -2455,7 +2522,9 @@ async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig + } : {}), + ...(creditsUsd ? { creditsUsd } : {}), + updatedAt: Date.now(), +- }); ++ }; ++ // Rolling windows and the credit balance both gate inference on this bearer. ++ return keyReport(provider, "command-code:credits", quota, config, bearer, quota); + } + + /** Cursor included usage via api2.cursor.sh (Bearer from OAuth) — unofficial, may change. */ +@@ -2964,7 +3033,9 @@ async function maybeFetchProviderQuota( + // probe to run — the row is the active account's last in-band observation. + if (provider.authMode === "oauth" && hasPassiveAccountQuota(name)) return fetchPassiveProviderQuota(name); + const reader = keyQuotaReaderForProvider(name, provider); +- return reader ? reader(name, provider) : null; ++ // Keep destination/auth fields bound to the same request as the reader's captured ++ // bearer, even if the live provider object changes while the quota probe awaits. ++ return reader ? reader(name, { ...provider }) : null; + } catch { + return null; + } +@@ -3151,7 +3222,7 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh + ) { + const reports = response.reports.filter(item => mayCommitProviderQuotaKey(item.provider, writerGeneration)); + cache = { key, ts: Date.now(), response: { ...response, reports } }; +- replaceCachedProviderQuotas(reports); ++ replaceCachedProviderQuotas(reports, routingEvidence); + notifyProviderQuotaSnapshot(reports, config); + } + return response; +diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md +index 3aac18b6ef..23ab3bb17e 100644 +--- a/structure/04_transports-and-sidecars.md ++++ b/structure/04_transports-and-sidecars.md +@@ -1638,6 +1638,40 @@ retried. Guarded paths: the ChatGPT passthrough and generic adapter fetch in + fallback. Adapters with their own `fetchResponse` (kiro, cursor, google) keep their own retry + policies; kiro imports the shared abort/sleep helpers from this module. + ++## Cached quota used by Combo selection ++ ++Provider quota reports describe the observed account, model group, or service window; they are ++not automatically proof that every request through the provider is unavailable. Before account ++selection, Combo exclusion and `reset-window` ranking consume only the producer's inference-wide ++subset for the current single API key. Synthetic search windows and legacy ZAI MCP monthly data ++remain display rows, while credential-wide key limits such as the OpenRouter spending cap remain ++eligible for early exclusion. ++ ++Routing evidence is published only when a producer hands the reporting helper its inference-only ++projection. Omitting that argument leaves the report display-only, so a new quota producer cannot ++inherit provider-veto authority merely by reporting through the credential-bound helper, and ++ownership by itself is never the scope decision. The producer records the subset it opts into in ++a private WeakMap bound to the provider name, adapter, destination and captured probe credential. ++Publication retains that evidence without adding it to report JSON. ++ ++The cache getter rechecks the live key, effective registry authentication, static ++credential headers, key-pool size and freshness. OAuth/current-account reports, caller-forward ++routes and ambiguous credential scopes cannot rank or veto the provider before its normal ++account selection. Restoring a matching configuration may reuse still-fresh evidence; a new ++credential cannot inherit another key's cap. The same getter controls immediate selection, ++bounded cooldown waiting and reset-window ordering. This does not override explicit eligibility, ++target cooldowns, account admission or response-driven retry rules. ++ ++```text ++[Decision Log] ++- 목적과 의도: Keep account-, model- and service-scoped quota from disabling an otherwise usable Combo provider while retaining valid single-key inference caps. ++- 기존 구현 및 제약 조건: The routing cache retained only the display quota and treated any exhausted window as a provider-wide veto before account/key selection. ++- 검토한 주요 대안: Remove quota pruning entirely; infer scope from display labels; or require producer-owned inference scope and current credential binding. ++- 선택한 방식: Publish private scoped evidence only for a producer that explicitly supplies its inference projection, and validate it in both provider exclusion and reset-window ranking. ++- 다른 대안 대신 이 방식을 선택한 이유: Display labels cannot prove credential ownership, while deleting the gate would lose valid OpenRouter and other single-key caps. ++- 장점, 단점 및 영향: Scoped/ambiguous reports become unknown for early routing and may require normal dispatch to establish availability; actual account and retry limits remain authoritative. ++``` ++ + ## Same-provider combo quota fallback + + For a failover combo with multiple models on the same Codex-login OpenAI provider, a pre-stream +diff --git a/tests/codex-integration/combos.test.ts b/tests/codex-integration/combos.test.ts +index 98174c3848..76e4f26ca9 100644 +--- a/tests/codex-integration/combos.test.ts ++++ b/tests/codex-integration/combos.test.ts +@@ -910,7 +910,7 @@ describe("combo failure policy and advancement", () => { + expect(sleeps).toEqual([1_000]); + }); + +- test("still filters exhausted quota on a noncanonical forward destination", () => { ++ test("does not infer provider-wide quota from a noncanonical forward row without a credential", () => { + const now = 50_000; + const config = baseConfig({ + providers: { +@@ -927,7 +927,8 @@ describe("combo failure policy and advancement", () => { + + const pick = pickComboTarget(config, "free", { now }); + +- expect(pick?.target.provider).toBe("b"); ++ // This is quota selection, not proof that this custom forward route can authenticate. ++ expect(pick?.target.provider).toBe("a"); + }); + + test("retains caller eligibility restrictions for native targets", () => { +@@ -1150,6 +1151,20 @@ describe("deterministic combo selection", () => { + }); + }); + ++ test.each(["oauth", "header", "key-pool"])("reset-window does not rank an inapplicable snapshot: %s", kind => { ++ const now = Date.now(); ++ const config = baseConfig({ combos: { free: { strategy: "reset-window", targets: [ ++ { provider: "a", model: "m1" }, { provider: "b", model: "m2" }, ++ ] } } }); ++ setCachedProviderQuotaForTests("a", { updatedAt: now, weeklyResetAt: now + 2_000 }); ++ setCachedProviderQuotaForTests("b", { updatedAt: now, weeklyResetAt: now + 1_000 }); ++ expect(pickComboTarget(config, "free", { now })?.target.provider).toBe("b"); ++ if (kind === "oauth") config.providers.b!.authMode = "oauth"; ++ else if (kind === "header") config.providers.b!.headers = { Authorization: "Bearer different-key" }; ++ else config.providers.b!.apiKeyPool = [{ id: "one", key: "one" }, { id: "two", key: "two" }]; ++ expect(pickComboTarget(config, "free", { now })?.target.provider).toBe("a"); ++ }); ++ + test("reset-window treats elapsed resets as unknown and falls back to configured order", () => { + const now = Date.now(); + const config = baseConfig({ +diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts +index 56d69951d5..0f7ea31451 100644 +--- a/tests/providers/provider-quota.test.ts ++++ b/tests/providers/provider-quota.test.ts +@@ -18,10 +18,14 @@ import { + parseXaiCreditsResponse, + QUOTA_RESPONSE_MAX_BYTES, + readProviderQuotaJsonForTests, ++ publishKeyReportForTests, + setAntigravityAccountQuotaTransportForTests, + setProviderQuotaBeforePublishForTests, + } from "../../src/providers/quota"; + import type { OcxConfig } from "../../src/types"; ++import { clearComboTargetCooldowns, coolComboTarget, pickComboTarget, pickComboTargetWithWait } from "../../src/combos"; ++import { routedProviderConfig } from "../../src/router"; ++import { buildOpenAIChatPassthroughRequest } from "../../src/adapters/openai-chat"; + import { PROXY_ENV_KEYS } from "../../src/lib/proxy-env"; + import { repoPath } from "../helpers/repo-root"; + const proxyKeys = PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]); +@@ -95,6 +99,7 @@ beforeEach(() => { + }); + + afterEach(() => { ++ clearComboTargetCooldowns(); + for (const key of proxyKeys) { + if (originalProxyEnv[key] === undefined) delete process.env[key]; + else process.env[key] = originalProxyEnv[key]; +@@ -703,6 +708,241 @@ describe("fetchProviderQuotaReports", () => { + } as OcxConfig; + } + ++ function quotaCombo(config: OcxConfig): OcxConfig { ++ const provider = config.defaultProvider; ++ return { ++ ...config, ++ providers: { ++ ...config.providers, ++ fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", apiKey: "fallback-key" }, ++ }, ++ combos: { "quota-scope": { strategy: "failover", targets: [ ++ { provider, model: "primary-model" }, { provider: "fallback", model: "fallback-model" }, ++ ] } }, ++ }; ++ } ++ ++ test("routing quota scope keeps Synthetic search exhaustion out of model selection", async () => { ++ globalThis.fetch = (async () => Response.json({ ++ data: { rollingFiveHourLimit: 20, weeklyTokenLimit: 30, search: { hourly: 100 } }, ++ })) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("synthetic", "https://api.synthetic.new/v2")); ++ const reports = await fetchProviderQuotaReports(config, true); ++ expect(reports.reports[0]?.quota.customWindows?.[0]?.percent).toBe(100); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("synthetic"); ++ }); ++ ++ test("routing quota scope keeps ZAI legacy MCP exhaustion out of model selection", async () => { ++ globalThis.fetch = (async () => Response.json({ ++ success: true, data: { fiveHourPercent: 20, weeklyPercent: 30, monthlyMCPUsage: 100 }, ++ })) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("zai", "https://api.z.ai/api/coding/paas/v4")); ++ const reports = await fetchProviderQuotaReports(config, true); ++ expect(reports.reports[0]?.quota.monthlyPercent).toBe(100); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("zai"); ++ }); ++ ++ test("routing quota scope keeps a key-bound display-only report out of model selection", async () => { ++ // MiniMax publishes its Token Plan countdown through the display-only path. The provider ++ // is single-key `key` auth, so ownership alone would resolve a routing binding; without ++ // an inference projection the exhausted row must still not rank or veto the target. ++ globalThis.fetch = (async () => Response.json({ ++ success: true, data: { remains_time: 0, total_time: 1_000_000_000 }, ++ })) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("minimax", "https://api.minimax.io/v1")); ++ const reports = await fetchProviderQuotaReports(config, true); ++ expect(reports.reports[0]?.quota.customWindows?.[0]?.percent).toBe(100); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("minimax"); ++ }); ++ ++ test("routing quota scope retains the OpenRouter single-key spending cap", async () => { ++ globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1")); ++ await fetchProviderQuotaReports(config, true); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); ++ }); ++ ++ test("keyReport publishes routing evidence only for an explicit inference projection", () => { ++ // The MiniMax case above rides the display-only `report()` path, so it would still pass if ++ // `keyReport`'s projection were quietly defaulted back to the display quota. This drives the ++ // credential-bound helper directly: the binding resolves for BOTH calls (same single-key ++ // provider and probed credential), so the only variable left is the projection itself. ++ const provider = keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1").providers.openrouter!; ++ const exhausted = { monthlyPercent: 100 }; ++ ++ const omitted = publishKeyReportForTests("openrouter", "openrouter:key-info", exhausted, provider, "openrouter-secret"); ++ expect(omitted.report?.quota.monthlyPercent).toBe(100); ++ expect(omitted.routing).toBeUndefined(); ++ ++ const projected = { monthlyPercent: 100 }; ++ const explicit = publishKeyReportForTests( ++ "openrouter", "openrouter:key-info", exhausted, provider, "openrouter-secret", projected, ++ ); ++ expect(explicit.routing?.quota).toBe(projected); ++ expect(typeof explicit.routing?.binding).toBe("string"); ++ }); ++ ++ test("routing quota scope does not apply a probed key cap to an Authorization override", async () => { ++ const probeAuth: Array = []; ++ globalThis.fetch = (async (_input, init) => { ++ probeAuth.push(new Headers(init?.headers).get("authorization")); ++ return Response.json({ data: { limit: 20, limit_remaining: 0 } }); ++ }) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1", "spent-A")); ++ config.providers.openrouter!.headers = { Authorization: "Bearer live-B" }; ++ await fetchProviderQuotaReports(config, true); ++ const request = buildOpenAIChatPassthroughRequest(routedProviderConfig("openrouter", config.providers.openrouter!), { ++ messages: [{ role: "user", content: "synthetic" }], ++ }, "primary-model", false); ++ expect(probeAuth).toEqual(["Bearer spent-A"]); ++ expect(new Headers(request.headers).get("authorization")).toBe("Bearer live-B"); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("openrouter"); ++ ++ delete config.providers.openrouter!.headers; ++ await fetchProviderQuotaReports(config, true); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); ++ }); ++ ++ test("routing quota scope rechecks an Authorization override added after publication", async () => { ++ globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1", "spent-A")); ++ await fetchProviderQuotaReports(config, true); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); ++ config.providers.openrouter!.headers = { aUtHoRiZaTiOn: "Bearer live-B" }; ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("openrouter"); ++ delete config.providers.openrouter!.headers; ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); ++ }); ++ ++ test("routing quota scope does not apply a probed key cap to an Anthropic x-api-key override", async () => { ++ const probeAuth: Array = []; ++ globalThis.fetch = (async (_input, init) => { ++ probeAuth.push(new Headers(init?.headers).get("authorization")); ++ return Response.json({ usage: { limit: "100", used: "100" } }); ++ }) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("kimi-code", "https://api.kimi.com/coding/v1", "spent-A")); ++ config.providers["kimi-code"]!.adapter = "anthropic"; ++ config.providers["kimi-code"]!.headers = { "x-api-key": "live-B" }; ++ await fetchProviderQuotaReports(config, true); ++ expect(probeAuth).toEqual(["Bearer spent-A"]); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("kimi-code"); ++ delete config.providers["kimi-code"]!.headers; ++ await fetchProviderQuotaReports(config, true); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); ++ }); ++ ++ test.each(["key", "omitted", "custom-key"])("routing quota scope follows effective Kimi authentication: %s", async mode => { ++ globalThis.fetch = (async () => Response.json({ usage: { limit: "100", used: "100" } })) as typeof fetch; ++ const name = mode === "custom-key" ? "kimi-code" : "kimi"; ++ const config = quotaCombo(keyQuotaConfig(name, "https://api.kimi.com/coding/v1", "spent-A")); ++ if (mode === "omitted") delete config.providers[name]!.authMode; ++ expect(routedProviderConfig(name, config.providers[name]!).authMode).toBe(mode === "custom-key" ? "key" : "oauth"); ++ const report = await fetchProviderQuotaReports(config, true); ++ expect(report.reports[0]?.quota.weeklyPercent).toBe(100); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe(mode === "custom-key" ? "fallback" : "kimi"); ++ }); ++ ++ test("routing quota scope keeps an exhausted Gemini group from vetoing an Antigravity Claude target", async () => { ++ await saveCredential("google-antigravity", { ++ access: "synthetic-agy-access", refresh: "synthetic-agy-refresh", ++ expires: Date.now() + 3600_000, projectId: "synthetic-project", ++ }); ++ const resetTime = new Date(Date.now() + 3600_000).toISOString(); ++ setAntigravityAccountQuotaTransportForTests({ ++ resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), ++ pinnedPost: async url => { ++ expect(url.endsWith("retrieveUserQuotaSummary")).toBe(true); ++ return Response.json({ groups: [ ++ { displayName: "Gemini Models", buckets: [{ window: "5h", remainingFraction: 0, resetTime }] }, ++ { displayName: "Claude and GPT models", buckets: [{ window: "5h", remainingFraction: 1, resetTime }] }, ++ ] }); ++ }, ++ }); ++ const config = quotaCombo({ defaultProvider: "google-antigravity", providers: { ++ "google-antigravity": { adapter: "google", authMode: "oauth", baseUrl: "https://daily-cloudcode-pa.googleapis.com" }, ++ } } as OcxConfig); ++ config.combos!["quota-scope"]!.targets[0]!.model = "claude-sonnet-4.6"; ++ const report = await fetchProviderQuotaReports(config, true); ++ expect(report.reports[0]?.quota.customWindows).toEqual([ ++ { label: "Gem", percent: 100, resetAt: Date.parse(resetTime) }, ++ { label: "Cla", percent: 0, resetAt: Date.parse(resetTime) }, ++ ]); ++ expect(pickComboTarget(config, "quota-scope")?.target).toMatchObject({ provider: "google-antigravity", model: "claude-sonnet-4.6" }); ++ }); ++ ++ test("routing quota scope keeps an active Anthropic account report out of whole-provider selection", async () => { ++ await saveCredential("anthropic", { ++ access: "synthetic-claude-access", refresh: "synthetic-claude-refresh", expires: Date.now() + 3600_000, ++ }); ++ globalThis.fetch = (async input => { ++ expect(String(input)).toBe("https://api.anthropic.com/api/oauth/usage"); ++ return Response.json({ five_hour: { utilization: 100, resets_at: new Date(Date.now() + 3600_000).toISOString() } }); ++ }) as typeof fetch; ++ const config = quotaCombo({ defaultProvider: "anthropic", providers: { ++ anthropic: { adapter: "anthropic", authMode: "oauth", baseUrl: "https://api.anthropic.com/v1" }, ++ } } as OcxConfig); ++ const report = await fetchProviderQuotaReports(config, true); ++ expect(report.reports[0]?.quota.fiveHourPercent).toBe(100); ++ // Account selection and its exhaustion rules still decide whether this route can dispatch. ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("anthropic"); ++ config.providers.fallback!.disabled = true; ++ const now = Date.now(); ++ const waits: number[] = []; ++ coolComboTarget("quota-scope", config.combos!["quota-scope"]!.targets[0]!, { now, cooldownMs: 1_000 }); ++ const afterWait = await pickComboTargetWithWait(config, "quota-scope", { ++ now, waitForCooldownMs: 1_000, sleep: async ms => { waits.push(ms); }, ++ }); ++ expect(waits).toEqual([1_000]); ++ expect(afterWait?.target.provider).toBe("anthropic"); ++ }); ++ ++ test("routing quota scope retains a verified cap through a transient refresh failure", async () => { ++ let transient = false; ++ globalThis.fetch = (async () => transient ++ ? new Response("unavailable", { status: 503 }) ++ : Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1")); ++ await fetchProviderQuotaReports(config, true); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); ++ transient = true; ++ await fetchProviderQuotaReports(config, true); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); ++ }); ++ ++ test("routing quota scope stops vetoing the provider when a second key is added", async () => { ++ globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1")); ++ await fetchProviderQuotaReports(config, true); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); ++ config.providers.openrouter!.apiKeyPool = [ ++ { id: "old", key: "openrouter-secret" }, { id: "new", key: "second-key" }, ++ ]; ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("openrouter"); ++ }); ++ ++ test("routing quota scope rejects a cached cap after the active key changes", async () => { ++ globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1")); ++ await fetchProviderQuotaReports(config, true); ++ config.providers.openrouter!.apiKey = "replacement-key"; ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("openrouter"); ++ }); ++ ++ test("routing quota scope rejects a cached cap after an env key resolves differently", async () => { ++ const previous = process.env.OCX_TEST_ROUTING_QUOTA_KEY; ++ try { ++ process.env.OCX_TEST_ROUTING_QUOTA_KEY = "first-key"; ++ globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1", "$OCX_TEST_ROUTING_QUOTA_KEY")); ++ await fetchProviderQuotaReports(config, true); ++ process.env.OCX_TEST_ROUTING_QUOTA_KEY = "replacement-key"; ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("openrouter"); ++ } finally { ++ if (previous === undefined) delete process.env.OCX_TEST_ROUTING_QUOTA_KEY; ++ else process.env.OCX_TEST_ROUTING_QUOTA_KEY = previous; ++ } ++ }); ++ + test("OpenRouter quota renders a credit window against the per-key cap", async () => { + const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { +``` + +## Current-dev consumer amendment + +`src/combos/resolve.ts:126` has a newer catalog `quotaInactiveReason` consumer. MODIFY its loop from separate native-forward exemption + `getCachedProviderQuota(target.provider, now)` to `getCachedProviderRoutingQuota(target.provider, provider, now)`. Unknown routing evidence returns undefined, explicit exhausted evidence retains no_credit. MODIFY the existing quota inactive tests (locate with rg quotaInactiveReason tests) to use credential-bearing provider fixtures and prove display-only/mismatched evidence cannot mark catalog rows inactive. Update stale explanatory comments to reference the scoped cache. This preserves consistency after removed imports and is necessary current-dev integration, not unrelated catalog redesign. + +## Design reflection D5 amendment + +Accept Bohr D1-D6 with D5 corrected: MODIFY `getCachedProviderRoutingQuota` to return null for nonfinite/negative/future timestamps or age `>= maxAgeMs`, aligning runtime/catalog with the editor's exclusive deadline. Retain display getter compatibility. MODIFY `tests/codex-integration/catalog-zero-credit-picker.test.ts` with genuine WeakMap publication plus positive control; then mutate apiKey/baseUrl/adapter independently and require undefined inactivity. Test timestamp at exactly 30 minutes, future, negative and NaN as unknown. This is the shared scoped evidence boundary, not a new auth flow. No local suites; hosted final tip owns execution. diff --git a/devlog/_plan/260912_combo_carry/020_editor.md b/devlog/_plan/260912_combo_carry/020_editor.md new file mode 100644 index 0000000000..fe03bd8982 --- /dev/null +++ b/devlog/_plan/260912_combo_carry/020_editor.md @@ -0,0 +1,1091 @@ +# editor carry + +MODIFY exactly the paths in this public source diff. Apply each original commit in order with attribution. Resolve retired structure document into current runtime.md and gui-and-management-api.md; never restore the retired file. Earlier phase dependency: runtime. + +Before/after source contract (review against current dev at P; source diff is the executable carry input): + +```diff +diff --git a/docs-site/src/content/docs/fr/guides/combos.md b/docs-site/src/content/docs/fr/guides/combos.md +index 9434a1e98a..9073372327 100644 +--- a/docs-site/src/content/docs/fr/guides/combos.md ++++ b/docs-site/src/content/docs/fr/guides/combos.md +@@ -281,9 +281,7 @@ Ouvrez le tableau de bord local et choisissez **Modèles → Combos**. L'espace + combos, et son sélecteur de cible exclut les modèles désactivés et les combos imbriqués. + + Chaque cible affiche aussi un badge de quota en direct : **Disponible**, **Quota épuisé** ou **Quota inconnu**. +-Enregistrer et Créer ne sont désactivés que lorsque chaque cible activée dispose de preuves fraîches et complètes +-que son quota est épuisé. Les données manquantes, obsolètes, mal formées ou agrégées de façon incomplète restent +-inconnues et ne verrouillent jamais un contrôle. La récupération du quota réactive automatiquement l’action. ++L’éditeur bloque Enregistrer et Créer pour une raison de quota uniquement lorsque chaque cible utilisable dispose d’une confirmation serveur encore valide indiquant que la limite d’inférence liée à ses identifiants configurés est épuisée. Les quotas de compte, de modèle, de recherche et de MCP fournis uniquement à titre d’affichage, ainsi que les informations de routage absentes ou expirées, ne déclenchent pas ce blocage. Le blocage expire à la réinitialisation applicable ou à l’expiration de la validité des données et fait l’objet d’une nouvelle vérification lorsque la page devient active ou visible ; Actualiser recharge à la fois les données des combos et les quotas. + + ### CLI + +diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md +index c7d076d9b7..ef5ccde16c 100644 +--- a/docs-site/src/content/docs/guides/combos.md ++++ b/docs-site/src/content/docs/guides/combos.md +@@ -349,10 +349,7 @@ task workflow. + Open the local dashboard and choose **Models → Combos**. The workspace creates, edits, renames, and removes + combos, and its target picker excludes disabled models and nested combos. + +-Each target also shows a live quota badge: **Available**, **Out of quota**, or **Quota unknown**. Save and +-Create are disabled only when every enabled target has fresh, complete evidence that its quota is exhausted. +-Missing, stale, malformed, or incomplete aggregate evidence stays unknown and never locks a control. Polling +-continues while the workspace is visible, so recovery automatically restores the action. The dashboard ++Each target also shows a live quota badge: **Available**, **Out of quota**, or **Quota unknown**. The editor blocks Save and Create for quota only when every usable target has a current server-confirmed exhausted inference limit for its configured credential. Display-only account, model, search and MCP quota, or missing or expired routing evidence, does not cause this block. The block expires at the applicable reset or freshness boundary and is rechecked when the page becomes active or visible; Refresh reloads both Combo data and quota. The dashboard + editor does not yet expose `cooldownMs` or `waitForCooldownMs`; use the configuration file or management + API until the follow-up UI work lands. + +diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md +index f6eca53214..70c902ec6e 100644 +--- a/docs-site/src/content/docs/ja/guides/combos.md ++++ b/docs-site/src/content/docs/ja/guides/combos.md +@@ -182,8 +182,7 @@ v1/base/v2 モードと完全な暗号化タスクのワークフローについ + ローカル ダッシュボードを開き、**Models → コンボ**を選択します。ワークスペースはコンボを作成、編集、名前変更、削除し、そのターゲット ピッカーは無効なモデルとネストされたコンボを除外します。 + + 各ターゲットには **利用可能**、**クォータを使い切りました**、**クォータ不明** のライブバッジも表示されます。 +-保存と作成が無効になるのは、有効な全ターゲットについて、クォータ枯渇を示す新鮮で完全な証拠がある場合だけです。 +-欠落、古い、不正、または不完全な集約データは不明のままで、操作をロックしません。クォータが回復すると操作は自動で再び有効になります。ダッシュボードのエディターではまだ `cooldownMs` と `waitForCooldownMs` を設定できません。後続の UI 作業が完了するまでは、構成ファイルまたは管理 API を使用してください。 ++エディターがクォータを理由に保存と作成をブロックするのは、使用可能なすべてのターゲットについて、設定された認証情報の推論上限に達したことを示す、サーバーによる確認が現在も有効な場合だけです。表示専用のアカウント・モデル・検索・MCP クォータや、ルーティングの根拠情報の欠落・期限切れによって、このブロックが発生することはありません。ブロックは該当するリセット時刻またはデータの有効期限に解除され、ページがアクティブになるか表示状態になると再確認されます。「更新」はコンボデータとクォータの両方を再読み込みします。ダッシュボードのエディターではまだ `cooldownMs` と `waitForCooldownMs` を設定できません。後続の UI 作業が完了するまでは、構成ファイルまたは管理 API を使用してください。 + + ### CLI + +diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md +index 71e557f25c..80eac32c2d 100644 +--- a/docs-site/src/content/docs/ko/guides/combos.md ++++ b/docs-site/src/content/docs/ko/guides/combos.md +@@ -187,9 +187,7 @@ v1/base/v2 모드와 암호화된 작업의 전체 흐름은 [Sub-agent Surface] + + 로컬 대시보드를 열고 **Models → Combos**를 선택합니다. 워크스페이스는 콤보를 만들고, 편집하고, 이름을 바꾸고, 제거할 수 있으며, 대상 선택기에서는 비활성 모델과 중첩 콤보를 제외합니다. + +-각 대상에는 **사용 가능**, **할당량 소진**, **할당량 알 수 없음** 실시간 배지도 표시됩니다. 저장과 만들기 버튼은 +-활성화된 모든 대상에 할당량 소진을 입증하는 최신의 완전한 증거가 있을 때만 비활성화됩니다. 누락되거나 오래되거나 +-형식이 잘못되었거나 집계가 불완전한 데이터는 알 수 없음으로 남으며 버튼을 잠그지 않습니다. 할당량이 복구되면 버튼도 자동으로 다시 활성화됩니다. 대시보드 편집기에서는 아직 `cooldownMs`나 `waitForCooldownMs`를 설정할 수 없습니다. 후속 UI 작업이 완료될 때까지 구성 파일이나 관리 API를 사용하세요. ++각 대상에는 **사용 가능**, **할당량 소진**, **할당량 알 수 없음** 실시간 배지도 표시됩니다. 편집기는 사용 가능한 모든 대상에 대해 설정된 인증 정보의 추론 한도가 소진되었다는 서버 확인이 현재 유효할 때만 할당량을 이유로 저장과 만들기를 차단합니다. 표시 전용 계정·모델·검색·MCP 할당량이나 누락되거나 만료된 라우팅 근거 정보는 이 차단을 일으키지 않습니다. 차단은 해당 한도의 초기화 시점이나 데이터 유효기간이 끝나면 해제되며 페이지가 활성화되거나 표시될 때 다시 확인됩니다. 새로 고침은 콤보 데이터와 할당량을 모두 다시 불러옵니다. 대시보드 편집기에서는 아직 `cooldownMs`나 `waitForCooldownMs`를 설정할 수 없습니다. 후속 UI 작업이 완료될 때까지 구성 파일이나 관리 API를 사용하세요. + + ### CLI + +diff --git a/docs-site/src/content/docs/ru/guides/combos.md b/docs-site/src/content/docs/ru/guides/combos.md +index 868416ae5b..b873f4ed03 100644 +--- a/docs-site/src/content/docs/ru/guides/combos.md ++++ b/docs-site/src/content/docs/ru/guides/combos.md +@@ -234,9 +234,7 @@ effort вызывающей стороне и цели. + переименовывать и удалять combo, а селектор целей исключает отключённые модели и вложенные combo. + + У каждой цели также отображается актуальный значок квоты: **Доступно**, **Квота исчерпана** или **Квота неизвестна**. +-Кнопки сохранения и создания отключаются только тогда, когда для всех включённых целей есть свежие и полные +-данные об исчерпании квоты. Отсутствующие, устаревшие, некорректные или неполные агрегированные данные остаются +-неизвестными и никогда не блокируют управление. Восстановление квоты автоматически снова включает действие. Редактор дашборда пока не предоставляет `cooldownMs` и `waitForCooldownMs`; до появления соответствующего UI используйте файл конфигурации или Management API. ++Редактор блокирует сохранение и создание из-за квоты только тогда, когда для каждой пригодной к использованию цели есть действующее подтверждение сервера об исчерпании лимита инференса для настроенных учётных данных. Квоты аккаунта, модели, поиска и MCP, предназначенные только для отображения, а также отсутствующие или просроченные данные для принятия решения о маршрутизации не вызывают эту блокировку. Блокировка истекает при соответствующем сбросе квоты или окончании срока актуальности данных и проверяется повторно, когда страница становится активной или видимой; «Обновить» повторно загружает и данные combo, и квоты. Редактор дашборда пока не предоставляет `cooldownMs` и `waitForCooldownMs`; до появления соответствующего UI используйте файл конфигурации или Management API. + + ### CLI + +diff --git a/docs-site/src/content/docs/tr/guides/combos.md b/docs-site/src/content/docs/tr/guides/combos.md +index 520c157e83..b8cd5bad0d 100644 +--- a/docs-site/src/content/docs/tr/guides/combos.md ++++ b/docs-site/src/content/docs/tr/guides/combos.md +@@ -312,9 +312,7 @@ hedef seçicisi ise devre dışı bırakılmış modelleri ve iç içe geçmiş + hariç tutar. + + Her hedef ayrıca canlı bir kota rozeti gösterir: **Kullanılabilir**, **Kota tükendi** veya **Kota bilinmiyor**. +-Kaydet ve Oluştur yalnızca etkin hedeflerin tamamı için kotanın tükendiğini gösteren güncel ve eksiksiz kanıt varsa +-devre dışı bırakılır. Eksik, eski, bozuk veya tamamlanmamış toplu kanıt bilinmiyor olarak kalır ve denetimleri asla +-kilitlemez. Kota yenilendiğinde işlem otomatik olarak yeniden etkinleşir. ++Düzenleyici, kota nedeniyle Kaydet ve Oluştur işlemlerini yalnızca kullanılabilir hedeflerin tümü için yapılandırılmış kimlik bilgisine ait çıkarım sınırının tükendiğini doğrulayan geçerli sunucu bilgisi varsa engeller. Yalnızca görüntüleme amaçlı hesap, model, arama ve MCP kotaları ya da eksik veya süresi dolmuş yönlendirme kanıtları bu engellemeye neden olmaz. Engelleme, ilgili sıfırlama zamanında veya verinin güncellik süresi dolduğunda sona erer ve sayfa etkin ya da görünür olduğunda yeniden kontrol edilir; Yenile, hem kombo verilerini hem de kotaları yeniden yükler. + + ### CLI + +@@ -411,4 +409,3 @@ Hata hedefe özgü olmaktan ziyade uç (terminal) bir hataydı. Geçersiz girdiy + düzeltin, aşırı büyük bir bağlamı azaltın, bir politika reddini işleyin veya + reddedilen istek kaynağını düzeltin. Kombolar bu durumlar için atlama yapmaz. + +- +diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md +index d84efca472..abe32ae786 100644 +--- a/docs-site/src/content/docs/zh-cn/guides/combos.md ++++ b/docs-site/src/content/docs/zh-cn/guides/combos.md +@@ -211,9 +211,7 @@ combo 失败分为 **跳转** 失败和 **终止** 失败。 + + 打开本地 dashboard 并选择 **Models → Combos**。该工作区可以创建、编辑、重命名和删除 combo,其目标选择器会排除已禁用的模型和嵌套 combo。 + +-每个目标还会显示实时额度徽章:**可用**、**额度已用尽**或**额度未知**。只有当所有已启用目标都有最新、 +-完整的额度耗尽证据时,保存和创建操作才会被禁用。缺失、过期、格式错误或聚合不完整的证据会保持为未知, +-绝不会锁定控件。额度恢复后,操作会自动重新启用。dashboard 编辑器目前还不能设置 `cooldownMs` 或 `waitForCooldownMs`;在后续 UI 完成前,请使用配置文件或管理 API。 ++每个目标还会显示实时额度徽章:**可用**、**额度已用尽**或**额度未知**。只有当每个可用目标均有当前有效的服务器确认,表明其所配置凭据的推理限额已耗尽时,编辑器才会因额度而禁止保存和创建。仅供显示的账户、模型、搜索和 MCP 额度,以及缺失或已过期的路由依据,都不会触发此限制。此限制会在适用的重置时间或数据有效期结束时解除,并在页面变为活动或可见状态时重新检查;刷新会同时重新加载 Combo 数据和额度。dashboard 编辑器目前还不能设置 `cooldownMs` 或 `waitForCooldownMs`;在后续 UI 完成前,请使用配置文件或管理 API。 + + ### CLI + +diff --git a/docs-site/src/content/docs/zh-tw/guides/combos.md b/docs-site/src/content/docs/zh-tw/guides/combos.md +index d82b399e6f..ce3ad70a94 100644 +--- a/docs-site/src/content/docs/zh-tw/guides/combos.md ++++ b/docs-site/src/content/docs/zh-tw/guides/combos.md +@@ -219,9 +219,7 @@ Codex v2 子代理有一個重要限制([issue #92](https://github.com/lidge-j + + 開啟本機儀表板並選擇 **Combos**。該工作區可建立、編輯、重新命名與移除 combo,且其目標 picker 會排除已停用的模型與巢狀 combo。 + +-每個目標也會顯示即時額度徽章:**可用**、**額度已用盡**或**額度未知**。只有當所有已啟用目標都有最新、 +-完整的額度耗盡證據時,儲存與建立操作才會停用。缺失、過期、格式錯誤或聚合不完整的證據會維持未知, +-絕不會鎖住控制項。額度恢復後,操作會自動重新啟用。 ++每個目標也會顯示即時額度徽章:**可用**、**額度已用盡**或**額度未知**。只有當每個可用目標均有目前有效的伺服器確認,顯示其所設定憑證的推論限額已耗盡時,編輯器才會因配額而停用儲存與建立。僅供顯示的帳戶、模型、搜尋與 MCP 配額,以及缺失或已過期的路由依據,都不會觸發此限制。此限制會在適用的重設時間或資料有效期限結束時解除,並在頁面變為作用中或可見狀態時重新檢查;重新整理會同時重新載入 Combo 資料與配額。 + + ### CLI + +diff --git a/gui/src/combo-workspace-data.ts b/gui/src/combo-workspace-data.ts +index bf8b881c55..b89bff656f 100644 +--- a/gui/src/combo-workspace-data.ts ++++ b/gui/src/combo-workspace-data.ts +@@ -4,6 +4,7 @@ + */ + + import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../../src/codex/catalog/native-models"; ++import { PROVIDER_QUOTA_MAX_AGE_MS } from "../../src/providers/quota-types"; + import type { TKey } from "./i18n/shared"; + + export { SUPPORTED_NATIVE_OPENAI_SLUGS }; +@@ -92,7 +93,7 @@ export type ComboQuotaState = "available" | "exhausted" | "unknown"; + export type ProviderQuotaStates = Readonly>; + + /** Matches the management endpoint's bounded last-good quota lifetime. */ +-export const COMBO_QUOTA_MAX_AGE_MS = 30 * 60_000; ++export const COMBO_QUOTA_MAX_AGE_MS = PROVIDER_QUOTA_MAX_AGE_MS; + + let comboTargetKeySeq = 0; + +@@ -282,133 +283,36 @@ function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; + } + +-function quotaTimestampIsFresh(value: unknown, now: number): boolean { +- const timestamp = finiteNumber(value); +- return timestamp !== null && now - timestamp < COMBO_QUOTA_MAX_AGE_MS; +-} +- +-function nonNegativeInteger(value: unknown): number | null { +- const number = finiteNumber(value); +- return number !== null && Number.isInteger(number) && number >= 0 ? number : null; +-} +- +-function aggregateWindowIsComplete(value: unknown, now: number): boolean { +- const window = recordFromUnknown(value); +- const usedPercent = finiteNumber(window?.usedPercent); +- return !!window +- && usedPercent !== null +- && usedPercent >= 0 +- && nonNegativeInteger(window.includedAccounts) !== null +- && (nonNegativeInteger(window.includedAccounts) ?? 0) > 0 +- && nonNegativeInteger(window.excludedAccounts) === 0 +- && window.incomplete === false +- && quotaTimestampIsFresh(window.updatedAt, now); +-} +- +-function aggregateEvidenceIsComplete(value: unknown, now: number): boolean { +- const aggregation = recordFromUnknown(value); +- if ( +- !aggregation +- || aggregation.kind !== "capacity-weighted-v1" +- || aggregation.scope !== "routable-known" +- || aggregation.presentation !== "aggregate" +- || aggregation.incomplete !== false +- ) return false; +- +- for (const key of [ +- "includedAccounts", +- "excludedAccounts", +- "unknownPlanAccounts", +- "missingQuotaAccounts", +- "pausedAccounts", +- "reauthAccounts", +- "staleQuotaAccounts", +- "partialWindowAccounts", +- ] as const) { +- if (nonNegativeInteger(aggregation[key]) === null) return false; +- } +- if ((nonNegativeInteger(aggregation.includedAccounts) ?? 0) === 0) return false; +- for (const key of [ +- "excludedAccounts", +- "unknownPlanAccounts", +- "missingQuotaAccounts", +- "pausedAccounts", +- "reauthAccounts", +- "staleQuotaAccounts", +- "partialWindowAccounts", +- ] as const) { +- if (aggregation[key] !== 0) return false; +- } +- +- let hasWindow = false; +- for (const key of ["fiveHour", "weekly", "monthly"] as const) { +- if (!Object.hasOwn(aggregation, key)) continue; +- if (!aggregateWindowIsComplete(aggregation[key], now)) return false; +- hasWindow = true; +- } +- if (Object.hasOwn(aggregation, "customWindows")) { +- if (!Array.isArray(aggregation.customWindows)) return false; +- for (const value of aggregation.customWindows) { +- const custom = recordFromUnknown(value); +- if (!custom || typeof custom.label !== "string" || !custom.label.trim()) return false; +- if (!aggregateWindowIsComplete(custom, now)) return false; +- hasWindow = true; +- } +- } +- return hasWindow; ++function routingQuotaFromReport(raw: Record, now: number): { ++ state: "available" | "exhausted"; ++ validUntil: number; ++} | null { ++ const routing = recordFromUnknown(raw.routingQuota); ++ if (!routing || (routing.state !== "available" && routing.state !== "exhausted")) return null; ++ const updatedAt = finiteNumber(routing.updatedAt); ++ const validUntil = finiteNumber(routing.validUntil); ++ if (updatedAt === null || updatedAt < 0 || updatedAt > now ++ || now - updatedAt >= COMBO_QUOTA_MAX_AGE_MS ++ || validUntil === null || validUntil <= now ++ || validUntil > updatedAt + COMBO_QUOTA_MAX_AGE_MS) return null; ++ return { state: routing.state, validUntil }; + } + + function quotaStateFromReport(raw: Record, now: number): ComboQuotaState { +- if (!quotaTimestampIsFresh(raw.updatedAt, now)) return "unknown"; +- const quota = recordFromUnknown(raw.quota); +- if (!quota || !quotaTimestampIsFresh(quota.updatedAt, now)) return "unknown"; +- if (raw.aggregation !== undefined && !aggregateEvidenceIsComplete(raw.aggregation, now)) return "unknown"; +- +- let hasEvidence = false; +- let exhausted = false; +- for (const key of ["fiveHourPercent", "weeklyPercent", "monthlyPercent"] as const) { +- if (!Object.hasOwn(quota, key)) continue; +- const percent = finiteNumber(quota[key]); +- if (percent === null || percent < 0) return "unknown"; +- hasEvidence = true; +- if (percent >= 100) exhausted = true; +- } +- for (const key of ["fiveHourResetAt", "weeklyResetAt", "monthlyResetAt"] as const) { +- if (Object.hasOwn(quota, key) && finiteNumber(quota[key]) === null) return "unknown"; +- } +- +- if (Object.hasOwn(quota, "customWindows")) { +- if (!Array.isArray(quota.customWindows)) return "unknown"; +- for (const value of quota.customWindows) { +- const window = recordFromUnknown(value); +- const percent = finiteNumber(window?.percent); +- if (!window || typeof window.label !== "string" || !window.label.trim() || percent === null || percent < 0) { +- return "unknown"; +- } +- if (Object.hasOwn(window, "resetAt") && finiteNumber(window.resetAt) === null) return "unknown"; +- hasEvidence = true; +- if (percent >= 100) exhausted = true; +- } +- } ++ return routingQuotaFromReport(raw, now)?.state ?? "unknown"; ++} + +- if (Object.hasOwn(quota, "creditsUsd")) { +- const credits = recordFromUnknown(quota.creditsUsd); +- if (!credits) return "unknown"; +- const used = finiteNumber(credits.used); +- const limit = finiteNumber(credits.limit); +- const remaining = finiteNumber(credits.remaining); +- const percent = finiteNumber(credits.percent); +- if (used === null || used < 0 || limit === null || limit < 0 || remaining === null || percent === null || percent < 0) { +- return "unknown"; +- } +- if (credits.unlimited !== undefined && typeof credits.unlimited !== "boolean") return "unknown"; +- if (Object.hasOwn(credits, "expiresAt") && finiteNumber(credits.expiresAt) === null) return "unknown"; +- hasEvidence = true; +- if (credits.unlimited !== true && remaining <= 0) exhausted = true; ++/** The next expiry also wakes the page when no poll response has arrived. */ ++export function nextProviderQuotaStateExpiration(reports: unknown, now = Date.now()): number | undefined { ++ if (!Array.isArray(reports)) return undefined; ++ let next: number | undefined; ++ for (const value of reports) { ++ const report = recordFromUnknown(value); ++ if (!report || typeof report.provider !== "string" || !report.provider.trim()) continue; ++ const routing = routingQuotaFromReport(report, now); ++ if (routing && (next === undefined || routing.validUntil < next)) next = routing.validUntil; + } +- +- if (!hasEvidence) return "unknown"; +- return exhausted ? "exhausted" : "available"; ++ return next; + } + + /** Fail-unknown parser for the live `/api/provider-quotas` report array. */ +diff --git a/gui/src/pages/Combos.tsx b/gui/src/pages/Combos.tsx +index ca05ca8fd5..dee11a7e69 100644 +--- a/gui/src/pages/Combos.tsx ++++ b/gui/src/pages/Combos.tsx +@@ -5,6 +5,7 @@ import { + comboModelId, + parseComboList, + providerQuotaStatesFromReports, ++ nextProviderQuotaStateExpiration, + toPutBody, + } from "../combo-workspace-data"; + import { hideRedundantChatGptForwardProviders } from "../provider-workspace/catalog"; +@@ -239,12 +240,24 @@ export default function Combos({ + enabled: active, + }, + ); +- const providerQuotaStates = useMemo( +- () => quotaResource.lastAttemptOk +- ? providerQuotaStatesFromReports(quotaResource.data?.reports) +- : {}, +- [quotaResource.data, quotaResource.lastAttemptOk], +- ); ++ const [quotaNow, setQuotaClock] = useState(() => Date.now()); ++ const quotaReports = active && quotaResource.lastAttemptOk ? quotaResource.data?.reports : undefined; ++ const providerQuotaStates = providerQuotaStatesFromReports(quotaReports, quotaNow); ++ const quotaExpiry = nextProviderQuotaStateExpiration(quotaReports, quotaNow); ++ useEffect(() => { ++ if (!active) return; ++ const recheck = () => setQuotaClock(Date.now()); ++ // The render may cross this boundary before effects run. Keep its deadline and wake now. ++ // A new snapshot may be newer than this clock, so unknown state also gets one immediate check. ++ const timer = window.setTimeout(recheck, ++ quotaExpiry === undefined ? 0 : Math.max(0, quotaExpiry - Date.now())); ++ const onVisible = () => { if (document.visibilityState === "visible") recheck(); }; ++ document.addEventListener("visibilitychange", onVisible); ++ return () => { ++ window.clearTimeout(timer); ++ document.removeEventListener("visibilitychange", onVisible); ++ }; ++ }, [active, apiBase, quotaResource.data, quotaResource.lastAttemptOk, quotaExpiry]); + + const data = state.data ?? retainedData ?? undefined; + const combos = data?.combos ?? []; +@@ -361,7 +374,7 @@ export default function Combos({ + models={models} + cataloguedComboIds={cataloguedComboIds} + loading={false} +- onRefresh={() => resource.refresh()} ++ onRefresh={() => { resource.refresh(); quotaResource.refresh(); }} + onSave={saveCombo} + onRemove={removeCombo} + onAdd={() => setAdding(true)} +diff --git a/gui/tests/combo-workspace-dirty.test.tsx b/gui/tests/combo-workspace-dirty.test.tsx +index 1f3566dbbe..c82ac799d4 100644 +--- a/gui/tests/combo-workspace-dirty.test.tsx ++++ b/gui/tests/combo-workspace-dirty.test.tsx +@@ -2,7 +2,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; + import { Window } from "happy-dom"; + import { act, StrictMode } from "react"; + import type { Root } from "react-dom/client"; +-import type { ComboItem } from "../src/combo-workspace-data"; ++import { type ComboItem, providerQuotaStatesFromReports } from "../src/combo-workspace-data"; + import ComboWorkspace from "../src/components/ComboWorkspace"; + import { LanguageProvider } from "../src/i18n/provider"; + +@@ -178,11 +178,14 @@ test("dirty Save disables for exhausted targets and re-enables on quota recovery + document.body.append(container); + const root = createRoot(container); + +- const render = (quotaState: "available" | "exhausted") => ( ++ const now = Date.now(); ++ const display = { provider: "openai", updatedAt: now, ++ quota: { updatedAt: now, customWindows: [{ label: "Search", percent: 100 }] } }; ++ const render = (routingQuota?: Record) => ( + + + ); + +- await act(async () => { root.render(render("exhausted")); }); ++ await act(async () => { root.render(render({ state: "exhausted", updatedAt: now, validUntil: now + 60_000 })); }); + await flushTimers(); + await act(async () => { railButton(container, "combo/alpha").click(); }); + await flushTimers(); +@@ -208,7 +211,11 @@ test("dirty Save disables for exhausted targets and re-enables on quota recovery + expect(container.querySelector("#cwi-edit-save")!.disabled).toBe(true); + expect(container.textContent).toContain("All enabled targets are out of quota"); + +- await act(async () => { root.render(render("available")); }); ++ await act(async () => { root.render(render()); }); ++ expect(container.querySelector("#cwi-edit-save")!.disabled).toBe(false); ++ expect(container.textContent).not.toContain("All enabled targets are out of quota"); ++ ++ await act(async () => { root.render(render({ state: "available", updatedAt: now, validUntil: now + 60_000 })); }); + expect(container.querySelector("#cwi-edit-save")!.disabled).toBe(false); + expect(container.textContent).not.toContain("All enabled targets are out of quota"); + +diff --git a/gui/tests/combo-workspace-empty.test.tsx b/gui/tests/combo-workspace-empty.test.tsx +index 4fb8416067..ba6efc49d7 100644 +--- a/gui/tests/combo-workspace-empty.test.tsx ++++ b/gui/tests/combo-workspace-empty.test.tsx +@@ -5,6 +5,7 @@ import type { Root } from "react-dom/client"; + import { renderToStaticMarkup } from "react-dom/server"; + import ComboWorkspace from "../src/components/ComboWorkspace"; + import { LanguageProvider } from "../src/i18n/provider"; ++import { providerQuotaStatesFromReports } from "../src/combo-workspace-data"; + + const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; + let previousGlobals: Record<(typeof globals)[number], unknown>; +@@ -134,11 +135,14 @@ test("first-combo Create disables only while every usable target is known exhaus + document.body.append(container); + const root = createRoot(container); + +- const render = (quotaState: "available" | "exhausted") => ( ++ const now = Date.now(); ++ const display = { provider: "openai", updatedAt: now, ++ quota: { updatedAt: now, customWindows: [{ label: "Search", percent: 100 }] } }; ++ const render = (routingQuota?: Record) => ( + + + ); + +- await act(async () => { root.render(render("exhausted")); }); ++ await act(async () => { root.render(render({ state: "exhausted", updatedAt: now, validUntil: now + 60_000 })); }); + await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, 0)); }); + + const providerSelect = container.querySelector('select[aria-label="Provider"]')!; +@@ -167,7 +171,11 @@ test("first-combo Create disables only while every usable target is known exhaus + expect(createButton.disabled).toBe(true); + expect(container.textContent).toContain("All enabled targets are out of quota"); + +- await act(async () => { root.render(render("available")); }); ++ await act(async () => { root.render(render()); }); ++ expect(container.querySelector("#cwi-edit-create")!.disabled).toBe(false); ++ expect(container.textContent).not.toContain("All enabled targets are out of quota"); ++ ++ await act(async () => { root.render(render({ state: "available", updatedAt: now, validUntil: now + 60_000 })); }); + expect(container.querySelector("#cwi-edit-create")!.disabled).toBe(false); + expect(container.textContent).not.toContain("All enabled targets are out of quota"); + +diff --git a/gui/tests/page-loading-contract.test.tsx b/gui/tests/page-loading-contract.test.tsx +index 2ab6b88385..52a889fb28 100644 +--- a/gui/tests/page-loading-contract.test.tsx ++++ b/gui/tests/page-loading-contract.test.tsx +@@ -1,6 +1,6 @@ +-import { afterEach, beforeEach, expect, test } from "bun:test"; ++import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; + import { Window } from "happy-dom"; +-import { act } from "react"; ++import { act, useLayoutEffect } from "react"; + import type { Root } from "react-dom/client"; + import Combos from "../src/pages/Combos"; + import { LanguageProvider } from "../src/i18n/provider"; +@@ -204,3 +204,111 @@ test("Combos announces silent revalidation over cached content via aria-busy", a + await act(async () => { root.unmount(); }); + container.remove(); + }); ++ ++ ++test.each(["timer", "visible", "active", "commit-boundary"])("Combos expires a quota block before a new response: %s", async wake => { ++ const { createRoot } = await import("react-dom/client"); ++ const startedAt = Date.now(); ++ let now = startedAt; ++ const clock = spyOn(Date, "now").mockImplementation(() => now); ++ const schedule = testWindow.setTimeout.bind(testWindow); ++ const cancel = testWindow.clearTimeout.bind(testWindow); ++ const expiryTimers = new Set(); ++ let expire: (() => void) | undefined; ++ const scheduleSpy = spyOn(testWindow, "setTimeout").mockImplementation((callback, delay, ...args) => { ++ const timer = schedule(callback, delay, ...args); ++ if (delay === 123_456 && typeof callback === "function") { ++ expiryTimers.add(timer); ++ expire = () => callback(...args); ++ } ++ return timer; ++ }); ++ const cancelSpy = spyOn(testWindow, "clearTimeout").mockImplementation(timer => { ++ expiryTimers.delete(timer); ++ cancel(timer); ++ }); ++ const item = { id: "alpha", model: "combo/alpha", strategy: "failover", stickyLimit: 1, ++ targets: [{ provider: "keyed", model: "m1" }] }; ++ let quotaFetches = 0; ++ const workspaceFetches = new Map(); ++ const waitForAbort = (signal: AbortSignal | null | undefined) => new Promise((_resolve, reject) => { ++ if (signal?.aborted) reject(signal.reason); ++ else signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); ++ }); ++ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { ++ const url = String(input); ++ if (url.includes("/api/provider-quotas")) { ++ quotaFetches += 1; ++ if (quotaFetches > 1) return waitForAbort(init?.signal); ++ return Response.json({ reports: [{ provider: "keyed", updatedAt: startedAt, ++ quota: { updatedAt: startedAt, fiveHourPercent: 100 }, ++ routingQuota: { state: "exhausted", updatedAt: startedAt, validUntil: startedAt + 123_456 }, ++ }] }); ++ } ++ const count = (workspaceFetches.get(url) ?? 0) + 1; ++ workspaceFetches.set(url, count); ++ if (count > 1) return waitForAbort(init?.signal); ++ if (url.includes("/api/combos")) return Response.json({ combos: [item] }); ++ if (url.includes("/api/config")) return Response.json({ providers: { ++ keyed: { adapter: "openai-chat", authMode: "key", baseUrl: "https://provider.example/v1", defaultModel: "m1" }, ++ } }); ++ if (url.includes("/api/models")) return Response.json([ ++ { provider: "keyed", id: "m1" }, { provider: "combo", id: "alpha" }, ++ ]); ++ return new Response(null, { status: 404 }); ++ }) as typeof fetch; ++ const container = document.createElement("div"); ++ document.body.append(container); ++ const root = createRoot(container); ++ function ClockBoundary({ active, expireDuringCommit }: { active: boolean; expireDuringCommit: boolean }) { ++ useLayoutEffect(() => { ++ if (expireDuringCommit) now = startedAt + 123_456; ++ }, [expireDuringCommit]); ++ return ; ++ } ++ const render = (active = true, expireDuringCommit = false) => ++ ; ++ try { ++ await act(async () => { root.render(render()); }); ++ await act(async () => { await new Promise(resolve => schedule(resolve, 0)); }); ++ const rail = [...container.querySelectorAll(".combos-workspace-rail-row")] ++ .find(row => row.querySelector(".combos-workspace-rail-name")?.textContent === "combo/alpha"); ++ expect(rail).toBeDefined(); ++ await act(async () => { rail!.click(); }); ++ await act(async () => { await new Promise(resolve => schedule(resolve, 0)); }); ++ const alias = container.querySelector("#cwi-edit-alias")!; ++ await act(async () => { ++ Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")!.set!.call(alias, "kept-draft"); ++ alias.dispatchEvent(new testWindow.Event("input", { bubbles: true })); ++ }); ++ expect(container.querySelector("#cwi-edit-save")!.disabled).toBe(true); ++ expect(expire).toBeDefined(); ++ if (wake === "visible") { ++ Object.defineProperty(testWindow.document, "visibilityState", { configurable: true, value: "hidden" }); ++ await act(async () => { testWindow.document.dispatchEvent(new testWindow.Event("visibilitychange")); }); ++ } else if (wake === "active" || wake === "commit-boundary") { ++ await act(async () => { root.render(render(false)); }); ++ } ++ now = startedAt + 123_456 - (wake === "commit-boundary" ? 1 : 0); ++ await act(async () => { ++ if (wake === "timer") expire!(); ++ else if (wake === "visible") { ++ Object.defineProperty(testWindow.document, "visibilityState", { configurable: true, value: "visible" }); ++ testWindow.document.dispatchEvent(new testWindow.Event("visibilitychange")); ++ } else root.render(render(true, wake === "commit-boundary")); ++ }); ++ if (wake === "commit-boundary") { ++ await act(async () => { await new Promise(resolve => schedule(resolve, 0)); }); ++ } ++ expect(container.querySelector("#cwi-edit-alias")!.value).toBe("kept-draft"); ++ expect(container.querySelector("#cwi-edit-save")!.disabled).toBe(false); ++ if (wake === "timer") expect(quotaFetches).toBe(1); ++ } finally { ++ await act(async () => { root.unmount(); }); ++ container.remove(); ++ scheduleSpy.mockRestore(); ++ cancelSpy.mockRestore(); ++ clock.mockRestore(); ++ } ++ expect(expiryTimers.size).toBe(0); ++}); +diff --git a/src/providers/quota-routing-cache.ts b/src/providers/quota-routing-cache.ts +index 1e45d60065..3acaf1be50 100644 +--- a/src/providers/quota-routing-cache.ts ++++ b/src/providers/quota-routing-cache.ts +@@ -3,6 +3,7 @@ import type { OcxProviderConfig } from "../types"; + import type { ProviderQuota, ProviderQuotaReport } from "./quota"; + import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./key-store"; + import { getProviderRegistryEntry } from "./registry"; ++import { PROVIDER_QUOTA_MAX_AGE_MS } from "./quota-types"; + + export interface ProviderQuotaRoutingEvidence { + quota: ProviderQuota; +@@ -54,7 +55,7 @@ export function replaceCachedProviderQuotas( + export function getCachedProviderQuota( + provider: string, + now: number, +- maxAgeMs = 30 * 60_000, ++ maxAgeMs = PROVIDER_QUOTA_MAX_AGE_MS, + ): ProviderQuota | null { + const quota = quotaCache.get(provider)?.quota; + if (!quota) return null; +@@ -67,7 +68,7 @@ export function getCachedProviderRoutingQuota( + name: string, + provider: OcxProviderConfig | undefined, + now: number, +- maxAgeMs = 30 * 60_000, ++ maxAgeMs = PROVIDER_QUOTA_MAX_AGE_MS, + ): ProviderQuota | null { + if (!provider || provider.disabled === true || (provider.authMode ?? "key") !== "key") return null; + // An active-key report cannot speak for the other keys the dispatcher may select. +diff --git a/src/providers/quota-types.ts b/src/providers/quota-types.ts +index 873eb30221..e0bdf9cb4f 100644 +--- a/src/providers/quota-types.ts ++++ b/src/providers/quota-types.ts +@@ -8,6 +8,13 @@ + * blocks any later attempt to load one side without the other. + */ + ++export const PROVIDER_QUOTA_MAX_AGE_MS = 30 * 60_000; ++ ++/** Management-only eligibility evidence; private credential binding never leaves the server. */ ++export type ProviderRoutingQuota = ++ | { state: "unknown" } ++ | { state: "available" | "exhausted"; updatedAt: number; validUntil: number }; ++ + export interface ProviderQuotaWindow { + label: string; + percent: number; +diff --git a/src/providers/quota.ts b/src/providers/quota.ts +index ab23070bea..f6d96c5a35 100644 +--- a/src/providers/quota.ts ++++ b/src/providers/quota.ts +@@ -54,6 +54,7 @@ import type { + ProviderQuota, + ProviderQuotaCreditsUsd, + ProviderQuotaWindow, ++ ProviderRoutingQuota, + } from "./quota-types"; + import { + clearKiroAccountUsageState, +@@ -139,6 +140,8 @@ export interface ProviderQuotaReport { + source: string; + quota: ProviderQuota; + updatedAt: number; ++ /** Added by the management response projection, never stored on a cached report. */ ++ routingQuota?: ProviderRoutingQuota; + reverseEngineered?: boolean; + /** + * The row was OBSERVED in-band on a streaming turn rather than probed. +diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts +index 1439d7899c..847b240994 100644 +--- a/src/server/management/provider-routes.ts ++++ b/src/server/management/provider-routes.ts +@@ -57,6 +57,9 @@ import { + import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery"; + import { routedSlug, slugEquals } from "../../providers/slug-codec"; + import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; ++import { getCachedProviderRoutingQuota } from "../../providers/quota-routing-cache"; ++import { PROVIDER_QUOTA_MAX_AGE_MS, type ProviderRoutingQuota } from "../../providers/quota-types"; ++import { cachedProviderQuotaIsExhausted } from "../../combos/resolve"; + import { clearKeyCooldowns } from "../../providers/key-failover"; + import { providerRequestPacingStatus } from "../../providers/request-pacing"; + import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +@@ -689,12 +692,54 @@ function canonicalOpenAiBudgetPatchError( + ?? providerEmptyToolOutputConfigError("openai", applied.next); + } + ++function providerRoutingQuota(config: OcxConfig, name: string, now: number): ProviderRoutingQuota { ++ const provider = hasOwnProvider(config.providers, name) ? config.providers[name] : undefined; ++ const quota = getCachedProviderRoutingQuota(name, provider, now); ++ if (!quota || !Number.isFinite(quota.updatedAt) || quota.updatedAt < 0 || quota.updatedAt > now ++ || now >= quota.updatedAt + PROVIDER_QUOTA_MAX_AGE_MS) return { state: "unknown" }; ++ ++ // Removing search/MCP windows may leave only a timestamp. That is not inference evidence. ++ const percentages = [quota.fiveHourPercent, quota.weeklyPercent, quota.monthlyPercent, ++ ...(quota.customWindows ?? []).map(window => window.percent)]; ++ const hasPercentage = percentages.some(value => typeof value === "number" && Number.isFinite(value) && value >= 0); ++ const credits = quota.creditsUsd; ++ const hasCredits = credits !== undefined && Number.isFinite(credits.percent) ++ && credits.percent >= 0 && Number.isFinite(credits.remaining); ++ if (!hasPercentage && !hasCredits) return { state: "unknown" }; ++ ++ const state = cachedProviderQuotaIsExhausted(quota, now) ? "exhausted" : "available"; ++ let validUntil = quota.updatedAt + PROVIDER_QUOTA_MAX_AGE_MS; ++ if (state === "exhausted") { ++ const resets = [quota.fiveHourResetAt, quota.weeklyResetAt, quota.monthlyResetAt, ++ ...(quota.customWindows ?? []).map(window => window.resetAt)] ++ .filter((reset): reset is number => typeof reset === "number" && Number.isFinite(reset) ++ && reset > now && reset < validUntil) ++ .sort((left, right) => left - right); ++ // Reuse dispatch's predicate: another exhausted window or USD cap may still block. ++ for (const reset of resets) { ++ if (!cachedProviderQuotaIsExhausted(quota, reset)) { ++ validUntil = reset; ++ break; ++ } ++ } ++ } ++ return { state, updatedAt: quota.updatedAt, validUntil }; ++} ++ + export async function handleProviderRoutes(ctx: ManagementContext): Promise { + const { req, url, config, deps, principal, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; + + if (url.pathname === "/api/provider-quotas" && req.method === "GET") { + const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true"; +- return jsonResponse(await fetchProviderQuotaReports(config, forceRefresh)); ++ const snapshot = await fetchProviderQuotaReports(config, forceRefresh); ++ const now = Date.now(); ++ return jsonResponse({ ++ ...snapshot, ++ reports: snapshot.reports.map(report => ({ ++ ...report, ++ routingQuota: providerRoutingQuota(config, report.provider, now), ++ })), ++ }); + } + + if (url.pathname === "/api/provider-request-pacing" && req.method === "GET") { +diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md +index ecade01c78..f91195e8bb 100644 +--- a/structure/04_transports-and-sidecars.md ++++ b/structure/04_transports-and-sidecars.md +@@ -1657,6 +1657,15 @@ credential cannot inherit another key's cap. The same getter controls immediate + bounded cooldown waiting and reset-window ordering. This does not override explicit eligibility, + target cooldowns, account admission or response-driven retry rules. + ++The management quota response projects a separate `routingQuota` from this evidence after each ++probe or cached read, using the current provider row. It contains only a state, observation time ++and `validUntil`; the cached display report and private binding remain unchanged. Known states ++expire after 30 minutes or, for exhaustion, when the dispatch predicate first clears at a reset ++boundary. Multiple windows and USD blockers use that same predicate. The Combo editor uses only ++this projection for quota-based Save/Create blocking and treats missing, invalid or expired ++evidence as unknown. It schedules the rendered expiry even when that deadline passes before ++effects run, rechecks on activation/visibility, and refreshes quota alongside Combo data. ++ + ```text + [Decision Log] + - 목적과 의도: Keep account-, model- and service-scoped quota from disabling an otherwise usable Combo provider while retaining valid single-key inference caps. +diff --git a/tests/gui/combo-workspace-data.test.ts b/tests/gui/combo-workspace-data.test.ts +index e3d340f2f8..e754020636 100644 +--- a/tests/gui/combo-workspace-data.test.ts ++++ b/tests/gui/combo-workspace-data.test.ts +@@ -13,6 +13,7 @@ import { + isValidComboId, + parseComboList, + providerQuotaStatesFromReports, ++ nextProviderQuotaStateExpiration, + toPutBody, + updateComboAliasDraft, + validateComboDraft, +@@ -42,6 +43,31 @@ function quotaReport( + }; + } + ++describe("server-scoped Combo quota", () => { ++ test("display exhaustion without routing authority stays unknown", () => { ++ expect(providerQuotaStatesFromReports([ ++ quotaReport("oauth", { fiveHourPercent: 100 }), ++ quotaReport("search", { customWindows: [{ label: "Search", percent: 100 }] }), ++ ], QUOTA_NOW)).toEqual({ oauth: "unknown", search: "unknown" }); ++ }); ++ ++ test("uses current server routing state instead of display windows", () => { ++ expect(providerQuotaStatesFromReports([ ++ quotaReport("search", { customWindows: [{ label: "Search", percent: 100 }] }, { ++ routingQuota: { state: "available", updatedAt: QUOTA_NOW, validUntil: QUOTA_NOW + 60_000 }, ++ }), ++ ], QUOTA_NOW)).toEqual({ search: "available" }); ++ }); ++ ++ test("expires routing authority at its reset boundary", () => { ++ expect(providerQuotaStatesFromReports([ ++ quotaReport("spent", { fiveHourPercent: 100 }, { ++ routingQuota: { state: "exhausted", updatedAt: QUOTA_NOW - 100, validUntil: QUOTA_NOW }, ++ }), ++ ], QUOTA_NOW)).toEqual({ spent: "unknown" }); ++ }); ++}); ++ + function combo(overrides: Partial = {}): ComboItem { + return { + id: "free", +@@ -297,87 +323,57 @@ describe("combo-workspace-data", () => { + ]); + }); + +- test("derives exhausted state from USD, percentage, and custom-window evidence", () => { ++ test("accepts known routing states independently of display data", () => { + expect(providerQuotaStatesFromReports([ +- quotaReport("usd", { +- creditsUsd: { used: 10, limit: 10, remaining: 0, percent: 100 }, ++ quotaReport(" keyed ", { fiveHourPercent: 0 }, { ++ routingQuota: { state: "exhausted", updatedAt: QUOTA_NOW, validUntil: QUOTA_NOW + 60_000 }, + }), +- quotaReport("percent", { fiveHourPercent: 100 }), +- quotaReport("custom", { customWindows: [{ label: "Daily", percent: 101 }] }), +- ], QUOTA_NOW)).toEqual({ +- usd: "exhausted", +- percent: "exhausted", +- custom: "exhausted", +- }); +- }); +- +- test("keeps unlimited credits available and stale or malformed evidence unknown", () => { +- expect(providerQuotaStatesFromReports([ +- quotaReport("unlimited", { +- creditsUsd: { used: 0, limit: 0, remaining: 0, percent: 0, unlimited: true }, ++ quotaReport("unlimited", { creditsUsd: { remaining: 0, unlimited: true } }, { ++ routingQuota: { state: "available", updatedAt: QUOTA_NOW, validUntil: QUOTA_NOW + 60_000 }, + }), +- quotaReport("stale", { weeklyPercent: 100 }, { updatedAt: QUOTA_NOW - 30 * 60_000 }), +- quotaReport("malformed", { fiveHourPercent: "100" }), +- quotaReport("missing", {}), +- ], QUOTA_NOW)).toEqual({ +- unlimited: "available", +- stale: "unknown", +- malformed: "unknown", +- missing: "unknown", +- }); ++ ], QUOTA_NOW)).toEqual({ keyed: "exhausted", unlimited: "available" }); ++ }); ++ ++ test("rejects malformed, future, stale and overlong routing lifetimes", () => { ++ const fresh = { state: "exhausted", updatedAt: QUOTA_NOW, validUntil: QUOTA_NOW + 1000 }; ++ const bad = [ ++ undefined, null, [], { ...fresh, state: "maybe" }, ++ { ...fresh, updatedAt: "100" }, { ...fresh, updatedAt: NaN }, ++ { ...fresh, updatedAt: -1 }, { ...fresh, updatedAt: QUOTA_NOW + 1 }, ++ { ...fresh, updatedAt: QUOTA_NOW - 30 * 60_000 }, ++ { ...fresh, validUntil: undefined }, { ...fresh, validUntil: Infinity }, ++ { ...fresh, validUntil: QUOTA_NOW }, { ...fresh, validUntil: QUOTA_NOW + 30 * 60_000 + 1 }, ++ ]; ++ for (const routingQuota of bad) { ++ expect(providerQuotaStatesFromReports([ ++ quotaReport("keyed", { weeklyPercent: 100 }, { routingQuota }), ++ ], QUOTA_NOW)).toEqual({ keyed: "unknown" }); ++ } + }); + +- test("trims provider ids and rejects incomplete aggregate quota evidence", () => { ++ test("complete display aggregates cannot authorize a provider-wide block", () => { + expect(providerQuotaStatesFromReports([ +- quotaReport(" openai ", { weeklyPercent: 75 }), + quotaReport("pool", { weeklyPercent: 100 }, { + aggregation: { +- kind: "capacity-weighted-v1", +- scope: "routable-known", +- presentation: "aggregate", +- incomplete: true, +- excludedAccounts: 1, +- unknownPlanAccounts: 0, ++ kind: "capacity-weighted-v1", scope: "routable-known", presentation: "aggregate", ++ incomplete: false, includedAccounts: 2, excludedAccounts: 0, unknownPlanAccounts: 0, ++ missingQuotaAccounts: 0, pausedAccounts: 0, reauthAccounts: 0, staleQuotaAccounts: 0, + partialWindowAccounts: 0, ++ weekly: { usedPercent: 100, includedAccounts: 2, excludedAccounts: 0, incomplete: false, updatedAt: QUOTA_NOW }, + }, + }), +- quotaReport("malformed-pool", { weeklyPercent: 100 }, { +- aggregation: { +- kind: "capacity-weighted-v1", +- scope: "routable-known", +- presentation: "aggregate", +- incomplete: false, +- }, +- }), +- quotaReport("complete-pool", { weeklyPercent: 100 }, { +- aggregation: { +- kind: "capacity-weighted-v1", +- scope: "routable-known", +- presentation: "aggregate", +- incomplete: false, +- includedAccounts: 2, +- excludedAccounts: 0, +- unknownPlanAccounts: 0, +- missingQuotaAccounts: 0, +- pausedAccounts: 0, +- reauthAccounts: 0, +- staleQuotaAccounts: 0, +- partialWindowAccounts: 0, +- weekly: { +- usedPercent: 100, +- includedAccounts: 2, +- excludedAccounts: 0, +- incomplete: false, +- updatedAt: QUOTA_NOW, +- }, +- }, +- }), +- ], QUOTA_NOW)).toEqual({ +- openai: "available", +- pool: "unknown", +- "malformed-pool": "unknown", +- "complete-pool": "exhausted", +- }); ++ ], QUOTA_NOW)).toEqual({ pool: "unknown" }); ++ }); ++ ++ test("conflicting duplicate rows stay unknown and the next valid expiry is selected", () => { ++ const rows = [ ++ quotaReport("keyed", {}, { routingQuota: { state: "available", updatedAt: QUOTA_NOW, validUntil: QUOTA_NOW + 5000 } }), ++ quotaReport("keyed", {}, { routingQuota: { state: "exhausted", updatedAt: QUOTA_NOW, validUntil: QUOTA_NOW + 1000 } }), ++ quotaReport("bad", {}, { routingQuota: { state: "exhausted", updatedAt: QUOTA_NOW, validUntil: QUOTA_NOW - 1 } }), ++ ]; ++ expect(providerQuotaStatesFromReports(rows, QUOTA_NOW)).toEqual({ keyed: "unknown", bad: "unknown" }); ++ expect(nextProviderQuotaStateExpiration(rows, QUOTA_NOW)).toBe(QUOTA_NOW + 1000); ++ expect(nextProviderQuotaStateExpiration(rows, QUOTA_NOW + 5000)).toBeUndefined(); + }); + + test("combo quota excludes disabled targets and disables only when every usable target is exhausted", () => { +diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts +index 09bfb1e67c..949791f2d6 100644 +--- a/tests/server/management-provider-validation.test.ts ++++ b/tests/server/management-provider-validation.test.ts +@@ -48,6 +48,8 @@ import { getAccountSet, saveCredential } from "../../src/oauth/store"; + import { fastPolicyForModel } from "../../src/providers/service-tier"; + import { resolveWireProtocolOverride } from "../../src/server/adapter-resolve"; + import { removeTreeWithRetry } from "../helpers/remove-tree"; ++import { clearProviderQuotaCache, fetchProviderQuotaReports, setProviderQuotaBeforePublishForTests } from "../../src/providers/quota"; ++import { setCachedProviderQuotaForTests } from "../../src/providers/quota-routing-cache"; + + // Full-suite Windows load: startServer + multi-step provider PATCH/GET flows exceed the + // default 5s per-test budget (same flake class as 810fa115 / claude-management-api). +@@ -136,6 +138,153 @@ afterEach(() => { + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + }); + ++describe("provider quota routing state", () => { ++ function quotaConfig(name = "openrouter", baseUrl = "https://openrouter.ai/api/v1"): OcxConfig { ++ return { port: 10100, defaultProvider: name, providers: { [name]: { ++ adapter: "openai-chat", authMode: "key", baseUrl, apiKey: "synthetic-probed-key", ++ } } }; ++ } ++ ++ async function readQuota(cfg: OcxConfig, force = false) { ++ const url = new URL(`http://localhost/api/provider-quotas${force ? "?refresh=1" : ""}`); ++ const response = await handleManagementAPI(new Request(url), url, cfg); ++ expect(response?.status).toBe(200); ++ return response!.json(); ++ } ++ ++ beforeEach(() => { ++ mkdirSync(TEST_DIR, { recursive: true }); ++ process.env.OPENCODEX_HOME = TEST_DIR; ++ clearProviderQuotaCache(); ++ setProviderQuotaBeforePublishForTests(null); ++ }); ++ ++ afterEach(() => { ++ clearProviderQuotaCache(); ++ setProviderQuotaBeforePublishForTests(null); ++ }); ++ ++ test("projects bound inference state without mutating display reports", async () => { ++ const cfg: OcxConfig = { ++ port: 10100, ++ defaultProvider: "openrouter", ++ providers: { ++ openrouter: { ++ adapter: "openai-chat", authMode: "key", ++ baseUrl: "https://openrouter.ai/api/v1", apiKey: "synthetic-probed-key", ++ }, ++ }, ++ }; ++ globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; ++ const url = new URL("http://localhost/api/provider-quotas"); ++ const response = await handleManagementAPI(new Request(url), url, cfg); ++ expect(response?.status).toBe(200); ++ const dto = await response!.json(); ++ const row = dto.reports.find((item: { provider: string }) => item.provider === "openrouter"); ++ expect(row.routingQuota).toEqual({ ++ state: "exhausted", updatedAt: row.quota.updatedAt, ++ validUntil: row.quota.updatedAt + 30 * 60_000, ++ }); ++ const cached = await fetchProviderQuotaReports(cfg, false); ++ expect(cached.reports[0]).not.toHaveProperty("routingQuota"); ++ expect(row.quota).toEqual(cached.reports[0]!.quota); ++ expect(JSON.stringify(dto)).not.toContain("synthetic-probed-key"); ++ expect(JSON.stringify(dto)).not.toContain("binding"); ++ }); ++ ++ test("single-key capacity recovers on refresh and an uncapped key drops its old cap", async () => { ++ const cfg = quotaConfig(); ++ let payload = { limit: 20 as number | null, limit_remaining: 0 }; ++ globalThis.fetch = (async () => Response.json({ data: payload })) as typeof fetch; ++ expect((await readQuota(cfg)).reports[0].routingQuota.state).toBe("exhausted"); ++ payload = { limit: 20, limit_remaining: 8 }; ++ expect((await readQuota(cfg, true)).reports[0].routingQuota.state).toBe("available"); ++ payload = { limit: null, limit_remaining: 0 }; ++ expect((await readQuota(cfg, true)).reports).toEqual([]); ++ }); ++ ++ test.each(["authorization", "x-api-key", "x-goog-api-key", "key-pool", "oauth"])( ++ "rechecks current credential scope: %s", async change => { ++ const cfg = quotaConfig(); ++ globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; ++ expect((await readQuota(cfg)).reports[0].routingQuota.state).toBe("exhausted"); ++ if (change === "key-pool") cfg.providers.openrouter!.apiKeyPool = [ ++ { id: "primary", key: "synthetic-probed-key" }, ++ { id: "secondary", key: "other-key" }, ++ ]; ++ else if (change === "oauth") cfg.providers.openrouter!.authMode = "oauth"; ++ else cfg.providers.openrouter!.headers = { [change]: "other-credential" }; ++ const dto = await readQuota(cfg); ++ expect(dto.reports.every((row: { routingQuota: { state: string } }) => row.routingQuota.state === "unknown")).toBe(true); ++ }, ++ ); ++ ++ test("reads a provider row replaced while the quota probe is awaiting publication", async () => { ++ const cfg = quotaConfig(); ++ let replaced = false; ++ globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; ++ setProviderQuotaBeforePublishForTests(() => { ++ cfg.providers.openrouter = { ...cfg.providers.openrouter!, apiKey: "replacement-key" }; ++ replaced = true; ++ }); ++ const dto = await readQuota(cfg); ++ expect(replaced).toBe(true); ++ expect(dto.reports.every((row: { routingQuota: { state: string } }) => row.routingQuota.state === "unknown")).toBe(true); ++ }); ++ ++ test("search-only and MCP-only display windows have no inference authority", async () => { ++ const cfg: OcxConfig = { port: 10100, defaultProvider: "synthetic", providers: { ++ ...quotaConfig("synthetic", "https://api.synthetic.new/v2").providers, ++ ...quotaConfig("zai", "https://api.z.ai/api/coding/paas/v4").providers, ++ } }; ++ globalThis.fetch = (async input => String(input).includes("synthetic") ++ ? Response.json({ data: { search: { hourly: 100 } } }) ++ : Response.json({ success: true, data: { monthlyMCPUsage: 100 } })) as typeof fetch; ++ const dto = await readQuota(cfg); ++ expect(dto.reports).toHaveLength(2); ++ expect(dto.reports.every((row: { routingQuota: { state: string } }) => row.routingQuota.state === "unknown")).toBe(true); ++ expect(dto.reports.find((row: { provider: string }) => row.provider === "synthetic").quota.customWindows[0].percent).toBe(100); ++ expect(dto.reports.find((row: { provider: string }) => row.provider === "zai").quota.monthlyPercent).toBe(100); ++ }); ++ ++ test("an exhausted OAuth account report stays display-only", async () => { ++ const cfg = quotaConfig("kimi", "https://api.kimi.com/coding/v1"); ++ cfg.providers.kimi!.authMode = "oauth"; ++ await saveCredential("kimi", { access: "synthetic-account-access", refresh: "synthetic-account-refresh", expires: Date.now() + 3600_000 }); ++ globalThis.fetch = (async () => Response.json({ usage: { limit: "100", used: "100" } })) as typeof fetch; ++ const dto = await readQuota(cfg); ++ expect(dto.reports[0].quota.weeklyPercent).toBe(100); ++ expect(dto.reports[0].routingQuota).toEqual({ state: "unknown" }); ++ }); ++ ++ test("cached responses respect reset boundaries, persistent blockers and evidence expiry", async () => { ++ const cfg = quotaConfig(); ++ let probes = 0; ++ globalThis.fetch = (async () => { ++ probes += 1; ++ return Response.json({ data: { limit: 20, limit_remaining: 0 } }); ++ }) as typeof fetch; ++ const first = await readQuota(cfg); ++ const now = Date.now(); ++ const quota = { updatedAt: now, fiveHourPercent: 100, fiveHourResetAt: now + 10_000, ++ weeklyPercent: 100, weeklyResetAt: now + 20_000 }; ++ setCachedProviderQuotaForTests("openrouter", quota); ++ expect((await readQuota(cfg)).reports[0].routingQuota.validUntil).toBe(now + 20_000); ++ setCachedProviderQuotaForTests("openrouter", { ...quota, creditsUsd: { used: 20, limit: 20, remaining: 0, percent: 100 } }); ++ expect((await readQuota(cfg)).reports[0].routingQuota.validUntil).toBe(now + 30 * 60_000); ++ setCachedProviderQuotaForTests("openrouter", { updatedAt: now, fiveHourPercent: 100, fiveHourResetAt: now - 1 }); ++ expect((await readQuota(cfg)).reports[0].routingQuota.state).toBe("available"); ++ setCachedProviderQuotaForTests("openrouter", { updatedAt: now, ++ creditsUsd: { used: 0, limit: 0, remaining: 0, percent: 0, unlimited: true } }); ++ expect((await readQuota(cfg)).reports[0].routingQuota.state).toBe("available"); ++ setCachedProviderQuotaForTests("openrouter", { ...quota, updatedAt: now - 30 * 60_000 }); ++ const stale = await readQuota(cfg); ++ expect(stale.reports[0].routingQuota).toEqual({ state: "unknown" }); ++ expect(stale.reports[0].quota).toEqual(first.reports[0].quota); ++ expect(probes).toBe(1); ++ }); ++}); ++ + describe("provider management validation", () => { + test("provider reload adopts only the validated disk row without rewriting config", async () => { + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); +``` diff --git a/devlog/_plan/260912_combo_carry/030_verification.md b/devlog/_plan/260912_combo_carry/030_verification.md new file mode 100644 index 0000000000..75b4a4e98c --- /dev/null +++ b/devlog/_plan/260912_combo_carry/030_verification.md @@ -0,0 +1,11 @@ +# Final verification + +Depends on editor. MODIFY PR descriptions with exact source/head, manual chain map, attribution and hosted CI evidence; NEW .tmp/combo-handoff/HANDOFF.md, security review and screenshots. No planned product edits; any hosted failure requires a concrete P amendment before repair. + +Before: no same-repository carries, no final cumulative proof. After: bottom runtime PR -> editor child PR with editor-only delta; git merge-base --is-ancestor lower upper exits zero, gh pr view proves head/base, gh run view proves successful final-head hosted CI and run URL. Capture actual rendered Combo editor with synthetic fixture data using an existing runtime or hosted-built artifact (no local build/install). Fresh exhausted target disables Save; unknown or expired permits it. Screenshot file must be durably accessible and included in upper PR body. No screenshot waiver. + +Security review checks binding creation, private WeakMap retention, report serialization, key-pool/OAuth exclusions and invalidation against current configuration. Review is distinct from maintainer approval. CI negatives cover changed credentials, destination, auth, headers, key pools, display-only limits, fresh/expired/malformed evidence. All local tests NOT RUN. Record any unmet browser/architect/review gate honestly. + +## Audit repair amendment + +MODIFY lower-layer `tests/providers/provider-quota.test.ts`: explicit-projection test `exhausted` and `projected` objects each gain `updatedAt: Date.now()`. MODIFY lower-layer `tests/codex-integration/catalog-zero-credit-picker.test.ts`: display and bound report fixtures each gain `label: "Alpha"`. Keep runtime and assertions unchanged. Commit on own lower branch, rebase own editor branch onto the new lower tip, push lower normally and upper with explicit lease plus --no-verify. This preserves editor-only upper delta. Independent runtime security audit PASS; these low fixture contract findings are accepted. Recheck original current heads and final carry bases/head/CI; no merge. diff --git a/docs-site/src/content/docs/fr/guides/combos.md b/docs-site/src/content/docs/fr/guides/combos.md index 9785135d53..9434a1e98a 100644 --- a/docs-site/src/content/docs/fr/guides/combos.md +++ b/docs-site/src/content/docs/fr/guides/combos.md @@ -190,6 +190,8 @@ indique la réinitialisation de fenêtre à venir la plus proche (cinq heures, h Le fournisseur dont le quota se renouvelle en premier est ainsi sollicité. Les cibles dépourvues de données de quota récentes et les égalités conservent l’ordre de configuration. `weight` et `stickyLimit` n’affectent pas cette stratégie. +Ce classement et l’exclusion des fournisseurs avant l’envoi exigent des limites récentes d’inférence de modèles applicables dans leur ensemble à l’unique clé API actuelle. Les résumés OAuth ou du compte courant, les routes transmettant les identifiants de l’appelant, les configurations à plusieurs clés et les instantanés dont les identifiants ou la destination ont changé servent uniquement à l’affichage pour cette décision préalable. Il en va de même lorsque les en-têtes `Authorization`, `x-api-key` ou `x-goog-api-key` remplacent les identifiants ; les fenêtres réservées à la recherche ou à MCP sont exclues. Si aucune cible admissible n’a de réinitialisation applicable, l’ordre de configuration prévaut. La sélection des comptes et les nouvelles tentatives appliquent toujours leurs limites habituelles. + ## Que se passe-t-il lorsqu'une cible échoue Les échecs d’un combo se répartissent entre ceux qui entraînent un **basculement** et les échecs **terminaux**. diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index db94da045f..c7d076d9b7 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -202,6 +202,8 @@ shows the soonest upcoming window reset (five-hour, weekly, monthly, or custom). provider that refreshes first. Targets without fresh quota data, and ties, keep configuration order. Weights and `stickyLimit` do not affect this strategy. +This ranking and provider exclusion before dispatch require fresh model-inference limits that apply to the current single API key as a whole. OAuth/current-account summaries, caller-forward routes, multiple keys, and snapshots with changed credentials or destinations are display-only for this early decision. The same applies when `Authorization`, `x-api-key`, or `x-goog-api-key` headers override credentials; search-only and MCP-only windows are excluded. If no eligible target has an applicable reset, configuration order wins. Account selection and retries still enforce their normal limits. + ## What happens when a target fails Combo failures are divided into **hop** failures and **terminal** failures. diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md index 655dae2232..f6eca53214 100644 --- a/docs-site/src/content/docs/ja/guides/combos.md +++ b/docs-site/src/content/docs/ja/guides/combos.md @@ -113,6 +113,8 @@ ocx combo set balanced \ `reset-window` は、キャッシュされたプロバイダーのクォータスナップショットで、次回のウィンドウリセット(5 時間、週次、月次、またはカスタム)が最も早い適格なターゲットへ、各リクエストをルーティングします。これにより、最初にクォータが補充されるプロバイダーを先に使用します。新しいクォータデータがないターゲットと、リセット時刻が同じターゲットでは、構成順序が維持されます。`weight` と `stickyLimit` はこの戦略に影響しません。 +この順位付けと送信前のプロバイダー除外には、現在の単一 API キー全体に適用される最新のモデル推論制限が必要です。OAuth/現在のアカウントの概要、呼び出し元の認証情報を転送するルート、複数キー、認証情報や送信先が変わったスナップショットは、この事前判断では表示専用です。`Authorization`、`x-api-key`、`x-goog-api-key` ヘッダーで認証情報を上書きする場合も同様で、検索専用および MCP 専用ウィンドウは対象外です。適用可能なリセット情報を持つ適格な対象がなければ、設定順序を使用します。アカウント選択と再試行には引き続き通常の制限が適用されます。 + ## ターゲットが失敗すると何が起こるか コンボ障害は、**ホップ** 障害と **ターミナル** 障害に分類されます。 diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md index 633feb838b..71e557f25c 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -119,6 +119,8 @@ ocx combo set balanced \ `reset-window`는 캐시된 공급자 할당량 스냅샷에서 가장 가까운 다음 기간 재설정(5시간, 주간, 월간 또는 사용자 지정)이 표시되는 적합한 대상으로 각 요청을 라우팅합니다. 이렇게 하면 가장 먼저 새로 충전되는 공급자를 사용합니다. 최신 할당량 데이터가 없는 대상과 동률인 대상은 설정 순서를 유지합니다. `weight`와 `stickyLimit`은 이 전략에 영향을 주지 않습니다. +이 순위 결정과 전송 전 공급자 제외에는 현재 단일 API 키의 전체 모델 추론에 적용되는 최신 한도 정보가 필요합니다. OAuth·현재 계정 요약, 호출자 인증을 전달하는 경로, 여러 키, 인증 정보나 목적지가 달라진 스냅샷은 이 사전 판단에서 표시 용도로만 사용합니다. `Authorization`, `x-api-key`, `x-goog-api-key` 헤더로 인증을 덮어쓰는 경우도 같으며, 검색 전용·MCP 전용 기간은 제외합니다. 적격 대상 중 적용 가능한 초기화 정보가 없으면 설정 순서를 따릅니다. 실제 계정 선택과 재시도에는 기존 제한이 계속 적용됩니다. + ## 대상 실패 시 동작 콤보 실패는 **홉** 실패와 **종결** 실패로 나뉩니다. diff --git a/docs-site/src/content/docs/ru/guides/combos.md b/docs-site/src/content/docs/ru/guides/combos.md index 3d4820e521..868416ae5b 100644 --- a/docs-site/src/content/docs/ru/guides/combos.md +++ b/docs-site/src/content/docs/ru/guides/combos.md @@ -150,6 +150,8 @@ ocx combo set balanced \ данных о квоте, а также цели с одинаковым временем сброса сохраняют порядок конфигурации. Значения `weight` и `stickyLimit` не влияют на эту стратегию. +Для этого ранжирования и исключения провайдеров до отправки нужны свежие лимиты инференса моделей, применимые к единственному текущему API-ключу в целом. Сводки OAuth и текущего аккаунта, маршруты с передачей учётных данных вызывающей стороны, несколько ключей и снимки с изменившимися учётными данными или адресом назначения служат только для отображения при этом предварительном решении. То же относится к переопределению учётных данных заголовками `Authorization`, `x-api-key` или `x-goog-api-key`; окна только для поиска или MCP исключаются. Если ни у одной допустимой цели нет подходящего времени сброса, используется порядок конфигурации. При выборе аккаунта и повторных попытках по-прежнему действуют обычные ограничения. + ## Что происходит, когда цель сбоит Сбои в combo делятся на **hop**-сбои и **terminal**-сбои. diff --git a/docs-site/src/content/docs/tr/guides/combos.md b/docs-site/src/content/docs/tr/guides/combos.md index 8b67170068..520c157e83 100644 --- a/docs-site/src/content/docs/tr/guides/combos.md +++ b/docs-site/src/content/docs/tr/guides/combos.md @@ -218,6 +218,8 @@ kullanılır. Güncel kota verisi bulunmayan hedeflerde ve eşitliklerde yapılandırma sırası korunur. `weight` değerleri ve `stickyLimit` bu stratejiyi etkilemez. +Bu sıralama ve gönderim öncesi sağlayıcı elemesi, mevcut tek API anahtarının model çıkarımı kullanımının tamamına uygulanan güncel sınırlara dayanır. OAuth veya geçerli hesap özetleri, çağıranın kimlik bilgilerini ileten rotalar, birden fazla anahtar ve kimlik bilgileri ya da hedefi değişmiş anlık görüntüler, bu ön kararda yalnızca görüntüleme amaçlıdır. `Authorization`, `x-api-key` veya `x-goog-api-key` başlıkları kimlik bilgilerini geçersiz kıldığında da aynı kural uygulanır; yalnızca arama veya MCP için olan pencereler hariç tutulur. Uygun hedeflerin hiçbirinde geçerli sıfırlama bilgisi yoksa yapılandırma sırası kullanılır. Hesap seçimi ve yeniden denemelerde normal sınırlar uygulanmaya devam eder. + ## Bir hedef başarısız olduğunda ne olur? Kombo hataları **atlama (hop)** hataları ve **uç (terminal)** hatalar olarak diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md index fea189deb3..d84efca472 100644 --- a/docs-site/src/content/docs/zh-cn/guides/combos.md +++ b/docs-site/src/content/docs/zh-cn/guides/combos.md @@ -139,6 +139,8 @@ ocx combo set balanced \ `reset-window` 会将每个请求路由到合格目标中,其缓存的提供商额度快照显示下一个窗口最早重置者(五小时、每周、每月或自定义窗口)。这样会优先消耗最先刷新额度的提供商。没有最新额度数据的目标以及并列目标会保持配置顺序。`weight` 和 `stickyLimit` 不影响此策略。 +此排序和发送前的提供商排除,需要适用于当前单个 API 密钥全部模型推理的最新限额信息。OAuth/当前账户摘要、转发调用方凭据的路由、多密钥以及凭据或目标地址已改变的快照,在这项提前判断中仅供显示。通过 `Authorization`、`x-api-key` 或 `x-goog-api-key` 请求头覆盖凭据时也适用相同规则;仅用于搜索或 MCP 的窗口不参与判断。如果所有符合条件的目标都没有适用的重置时间,则按配置顺序选择。实际账户选择和重试仍执行正常限制。 + ## 目标失败时会发生什么 combo 失败分为 **跳转** 失败和 **终止** 失败。 diff --git a/docs-site/src/content/docs/zh-tw/guides/combos.md b/docs-site/src/content/docs/zh-tw/guides/combos.md index bb8ef901f9..d82b399e6f 100644 --- a/docs-site/src/content/docs/zh-tw/guides/combos.md +++ b/docs-site/src/content/docs/zh-tw/guides/combos.md @@ -154,6 +154,8 @@ ocx combo set balanced \ `reset-window` 將每個請求路由至快取供應商配額快照顯示下一個時段最早重設的合格目標(五小時、每週、每月或自訂)。這會優先使用最早重新取得額度的供應商。沒有最新配額資料的目標,以及發生平手時,皆維持設定順序。`weight` 與 `stickyLimit` 不影響此策略。 +此排序與傳送前的供應商排除,需要適用於目前單一 API 金鑰全部模型推論的最新限額資訊。OAuth/目前帳戶摘要、轉送呼叫者憑證的路由、多金鑰,以及憑證或目的地位址已變更的快照,在這項預先判斷中僅供顯示。透過 `Authorization`、`x-api-key` 或 `x-goog-api-key` 標頭覆寫憑證時也適用相同規則;僅供搜尋或 MCP 使用的時段不參與判斷。若所有符合條件的目標都沒有適用的重設時間,則依設定順序選擇。實際帳戶選擇與重試仍套用一般限制。 + ## 目標失敗時會發生什麼 Combo 失敗分為**跳轉**失敗與**終端**失敗。 diff --git a/src/combos/resolve.ts b/src/combos/resolve.ts index 71ea750b6e..13fd4ccf6d 100644 --- a/src/combos/resolve.ts +++ b/src/combos/resolve.ts @@ -1,7 +1,6 @@ import type { OcxComboTarget, OcxConfig } from "../types"; -import { getCachedProviderQuota } from "../providers/quota-routing-cache"; +import { getCachedProviderRoutingQuota } from "../providers/quota-routing-cache"; import type { ProviderQuota } from "../providers/quota-types"; -import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; import { sleepWithAbort } from "../lib/upstream-retry"; import { coolComboTarget, @@ -65,9 +64,7 @@ function targetProviderIsUsable(config: OcxConfig, target: OcxComboTarget, now: if (!Object.hasOwn(config.providers, target.provider)) return false; const provider = config.providers[target.provider]; if (!provider || provider.disabled === true) return false; - // Native account selection owns model-scoped quota; a provider summary cannot veto it. - return isCanonicalOpenAiForwardProvider(provider) - || !cachedProviderQuotaIsExhausted(getCachedProviderQuota(target.provider, now), now); + return !cachedProviderQuotaIsExhausted(getCachedProviderRoutingQuota(target.provider, provider, now), now); } function quotaWindowExhausted(percent: number | undefined, resetAt: number | undefined, now: number): boolean { @@ -104,11 +101,8 @@ export type QuotaInactiveReason = "no_credit"; * `"no_credit"` when every USABLE target of a catalog row has positive exhaustion evidence * (#1711), otherwise undefined. * - * This deliberately reuses the runtime rules in `targetProviderIsUsable` above rather than the - * Dashboard's `quotaStateFromReport`, which is harsher: it treats `remaining <= 0` as exhausted - * without requiring `percent >= 100` and ignores an elapsed `resetAt`. A catalog row marked - * inactive on the harsher rule would contradict the router, which would still happily send the - * request. + * This reuses the credential-scoped evidence and exhaustion rules used by runtime selection. + * Display-only account, model-group and service windows cannot mark a whole provider inactive. * * Three rules carry the correctness, all inherited rather than restated: * @@ -117,7 +111,7 @@ export type QuotaInactiveReason = "no_credit"; * reason rather than a quota one, so this returns undefined. * - The canonical ChatGPT forward provider is exempt. Native account selection owns model-scoped * quota, and a provider-level summary cannot veto it. - * - A stale cache is NOT exhaustion. `getCachedProviderQuota` returns null past its 30-minute + * - A stale cache is NOT exhaustion. `getCachedProviderRoutingQuota` returns null past its 30-minute * window, and a null reading ends the vote rather than counting as evidence, so an unprobed * provider is never marked inactive. * @@ -137,8 +131,7 @@ export function quotaInactiveReason( if (usable.length === 0) return undefined; for (const target of usable) { const provider = config.providers[target.provider]!; - if (isCanonicalOpenAiForwardProvider(provider)) return undefined; - const quota = getCachedProviderQuota(target.provider, now); + const quota = getCachedProviderRoutingQuota(target.provider, provider, now); if (!quota || !cachedProviderQuotaIsExhausted(quota, now)) return undefined; } return "no_credit"; @@ -181,6 +174,7 @@ function smoothWeightedIndex( * unknown (Infinity). */ function resetWindowIndex( + config: OcxConfig, targets: Required[], eligible: (target: Required) => boolean, now = Date.now(), @@ -190,7 +184,9 @@ function resetWindowIndex( for (let index = 0; index < targets.length; index++) { const target = targets[index]!; if (!eligible(target)) continue; - const remaining = quotaResetRemainingMs(getCachedProviderQuota(target.provider, now), now); + const remaining = quotaResetRemainingMs( + getCachedProviderRoutingQuota(target.provider, config.providers[target.provider], now), now, + ); // Strict comparison deliberately retains configured order for ties, // including the no-snapshot fallback where every value is Infinity. if (selected < 0 || remaining < smallestRemaining) { @@ -276,7 +272,7 @@ export function pickComboTarget( } } } else if (combo.strategy === "reset-window") { - targetIndex = resetWindowIndex(combo.targets, eligible, now); + targetIndex = resetWindowIndex(config, combo.targets, eligible, now); } else { targetIndex = combo.targets.findIndex(eligible); } diff --git a/src/providers/quota-routing-cache.ts b/src/providers/quota-routing-cache.ts index 065d7338ca..be98cb7b04 100644 --- a/src/providers/quota-routing-cache.ts +++ b/src/providers/quota-routing-cache.ts @@ -1,15 +1,53 @@ +import { createHash } from "node:crypto"; +import type { OcxProviderConfig } from "../types"; import type { ProviderQuota, ProviderQuotaReport } from "./quota"; +import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./key-store"; +import { getProviderRegistryEntry } from "./registry"; -const quotaCache = new Map(); +export interface ProviderQuotaRoutingEvidence { + quota: ProviderQuota; + binding: string; +} + +type CachedQuota = { + quota: ProviderQuota; + routing?: ProviderQuotaRoutingEvidence | { quota: ProviderQuota; testOnly: true }; +}; + +const quotaCache = new Map(); + +/** Private cache identity; neither key material nor this digest enters management reports. */ +export function providerQuotaRoutingBinding( + name: string, + provider: OcxProviderConfig, + credential = resolveProviderApiKey(provider.apiKey)?.trim(), +): string | null { + if ((provider.authMode ?? "key") !== "key" || !credential) return null; + // Registry-owned OAuth/forward rows normalize saved authMode before dispatch. + // A key probe must not constrain that later account selection. + const entry = getProviderRegistryEntry(name); + if (entry && (entry.authKind === "oauth" || entry.authKind === "forward") + && !providerUsesKeyAuthOverride(entry, provider, credential)) return null; + // Static auth headers can replace or combine with the probed API-key header. + // Its semantics belong to the adapter, so it is not provider-wide quota evidence. + if (Object.keys(provider.headers ?? {}).some(header => + ["authorization", "x-api-key", "x-goog-api-key"].includes(header.toLowerCase()))) return null; + return createHash("sha256").update(JSON.stringify([ + name, provider.adapter, provider.baseUrl, credential, + ])).digest("hex"); +} export function clearCachedProviderQuotas(): void { quotaCache.clear(); } -export function replaceCachedProviderQuotas(reports: ProviderQuotaReport[]): void { +export function replaceCachedProviderQuotas( + reports: ProviderQuotaReport[], + routingEvidence?: WeakMap, +): void { quotaCache.clear(); for (const report of reports) { - quotaCache.set(report.provider, report.quota); + quotaCache.set(report.provider, { quota: report.quota, routing: routingEvidence?.get(report) }); } } @@ -18,15 +56,35 @@ export function getCachedProviderQuota( now: number, maxAgeMs = 30 * 60_000, ): ProviderQuota | null { - const quota = quotaCache.get(provider); + const quota = quotaCache.get(provider)?.quota; if (!quota) return null; if (now - quota.updatedAt > maxAgeMs) return null; return quota; } +/** Only inference-wide evidence for this sole credential may rank or veto a whole provider. */ +export function getCachedProviderRoutingQuota( + name: string, + provider: OcxProviderConfig | undefined, + now: number, + maxAgeMs = 30 * 60_000, +): ProviderQuota | null { + if (!provider || provider.disabled === true || (provider.authMode ?? "key") !== "key") return null; + // An active-key report cannot speak for the other keys the dispatcher may select. + if ((provider.apiKeyPool?.length ?? 0) > 1) return null; + const routing = quotaCache.get(name)?.routing; + if (!routing || !Number.isFinite(routing.quota.updatedAt) || routing.quota.updatedAt < 0 + || routing.quota.updatedAt > now || now - routing.quota.updatedAt >= maxAgeMs) return null; + const binding = providerQuotaRoutingBinding(name, provider); + if (!binding || (!("testOnly" in routing) && routing.binding !== binding)) return null; + return routing.quota; +} + export function setCachedProviderQuotaForTests( provider: string, quota: ProviderQuota, ): void { - quotaCache.set(provider, quota); + // Unit tests deliberately assert the supplied quota's scope. Production publication + // requires the producer's private, credential-bound evidence map above. + quotaCache.set(provider, { quota, routing: { quota, testOnly: true } }); } diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 69ee60626c..6909e46131 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -39,7 +39,9 @@ import { } from "./quota-wire"; import { clearCachedProviderQuotas, + providerQuotaRoutingBinding, replaceCachedProviderQuotas, + type ProviderQuotaRoutingEvidence, } from "./quota-routing-cache"; import { aggregateCodexPoolCapacity, @@ -103,6 +105,7 @@ const XAI_CREDITS_URL = `${XAI_BILLING_URL}?format=credits`; const LAST_GOOD_MAX_AGE_MS = CODEX_CAPACITY_MAX_QUOTA_AGE_MS; const nativeMainReportGenerations = new WeakMap(); const accountReportCurrent = new WeakMap boolean>(); +const routingEvidence = new WeakMap(); let providerQuotaBeforePublishForTests: (() => void | Promise) | null = null; /** Test-only seam for identity/config invalidation after probes but before publication. */ @@ -447,7 +450,9 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro ? { expiresAt: normalizedExpiry } : {}; if (unlimited) { - return report(provider, "a6api:billing", { + // Every row is an API-credit constraint on inference, so the display quota is also + // the routing projection. Passing it explicitly is the opt-in. + const quota: ProviderQuota = { creditsUsd: { used: 0, limit: 0, @@ -458,7 +463,8 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro }, customWindows: [{ label: "Unlimited API credits", percent: 0 }], updatedAt: Date.now(), - }); + }; + return keyReport(provider, "a6api:billing", quota, config, apiKey, quota); } const limitUsd = firstFinite(subscription, ["hard_limit_usd"]); const grantedUnits = firstFinite(token, ["total_granted"]); @@ -481,7 +487,7 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro const percent = normalizePercent((usedUsd / limitUsd) * 100); if (percent === undefined) return TERMINAL_QUOTA_FAILURE; const label = `API credits ($${remainingUsd.toFixed(2)} of $${limitUsd.toFixed(2)} remaining)`; - return report(provider, "a6api:billing", { + const quota: ProviderQuota = { creditsUsd: { used: usedUsd, limit: limitUsd, @@ -491,7 +497,9 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro }, customWindows: [{ label, percent }], updatedAt: Date.now(), - }); + }; + // The credit balance funds inference itself, so display and routing scope agree. + return keyReport(provider, "a6api:billing", quota, config, apiKey, quota); } function parseOpenCodeGoUsageWindow(value: unknown): { percent: number; resetAt?: number } | null { @@ -539,7 +547,7 @@ async function fetchOpenCodeGoQuota(provider: string, config: OcxProviderConfig) } : {}), updatedAt: Date.now(), }; - return report(provider, "opencode-go:usage", quota); + return keyReport(provider, "opencode-go:usage", quota, config, apiKey, quota); } /** @@ -583,10 +591,13 @@ async function fetchOpenRouterQuota(provider: string, config: OcxProviderConfig) if (percent === undefined) return null; const remaining = Math.max(0, limit - used); const label = `API credits ($${remaining.toFixed(2)} of $${limit.toFixed(2)} remaining)`; - return report(provider, "openrouter:key-info", { + // The per-key spending cap stops every request this credential can make, so the + // whole report is inference-wide routing evidence. + const quota: ProviderQuota = { customWindows: [{ label, percent }], updatedAt: Date.now(), - }); + }; + return keyReport(provider, "openrouter:key-info", quota, config, apiKey, quota); } /** @@ -685,7 +696,7 @@ async function fetchClineQuota(provider: string, config: OcxProviderConfig): Pro windows += 1; } } - return windows > 0 ? report(provider, "cline:plan-usage-limits", quota) : null; + return windows > 0 ? keyReport(provider, "cline:plan-usage-limits", quota, config, apiKey, quota) : null; } /** @@ -757,7 +768,7 @@ async function fetchOllamaCloudQuota(provider: string, config: OcxProviderConfig } const body = asRecord(await readQuotaJson(response)); const quota = parseOllamaCloudQuota(body); - return quota ? report(provider, "ollama-cloud:usage", quota) : null; + return quota ? keyReport(provider, "ollama-cloud:usage", quota, config, apiKey, quota) : null; } /** @@ -887,10 +898,18 @@ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promi // model window — for example a plan reporting only the monthly MCP `TIME_LIMIT` row. // Returning `null` here would preserve the previous token windows for up to 30 minutes // and keep quota-aware routing acting on a report the provider has already superseded. - return quota ? report(provider, "zai:quota-limit", quota) : AUTHORITATIVE_EMPTY_QUOTA; + return quota + ? keyReport(provider, "zai:quota-limit", quota, config, apiKey, quota) + : AUTHORITATIVE_EMPTY_QUOTA; } const legacy = parseZaiQuotaLegacyFields(data); - return legacy ? report(provider, "zai:quota-limit", legacy) : null; + if (!legacy) return null; + // The legacy monthly figure also carries MCP usage; it is display evidence, not + // proof that model inference is unavailable. Modern TOKEN_LIMIT rows above are scoped. + const inferenceQuota = { ...legacy }; + delete inferenceQuota.monthlyPercent; + delete inferenceQuota.monthlyResetAt; + return keyReport(provider, "zai:quota-limit", legacy, config, apiKey, inferenceQuota); } /** @@ -1073,7 +1092,9 @@ async function fetchSyntheticQuota(provider: string, config: OcxProviderConfig): quota.customWindows = [...(quota.customWindows ?? []), { label: "Search hourly", percent: searchHourly }]; windows += 1; } - return windows > 0 ? report(provider, "synthetic:quotas", quota) : null; + const inferenceQuota = { ...quota }; + delete inferenceQuota.customWindows; // search.hourly does not constrain model inference. + return windows > 0 ? keyReport(provider, "synthetic:quotas", quota, config, apiKey, inferenceQuota) : null; } /** @@ -1185,6 +1206,31 @@ function report( }; } +/** + * Publish a credential-bound report, and routing evidence only when the producer + * hands over its inference-only projection. + * + * The projection is deliberately not defaulted to the display quota. A producer must + * decide that its rows really do constrain inference on the probed credential; omitting + * the argument leaves the report display-only, so a new producer cannot inherit + * provider-veto authority merely by calling this helper. Ownership alone is not the + * scope decision: providerQuotaRoutingBinding resolving is necessary, never sufficient. + */ +function keyReport( + provider: string, + source: string, + quota: ProviderQuota, + config: OcxProviderConfig, + probedCredential: string, + inferenceQuota?: ProviderQuota, +): ProviderQuotaReport | null { + const result = report(provider, source, quota); + if (!result || !inferenceQuota) return result; + const binding = providerQuotaRoutingBinding(provider, config, probedCredential); + if (binding) routingEvidence.set(result, { quota: inferenceQuota, binding }); + return result; +} + function tagNativeMainReport( value: ProviderQuotaReport | null, generation: number, @@ -1193,6 +1239,27 @@ function tagNativeMainReport( return value; } +/** + * Test-only seam: publish exactly as a credential-bound producer does, and hand back the + * routing evidence the publication actually attached. + * + * Live producers all pass a projection today, so no probe fixture can prove the OTHER half + * of the contract: that omitting it stays display-only. Routing an omitted argument through + * the real helper keeps that provable, and a re-introduced `= quota` default would be + * observed here (a defaulted parameter also fires for an explicitly undefined argument). + */ +export function publishKeyReportForTests( + provider: string, + source: string, + quota: ProviderQuota, + config: OcxProviderConfig, + probedCredential: string, + inferenceQuota?: ProviderQuota, +): { report: ProviderQuotaReport | null; routing: ProviderQuotaRoutingEvidence | undefined } { + const result = keyReport(provider, source, quota, config, probedCredential, inferenceQuota); + return { report: result, routing: result ? routingEvidence.get(result) : undefined }; +} + function isProviderQuotaReportCurrent(value: ProviderQuotaReport): boolean { const generation = nativeMainReportGenerations.get(value); return (generation === undefined || isMainAccountIdentityGenerationLive(generation)) @@ -1888,7 +1955,7 @@ export function reconcileProviderAccountQuotaRows(context: GenerationContext): n const reports = cache.response.reports.filter(report => context.providerNames.has(report.provider)); removed += cache.response.reports.length - reports.length; cache = { ...cache, response: { ...cache.response, reports } }; - replaceCachedProviderQuotas(reports); + replaceCachedProviderQuotas(reports, routingEvidence); } liveAccountQuotaKeys = new Set(context.oauthAccountKeys); liveProviderQuotaKeys = new Set(context.providerNames); @@ -2317,7 +2384,7 @@ async function fetchKimiQuota(provider: string, config: OcxProviderConfig, acces }); if (!response.ok) return null; const quota = parseKimiQuotaPayload(await readQuotaJson(response)); - return quota ? report(provider, "kimi:usages", quota) : null; + return quota ? keyReport(provider, "kimi:usages", quota, config, accessToken, quota) : null; } /** @@ -2444,7 +2511,7 @@ async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig const fiveHour = parseCommandCodeWindow(limits?.fiveHour); const weekly = parseCommandCodeWindow(limits?.weekly); const creditsUsd = await fetchCommandCodeSpend(bearer, credits, orgQuery); - return report(provider, "command-code:credits", { + const quota: ProviderQuota = { ...(fiveHour ? { fiveHourPercent: fiveHour.percent, ...(fiveHour.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}), @@ -2455,7 +2522,9 @@ async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig } : {}), ...(creditsUsd ? { creditsUsd } : {}), updatedAt: Date.now(), - }); + }; + // Rolling windows and the credit balance both gate inference on this bearer. + return keyReport(provider, "command-code:credits", quota, config, bearer, quota); } /** Cursor included usage via api2.cursor.sh (Bearer from OAuth) — unofficial, may change. */ @@ -2964,7 +3033,9 @@ async function maybeFetchProviderQuota( // probe to run — the row is the active account's last in-band observation. if (provider.authMode === "oauth" && hasPassiveAccountQuota(name)) return fetchPassiveProviderQuota(name); const reader = keyQuotaReaderForProvider(name, provider); - return reader ? reader(name, provider) : null; + // Keep destination/auth fields bound to the same request as the reader's captured + // bearer, even if the live provider object changes while the quota probe awaits. + return reader ? reader(name, { ...provider }) : null; } catch { return null; } @@ -3151,7 +3222,7 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh ) { const reports = response.reports.filter(item => mayCommitProviderQuotaKey(item.provider, writerGeneration)); cache = { key, ts: Date.now(), response: { ...response, reports } }; - replaceCachedProviderQuotas(reports); + replaceCachedProviderQuotas(reports, routingEvidence); notifyProviderQuotaSnapshot(reports, config); } return response; diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index a7ef656162..0352ba6c83 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -303,3 +303,5 @@ The Remote Hub guide and affected CLI, server-config, management-API, and dashbo Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](../providers/openai-tiers.md#quota-cache-and-short-window-history). + +The Combo guides describe the distinction between display quota and single-credential inference evidence used by routing. See [scoped provider quota](../runtime.md#scoped-provider-quota-for-combo-selection). diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 22105b20c8..ee983cd77c 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -53,3 +53,5 @@ malformed, gapped, oversized, contradictory, failed, or incomplete streams stay Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +Account-scoped OAuth quota remains display evidence for provider-level Combo selection; it does not acquire single-key inference-veto authority. See [scoped provider quota](../runtime.md#scoped-provider-quota-for-combo-selection). diff --git a/structure/runtime.md b/structure/runtime.md index 5f67121e3e..26b37a33e9 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -194,3 +194,18 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +## Scoped provider quota for Combo selection + +`src/providers/quota.ts` publishes routing evidence only when a producer explicitly supplies its +inference-wide projection. A matching credential alone does not grant veto authority. Display-only +account, model-group, search and legacy MCP windows remain visible but cannot exclude a provider. +The private WeakMap binds provider name, adapter, destination and captured credential; neither +credential nor binding enters report JSON. + +`src/providers/quota-routing-cache.ts` rechecks the live single key, effective authentication, +static credential headers and key-pool size. Unknown, invalid, future or 30-minute-old evidence +cannot rank or veto a provider. `src/combos/resolve.ts` uses that same scoped getter for selection, +reset-window ordering and catalog inactivity. Changing a key, destination or adapter invalidates +the old binding; restoring the same configuration may reuse still-fresh evidence. Account admission, +cooldowns and response-driven retry remain authoritative. diff --git a/structure/subagents.md b/structure/subagents.md index 3f00e96302..a155d3d785 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -202,3 +202,5 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +Provider-level Combo eligibility uses explicit inference evidence for the current single credential; account-specific admission remains separate. See [scoped provider quota](runtime.md#scoped-provider-quota-for-combo-selection). diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 66f80525ad..072694a470 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -61,3 +61,5 @@ executor contract. Main-request migration must not treat that branch as fixed-tr Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +Quota publication distinguishes display reports from explicitly supplied inference projections; a credential-bound cache read validates the current destination and key. See [scoped provider quota](../runtime.md#scoped-provider-quota-for-combo-selection). diff --git a/tests/codex-integration/catalog-zero-credit-picker.test.ts b/tests/codex-integration/catalog-zero-credit-picker.test.ts index 423ca834f6..c6a564b9eb 100644 --- a/tests/codex-integration/catalog-zero-credit-picker.test.ts +++ b/tests/codex-integration/catalog-zero-credit-picker.test.ts @@ -1,10 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { clearCachedProviderQuotas, setCachedProviderQuotaForTests } from "../../src/providers/quota-routing-cache"; +import { clearCachedProviderQuotas, setCachedProviderQuotaForTests, replaceCachedProviderQuotas, providerQuotaRoutingBinding, type ProviderQuotaRoutingEvidence } from "../../src/providers/quota-routing-cache"; import { quotaInactiveReason } from "../../src/combos/resolve"; import { buildCatalogEntries, CATALOG_INACTIVE_REASON_FIELD, deriveEntry } from "../../src/codex/catalog/sync"; import type { RawEntry } from "../../src/codex/catalog/parsing"; import type { OcxConfig } from "../../src/types"; -import type { ProviderQuota } from "../../src/providers/quota"; +import type { ProviderQuotaReport, ProviderQuota } from "../../src/providers/quota"; /** * Regression coverage for #1711 — zero-credit models and combos were still offered as ordinary @@ -51,6 +51,32 @@ describe("quota-inactive catalog rows (#1711)", () => { expect(quotaInactiveReason(config(), [{ provider: "alpha" }], NOW)).toBe("no_credit"); }); + test("display-only reports cannot mark a catalog row inactive", () => { + replaceCachedProviderQuotas([{ provider: "alpha", label: "Alpha", source: "display", quota: exhausted(), updatedAt: NOW }]); + expect(quotaInactiveReason(config(), [{ provider: "alpha" }], NOW)).toBeUndefined(); + }); + + test.each(["apiKey", "baseUrl", "adapter"] as const)("bound evidence stops marking inactivity after %s changes", field => { + const current = config(); + const provider = current.providers.alpha!; + const report: ProviderQuotaReport = { provider: "alpha", label: "Alpha", source: "inference", quota: exhausted(), updatedAt: NOW }; + const binding = providerQuotaRoutingBinding("alpha", provider); + expect(binding).not.toBeNull(); + const evidence = new WeakMap(); + evidence.set(report, { quota: report.quota, binding: binding! }); + replaceCachedProviderQuotas([report], evidence); + expect(quotaInactiveReason(current, [{ provider: "alpha" }], NOW)).toBe("no_credit"); + if (field === "apiKey") provider.apiKey = "changed-key"; + else if (field === "baseUrl") provider.baseUrl = "https://changed.example.test/v1"; + else provider.adapter = "openai-responses"; + expect(quotaInactiveReason(current, [{ provider: "alpha" }], NOW)).toBeUndefined(); + }); + + test.each([NOW - 30 * 60_000, NOW + 1, -1, Number.NaN])("invalid or exactly expired evidence is unknown: %s", updatedAt => { + setCachedProviderQuotaForTests("alpha", exhausted(updatedAt)); + expect(quotaInactiveReason(config(), [{ provider: "alpha" }], NOW)).toBeUndefined(); + }); + test("a refill clears the field", () => { setCachedProviderQuotaForTests("alpha", funded()); expect(quotaInactiveReason(config(), [{ provider: "alpha" }], NOW)).toBeUndefined(); diff --git a/tests/codex-integration/combos.test.ts b/tests/codex-integration/combos.test.ts index 98174c3848..76e4f26ca9 100644 --- a/tests/codex-integration/combos.test.ts +++ b/tests/codex-integration/combos.test.ts @@ -910,7 +910,7 @@ describe("combo failure policy and advancement", () => { expect(sleeps).toEqual([1_000]); }); - test("still filters exhausted quota on a noncanonical forward destination", () => { + test("does not infer provider-wide quota from a noncanonical forward row without a credential", () => { const now = 50_000; const config = baseConfig({ providers: { @@ -927,7 +927,8 @@ describe("combo failure policy and advancement", () => { const pick = pickComboTarget(config, "free", { now }); - expect(pick?.target.provider).toBe("b"); + // This is quota selection, not proof that this custom forward route can authenticate. + expect(pick?.target.provider).toBe("a"); }); test("retains caller eligibility restrictions for native targets", () => { @@ -1150,6 +1151,20 @@ describe("deterministic combo selection", () => { }); }); + test.each(["oauth", "header", "key-pool"])("reset-window does not rank an inapplicable snapshot: %s", kind => { + const now = Date.now(); + const config = baseConfig({ combos: { free: { strategy: "reset-window", targets: [ + { provider: "a", model: "m1" }, { provider: "b", model: "m2" }, + ] } } }); + setCachedProviderQuotaForTests("a", { updatedAt: now, weeklyResetAt: now + 2_000 }); + setCachedProviderQuotaForTests("b", { updatedAt: now, weeklyResetAt: now + 1_000 }); + expect(pickComboTarget(config, "free", { now })?.target.provider).toBe("b"); + if (kind === "oauth") config.providers.b!.authMode = "oauth"; + else if (kind === "header") config.providers.b!.headers = { Authorization: "Bearer different-key" }; + else config.providers.b!.apiKeyPool = [{ id: "one", key: "one" }, { id: "two", key: "two" }]; + expect(pickComboTarget(config, "free", { now })?.target.provider).toBe("a"); + }); + test("reset-window treats elapsed resets as unknown and falls back to configured order", () => { const now = Date.now(); const config = baseConfig({ diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index 56d69951d5..788c0dff11 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -18,10 +18,14 @@ import { parseXaiCreditsResponse, QUOTA_RESPONSE_MAX_BYTES, readProviderQuotaJsonForTests, + publishKeyReportForTests, setAntigravityAccountQuotaTransportForTests, setProviderQuotaBeforePublishForTests, } from "../../src/providers/quota"; import type { OcxConfig } from "../../src/types"; +import { clearComboTargetCooldowns, coolComboTarget, pickComboTarget, pickComboTargetWithWait } from "../../src/combos"; +import { routedProviderConfig } from "../../src/router"; +import { buildOpenAIChatPassthroughRequest } from "../../src/adapters/openai-chat"; import { PROXY_ENV_KEYS } from "../../src/lib/proxy-env"; import { repoPath } from "../helpers/repo-root"; const proxyKeys = PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]); @@ -95,6 +99,7 @@ beforeEach(() => { }); afterEach(() => { + clearComboTargetCooldowns(); for (const key of proxyKeys) { if (originalProxyEnv[key] === undefined) delete process.env[key]; else process.env[key] = originalProxyEnv[key]; @@ -703,6 +708,241 @@ describe("fetchProviderQuotaReports", () => { } as OcxConfig; } + function quotaCombo(config: OcxConfig): OcxConfig { + const provider = config.defaultProvider; + return { + ...config, + providers: { + ...config.providers, + fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", apiKey: "fallback-key" }, + }, + combos: { "quota-scope": { strategy: "failover", targets: [ + { provider, model: "primary-model" }, { provider: "fallback", model: "fallback-model" }, + ] } }, + }; + } + + test("routing quota scope keeps Synthetic search exhaustion out of model selection", async () => { + globalThis.fetch = (async () => Response.json({ + data: { rollingFiveHourLimit: 20, weeklyTokenLimit: 30, search: { hourly: 100 } }, + })) as typeof fetch; + const config = quotaCombo(keyQuotaConfig("synthetic", "https://api.synthetic.new/v2")); + const reports = await fetchProviderQuotaReports(config, true); + expect(reports.reports[0]?.quota.customWindows?.[0]?.percent).toBe(100); + expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("synthetic"); + }); + + test("routing quota scope keeps ZAI legacy MCP exhaustion out of model selection", async () => { + globalThis.fetch = (async () => Response.json({ + success: true, data: { fiveHourPercent: 20, weeklyPercent: 30, monthlyMCPUsage: 100 }, + })) as typeof fetch; + const config = quotaCombo(keyQuotaConfig("zai", "https://api.z.ai/api/coding/paas/v4")); + const reports = await fetchProviderQuotaReports(config, true); + expect(reports.reports[0]?.quota.monthlyPercent).toBe(100); + expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("zai"); + }); + + test("routing quota scope keeps a key-bound display-only report out of model selection", async () => { + // MiniMax publishes its Token Plan countdown through the display-only path. The provider + // is single-key `key` auth, so ownership alone would resolve a routing binding; without + // an inference projection the exhausted row must still not rank or veto the target. + globalThis.fetch = (async () => Response.json({ + success: true, data: { remains_time: 0, total_time: 1_000_000_000 }, + })) as typeof fetch; + const config = quotaCombo(keyQuotaConfig("minimax", "https://api.minimax.io/v1")); + const reports = await fetchProviderQuotaReports(config, true); + expect(reports.reports[0]?.quota.customWindows?.[0]?.percent).toBe(100); + expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("minimax"); + }); + + test("routing quota scope retains the OpenRouter single-key spending cap", async () => { + globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; + const config = quotaCombo(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1")); + await fetchProviderQuotaReports(config, true); + expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); + }); + + test("keyReport publishes routing evidence only for an explicit inference projection", () => { + // The MiniMax case above rides the display-only `report()` path, so it would still pass if + // `keyReport`'s projection were quietly defaulted back to the display quota. This drives the + // credential-bound helper directly: the binding resolves for BOTH calls (same single-key + // provider and probed credential), so the only variable left is the projection itself. + const provider = keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1").providers.openrouter!; + const exhausted = { monthlyPercent: 100, updatedAt: Date.now() }; + + const omitted = publishKeyReportForTests("openrouter", "openrouter:key-info", exhausted, provider, "openrouter-secret"); + expect(omitted.report?.quota.monthlyPercent).toBe(100); + expect(omitted.routing).toBeUndefined(); + + const projected = { monthlyPercent: 100, updatedAt: Date.now() }; + const explicit = publishKeyReportForTests( + "openrouter", "openrouter:key-info", exhausted, provider, "openrouter-secret", projected, + ); + expect(explicit.routing?.quota).toBe(projected); + expect(typeof explicit.routing?.binding).toBe("string"); + }); + + test("routing quota scope does not apply a probed key cap to an Authorization override", async () => { + const probeAuth: Array = []; + globalThis.fetch = (async (_input, init) => { + probeAuth.push(new Headers(init?.headers).get("authorization")); + return Response.json({ data: { limit: 20, limit_remaining: 0 } }); + }) as typeof fetch; + const config = quotaCombo(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1", "spent-A")); + config.providers.openrouter!.headers = { Authorization: "Bearer live-B" }; + await fetchProviderQuotaReports(config, true); + const request = buildOpenAIChatPassthroughRequest(routedProviderConfig("openrouter", config.providers.openrouter!), { + messages: [{ role: "user", content: "synthetic" }], + }, "primary-model", false); + expect(probeAuth).toEqual(["Bearer spent-A"]); + expect(new Headers(request.headers).get("authorization")).toBe("Bearer live-B"); + expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("openrouter"); + + delete config.providers.openrouter!.headers; + await fetchProviderQuotaReports(config, true); + expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); + }); + + test("routing quota scope rechecks an Authorization override added after publication", async () => { + globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; + const config = quotaCombo(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1", "spent-A")); + await fetchProviderQuotaReports(config, true); + expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); + config.providers.openrouter!.headers = { aUtHoRiZaTiOn: "Bearer live-B" }; + expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("openrouter"); + delete config.providers.openrouter!.headers; + expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); + }); + + test("routing quota scope does not apply a probed key cap to an Anthropic x-api-key override", async () => { + const probeAuth: Array = []; + globalThis.fetch = (async (_input, init) => { + probeAuth.push(new Headers(init?.headers).get("authorization")); + return Response.json({ usage: { limit: "100", used: "100" } }); + }) as typeof fetch; + const config = quotaCombo(keyQuotaConfig("kimi-code", "https://api.kimi.com/coding/v1", "spent-A")); + config.providers["kimi-code"]!.adapter = "anthropic"; + config.providers["kimi-code"]!.headers = { "x-api-key": "live-B" }; + await fetchProviderQuotaReports(config, true); + expect(probeAuth).toEqual(["Bearer spent-A"]); + expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("kimi-code"); + delete config.providers["kimi-code"]!.headers; + await fetchProviderQuotaReports(config, true); + expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); + }); + + test.each(["key", "omitted", "custom-key"])("routing quota scope follows effective Kimi authentication: %s", async mode => { + globalThis.fetch = (async () => Response.json({ usage: { limit: "100", used: "100" } })) as typeof fetch; + const name = mode === "custom-key" ? "kimi-code" : "kimi"; + const config = quotaCombo(keyQuotaConfig(name, "https://api.kimi.com/coding/v1", "spent-A")); + if (mode === "omitted") delete config.providers[name]!.authMode; + expect(routedProviderConfig(name, config.providers[name]!).authMode).toBe(mode === "custom-key" ? "key" : "oauth"); + const report = await fetchProviderQuotaReports(config, true); + expect(report.reports[0]?.quota.weeklyPercent).toBe(100); + expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe(mode === "custom-key" ? "fallback" : "kimi"); + }); + + test("routing quota scope keeps an exhausted Gemini group from vetoing an Antigravity Claude target", async () => { + await saveCredential("google-antigravity", { + access: "synthetic-agy-access", refresh: "synthetic-agy-refresh", + expires: Date.now() + 3600_000, projectId: "synthetic-project", + }); + const resetTime = new Date(Date.now() + 3600_000).toISOString(); + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async url => { + expect(url.endsWith("retrieveUserQuotaSummary")).toBe(true); + return Response.json({ groups: [ + { displayName: "Gemini Models", buckets: [{ window: "5h", remainingFraction: 0, resetTime }] }, + { displayName: "Claude and GPT models", buckets: [{ window: "5h", remainingFraction: 1, resetTime }] }, + ] }); + }, + }); + const config = quotaCombo({ defaultProvider: "google-antigravity", providers: { + "google-antigravity": { adapter: "google", authMode: "oauth", baseUrl: "https://daily-cloudcode-pa.googleapis.com" }, + } } as OcxConfig); + config.combos!["quota-scope"]!.targets[0]!.model = "claude-sonnet-4.6"; + const report = await fetchProviderQuotaReports(config, true); + expect(report.reports[0]?.quota.customWindows).toEqual([ + { label: "Gem", percent: 100, resetAt: Date.parse(resetTime) }, + { label: "Cla", percent: 0, resetAt: Date.parse(resetTime) }, + ]); + expect(pickComboTarget(config, "quota-scope")?.target).toMatchObject({ provider: "google-antigravity", model: "claude-sonnet-4.6" }); + }); + + test("routing quota scope keeps an active Anthropic account report out of whole-provider selection", async () => { + await saveCredential("anthropic", { + access: "synthetic-claude-access", refresh: "synthetic-claude-refresh", expires: Date.now() + 3600_000, + }); + globalThis.fetch = (async input => { + expect(String(input)).toBe("https://api.anthropic.com/api/oauth/usage"); + return Response.json({ five_hour: { utilization: 100, resets_at: new Date(Date.now() + 3600_000).toISOString() } }); + }) as typeof fetch; + const config = quotaCombo({ defaultProvider: "anthropic", providers: { + anthropic: { adapter: "anthropic", authMode: "oauth", baseUrl: "https://api.anthropic.com/v1" }, + } } as OcxConfig); + const report = await fetchProviderQuotaReports(config, true); + expect(report.reports[0]?.quota.fiveHourPercent).toBe(100); + // Account selection and its exhaustion rules still decide whether this route can dispatch. + expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("anthropic"); + config.providers.fallback!.disabled = true; + const now = Date.now(); + const waits: number[] = []; + coolComboTarget("quota-scope", config.combos!["quota-scope"]!.targets[0]!, { now, cooldownMs: 1_000 }); + const afterWait = await pickComboTargetWithWait(config, "quota-scope", { + now, waitForCooldownMs: 1_000, sleep: async ms => { waits.push(ms); }, + }); + expect(waits).toEqual([1_000]); + expect(afterWait?.target.provider).toBe("anthropic"); + }); + + test("routing quota scope retains a verified cap through a transient refresh failure", async () => { + let transient = false; + globalThis.fetch = (async () => transient + ? new Response("unavailable", { status: 503 }) + : Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; + const config = quotaCombo(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1")); + await fetchProviderQuotaReports(config, true); + expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); + transient = true; + await fetchProviderQuotaReports(config, true); + expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); + }); + + test("routing quota scope stops vetoing the provider when a second key is added", async () => { + globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; + const config = quotaCombo(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1")); + await fetchProviderQuotaReports(config, true); + expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); + config.providers.openrouter!.apiKeyPool = [ + { id: "old", key: "openrouter-secret" }, { id: "new", key: "second-key" }, + ]; + expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("openrouter"); + }); + + test("routing quota scope rejects a cached cap after the active key changes", async () => { + globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; + const config = quotaCombo(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1")); + await fetchProviderQuotaReports(config, true); + config.providers.openrouter!.apiKey = "replacement-key"; + expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("openrouter"); + }); + + test("routing quota scope rejects a cached cap after an env key resolves differently", async () => { + const previous = process.env.OCX_TEST_ROUTING_QUOTA_KEY; + try { + process.env.OCX_TEST_ROUTING_QUOTA_KEY = "first-key"; + globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; + const config = quotaCombo(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1", "$OCX_TEST_ROUTING_QUOTA_KEY")); + await fetchProviderQuotaReports(config, true); + process.env.OCX_TEST_ROUTING_QUOTA_KEY = "replacement-key"; + expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("openrouter"); + } finally { + if (previous === undefined) delete process.env.OCX_TEST_ROUTING_QUOTA_KEY; + else process.env.OCX_TEST_ROUTING_QUOTA_KEY = previous; + } + }); + test("OpenRouter quota renders a credit window against the per-key cap", async () => { const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {