feat: claude quotas
CI / verify (pull_request) Successful in 9s

This commit is contained in:
2026-10-07 14:49:00 +02:00
parent 0c7ac2b532
commit f6c69c7db0
6 changed files with 405 additions and 19 deletions
+158
View File
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, expect, test } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ClaudeQuotaTracker, normalizeClaudeQuota } from "../src/claude-quota.js";
const payload = {
five_hour: { utilization: 12.5, resets_at: "2026-10-07T12:00:00+00:00" },
seven_day: { utilization: 140, resets_at: null },
seven_day_opus: null,
seven_day_sonnet: { utilization: 3, resets_at: "2026-10-10T00:00:00+00:00" },
limits: [
{ kind: "session", percent: 12.5, resets_at: "2026-10-07T12:00:00+00:00", scope: null },
{
kind: "weekly_scoped",
percent: 40,
resets_at: "2026-10-10T00:00:00+00:00",
scope: { model: { id: "claude-fable-5-1", display_name: "Fable" } },
},
{
kind: "weekly_scoped",
percent: 3,
resets_at: "2026-10-10T00:00:00+00:00",
scope: { model: { id: "claude-sonnet-5-5", display_name: "Sonnet" } },
},
],
extra_usage: { is_enabled: false },
};
let dir: string;
let path: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "claude-quota-"));
path = join(dir, ".credentials.json");
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
function credentials(expiresAt: number) {
return JSON.stringify({
claudeAiOauth: {
accessToken: "token-123",
refreshToken: "refresh",
expiresAt,
scopes: ["user:inference"],
subscriptionType: "Max",
rateLimitTier: "default_claude_max_20x",
},
});
}
test("normalizes Claude usage windows", () => {
expect(normalizeClaudeQuota(payload, "Max", 1)).toEqual({
plan: "max",
fetchedAt: 1,
windows: [
{
id: "claude:five_hour",
label: null,
seconds: 18_000,
used: 12.5,
resetsAt: Date.parse("2026-10-07T12:00:00Z"),
},
{
id: "claude:seven_day",
label: null,
seconds: 604_800,
used: 100,
resetsAt: Number.NaN,
},
{
id: "claude:seven_day_sonnet",
label: "Sonnet",
seconds: 604_800,
used: 3,
resetsAt: Date.parse("2026-10-10T00:00:00Z"),
},
{
id: "claude:weekly_scoped:claude-fable-5-1",
label: "Fable",
seconds: 604_800,
used: 40,
resetsAt: Date.parse("2026-10-10T00:00:00Z"),
},
],
});
expect(normalizeClaudeQuota({ five_hour: null, seven_day: null }, "pro")).toBeUndefined();
});
test("reads credentials file and sends OAuth usage request", async () => {
await writeFile(path, credentials(Date.now() + 3_600_000));
const calls: Array<{ url: string; headers: Headers }> = [];
let changed = 0;
const tracker = new ClaudeQuotaTracker(
async () => {
changed++;
},
async (input, init) => {
calls.push({ url: String(input), headers: new Headers(init?.headers) });
return Response.json(payload);
},
60_000,
path,
);
await tracker.init();
expect(calls).toHaveLength(1);
expect(calls[0]!.url).toBe("https://api.anthropic.com/api/oauth/usage");
expect(calls[0]!.headers.get("authorization")).toBe("Bearer token-123");
expect(calls[0]!.headers.get("anthropic-beta")).toBe("oauth-2025-04-20");
expect(calls[0]!.headers.get("accept")).toBe("application/json");
expect(tracker.status()?.plan).toBe("max");
expect(tracker.status()?.windows[0]?.remaining).toBe(87.5);
expect(changed).toBe(1);
await rm(path);
await tracker.refresh();
expect(tracker.status()).toBeUndefined();
expect(changed).toBe(2);
await tracker.dispose();
});
test("missing credentials file yields no status", async () => {
let calls = 0;
const tracker = new ClaudeQuotaTracker(
async () => {},
async () => {
calls++;
return Response.json(payload);
},
60_000,
path,
);
await tracker.init();
expect(calls).toBe(0);
expect(tracker.status()).toBeUndefined();
await tracker.dispose();
});
test("expired token skips fetch and keeps last snapshot", async () => {
await writeFile(path, credentials(Date.now() + 3_600_000));
let calls = 0;
const tracker = new ClaudeQuotaTracker(
async () => {},
async () => {
calls++;
return Response.json(payload);
},
60_000,
path,
);
await tracker.init();
await writeFile(path, credentials(Date.now() - 1));
await tracker.refresh();
expect(calls).toBe(1);
expect(tracker.status()?.windows).toHaveLength(4);
await tracker.dispose();
});