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; const jsonValue = z.json(); type QuotaFetch = (input: string | URL | Request, init?: RequestInit) => Promise; 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(), 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(), 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 === null || resetsAt === undefined ? Number.NaN : Date.parse(resetsAt), }; } /** 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 | undefined; private request: Promise | undefined; private controller: AbortController | undefined; private wake: (() => void) | undefined; private disposed = false; constructor( private readonly changed: () => Promise, 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 { 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((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 response = await this.fetchUsage(credential.accessToken); if (!response.ok) return false; const payload = jsonValue.parse(await response.json()); const snapshot = normalizeClaudeQuota( payload, credential.subscriptionType ?? "unknown", ); if (this.isDisposed()) return true; if (!snapshot) return false; this.latest = snapshot; } catch { return false; } await this.changed().catch(() => {}); return true; } private async fetchUsage(accessToken: string) { const controller = new AbortController(); this.controller = controller; const timeout = setTimeout(() => { controller.abort(); }, TIMEOUT_MS); try { return await this.fetcher(USAGE_URL, { headers: { Authorization: `Bearer ${accessToken}`, "anthropic-beta": "oauth-2025-04-20", Accept: "application/json", }, signal: controller.signal, }); } finally { clearTimeout(timeout); if (this.controller === controller) this.controller = undefined; } } /** Reads the flag through a call so checks after `await` are not narrowed away. */ private isDisposed() { return this.disposed; } 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; } }