diff --git a/packages/ui/src/components/chat/work-status/usageHeadline.test.ts b/packages/ui/src/components/chat/work-status/usageHeadline.test.ts index 998808a0..64b66292 100644 --- a/packages/ui/src/components/chat/work-status/usageHeadline.test.ts +++ b/packages/ui/src/components/chat/work-status/usageHeadline.test.ts @@ -36,6 +36,10 @@ describe('resolveQuotaProviderId', () => { expect(resolveQuotaProviderId('anthropic')).toBe('claude'); }); + test('maps the opencode-claude integration provider onto Claude quota', () => { + expect(resolveQuotaProviderId('claude-code')).toBe('claude'); + }); + test('is case and whitespace tolerant, and rejects empties', () => { expect(resolveQuotaProviderId(' OpenAI ')).toBe('codex'); expect(resolveQuotaProviderId('')).toBeNull(); diff --git a/packages/ui/src/components/chat/work-status/usageHeadline.ts b/packages/ui/src/components/chat/work-status/usageHeadline.ts index ad80e553..502a1bc3 100644 --- a/packages/ui/src/components/chat/work-status/usageHeadline.ts +++ b/packages/ui/src/components/chat/work-status/usageHeadline.ts @@ -13,11 +13,15 @@ import type { UsageProviderGroup, UsageLimitRow } from '@/components/usage/usage /** * Quota provider ids mostly match OpenCode provider ids; these are the ones * that do not. Unmatched providers simply produce no headline. + * + * `claude-code` is the provider the opencode-claude integration registers, and + * it bills against the same Claude subscription the `claude` quota reports. */ const QUOTA_PROVIDER_ALIASES = new Map([ ['openai', 'codex'], ['chatgpt', 'codex'], ['anthropic', 'claude'], + ['claude-code', 'claude'], ['gemini', 'google'], ]); diff --git a/packages/web/server/lib/quota/providers/claude/transforms.js b/packages/web/server/lib/quota/providers/claude/transforms.js index 6ca6c0e4..c61a552a 100644 --- a/packages/web/server/lib/quota/providers/claude/transforms.js +++ b/packages/web/server/lib/quota/providers/claude/transforms.js @@ -16,6 +16,11 @@ const SESSION_WINDOW = '5h'; const WEEKLY_WINDOW = '7d'; const EXTRA_USAGE_WINDOW = 'extra_usage'; +// Consumers rank limits by how soon they run out, so each window carries its +// duration. Extra usage is a monthly spend cap, not a rolling window. +const SESSION_WINDOW_SECONDS = 5 * 60 * 60; +const WEEKLY_WINDOW_SECONDS = 7 * 24 * 60 * 60; + const asArray = (value) => (Array.isArray(value) ? value : []); /** Money in Anthropic's minor-unit form, e.g. `{ amount_minor: 10000, exponent: 2 }`. */ @@ -35,9 +40,9 @@ const formatSpendLabel = (used, limit, currency) => { return limitLabel === null ? `${prefix}${usedLabel}` : `${prefix}${usedLabel} / ${prefix}${limitLabel}`; }; -const addWindow = (target, key, { percent, resetAt, valueLabel }) => { +const addWindow = (target, key, { percent, resetAt, valueLabel, windowSeconds = null }) => { if (percent === null && !valueLabel) return; - target[key] = toUsageWindow({ usedPercent: percent, windowSeconds: null, resetAt, valueLabel }); + target[key] = toUsageWindow({ usedPercent: percent, windowSeconds, resetAt, valueLabel }); }; const applyLimitsArray = (limits, windows, models) => { @@ -49,16 +54,16 @@ const applyLimitsArray = (limits, windows, models) => { const modelName = asNonEmptyString(asObject(asObject(limit.scope)?.model)?.display_name); if (limit.kind === 'session') { - addWindow(windows, SESSION_WINDOW, { percent, resetAt }); + addWindow(windows, SESSION_WINDOW, { percent, resetAt, windowSeconds: SESSION_WINDOW_SECONDS }); continue; } if (limit.kind === 'weekly_all') { - addWindow(windows, WEEKLY_WINDOW, { percent, resetAt }); + addWindow(windows, WEEKLY_WINDOW, { percent, resetAt, windowSeconds: WEEKLY_WINDOW_SECONDS }); continue; } if (limit.kind === 'weekly_scoped' && modelName) { const modelWindows = {}; - addWindow(modelWindows, WEEKLY_WINDOW, { percent, resetAt }); + addWindow(modelWindows, WEEKLY_WINDOW, { percent, resetAt, windowSeconds: WEEKLY_WINDOW_SECONDS }); if (Object.keys(modelWindows).length > 0) models[modelName] = { windows: modelWindows }; } } @@ -70,13 +75,15 @@ const applyLegacyFields = (payload, windows) => { if (fiveHour) { addWindow(windows, SESSION_WINDOW, { percent: toNumber(fiveHour.utilization), - resetAt: toTimestamp(fiveHour.resets_at) + resetAt: toTimestamp(fiveHour.resets_at), + windowSeconds: SESSION_WINDOW_SECONDS }); } if (sevenDay) { addWindow(windows, WEEKLY_WINDOW, { percent: toNumber(sevenDay.utilization), - resetAt: toTimestamp(sevenDay.resets_at) + resetAt: toTimestamp(sevenDay.resets_at), + windowSeconds: WEEKLY_WINDOW_SECONDS }); } }; diff --git a/packages/web/server/lib/quota/providers/claude/transforms.test.js b/packages/web/server/lib/quota/providers/claude/transforms.test.js index 679b8ff9..77d20b3b 100644 --- a/packages/web/server/lib/quota/providers/claude/transforms.test.js +++ b/packages/web/server/lib/quota/providers/claude/transforms.test.js @@ -39,6 +39,15 @@ describe('Claude usage transforms', () => { expect(models.Fable.windows['7d'].usedPercent).toBe(12); }); + it('reports each window duration so callers can rank limits by urgency', () => { + const { windows, models } = toClaudeUsage(LIVE_PAYLOAD); + + expect(windows['5h'].windowSeconds).toBe(5 * 60 * 60); + expect(windows['7d'].windowSeconds).toBe(7 * 24 * 60 * 60); + expect(models.Fable.windows['7d'].windowSeconds).toBe(7 * 24 * 60 * 60); + expect(windows.extra_usage.windowSeconds).toBeNull(); + }); + it('reports extra usage as a spend window with a money label', () => { const { windows } = toClaudeUsage(LIVE_PAYLOAD);