From b77a30cd88dd875ac425f8006dc3f85f2b32e9e0 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 14 Aug 2026 20:49:59 +0300 Subject: [PATCH] feat(quota): read Claude plan limits from the Claude Code login Claude quota only worked when the user had signed into Anthropic through OpenCode. Credentials are now discovered from Claude Code itself first: the macOS Keychain entry, then the Linux/WSL credentials file (honouring CLAUDE_CONFIG_DIR), then OpenCode auth.json, then CLAUDE_CODE_OAUTH_TOKEN. All sources stay read-only and the OAuth token is never refreshed: Anthropic allows one live refresh token per client_id, so refreshing here would sign the user out of Claude Code. Credentials are re-read per request instead, and an expired token reports that Claude Code needs a sign-in rather than a bare 401. Usage is now read from the limits[] array, so model-scoped weekly limits work again after Anthropic stopped populating seven_day_sonnet/seven_day_opus, and new limit kinds no longer need a code change. Adds extra-usage spend and the plan name, and holds the last good values through Anthropic's 429s with a cooldown and an account-keyed cache. --- CHANGELOG.md | 1 + .../components/sections/usage/UsagePage.tsx | 5 + .../ui/src/lib/i18n/messages/de.settings.ts | 1 + packages/ui/src/lib/i18n/messages/de.ts | 3 +- .../ui/src/lib/i18n/messages/en.settings.ts | 1 + packages/ui/src/lib/i18n/messages/en.ts | 3 +- .../ui/src/lib/i18n/messages/es.settings.ts | 1 + packages/ui/src/lib/i18n/messages/es.ts | 3 +- .../ui/src/lib/i18n/messages/fr.settings.ts | 1 + packages/ui/src/lib/i18n/messages/fr.ts | 3 +- .../ui/src/lib/i18n/messages/ja.settings.ts | 1 + packages/ui/src/lib/i18n/messages/ja.ts | 3 +- .../ui/src/lib/i18n/messages/ko.settings.ts | 1 + packages/ui/src/lib/i18n/messages/ko.ts | 3 +- .../ui/src/lib/i18n/messages/pl.settings.ts | 1 + packages/ui/src/lib/i18n/messages/pl.ts | 3 +- .../src/lib/i18n/messages/pt-BR.settings.ts | 1 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 3 +- .../ui/src/lib/i18n/messages/uk.settings.ts | 1 + packages/ui/src/lib/i18n/messages/uk.ts | 3 +- .../src/lib/i18n/messages/zh-CN.settings.ts | 1 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 3 +- .../src/lib/i18n/messages/zh-TW.settings.ts | 1 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 3 +- packages/ui/src/lib/quota/model-families.ts | 4 + packages/ui/src/lib/quota/utils.ts | 3 +- packages/ui/src/types/quota.ts | 2 + .../web/server/lib/quota/DOCUMENTATION.md | 13 +- .../web/server/lib/quota/providers/claude.js | 107 --------------- .../server/lib/quota/providers/claude/auth.js | 113 +++++++++++++++ .../lib/quota/providers/claude/auth.test.js | 117 ++++++++++++++++ .../lib/quota/providers/claude/index.js | Bin 0 -> 3957 bytes .../lib/quota/providers/claude/index.test.js | 129 ++++++++++++++++++ .../lib/quota/providers/claude/transforms.js | 120 ++++++++++++++++ .../quota/providers/claude/transforms.test.js | 73 ++++++++++ .../web/server/lib/quota/providers/index.js | 2 +- .../web/server/lib/quota/utils/formatters.js | 3 +- 37 files changed, 602 insertions(+), 134 deletions(-) delete mode 100644 packages/web/server/lib/quota/providers/claude.js create mode 100644 packages/web/server/lib/quota/providers/claude/auth.js create mode 100644 packages/web/server/lib/quota/providers/claude/auth.test.js create mode 100644 packages/web/server/lib/quota/providers/claude/index.js create mode 100644 packages/web/server/lib/quota/providers/claude/index.test.js create mode 100644 packages/web/server/lib/quota/providers/claude/transforms.js create mode 100644 packages/web/server/lib/quota/providers/claude/transforms.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 56bc5b81..6cd02195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Usage/Claude:** Claude plan limits now work when you are signed in through Claude Code, without also signing into Anthropic in OpenCode; the account is read from Claude Code's own login on macOS, Linux, and WSL. The page shows your session and weekly limits again, adds per-model weekly limits and extra usage spending, and names your plan. Limits are kept on screen instead of disappearing when Anthropic temporarily blocks refreshes. - **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders. ## [1.18.4] - 2026-08-14 diff --git a/packages/ui/src/components/sections/usage/UsagePage.tsx b/packages/ui/src/components/sections/usage/UsagePage.tsx index 0dac1033..7e8e3ccc 100644 --- a/packages/ui/src/components/sections/usage/UsagePage.tsx +++ b/packages/ui/src/components/sections/usage/UsagePage.tsx @@ -162,6 +162,11 @@ export const UsagePage: React.FC = () => { description={ isLoading ? ( {t('settings.usage.page.header.refreshing')} + ) : selectedResult?.planLabel ? ( + t('settings.usage.page.header.lastUpdatedWithPlan', { + plan: selectedResult.planLabel, + time: formatTime(lastUpdated, timeFormatPreference), + }) ) : ( t('settings.usage.page.header.lastUpdated', { time: formatTime(lastUpdated, timeFormatPreference) }) ) diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index ff1292c5..8c3ec09e 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1131,6 +1131,7 @@ export const settingsDict = { 'settings.usage.page.header.providerUsage': '{provider} Nutzung', 'settings.usage.page.header.refreshing': 'Aktualisiere Nutzung...', 'settings.usage.page.header.lastUpdated': 'Zuletzt aktualisiert: {time}', + 'settings.usage.page.header.lastUpdatedWithPlan': 'Tarif: {plan} · Zuletzt aktualisiert: {time}', 'settings.usage.page.options.showInWorkStatusAria': 'Im Arbeitsstatusbereich anzeigen', 'settings.usage.page.options.showInWorkStatus': 'Im Arbeitsstatusbereich anzeigen', 'settings.usage.page.options.showInWorkStatusTooltip': 'Wenn aktiviert, ist die Nutzung dieses Anbieters im Arbeitsstatusbereich sichtbar.', diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 4018c5ae..8c9f215d 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2804,8 +2804,7 @@ export const dict = { 'contextFileOpen.failure.unreadable': 'Fehler beim Öffnen der Datei', 'quota.window.5h': '5-Stunden-Limit', 'quota.window.7d': '7-Tage-Limit', - 'quota.window.7dSonnet': '7-Tage Sonnet-Limit', - 'quota.window.7dOpus': '7-Tage Opus-Limit', + 'quota.window.extraUsage': 'Zusätzliche Nutzung', 'quota.window.weekly': 'Wöchentliches Limit', 'quota.window.daily': 'Täglich', 'quota.window.monthly': 'Monatliches Limit', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 73c444c7..66bea849 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1193,6 +1193,7 @@ export const settingsDict = { 'settings.usage.page.header.providerUsage': '{provider} Usage', 'settings.usage.page.header.refreshing': 'Refreshing usage...', 'settings.usage.page.header.lastUpdated': 'Last updated: {time}', + 'settings.usage.page.header.lastUpdatedWithPlan': 'Plan: {plan} · Last updated: {time}', 'settings.usage.page.options.showInWorkStatusAria': 'Show in work status panel', 'settings.usage.page.options.showInWorkStatus': 'Show in Work Status Panel', 'settings.usage.page.options.showInWorkStatusTooltip': 'When enabled, this provider\'s usage will be visible in the work status panel.', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 77546094..9d60f0d7 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -2982,8 +2982,7 @@ export const dict = { 'contextFileOpen.failure.unreadable': 'Failed to open file', 'quota.window.5h': '5-Hour', 'quota.window.7d': '7-Day Limit', - 'quota.window.7dSonnet': '7-Day Sonnet Limit', - 'quota.window.7dOpus': '7-Day Opus Limit', + 'quota.window.extraUsage': 'Extra Usage', 'quota.window.weekly': 'Weekly Limit', 'quota.window.daily': 'Daily', 'quota.window.monthly': 'Monthly Limit', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 4a51c6b8..9b4d3094 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1161,6 +1161,7 @@ export const settingsDict = { "settings.usage.page.header.providerUsage": "Uso de {provider}", "settings.usage.page.header.refreshing": "Actualizando uso...", "settings.usage.page.header.lastUpdated": "Última actualización: {time}", + "settings.usage.page.header.lastUpdatedWithPlan": "Plan: {plan} · Última actualización: {time}", "settings.usage.page.options.showInWorkStatusAria": "Mostrar en el panel de estado del trabajo", "settings.usage.page.options.showInWorkStatus": "Mostrar en el panel de estado del trabajo", "settings.usage.page.options.showInWorkStatusTooltip": "Cuando esté habilitado, el uso de este proveedor será visible en el panel de estado del trabajo.", diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 911387e2..e30ea4ad 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2983,8 +2983,7 @@ export const dict: Record = { "contextFileOpen.failure.unreadable": "Failed to open file", "quota.window.5h": "5-Hour", "quota.window.7d": "7-Day Limit", - "quota.window.7dSonnet": "7-Day Sonnet Limit", - "quota.window.7dOpus": "7-Day Opus Limit", + "quota.window.extraUsage": "Uso adicional", "quota.window.weekly": "Weekly Limit", "quota.window.daily": "Daily", "quota.window.monthly": "Monthly Limit", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 208ceefe..5e065403 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1079,6 +1079,7 @@ export const settingsDict = { 'settings.usage.page.header.providerUsage': 'Utilisation de {provider}', 'settings.usage.page.header.refreshing': 'Utilisation rafraîchissante...', 'settings.usage.page.header.lastUpdated': 'Dernière mise à jour : {time}', + 'settings.usage.page.header.lastUpdatedWithPlan': 'Forfait : {plan} · Dernière mise à jour : {time}', 'settings.usage.page.options.showInWorkStatusAria': 'Afficher dans le panneau d’état du travail', 'settings.usage.page.options.showInWorkStatus': 'Afficher dans le panneau d’état du travail', 'settings.usage.page.options.showInWorkStatusTooltip': 'Lorsqu’elle est activée, l’utilisation de ce fournisseur sera visible dans le panneau d’état du travail.', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 4a9f2ffc..f9c74dcf 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -2675,8 +2675,7 @@ export const dict = { 'contextFileOpen.failure.unreadable': 'Impossible d’ouvrir le fichier', 'quota.window.5h': '5 heures', 'quota.window.7d': 'Limite sur 7 jours', - 'quota.window.7dSonnet': 'Limite Sonnet sur 7 jours', - 'quota.window.7dOpus': 'Limite Opus sur 7 jours', + 'quota.window.extraUsage': 'Utilisation supplémentaire', 'quota.window.weekly': 'Limite hebdomadaire', 'quota.window.daily': 'Quotidien', 'quota.window.monthly': 'Limite mensuelle', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 451dafcd..b842fe8a 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1194,6 +1194,7 @@ export const settingsDict = { 'settings.usage.page.header.providerUsage': '{provider} の使用量', 'settings.usage.page.header.refreshing': '使用量を更新中...', 'settings.usage.page.header.lastUpdated': '最終更新: {time}', + 'settings.usage.page.header.lastUpdatedWithPlan': 'プラン: {plan} · 最終更新: {time}', 'settings.usage.page.options.showInWorkStatusAria': '作業ステータスパネルに表示', 'settings.usage.page.options.showInWorkStatus': '作業ステータスパネルに表示', 'settings.usage.page.options.showInWorkStatusTooltip': '有効にすると、このプロバイダーの使用量が作業ステータスパネルに表示されます。', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index f58a6b89..441d7fd0 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -2978,8 +2978,7 @@ export const dict: Record = { 'contextFileOpen.failure.unreadable': 'ファイルを開けませんでした', 'quota.window.5h': '5時間', 'quota.window.7d': '7日間制限', - 'quota.window.7dSonnet': '7日間Sonnet制限', - 'quota.window.7dOpus': '7日間Opus制限', + 'quota.window.extraUsage': '追加利用', 'quota.window.weekly': '週間制限', 'quota.window.daily': '日次', 'quota.window.monthly': '月間制限', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index f708ff3b..aae1fb17 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1161,6 +1161,7 @@ export const settingsDict = { 'settings.usage.page.header.providerUsage': '{provider} 사용량', 'settings.usage.page.header.refreshing': '사용량 새로고침 중...', 'settings.usage.page.header.lastUpdated': '마지막 업데이트: {time}', + 'settings.usage.page.header.lastUpdatedWithPlan': '플랜: {plan} · 마지막 업데이트: {time}', 'settings.usage.page.options.showInWorkStatusAria': '작업 상태 패널에 표시', 'settings.usage.page.options.showInWorkStatus': '작업 상태 패널에 표시', 'settings.usage.page.options.showInWorkStatusTooltip': '활성화하면 이 제공업체의 사용량이 작업 상태 패널에 표시됩니다.', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 34610de1..5c9499dd 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2982,8 +2982,7 @@ export const dict: Record = { 'contextFileOpen.failure.unreadable': 'Failed to open file', 'quota.window.5h': '5-Hour', 'quota.window.7d': '7-Day Limit', - 'quota.window.7dSonnet': '7-Day Sonnet Limit', - 'quota.window.7dOpus': '7-Day Opus Limit', + 'quota.window.extraUsage': '추가 사용량', 'quota.window.weekly': 'Weekly Limit', 'quota.window.daily': 'Daily', 'quota.window.monthly': 'Monthly Limit', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 10f7c910..5fe473cb 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1963,6 +1963,7 @@ export const settingsDict = { 'settings.usage.pace.waitSeparator': ' · Czekaj ', 'settings.usage.page.empty.selectProvider': 'Wybierz dostawcę, aby wyświetlić szczegóły użycia.', 'settings.usage.page.header.lastUpdated': 'Ostatnio aktualizowano: {time}', + 'settings.usage.page.header.lastUpdatedWithPlan': 'Plan: {plan} · Ostatnia aktualizacja: {time}', 'settings.usage.page.header.providerUsage': 'Użycie {provider}', 'settings.usage.page.header.refreshing': 'Odświeżanie użycia...', 'settings.usage.page.options.showInWorkStatus': 'Pokaż w panelu statusu pracy', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 8c78bd5d..54b53ee9 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2999,8 +2999,7 @@ export const dict: Record = { 'contextFileOpen.failure.unreadable': 'Failed to open file', 'quota.window.5h': '5-Hour', 'quota.window.7d': '7-Day Limit', - 'quota.window.7dSonnet': '7-Day Sonnet Limit', - 'quota.window.7dOpus': '7-Day Opus Limit', + 'quota.window.extraUsage': 'Dodatkowe zużycie', 'quota.window.weekly': 'Weekly Limit', 'quota.window.daily': 'Daily', 'quota.window.monthly': 'Monthly Limit', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index cb7bed04..6a6efa84 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1161,6 +1161,7 @@ export const settingsDict = { "settings.usage.page.header.providerUsage": "Uso de {provider}", "settings.usage.page.header.refreshing": "Atualizando uso...", "settings.usage.page.header.lastUpdated": "Última atualização: {time}", + "settings.usage.page.header.lastUpdatedWithPlan": "Plano: {plan} · Última atualização: {time}", "settings.usage.page.options.showInWorkStatusAria": "Mostrar no painel de status do trabalho", "settings.usage.page.options.showInWorkStatus": "Mostrar no painel de status do trabalho", "settings.usage.page.options.showInWorkStatusTooltip": "Quando habilitado, o uso deste provedor ficará visível no painel de status do trabalho.", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 7d940f6f..8f377453 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2983,8 +2983,7 @@ export const dict: Record = { "contextFileOpen.failure.unreadable": "Failed to open file", "quota.window.5h": "5-Hour", "quota.window.7d": "7-Day Limit", - "quota.window.7dSonnet": "7-Day Sonnet Limit", - "quota.window.7dOpus": "7-Day Opus Limit", + "quota.window.extraUsage": "Uso adicional", "quota.window.weekly": "Weekly Limit", "quota.window.daily": "Daily", "quota.window.monthly": "Monthly Limit", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 7b3980dc..3b54c685 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1161,6 +1161,7 @@ export const settingsDict = { "settings.usage.page.header.providerUsage": "Використання {provider}", "settings.usage.page.header.refreshing": "Оновлення використання...", "settings.usage.page.header.lastUpdated": "Останнє оновлення: {time}", + "settings.usage.page.header.lastUpdatedWithPlan": "План: {plan} · Останнє оновлення: {time}", "settings.usage.page.options.showInWorkStatusAria": "Показувати в панелі статусу роботи", "settings.usage.page.options.showInWorkStatus": "Показувати в панелі статусу роботи", "settings.usage.page.options.showInWorkStatusTooltip": "Якщо ввімкнути, використання цього провайдера буде видно в панелі статусу роботи.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 058f152f..d9f07823 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2983,8 +2983,7 @@ export const dict: Record = { "contextFileOpen.failure.unreadable": "Failed to open file", "quota.window.5h": "5-Hour", "quota.window.7d": "7-Day Limit", - "quota.window.7dSonnet": "7-Day Sonnet Limit", - "quota.window.7dOpus": "7-Day Opus Limit", + "quota.window.extraUsage": "Додаткове використання", "quota.window.weekly": "Weekly Limit", "quota.window.daily": "Daily", "quota.window.monthly": "Monthly Limit", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 171fd0a3..a2a459b0 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1161,6 +1161,7 @@ export const settingsDict = { 'settings.usage.page.header.providerUsage': '{provider} 用量', 'settings.usage.page.header.refreshing': '正在刷新用量...', 'settings.usage.page.header.lastUpdated': '最后更新:{time}', + 'settings.usage.page.header.lastUpdatedWithPlan': '套餐:{plan} · 最后更新:{time}', 'settings.usage.page.options.showInWorkStatusAria': '在工作状态面板中显示', 'settings.usage.page.options.showInWorkStatus': '在工作状态面板中显示', 'settings.usage.page.options.showInWorkStatusTooltip': '启用后,该提供商的用量会显示在工作状态面板中。', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index f1c3322e..7edfc6b3 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2983,8 +2983,7 @@ export const dict: Record = { 'contextFileOpen.failure.unreadable': 'Failed to open file', 'quota.window.5h': '5-Hour', 'quota.window.7d': '7-Day Limit', - 'quota.window.7dSonnet': '7-Day Sonnet Limit', - 'quota.window.7dOpus': '7-Day Opus Limit', + 'quota.window.extraUsage': '额外用量', 'quota.window.weekly': 'Weekly Limit', 'quota.window.daily': 'Daily', 'quota.window.monthly': 'Monthly Limit', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index d8de27b9..b25bf8ce 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1068,6 +1068,7 @@ export const settingsDict = { 'settings.usage.page.header.providerUsage': '{provider} 用量', 'settings.usage.page.header.refreshing': '正在重新整理用量...', 'settings.usage.page.header.lastUpdated': '最後更新:{time}', + 'settings.usage.page.header.lastUpdatedWithPlan': '方案:{plan} · 最後更新:{time}', 'settings.usage.page.options.showInWorkStatusAria': '在工作狀態面板中顯示', 'settings.usage.page.options.showInWorkStatus': '在工作狀態面板中顯示', 'settings.usage.page.options.showInWorkStatusTooltip': '啟用後,該供應商的用量會顯示在工作狀態面板中。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 59f99c81..def005d2 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2982,8 +2982,7 @@ export const dict: Record = { 'contextFileOpen.failure.unreadable': 'Failed to open file', 'quota.window.5h': '5-Hour', 'quota.window.7d': '7-Day Limit', - 'quota.window.7dSonnet': '7-Day Sonnet Limit', - 'quota.window.7dOpus': '7-Day Opus Limit', + 'quota.window.extraUsage': '額外用量', 'quota.window.weekly': 'Weekly Limit', 'quota.window.daily': 'Daily', 'quota.window.monthly': 'Monthly Limit', diff --git a/packages/ui/src/lib/quota/model-families.ts b/packages/ui/src/lib/quota/model-families.ts index 69d7eee0..8430de23 100644 --- a/packages/ui/src/lib/quota/model-families.ts +++ b/packages/ui/src/lib/quota/model-families.ts @@ -113,12 +113,16 @@ export function groupModelsByFamilyWithGetter( * For Google provider with gemini/ and antigravity/ prefixes: * - Gemini 3.x models * - All Claude models + * For the Claude provider: every model it reports a limit for. */ export function getDefaultModels( providerId: QuotaProviderId, availableModels: string[] ): string[] { return availableModels.filter((model) => { + // Anthropic only reports a model here when that model has its own plan + // limit, so every one it names is worth showing by default. + if (providerId === 'claude') return true; const lower = model.toLowerCase(); // Handle gemini/ and antigravity/ prefixes const modelName = lower.includes('/') ? lower.split('/')[1] : lower; diff --git a/packages/ui/src/lib/quota/utils.ts b/packages/ui/src/lib/quota/utils.ts index 6f2f02f4..d2c7f726 100644 --- a/packages/ui/src/lib/quota/utils.ts +++ b/packages/ui/src/lib/quota/utils.ts @@ -75,8 +75,7 @@ export const resolveUsageTone = (percent: number | null): 'safe' | 'warn' | 'cri export const formatWindowLabel = (label: string): string => { if (label === '5h') return t('quota.window.5h'); if (label === '7d') return t('quota.window.7d'); - if (label === '7d-sonnet') return t('quota.window.7dSonnet'); - if (label === '7d-opus') return t('quota.window.7dOpus'); + if (label === 'extra_usage') return t('quota.window.extraUsage'); if (label === 'weekly') return t('quota.window.weekly'); if (label === 'daily') return t('quota.window.daily'); if (label === 'monthly') return t('quota.window.monthly'); diff --git a/packages/ui/src/types/quota.ts b/packages/ui/src/types/quota.ts index fc634a78..059e4b98 100644 --- a/packages/ui/src/types/quota.ts +++ b/packages/ui/src/types/quota.ts @@ -46,6 +46,8 @@ export interface ProviderResult { ok: boolean; configured: boolean; error?: string; + /** Subscription tier reported by the provider, when it exposes one. */ + planLabel?: string | null; usage: ProviderUsage | null; fetchedAt: number; } diff --git a/packages/web/server/lib/quota/DOCUMENTATION.md b/packages/web/server/lib/quota/DOCUMENTATION.md index fb876284..08876ff1 100644 --- a/packages/web/server/lib/quota/DOCUMENTATION.md +++ b/packages/web/server/lib/quota/DOCUMENTATION.md @@ -8,6 +8,7 @@ This module fetches quota and usage signals for supported providers in the web s - `packages/web/server/lib/quota/routes.js`: Express route registration for quota endpoints. - `packages/web/server/lib/quota/providers/index.js`: provider registry, configured-provider list, and provider dispatcher. - `packages/web/server/lib/quota/providers/google/`: Google-specific auth, API, and transform modules. +- `packages/web/server/lib/quota/providers/claude/`: Claude credential discovery, usage transforms, and rate-limit handling. - `packages/web/server/lib/quota/utils/`: shared auth, transform, and formatting helpers. ## Supported provider IDs (dispatcher) @@ -16,7 +17,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide | Provider ID | Display name | Module | Auth aliases/keys | | --- | --- | --- | --- | -| `claude` | Claude | `providers/claude.js` | `anthropic`, `claude` | +| `claude` | Claude | `providers/claude/` | Claude Code Keychain entry, Claude Code credentials file, OpenCode `auth.json` (`anthropic`, `claude`), `CLAUDE_CODE_OAUTH_TOKEN` | | `codex` | Codex | `providers/codex.js` | `openai`, `codex`, `chatgpt` | | `cursor` | Cursor | `providers/cursor.js` | Environment/token files, OpenChamber-managed credentials, or explicit one-time Cursor import | | `crof` | CrofAI | `providers/crof.js` | `crof` (API key under `key` or `token`) | @@ -53,6 +54,16 @@ Ollama Cloud and Cursor credentials are explicitly managed through Settings. Ope On the first OpenCode Go usage refresh after upgrading, OpenChamber deletes the obsolete `quota/opencode-go.json` credential file without reading its cookie value. +## Claude credential and limit semantics + +Claude quota reports the subscription limits Claude Code itself is bound by, read from `GET https://api.anthropic.com/api/oauth/usage`. + +- **Credential sources**, in priority order: the macOS Keychain entry `Claude Code-credentials`, then `${CLAUDE_CONFIG_DIR:-~/.claude}/.credentials.json` (the Linux/WSL location), then the OpenCode `auth.json` entry, then `CLAUDE_CODE_OAUTH_TOKEN`. The Keychain wins on macOS because the credentials file there is a leftover Claude Code no longer updates. +- **All sources are read-only.** OpenChamber never writes to Claude Code's credential store and never refreshes the OAuth token, because Anthropic does not support two live refresh tokens for one `client_id` — refreshing here would sign the user out of Claude Code. Credentials are read fresh per request so a Claude Code refresh is picked up immediately; an expired token yields an explicit "open Claude Code to sign in again" error rather than a bare 401. +- **Limits come from the `limits` array**, keyed by `kind`: `session` maps to the `5h` window, `weekly_all` to `7d`, and `weekly_scoped` to a per-model `7d` window named by `scope.model.display_name`. The legacy `five_hour`/`seven_day` fields are only a fallback; `seven_day_sonnet`/`seven_day_opus` are no longer populated by Anthropic. Unrecognized limit kinds and Anthropic's rotating internal code names (`nimbus_quill`, `tangelo`, ...) are ignored rather than guessed at. +- **Extra usage** is reported as the `extra_usage` window from `spend`, only while `spend.enabled` is true, with a money `valueLabel`. +- **Rate limiting**: Anthropic returns 429 aggressively. The last successful usage payload is cached in memory and reserved during a cooldown (`Retry-After`, else five minutes, capped at one hour). The cache is keyed by a hash of the access and refresh tokens, so switching accounts drops it instead of showing the previous account's numbers. + ## Add a new provider (quick steps) 1. Choose module shape based on complexity: - Simple providers: create `packages/web/server/lib/quota/providers/.js`. diff --git a/packages/web/server/lib/quota/providers/claude.js b/packages/web/server/lib/quota/providers/claude.js deleted file mode 100644 index 8e83dae6..00000000 --- a/packages/web/server/lib/quota/providers/claude.js +++ /dev/null @@ -1,107 +0,0 @@ -import { readAuthFile } from '../../opencode/auth.js'; -import { - getAuthEntry, - normalizeAuthEntry, - buildResult, - toUsageWindow, - toNumber, - toTimestamp -} from '../utils/index.js'; - -export const providerId = 'claude'; -export const providerName = 'Claude'; -const aliases = ['anthropic', 'claude']; - -export const isConfigured = () => { - const auth = readAuthFile(); - const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); - return Boolean(entry?.access || entry?.token); -}; - -export const fetchQuota = async () => { - const auth = readAuthFile(); - const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); - const accessToken = entry?.access ?? entry?.token; - - if (!accessToken) { - return buildResult({ - providerId, - providerName, - ok: false, - configured: false, - error: 'Not configured' - }); - } - - try { - const response = await fetch('https://api.anthropic.com/api/oauth/usage', { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'anthropic-beta': 'oauth-2025-04-20' - } - }); - - if (!response.ok) { - return buildResult({ - providerId, - providerName, - ok: false, - configured: true, - error: `API error: ${response.status}` - }); - } - - const payload = await response.json(); - const windows = {}; - const fiveHour = payload?.five_hour ?? null; - const sevenDay = payload?.seven_day ?? null; - const sevenDaySonnet = payload?.seven_day_sonnet ?? null; - const sevenDayOpus = payload?.seven_day_opus ?? null; - - if (fiveHour) { - windows['5h'] = toUsageWindow({ - usedPercent: toNumber(fiveHour.utilization), - windowSeconds: null, - resetAt: toTimestamp(fiveHour.resets_at) - }); - } - if (sevenDay) { - windows['7d'] = toUsageWindow({ - usedPercent: toNumber(sevenDay.utilization), - windowSeconds: null, - resetAt: toTimestamp(sevenDay.resets_at) - }); - } - if (sevenDaySonnet) { - windows['7d-sonnet'] = toUsageWindow({ - usedPercent: toNumber(sevenDaySonnet.utilization), - windowSeconds: null, - resetAt: toTimestamp(sevenDaySonnet.resets_at) - }); - } - if (sevenDayOpus) { - windows['7d-opus'] = toUsageWindow({ - usedPercent: toNumber(sevenDayOpus.utilization), - windowSeconds: null, - resetAt: toTimestamp(sevenDayOpus.resets_at) - }); - } - - return buildResult({ - providerId, - providerName, - ok: true, - configured: true, - usage: { windows } - }); - } catch (error) { - return buildResult({ - providerId, - providerName, - ok: false, - configured: true, - error: error instanceof Error ? error.message : 'Request failed' - }); - } -}; diff --git a/packages/web/server/lib/quota/providers/claude/auth.js b/packages/web/server/lib/quota/providers/claude/auth.js new file mode 100644 index 00000000..de38f7fd --- /dev/null +++ b/packages/web/server/lib/quota/providers/claude/auth.js @@ -0,0 +1,113 @@ +/** + * Claude credential discovery. + * + * Claude Code is the primary source: on macOS it keeps its OAuth tokens in the + * login Keychain, elsewhere in a credentials file. OpenCode's own `auth.json` + * entry is the fallback for users who signed into Anthropic through OpenCode + * instead of Claude Code. + * + * Every source is read-only. Claude rotates a Keychain/credentials entry from + * under us whenever Claude Code refreshes, so credentials are read fresh per + * request rather than cached; a stale cached token would outlive the record it + * came from. + * + * @module quota/providers/claude/auth + */ + +import { execFileSync } from 'child_process'; +import os from 'os'; +import path from 'path'; + +import { readAuthFile } from '../../../opencode/auth.js'; +import { asObject, asNonEmptyString, normalizeTimestamp, getAuthEntry, normalizeAuthEntry, readJsonFile } from '../../utils/index.js'; + +const KEYCHAIN_SERVICE = 'Claude Code-credentials'; +const OPENCODE_AUTH_ALIASES = ['anthropic', 'claude']; + +/** + * @typedef {object} ClaudeCredential + * @property {string} accessToken + * @property {string|null} refreshToken + * @property {number|null} expiresAt Epoch milliseconds, when the source reports it. + * @property {string|null} planLabel Subscription tier reported by Claude Code, e.g. `max`. + * @property {'keychain'|'credentials-file'|'opencode-auth'|'env'} source + */ + +const claudeConfigDirectory = () => { + const override = asNonEmptyString(process.env.CLAUDE_CONFIG_DIR); + return override ? path.resolve(override) : path.join(os.homedir(), '.claude'); +}; + +/** + * Claude Code writes one JSON blob holding both its own OAuth tokens + * (`claudeAiOauth`) and unrelated MCP server tokens. Only the former is read. + */ +const parseClaudeCodeBlob = (blob, source) => { + const oauth = asObject(asObject(blob)?.claudeAiOauth); + const accessToken = asNonEmptyString(oauth?.accessToken); + if (!accessToken) return null; + return { + accessToken, + refreshToken: asNonEmptyString(oauth.refreshToken), + expiresAt: normalizeTimestamp(oauth.expiresAt), + planLabel: asNonEmptyString(oauth.subscriptionType), + source + }; +}; + +const readKeychainCredential = () => { + if (process.platform !== 'darwin') return null; + let raw; + try { + raw = execFileSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w'], { + encoding: 'utf8', + timeout: 10_000, + stdio: ['ignore', 'pipe', 'ignore'] + }); + } catch { + // No entry, or the user denied Keychain access. Both mean "try the next source". + return null; + } + try { + return parseClaudeCodeBlob(JSON.parse(raw.trim()), 'keychain'); + } catch { + console.warn('Claude quota: Keychain credentials are not valid JSON'); + return null; + } +}; + +const readCredentialsFile = () => + parseClaudeCodeBlob(readJsonFile(path.join(claudeConfigDirectory(), '.credentials.json')), 'credentials-file'); + +const readOpenCodeCredential = () => { + const entry = normalizeAuthEntry(getAuthEntry(readAuthFile(), OPENCODE_AUTH_ALIASES)); + const accessToken = asNonEmptyString(entry?.access) ?? asNonEmptyString(entry?.token); + if (!accessToken) return null; + return { + accessToken, + refreshToken: asNonEmptyString(entry.refresh), + expiresAt: normalizeTimestamp(entry.expires), + planLabel: null, + source: 'opencode-auth' + }; +}; + +const readEnvCredential = () => { + const accessToken = asNonEmptyString(process.env.CLAUDE_CODE_OAUTH_TOKEN); + if (!accessToken) return null; + return { accessToken, refreshToken: null, expiresAt: null, planLabel: null, source: 'env' }; +}; + +/** + * First credential a source can produce, in priority order. + * + * The Keychain wins over the credentials file because on macOS the file is a + * leftover that Claude Code no longer updates. + * + * @returns {ClaudeCredential|null} + */ +export const loadClaudeCredential = () => + readKeychainCredential() + ?? readCredentialsFile() + ?? readOpenCodeCredential() + ?? readEnvCredential(); diff --git a/packages/web/server/lib/quota/providers/claude/auth.test.js b/packages/web/server/lib/quota/providers/claude/auth.test.js new file mode 100644 index 00000000..2f59cefe --- /dev/null +++ b/packages/web/server/lib/quota/providers/claude/auth.test.js @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const execFileSync = vi.fn(); +const files = new Map(); +const openCodeAuth = vi.fn(() => ({})); + +vi.mock('child_process', () => ({ execFileSync: (...args) => execFileSync(...args) })); + +vi.mock('fs', () => { + const fs = { + existsSync: (filePath) => files.has(filePath), + readFileSync: (filePath) => { + if (!files.has(filePath)) throw new Error('ENOENT'); + return files.get(filePath); + }, + }; + return { ...fs, default: fs }; +}); + +vi.mock('../../../opencode/auth.js', () => ({ readAuthFile: () => openCodeAuth() })); + +import { loadClaudeCredential } from './auth.js'; + +const claudeCodeBlob = (accessToken) => JSON.stringify({ + mcpOAuth: { 'linear|abc': { accessToken: 'unrelated-mcp-token' } }, + claudeAiOauth: { + accessToken, + refreshToken: `${accessToken}-refresh`, + expiresAt: 1786735755912, + subscriptionType: 'max', + }, +}); + +const withPlatform = (platform, run) => { + const original = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + try { + return run(); + } finally { + Object.defineProperty(process, 'platform', original); + } +}; + +beforeEach(() => { + files.clear(); + execFileSync.mockReset(); + execFileSync.mockImplementation(() => { throw new Error('no keychain entry'); }); + openCodeAuth.mockReturnValue({}); + delete process.env.CLAUDE_CONFIG_DIR; + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; +}); + +afterEach(() => { + delete process.env.CLAUDE_CONFIG_DIR; + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; +}); + +describe('Claude credential discovery', () => { + it('prefers the macOS Keychain over a stale credentials file', () => { + execFileSync.mockReturnValue(claudeCodeBlob('keychain-token')); + files.set(`${process.env.HOME}/.claude/.credentials.json`, claudeCodeBlob('file-token')); + + const credential = withPlatform('darwin', loadClaudeCredential); + + expect(credential.accessToken).toBe('keychain-token'); + expect(credential.refreshToken).toBe('keychain-token-refresh'); + expect(credential.planLabel).toBe('max'); + expect(credential.source).toBe('keychain'); + }); + + it('reads the credentials file on Linux, where there is no Keychain', () => { + files.set(`${process.env.HOME}/.claude/.credentials.json`, claudeCodeBlob('file-token')); + + const credential = withPlatform('linux', loadClaudeCredential); + + expect(execFileSync).not.toHaveBeenCalled(); + expect(credential.accessToken).toBe('file-token'); + expect(credential.source).toBe('credentials-file'); + }); + + it('honours CLAUDE_CONFIG_DIR when locating the credentials file', () => { + process.env.CLAUDE_CONFIG_DIR = '/tmp/claude-home'; + files.set('/tmp/claude-home/.credentials.json', claudeCodeBlob('custom-dir-token')); + + expect(withPlatform('linux', loadClaudeCredential).accessToken).toBe('custom-dir-token'); + }); + + it('falls back to the OpenCode auth entry when Claude Code is not signed in', () => { + openCodeAuth.mockReturnValue({ anthropic: { access: 'opencode-token', refresh: 'opencode-refresh', expires: 1786735755912 } }); + + const credential = withPlatform('linux', loadClaudeCredential); + + expect(credential.accessToken).toBe('opencode-token'); + expect(credential.source).toBe('opencode-auth'); + expect(credential.planLabel).toBeNull(); + }); + + it('falls back to CLAUDE_CODE_OAUTH_TOKEN last, without a refresh token', () => { + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'env-token'; + + const credential = withPlatform('linux', loadClaudeCredential); + + expect(credential.accessToken).toBe('env-token'); + expect(credential.refreshToken).toBeNull(); + expect(credential.source).toBe('env'); + }); + + it('ignores a Keychain blob that only holds unrelated MCP tokens', () => { + execFileSync.mockReturnValue(JSON.stringify({ mcpOAuth: { 'linear|abc': { accessToken: 'unrelated' } } })); + + expect(withPlatform('darwin', loadClaudeCredential)).toBeNull(); + }); + + it('returns null when every source is empty', () => { + expect(withPlatform('darwin', loadClaudeCredential)).toBeNull(); + }); +}); diff --git a/packages/web/server/lib/quota/providers/claude/index.js b/packages/web/server/lib/quota/providers/claude/index.js new file mode 100644 index 0000000000000000000000000000000000000000..79e8206894e6bf97df39af463c90208af31cbe29 GIT binary patch literal 3957 zcmc&%ZExE)5Z-71iYqWwa_7iy))tuCtaJ0y6ie$ZO|SvOkT4Q$vynwrq>^~4|N9=2 z5>2`3mwlNL#1?ft-aYr+3x`jh^ymrg=d8{+srpK#l_<541%0liW(nRJfm2?}N-NR} zPG!yt%EeOPx!Jfcu^Z1kpOH{>CF>%is};qS%yUu9NpYp1)Tp`@MJ8_tbj$g5zG@!T zfR@ma@2FIkJp1Y;gT9i9jQE|d7}bi+dEaQOl~HfZnoerHptRx{FSKBJ$2OK%oMkIo z$UIZ6Q@`NII_J*QVOhx=k?~3mQ;P<)4|_eaG>+4~QOqm=-WQ04<$awFx=*_L9tV2CIL{)$+k7nLkU z8V$VCzX6Fm^ZeuF?cv4w>4{Oc(7IIP;gFRg@!OJAF3rkN8b^mVvN+FepH9xtjxXLE zo=q-}4<`qQr$&tx+}R!NKHnKV!?%|{I6Ru1pPXInPp2mb(?333y#EOK=U~Z;5&k_L zjYh5F_me-j#H>gl*BiRjIAMTpE~Vk$%7kh_*Sypu3y@b!?lhQb0K5*$XCVBOVr+Q!$`h;Eayp{M;)Ok*8`?a=t zkaJBbOBXzIoZG{JdH%9z*Cmol-WKObyBvT6lZD-_k~zmAR&s|K(WsBp zh{TNIM{Z9d)R8CzJlZJi)0Z!_d8Ai>t#20Us-OpWY*aRH-!s&Mr6>Yz4Y>!jMQd2H z-neeV=W5fDw0;Ni30UA!us(!Qht0ub5^SeOdwU_*yf2O>-uR$C#s+8#s?wnIY$}jj zk=J9=Roy%xG&3=;Q7OY!TQ+Dja6CJA|xM^PYr(sIyjDGyDeDi~hHDkBndWvsk`!;iYq#M-Y=z~P*@Mfb+ zgy=QyTVH{D58y)<6*uHohgs8ESVdYpf=JTi$DP22n!aMG<6yYEt!##yZM6t0TVte2 zjRO>^q>#6O?^RcH3ks*+XI>mwgt+^jpLhylvaugf)Wdk{8;R>4zTYGm&QPaN*hwug ztK_T16uibzQ=r@OW)>moLS{V5j$60vuKQQZmab-#-JbJV=@%~5op-l z(WT@6=*QOora85sH-lq4_hW1? z1&E%|wB$u+GB+cXm=`1pj7>}wA$e^B^1#dMZya_x`R$#H<``u2@-9%A1v<&lOmW>@ zXcm1ccpaaVEaDE=TvBYidtSgp3{%L>cCWi6_%sdZHJe`?@R-7$iJ=$dD252*t@7OD> vgMd(Ua33x3gnLb5ZhoC>5n{@bo6oSs#L9(5CwyOe0V;J@?41pJv literal 0 HcmV?d00001 diff --git a/packages/web/server/lib/quota/providers/claude/index.test.js b/packages/web/server/lib/quota/providers/claude/index.test.js new file mode 100644 index 00000000..d22fe769 --- /dev/null +++ b/packages/web/server/lib/quota/providers/claude/index.test.js @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const credential = vi.fn(); + +vi.mock('./auth.js', () => ({ + loadClaudeCredential: () => credential(), +})); + +import { fetchQuota, isConfigured, resetClaudeQuotaCache } from './index.js'; + +const CREDENTIAL = { + accessToken: 'access-a', + refreshToken: 'refresh-a', + expiresAt: Date.now() + 3_600_000, + planLabel: 'max', + source: 'keychain', +}; + +const PAYLOAD = { + limits: [ + { kind: 'session', percent: 5, resets_at: '2026-08-14T19:10:00Z', scope: null }, + { kind: 'weekly_all', percent: 4, resets_at: '2026-08-20T15:00:00Z', scope: null }, + ], +}; + +const jsonResponse = (body) => ({ + ok: true, + status: 200, + headers: new Headers(), + json: async () => body, +}); + +const errorResponse = (status, headers = {}) => ({ + ok: false, + status, + headers: new Headers(headers), + json: async () => ({}), +}); + +beforeEach(() => { + resetClaudeQuotaCache(); + credential.mockReturnValue(CREDENTIAL); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('Claude quota provider', () => { + it('reports not configured when no credential source has a token', async () => { + credential.mockReturnValue(null); + + expect(isConfigured()).toBe(false); + const result = await fetchQuota(); + expect(result.configured).toBe(false); + expect(result.ok).toBe(false); + expect(result.usage).toBeNull(); + }); + + it('returns windows and the plan label from the credential', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(PAYLOAD))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(true); + expect(result.planLabel).toBe('max'); + expect(result.usage.windows['5h'].usedPercent).toBe(5); + }); + + it('keeps serving the last good values while Anthropic rate limits', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(PAYLOAD)) + .mockResolvedValueOnce(errorResponse(429, { 'retry-after': '120' })); + vi.stubGlobal('fetch', fetchMock); + + await fetchQuota(); + const rateLimited = await fetchQuota(); + + expect(rateLimited.ok).toBe(true); + expect(rateLimited.usage.windows['5h'].usedPercent).toBe(5); + }); + + it('does not call Anthropic again while the rate-limit cooldown is active', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(PAYLOAD)) + .mockResolvedValueOnce(errorResponse(429)); + vi.stubGlobal('fetch', fetchMock); + + await fetchQuota(); + await fetchQuota(); + await fetchQuota(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('never shows one account cached values after the credential changes', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(PAYLOAD)) + .mockResolvedValueOnce(errorResponse(429)); + vi.stubGlobal('fetch', fetchMock); + + await fetchQuota(); + credential.mockReturnValue({ ...CREDENTIAL, accessToken: 'access-b', refreshToken: 'refresh-b', planLabel: null }); + const afterSwitch = await fetchQuota(); + + expect(afterSwitch.ok).toBe(false); + expect(afterSwitch.usage).toBeNull(); + }); + + it('explains an expired session instead of reporting a bare 401', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(errorResponse(401))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(false); + expect(result.configured).toBe(true); + expect(result.error).toContain('Claude Code'); + }); + + it('surfaces a network failure instead of an empty successful result', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket hang up'))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(false); + expect(result.usage).toBeNull(); + expect(result.error).toBe('socket hang up'); + }); +}); diff --git a/packages/web/server/lib/quota/providers/claude/transforms.js b/packages/web/server/lib/quota/providers/claude/transforms.js new file mode 100644 index 00000000..6ca6c0e4 --- /dev/null +++ b/packages/web/server/lib/quota/providers/claude/transforms.js @@ -0,0 +1,120 @@ +/** + * Shapes Anthropic's OAuth usage payload into quota windows. + * + * The payload carries the same limit twice: a legacy set of named fields + * (`five_hour`, `seven_day`, ...) and a newer `limits` array. Only the array + * reports model-scoped limits, and Anthropic ships new limit kinds there under + * rotating internal code names, so limits are read from the array by `kind` and + * fall back to the legacy fields when the array is missing. + * + * @module quota/providers/claude/transforms + */ + +import { asObject, asNonEmptyString, toNumber, toTimestamp, toUsageWindow, formatMoney } from '../../utils/index.js'; + +const SESSION_WINDOW = '5h'; +const WEEKLY_WINDOW = '7d'; +const EXTRA_USAGE_WINDOW = 'extra_usage'; + +const asArray = (value) => (Array.isArray(value) ? value : []); + +/** Money in Anthropic's minor-unit form, e.g. `{ amount_minor: 10000, exponent: 2 }`. */ +const toAmount = (value) => { + const money = asObject(value); + const minor = toNumber(money?.amount_minor); + if (minor === null) return null; + const exponent = toNumber(money?.exponent) ?? 2; + return minor / 10 ** exponent; +}; + +const formatSpendLabel = (used, limit, currency) => { + const usedLabel = formatMoney(used); + if (usedLabel === null) return null; + const prefix = currency === 'USD' || !currency ? '$' : `${currency} `; + const limitLabel = formatMoney(limit); + return limitLabel === null ? `${prefix}${usedLabel}` : `${prefix}${usedLabel} / ${prefix}${limitLabel}`; +}; + +const addWindow = (target, key, { percent, resetAt, valueLabel }) => { + if (percent === null && !valueLabel) return; + target[key] = toUsageWindow({ usedPercent: percent, windowSeconds: null, resetAt, valueLabel }); +}; + +const applyLimitsArray = (limits, windows, models) => { + for (const entry of limits) { + const limit = asObject(entry); + if (!limit) continue; + const percent = toNumber(limit.percent); + const resetAt = toTimestamp(limit.resets_at); + const modelName = asNonEmptyString(asObject(asObject(limit.scope)?.model)?.display_name); + + if (limit.kind === 'session') { + addWindow(windows, SESSION_WINDOW, { percent, resetAt }); + continue; + } + if (limit.kind === 'weekly_all') { + addWindow(windows, WEEKLY_WINDOW, { percent, resetAt }); + continue; + } + if (limit.kind === 'weekly_scoped' && modelName) { + const modelWindows = {}; + addWindow(modelWindows, WEEKLY_WINDOW, { percent, resetAt }); + if (Object.keys(modelWindows).length > 0) models[modelName] = { windows: modelWindows }; + } + } +}; + +const applyLegacyFields = (payload, windows) => { + const fiveHour = asObject(payload.five_hour); + const sevenDay = asObject(payload.seven_day); + if (fiveHour) { + addWindow(windows, SESSION_WINDOW, { + percent: toNumber(fiveHour.utilization), + resetAt: toTimestamp(fiveHour.resets_at) + }); + } + if (sevenDay) { + addWindow(windows, WEEKLY_WINDOW, { + percent: toNumber(sevenDay.utilization), + resetAt: toTimestamp(sevenDay.resets_at) + }); + } +}; + +/** + * Extra usage is the paid overflow beyond plan limits. It is only meaningful + * once the account has it enabled; a disabled block would render as a permanent + * empty bar. + */ +const applyExtraUsage = (payload, windows) => { + const spend = asObject(payload.spend); + if (!spend || spend.enabled !== true) return; + const used = toAmount(spend.used); + const limit = toAmount(spend.limit); + addWindow(windows, EXTRA_USAGE_WINDOW, { + percent: toNumber(spend.percent), + resetAt: null, + valueLabel: formatSpendLabel(used, limit, asNonEmptyString(asObject(spend.used)?.currency)) + }); +}; + +/** + * @param {unknown} rawPayload Parsed JSON body of the OAuth usage endpoint. + * @returns {{ windows: Record, models: Record }} + */ +export const toClaudeUsage = (rawPayload) => { + const payload = asObject(rawPayload) ?? {}; + const windows = {}; + const models = {}; + + const limits = asArray(payload.limits); + if (limits.length > 0) { + applyLimitsArray(limits, windows, models); + } else { + applyLegacyFields(payload, windows); + } + + applyExtraUsage(payload, windows); + + return { windows, models }; +}; diff --git a/packages/web/server/lib/quota/providers/claude/transforms.test.js b/packages/web/server/lib/quota/providers/claude/transforms.test.js new file mode 100644 index 00000000..679b8ff9 --- /dev/null +++ b/packages/web/server/lib/quota/providers/claude/transforms.test.js @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { toClaudeUsage } from './transforms.js'; + +// Trimmed capture of GET https://api.anthropic.com/api/oauth/usage for a Max account. +const LIVE_PAYLOAD = { + five_hour: { utilization: 5.0, resets_at: '2026-08-14T19:10:00.313090+00:00' }, + seven_day: { utilization: 4.0, resets_at: '2026-08-20T15:00:00.313112+00:00' }, + seven_day_opus: null, + seven_day_sonnet: null, + nimbus_quill: { utilization: 0.0, resets_at: null }, + limits: [ + { kind: 'session', group: 'session', percent: 5, resets_at: '2026-08-14T19:10:00.313090+00:00', scope: null, is_active: true }, + { kind: 'weekly_all', group: 'weekly', percent: 4, resets_at: '2026-08-20T15:00:00.313112+00:00', scope: null, is_active: false }, + { + kind: 'weekly_scoped', + group: 'weekly', + percent: 12, + resets_at: '2026-08-20T15:00:00.313301+00:00', + scope: { model: { id: null, display_name: 'Fable' }, surface: null }, + is_active: false + } + ], + spend: { + used: { amount_minor: 250, currency: 'USD', exponent: 2 }, + limit: { amount_minor: 10000, currency: 'USD', exponent: 2 }, + percent: 2.5, + enabled: true + } +}; + +describe('Claude usage transforms', () => { + it('maps the limits array to session, weekly, and model-scoped windows', () => { + const { windows, models } = toClaudeUsage(LIVE_PAYLOAD); + + expect(windows['5h'].usedPercent).toBe(5); + expect(windows['5h'].resetAt).toBe(Date.parse('2026-08-14T19:10:00.313090+00:00')); + expect(windows['7d'].usedPercent).toBe(4); + expect(models.Fable.windows['7d'].usedPercent).toBe(12); + }); + + it('reports extra usage as a spend window with a money label', () => { + const { windows } = toClaudeUsage(LIVE_PAYLOAD); + + expect(windows.extra_usage.usedPercent).toBe(2.5); + expect(windows.extra_usage.valueLabel).toBe('$2.50 / $100.00'); + }); + + it('omits extra usage when the account has it disabled', () => { + const { windows } = toClaudeUsage({ ...LIVE_PAYLOAD, spend: { ...LIVE_PAYLOAD.spend, enabled: false } }); + + expect(windows.extra_usage).toBeUndefined(); + }); + + it('falls back to the legacy named fields when no limits array is present', () => { + const { windows, models } = toClaudeUsage({ five_hour: LIVE_PAYLOAD.five_hour, seven_day: LIVE_PAYLOAD.seven_day }); + + expect(windows['5h'].usedPercent).toBe(5); + expect(windows['7d'].usedPercent).toBe(4); + expect(models).toEqual({}); + }); + + it('ignores unknown limit kinds instead of inventing windows for them', () => { + const { windows } = toClaudeUsage({ limits: [{ kind: 'iguana_necktie', percent: 90, resets_at: null }] }); + + expect(windows).toEqual({}); + }); + + it('returns empty usage for a malformed payload', () => { + expect(toClaudeUsage(null)).toEqual({ windows: {}, models: {} }); + expect(toClaudeUsage({ limits: 'nope' })).toEqual({ windows: {}, models: {} }); + }); +}); diff --git a/packages/web/server/lib/quota/providers/index.js b/packages/web/server/lib/quota/providers/index.js index 41d0944c..e850672a 100644 --- a/packages/web/server/lib/quota/providers/index.js +++ b/packages/web/server/lib/quota/providers/index.js @@ -7,7 +7,7 @@ import { buildResult } from '../utils/index.js'; -import * as claude from './claude.js'; +import * as claude from './claude/index.js'; import * as codex from './codex.js'; import * as copilot from './copilot.js'; import * as crof from './crof.js'; diff --git a/packages/web/server/lib/quota/utils/formatters.js b/packages/web/server/lib/quota/utils/formatters.js index 5bdf8632..5b0dcf6d 100644 --- a/packages/web/server/lib/quota/utils/formatters.js +++ b/packages/web/server/lib/quota/utils/formatters.js @@ -53,13 +53,14 @@ export const toUsageWindow = ({ usedPercent, windowSeconds, resetAt, valueLabel }; }; -export const buildResult = ({ providerId, providerName, ok, configured, usage, error }) => ({ +export const buildResult = ({ providerId, providerName, ok, configured, usage, error, planLabel }) => ({ providerId, providerName, ok, configured, usage: usage ?? null, ...(error ? { error } : {}), + ...(planLabel ? { planLabel } : {}), fetchedAt: Date.now() });