Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 78 additions & 22 deletions src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;

Expand Down Expand Up @@ -73,19 +73,53 @@ const windowSchema: Schema<UsageWindow> = 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<LimitEntry> = 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<LimitsShape>({
limits: arr(limitEntrySchema),
}) as Schema<LimitsShape | UsageShape>,
// Fallback: top-level windows. Unused windows arrive as null, not absent.
org_usage_windows: obj<UsageShape>({
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<LimitsShape | UsageShape>,
};

const LANE_LABELS: Record<keyof UsageShape, string> = {
Expand All @@ -95,30 +129,52 @@ const LANE_LABELS: Record<keyof UsageShape, string> = {
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;
}
Expand Down
24 changes: 22 additions & 2 deletions tests/claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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],
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/claude/usage.expected.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"seven_day_sonnet": null,
"five_hour": {
"utilization": 4,
"resets_at": "2026-08-28T13:08:00Z"
Expand Down
64 changes: 64 additions & 0 deletions tests/fixtures/claude/usage.limits.json
Original file line number Diff line number Diff line change
@@ -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
}
Loading