diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 739e6acefc..fff8735b21 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -32,7 +32,7 @@ bun run dev:gui | **Startup safety** | Show whether injected Codex routing survives a restart, with separate service and launcher-shim health plus exact repair commands. | | **Windows tray** | Install a per-user login tray for one-click proxy start, stop, restart, dashboard access, and status. The tray is a controller, not a proxy restart service. | | **Codex autostart** | Allow an already-installed Codex launcher shim to run `ocx ensure`. This toggle does not install a shim or background service. | -| **Providers** | Add, edit, enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. | +| **Providers** | Add, edit, enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. For Claude (Anthropic) OAuth pools, each logged-in account shows its own 5-hour and weekly rate-limit bars (usage is per credential); a failed probe keeps the last-known bars and marks them unavailable until the next successful refresh. | | **Add provider** | Search registry-backed presets for account login, API-key services, local servers, or a custom endpoint. | | **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, enable or disable quota auto-switch, set its 1–100% threshold, and configure transient-failure failover. | | **Subagents** | Feature up to five bare native or namespaced routed models in the `spawn_agent` override list. | diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 2a253dc9c7..c0ae3a6c16 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -32,7 +32,7 @@ bun run dev:gui | **起動安全性** | 注入された Codex ルーティングが再起動後も機能するか、サービスと launcher shim の状態、正確な修復コマンドと共に表示します。 | | **Windows トレイ** | ユーザーのログイントレイを導入し、プロキシ開始・停止・再起動・ダッシュボード・状態をクリックで操作します。トレイは再起動サービスではありません。 | | **Codex 自動起動** | インストール済み Codex launcher shim に `ocx ensure` の実行を許可します。このトグルは shim やバックグラウンドサービスをインストールしません。 | -| **プロバイダー** | プロバイダーを追加、編集、有効化/無効化、削除し、対応する OAuth アカウントプールと API キープールを管理します。 | +| **プロバイダー** | プロバイダーを追加、編集、有効化/無効化、削除し、対応する OAuth アカウントプールと API キープールを管理します。Claude(Anthropic)OAuth プールでは、ログイン済みの各アカウントに独自の 5 時間・週間レート制限バーが表示され(利用量は資格情報単位)、取得失敗時は直近の値を保持して一時利用不可と表示します。 | | **プロバイダー追加** | レジストリベースのプリセットからアカウントログイン、API キーサービス、ローカルサーバー、custom エンドポイントを検索します。 | | **Codex 認証** | ChatGPT/Codex プールアカウントを追加し、次回セッションアカウントを選び、5 時間 / 週間 / 30 日クォータを更新し、クォータ自動切り替えのオン/オフと 1~100% のしきい値、一時的失敗フェイルオーバーを設定します。 | | **サブエージェント** | `spawn_agent` オーバーライド一覧にネイティブまたはルーティングモデルを最大 5 つまで優先公開します。 | diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index 8ab11d4e6d..473759ac06 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -32,7 +32,7 @@ bun run dev:gui | **시작 안전성** | 주입된 Codex 라우팅이 재부팅 후에도 유지되는지 서비스와 launcher shim 상태, 정확한 복구 명령과 함께 표시합니다. | | **Windows 트레이** | 로그인할 때 사용자 전용 트레이를 시작하고 프록시 시작·중지·재시작·대시보드·상태를 클릭으로 제어합니다. 트레이는 재시작 서비스가 아닙니다. | | **Codex 자동 시작** | 이미 설치된 Codex launcher shim이 `ocx ensure`를 실행하도록 허용합니다. 이 토글은 shim이나 백그라운드 서비스를 설치하지 않습니다. | -| **Providers** | 프로바이더를 추가, 편집, 활성화/비활성화, 제거하고, 지원되는 OAuth 계정 풀과 API key 풀을 관리합니다. | +| **Providers** | 프로바이더를 추가, 편집, 활성화/비활성화, 제거하고, 지원되는 OAuth 계정 풀과 API key 풀을 관리합니다. Claude(Anthropic) OAuth 풀에서는 로그인한 계정마다 자체 5시간·주간 한도 막대가 표시되며(사용량은 자격 증명 단위), 조회 실패 시 마지막 값을 유지하고 일시 불가 상태로 표시합니다. | | **Add provider** | 레지스트리 기반 프리셋에서 계정 로그인, API key 서비스, 로컬 서버, custom endpoint를 검색합니다. | | **Codex Auth** | ChatGPT/Codex 풀 계정을 추가하고, 다음 세션 계정을 선택하고, 5시간 / 주간 / 30일 할당량을 갱신하며, 할당량 자동 전환을 켜거나 끄고 1~100% 임계값과 일시적 실패 failover를 설정합니다. | | **Subagents** | `spawn_agent` override 목록에 네이티브 또는 라우팅 모델을 최대 5개까지 우선 노출합니다. | diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 70d8e9e22a..45b433513f 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -32,7 +32,7 @@ bun run dev:gui | **Безопасность запуска** | Показывает, сохранит ли внедрённая маршрутизация Codex работоспособность после перезагрузки, отдельно отображая службу, launcher shim и точные команды исправления. | | **Трей Windows** | Устанавливает пользовательский значок входа для запуска, остановки, перезапуска, панели и состояния прокси одним щелчком. Трей не является службой перезапуска. | | **Автозапуск Codex** | Разрешает уже установленному launcher shim Codex выполнять `ocx ensure`. Переключатель не устанавливает shim или фоновую службу. | -| **Providers** | Добавление, редактирование, включение/отключение и удаление провайдеров; управление пулами OAuth-аккаунтов и пулами API-ключей там, где они поддерживаются. | +| **Providers** | Добавление, редактирование, включение/отключение и удаление провайдеров; управление пулами OAuth-аккаунтов и пулами API-ключей там, где они поддерживаются. Для пулов Claude (Anthropic) OAuth у каждого вошедшего аккаунта свои полосы 5-часового и недельного лимита (использование по учётным данным); при сбое опроса сохраняются последние известные значения с пометкой недоступности. | | **Add provider** | Поиск по пресетам из реестра: вход по аккаунту, сервисы с API-ключом, локальные серверы или пользовательская конечная точка. | | **Codex Auth** | Добавление аккаунтов пула ChatGPT/Codex, выбор аккаунта для следующей сессии, обновление квот 5 ч / недельных / 30-дневных, включение или отключение автопереключения, настройка его порога 1–100% и failover при временных сбоях. | | **Subagents** | Выделение до пяти «голых» нативных или маршрутизируемых моделей с пространством имён в списке переопределений `spawn_agent`. | diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index d799eea539..ece5b04a03 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -31,7 +31,7 @@ bun run dev:gui | **启动安全** | 显示注入的 Codex 路由能否在重启后继续工作,并分别显示服务、launcher shim 状态和准确的修复命令。 | | **Windows 托盘** | 安装用户登录托盘,一键控制代理启动、停止、重启、面板和状态。托盘不是代理重启服务。 | | **Codex 自动启动** | 允许已安装的 Codex launcher shim 运行 `ocx ensure`。此开关不会安装 shim 或后台服务。 | -| **Providers** | 添加、编辑、启用/禁用、删除 provider,并在支持时管理 OAuth 账号池和 API key 池。 | +| **Providers** | 添加、编辑、启用/禁用、删除 provider,并在支持时管理 OAuth 账号池和 API key 池。Claude(Anthropic)OAuth 池中,每个已登录账号显示各自的 5 小时与周限额条(用量按凭证计);探测失败时保留上次已知数值并标记为暂时不可用。 | | **Add provider** | 搜索 registry preset,选择账号登录、API key 服务、本地服务器或自定义 endpoint。 | | **Codex Auth** | 添加 ChatGPT/Codex 池账号,选择下一 session 的账号,刷新 5h / 每周 / 30d 配额,启用或停用配额自动切换,设置其 1–100% 阈值和临时故障 failover。 | | **Subagents** | 在 `spawn_agent` override 列表中置顶最多五个原生或路由模型。 | diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index efa61b33e0..45049d16e5 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -20,6 +20,7 @@ import { } from "../../oauth-health-display"; import CodexAccountPool from "../CodexAccountPool"; import { LoginUrlBlock } from "../login-url-block"; +import QuotaBars from "../QuotaBars"; import { useCopyFeedback } from "../use-copy-feedback"; import type { CodexAccountPoolController } from "../../hooks/useCodexAccountPool"; import type { AccountLoadState, OAuthAccountRow, ApiKeyRow, LoginHint, ProviderAuthHandlers } from "./types"; @@ -181,7 +182,8 @@ export default function ProviderAuthPanel({ const healthSummary = formatOAuthHealthSummary(t, item.name, account.id, account.health); const copyDoctor = () => { doctorCopy.copy(DOCTOR_CMD, account.id); }; return ( -
  • +
  • +
    +
    + {(account.quota || account.quotaUnavailable) && ( +
    + {account.quota && ( + + )} + {account.quotaUnavailable && ( +

    {t("pws.accountQuotaUnavailable")}

    + )} +
    + )}
  • ); })} diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx index 952ec4c4bd..869a867fec 100644 --- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx @@ -65,6 +65,8 @@ export default function ProviderWorkspaceShell({ jsonSaving = false, modelsRefreshToken = 0, activeAccountNeedsReauth, + /** Stable key of active OAuth account ids — refetch overview quotas after account switch. */ + quotaRefreshKey = "", detail, }: { providers: Record; @@ -81,6 +83,11 @@ export default function ProviderWorkspaceShell({ /** Bump after login/config changes so /api/selected-models is refetched. */ modelsRefreshToken?: number; activeAccountNeedsReauth?: Record; + /** + * Explicit active-account identity key (e.g. `anthropic:|…`). Prefer this over + * `activeAccountNeedsReauth` object identity so healthy account switches still refresh. + */ + quotaRefreshKey?: string; /** Detail body for the selected provider (WP090); a placeholder renders when absent. */ detail?: (item: WorkspaceItem, data: DetailSlotData) => ReactNode; }) { @@ -200,7 +207,9 @@ export default function ProviderWorkspaceShell({ }) .catch(() => { /* keep last-good */ }); return () => { cancelled = true; }; - }, [apiBase]); + // Key on active-account identity (not the reauth boolean map) so switching between two + // healthy accounts still re-reads /api/provider-quotas for the Usage/overview bars. + }, [apiBase, quotaRefreshKey]); useEffect(() => { if (!filterOpen) return; diff --git a/gui/src/components/provider-workspace/types.ts b/gui/src/components/provider-workspace/types.ts index 16a5949061..9bac652a63 100644 --- a/gui/src/components/provider-workspace/types.ts +++ b/gui/src/components/provider-workspace/types.ts @@ -3,6 +3,7 @@ * workspace shell/rail/detail (WP080a). Data shapes only; no React. */ import type { ProviderSortMode, WorkspaceItem } from "../../provider-workspace/catalog"; +import type { AccountQuota } from "../../codex-quota-utils"; export type { ProviderSortMode, WorkspaceItem }; @@ -49,6 +50,9 @@ export type OAuthAccountRow = { healthLabel?: string; healthSummary?: string; healthAction?: string; + /** Per-account rate limits, for providers that report usage per credential (anthropic). */ + quota?: AccountQuota | null; + quotaUnavailable?: boolean; }; export type ApiKeyRow = { diff --git a/gui/src/hooks/useProviderAccountPools.ts b/gui/src/hooks/useProviderAccountPools.ts index 42647e9ed7..b1220ba2ff 100644 --- a/gui/src/hooks/useProviderAccountPools.ts +++ b/gui/src/hooks/useProviderAccountPools.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; import type { AccountLoadState } from "../components/provider-workspace/types"; import { accountNeedsReauth } from "../oauth-health-display"; +import type { AccountQuota } from "../codex-quota-utils"; import { oauthAccountDisplayLabel } from "../provider-workspace/auth"; export interface Config { @@ -21,6 +22,10 @@ export interface OAuthAccount { healthLabel?: string; healthSummary?: string; healthAction?: string; + /** Per-account rate limits (providers that report usage per credential, e.g. anthropic). */ + quota?: AccountQuota | null; + /** Set when the per-account probe could not reach upstream (expired login, 429, network). */ + quotaUnavailable?: boolean; } export interface ApiKeyEntry { id: string; label?: string; masked: string; active: boolean } @@ -75,12 +80,34 @@ export function useProviderAccountPools(deps: { const generation = (accountRequestGenerationRef.current[provider] ?? 0) + 1; accountRequestGenerationRef.current[provider] = generation; try { + // Cheap local read first so account switch / reauth / remove controls appear + // even when Anthropic's usage endpoint is slow or timing out. const res = await fetch(`${apiBase}/api/oauth/accounts?provider=${encodeURIComponent(provider)}`); if (!res.ok) throw new Error(String(res.status)); const data = await res.json() as { activeAccountId?: string | null; accounts?: OAuthAccount[] }; if (!aliveRef.current || accountRequestGenerationRef.current[provider] !== generation) return true; setAccountSets(current => ({ ...current, [provider]: { activeAccountId: data.activeAccountId ?? null, accounts: data.accounts ?? [] } })); setAccountLoadStates(current => ({ ...current, [provider]: "ready" })); + + // Enrich with per-account rate limits asynchronously (Anthropic reports usage + // per credential). Failures leave the already-ready account rows untouched. + void (async () => { + try { + const quotaRes = await fetch(`${apiBase}/api/oauth/accounts?provider=${encodeURIComponent(provider)}"a=1`); + if (!quotaRes.ok) return; + const quotaData = await quotaRes.json() as { activeAccountId?: string | null; accounts?: OAuthAccount[] }; + if (!aliveRef.current || accountRequestGenerationRef.current[provider] !== generation) return; + setAccountSets(current => ({ + ...current, + [provider]: { + activeAccountId: quotaData.activeAccountId ?? data.activeAccountId ?? null, + accounts: quotaData.accounts ?? data.accounts ?? [], + }, + })); + } catch { + /* keep local account rows without quota enrichment */ + } + })(); return true; } catch { if (!aliveRef.current || accountRequestGenerationRef.current[provider] !== generation) return true; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index d206e2c2d1..3b5e71f88a 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1115,6 +1115,7 @@ export const de: Record = { "pws.usageUnavailable": "Noch keine Nutzung erfasst.", "pws.rateLimits": "Limits", "pws.quotaUnavailable": "Keine Kontingentdaten für diesen Provider.", + "pws.accountQuotaUnavailable": "Ratenlimit-Daten vorübergehend nicht verfügbar; falls vorhanden, werden zuletzt bekannte Werte angezeigt.", "pws.selected": "Ausgewählt", "pws.copyModelId": "ID kopieren", "pws.modelCopied": "Kopiert!", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index a2b07434d2..56b1288efa 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -891,6 +891,7 @@ export const en = { "pws.usageUnavailable": "No usage recorded yet.", "pws.rateLimits": "Rate limits", "pws.quotaUnavailable": "No quota data for this provider.", + "pws.accountQuotaUnavailable": "Rate-limit data temporarily unavailable; showing last known values when present.", "pws.selected": "Selected", "pws.copyModelId": "Copy ID", "pws.modelCopied": "Copied!", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index d27de24152..5c48772152 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -848,6 +848,7 @@ export const ja: Record = { "pws.usageUnavailable": "まだ使用量が記録されていません。", "pws.rateLimits": "レート制限", "pws.quotaUnavailable": "このプロバイダーのクォータデータがありません。", + "pws.accountQuotaUnavailable": "レート制限データを一時的に取得できません。前回の値がある場合はそれを表示します。", "pws.selected": "選択中", "pws.copyModelId": "ID をコピー", "pws.modelCopied": "コピーしました!", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 1257596e93..9e7aac3263 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1135,6 +1135,7 @@ export const ko: Record = { "pws.usageUnavailable": "아직 기록된 사용량이 없습니다.", "pws.rateLimits": "요청 한도", "pws.quotaUnavailable": "이 프로바이더의 쿼터 데이터가 없습니다.", + "pws.accountQuotaUnavailable": "요금 한도 데이터를 일시적으로 가져올 수 없습니다. 이전 값이 있으면 그대로 표시합니다.", "pws.selected": "선택됨", "pws.copyModelId": "ID 복사", "pws.modelCopied": "복사됨!", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 82efcb20d0..aaa66fd6b7 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -890,6 +890,7 @@ export const ru: Record = { "pws.usageUnavailable": "Использование пока не зафиксировано.", "pws.rateLimits": "Лимиты запросов", "pws.quotaUnavailable": "Нет данных о квоте для этого провайдера.", + "pws.accountQuotaUnavailable": "Данные о лимитах временно недоступны; при наличии показываются последние известные значения.", "pws.selected": "Выбрана", "pws.copyModelId": "Копировать ID", "pws.modelCopied": "Скопировано!", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 796724fc1d..7651c00cb8 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1135,6 +1135,7 @@ export const zh: Record = { "pws.usageUnavailable": "尚无用量记录。", "pws.rateLimits": "速率限制", "pws.quotaUnavailable": "此提供商暂无配额数据。", + "pws.accountQuotaUnavailable": "速率限制数据暂时不可用;若有上次已知值则继续显示。", "pws.selected": "已选择", "pws.copyModelId": "复制 ID", "pws.modelCopied": "已复制!", diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 0a369195b4..14d7baae73 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import ProviderWorkspaceShell, { type AddProviderIntent } from "../components/provider-workspace/ProviderWorkspaceShell"; import ProviderDetails from "../components/provider-workspace/ProviderDetails"; import type { WorkspaceProvider } from "../provider-workspace/catalog"; @@ -73,6 +73,13 @@ export default function Providers({ apiBase }: { apiBase: string }) { switchAccount, switchApiKey, removeApiKey, addApiKeyValue, editCredentialAlias, removeAccount, activeAccountNeedsReauth, } = pools; + const quotaRefreshKey = useMemo( + () => Object.entries(accountSets) + .map(([provider, set]) => `${provider}:${set.activeAccountId ?? ""}`) + .sort() + .join("|"), + [accountSets], + ); const jsonEditor = useJsonConfigEditor({ apiBase, config, notify, @@ -203,6 +210,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { jsonSaving={jsonSaving} modelsRefreshToken={modelsRefreshToken} activeAccountNeedsReauth={activeAccountNeedsReauth} + quotaRefreshKey={quotaRefreshKey} detail={(item, data) => { const loginStatus = accountLoginStatus[item.name] ?? oauthStatus[item.name]; return ( diff --git a/gui/src/styles/provider-workspace-settings.css b/gui/src/styles/provider-workspace-settings.css index 8da4fc5456..5241c59053 100644 --- a/gui/src/styles/provider-workspace-settings.css +++ b/gui/src/styles/provider-workspace-settings.css @@ -42,6 +42,13 @@ } .pwi-auth-row:hover { background: var(--hover); } .pwi-auth-row--active { background: var(--accent-soft); } + +/* One account entry: the row itself plus (optionally) that account's own rate-limit bars. */ +.pwi-auth-acct { display: flex; flex-direction: column; gap: 2px; border-radius: var(--radius-xs); } +.pwi-auth-acct--active { background: var(--accent-soft); } +.pwi-auth-acct--active .pwi-auth-row--active { background: none; } +.pwi-auth-acct-quota { padding: 0 8px 8px 26px; } +.pwi-auth-acct-quota-stale { margin: 4px 0 0; font-size: 0.85em; } .pwi-auth-row-main { appearance: none; flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 2px; border: 0; background: transparent; color: inherit; text-align: left; diff --git a/src/oauth/index.ts b/src/oauth/index.ts index a610a8cbd7..da6fbc8583 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -667,6 +667,15 @@ export async function runLogin(provider: string, ctrl: OAuthController, opts?: L } else { await saveCredential(provider, cred); } + if (provider !== "chatgpt") { + try { + const { clearAccountQuotaCache, clearProviderQuotaCache } = await import("../providers/quota"); + clearProviderQuotaCache(); + clearAccountQuotaCache(provider); + } catch { + // Quota module may be unavailable in tightly scoped unit tests. + } + } if (provider === "chatgpt") return cred; const config = loadConfig(); upsertOAuthProvider(config, provider); diff --git a/src/providers/quota.ts b/src/providers/quota.ts index cfe76f8df9..f0503f4ecc 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -1,13 +1,16 @@ import { fetchMainAccountInfo, listCodexAuthAccounts } from "../codex/auth-api"; import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; import { resolveEnvValue } from "../config"; -import { getValidAccessToken } from "../oauth"; -import { getCredential } from "../oauth/store"; +import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth"; +import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store"; import { antigravityUserAgent } from "../adapters/client-fingerprint"; import { getProviderRegistryEntry, providerCodexAccountMode } from "./registry"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./openai-tiers"; +/** Match oauth/index REFRESH_SKEW_MS — use stored access without refresh when still fresh. */ +const ACCOUNT_TOKEN_SKEW_MS = 60_000; + const CACHE_TTL_MS = 5 * 60_000; const REQUEST_TIMEOUT_MS = 8_000; const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1"; @@ -188,43 +191,221 @@ function parseClaudeBucket(value: unknown): { percent?: number; resetAt?: number return { percent, resetAt }; } +/** Claude's OAuth usage endpoint, probed with ONE account's own bearer token. */ +const anthropicUsageInflight = new Map>(); + +async function fetchAnthropicUsageQuota(accessToken: string): Promise { + const joinable = anthropicUsageInflight.get(accessToken); + if (joinable) return joinable; + + const probe = (async (): Promise => { + const response = await fetch("https://api.anthropic.com/api/oauth/usage", { + headers: { + Accept: "application/json, text/plain, */*", + "Content-Type": "application/json", + "User-Agent": "claude-cli/2.1.63 (external, cli)", + "anthropic-beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05", + Authorization: `Bearer ${accessToken}`, + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) return null; + const body = asRecord(await response.json().catch(() => null)); + if (!body) return null; + const fiveHour = parseClaudeBucket(body.five_hour); + const sevenDay = parseClaudeBucket(body.seven_day); + const opus = parseClaudeBucket(body.seven_day_opus); + const sonnet = parseClaudeBucket(body.seven_day_sonnet); + const customWindows: ProviderQuotaWindow[] = []; + if (opus?.percent !== undefined) customWindows.push({ label: "Opus", percent: opus.percent, ...(opus.resetAt !== undefined ? { resetAt: opus.resetAt } : {}) }); + if (sonnet?.percent !== undefined) customWindows.push({ label: "Sonnet", percent: sonnet.percent, ...(sonnet.resetAt !== undefined ? { resetAt: sonnet.resetAt } : {}) }); + const quota: ProviderQuota = { + // Claude's 5-hour window is a first-class rate limit, same as the Codex login 5h/weekly + // rows: report it in the canonical fields so the dashboard renders it with the standard + // "5-hour limit" label and ordering instead of as a generic extra window. + ...(fiveHour?.percent !== undefined ? { fiveHourPercent: fiveHour.percent } : {}), + ...(fiveHour?.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}), + ...(sevenDay?.percent !== undefined ? { weeklyPercent: sevenDay.percent } : {}), + ...(sevenDay?.resetAt !== undefined ? { weeklyResetAt: sevenDay.resetAt } : {}), + ...(customWindows.length > 0 ? { customWindows } : {}), + updatedAt: Date.now(), + }; + // Empty / schema-changed payloads must not cache as "success with no bars". + return hasQuotaRows(quota) ? quota : null; + })().finally(() => { + if (anthropicUsageInflight.get(accessToken) === probe) anthropicUsageInflight.delete(accessToken); + }); + anthropicUsageInflight.set(accessToken, probe); + return probe; +} + async function fetchAnthropicQuota(provider: string): Promise { + // Capture the account we intend to probe before awaiting — a mid-flight active + // switch must not seed the wrong account's cache with this response. + const probedAccountId = getAccountSet("anthropic")?.activeAccountId; let accessToken: string; try { accessToken = await getValidAccessToken("anthropic"); } catch { return null; } - const response = await fetch("https://api.anthropic.com/api/oauth/usage", { - headers: { - Accept: "application/json, text/plain, */*", - "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.63 (external, cli)", - "anthropic-beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05", - Authorization: `Bearer ${accessToken}`, - }, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) return null; - const body = asRecord(await response.json().catch(() => null)); - if (!body) return null; - const fiveHour = parseClaudeBucket(body.five_hour); - const sevenDay = parseClaudeBucket(body.seven_day); - const opus = parseClaudeBucket(body.seven_day_opus); - const sonnet = parseClaudeBucket(body.seven_day_sonnet); - const customWindows: ProviderQuotaWindow[] = []; - if (fiveHour?.percent !== undefined) customWindows.push({ label: "5h", percent: fiveHour.percent, ...(fiveHour.resetAt !== undefined ? { resetAt: fiveHour.resetAt } : {}) }); - if (opus?.percent !== undefined) customWindows.push({ label: "Opus", percent: opus.percent, ...(opus.resetAt !== undefined ? { resetAt: opus.resetAt } : {}) }); - if (sonnet?.percent !== undefined) customWindows.push({ label: "Sonnet", percent: sonnet.percent, ...(sonnet.resetAt !== undefined ? { resetAt: sonnet.resetAt } : {}) }); - const quota: ProviderQuota = { - ...(sevenDay?.percent !== undefined ? { weeklyPercent: sevenDay.percent } : {}), - ...(sevenDay?.resetAt !== undefined ? { weeklyResetAt: sevenDay.resetAt } : {}), - ...(customWindows.length > 0 ? { customWindows } : {}), - updatedAt: Date.now(), - }; + const quota = await fetchAnthropicUsageQuota(accessToken); + if (!quota) return null; + // Share the active-account probe with the per-account cache so Providers-page + // loads do not double-hit Anthropic's rate-limited usage endpoint. + if (probedAccountId) { + const stillOwnsToken = getAccountCredential("anthropic", probedAccountId)?.access === accessToken; + if (stillOwnsToken) { + accountQuotaCache.set(accountCacheKey("anthropic", probedAccountId), { ts: Date.now(), quota }); + } + } return report(provider, "anthropic:oauth-usage", quota); } +// --------------------------------------------------------------------------- +// Per-account quota (multiauth) +// --------------------------------------------------------------------------- + +/** + * Anthropic reports usage per CREDENTIAL, so every logged-in account can be probed with its + * own bearer token — the active-account selection and the local usage log are irrelevant here. + * Mirrors the Codex pool behaviour (codex/auth-api.ts:fetchPoolAccountQuota), including a + * per-account TTL so N accounts cost at most N upstream calls per window. + * + * The TTL is deliberately longer than the provider-level one: this path multiplies by account + * count, and Anthropic rate-limits the usage endpoint (observed 429 under repeated probing). + */ +const ACCOUNT_QUOTA_TTL_MS = 10 * 60_000; +type AccountQuotaCacheEntry = { + ts: number; + quota: ProviderQuota | null; + /** Last probe failed (429 / network / expired login); still may hold last-good quota. */ + unavailable?: true; +}; +const accountQuotaCache = new Map(); +const accountQuotaInflight = new Map>(); + +export interface ProviderAccountQuota { + accountId: string; + quota: ProviderQuota | null; + /** Set when the probe could not reach upstream (expired login, 429, network). */ + unavailable?: true; +} + +/** Providers whose per-account quota can be probed. Extend as other OAuth APIs are covered. */ +export function supportsPerAccountQuota(provider: string): boolean { + return provider === "anthropic"; +} + +function accountCacheKey(provider: string, accountId: string): string { + return `${provider}\u0000${accountId}`; +} + +/** Drop cached per-account rows (all, or just one provider's). */ +export function clearAccountQuotaCache(provider?: string): void { + if (!provider) { + accountQuotaCache.clear(); + accountQuotaInflight.clear(); + return; + } + const prefix = `${provider}\u0000`; + for (const key of [...accountQuotaCache.keys()]) { + if (key.startsWith(prefix)) accountQuotaCache.delete(key); + } + // Drop in-flight probes too so a late resolve cannot repopulate after logout/remove. + for (const key of [...accountQuotaInflight.keys()]) { + if (key.startsWith(prefix)) accountQuotaInflight.delete(key); + } +} + +/** + * Resolve a bearer for quota probing without silently adopting a newer global + * Claude CLI credential into a background multiauth slot. + * + * - Fresh stored access → use as-is (no refresh). + * - Active account with expired access → normal refresh path. + * - Background `local-cli` with expired access → fail closed (unavailable): + * `getValidAccessTokenForAccount` can persist a mismatched Claude CLI identity. + * - Background ordinary OAuth (`source !== "local-cli"`) → safe to refresh; + * Anthropic's lock only adopts disk credentials for `local-cli` rows. + */ +async function getTokenForAccountQuotaProbe(provider: string, accountId: string): Promise { + const stored = getAccountCredential(provider, accountId); + if (!stored) throw new Error("account credential missing"); + if (stored.expires > Date.now() + ACCOUNT_TOKEN_SKEW_MS) return stored.access; + const activeId = getAccountSet(provider)?.activeAccountId; + if (activeId !== accountId && stored.source === "local-cli") { + throw new Error("background local-cli token expired; skip CLI-adopting refresh for quota probe"); + } + return getValidAccessTokenForAccount(provider, accountId); +} + +async function fetchAccountQuota( + provider: string, + accountId: string, + forceRefresh: boolean, +): Promise { + const key = accountCacheKey(provider, accountId); + const cached = accountQuotaCache.get(key); + if (!forceRefresh && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS) return cached; + const joinable = accountQuotaInflight.get(key); + if (joinable) return joinable; + + const probe = (async (): Promise => { + try { + const token = await getTokenForAccountQuotaProbe(provider, accountId); + const quota = await fetchAnthropicUsageQuota(token); + if (!quota) { + // Preserve last-good bars and mark unavailable; advance TTL so failures + // negative-cache instead of re-probing on every GUI poll. + const entry: AccountQuotaCacheEntry = { + ts: Date.now(), + quota: cached?.quota ?? null, + unavailable: true, + }; + accountQuotaCache.set(key, entry); + return entry; + } + const entry: AccountQuotaCacheEntry = { ts: Date.now(), quota }; + accountQuotaCache.set(key, entry); + return entry; + } catch { + const entry: AccountQuotaCacheEntry = { + ts: Date.now(), + quota: cached?.quota ?? null, + unavailable: true, + }; + accountQuotaCache.set(key, entry); + return entry; + } + })().finally(() => { + if (accountQuotaInflight.get(key) === probe) accountQuotaInflight.delete(key); + }); + accountQuotaInflight.set(key, probe); + return probe; +} + +/** + * Per-account quota rows for a provider's logged-in accounts. Probes run in parallel; a + * single failing account never blocks the others. + */ +export async function fetchProviderAccountQuotas( + provider: string, + forceRefresh = false, +): Promise { + if (!supportsPerAccountQuota(provider)) return []; + const set = getAccountSet(provider); + if (!set) return []; + return await Promise.all(set.accounts.map(async account => { + const entry = await fetchAccountQuota(provider, account.id, forceRefresh); + return { + accountId: account.id, + quota: entry.quota, + ...(entry.unavailable ? { unavailable: true as const } : {}), + }; + })); +} + function normalizedBaseUrl(value: string): string | null { try { const url = new URL(value); diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 70ac002a35..009f1147a4 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -27,7 +27,7 @@ import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/ke import { deriveProviderPresets } from "../../providers/derive"; import { providerCodexAccountMode } from "../../providers/registry"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; -import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; +import { clearProviderQuotaCache, fetchProviderAccountQuotas, fetchProviderQuotaReports, supportsPerAccountQuota } from "../../providers/quota"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; @@ -146,8 +146,9 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< await removeCredential(provider); clearLoginState(provider); // Drop cached/last-good quota rows tied to the removed credential. - const { clearProviderQuotaCache } = await import("../../providers/quota"); + const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); + clearAccountQuotaCache(provider); return jsonResponse({ success: true }); } @@ -163,18 +164,46 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< projectOAuthAccountHealth, projectStoredOAuthAccountHealth, } = await import("../../oauth/health"); - const set = getAccountSet(provider); - const accounts = (status.accounts ?? []).map(summary => { - const full = set?.accounts.find(account => account.id === summary.id); - const health = full - ? projectStoredOAuthAccountHealth(provider, full) - : projectOAuthAccountHealth({ - needsReauth: summary.needsReauth === true, - reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined, - }); - return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health) }; + const projectAccounts = () => { + const set = getAccountSet(provider); + const current = getLoginStatus(provider); + return { + activeAccountId: current.activeAccountId ?? null, + accounts: (current.accounts ?? []).map(summary => { + const full = set?.accounts.find(account => account.id === summary.id); + const health = full + ? projectStoredOAuthAccountHealth(provider, full) + : projectOAuthAccountHealth({ + needsReauth: summary.needsReauth === true, + reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined, + }); + return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health) }; + }), + }; + }; + // Per-account rate limits: Anthropic reports usage per credential, so every logged-in + // account can show its own 5h/weekly bars (not just the active one). Opt-in via ?quota=1 + // so the plain account list stays a cheap local read; ?refresh=1 bypasses the TTL. + const wantQuota = url.searchParams.get("quota") === "1" && supportsPerAccountQuota(provider); + if (!wantQuota) return jsonResponse(projectAccounts()); + const forceRefresh = url.searchParams.get("refresh") === "1"; + // Probing may refresh the active credential and mark needsReauth — project health + // from the post-probe store so the response is not stale. + const rows = await fetchProviderAccountQuotas(provider, forceRefresh); + const byId = new Map(rows.map(row => [row.accountId, row])); + const projected = projectAccounts(); + return jsonResponse({ + activeAccountId: projected.activeAccountId, + accounts: projected.accounts.map(account => { + const row = byId.get(account.id); + if (!row) return account; + return { + ...account, + quota: row.quota, + ...(row.unavailable ? { quotaUnavailable: true } : {}), + }; + }), }); - return jsonResponse({ activeAccountId: status.activeAccountId ?? null, accounts }); } if (url.pathname === "/api/oauth/accounts/active" && req.method === "PUT") { const body = await req.json().catch(() => ({})) as { provider?: string; accountId?: string }; @@ -209,8 +238,9 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const { removeAccount, getAccountSet } = await import("../../oauth/store"); if (!(await removeAccount(provider, id))) return jsonResponse({ error: "account not found" }, 404); if (!getAccountSet(provider)) clearLoginState(provider); - const { clearProviderQuotaCache } = await import("../../providers/quota"); + const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); + clearAccountQuotaCache(provider); return jsonResponse({ ok: true }); } diff --git a/tests/provider-account-quota.test.ts b/tests/provider-account-quota.test.ts new file mode 100644 index 0000000000..16dd5dd3a1 --- /dev/null +++ b/tests/provider-account-quota.test.ts @@ -0,0 +1,376 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveCredential } from "../src/oauth/store"; +import type { OcxConfig } from "../src/types"; +import { + clearAccountQuotaCache, + clearProviderQuotaCache, + fetchProviderAccountQuotas, + fetchProviderQuotaReports, + supportsPerAccountQuota, +} from "../src/providers/quota"; + +const originalFetch = globalThis.fetch; +const previousOpencodexHome = process.env.OPENCODEX_HOME; +let opencodexHome: string; + +const FIRST = { accountId: "acct-first", email: "first@example.com" }; +const SECOND = { accountId: "acct-second", email: "second@example.com" }; + +/** Two logged-in Claude accounts, each with its own (non-expired) bearer token. */ +async function seedTwoAccounts(): Promise { + const expires = Date.now() + 60 * 60_000; + await saveCredential("anthropic", { access: "token-first", refresh: "refresh-first", expires, ...FIRST }); + await saveCredential("anthropic", { access: "token-second", refresh: "refresh-second", expires, ...SECOND }); +} + +function usageBody(fiveHour: number, sevenDay: number): string { + return JSON.stringify({ + five_hour: { utilization: fiveHour, resets_at: "2026-07-05T12:00:00Z" }, + seven_day: { utilization: sevenDay, resets_at: "2026-07-08T12:00:00Z" }, + }); +} + +beforeEach(() => { + opencodexHome = mkdtempSync(join(tmpdir(), "ocx-account-quota-")); + process.env.OPENCODEX_HOME = opencodexHome; + clearAccountQuotaCache(); + clearProviderQuotaCache(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + rmSync(opencodexHome, { recursive: true, force: true }); + clearAccountQuotaCache(); + clearProviderQuotaCache(); +}); + +describe("fetchProviderAccountQuotas", () => { + test("reports each account's own rate limits, keyed by the account's bearer token", async () => { + await seedTwoAccounts(); + const seenTokens: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toBe("https://api.anthropic.com/api/oauth/usage"); + const auth = new Headers(init?.headers).get("authorization") ?? ""; + seenTokens.push(auth); + // Distinct upstream numbers per credential — the whole point of a per-account probe. + const body = auth.endsWith("token-first") ? usageBody(70, 15) : usageBody(3, 21); + return new Response(body, { status: 200 }); + }) as typeof fetch; + + const rows = await fetchProviderAccountQuotas("anthropic"); + const byId = Object.fromEntries(rows.map(row => [row.accountId, row])); + const ids = Object.keys(byId); + expect(ids.length).toBe(2); + + const values = rows.map(row => `${row.quota?.fiveHourPercent}/${row.quota?.weeklyPercent}`).sort(); + expect(values).toEqual(["3/21", "70/15"]); + expect(seenTokens.sort()).toEqual(["Bearer token-first", "Bearer token-second"]); + // The 5-hour window lands in the canonical fields, not in customWindows. + for (const row of rows) expect(row.quota?.customWindows).toBeUndefined(); + }); + + test("a cached row is reused instead of re-probing upstream", async () => { + await seedTwoAccounts(); + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return new Response(usageBody(50, 10), { status: 200 }); + }) as typeof fetch; + + await fetchProviderAccountQuotas("anthropic"); + expect(calls).toBe(2); + await fetchProviderAccountQuotas("anthropic"); + expect(calls).toBe(2); + + // A forced refresh bypasses the TTL. + await fetchProviderAccountQuotas("anthropic", true); + expect(calls).toBe(4); + }); + + test("a failing probe is flagged unavailable without dropping the other account", async () => { + await seedTwoAccounts(); + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const auth = new Headers(init?.headers).get("authorization") ?? ""; + // Anthropic rate-limits this endpoint; one 429 must not blank the sibling account. + if (auth.endsWith("token-first")) return new Response("rate limited", { status: 429 }); + return new Response(usageBody(3, 21), { status: 200 }); + }) as typeof fetch; + + const rows = await fetchProviderAccountQuotas("anthropic"); + const failed = rows.find(row => row.quota === null); + const ok = rows.find(row => row.quota !== null); + expect(failed?.unavailable).toBe(true); + expect(ok?.quota?.fiveHourPercent).toBe(3); + }); + + test("success-then-fail preserves last-good quota and keeps unavailable", async () => { + await seedTwoAccounts(); + let calls = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const auth = new Headers(init?.headers).get("authorization") ?? ""; + calls += 1; + if (calls <= 2) { + const body = auth.endsWith("token-first") ? usageBody(70, 15) : usageBody(3, 21); + return new Response(body, { status: 200 }); + } + return new Response("rate limited", { status: 429 }); + }) as typeof fetch; + + const first = await fetchProviderAccountQuotas("anthropic"); + expect(first.every(row => row.quota && !row.unavailable)).toBe(true); + expect(calls).toBe(2); + const firstByValues = Object.fromEntries( + first.map(row => [`${row.quota?.fiveHourPercent}/${row.quota?.weeklyPercent}`, row.accountId]), + ); + + const second = await fetchProviderAccountQuotas("anthropic", true); + expect(calls).toBe(4); + for (const row of second) { + expect(row.unavailable).toBe(true); + expect(row.quota).not.toBeNull(); + } + const byId = Object.fromEntries(second.map(row => [row.accountId, row])); + expect(byId[firstByValues["70/15"]!]?.quota?.fiveHourPercent).toBe(70); + expect(byId[firstByValues["3/21"]!]?.quota?.fiveHourPercent).toBe(3); + }); + + test("failed probes negative-cache for the account TTL instead of re-probing", async () => { + await seedTwoAccounts(); + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return new Response("rate limited", { status: 429 }); + }) as typeof fetch; + + const first = await fetchProviderAccountQuotas("anthropic"); + expect(first.every(row => row.unavailable && row.quota === null)).toBe(true); + expect(calls).toBe(2); + + const second = await fetchProviderAccountQuotas("anthropic"); + expect(second.every(row => row.unavailable && row.quota === null)).toBe(true); + expect(calls).toBe(2); + }); + + test("expired background accounts skip CLI-adopting refresh for quota probes", async () => { + const expires = Date.now() - 60_000; + await saveCredential("anthropic", { + access: "token-active", refresh: "refresh-active", expires: Date.now() + 60 * 60_000, + accountId: "acct-active", email: "active@example.com", + }); + await saveCredential("anthropic", { + access: "token-bg", refresh: "refresh-bg", expires, + accountId: "acct-bg", email: "bg@example.com", source: "local-cli", + }); + const { getAccountSet, setActiveAccount } = await import("../src/oauth/store"); + const set = getAccountSet("anthropic"); + const active = set?.accounts.find(a => a.credential.email === "active@example.com"); + const background = set?.accounts.find(a => a.credential.email === "bg@example.com"); + expect(active && background).toBeTruthy(); + await setActiveAccount("anthropic", active!.id); + + let calls = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + calls += 1; + const auth = new Headers(init?.headers).get("authorization") ?? ""; + expect(auth).toBe("Bearer token-active"); + return new Response(usageBody(11, 22), { status: 200 }); + }) as typeof fetch; + + const rows = await fetchProviderAccountQuotas("anthropic"); + const byId = Object.fromEntries(rows.map(row => [row.accountId, row])); + expect(byId[active!.id]?.quota?.fiveHourPercent).toBe(11); + expect(byId[active!.id]?.unavailable).toBeUndefined(); + expect(byId[background!.id]?.quota).toBeNull(); + expect(byId[background!.id]?.unavailable).toBe(true); + // Only the active credential was probed; background expired slot failed closed. + expect(calls).toBe(1); + // Credential integrity: the expired local-cli background slot must not have + // been overwritten with the active (or any other) disk/CLI identity. + const after = getAccountSet("anthropic")?.accounts.find(a => a.id === background!.id); + expect(after?.credential.access).toBe("token-bg"); + expect(after?.credential.email).toBe("bg@example.com"); + expect(after?.credential.source).toBe("local-cli"); + }); + + test("providers without a per-account usage API are skipped", async () => { + expect(supportsPerAccountQuota("anthropic")).toBe(true); + expect(supportsPerAccountQuota("kiro")).toBe(false); + let called = false; + globalThis.fetch = (async () => { called = true; return new Response("{}", { status: 200 }); }) as typeof fetch; + expect(await fetchProviderAccountQuotas("kiro")).toEqual([]); + expect(called).toBe(false); + }); + + test("a provider with no logged-in accounts yields no rows and no upstream calls", async () => { + let called = false; + globalThis.fetch = (async () => { called = true; return new Response("{}", { status: 200 }); }) as typeof fetch; + expect(await fetchProviderAccountQuotas("anthropic")).toEqual([]); + expect(called).toBe(false); + }); + + test("provider-report probe seeds the active account cache for per-account reads", async () => { + await seedTwoAccounts(); + const { getAccountSet, setActiveAccount } = await import("../src/oauth/store"); + const set = getAccountSet("anthropic"); + const first = set?.accounts.find(a => a.credential.email === "first@example.com"); + expect(first).toBeTruthy(); + await setActiveAccount("anthropic", first!.id); + let calls = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + calls += 1; + const auth = new Headers(init?.headers).get("authorization") ?? ""; + const body = auth.endsWith("token-first") ? usageBody(70, 15) : usageBody(3, 21); + return new Response(body, { status: 200 }); + }) as typeof fetch; + + const config: OcxConfig = { + port: 1455, + defaultProvider: "anthropic", + providers: { + anthropic: { + adapter: "anthropic", + authMode: "oauth", + baseUrl: "https://api.anthropic.com/v1", + }, + }, + }; + await fetchProviderQuotaReports(config, true); + expect(calls).toBe(1); + + const rows = await fetchProviderAccountQuotas("anthropic"); + const byId = Object.fromEntries(rows.map(row => [row.accountId, row])); + // Active account reused the provider-report probe; only the sibling was hit again. + expect(calls).toBe(2); + expect(byId[first!.id]?.quota?.fiveHourPercent).toBe(70); + const sibling = rows.find(row => row.accountId !== first!.id); + expect(sibling?.quota?.fiveHourPercent).toBe(3); + }); + + test("empty Anthropic usage payloads are treated as probe failures", async () => { + await seedTwoAccounts(); + globalThis.fetch = (async () => new Response("{}", { status: 200 })) as typeof fetch; + const rows = await fetchProviderAccountQuotas("anthropic"); + expect(rows).toHaveLength(2); + expect(rows.every(row => row.unavailable && row.quota === null)).toBe(true); + }); + + test("expired ordinary OAuth background accounts still refresh for quota probes", async () => { + const expires = Date.now() - 60_000; + await saveCredential("anthropic", { + access: "token-active", refresh: "refresh-active", expires: Date.now() + 60 * 60_000, + accountId: "acct-active", email: "active@example.com", source: "oauth", + }); + await saveCredential("anthropic", { + access: "token-bg", refresh: "refresh-bg", expires, + accountId: "acct-bg", email: "bg@example.com", source: "oauth", + }); + const { getAccountSet, setActiveAccount } = await import("../src/oauth/store"); + const set = getAccountSet("anthropic"); + const active = set?.accounts.find(a => a.credential.email === "active@example.com"); + const background = set?.accounts.find(a => a.credential.email === "bg@example.com"); + expect(active && background).toBeTruthy(); + await setActiveAccount("anthropic", active!.id); + + let refreshCalls = 0; + let usageForBg = 0; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/v1/oauth/token")) { + refreshCalls += 1; + return new Response(JSON.stringify({ + access_token: "token-bg-fresh", + refresh_token: "refresh-bg", + expires_in: 3600, + }), { status: 200 }); + } + const auth = new Headers(init?.headers).get("authorization") ?? ""; + if (auth.endsWith("token-bg-fresh")) { + usageForBg += 1; + return new Response(usageBody(44, 55), { status: 200 }); + } + return new Response(usageBody(11, 22), { status: 200 }); + }) as typeof fetch; + + const rows = await fetchProviderAccountQuotas("anthropic"); + const byId = Object.fromEntries(rows.map(row => [row.accountId, row])); + expect(refreshCalls).toBeGreaterThanOrEqual(1); + expect(usageForBg).toBe(1); + expect(byId[background!.id]?.quota?.fiveHourPercent).toBe(44); + expect(byId[background!.id]?.unavailable).toBeUndefined(); + }); + + test("clearing account quota cache after failure allows a fresh probe", async () => { + await seedTwoAccounts(); + globalThis.fetch = (async () => new Response("rate limited", { status: 429 })) as typeof fetch; + const failed = await fetchProviderAccountQuotas("anthropic"); + expect(failed.every(row => row.unavailable)).toBe(true); + + // runLogin / reauth clears this cache after credentials are replaced. + clearAccountQuotaCache("anthropic"); + globalThis.fetch = (async () => new Response(usageBody(9, 8), { status: 200 })) as typeof fetch; + const after = await fetchProviderAccountQuotas("anthropic"); + expect(after.every(row => row.quota && !row.unavailable)).toBe(true); + }); + + test("provider-report seeding binds to the probed account across an active switch", async () => { + await seedTwoAccounts(); + const { getAccountSet, setActiveAccount } = await import("../src/oauth/store"); + const set = getAccountSet("anthropic"); + const first = set?.accounts.find(a => a.credential.email === "first@example.com"); + const second = set?.accounts.find(a => a.credential.email === "second@example.com"); + expect(first && second).toBeTruthy(); + await setActiveAccount("anthropic", first!.id); + + let releaseUsage!: () => void; + const usageGate = new Promise(resolve => { releaseUsage = resolve; }); + let usageCalls = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const auth = new Headers(init?.headers).get("authorization") ?? ""; + if (auth.endsWith("token-first")) { + usageCalls += 1; + await usageGate; + return new Response(usageBody(70, 15), { status: 200 }); + } + return new Response(usageBody(3, 21), { status: 200 }); + }) as typeof fetch; + + const config: OcxConfig = { + port: 1455, + defaultProvider: "anthropic", + providers: { + anthropic: { + adapter: "anthropic", + authMode: "oauth", + baseUrl: "https://api.anthropic.com/v1", + }, + }, + }; + const reportPromise = fetchProviderQuotaReports(config, true); + // Switch active mid-flight before Anthropic responds. + await setActiveAccount("anthropic", second!.id); + releaseUsage(); + await reportPromise; + + // First account still owns token-first — seed must land on first, not second. + clearProviderQuotaCache(); + let calls = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + calls += 1; + const auth = new Headers(init?.headers).get("authorization") ?? ""; + const body = auth.endsWith("token-first") ? usageBody(70, 15) : usageBody(3, 21); + return new Response(body, { status: 200 }); + }) as typeof fetch; + const rows = await fetchProviderAccountQuotas("anthropic"); + const byId = Object.fromEntries(rows.map(row => [row.accountId, row])); + // First was seeded (no re-probe); only second needs a fresh probe after the switch. + expect(usageCalls).toBe(1); + expect(byId[first!.id]?.quota?.fiveHourPercent).toBe(70); + expect(byId[second!.id]?.quota?.fiveHourPercent).toBe(3); + expect(calls).toBe(1); + }); +}); diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index ad23fa1c4f..11e22aaa46 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -184,8 +184,11 @@ describe("fetchProviderQuotaReports", () => { expect(byProvider.openai?.quota.weeklyPercent).toBe(34); expect(byProvider.xai?.quota.monthlyPercent).toBe(25); expect(byProvider.anthropic?.quota.weeklyPercent).toBe(72); + // Claude's 5-hour window is reported in the canonical fields (like the Codex login rows), + // so only the model-specific windows remain as custom entries. + expect(byProvider.anthropic?.quota.fiveHourPercent).toBe(41.5); + expect(byProvider.anthropic?.quota.fiveHourResetAt).toBe(Date.parse("2026-07-05T12:00:00Z")); expect(byProvider.anthropic?.quota.customWindows).toEqual([ - { label: "5h", percent: 41.5, resetAt: Date.parse("2026-07-05T12:00:00Z") }, { label: "Opus", percent: 88 }, { label: "Sonnet", percent: 19 }, ]);