diff --git a/src/adapters/claude.ts b/src/adapters/claude.ts index 03b00e5..7ffd2ee 100644 --- a/src/adapters/claude.ts +++ b/src/adapters/claude.ts @@ -31,7 +31,7 @@ import { type Schema, } from '../lib/validate'; -export const CLAUDE_ADAPTER_VERSION = 1; +export const CLAUDE_ADAPTER_VERSION = 2; const ORIGIN = 'https://claude.ai'; const ORGANIZATIONS_URL = `${ORIGIN}/api/organizations`; @@ -73,19 +73,53 @@ const windowSchema: Schema = obj({ }); interface UsageShape { - five_hour?: UsageWindow; - seven_day?: UsageWindow; - seven_day_sonnet?: UsageWindow; - seven_day_opus?: UsageWindow; + five_hour?: UsageWindow | null; + seven_day?: UsageWindow | null; + seven_day_sonnet?: UsageWindow | null; + seven_day_opus?: UsageWindow | null; } +/** + * Entry of the `limits` array — the canonical list the claude.ai usage page + * renders (verified against a live capture, 2026-08): kind 'session' / + * 'weekly_all' / 'weekly_scoped', percent used, and for scoped limits the + * model's display name. + */ +interface LimitEntry { + kind: string; + percent: number; + resets_at?: number | string | null; + scope?: { model?: { display_name?: string | null } | null } | null; +} + +interface LimitsShape { + limits: LimitEntry[]; +} + +const limitEntrySchema: Schema = obj({ + kind: str, + percent: num, + resets_at: optional(nullable(numOrStr)), + scope: optional( + nullable( + obj({ + model: optional(nullable(obj({ display_name: optional(nullable(str)) }))), + }), + ), + ), +}); + const usageVariants = { + org_usage_limits: obj({ + limits: arr(limitEntrySchema), + }) as Schema, + // Fallback: top-level windows. Unused windows arrive as null, not absent. org_usage_windows: obj({ - five_hour: optional(windowSchema), - seven_day: optional(windowSchema), - seven_day_sonnet: optional(windowSchema), - seven_day_opus: optional(windowSchema), - }), + five_hour: optional(nullable(windowSchema)), + seven_day: optional(nullable(windowSchema)), + seven_day_sonnet: optional(nullable(windowSchema)), + seven_day_opus: optional(nullable(windowSchema)), + }) as Schema, }; const LANE_LABELS: Record = { @@ -95,30 +129,52 @@ const LANE_LABELS: Record = { seven_day_opus: 'Weekly (Opus)', }; -function windowToLane(id: keyof UsageShape, w: UsageWindow): QuotaLane { - let resetsAt: string | null = null; - if (typeof w.resets_at === 'number') { - resetsAt = new Date(w.resets_at * 1000).toISOString(); - } else if (typeof w.resets_at === 'string') { - const parsed = Date.parse(w.resets_at); - resetsAt = Number.isNaN(parsed) ? null : new Date(parsed).toISOString(); +function parseResetsAt(resetsAt: number | string | null | undefined): string | null { + if (typeof resetsAt === 'number') return new Date(resetsAt * 1000).toISOString(); + if (typeof resetsAt === 'string') { + const parsed = Date.parse(resetsAt); + return Number.isNaN(parsed) ? null : new Date(parsed).toISOString(); } + return null; +} + +function percentLane(id: string, label: string, usedPct: number, resetsAt: string | null): QuotaLane { return { id, - label: LANE_LABELS[id], + label, kind: 'percent', - used: clampPct(w.utilization), + used: clampPct(usedPct), limit: 100, resetsAt, - headroomPct: clampPct(100 - w.utilization), + headroomPct: clampPct(100 - usedPct), }; } -function toLanes(shape: UsageShape): QuotaLane[] { +function limitToLane(entry: LimitEntry): QuotaLane { + const scopeName = entry.scope?.model?.display_name ?? null; + let id = entry.kind; + let label: string; + if (entry.kind === 'session') { + label = 'Session (5h)'; + } else if (entry.kind === 'weekly_all') { + label = 'Weekly (all models)'; + } else if (entry.kind === 'weekly_scoped' && scopeName) { + id = `weekly_scoped:${scopeName}`; + label = `Weekly (${scopeName})`; + } else { + label = entry.kind.replace(/_/g, ' '); + } + return percentLane(id, label, entry.percent, parseResetsAt(entry.resets_at)); +} + +function toLanes(shape: LimitsShape | UsageShape): QuotaLane[] { + if ('limits' in shape) { + return shape.limits.map(limitToLane); + } const lanes: QuotaLane[] = []; for (const id of Object.keys(LANE_LABELS) as (keyof UsageShape)[]) { const w = shape[id]; - if (w) lanes.push(windowToLane(id, w)); + if (w) lanes.push(percentLane(id, LANE_LABELS[id], w.utilization, parseResetsAt(w.resets_at))); } return lanes; } diff --git a/tests/claude.test.ts b/tests/claude.test.ts index f594a68..c4f1894 100644 --- a/tests/claude.test.ts +++ b/tests/claude.test.ts @@ -8,7 +8,7 @@ const fixture = (name: string): unknown => const CHAT_ORG = '00000000-0000-4000-8000-0000000000ca'; const orgs = () => fixture('organizations.json'); -const usage = () => fixture('usage.expected.json'); +const usage = () => fixture('usage.limits.json'); /** Happy-path responder: org discovery, then usage on the first candidate. */ const happyResponder = (url: string): Response => { @@ -20,12 +20,32 @@ const happyResponder = (url: string): Response => { }; describe('claude adapter', () => { - it('discovers the chat-capable org and parses usage windows', async () => { + it('discovers the chat-capable org and parses the limits array (live shape)', async () => { const ctx = makeCtx(happyResponder); const snap = await claudeAdapter.fetch(ctx); + expect(snap.status).toBe('ok'); + expect(snap.schemaVariant).toBe('org_usage_limits'); + expect(snap.lanes.map((l) => [l.id, l.label, l.headroomPct])).toEqual([ + ['session', 'Session (5h)', 87], + ['weekly_all', 'Weekly (all models)', 95], + ['weekly_scoped:Fable', 'Weekly (Fable)', 93], + ]); + // Microsecond-precision ISO timestamps normalize cleanly + expect(snap.lanes[0]?.resetsAt).toBe('2026-08-28T20:39:59.559Z'); + }); + + it('falls back to the top-level windows shape, tolerating null windows', async () => { + const ctx = makeCtx((url) => + url === 'https://claude.ai/api/organizations' + ? jsonResponse(orgs()) + : jsonResponse(fixture('usage.expected.json')), + ); + const snap = await claudeAdapter.fetch(ctx); + expect(snap.status).toBe('ok'); expect(snap.schemaVariant).toBe('org_usage_windows'); + // seven_day_sonnet is null in the fixture and must be skipped, not fatal expect(snap.lanes.map((l) => [l.id, l.headroomPct])).toEqual([ ['five_hour', 96], ['seven_day', 97], diff --git a/tests/fixtures/claude/usage.expected.json b/tests/fixtures/claude/usage.expected.json index acde428..82df6d4 100644 --- a/tests/fixtures/claude/usage.expected.json +++ b/tests/fixtures/claude/usage.expected.json @@ -1,4 +1,5 @@ { + "seven_day_sonnet": null, "five_hour": { "utilization": 4, "resets_at": "2026-08-28T13:08:00Z" diff --git a/tests/fixtures/claude/usage.limits.json b/tests/fixtures/claude/usage.limits.json new file mode 100644 index 0000000..720ca56 --- /dev/null +++ b/tests/fixtures/claude/usage.limits.json @@ -0,0 +1,64 @@ +{ + "five_hour": { + "utilization": 13.0, + "resets_at": "2026-08-28T20:39:59.559255+00:00", + "limit_dollars": null, + "used_dollars": null, + "remaining_dollars": null, + "locked_reason": null + }, + "seven_day": { + "utilization": 5.0, + "resets_at": "2026-09-02T14:59:59.559275+00:00", + "limit_dollars": null, + "used_dollars": null, + "remaining_dollars": null, + "locked_reason": null + }, + "seven_day_oauth_apps": null, + "seven_day_opus": null, + "seven_day_sonnet": null, + "extra_usage": { + "is_enabled": true, + "monthly_limit": null, + "used_credits": 0.0, + "utilization": null, + "currency": "USD" + }, + "limits": [ + { + "kind": "session", + "group": "session", + "percent": 13, + "severity": "normal", + "resets_at": "2026-08-28T20:39:59.559255+00:00", + "scope": null, + "is_active": true + }, + { + "kind": "weekly_all", + "group": "weekly", + "percent": 5, + "severity": "normal", + "resets_at": "2026-09-02T14:59:59.559275+00:00", + "scope": null, + "is_active": false + }, + { + "kind": "weekly_scoped", + "group": "weekly", + "percent": 7, + "severity": "normal", + "resets_at": "2026-09-02T14:59:59.559524+00:00", + "scope": { + "model": { + "id": null, + "display_name": "Fable" + }, + "surface": null + }, + "is_active": false + } + ], + "member_dashboard_available": false +}