From 1f75c2302d6ce76acbcfc1a0b9f37e41e367c5ca Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 27 Sep 2026 06:23:46 +0900 Subject: [PATCH 1/2] feat(kiro): quota metrics and a shared auto-selection projection The metrics snapshot exports cached Kiro quota (percent, credits used, credit limit, seconds to reset) for up to 32 accounts under opaque labels, with no scrape-time upstream call. Routing, the management account list and the CLI now share one decision, kiroAutoSelection, which reports whether an account is automatically selectable and why not (needs_reauth, suspended, cooldown, quota_exhausted); eligibility calls it for Kiro, so the projection cannot drift from routing. The usage parser rejects negative or non-finite credit readings, and credit balances survive a restart. --- .../001_research_gap_inventory.md | 2 +- .../070_measured_credits_metrics_routable.md | 54 +++++++++ .../src/content/docs/fr/reference/adapters.md | 1 + .../fr/reference/cli/providers-accounts.md | 2 + .../docs/fr/reference/management-api.md | 4 +- .../docs/ja/reference/management-api.md | 4 +- .../docs/ko/reference/management-api.md | 4 +- .../src/content/docs/reference/adapters.md | 4 + .../docs/reference/cli/providers-accounts.md | 7 ++ .../content/docs/reference/management-api.md | 9 +- .../docs/ru/reference/management-api.md | 4 +- .../src/content/docs/tr/reference/adapters.md | 2 + .../tr/reference/cli/providers-accounts.md | 2 + .../docs/tr/reference/management-api.md | 4 +- .../docs/zh-cn/reference/management-api.md | 4 +- .../docs/zh-tw/reference/management-api.md | 4 +- scripts/test-layout/layout.json | 6 +- src/cli/account-api.ts | 13 +++ src/cli/account.ts | 4 +- src/oauth/generic-account-failover.ts | 21 +++- src/providers/kiro-account-state-disk.ts | 4 + src/providers/kiro-quota-metrics.ts | 35 ++++++ src/providers/kiro-usage.ts | 11 +- src/providers/quota-types.ts | 3 + src/server/index/serve-options.ts | 4 +- src/server/management/oauth-account-routes.ts | 4 +- src/server/request-metrics.ts | 28 +++++ structure/dashboard-and-usage.md | 4 +- structure/gui-and-management-api.md | 4 +- structure/providers-and-adapters.md | 2 + structure/providers/kiro.md | 10 ++ structure/transports/inventory.md | 5 + tests/cli/cli-kiro-auto-selection.test.ts | 48 ++++++++ tests/fixtures/test-layout-expected.json | 3 + .../kiro/kiro-auto-selection.test.ts | 67 +++++++++++ .../providers/kiro/kiro-quota-metrics.test.ts | 104 ++++++++++++++++++ tests/providers/kiro/kiro-usage-quota.test.ts | 10 ++ .../server/management-metrics-export.test.ts | 5 +- 38 files changed, 469 insertions(+), 37 deletions(-) create mode 100644 src/providers/kiro-quota-metrics.ts create mode 100644 tests/cli/cli-kiro-auto-selection.test.ts create mode 100644 tests/providers/kiro/kiro-auto-selection.test.ts create mode 100644 tests/providers/kiro/kiro-quota-metrics.test.ts diff --git a/devlog/_plan/260926_kiro_lb_parity2/001_research_gap_inventory.md b/devlog/_plan/260926_kiro_lb_parity2/001_research_gap_inventory.md index 0ed7edd9715..b247e20788d 100644 --- a/devlog/_plan/260926_kiro_lb_parity2/001_research_gap_inventory.md +++ b/devlog/_plan/260926_kiro_lb_parity2/001_research_gap_inventory.md @@ -60,7 +60,7 @@ row records which one was verified and how. | P1/P2 | Healthy-account spreading | Quota-weighted random race (`kiro/account_manager.py:1162-1208,1288`) | Keeps a healthy active account; deterministic ordering (`src/oauth/account-quota-rank.ts:158-172`, `src/oauth/generic-account-failover.ts:443-484`) | ahead-lb on distribution | **Adopt, better** → 040: deterministic least-loaded choice among quota-healthy accounts, opt-in | | P3 | Per-account concurrency | Optional semaphore with bounded wait (`kiro/concurrency.py:2,95`, `kiro/http_client.py:554`) | Absent | ahead-lb | **Adopt** → 040 | | P10 | Affinity | Global last-success cursor (`kiro/account_manager.py:1444`) | None, documented | parity | Reject: a global cursor is not conversation affinity | -| P8/J | Per-request credits | Records upstream credit frames per serving account (`kiro/usage_tracking.py:55,80`, `main.py:594`, `432c9b3`) | Kiro token usage is estimated; no credit field (`src/usage/log.ts:532`) | ahead-lb | **Adopt** → 070 | +| P8/J | Per-request credits | Records upstream credit frames per serving account (`kiro/usage_tracking.py:55,80`, `main.py:594`, `432c9b3`) | At research time: no credit field. Since landed on dev outside this unit as `OcxUsage.providerCredits` from Kiro `meteringEvent` frames (`src/adapters/kiro-events.ts`, `src/adapters/kiro/stream.ts`, `src/usage/log.ts`), summed per physical send | parity | Landed upstream of 070; 070 adds quota gauges and auto-selection only | | U1 | Multiplier estimates | Coarse per-model estimates (`kiro/model_costs.py:8-22,108-132`) | None | ahead-lb (advisory) | Reject: 070 records measured credits; an estimate beside them would be a second, weaker number | ## Model capability and wire diff --git a/devlog/_plan/260926_kiro_lb_parity2/070_measured_credits_metrics_routable.md b/devlog/_plan/260926_kiro_lb_parity2/070_measured_credits_metrics_routable.md index 525ee177488..c095df81b8f 100644 --- a/devlog/_plan/260926_kiro_lb_parity2/070_measured_credits_metrics_routable.md +++ b/devlog/_plan/260926_kiro_lb_parity2/070_measured_credits_metrics_routable.md @@ -490,3 +490,57 @@ At implementation C, run the four new test files above, the four existing focuse ## Round-2 audit fold - **r3-R2-2 Medium** → Rebased the 070 eligibility hunk on 030's `eligibleIdsIn` filter and existing `cooldownSource: "kiro-suspension"` writes; `projectKiroAccountAutoSelection` maps active suspension to `skipReason: "suspended"` and ordinary rate/default cooldown to `"cooldown"` (`070:263-294,358`). The named candidate/list agreement test covers quarantine, refusal rotation, ordinary cooldown and expiry (`070:387`). Rebase-verify at this layer's P against the implemented 030 head. + +## wp8 P re-verification (2026-09-27, branch `codex/kiro-lb2-070-credits-ops` on dev `a91568ec5a`, which contains 010–060) + +Executable plan for the 070 build; **overrides** earlier sections where they conflict. + +| ID | Disposition | +|---|---| +| **P8/J metering (decision)** | **Already on dev**, outside this stack, as `OcxUsage.providerCredits` (`src/types/request.ts:451`): `meteringEvent` is a known Kiro event parsed from real kiro-cli captures (`src/adapters/kiro-events.ts:24-27,185-200`), the stream keeps the last credit value per attempt (`src/adapters/kiro/stream.ts:609-610`), and `usage/log.ts` persists it (582, 626), tested by `kiro-metering-usage.test.ts` and `kiro-metering-events.test.ts`. 070 therefore drops its own parser, `meteredCredits`, `kiro-credits.ts` and `kiro-metered-credits.test.ts`. The landed code **sums** credits across physical sends (completion fallback, continuations, request-log aggregate); that is kept, because each physical send is billed separately and summing is the correct request spend. The earlier "last value wins, never sum" rule is withdrawn. Docs describe the final request row as request spend and attempt rows (sealed per serving account) as per-account spend. | +| D070-S3 | The metrics projector reads Kiro quota only through `kiroAccountEvidence(account)` (identity-fenced); the `quota.identity` clause is removed. | +| D070-S4 | `parseKiroUsage` adds `kiroCreditsUsed`/`kiroCreditsLimit` to the quota (`src/providers/kiro-usage.ts:131-133,153-158`); `sanitizeKiroQuota` (`src/providers/kiro-account-state-disk.ts:19-30`) keeps both when finite and non-negative, so gauges survive a restart. | +| D070-S5–S7, S9 | Anchors: `eligibleIdsIn` 239-251 (Kiro branch 248-249), `AccountHealth.identity` 84-88, identity-fenced `isCooled` 112-127; `projectAccounts` `oauth-account-routes.ts:392-408` (row literal 405); CLI `account-api.ts:16-34`, `account.ts:100-113` (reuse `not-auto-selected()` wording from `selectionExcludedReason` 109-111); metrics owner `request-metrics.ts` (`createRequestMetricsOwner` 191, snapshot 239), `serve-options.ts:295`, `metrics-routes.ts:3-19`. | +| D070-S8 | SD4'-era text and the run-turn reference are removed; the L projection follows SD4'' and the landed 030/040. | +| **autoSelectable (L)** | `kiroAutoSelection(account, now, family?)` returns `{ autoSelectable, skipReason? }` mirroring exactly what `eligibleIdsIn` excludes: `needs_reauth`, `suspended` (`cooldownSource === "kiro-suspension"`, read after `isCooled` prunes and checks identity), `cooled` (any other cooldown), `exhausted` (`kiroAccountEvidence(account).exhausted === true`). It checks both the family key and the family-less key, as `eligibleIdsIn` does. The 040 cap, least-loaded order, 050 membership and 060 `loginOrigin` are preferences or provenance, never skip reasons. A test asserts the projection equals `eligibleIdsIn` membership over a table of states. Recorded limitation: the existing `health` field (`health.ts:180`) does not reflect Kiro suspension/exhaustion, so a row can read `health: ok` next to `autoSelectable: false`; the GUI does not read the new fields, so no GUI change. | +| Metrics (K) | `src/providers/kiro-quota-metrics.ts` projects cached rows only (no scrape-time upstream call): quota percent, credits used, credit limit, seconds to reset; opaque `oauthAccountLogLabel` labels (`o` + 6 hex), at most 32 accounts, rows with `updatedAt > now` dropped. Wired from `serve-options.ts` into `createRequestMetricsOwner` with a type-only import in `request-metrics.ts`. `tests/server/management-metrics-export.test.ts:549-551` (source-oracle composition string) is updated. | +| Residuals | 070:~390 test names become the landed ones (030: `kiro-refusal`, `kiro-refusal-failover`, `server-kiro-refusal-e2e`; 040: `kiro-account-load`, `kiro-leased-responses`, `kiro-pool-load-settings`); the "030/040 must also add" paragraph is deleted. | +| Registry | `kiro-auto-selection.test.ts` (providers/kiro) between `kiro-auth-context-continuation` and `kiro-builder-id-profile` (layout 1079/1080; expected 900/901); `kiro-quota-metrics.test.ts` between `kiro-pool-rank` and `kiro-reasoning-roundtrip` (1091/1092; 912/913); `tests/cli/cli-kiro-auto-selection.test.ts` after `cli-json-contract` (528; 354). | + +Verifier set for C: `bun run typecheck`; `bun test tests/providers/kiro/ tests/server/management-metrics-export.test.ts tests/cli/cli-kiro-auto-selection.test.ts tests/oauth/generic-oauth-failover.test.ts` plus `bun test $(rg -l "metrics-routes|request-metrics|projectAccounts|oauth/accounts" tests)` in the clean `/tmp` worktree; layout, ratchet, lab-boundary; privacy; structure. + + +### wp8 reflection fold (MISALIGNED → folded) + +1. **Gauge source:** `kiroAccountEvidence(account, now?)` is extended to also return the identity-fenced + `creditsUsed`/`creditsLimit` from the same quota row (same TTL/reset bound); the metrics projector + reads only that function. +2. **One closed skip-reason set:** `KiroSkipReason = "needs_reauth" | "suspended" | "cooldown" | + "quota_exhausted"`. Every place uses exactly these: the type, `isKiroSkipReason`, the management + DTO, the CLI `AccountRow` and `not-auto-selected()` output, docs and test names. The names + `cooled` and `exhausted` in "wp8 P re-verification" are replaced by `cooldown` and + `quota_exhausted`. +3. **One source of truth:** `eligibleIdsIn` calls `kiroAutoSelection` for Kiro accounts (so routing and + the projection cannot drift), and the parity table test stays as a guard. `structure/providers/kiro.md` + and `001_research_gap_inventory.md` are checked for any "never summed" claim; the inventory's P8/J + row is updated to say metering landed outside this stack as `providerCredits` and sums per physical + send. + + +### wp8 A round 1 fold (reviewer 01a0df8b: GO-WITH-FIXES, 3 Medium → folded) + +1. `parseKiroUsage` rejects `used < 0` (and non-finite `used`/`limit`); test: `a negative used reading yields no quota`. +2. The metrics projector iterates the live Kiro roster in stable order and stops after 32 **valid** rows + with distinct labels (stale or unknown rows do not consume the budget); test: + `stale leading accounts do not hide later fresh gauges`. +3. `kiroAutoSelection` has no family parameter: Kiro health keys are family-less (the classifier returns a + family only for `google-antigravity`, `src/oauth/account-quota-rank.ts:24`; Kiro refusal writes use + the family-less key, `generic-account-failover.ts:445`). The family branch is removed; tests cover the + reachable family-less states. + +## wp8 build notes + +- Added identity-fenced precise Kiro plan credits, cache-only bounded quota gauges, and a single `kiroAutoSelection` projection shared by candidate routing and account-list status. CLI text and JSON expose the same closed reason set. Existing `providerCredits` metering remains the request-spend source; no second parser or token-derived credit estimate was added. +- Updated public English and directly affected translations, plus owning structure contracts. No GUI or scrape-time network path changed. The three `layout.json` entries share lines with their preceding alphabetical entries to stay below the 2,000-line file-size ratchet. +- Verification: `bun run typecheck` passed; focused Kiro/metrics/CLI/failover/refusal suite passed (696 tests); layout, file-size, and Lab guards passed (52 tests); `bun run privacy:scan` and `bun run structure:check` passed; docs-site frozen install and build passed. A later focused test addition for disk sanitization is rerun in the final gate below. No full suite or live Kiro call was run. +- Final focused rerun after the disk-sanitizer test: 697 pass, 0 fail across 39 files. The translated management rows were then corrected to place the new Kiro facts in the description column; the docs-site build completed (529 pages, 72,011 links checked). `git diff --check` reported no whitespace errors. diff --git a/docs-site/src/content/docs/fr/reference/adapters.md b/docs-site/src/content/docs/fr/reference/adapters.md index 33c81a2a2da..d45e12b48a1 100644 --- a/docs-site/src/content/docs/fr/reference/adapters.md +++ b/docs-site/src/content/docs/fr/reference/adapters.md @@ -123,6 +123,7 @@ Les comptes inactifs n’ont pas encore nécessairement de liste observée. - Construit le `conversationState` de Kiro, mappe les outils Codex et leurs résultats, puis envoie les blocs d’image pris en charge par le protocole Kiro. - Décode `application/vnd.amazon.eventstream`, reconstruit les événements de texte, de raisonnement et d’outil, détecte les données JSON d’outil tronquées et estime l’utilisation, car le service en amont ne renvoie aucun nombre de jetons. + Les jetons restent estimés, mais les crédits de `meteringEvent` sont mesurés dans `providerCredits` : la dernière valeur d'une réponse est retenue, puis les envois physiques facturés séparément sont additionnés. Aucun crédit n'est déduit des jetons. - Utilise à l’identique le `baseUrl` configuré lorsqu’il est personnalisé. Une URL canonique `runtime.{region}.kiro.dev` suit la région d’API de l’identifiant importé ; seule cette forme canonique peut faire l’objet d’un unique repli borné vers `q.{region}.amazonaws.com` après un échec de point de terminaison, de signature, de DNS ou de connexion, ou une réponse HTTP 502/503/504 reçue avant toute sortie. - Gère la récupération après réinitialisation de connexion lorsqu’un rejeu est sûr, cet unique repli de point de terminaison admissible, une actualisation OAuth suivie d’un rejeu après une réponse HTTP 401, ainsi qu’une récupération bornée pour les réponses Kiro 429 transitoires. Un délai de récupération partagé et une seule sonde après ce délai empêchent les requêtes concurrentes d’épuiser des budgets de nouvelle tentative indépendants ; un quota épuisé n’est pas réessayé sur le même compte, et les autres erreurs de service ne sont pas rejouées. Tous les envois Kiro utilisent la sortie réseau configurée ; un délai d’en-tête dépassé renvoie 504, l’annulation du client arrête la requête et les erreurs HTTP 5xx finales affichent un texte public fixe. - Avec deux comptes enregistrés, un refus de débit refroidit brièvement le compte concerné. Un refus mensuel confirmé (HTTP 400 ou 429) l’exclut jusqu’à la réinitialisation observée ou l’expiration des données ; une suspension confirmée (HTTP 403) le met temporairement en quarantaine, mais un 403 ordinaire ne déclenche pas de rotation. La rotation après refus reste active lorsque la préférence proactive est désactivée. Le choix d’un autre compte avant le premier envoi exige une préférence proactive, où le réglage du fournisseur prime sur le réglage global. Un tour terminé par le même compte efface un ancien verdict d’épuisement. diff --git a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md index accbe6b1adb..9282cf080f0 100644 --- a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md @@ -171,6 +171,8 @@ en privilégiant celui dont l'allocation restante connue est la plus élevée ; Pour Kiro, les refus de débit, de quota mensuel confirmé et de suspension confirmée peuvent changer de compte avant toute sortie. Le quota mensuel exclut seulement ce compte jusqu’à la réinitialisation ou l’expiration des données ; une réponse terminée du même compte efface un ancien verdict. Le réglage du fournisseur prime sur le réglage global pour la préférence proactive, sans désactiver la rotation réactive. Kiro peut choisir `least-loaded` pour placer les requêtes de façon proactive lorsque `pool.kernel` et la préférence proactive sont activés. `maxConcurrentPerAccount` (1–100) crée une file bornée par compte et par processus : un compte sélectionné saturé attend au plus 250 ms, puis renvoie 503 `account_capacity` avec `Retry-After: 1`. Cette limite ne déplace pas la requête vers un autre compte. +`ocx account list kiro` affiche `not-auto-selected()` pour un compte exclu de la sélection automatique. Le JSON contient `autoSelectable` et, si la valeur est fausse, un `skipReason` fermé (`needs_reauth`, `suspended`, `cooldown` ou `quota_exhausted`). Un compte actif unique peut encore servir. Les crédits `providerCredits` sont mesurés par `meteringEvent` : la dernière valeur d'une réponse est retenue et les envois facturés séparément sont additionnés, sans estimation à partir des jetons. + `--json` renvoie : ```text diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md index bc5d0cf0290..6b0f60fc5e5 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -216,7 +216,7 @@ par le fournisseur en amont, cette information reste absente : elle n'est pas d | `GET /api/debug/injection-logs` | Lire un nombre limité d'entrées de débogage de l'injection du guidage | — | | `GET /api/claude/inbound-debug` | Lire l'état et les entrées du débogage entrant | — | | `GET /api/usage` | Résumer l'utilisation par période et par interface cliente ; les réponses Codex comprennent aussi une ventilation `accounts` indexée par des libellés de journalisation stables ne contenant aucune donnée personnelle | Renvoie 500 `{ "error": "read_failed" }` si le stockage ne peut pas être lu | -| `GET /api/metrics` | Renvoyer les métriques texte Prometheus locales au processus : requêtes logiques, envois physiques, types de récupération, durée et TTFT. Les libellés sont limités au protocole, au résultat et à la classe de récupération ; aucun identifiant de requête ou d'identifiant secret n'est exporté. | 404 si `metricsExport.enabled` n'était pas vrai au démarrage ; l'authentification de gestion est obligatoire et les identifiants du plan de données ne donnent aucun accès | +| `GET /api/metrics` | Renvoyer les métriques texte Prometheus locales au processus : requêtes logiques, envois physiques, types de récupération, durée et TTFT. Les métriques de requêtes utilisent des libellés fermés ; les jauges Kiro ajoutent uniquement un libellé de compte opaque et borné ; aucun identifiant de requête ou d'identifiant secret n'est exporté. Les quatre jauges `opencodex_kiro_quota_{used_credits,limit_credits,used_percent,seconds_to_reset}` lisent uniquement le cache et utilisent au plus 32 étiquettes de compte opaques. Aucune sonde réseau lors de la collecte. | 404 si `metricsExport.enabled` n'était pas vrai au démarrage ; l'authentification de gestion est obligatoire et les identifiants du plan de données ne donnent aucun accès | | `GET /api/storage` | Analyser l'utilisation du stockage Codex par catégorie | Renvoie une charge utile `error: "scan_failed"` en cas d'échec de l'analyse | | `POST /api/storage/cleanup/preview` | Prévisualiser le nettoyage des sessions archivées et renvoyer une empreinte contraignante | 400 `invalid_json` ou `invalid_percent` | | `POST /api/storage/cleanup` | Mettre en quarantaine ou supprimer définitivement l'ensemble archivé prévisualisé | 400 saisie invalide ; 409 état obsolète, occupé ou référencé ; 500 échec du système de fichiers ou de la base de données | @@ -278,7 +278,7 @@ Tant qu’une liste initiale fiable n’est pas disponible, les requêtes PUT va | `POST /api/oauth/login/cancel` | Annuler un flux OAuth public en cours | 400 fournisseur inconnu | | `GET /api/oauth/status` | Sonder le flux OAuth d'un fournisseur | 400 fournisseur inconnu | | `POST /api/oauth/logout` | Supprimer les informations d'identification du fournisseur sélectionné | 400 fournisseur inconnu ; `oauth_mutation_busy` | -| `GET, DELETE /api/oauth/accounts` | Répertorier les comptes masqués ou supprimer un compte | 400 invalide provider/id ; 404 compte manquant ; `oauth_mutation_busy` | +| `GET, DELETE /api/oauth/accounts` | Répertorier les comptes masqués ou supprimer un compte Les lignes Kiro ajoutent `autoSelectable` et un `skipReason` fermé en cas d’exclusion de la sélection automatique ; un compte actif unique peut encore servir. Le quota reste facultatif. | 400 invalide provider/id ; 404 compte manquant ; `oauth_mutation_busy` | | `PUT /api/oauth/accounts/active` | Sélectionnez le compte OAuth actif | 400 invalide provider/account ; `oauth_mutation_busy` | | `GET, PUT, PATCH /api/oauth/accounts/pool` | Lire ou mettre à jour la stratégie du pool OAuth Anthropic | 400 fournisseur non Anthropic ou stratégie invalide | | `POST /api/oauth/accounts/clear-cooldown` | Effacer le temps de recharge d'un compte OAuth | 400 invalide provider/account | diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index a2d1824b4d4..9821c5d358f 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -191,7 +191,7 @@ Aside プロファイルの変更はこの場合でも一つだけ保存しま | `GET /api/debug/injection-logs` |制限付きガイダンス挿入デバッグ エントリを読み取る | — | | `GET /api/claude/inbound-debug` | Claude インバウンドのデバッグ状態とエントリを読む | — | | `GET /api/usage` |範囲とクライアント サーフェスごとの使用状況を要約する |ストレージを読み取れない場合は 500 `{ "error": "read_failed" }` を返します。 -| `GET /api/metrics` | 論理リクエスト、物理送信、復旧種別、所要時間、TTFT のプロセスローカル Prometheus テキストメトリクスを返します。ラベルはプロトコル、結果、復旧クラスの閉じた集合のみで、リクエストや認証情報の識別子は出力しません。 | 起動時に `metricsExport.enabled` が true でなければ 404。通常の管理認証が必要で、データプレーン認証情報ではアクセスできません。 | +| `GET /api/metrics` | 論理リクエスト、物理送信、復旧種別、所要時間、TTFT のプロセスローカル Prometheus テキストメトリクスを返します。リクエストメトリクスのラベルは閉じた集合を使い、Kiro ゲージには上限付きの不透明なアカウントラベルのみを追加し、リクエストや認証情報の識別子は出力しません。 Kiro の 4 つのクォータゲージ (`opencodex_kiro_quota_{used_credits,limit_credits,used_percent,seconds_to_reset}`) はキャッシュのみを読み、最大 32 個の不透明なアカウントラベルを使います。収集時にネットワーク照会は行いません。 | 起動時に `metricsExport.enabled` が true でなければ 404。通常の管理認証が必要で、データプレーン認証情報ではアクセスできません。 | | `GET /api/storage` |バケットごとの Codex ストレージ使用量をスキャン |スキャン失敗時に `error: "scan_failed"` ペイロードを返します。 | `POST /api/storage/cleanup/preview` |アーカイブされたセッションのクリーンアップをプレビューし、バインディング ダイジェストを返します。 400 `invalid_json` または `invalid_percent` | | `POST /api/storage/cleanup` |プレビューされたアーカイブ セットを隔離または完全に削除します。 400 無効な入力。 409 古い/ビジー/参照状態。 500 ファイルシステム/データベース障害 | @@ -240,7 +240,7 @@ Aside プロファイルの変更はこの場合でも一つだけ保存しま | `POST /api/oauth/login/cancel` |進行中のパブリック OAuth フローをキャンセルする | 400 不明なプロバイダー | | `GET /api/oauth/status` | 1 つのプロバイダーの OAuth フローをポーリングする | 400 不明なプロバイダー | | `POST /api/oauth/logout` |選択したプロバイダー資格情報を削除します | 400 不明なプロバイダー。 `oauth_mutation_busy` | -| `GET, DELETE /api/oauth/accounts` |マスクされたアカウントを一覧表示するか、アカウントを 1 つ削除する | 400 無効なプロバイダー/ID。 404 アカウントがありません。 `oauth_mutation_busy` | +| `GET, DELETE /api/oauth/accounts` | マスクされたアカウントを一覧表示するか、アカウントを 1 つ削除する Kiro の行には自動選択の `autoSelectable` と、除外時には閉じた集合の `skipReason` が含まれます。アクティブな単一アカウントは送信を続けられ、クォータ取得は任意です。 | 400 無効なプロバイダー/ID。 404 アカウントがありません。 `oauth_mutation_busy` | | `PUT /api/oauth/accounts/active` |アクティブな OAuth アカウントを選択します | 400 無効なプロバイダー/アカウント。 `oauth_mutation_busy` | | `GET, PUT, PATCH /api/oauth/accounts/pool` | Anthropic OAuth プール ポリシーの読み取りまたは更新 | 400 非 Anthropic プロバイダーまたは無効なポリシー | | `POST /api/oauth/accounts/clear-cooldown` | 1 つの OAuth アカウントのランタイム クールダウンをクリアする | 400 無効なプロバイダー/アカウント | diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index 919d0d3e5b5..cd428d4ef35 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -200,7 +200,7 @@ Aside 프로필 변경은 이때도 한 가지를 저장합니다. 확인을 보 | `GET /api/debug/injection-logs` | 제한된 guidance-injection debug 항목을 읽습니다 | — | | `GET /api/claude/inbound-debug` | Claude inbound debug 상태와 항목을 읽습니다 | — | | `GET /api/usage` | 범위와 클라이언트 surface별 사용량을 요약합니다 | 저장소를 읽을 수 없으면 500 `{ "error": "read_failed" }`를 반환합니다 | -| `GET /api/metrics` | 논리 요청, 실제 송신, 복구 종류, 소요 시간, TTFT에 대한 프로세스 로컬 Prometheus 텍스트 메트릭을 반환합니다. label은 protocol, result, recovery class의 닫힌 집합만 사용하며 요청·자격 증명 식별자는 내보내지 않습니다. | 시작 시 `metricsExport.enabled`가 true가 아니면 404; 일반 관리 인증이 필요하며 데이터 플레인 자격 증명으로는 접근할 수 없습니다 | +| `GET /api/metrics` | 논리 요청, 실제 송신, 복구 종류, 소요 시간, TTFT에 대한 프로세스 로컬 Prometheus 텍스트 메트릭을 반환합니다. 요청 메트릭 label은 닫힌 집합을 사용하고 Kiro 게이지에는 제한된 불투명 계정 label만 추가되며 요청·자격 증명 식별자는 내보내지 않습니다. Kiro quota 게이지 4종(`opencodex_kiro_quota_{used_credits,limit_credits,used_percent,seconds_to_reset}`)은 캐시만 읽고 최대 32개의 불투명 계정 레이블을 사용하며 스크레이프 시 네트워크 요청을 하지 않습니다. | 시작 시 `metricsExport.enabled`가 true가 아니면 404; 일반 관리 인증이 필요하며 데이터 플레인 자격 증명으로는 접근할 수 없습니다 | | `GET /api/storage` | bucket별 Codex 저장소 사용량을 검사합니다 | 검사 실패 시 `error: "scan_failed"` payload를 반환합니다 | | `POST /api/storage/cleanup/preview` | archived-session cleanup을 미리 보고 binding digest를 반환합니다 | 400 `invalid_json` 또는 `invalid_percent` | | `POST /api/storage/cleanup` | 미리 본 archived set을 격리하거나 영구적으로 제거합니다 | 400 잘못된 입력; 409 오래되었음/바쁨/참조됨 상태; 500 파일 시스템/데이터베이스 실패 | @@ -252,7 +252,7 @@ Aside 프로필 변경은 이때도 한 가지를 저장합니다. 확인을 보 | `POST /api/oauth/login/cancel` | 공개적으로 진행 중인 OAuth 흐름을 취소합니다 | 400 알 수 없는 provider | | `GET /api/oauth/status` | 하나의 provider OAuth 흐름을 조회합니다 | 400 알 수 없는 provider | | `POST /api/oauth/logout` | 선택된 provider 자격 증명을 제거합니다 | 400 알 수 없는 provider; `oauth_mutation_busy` | -| `GET, DELETE /api/oauth/accounts` | 마스킹된 계정을 나열하거나 계정 하나를 제거합니다 | 400 잘못된 provider/id; 404 계정 없음; `oauth_mutation_busy` | +| `GET, DELETE /api/oauth/accounts` | 마스킹된 계정을 나열하거나 계정 하나를 제거합니다 Kiro 행에는 자동 선택 가능 여부인 `autoSelectable`과 제외 시 닫힌 집합의 `skipReason`이 포함됩니다. 활성 단일 계정은 여전히 요청을 보낼 수 있고 quota 조회는 선택 사항입니다. | 400 잘못된 provider/id; 404 계정 없음; `oauth_mutation_busy` | | `PUT /api/oauth/accounts/active` | 활성 OAuth 계정을 선택합니다 | 400 잘못된 provider/account; `oauth_mutation_busy` | | `GET, PUT, PATCH /api/pool/settings` | 모든 pool 종류(codex, anthropic, generic)의 policy를 읽거나 업데이트합니다. 세 종류 모두 같은 키로 응답하고, 해당 종류가 실제로 적용하는 필드는 `supported`에 나옵니다 | 400 알 수 없는 provider, 해당 종류가 지원하지 않는 필드, 잘못된 값 | | `GET, PUT, PATCH /api/oauth/accounts/pool` | Anthropic과 일반 OAuth provider의 기존 pool policy입니다. `/api/pool/settings`로 대체되었고 기존 클라이언트를 위해 유지합니다 | 400 codex 또는 API 키 provider, 잘못된 policy | diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 34339b4685b..a77af2706ce 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -381,6 +381,10 @@ MiMo model Command Code serves. unavailable while an earlier same-login quota bar remains visible. Known quota and exhaustion evidence survive restart only for the same login, each until its own reset or ten-minute lifetime. Missing, old, or malformed evidence becomes unknown. + The plan's precise credit balance is retained with that identity-fenced quota reading. + Separately, Kiro `meteringEvent` credit values are measured request spend: the last value + within a physical response is used, and credits from separately billed sends are added. + Token usage remains estimated; credits are never inferred from tokens. - After an admitted request, reads that account's available models from the regional management service without delaying the request. Cached results add model IDs to the shipped catalog. Empty or unrecognised replies retain the shipped list and any last good account list. Model diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 5f0e0e689ec..b193dedf4ea 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -380,6 +380,13 @@ kiro oauth 3f0a91c2 a***r@examp***.com - active mo 15% kiro oauth 8b24de70 k***1@examp***.net - mo 88% ``` +`ocx account list kiro` marks an account excluded from automatic selection as +`not-auto-selected()`. JSON carries `autoSelectable` and, when false, a closed +`skipReason` (`needs_reauth`, `suspended`, `cooldown`, or `quota_exhausted`). An active +singleton or all-excluded pool may still send. Kiro `providerCredits` comes from measured +`meteringEvent` values: the last reading within a physical response is retained, and +separately billed sends add to the request spend. Credits are never estimated from tokens. + With two or more Kiro accounts logged in, request-rate, confirmed monthly-quota, and confirmed suspension refusals can rotate before output. Monthly exhaustion excludes only that login until reset or evidence expiry; completed service clears an older verdict. diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index cdb80540125..0a1b76c94e4 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -297,7 +297,7 @@ by the current window size. | `GET /api/usage` | Scan the usage ledger into compact aggregates of readable rows, then incrementally fold verified appends; summarize by preset or inclusive custom window and client surface, with a Codex `accounts` breakdown keyed by stable non-PII log labels | 400 invalid custom bounds; 500 `{ "error": "read_failed" }` if storage cannot be read | | `GET /api/usage?jev=1` | Project persisted JEV decisions and their physical target sends. Optional `comboId` selects one Combo; `range` accepts `7d`, `30d`, or `all` (default `30d`). | 400 invalid `comboId`; 500 `{ "error": "read_failed" }` if the ledger cannot be read | | `GET /api/usage/timeline` | Bucketed usage by model/account; accepts `hours`, `bucketMinutes`, `metric`, `aggregation`, `grouping`, comma-separated `models` and repeated `hiddenProvider` filters | 400 invalid query or limits | -| `GET /api/metrics` | Return process-local Prometheus text metrics for logical requests, physical sends, recovery kinds, duration, and TTFT. Labels are closed to protocol, result, and recovery class; request and credential identifiers are never exported. | 404 when `metricsExport.enabled` was not true at startup; ordinary management authentication is required and data-plane credentials grant no access | +| `GET /api/metrics` | Return process-local Prometheus text metrics for requests and cached Kiro quota. Kiro accounts use bounded opaque digest labels; raw account and credential identifiers are never exported. | 404 when `metricsExport.enabled` was not true at startup; ordinary management authentication is required and data-plane credentials grant no access | | `GET /api/storage` | Scan Codex storage usage by bucket | Returns an `error: "scan_failed"` payload on scan failure | | `POST /api/storage/cleanup/preview` | Preview archived-session cleanup and return a binding digest | 400 `invalid_json` or `invalid_percent` | | `POST /api/storage/cleanup` | Quarantine or permanently remove the previewed archived set | 400 invalid input; 409 stale/busy/referenced state; 500 filesystem/database failure | @@ -328,11 +328,12 @@ boundary. Histogram buckets are cumulative and end with `le="+Inf"`, equal to th | `opencodex_ttft_seconds` | `protocol`, `result` | Fixed-bucket TTFT histogram for requests with observed first output. | | `opencodex_ttft_missing_total` | `protocol`, `result` | Complementary count for requests without observed TTFT. | | `opencodex_metrics_process_start_time_seconds` | none | Process-local reset boundary. | +| `opencodex_kiro_quota_{used_credits,limit_credits,used_percent,seconds_to_reset}` | `account` (opaque `o` plus six hex digits; at most 32 distinct live accounts) | Identity-matched cached Kiro plan readings. Missing, stale, future-dated, or reset-passed readings emit no sample; scraping never probes upstream. | The `recovery` label takes one of a fixed set of classes: `transient`, `connection`, `credential`, `rate_limit`, `quota`, `policy`, `ciphertext`, `payload`, `empty_completion`, `effort_downgrade`, -`fast_downgrade` and `other`. The set is closed, so no model, account, user or request identifier -can ever appear in a series. `fast_downgrade` records an Anthropic Fast refusal repaired at standard +`fast_downgrade` and `other`. The recovery set is closed; Kiro quota gauges use only the +opaque account digest label described above, never a raw account identifier. `fast_downgrade` records an Anthropic Fast refusal repaired at standard speed; it is distinct from the reasoning-effort `effort_downgrade` class. `rate_limit`, `quota`, `policy` and `ciphertext` are separate because the operator response differs: wait out the limit, move to another account, change the prompt, or drop stale encrypted @@ -512,7 +513,7 @@ outcome fields from an older server do not establish successful recovery. | `POST /api/oauth/login/cancel` | Cancel a public in-progress OAuth flow | 400 unknown provider | | `GET /api/oauth/status` | Poll one provider's OAuth flow | 400 unknown provider | | `POST /api/oauth/logout` | Remove the selected provider credential | 400 unknown provider; `oauth_mutation_busy` | -| `GET, DELETE /api/oauth/accounts` | List masked accounts or remove one account | 400 invalid provider/id; 404 account missing; `oauth_mutation_busy` | +| `GET, DELETE /api/oauth/accounts` | List masked accounts or remove one account. Kiro rows include `autoSelectable` and a closed `skipReason` when excluded from automatic selection; an active singleton may still send. Quota remains opt-in. | 400 invalid provider/id; 404 account missing; `oauth_mutation_busy` | | `PUT /api/oauth/accounts/active` | Select the active OAuth account | 400 invalid provider/account; `oauth_mutation_busy` | | `GET, PUT, PATCH /api/pool/settings` | Read or update pool policy for any kind (codex, anthropic, generic); answers with the same keys for all three and declares in `supported` which the kind honours | 400 unknown provider, a field the kind does not support, or an invalid value | | `GET, PUT, PATCH /api/oauth/accounts/pool` | Legacy per-pool policy for Anthropic and generic OAuth providers; superseded by `/api/pool/settings` and kept for existing clients | 400 codex or api-key provider, or invalid policy | diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index c0ebce7d4bc..30a02a70ed7 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -215,7 +215,7 @@ GUI-сессия в стиле loopback не выпускается. | `GET /api/debug/injection-logs` | Прочитать ограниченные guidance-injection debug-записи | — | | `GET /api/claude/inbound-debug` | Прочитать состояние и записи Claude inbound debug | — | | `GET /api/usage` | Сводка usage по диапазону и client surface | При сбое чтения storage вернёт 500 `{ "error": "read_failed" }` | -| `GET /api/metrics` | Вернуть локальные для процесса текстовые метрики Prometheus: логические запросы, физические отправки, виды восстановления, длительность и TTFT. Метки ограничены закрытыми наборами protocol, result и recovery class; идентификаторы запросов и учётных данных не экспортируются. | 404, если `metricsExport.enabled` не был true при запуске; требуется обычная management-аутентификация, data-plane credentials доступа не дают | +| `GET /api/metrics` | Вернуть локальные для процесса текстовые метрики Prometheus: логические запросы, физические отправки, виды восстановления, длительность и TTFT. Метки запросов используют закрытые наборы, а метрики Kiro добавляют только ограниченные непрозрачные метки аккаунтов; идентификаторы запросов и учётных данных не экспортируются. Четыре метрики `opencodex_kiro_quota_{used_credits,limit_credits,used_percent,seconds_to_reset}` читают только кэш и используют не более 32 непрозрачных меток аккаунтов. При сборе сетевых запросов нет. | 404, если `metricsExport.enabled` не был true при запуске; требуется обычная management-аутентификация, data-plane credentials доступа не дают | | `GET /api/storage` | Просканировать использование storage Codex по bucket'ам | При ошибке scan вернёт payload с `error: "scan_failed"` | | `POST /api/storage/cleanup/preview` | Предпросмотр cleanup archived-session и возврат binding digest | 400 `invalid_json` or `invalid_percent` | | `POST /api/storage/cleanup` | Поместить preview'нутый архивный набор в quarantine или удалить его навсегда | 400 invalid input; 409 stale/busy/referenced state; 500 filesystem/database failure | @@ -270,7 +270,7 @@ Endpoint'ы storage cleanup могут перемещать или навсег | `POST /api/oauth/login/cancel` | Отменить публичный OAuth-flow в progress | 400 unknown provider | | `GET /api/oauth/status` | Опрашивать OAuth-flow одного провайдера | 400 unknown provider | | `POST /api/oauth/logout` | Удалить сохранённый credential выбранного провайдера | 400 unknown provider; `oauth_mutation_busy` | -| `GET, DELETE /api/oauth/accounts` | Показать список masked-аккаунтов или удалить один аккаунт | 400 invalid provider/id; 404 account missing; `oauth_mutation_busy` | +| `GET, DELETE /api/oauth/accounts` | Показать список masked-аккаунтов или удалить один аккаунт Строки Kiro содержат `autoSelectable` и закрытый `skipReason` при исключении из автоматического выбора; единственный активный аккаунт всё ещё может отправлять запросы. Чтение квоты остаётся необязательным. | 400 invalid provider/id; 404 account missing; `oauth_mutation_busy` | | `PUT /api/oauth/accounts/active` | Выбрать активный OAuth-аккаунт | 400 invalid provider/account; `oauth_mutation_busy` | | `GET, PUT, PATCH /api/pool/settings` | Прочитать или обновить policy пула любого вида (codex, anthropic, generic); все три отвечают одинаковыми ключами, а поля, которые вид действительно применяет, перечислены в `supported` | 400 неизвестный provider, поле, которое вид не поддерживает, или недопустимое значение | | `GET, PUT, PATCH /api/oauth/accounts/pool` | Прежняя policy пула для Anthropic и обычных OAuth-провайдеров; заменена на `/api/pool/settings` и сохранена для существующих клиентов | 400 codex или api-key provider, либо недопустимая policy | diff --git a/docs-site/src/content/docs/tr/reference/adapters.md b/docs-site/src/content/docs/tr/reference/adapters.md index c72eb0cc90b..2a31a0cd7f0 100644 --- a/docs-site/src/content/docs/tr/reference/adapters.md +++ b/docs-site/src/content/docs/tr/reference/adapters.md @@ -202,6 +202,8 @@ veya Google Antigravity OAuth. **Kimlik Doğrulama:** Kiro kimlik bilgisinden bölge/profil meta verileriyle birlikte Bearer olarak Kiro OAuth erişim belirteci. +`meteringEvent` kredileri `providerCredits` içinde ölçülür: bir fiziksel yanıttaki son değer tutulur, ayrı ücretlendirilen gönderimler ise isteğin toplam harcamasına eklenir. Tokenlardan kredi tahmini yapılmaz. + Bir istek hesaba kabul edildikten sonra proxy, hesabın model listesini bölgesel yönetim hizmetinden arka planda okur. Gözlenen modeller yerleşik listeye eklenir; boş veya tanınmayan yanıt son geçerli listeyi korur. Üyelik yalnızca uygun hesaplar arasında tercih sağlar; bilinmeyen diff --git a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md index 558c7ac3cd8..a437c1042bb 100644 --- a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md @@ -185,6 +185,8 @@ kotası en yüksek hesabı tercih eder; rotasyon hesapların varlığıyla etkin Kiro için hız sınırı, doğrulanmış aylık kota ve doğrulanmış askıya alma retleri çıktıdan önce uygun başka bir hesaba geçebilir. Aylık kota yalnızca o hesabı sıfırlamaya veya kanıtın süresinin dolmasına kadar dışlar; aynı hesaptaki tamamlanmış yanıt eski kararı temizler. Proaktif tercih için sağlayıcı ayarı genel ayardan önceliklidir; reaktif hesap değişimi açık kalır. Kiro, `pool.kernel` ve proaktif tercih açıkken `least-loaded` stratejisini seçebilir. `maxConcurrentPerAccount` (1–100), süreç başına hesap kuyruğu sınırıdır: seçili hesap doluysa en çok 250 ms bekler, ardından `Retry-After: 1` ile 503 `account_capacity` döner. Sınır, isteği başka bir hesaba taşımaz. +`ocx account list kiro`, otomatik seçimden dışlanan hesaplar için `not-auto-selected()` gösterir. JSON, `autoSelectable` ve false olduğunda kapalı kümeden bir `skipReason` (`needs_reauth`, `suspended`, `cooldown` veya `quota_exhausted`) içerir. Tek etkin hesap yine istek gönderebilir. `providerCredits`, `meteringEvent` ile ölçülür: fiziksel yanıttaki son değer tutulur, ayrı ücretlendirilen gönderimler toplanır; tokenlardan kredi tahmini yapılmaz. + `--json` şunu döndürür: ```text diff --git a/docs-site/src/content/docs/tr/reference/management-api.md b/docs-site/src/content/docs/tr/reference/management-api.md index cc70f9b9443..1bd688931ae 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -229,7 +229,7 @@ gelmezse bu alan boş kalır; istenen modelden çıkarım yapılmaz. | `GET /api/debug/injection-logs` | Sınırlı rehberlik enjeksiyonu hata ayıklama girdilerini okuyun | — | | `GET /api/claude/inbound-debug` | Claude gelen hata ayıklama durumunu ve girdilerini okuyun | — | | `GET /api/usage` | Kullanımı aralığa ve istemci yüzeyine göre özetleyin; Codex yanıtları ayrıca kararlı PII olmayan günlük etiketlerine göre anahtarlanan bir `accounts` dökümü içerir | Depolama okunamıyorsa 500 `{ "error": "read_failed" }` döndürür | -| `GET /api/metrics` | Mantıksal istekler, fiziksel gönderimler, kurtarma türleri, süre ve TTFT için süreç yerel Prometheus metin metriklerini döndürür. Etiketler kapalı protokol, sonuç ve kurtarma sınıfı kümeleriyle sınırlıdır; istek veya kimlik bilgisi tanımlayıcıları dışa aktarılmaz. | Başlangıçta `metricsExport.enabled` true değilse 404; olağan yönetim kimlik doğrulaması gerekir ve veri düzlemi kimlik bilgileri erişim sağlamaz | +| `GET /api/metrics` | Mantıksal istekler, fiziksel gönderimler, kurtarma türleri, süre ve TTFT için süreç yerel Prometheus metin metriklerini döndürür. İstek metriklerinin etiketleri kapalı kümelerdir; Kiro göstergeleri yalnızca sınırlı opak hesap etiketleri ekler; istek veya kimlik bilgisi tanımlayıcıları dışa aktarılmaz. Dört `opencodex_kiro_quota_{used_credits,limit_credits,used_percent,seconds_to_reset}` göstergesi yalnızca önbelleği okur ve en fazla 32 opak hesap etiketi kullanır. Toplama sırasında ağ sorgusu yapılmaz. | Başlangıçta `metricsExport.enabled` true değilse 404; olağan yönetim kimlik doğrulaması gerekir ve veri düzlemi kimlik bilgileri erişim sağlamaz | | `GET /api/storage` | Sepete göre Codex depolama kullanımını tarayın | Tarama hatasında bir `error: "scan_failed"` yükü döndürür | | `POST /api/storage/cleanup/preview` | Arşivlenmiş oturum temizliğini önizleyin ve bağlayıcı bir özet döndürün | 400 `invalid_json` veya `invalid_percent` | | `POST /api/storage/cleanup` | Önizlenen arşivlenmiş kümeyi karantinaya alın veya kalıcı olarak kaldırın | 400 geçersiz girdi; 409 eski/meşgul/başvurulan durum; 500 dosya sistemi/veritabanı hatası | @@ -296,7 +296,7 @@ Güvenilir ilk model listesi hazır olana kadar `/api/selected-models` ve `/api/ | `POST /api/oauth/login/cancel` | Devam eden bir genel OAuth akışını iptal edin | 400 bilinmeyen sağlayıcı | | `GET /api/oauth/status` | Bir sağlayıcının OAuth akışını yoklayın | 400 bilinmeyen sağlayıcı | | `POST /api/oauth/logout` | Seçilen sağlayıcı kimlik bilgisini kaldırın | 400 bilinmeyen sağlayıcı; `oauth_mutation_busy` | -| `GET, DELETE /api/oauth/accounts` | Maskelenmiş hesapları listeleyin veya bir hesabı kaldırın | 400 geçersiz sağlayıcı/kimlik; 404 hesap eksik; `oauth_mutation_busy` | +| `GET, DELETE /api/oauth/accounts` | Maskelenmiş hesapları listeleyin veya bir hesabı kaldırın Kiro satırları otomatik seçimden dışlandığında `autoSelectable` ve kapalı bir `skipReason` taşır; tek etkin hesap yine istek gönderebilir. Kota isteğe bağlıdır. | 400 geçersiz sağlayıcı/kimlik; 404 hesap eksik; `oauth_mutation_busy` | | `PUT /api/oauth/accounts/active` | Aktif OAuth hesabını seçin | 400 geçersiz sağlayıcı/hesap; `oauth_mutation_busy` | | `GET, PUT, PATCH /api/oauth/accounts/pool` | Anthropic OAuth havuz politikasını okuyun veya güncelleyin | 400 Anthropic olmayan sağlayıcı veya geçersiz politika | | `POST /api/oauth/accounts/clear-cooldown` | Bir OAuth hesabının çalışma zamanı soğuma süresini temizleyin | 400 geçersiz sağlayıcı/hesap | diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index e901e364f0e..6280fb8b9d8 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -184,7 +184,7 @@ Aside 配置档的变更在这种情况下仍会保存一件事:确认之后 | `GET /api/debug/injection-logs` | 读取有上限的 guidance-injection 调试条目 | — | | `GET /api/claude/inbound-debug` | 读取 Claude 入站调试状态和条目 | — | | `GET /api/usage` | 按范围和客户端界面汇总使用情况 | 若无法读取存储,则返回 500 `{ "error": "read_failed" }` | -| `GET /api/metrics` | 返回进程本地的 Prometheus 文本指标,涵盖逻辑请求、实际发送、恢复类型、持续时间和 TTFT。标签仅使用协议、结果和恢复类别的封闭集合;绝不导出请求或凭据标识。 | 启动时 `metricsExport.enabled` 不为 true 则返回 404;需要普通管理认证,数据平面凭据不能访问 | +| `GET /api/metrics` | 返回进程本地的 Prometheus 文本指标,涵盖逻辑请求、实际发送、恢复类型、持续时间和 TTFT。请求指标使用封闭标签集合;Kiro 指标仅增加有上限的不透明账户标签;绝不导出请求或凭据标识。 四个 Kiro 配额指标 `opencodex_kiro_quota_{used_credits,limit_credits,used_percent,seconds_to_reset}` 只读取缓存,最多使用 32 个不透明账户标签;抓取时不发起网络请求。 | 启动时 `metricsExport.enabled` 不为 true 则返回 404;需要普通管理认证,数据平面凭据不能访问 | | `GET /api/storage` | 按桶扫描 Codex 存储使用情况 | 扫描失败时返回带有 `error: "scan_failed"` 的载荷 | | `POST /api/storage/cleanup/preview` | 预览已归档会话清理并返回绑定摘要 | 400 `invalid_json` 或 `invalid_percent` | | `POST /api/storage/cleanup` | 隔离或永久移除预览出的归档集合 | 400 输入无效;409 过期/忙碌/被引用状态;500 文件系统/数据库失败 | @@ -233,7 +233,7 @@ Aside 配置档的变更在这种情况下仍会保存一件事:确认之后 | `POST /api/oauth/login/cancel` | 取消一个公开进行中的 OAuth 流程 | 400 provider 未知 | | `GET /api/oauth/status` | 轮询某个 provider 的 OAuth 流程 | 400 provider 未知 | | `POST /api/oauth/logout` | 移除选定的 provider 凭证 | 400 provider 未知;`oauth_mutation_busy` | -| `GET, DELETE /api/oauth/accounts` | 列出已脱敏账户或移除一个账户 | 400 provider/id 无效;404 账户缺失;`oauth_mutation_busy` | +| `GET, DELETE /api/oauth/accounts` | 列出已脱敏账户或移除一个账户 Kiro 行包含自动选择状态 `autoSelectable`,被排除时还包含封闭集合的 `skipReason`。唯一的活动账户仍可发送请求,配额查询仍为可选。 | 400 provider/id 无效;404 账户缺失;`oauth_mutation_busy` | | `PUT /api/oauth/accounts/active` | 选择当前活跃的 OAuth 账户 | 400 provider/账户无效;`oauth_mutation_busy` | | `GET, PUT, PATCH /api/oauth/accounts/pool` | 读取或更新 Anthropic OAuth 池策略 | 400 非 Anthropic provider 或策略无效 | | `POST /api/oauth/accounts/clear-cooldown` | 清除一个 OAuth 账户的运行时冷却 | 400 provider/账户无效 | diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index d845ceaa922..f140be115ac 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -184,7 +184,7 @@ Aside 設定檔的變更在這種情況下仍會儲存一件事:確認之後 | `GET /api/debug/injection-logs` | 讀取有界的 guidance-injection 除錯項目 | — | | `GET /api/claude/inbound-debug` | 讀取 Claude inbound 除錯狀態與項目 | — | | `GET /api/usage` | 依範圍與客戶端介面摘要用量 | 若儲存無法讀取則回傳 500 `{ "error": "read_failed" }` | -| `GET /api/metrics` | 回傳程序本機的 Prometheus 文字指標,涵蓋邏輯請求、實際傳送、復原種類、持續時間與 TTFT。標籤僅使用 protocol、result 與 recovery class 的封閉集合;絕不匯出請求或憑證識別碼。 | 啟動時 `metricsExport.enabled` 不為 true 則回傳 404;需要一般管理驗證,資料平面憑證不能存取 | +| `GET /api/metrics` | 回傳程序本機的 Prometheus 文字指標,涵蓋邏輯請求、實際傳送、復原種類、持續時間與 TTFT。請求指標使用封閉標籤集合;Kiro 指標僅增加有上限的不透明帳號標籤;絕不匯出請求或憑證識別碼。 四個 Kiro 配額指標 `opencodex_kiro_quota_{used_credits,limit_credits,used_percent,seconds_to_reset}` 只讀取快取,最多使用 32 個不透明帳號標籤;擷取時不發起網路請求。 | 啟動時 `metricsExport.enabled` 不為 true 則回傳 404;需要一般管理驗證,資料平面憑證不能存取 | | `GET /api/storage` | 依 bucket 掃描 Codex 儲存用量 | 掃描失敗時回傳 `error: "scan_failed"` payload | | `POST /api/storage/cleanup/preview` | 預覽已封存 session 清理並回傳綁定摘要 | 400 `invalid_json` 或 `invalid_percent` | | `POST /api/storage/cleanup` | 隔離或永久移除預覽的已封存集合 | 400 無效輸入;409 過時/忙碌/被參照狀態;500 檔案系統/資料庫失敗 | @@ -233,7 +233,7 @@ Aside 設定檔的變更在這種情況下仍會儲存一件事:確認之後 | `POST /api/oauth/login/cancel` | 取消公開進行中的 OAuth 流程 | 400 未知供應商 | | `GET /api/oauth/status` | 輪詢一個供應商的 OAuth 流程 | 400 未知供應商 | | `POST /api/oauth/logout` | 移除所選的供應商憑證 | 400 未知供應商;`oauth_mutation_busy` | -| `GET, DELETE /api/oauth/accounts` | 列出遮罩帳號或移除一個帳號 | 400 無效供應商/id;404 帳號缺失;`oauth_mutation_busy` | +| `GET, DELETE /api/oauth/accounts` | 列出遮罩帳號或移除一個帳號 Kiro 列包含自動選取狀態 `autoSelectable`,排除時還包含封閉集合的 `skipReason`。唯一的有效帳號仍可傳送請求,配額查詢仍為選用。 | 400 無效供應商/id;404 帳號缺失;`oauth_mutation_busy` | | `PUT /api/oauth/accounts/active` | 選擇現用 OAuth 帳號 | 400 無效供應商/帳號;`oauth_mutation_busy` | | `GET, PUT, PATCH /api/oauth/accounts/pool` | 讀取或更新 Anthropic OAuth 池政策 | 400 非 Anthropic 供應商或無效政策 | | `POST /api/oauth/accounts/clear-cooldown` | 清除一個 OAuth 帳號的 runtime 冷卻 | 400 無效供應商/帳號 | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 3f8c566751f..5967fdb8ee5 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -525,7 +525,7 @@ "cli-head.test.ts": "cli", "cli-headless-parity.test.ts": "cli", "cli-help.test.ts": "cli", - "cli-json-contract.test.ts": "cli", + "cli-json-contract.test.ts": "cli", "cli-kiro-auto-selection.test.ts": "cli", "cli-management-auth.test.ts": "cli", "cli-models-free-only.test.ts": "cli", "cli-models-price.test.ts": "cli", @@ -1076,7 +1076,7 @@ "kiro-account-state-disk.test.ts": "providers/kiro", "kiro-account-state-imports.test.ts": "providers/kiro", "kiro-adapter.test.ts": "providers/kiro", - "kiro-auth-context-continuation.test.ts": "providers/kiro", + "kiro-auth-context-continuation.test.ts": "providers/kiro", "kiro-auto-selection.test.ts": "providers/kiro", "kiro-builder-id-profile.test.ts": "providers/kiro", "kiro-calibration.test.ts": "providers/kiro", "kiro-device-builder.test.ts": "providers/kiro", @@ -1088,7 +1088,7 @@ "kiro-model-preference.test.ts": "providers/kiro", "kiro-oauth.test.ts": "providers/kiro", "kiro-pool-load-settings.test.ts": "providers/kiro", - "kiro-pool-rank.test.ts": "providers/kiro", + "kiro-pool-rank.test.ts": "providers/kiro", "kiro-quota-metrics.test.ts": "providers/kiro", "kiro-reasoning-roundtrip.test.ts": "providers/kiro", "kiro-refusal-failover.test.ts": "providers/kiro", "kiro-refusal.test.ts": "providers/kiro", diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index f5567add09b..7d9c1b07536 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -27,6 +27,8 @@ export interface AccountRow { masked?: string; active: boolean; needsReauth?: boolean; + autoSelectable?: boolean; + skipReason?: "needs_reauth" | "suspended" | "cooldown" | "quota_exhausted"; selectionExcludedReason?: "plan_excluded"; selectionExcludedPlan?: string; /** Registered credential that is still excluded from routing until validation completes. */ @@ -343,6 +345,8 @@ interface OAuthAccountDto { email?: string; active?: boolean; needsReauth?: boolean; + autoSelectable?: boolean; + skipReason?: unknown; /** Always sent by the management route; explicitly `null` when the tier is unknown. */ plan?: string | null; quota?: CodexQuotaDto | null; @@ -350,6 +354,11 @@ interface OAuthAccountDto { quotaFailure?: unknown; } +function isKiroSkipReason(value: unknown): value is NonNullable { + return value === "needs_reauth" || value === "suspended" + || value === "cooldown" || value === "quota_exhausted"; +} + async function fetchOAuthRows( deps: AccountDeps, baseUrl: string, @@ -376,6 +385,10 @@ async function fetchOAuthRows( email: a.email, active: a.active ?? a.id === activeId, needsReauth: a.needsReauth, + ...(name === "kiro" && typeof a.autoSelectable === "boolean" + ? { autoSelectable: a.autoSelectable } : {}), + ...(name === "kiro" && a.autoSelectable === false && isKiroSkipReason(a.skipReason) + ? { skipReason: a.skipReason } : {}), // Forward the server's answer verbatim. An absent key means the proxy predates tier // reporting while `null` means it checked and found no tier — collapsing either // direction would destroy the one distinction this field exists to make. diff --git a/src/cli/account.ts b/src/cli/account.ts index aa3c5e1b36e..95bcd892eb7 100644 --- a/src/cli/account.ts +++ b/src/cli/account.ts @@ -104,7 +104,9 @@ function statusText(row: AccountRow): string { // held out -- so printing only one of the two would hide exactly the confusing case (#2703). if (row.paused) parts.push("paused"); if (row.active) parts.push(row.type === "codex" ? "selected" : "active"); - if (row.needsReauth) parts.push("needs-reauth"); + if (row.needsReauth && !(row.provider === "kiro" && row.skipReason === "needs_reauth")) parts.push("needs-reauth"); + if (row.provider === "kiro" && row.autoSelectable === false) + parts.push(row.skipReason ? `not-auto-selected(${row.skipReason})` : "not-auto-selected"); if (row.validationPending) parts.push("validation-pending"); if (row.selectionExcludedReason === "plan_excluded") { parts.push(`not-auto-selected(plan=${row.selectionExcludedPlan ?? row.plan ?? "unknown"})`); diff --git a/src/oauth/generic-account-failover.ts b/src/oauth/generic-account-failover.ts index d1faf2f8ef9..809fdba6b27 100644 --- a/src/oauth/generic-account-failover.ts +++ b/src/oauth/generic-account-failover.ts @@ -128,6 +128,22 @@ function isCooled(provider: string, accountId: string, now: number, family?: Quo return true; } +export type KiroSkipReason = "needs_reauth" | "suspended" | "cooldown" | "quota_exhausted"; + +/** Eligibility for automatic alternatives; an active singleton can still send. */ +export function kiroAutoSelection( + account: ProviderAccount, now = Date.now(), +): { autoSelectable: boolean; skipReason?: KiroSkipReason } { + if (account.needsReauth === true) return { autoSelectable: false, skipReason: "needs_reauth" }; + const cooled = isCooled("kiro", account.id, now); + if (cooled && health.get(healthKey("kiro", account.id))?.cooldownSource === "kiro-suspension") + return { autoSelectable: false, skipReason: "suspended" }; + if (kiroAccountEvidence(account, now).exhausted === true) + return { autoSelectable: false, skipReason: "quota_exhausted" }; + if (cooled) return { autoSelectable: false, skipReason: "cooldown" }; + return { autoSelectable: true }; +} + /** True when this provider participates in generic rotation at all. */ export function isGenericFailoverProvider(providerName: string, provider: OcxProviderConfig): boolean { return provider.authMode === "oauth" && !EXCLUDED_PROVIDERS.has(providerName); @@ -244,9 +260,8 @@ function eligibleIdsIn( ): string[] { if (!set) return []; return set.accounts - .filter(account => account.needsReauth !== true && !isCooled(providerName, account.id, now, family) - && (providerName !== "kiro" || (!isCooled("kiro", account.id, now) - && kiroAccountEvidence(account, now).exhausted !== true))) + .filter(account => providerName === "kiro" ? kiroAutoSelection(account, now).autoSelectable + : account.needsReauth !== true && !isCooled(providerName, account.id, now, family)) .map(account => account.id); } diff --git a/src/providers/kiro-account-state-disk.ts b/src/providers/kiro-account-state-disk.ts index 3e9cb78fe0a..edafa9b9efa 100644 --- a/src/providers/kiro-account-state-disk.ts +++ b/src/providers/kiro-account-state-disk.ts @@ -22,6 +22,10 @@ export function sanitizeKiroQuota(quota: ProviderQuota): ProviderQuota { && window.percent >= 0 && window.percent <= 100); return { monthlyPercent: quota.monthlyPercent, + ...(typeof quota.kiroCreditsUsed === "number" && Number.isFinite(quota.kiroCreditsUsed) + && quota.kiroCreditsUsed >= 0 ? { kiroCreditsUsed: quota.kiroCreditsUsed } : {}), + ...(typeof quota.kiroCreditsLimit === "number" && Number.isFinite(quota.kiroCreditsLimit) + && quota.kiroCreditsLimit > 0 ? { kiroCreditsLimit: quota.kiroCreditsLimit } : {}), ...(typeof quota.monthlyResetAt === "number" && Number.isFinite(quota.monthlyResetAt) && Number.isFinite(new Date(quota.monthlyResetAt).getTime()) ? { monthlyResetAt: quota.monthlyResetAt } : {}), diff --git a/src/providers/kiro-quota-metrics.ts b/src/providers/kiro-quota-metrics.ts new file mode 100644 index 00000000000..d2ca63d869e --- /dev/null +++ b/src/providers/kiro-quota-metrics.ts @@ -0,0 +1,35 @@ +/** Cache-only Kiro quota projection for the opt-in metrics snapshot. */ +import { oauthAccountLogLabel } from "../codex/account-label"; +import { getAccountSet } from "../oauth/store"; +import { kiroAccountEvidence } from "./kiro-usage"; + +export interface KiroQuotaMetricRow { + account: string; + used: number; + limit: number; + percent: number; + secondsToReset?: number; +} + +const MAX_KIRO_METRIC_ACCOUNTS = 32; + +export function cachedKiroQuotaMetricRows(now = Date.now()): KiroQuotaMetricRow[] { + const accounts = getAccountSet("kiro")?.accounts ?? []; + const rows: KiroQuotaMetricRow[] = []; + const labels = new Set(); + for (const account of [...accounts].sort((a, b) => a.id.localeCompare(b.id))) { + const { quotaPercent: percent, creditsUsed: used, creditsLimit: limit, resetAt } = + kiroAccountEvidence(account, now); + if (typeof used !== "number" || !Number.isFinite(used) || used < 0 + || typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0 + || typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100 + || (resetAt !== undefined && (!Number.isFinite(resetAt) || resetAt <= now))) continue; + const label = oauthAccountLogLabel(account.id, "kiro"); + if (labels.has(label)) continue; + labels.add(label); + rows.push({ account: label, used, limit, percent, + ...(resetAt !== undefined ? { secondsToReset: (resetAt - now) / 1000 } : {}) }); + if (rows.length === MAX_KIRO_METRIC_ACCOUNTS) break; + } + return rows; +} diff --git a/src/providers/kiro-usage.ts b/src/providers/kiro-usage.ts index 76d32675d93..f336dde26c7 100644 --- a/src/providers/kiro-usage.ts +++ b/src/providers/kiro-usage.ts @@ -130,7 +130,8 @@ function parseKiroUsage(body: unknown): KiroUsageSnapshot | null { const used = preciseNumber(breakdown, "currentUsageWithPrecision", "currentUsage"); const limit = preciseNumber(breakdown, "usageLimitWithPrecision", "usageLimit"); - if (used === undefined || limit === undefined || limit <= 0) return null; + if (used === undefined || !Number.isFinite(used) || used < 0 + || limit === undefined || !Number.isFinite(limit) || limit <= 0) return null; const percent = normalizePercent((used / limit) * 100); if (percent === undefined) return null; @@ -152,6 +153,8 @@ function parseKiroUsage(body: unknown): KiroUsageSnapshot | null { const quota: ProviderQuota = { monthlyPercent: percent, + kiroCreditsUsed: used, + kiroCreditsLimit: limit, ...(nextResetAt !== undefined ? { monthlyResetAt: nextResetAt } : {}), ...(customWindows.length > 0 ? { customWindows } : {}), updatedAt: Date.now(), @@ -295,7 +298,7 @@ export function getKiroAccountExhaustion( /** The only Kiro routing evidence read; each half expires on its own clock. */ export function kiroAccountEvidence(account: ProviderAccount, now = Date.now()): - { quotaPercent?: number; exhausted?: boolean; resetAt?: number } { + { quotaPercent?: number; creditsUsed?: number; creditsLimit?: number; exhausted?: boolean; resetAt?: number } { hydrateKiroAccountState(); const key = accountCacheKey("kiro", account.id); const row = accountQuotaCache.get(key); @@ -308,6 +311,10 @@ export function kiroAccountEvidence(account: ProviderAccount, now = Date.now()): const verdict = getKiroAccountExhaustion(key, account, now); return { ...(quota?.monthlyPercent !== undefined ? { quotaPercent: quota.monthlyPercent } : {}), + ...(typeof quota?.kiroCreditsUsed === "number" && Number.isFinite(quota.kiroCreditsUsed) + && quota.kiroCreditsUsed >= 0 ? { creditsUsed: quota.kiroCreditsUsed } : {}), + ...(typeof quota?.kiroCreditsLimit === "number" && Number.isFinite(quota.kiroCreditsLimit) + && quota.kiroCreditsLimit > 0 ? { creditsLimit: quota.kiroCreditsLimit } : {}), ...(verdict ? { exhausted: verdict.exhausted } : {}), ...(verdict?.nextResetAt !== undefined ? { resetAt: verdict.nextResetAt } : quota?.monthlyResetAt !== undefined ? { resetAt: quota.monthlyResetAt } : {}), diff --git a/src/providers/quota-types.ts b/src/providers/quota-types.ts index d16a02ca374..5b1b47e818b 100644 --- a/src/providers/quota-types.ts +++ b/src/providers/quota-types.ts @@ -48,6 +48,9 @@ export interface ProviderQuota { weeklyResetAt?: number; monthlyPercent?: number; monthlyResetAt?: number; + /** Observed Kiro plan credits, independent of token and currency estimates. */ + kiroCreditsUsed?: number; + kiroCreditsLimit?: number; customWindows?: ProviderQuotaWindow[]; creditsUsd?: ProviderQuotaCreditsUsd; updatedAt: number; diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 97a7c674b70..6988a4090a6 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -87,6 +87,7 @@ import { import { sessionLaneIdFromRequest } from "../request-log-conversation"; import { responseWithDeferredRequestLog } from "../relay"; import { createRequestMetricsOwner } from "../request-metrics"; +import { cachedKiroQuotaMetricRows } from "../../providers/kiro-quota-metrics"; import { corsHeaders, managementCorsHeaders, @@ -292,7 +293,8 @@ export function createServeOptions(ctx: ServeOptionsContext) { port, } = ctx; void port; - const requestMetrics = metricsExportEnabled(config) ? createRequestMetricsOwner() : undefined; + const requestMetrics = metricsExportEnabled(config) + ? createRequestMetricsOwner(Date.now() / 1000, cachedKiroQuotaMetricRows) : undefined; const requestMetricsLogContext = requestMetrics ? { requestMetricsRecorder: requestMetrics } : {}; const requestManagementApiDeps: ManagementApiDeps = requestMetrics ? { ...managementApiDeps, requestMetrics: { snapshot: () => requestMetrics.snapshot() } } diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index c70f4cff2ef..7c61ef04464 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -384,6 +384,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const quotaMode = providerOAuthAccountQuotaMode(provider); const quotaProvider = config.providers[provider]; const { getAccountSet } = await import("../../oauth/store"); + const { kiroAutoSelection } = await import("../../oauth/generic-account-failover"); const { oauthAccountHealthFields, projectOAuthAccountHealth, @@ -402,7 +403,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< needsReauth: summary.needsReauth === true, reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined, }); - return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health), quotaMode }; + return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health), quotaMode, + ...(provider === "kiro" && full ? kiroAutoSelection(full) : {}) }; }), }; }; diff --git a/src/server/request-metrics.ts b/src/server/request-metrics.ts index 37c300494c4..daa4e3357e9 100644 --- a/src/server/request-metrics.ts +++ b/src/server/request-metrics.ts @@ -1,4 +1,5 @@ import type { ResponsesTerminalStatus } from "../bridge"; +import type { KiroQuotaMetricRow } from "../providers/kiro-quota-metrics"; import type { AttemptRecoveryKind } from "../usage/log"; import { REQUEST_FAILURE_CAUSES, @@ -188,8 +189,34 @@ function appendHistogram( } } +function appendKiroQuotaGauges(lines: string[], rows: readonly KiroQuotaMetricRow[]): void { + const families = [ + ["opencodex_kiro_quota_used_credits", "Cached Kiro plan credits used.", "used"], + ["opencodex_kiro_quota_limit_credits", "Cached Kiro plan credit limit.", "limit"], + ["opencodex_kiro_quota_used_percent", "Cached Kiro plan percent used.", "percent"], + ["opencodex_kiro_quota_seconds_to_reset", "Seconds until the observed Kiro reset.", "secondsToReset"], + ] as const; + const labels = new Set(); + const valid = rows.slice(0, 32).filter(row => { + if (!/^o[0-9a-f]{6}$/.test(row.account) || labels.has(row.account)) return false; + if (![row.used, row.limit, row.percent].every(value => Number.isFinite(value) && value >= 0)) return false; + if (row.limit <= 0 || row.percent > 100) return false; + labels.add(row.account); + return true; + }); + for (const [name, help, key] of families) { + lines.push(`# HELP ${name} ${help}`, `# TYPE ${name} gauge`); + for (const row of valid) { + const value = row[key]; + if (typeof value === "number" && Number.isFinite(value) && value >= 0) + lines.push(`${name}{account="${row.account}"} ${value}`); + } + } +} + export function createRequestMetricsOwner( processStartTimeSeconds = Date.now() / 1000, + kiroQuotaRows?: () => readonly KiroQuotaMetricRow[], ): RequestMetricsOwner { let logicalRequests = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RESULTS.length); let physicalSends = Array.from({ length: REQUEST_METRICS_PROTOCOLS.length }, () => 0); @@ -282,6 +309,7 @@ export function createRequestMetricsOwner( lines.push(`opencodex_ttft_missing_total${sampleLabels(protocol, result)} ${missingTtft[protocolCell(protocol)]![resultCell(result)]}`); } } + appendKiroQuotaGauges(lines, kiroQuotaRows?.() ?? []); lines.push( "# HELP opencodex_metrics_process_start_time_seconds Unix time when this process metrics owner started.", "# TYPE opencodex_metrics_process_start_time_seconds gauge", diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index a9ef4045fd7..24b0cda7814 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -372,6 +372,7 @@ calls the injected recorder once from `addFinalRequestLog`; the management route snapshot capability. There is no module-global active registry, timer, outbound connection, scrape-time log scan, or persistence. Restart creates a fresh owner, resets every counter/histogram, and changes `opencodex_metrics_process_start_time_seconds`. +The opt-in owner also renders four Kiro quota gauges from fresh, identity-matched cached observations in `src/providers/kiro-quota-metrics.ts`. It emits at most 32 distinct opaque account labels and makes no scrape-time upstream call; missing, future-dated, expired, or reset-passed evidence emits no sample. The label vocabularies are closed: protocol is `responses`, `chat`, `messages`, or `unknown`; result is `completed`, `failed`, `incomplete`, or `aborted`; recovery is one of the coarse classes listed in @@ -386,8 +387,7 @@ recovery kind already retained on an attempt contributes once to its coarse clas and it labels a counter only: no histogram carries a cause. HTTP 200 never overrides a failed terminal event. Duration observes every valid finalized duration; TTFT observes only finite nonnegative first-output values, while `opencodex_ttft_missing_total` is the complementary -denominator. No request, credential, account, provider, model, conversation, raw error, prompt, tool, -body, header, or URL value enters a label or sample. +denominator. The only account-specific metric label is the bounded Kiro opaque digest; no raw request, credential, account, provider, model, conversation, error, prompt, tool, body, header, or URL value enters a label or sample. For diagnosing upstream-shape / usage-extraction issues run `ocx debug usage on` (or set `OPENCODEX_USAGE_DEBUG=1` before start). The proxy then writes a rolling debug record per finalized diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 22eda7916b1..f7e938229e2 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -211,14 +211,14 @@ per-request first-party callback reads that live object; a failed write leaves i | Updates | `GET /api/update/check`, `POST /api/update/run`, and `GET /api/update/status` own dashboard self-update state. A launched worker PID is persisted in `update-job.json`; dead PIDs recover immediately, while legacy active records without a PID recover only after ten minutes. Live PIDs remain exclusive regardless of record age. `GET /api/update/check` and `POST /api/update/run` await one per-channel asynchronous registry lookup and write successful results through to the package cache; run passes that result to the job starter. `GET /api/update/badge` only reads the cache and reports unknown after 40 hours, on missing cache, or on channel mismatch. The badge links to the update surface rather than gating other actions. `GET /api/update/badge?surface=desktop&session=` projects only that process-local Tauri session; missing or expired state is unknown and never falls back to the package cache. `POST /api/update/desktop-snapshot` is a 1 KiB bounded display-state mutation with no install permission. It accepts the existing admin-token principal or a dedicated single-use, ten-second snapshot capability bound to nonce, method, path, PID, port and the SHA-256 digest of the exact body bytes; that capability cannot authorize another route. | | Providers | Create/update/delete ordinary provider configs and enrich registry metadata. A `POST /api/providers` overwrite of an existing name keeps the five operator compatibility settings (`PROVIDER_COMPAT_CARRY_FIELDS` in `src/server/management/provider-overwrite-carry.ts`) and the stored key pool only while the destination (adapter, normalized base URL, auth mode when named) is unchanged; it never merges the rest of the old row. `PATCH` is a field mask and keeps every field it does not name. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. | | Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. | -| OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, and remove one. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Meta Muse login start and manual-code continuation require the server-resolved `gui-session` principal before credential acquisition or code submission (including reauth); see the [provider contract](providers-and-adapters.md). Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | +| OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, and remove one. Kiro account-list rows include the current automatic-selection projection and closed exclusion reason; an active singleton may still send. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Meta Muse login start and manual-code continuation require the server-resolved `gui-session` principal before credential acquisition or code submission (including reauth); see the [provider contract](providers-and-adapters.md). Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Machine links live in `src/server/management/link-routes.ts`: dashboard sessions reach the Home-side routes `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host` and `POST /api/link/apply`, meaning a paired session or, on a standalone runtime, the current loopback-issued session on trusted loopback ingress; `POST /api/link/join` stays paired-only, because a join restarts this runtime and moves Codex routing to the Home, and a standalone runtime never issues a paired session, so no dashboard can join as a Child in this release; `GET /api/link/status` reports `joinAvailable` (false in practice) to GUI-session callers so the dashboard disables the Child role and points to Home-initiated linking; `GET /api/link/status` and `DELETE /api/link/{id}` also accept the admin token on a trusted loopback ingress; `POST /api/link/issue` accepts only that admin token. Tailscale-identity sessions are refused on every link route. See [Remote Link](remote-link.md). Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | | OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`openai-tiers.md`](providers/openai-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. Both also return response-only `multiAgentSurfaceAdvisory: { required, mode, recommended, version, docsUrl }`, true while the resolved mode is not v1 and the stored acknowledgement version is behind; PUT accepts `multiAgentSurfaceAdvisoryAcknowledged`, where only `true` stores the current version and `false` is an explicit no-op, and it composes with a `multiAgentMode` write in the same body so the dialog's recommended answer is one request. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. `LogsFilterBar` owns controls over the shared `LogFilterState`; `filterLogs` composes filters over the loaded ring. The logs envelope adds `generatedAt` (proxy epoch milliseconds); the page advances that sample with monotonic elapsed time and retains a browser-clock fallback for older proxies. Reset returns focus to the stable All surface radio. Provider/model options include attempts, model choices match normalized complete identities, and relative-time filtering refreshes every 30 seconds while the Logs tab is active, independently of network auto-refresh. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | | Usage | `GET /api/usage` read-only aggregates of readable rows from `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. Oversized skipped rows produce positive `usageIncomplete` metadata. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. `GET /api/usage/timeline` uses the same ledger and canonical attribution helpers for bounded bucketed model series. Never exposes prompts. | -| Request metrics | `GET /api/metrics` exposes process-local Prometheus text format v0.0.4 only when `metricsExport.enabled` was true at startup. The ordinary management gate applies; data-plane credentials do not grant access, and disabled mode is 404. `src/server/request-metrics.ts` owns fixed counters/histograms and receives a narrow final-request fact from `src/server/request-log.ts`; `src/server/index/serve-options.ts` creates one owner and injects the recorder and read-only snapshot into the request and management paths. | +| Request metrics | `GET /api/metrics` exposes process-local Prometheus text format v0.0.4 only when `metricsExport.enabled` was true at startup. The ordinary management gate applies; data-plane credentials do not grant access, and disabled mode is 404. `src/server/request-metrics.ts` owns fixed counters/histograms and bounded cached Kiro quota gauges; `src/server/index/serve-options.ts` injects one owner and a read-only snapshot. Scraping makes no upstream quota request. | | System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/health` is the authenticated scalar-only identity used by shared-plane Dashboard status and restart reconnect polling; its `spendLedger` block reports only ownership held/unheld, initialized/configured/degraded booleans and bounded persistence/corruption counters. Reading it never constructs, replays or prunes the ledger. Paths, scopes, accounts and request ids are absent, and the block never moves to unauthenticated `/healthz`. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Its response-state block also reports spill-write `initial`/`healthy`/`degraded` status, a consecutive-failure streak, fixed error class, and failure/success timestamps. A successful publication clears the streak in the same process; raw error text and paths never enter this surface. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. | | Stop | `POST /api/stop` — restore native Codex, stop any installed service, and exit the proxy. A sibling instance restores nothing and answers `sharedTeardown: "not-owned"` ([Codex home](codex-home.md#codex-home)). | | Diagnostics/sync | `src/server/management/config-routes.ts` — `GET /api/diagnostics/project-config` reports project-level Codex config that bypasses managed routing; `POST /api/sync` re-runs catalog/config sync. The diagnostic reports the bypass; it does not rewrite the project file. | diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 2ed98938fc5..e7be6830c24 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -70,6 +70,8 @@ and uses `addedAt` for legacy rows without one. For Kiro, `src/oauth/generic-account-failover.ts` filters confirmed monthly exhaustion and process-local suspension by the live account identity before picking a replacement. +Its `kiroAutoSelection` projection also supplies the account-list exclusion reason; +cached plan credit amounts share the same identity and expiry fence. The account actually sent supplies the generation fence; a rotated bearer always travels with its own profile ARN and region. Reactive rotation follows the stored two-account quorum, while refusal-aware first admission follows the proactive preference setting. diff --git a/structure/providers/kiro.md b/structure/providers/kiro.md index d5e6b2f1d35..9d4b6a6d840 100644 --- a/structure/providers/kiro.md +++ b/structure/providers/kiro.md @@ -49,6 +49,14 @@ account without a formable ARN is not probed. Persisted quota and exhaustion evi are bound independently by observation time, reset, and login identity, never by token or raw account label; removal, identity change, expiry, or malformed disk degrades routing evidence to unknown. Initial routing reads it through `kiroAccountEvidence`. +The same identity-fenced reading carries precise plan `kiroCreditsUsed` and +`kiroCreditsLimit`; missing or expired evidence has no metric sample. The automatic +candidate filter and account list both use `kiroAutoSelection` from +`src/oauth/generic-account-failover.ts`. Its closed reasons are `needs_reauth`, +`suspended`, `cooldown`, and `quota_exhausted`. An active singleton can still send +when it is excluded as an alternative. The existing `health` field does not reflect +Kiro suspension or quota exhaustion, so `health: ok` can coexist with +`autoSelectable: false`; the GUI does not display the new projection. After an account is admitted, a detached `ListAvailableModels` request reads that account's regional management host with its own timeout and account-paired bearer/profile. The request @@ -186,6 +194,8 @@ is a snapshot; separate completion-fallback responses add their credits. Missing absent and measured zero stays zero. `initial-response` carries `conversationId` through the same validated provider-state path as `messageMetadataEvent`. Unknown event types produce opt-in `debugProviderDiagnostic` entries containing only the event-type length, never the raw header or payload. +The final usage row records summed request spend across billed physical sends; sealed attempt +rows preserve per-serving-account spend in `src/usage/log.ts`. Coverage: `tests/providers/kiro/kiro-metering-events.test.ts`, `tests/providers/kiro/kiro-metering-usage.test.ts`, and `tests/server/server-kiro-completion-e2e.test.ts`. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index a039f9e00f2..83261f94b24 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -42,6 +42,11 @@ surface is listed here so a maintainer can find the owner without grepping: | GitHub Copilot | `src/providers/xai-transport.ts` (`resolveProviderTransport`), `src/providers/github-copilot-transport.ts` | `resolveProviderTransport` selects the Copilot transport when the routed provider name is `github-copilot`; the Copilot module then resolves its headers and base URL, and the registry seeds the provider row and model fallback. | | API-key pools | `src/providers/api-key-selection.ts`, `src/providers/key-failover.ts` | A configured `apiKeyPoolStrategy` plus a cooling committed key rotates before the first send (`selectProactiveApiKeyTransport`); a 429 still rotates after the send and records a cooldown. `provider.apiKey` keeps mirroring the active entry so routing stays single-key. The pick is inert without a strategy or while the committed key is healthy. | | OAuth account failover | `src/oauth/generic-account-failover.ts`, `src/oauth/anthropic-routing.ts` | Reactive pre-output 429 recovery is presence-driven with 2+ eligible accounts. Pool and `oauthAccountFailover` flags govern proactive routing, not the reactive retry: a disabled Anthropic pool recovers through quota ordering rather than its dormant strategy, a per-provider `enabled` beats the global default in either direction, and a non-positive fill-first threshold disables proactive usage-based rotation. Kiro's optional process-local account lease is released at response completion or cancellation; other providers retain their admission path. | + +Kiro's `kiroAutoSelection` projects the same candidate eligibility for routing and account-list +status. Unknown evidence remains eligible; reauth, suspension, cooldown, and confirmed exhaustion +have closed reasons. An active singleton or all-excluded pool can still send unless a separately +configured capacity cap times out. | OAuth login callback (inbound) | `src/oauth/callback-server.ts` | Every response, including non-callback 404s, closes its connection so a pooled socket cannot deliver a later login to a retired flow on the same callback port. | | Alibaba regions | `src/providers/alibaba-region-backup.ts`, `src/providers/alibaba-region-migration.ts`, `src/providers/alibaba-region-startup.ts` | Region migration backs up before rewriting and is idempotent across restarts. | | Discovery and quota | `src/providers/model-discovery.ts`, `src/providers/quota.ts`, `src/providers/registry.ts` | Discovery rejects a response over 4 MiB or past 2,000 raw rows before caching it. Provider-scoped hints fill capabilities omitted by live rosters; OpenCode Go's `deepseek-v4.1-flash` keeps its 1,048,576-token context window. The fixed-key Opper preset uses the shared OpenAI Chat adapter at `https://api.opper.ai/v3/compat`, discovers models through its conventional authenticated `/models` path, preserves an older same-named custom destination, and falls back to bare pool ids while passing vendor-prefixed ids through unchanged. Codex quota DTOs suppress retired Spark evidence under the [OpenAI scope contract](../providers/openai-tiers.md#public-provider-contract), retaining ordinary custom windows. | diff --git a/tests/cli/cli-kiro-auto-selection.test.ts b/tests/cli/cli-kiro-auto-selection.test.ts new file mode 100644 index 00000000000..08c0bde37df --- /dev/null +++ b/tests/cli/cli-kiro-auto-selection.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test"; +import { fetchRows, type AccountDeps } from "../../src/cli/account-api"; +import { cmdAccount, formatAccountTable } from "../../src/cli/account"; + +function deps(accounts: unknown[]): AccountDeps { + return { + baseUrl: "http://127.0.0.1:10100", + loadConfigImpl: () => ({ providers: { kiro: { adapter: "kiro", authMode: "oauth" } } }) as never, + fetchImpl: (async () => new Response(JSON.stringify({ activeAccountId: "a", accounts }), + { status: 200, headers: { "content-type": "application/json" } })) as typeof fetch, + }; +} + +test("Kiro list prints a closed reason and carries it in JSON", async () => { + const d = deps([{ id: "a", active: true, autoSelectable: false, skipReason: "suspended" }]); + const result = await fetchRows(d, d.baseUrl!, "kiro", "oauth"); + expect(result.rows[0]).toMatchObject({ autoSelectable: false, skipReason: "suspended" }); + expect(formatAccountTable(result.rows)).toContain("not-auto-selected(suspended)"); + const output: string[] = []; + const old = console.log; + console.log = (...parts: unknown[]) => output.push(parts.map(String).join(" ")); + try { + expect(await cmdAccount(["list", "kiro", "--json"], d)).toBe(0); + } finally { console.log = old; } + expect(JSON.parse(output.join("\n")).accounts[0]).toMatchObject({ + autoSelectable: false, skipReason: "suspended", + }); +}); + +test("older or malformed reason degrades without emitting upstream text", async () => { + const result = await fetchRows(deps([ + { id: "a", autoSelectable: false, skipReason: "upstream secret" }, + { id: "b", skipReason: "suspended" }, + ]), "http://127.0.0.1:10100", "kiro", "oauth"); + expect(result.rows[0]).toMatchObject({ autoSelectable: false }); + expect(result.rows[0]!.skipReason).toBeUndefined(); + expect(result.rows[1]!.autoSelectable).toBeUndefined(); + expect(formatAccountTable(result.rows)).toContain("not-auto-selected"); + expect(formatAccountTable(result.rows)).not.toContain("upstream secret"); +}); + +test("non-Kiro account text retains its old shape", async () => { + const result = await fetchRows(deps([{ id: "a", autoSelectable: false, + skipReason: "cooldown" }]), "http://127.0.0.1:10100", "xai", "oauth"); + expect(result.rows[0]!.autoSelectable).toBeUndefined(); + expect(result.rows[0]!.skipReason).toBeUndefined(); + expect(formatAccountTable(result.rows)).not.toContain("not-auto-selected"); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b486050b8ec..f5ee5987264 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -352,6 +352,7 @@ "cli-headless-parity.test.ts": "cli", "cli-help.test.ts": "cli", "cli-json-contract.test.ts": "cli", + "cli-kiro-auto-selection.test.ts": "cli", "cli-management-auth.test.ts": "cli", "cli-models-free-only.test.ts": "cli", "cli-models-price.test.ts": "cli", @@ -898,6 +899,7 @@ "kiro-account-state-imports.test.ts": "providers/kiro", "kiro-adapter.test.ts": "providers/kiro", "kiro-auth-context-continuation.test.ts": "providers/kiro", + "kiro-auto-selection.test.ts": "providers/kiro", "kiro-builder-id-profile.test.ts": "providers/kiro", "kiro-calibration.test.ts": "providers/kiro", "kiro-device-builder.test.ts": "providers/kiro", @@ -910,6 +912,7 @@ "kiro-oauth.test.ts": "providers/kiro", "kiro-pool-load-settings.test.ts": "providers/kiro", "kiro-pool-rank.test.ts": "providers/kiro", + "kiro-quota-metrics.test.ts": "providers/kiro", "kiro-reasoning-roundtrip.test.ts": "providers/kiro", "kiro-refusal-failover.test.ts": "providers/kiro", "kiro-refusal.test.ts": "providers/kiro", diff --git a/tests/providers/kiro/kiro-auto-selection.test.ts b/tests/providers/kiro/kiro-auto-selection.test.ts new file mode 100644 index 00000000000..6a055a9ae98 --- /dev/null +++ b/tests/providers/kiro/kiro-auto-selection.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearGenericFailoverHealth, eligibleFailoverAccounts, kiroAutoSelection, + quarantineKiroSuspendedAccount, rotateGenericOAuthAccountOnRefusal } from "../../../src/oauth/generic-account-failover"; +import { getAccountSet, markAccountNeedsReauth, saveCredential } from "../../../src/oauth/store"; +import { setCachedProviderAccountQuotaForTests, clearAccountQuotaCache } from "../../../src/providers/quota"; +import { commitKiroAccountUsageState } from "../../../src/providers/kiro-usage"; +import { kiroEvidenceIdentity } from "../../../src/providers/kiro-account-state-disk"; +import type { ProviderAccount } from "../../../src/oauth/types"; +import type { OcxConfig } from "../../../src/types"; +import { removeTreeWithRetry } from "../../helpers/remove-tree"; + +const previousHome = process.env.OPENCODEX_HOME; +let home: string; +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-kiro-auto-")); + process.env.OPENCODEX_HOME = home; + clearGenericFailoverHealth(); + clearAccountQuotaCache(); +}); +afterEach(() => { + clearGenericFailoverHealth(); + clearAccountQuotaCache(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +async function accounts(): Promise { + for (const id of ["reauth", "suspended", "cooldown", "exhausted", "unknown"]) { + await saveCredential("kiro", { access: `access-${id}`, refresh: `refresh-${id}`, + expires: Date.now() + 3600_000, accountId: id }, { addAccount: true }); + } + return getAccountSet("kiro")!.accounts; +} + +test("Kiro candidate and list projection agree on family-less exclusion states", async () => { + const roster = await accounts(); + const byName = (name: string) => roster.find(a => a.credential.accountId === name)!; + await markAccountNeedsReauth("kiro", byName("reauth").id, true); + quarantineKiroSuspendedAccount(byName("suspended").id); + const config = { providers: { kiro: { adapter: "kiro", authMode: "oauth" } } } as OcxConfig; + rotateGenericOAuthAccountOnRefusal(config, "kiro", byName("cooldown").id, + "rate", "120"); + const exhausted = byName("exhausted"); + const now = Date.now(); + setCachedProviderAccountQuotaForTests("kiro", exhausted.id, { + monthlyPercent: 100, updatedAt: now, monthlyResetAt: now + 3600_000, + }); + commitKiroAccountUsageState(`kiro\0${exhausted.id}`, { quota: { updatedAt: now }, + exhausted: true, nextResetAt: now + 3600_000 }, kiroEvidenceIdentity(exhausted)); + const expected = { reauth: "needs_reauth", suspended: "suspended", cooldown: "cooldown", + exhausted: "quota_exhausted", unknown: undefined } as const; + const live = getAccountSet("kiro")!.accounts; + const eligible = eligibleFailoverAccounts("kiro", now); + for (const account of live) { + const name = account.credential.accountId as keyof typeof expected; + const projected = kiroAutoSelection(account, now); + expect(projected.autoSelectable).toBe(eligible.includes(account.id)); + expect(projected.skipReason).toBe(expected[name]); + } + expect(eligible).toEqual([byName("unknown").id]); + expect(kiroAutoSelection(byName("suspended"), now + 24 * 60 * 60_000 + 1)) + .toEqual({ autoSelectable: true }); +}); diff --git a/tests/providers/kiro/kiro-quota-metrics.test.ts b/tests/providers/kiro/kiro-quota-metrics.test.ts new file mode 100644 index 00000000000..d70aca19ae9 --- /dev/null +++ b/tests/providers/kiro/kiro-quota-metrics.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getAccountSet, saveCredential } from "../../../src/oauth/store"; +import { clearAccountQuotaCache, setCachedProviderAccountQuotaForTests } from "../../../src/providers/quota"; +import { cachedKiroQuotaMetricRows } from "../../../src/providers/kiro-quota-metrics"; +import { sanitizeKiroQuota } from "../../../src/providers/kiro-account-state-disk"; +import { createRequestMetricsOwner } from "../../../src/server/request-metrics"; +import { removeTreeWithRetry } from "../../helpers/remove-tree"; + +const previousHome = process.env.OPENCODEX_HOME; +const realFetch = globalThis.fetch; +let home: string; +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-kiro-metric-")); + process.env.OPENCODEX_HOME = home; + clearAccountQuotaCache(); +}); +afterEach(() => { + globalThis.fetch = realFetch; + clearAccountQuotaCache(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +async function account(id: string) { + await saveCredential("kiro", { access: `access-${id}`, refresh: `refresh-${id}`, + expires: Date.now() + 3600_000, accountId: id }, { addAccount: true }); + return getAccountSet("kiro")!.accounts.find(a => a.credential.accountId === id)!; +} +function seed(id: string, updatedAt: number, resetAt?: number) { + setCachedProviderAccountQuotaForTests("kiro", id, { monthlyPercent: 25, + kiroCreditsUsed: 2.5, kiroCreditsLimit: 10, updatedAt, + ...(resetAt !== undefined ? { monthlyResetAt: resetAt } : {}) }); +} + +test("fresh cached credits yield four opaque gauges without probing", async () => { + const now = Date.now(); + const a = await account("person@example.com"); + seed(a.id, now, now + 60_000); + let calls = 0; + globalThis.fetch = (async () => { calls++; throw new Error("scrape probed upstream"); }) as typeof fetch; + const output = createRequestMetricsOwner(1, () => cachedKiroQuotaMetricRows(now)).snapshot(); + expect(calls).toBe(0); + const rows = cachedKiroQuotaMetricRows(now); + expect(rows).toHaveLength(1); + expect(rows[0]!.secondsToReset).toBe(60); + expect(rows[0]!.account).toMatch(/^o[0-9a-f]{6}$/); + for (const name of ["used_credits", "limit_credits", "used_percent", "seconds_to_reset"]) { + expect(output).toContain(`# TYPE opencodex_kiro_quota_${name} gauge`); + expect(output).toContain(`opencodex_kiro_quota_${name}{account="${rows[0]!.account}"}`); + } + expect(output).not.toContain("person@example.com"); + expect(output).not.toContain(a.id); +}); + +test("future, stale, reset-passed, and identity-mismatched rows are omitted", async () => { + const now = Date.now(); + const a = await account("future"); + seed(a.id, now + 1_000); + expect(cachedKiroQuotaMetricRows(now)).toEqual([]); + seed(a.id, now - 31 * 60_000); + expect(cachedKiroQuotaMetricRows(now)).toEqual([]); + seed(a.id, now, now - 1); + expect(cachedKiroQuotaMetricRows(now)).toEqual([]); + seed(a.id, now); + const { accountQuotaCache, accountCacheKey } = await import("../../../src/providers/quota/account-cache"); + accountQuotaCache.get(accountCacheKey("kiro", a.id))!.identity = "old-login"; + expect(cachedKiroQuotaMetricRows(now)).toEqual([]); +}); + +test("stale leading accounts do not hide 32 later valid distinct gauges", async () => { + const now = Date.now(); + for (let i = 0; i < 35; i++) { + const a = await account(String(i).padStart(3, "0")); + seed(a.id, i < 3 ? now - 31 * 60_000 : now); + } + const rows = cachedKiroQuotaMetricRows(now); + expect(rows).toHaveLength(32); + expect(new Set(rows.map(row => row.account)).size).toBe(32); +}); + +test("duplicate or malformed labels never produce a second Prometheus series", () => { + const output = createRequestMetricsOwner(1, () => [ + { account: "oabcdef", used: 1, limit: 10, percent: 10 }, + { account: "oabcdef", used: 2, limit: 10, percent: 20 }, + { account: "person@example.com", used: 3, limit: 10, percent: 30 }, + ]).snapshot(); + expect(output.match(/^opencodex_kiro_quota_used_credits\{account=/gm)).toHaveLength(1); + expect(output).not.toContain("person@example.com"); +}); + +test("disk sanitizer preserves valid precise plan credits only", () => { + expect(sanitizeKiroQuota({ monthlyPercent: 25, kiroCreditsUsed: 2.5, + kiroCreditsLimit: 10, updatedAt: 1 })).toMatchObject({ + kiroCreditsUsed: 2.5, kiroCreditsLimit: 10, + }); + const invalid = sanitizeKiroQuota({ monthlyPercent: 25, kiroCreditsUsed: -2, + kiroCreditsLimit: Infinity, updatedAt: 1 }); + expect(invalid.kiroCreditsUsed).toBeUndefined(); + expect(invalid.kiroCreditsLimit).toBeUndefined(); +}); diff --git a/tests/providers/kiro/kiro-usage-quota.test.ts b/tests/providers/kiro/kiro-usage-quota.test.ts index 7c2f79976ff..7533f328fe1 100644 --- a/tests/providers/kiro/kiro-usage-quota.test.ts +++ b/tests/providers/kiro/kiro-usage-quota.test.ts @@ -66,6 +66,8 @@ describe("Kiro usage limits", () => { }); const snapshot = await fetchKiroUsageSnapshot(baseContext); expect(snapshot?.quota.monthlyPercent).toBeCloseTo(14.782, 3); + expect(snapshot?.quota.kiroCreditsUsed).toBe(147.82); + expect(snapshot?.quota.kiroCreditsLimit).toBe(1000); expect(snapshot?.quota.monthlyResetAt).toBe(1785542400 * 1000); expect(snapshot?.exhausted).toBe(false); }); @@ -82,6 +84,14 @@ describe("Kiro usage limits", () => { expect(snapshot?.quota.monthlyPercent).toBe(10); }); + test("a negative or non-finite used reading yields no quota", async () => { + for (const used of [-1, "Infinity", "NaN"]) { + stubUsageResponse({ usageBreakdownList: [breakdown({ currentUsageWithPrecision: used, + currentUsage: used })] }); + expect(await fetchKiroUsageSnapshot(baseContext)).toBeNull(); + } + }); + test("reports unknown when no recognised resource type is present", async () => { stubUsageResponse({ usageBreakdownList: [{ resourceType: "FUTURE_POOL", currentUsage: 5, usageLimit: 10 }], diff --git a/tests/server/management-metrics-export.test.ts b/tests/server/management-metrics-export.test.ts index 1e22473801f..99fc58071b5 100644 --- a/tests/server/management-metrics-export.test.ts +++ b/tests/server/management-metrics-export.test.ts @@ -546,9 +546,8 @@ describe("metrics management boundary", () => { } expect(source).not.toMatch(/(?:let|const)\s+activeRequestMetrics/); const composition = readFileSync(repoPath("src/server/index/serve-options.ts"), "utf8"); - expect(composition).toContain( - "metricsExportEnabled(config) ? createRequestMetricsOwner() : undefined", - ); + expect(composition).toContain("metricsExportEnabled(config)"); + expect(composition).toContain("createRequestMetricsOwner(Date.now() / 1000, cachedKiroQuotaMetricRows)"); expect(composition).toContain("requestMetrics ? { requestMetricsRecorder: requestMetrics } : {}"); expect(composition).toContain("createWebsocketHandler(ctx, requestMetrics)"); expect(readFileSync(repoPath("src/server/index/websocket-handler.ts"), "utf8")) From f287a17af1155887245e12727a2e01ab57d831c4 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 27 Sep 2026 06:27:29 +0900 Subject: [PATCH 2/2] fix(kiro): keep the metrics scrape off the disk snapshot --- src/providers/kiro-quota-metrics.ts | 2 +- src/providers/kiro-usage.ts | 6 ++++-- .../providers/kiro/kiro-quota-metrics.test.ts | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/providers/kiro-quota-metrics.ts b/src/providers/kiro-quota-metrics.ts index d2ca63d869e..0e5cdf3df7c 100644 --- a/src/providers/kiro-quota-metrics.ts +++ b/src/providers/kiro-quota-metrics.ts @@ -19,7 +19,7 @@ export function cachedKiroQuotaMetricRows(now = Date.now()): KiroQuotaMetricRow[ const labels = new Set(); for (const account of [...accounts].sort((a, b) => a.id.localeCompare(b.id))) { const { quotaPercent: percent, creditsUsed: used, creditsLimit: limit, resetAt } = - kiroAccountEvidence(account, now); + kiroAccountEvidence(account, now, { hydrate: false }); if (typeof used !== "number" || !Number.isFinite(used) || used < 0 || typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0 || typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100 diff --git a/src/providers/kiro-usage.ts b/src/providers/kiro-usage.ts index f336dde26c7..4ebd7d15edf 100644 --- a/src/providers/kiro-usage.ts +++ b/src/providers/kiro-usage.ts @@ -297,9 +297,11 @@ export function getKiroAccountExhaustion( } /** The only Kiro routing evidence read; each half expires on its own clock. */ -export function kiroAccountEvidence(account: ProviderAccount, now = Date.now()): +export function kiroAccountEvidence(account: ProviderAccount, now = Date.now(), opts: { hydrate?: boolean } = {}): { quotaPercent?: number; creditsUsed?: number; creditsLimit?: number; exhausted?: boolean; resetAt?: number } { - hydrateKiroAccountState(); + // Routing hydrates saved evidence on first use; the metrics scrape passes hydrate:false so a + // scrape never touches the disk snapshot and simply reports nothing until routing has loaded it. + if (opts.hydrate !== false) hydrateKiroAccountState(); const key = accountCacheKey("kiro", account.id); const row = accountQuotaCache.get(key); const quota = row?.identity === kiroEvidenceIdentity(account) && row.quota diff --git a/tests/providers/kiro/kiro-quota-metrics.test.ts b/tests/providers/kiro/kiro-quota-metrics.test.ts index d70aca19ae9..932154526aa 100644 --- a/tests/providers/kiro/kiro-quota-metrics.test.ts +++ b/tests/providers/kiro/kiro-quota-metrics.test.ts @@ -102,3 +102,22 @@ test("disk sanitizer preserves valid precise plan credits only", () => { expect(invalid.kiroCreditsUsed).toBeUndefined(); expect(invalid.kiroCreditsLimit).toBeUndefined(); }); + +test("a metrics scrape never hydrates the disk snapshot; routing does", async () => { + const { writeFileSync } = await import("node:fs"); + const { getConfigDir } = await import("../../../src/config"); + const { kiroEvidenceIdentity } = await import("../../../src/providers/kiro-account-state-disk"); + const { kiroAccountEvidence } = await import("../../../src/providers/kiro-usage"); + const a = await account("disk"); + const now = Date.now(); + clearAccountQuotaCache(); + writeFileSync(join(getConfigDir(), "provider-account-quota-cache.json"), JSON.stringify({ + version: 1, + rows: { [`kiro\u0000${a.id}`]: { monthlyPercent: 40, kiroCreditsUsed: 4, kiroCreditsLimit: 10, + updatedAt: now - 1_000, identity: kiroEvidenceIdentity(a) } }, + })); + expect(cachedKiroQuotaMetricRows(now)).toEqual([]); + expect(kiroAccountEvidence(a, now).quotaPercent).toBe(40); + expect(cachedKiroQuotaMetricRows(now)).toHaveLength(1); +}); +