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 WEEK = 604_800; type QuotaFetch = (input: string | URL | Request, init?: RequestInit) => Promise; 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 | undefined; private request: Promise | undefined; private controller: AbortController | undefined; private disposed = false; constructor( private readonly changed: () => Promise, private readonly fetcher: QuotaFetch = fetch, private readonly pollMs = POLL_MS, private readonly path = claudeCredentialsPath(), ) {} 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() { try { let credential: z.infer["claudeAiOauth"] | undefined; try { const parsed = credentials.safeParse(JSON.parse(await readFile(this.path, "utf8"))); credential = parsed.success ? parsed.data.claudeAiOauth : undefined; } catch { credential = undefined; } if (!credential) { if (this.latest) { this.latest = undefined; await this.changed(); } return; } if (credential.expiresAt <= Date.now() || this.disposed) return; 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; // 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 (!snapshot || this.disposed) return; this.latest = snapshot; await this.changed(); } catch { return; } } 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(); if (this.timer) clearInterval(this.timer); this.timer = undefined; await this.request; } }