Files
stanp-opencode/src/claude-quota.ts
T
StanPonomarev a3c5ae5c42
CI / verify (pull_request) Successful in 11s
fix: claude limits missing sometimes
2026-10-08 07:12:33 +02:00

230 lines
7.4 KiB
TypeScript

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;
}
}