172 lines
5.6 KiB
TypeScript
172 lines
5.6 KiB
TypeScript
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/quota/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("failed fetches retry with re-read credentials and give up after five attempts", async () => {
|
|
{
|
|
await writeFile(path, credentials(Date.now() + 3_600_000));
|
|
const tokens: Array<string | null> = [];
|
|
let changed = 0;
|
|
const tracker = new ClaudeQuotaTracker(
|
|
async () => {
|
|
changed++;
|
|
},
|
|
async (_input, init) => {
|
|
tokens.push(new Headers(init?.headers).get("authorization"));
|
|
if (tokens.length > 1) return Response.json(payload);
|
|
await writeFile(
|
|
path,
|
|
credentials(Date.now() + 3_600_000).replace("token-123", "token-456"),
|
|
);
|
|
throw new Error("network down");
|
|
},
|
|
60_000,
|
|
path,
|
|
1,
|
|
);
|
|
await tracker.init();
|
|
expect(tokens).toEqual(["Bearer token-123", "Bearer token-456"]);
|
|
expect(tracker.status()?.plan).toBe("max");
|
|
expect(changed).toBe(1);
|
|
await tracker.dispose();
|
|
}
|
|
{
|
|
await writeFile(path, credentials(Date.now() + 3_600_000));
|
|
let calls = 0;
|
|
const tracker = new ClaudeQuotaTracker(
|
|
async () => {},
|
|
async () => {
|
|
calls++;
|
|
return new Response("nope", { status: 500 });
|
|
},
|
|
60_000,
|
|
path,
|
|
1,
|
|
);
|
|
await tracker.init();
|
|
expect(calls).toBe(5);
|
|
expect(tracker.status()).toBeUndefined();
|
|
await tracker.refresh();
|
|
expect(calls).toBe(10);
|
|
await tracker.dispose();
|
|
}
|
|
});
|