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 00000000..79e82068 Binary files /dev/null and b/packages/web/server/lib/quota/providers/claude/index.js differ 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() });