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()
});