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 },
]);