Compare commits
4
Commits
v3.0.0
..
5edb1b5449
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5edb1b5449 | ||
|
|
a3c5ae5c42 | ||
|
|
9d74def38a | ||
|
|
f6c69c7db0 |
@@ -13,6 +13,8 @@ opencode service restart
|
|||||||
|
|
||||||
Choose any plugin subset; Multi-work requires Agents first.
|
Choose any plugin subset; Multi-work requires Agents first.
|
||||||
|
|
||||||
|
Quota shows ChatGPT limits for the connected OpenAI account and, when Claude Code is logged in, Claude subscription limits read from `${CLAUDE_CONFIG_DIR:-~/.claude}/.credentials.json` (read-only; Claude Code refreshes the token).
|
||||||
|
|
||||||
## Local installation
|
## Local installation
|
||||||
|
|
||||||
From a local checkout, run:
|
From a local checkout, run:
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { Schema } from "effect";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { QuotaSnapshot, QuotaStatus, QuotaWindow } from "./quota.js";
|
||||||
|
|
||||||
|
const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
||||||
|
const POLL_MS = 60_000;
|
||||||
|
const TIMEOUT_MS = 5_000;
|
||||||
|
const RETRY_MS = 2_000;
|
||||||
|
const RETRIES = 4;
|
||||||
|
const WEEK = 604_800;
|
||||||
|
|
||||||
|
type QuotaFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
export function claudeCredentialsPath() {
|
||||||
|
return join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), ".credentials.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = z.object({
|
||||||
|
claudeAiOauth: z.object({
|
||||||
|
accessToken: z.string(),
|
||||||
|
expiresAt: z.number(),
|
||||||
|
subscriptionType: z.string().nullish(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const usageWindow = z
|
||||||
|
.object({ utilization: z.number().finite(), resets_at: z.string().nullish() })
|
||||||
|
.nullish();
|
||||||
|
const usagePayload = z.object({
|
||||||
|
five_hour: usageWindow,
|
||||||
|
seven_day: usageWindow,
|
||||||
|
seven_day_opus: usageWindow,
|
||||||
|
seven_day_sonnet: usageWindow,
|
||||||
|
limits: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
kind: z.string(),
|
||||||
|
percent: z.number().finite(),
|
||||||
|
resets_at: z.string().nullish(),
|
||||||
|
scope: z
|
||||||
|
.object({
|
||||||
|
model: z.object({ id: z.string(), display_name: z.string() }).nullish(),
|
||||||
|
})
|
||||||
|
.nullish(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.nullish()
|
||||||
|
.catch(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
function window(
|
||||||
|
id: string,
|
||||||
|
seconds: number,
|
||||||
|
used: number,
|
||||||
|
resetsAt: string | null | undefined,
|
||||||
|
label: string | null = null,
|
||||||
|
): QuotaWindow {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
seconds,
|
||||||
|
used: Math.max(0, Math.min(100, used)),
|
||||||
|
resetsAt: resetsAt ? Date.parse(resetsAt) : Number.NaN,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize the Claude OAuth usage payload into quota windows. */
|
||||||
|
export function normalizeClaudeQuota(
|
||||||
|
input: Schema.Json,
|
||||||
|
plan: string,
|
||||||
|
fetchedAt = Date.now(),
|
||||||
|
): QuotaSnapshot | undefined {
|
||||||
|
const parsed = usagePayload.safeParse(input);
|
||||||
|
if (!parsed.success) return;
|
||||||
|
const data = parsed.data;
|
||||||
|
const windows: QuotaWindow[] = [];
|
||||||
|
for (const [key, seconds, label] of [
|
||||||
|
["five_hour", 18_000, null],
|
||||||
|
["seven_day", WEEK, null],
|
||||||
|
["seven_day_opus", WEEK, "Opus"],
|
||||||
|
["seven_day_sonnet", WEEK, "Sonnet"],
|
||||||
|
] as const) {
|
||||||
|
const item = data[key];
|
||||||
|
if (item)
|
||||||
|
windows.push(window(`claude:${key}`, seconds, item.utilization, item.resets_at, label));
|
||||||
|
}
|
||||||
|
for (const limit of data.limits ?? []) {
|
||||||
|
const model = limit.scope?.model;
|
||||||
|
if (limit.kind !== "weekly_scoped" || !model) continue;
|
||||||
|
if (windows.some((item) => item.label === model.display_name)) continue;
|
||||||
|
windows.push(
|
||||||
|
window(
|
||||||
|
`claude:weekly_scoped:${model.id}`,
|
||||||
|
WEEK,
|
||||||
|
limit.percent,
|
||||||
|
limit.resets_at,
|
||||||
|
model.display_name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (windows.length === 0) return;
|
||||||
|
return { plan: plan.toLowerCase(), fetchedAt, windows };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClaudeQuotaTracker {
|
||||||
|
private latest: QuotaSnapshot | undefined;
|
||||||
|
private timer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
private request: Promise<void> | undefined;
|
||||||
|
private controller: AbortController | undefined;
|
||||||
|
private wake: (() => void) | undefined;
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly changed: () => Promise<void>,
|
||||||
|
private readonly fetcher: QuotaFetch = fetch,
|
||||||
|
private readonly pollMs = POLL_MS,
|
||||||
|
private readonly path = claudeCredentialsPath(),
|
||||||
|
private readonly retryMs = RETRY_MS,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.timer = setInterval(() => void this.refresh(), this.pollMs);
|
||||||
|
await this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(): Promise<void> {
|
||||||
|
if (this.disposed) return Promise.resolve();
|
||||||
|
this.request ??= this.query().finally(() => {
|
||||||
|
this.request = undefined;
|
||||||
|
});
|
||||||
|
return this.request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async query() {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
if (await this.attempt()) return;
|
||||||
|
if (attempt >= RETRIES || this.disposed) return;
|
||||||
|
await this.sleep(this.retryMs * 2 ** attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sleep(ms: number) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const timer = setTimeout(wake, ms);
|
||||||
|
this.wake = wake;
|
||||||
|
function wake() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true when done, false when the attempt failed and should be retried. */
|
||||||
|
private async attempt() {
|
||||||
|
try {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = await readFile(this.path, "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
|
||||||
|
return false;
|
||||||
|
if (this.latest && !this.disposed) {
|
||||||
|
this.latest = undefined;
|
||||||
|
await this.changed().catch(() => {});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const parsed = credentials.safeParse(JSON.parse(text));
|
||||||
|
if (!parsed.success) return false;
|
||||||
|
const credential = parsed.data.claudeAiOauth;
|
||||||
|
if (this.disposed) return true;
|
||||||
|
if (credential.expiresAt <= Date.now()) return false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
this.controller = controller;
|
||||||
|
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await this.fetcher(USAGE_URL, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${credential.accessToken}`,
|
||||||
|
"anthropic-beta": "oauth-2025-04-20",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (this.controller === controller) this.controller = undefined;
|
||||||
|
}
|
||||||
|
if (!response.ok) return false;
|
||||||
|
// SAFETY: Response.json yields JSON values; usagePayload validates fields used.
|
||||||
|
const payload = (await response.json()) as Schema.Json;
|
||||||
|
const snapshot = normalizeClaudeQuota(
|
||||||
|
payload,
|
||||||
|
credential.subscriptionType ?? "unknown",
|
||||||
|
);
|
||||||
|
if (this.disposed) return true;
|
||||||
|
if (!snapshot) return false;
|
||||||
|
this.latest = snapshot;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await this.changed().catch(() => {});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
status(): QuotaStatus | undefined {
|
||||||
|
if (!this.latest) return;
|
||||||
|
return {
|
||||||
|
...this.latest,
|
||||||
|
windows: this.latest.windows.map((item) => ({
|
||||||
|
...item,
|
||||||
|
remaining: Math.max(0, 100 - item.used),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispose() {
|
||||||
|
this.disposed = true;
|
||||||
|
this.controller?.abort();
|
||||||
|
this.wake?.();
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
this.timer = undefined;
|
||||||
|
await this.request;
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-2
@@ -1,4 +1,5 @@
|
|||||||
import { Plugin } from "@opencode/plugin";
|
import { Plugin } from "@opencode/plugin";
|
||||||
|
import { ClaudeQuotaTracker } from "./claude-quota.js";
|
||||||
import { QuotaTracker } from "./quota.js";
|
import { QuotaTracker } from "./quota.js";
|
||||||
import { QuotaRpc } from "./quota-rpc.js";
|
import { QuotaRpc } from "./quota-rpc.js";
|
||||||
|
|
||||||
@@ -9,9 +10,14 @@ export default Plugin.define({
|
|||||||
const quota = new QuotaTracker(context, async () => {
|
const quota = new QuotaTracker(context, async () => {
|
||||||
await registration.events.emit("updated", {});
|
await registration.events.emit("updated", {});
|
||||||
});
|
});
|
||||||
|
const claude = new ClaudeQuotaTracker(async () => {
|
||||||
|
await registration.events.emit("updated", {});
|
||||||
|
});
|
||||||
const registration = await context.rpc.register(QuotaRpc, {
|
const registration = await context.rpc.register(QuotaRpc, {
|
||||||
status: async () => quota.status() ?? null,
|
status: async () => quota.status() ?? null,
|
||||||
|
claude: async () => claude.status() ?? null,
|
||||||
});
|
});
|
||||||
|
void claude.init();
|
||||||
await quota.init();
|
await quota.init();
|
||||||
const updates = (async () => {
|
const updates = (async () => {
|
||||||
for await (const event of context.event.subscribe({ signal: controller.signal })) {
|
for await (const event of context.event.subscribe({ signal: controller.signal })) {
|
||||||
@@ -29,7 +35,7 @@ export default Plugin.define({
|
|||||||
event.type === "session.execution.interrupted" ||
|
event.type === "session.execution.interrupted" ||
|
||||||
event.type === "session.idle"
|
event.type === "session.idle"
|
||||||
) {
|
) {
|
||||||
await quota.complete(event.data.sessionID);
|
await Promise.all([quota.complete(event.data.sessionID), claude.refresh()]);
|
||||||
} else if (event.type === "credential.updated") {
|
} else if (event.type === "credential.updated") {
|
||||||
await quota.authChanged();
|
await quota.authChanged();
|
||||||
} else if (
|
} else if (
|
||||||
@@ -47,7 +53,7 @@ export default Plugin.define({
|
|||||||
return async () => {
|
return async () => {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
await updates;
|
await updates;
|
||||||
await quota.dispose();
|
await Promise.all([quota.dispose(), claude.dispose()]);
|
||||||
await registration.dispose();
|
await registration.dispose();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
+6
-6
@@ -10,15 +10,15 @@ const window = z.object({
|
|||||||
remaining: z.number(),
|
remaining: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const status = z
|
||||||
|
.object({ plan: z.string(), fetchedAt: z.number(), windows: z.array(window) })
|
||||||
|
.nullable();
|
||||||
|
|
||||||
export const QuotaRpc = Rpc.define({
|
export const QuotaRpc = Rpc.define({
|
||||||
id: "stanp-opencode.quota",
|
id: "stanp-opencode.quota",
|
||||||
methods: {
|
methods: {
|
||||||
status: {
|
status: { input: z.object({}), output: status },
|
||||||
input: z.object({}),
|
claude: { input: z.object({}), output: status },
|
||||||
output: z
|
|
||||||
.object({ plan: z.string(), fetchedAt: z.number(), windows: z.array(window) })
|
|
||||||
.nullable(),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
events: { updated: { schema: z.object({}) } },
|
events: { updated: { schema: z.object({}) } },
|
||||||
});
|
});
|
||||||
|
|||||||
+31
-11
@@ -61,24 +61,44 @@ export default Plugin.define({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const Slot = (props: { instance: string; centered?: boolean; visible?: () => boolean }) => {
|
const Slot = (props: { instance: string; centered?: boolean; visible?: () => boolean }) => {
|
||||||
const [status] = createResource(
|
const [statuses] = createResource(
|
||||||
() => (props.visible?.() === false ? false : `${props.instance}:${revision()}`),
|
() => (props.visible?.() === false ? false : `${props.instance}:${revision()}`),
|
||||||
() => loadQuotaStatus(() => quota.status({})),
|
async () => {
|
||||||
|
const [chatgpt, claude] = await Promise.all([
|
||||||
|
loadQuotaStatus(() => quota.status({})),
|
||||||
|
loadQuotaStatus(() => quota.claude({})),
|
||||||
|
]);
|
||||||
|
return chatgpt || claude ? { chatgpt, claude } : null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const Section = (section: { title: string; status: QuotaStatus | null }) => (
|
||||||
|
<Show when={section.status}>
|
||||||
|
{(status: () => QuotaStatus) => (
|
||||||
|
<>
|
||||||
|
<text fg={context.theme.text.default}>
|
||||||
|
<b>
|
||||||
|
{section.title} quota ({status().plan})
|
||||||
|
</b>
|
||||||
|
</text>
|
||||||
|
<For each={status().windows}>
|
||||||
|
{(window) => (
|
||||||
|
<text fg={context.theme.text.muted}>
|
||||||
|
{formatQuotaWindow(window)}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
);
|
);
|
||||||
const Content = () => (
|
const Content = () => (
|
||||||
<box flexDirection="column">
|
<box flexDirection="column">
|
||||||
<text fg={context.theme.text.default}>
|
<Section title="ChatGPT" status={statuses()!.chatgpt} />
|
||||||
<b>ChatGPT quota ({status()!.plan})</b>
|
<Section title="Claude" status={statuses()!.claude} />
|
||||||
</text>
|
|
||||||
<For each={status()!.windows}>
|
|
||||||
{(window) => (
|
|
||||||
<text fg={context.theme.text.muted}>{formatQuotaWindow(window)}</text>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Show when={status()}>
|
<Show when={statuses()}>
|
||||||
<Show when={props.centered} fallback={<Content />}>
|
<Show when={props.centered} fallback={<Content />}>
|
||||||
<box position="absolute" top={2} left={0} width="100%" alignItems="center">
|
<box position="absolute" top={2} left={0} width="100%" alignItems="center">
|
||||||
<Content />
|
<Content />
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
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,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed fetch retries with re-read credentials", 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("corrupt credentials file keeps previous status and retries", async () => {
|
||||||
|
await writeFile(path, credentials(Date.now() + 3_600_000));
|
||||||
|
let calls = 0;
|
||||||
|
let changed = 0;
|
||||||
|
const tracker = new ClaudeQuotaTracker(
|
||||||
|
async () => {
|
||||||
|
changed++;
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
calls++;
|
||||||
|
return Response.json(payload);
|
||||||
|
},
|
||||||
|
60_000,
|
||||||
|
path,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
await tracker.init();
|
||||||
|
await writeFile(path, "{not json");
|
||||||
|
await tracker.refresh();
|
||||||
|
await writeFile(path, JSON.stringify({ claudeAiOauth: { accessToken: 1 } }));
|
||||||
|
await tracker.refresh();
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
expect(changed).toBe(1);
|
||||||
|
expect(tracker.status()?.windows).toHaveLength(4);
|
||||||
|
await tracker.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing credentials file clears status without retry", async () => {
|
||||||
|
await writeFile(path, credentials(Date.now() + 3_600_000));
|
||||||
|
let calls = 0;
|
||||||
|
let changed = 0;
|
||||||
|
const tracker = new ClaudeQuotaTracker(
|
||||||
|
async () => {
|
||||||
|
changed++;
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
calls++;
|
||||||
|
return Response.json(payload);
|
||||||
|
},
|
||||||
|
60_000,
|
||||||
|
path,
|
||||||
|
10_000,
|
||||||
|
);
|
||||||
|
await tracker.init();
|
||||||
|
await rm(path);
|
||||||
|
const start = performance.now();
|
||||||
|
await tracker.refresh();
|
||||||
|
expect(performance.now() - start).toBeLessThan(1_000);
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
expect(changed).toBe(2);
|
||||||
|
expect(tracker.status()).toBeUndefined();
|
||||||
|
await tracker.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gives up after five failed attempts", async () => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispose during backoff resolves quickly", async () => {
|
||||||
|
await writeFile(path, credentials(Date.now() + 3_600_000));
|
||||||
|
let calls = 0;
|
||||||
|
let changed = 0;
|
||||||
|
const tracker = new ClaudeQuotaTracker(
|
||||||
|
async () => {
|
||||||
|
changed++;
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
calls++;
|
||||||
|
throw new Error("network down");
|
||||||
|
},
|
||||||
|
60_000,
|
||||||
|
path,
|
||||||
|
60_000,
|
||||||
|
);
|
||||||
|
const init = tracker.init();
|
||||||
|
while (calls === 0) await new Promise((resolve) => setTimeout(resolve, 1));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
const start = performance.now();
|
||||||
|
await tracker.dispose();
|
||||||
|
await init;
|
||||||
|
expect(performance.now() - start).toBeLessThan(1_000);
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
expect(changed).toBe(0);
|
||||||
|
expect(tracker.status()).toBeUndefined();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user