From f6c69c7db064a1b7524f2a44111792f9d6b3c995 Mon Sep 17 00:00:00 2001 From: StanPonomarev Date: Wed, 7 Oct 2026 14:49:00 +0200 Subject: [PATCH] feat: claude quotas --- README.md | 2 + src/claude-quota.ts | 200 +++++++++++++++++++++++++++++++++++++ src/quota-plugin.ts | 10 +- src/quota-rpc.ts | 12 +-- src/tui.tsx | 42 ++++++-- tests/claude-quota.test.ts | 158 +++++++++++++++++++++++++++++ 6 files changed, 405 insertions(+), 19 deletions(-) create mode 100644 src/claude-quota.ts create mode 100644 tests/claude-quota.test.ts diff --git a/README.md b/README.md index 9abb5a2..3fd3e5a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ opencode service restart 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 From a local checkout, run: diff --git a/src/claude-quota.ts b/src/claude-quota.ts new file mode 100644 index 0000000..c3433ee --- /dev/null +++ b/src/claude-quota.ts @@ -0,0 +1,200 @@ +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; + } +} diff --git a/src/quota-plugin.ts b/src/quota-plugin.ts index c44b9da..9641eff 100644 --- a/src/quota-plugin.ts +++ b/src/quota-plugin.ts @@ -1,4 +1,5 @@ import { Plugin } from "@opencode/plugin"; +import { ClaudeQuotaTracker } from "./claude-quota.js"; import { QuotaTracker } from "./quota.js"; import { QuotaRpc } from "./quota-rpc.js"; @@ -9,9 +10,14 @@ export default Plugin.define({ const quota = new QuotaTracker(context, async () => { await registration.events.emit("updated", {}); }); + const claude = new ClaudeQuotaTracker(async () => { + await registration.events.emit("updated", {}); + }); const registration = await context.rpc.register(QuotaRpc, { status: async () => quota.status() ?? null, + claude: async () => claude.status() ?? null, }); + void claude.init(); await quota.init(); const updates = (async () => { 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.idle" ) { - await quota.complete(event.data.sessionID); + await Promise.all([quota.complete(event.data.sessionID), claude.refresh()]); } else if (event.type === "credential.updated") { await quota.authChanged(); } else if ( @@ -47,7 +53,7 @@ export default Plugin.define({ return async () => { controller.abort(); await updates; - await quota.dispose(); + await Promise.all([quota.dispose(), claude.dispose()]); await registration.dispose(); }; }, diff --git a/src/quota-rpc.ts b/src/quota-rpc.ts index c9b78bd..9577409 100644 --- a/src/quota-rpc.ts +++ b/src/quota-rpc.ts @@ -10,15 +10,15 @@ const window = z.object({ remaining: z.number(), }); +const status = z + .object({ plan: z.string(), fetchedAt: z.number(), windows: z.array(window) }) + .nullable(); + export const QuotaRpc = Rpc.define({ id: "stanp-opencode.quota", methods: { - status: { - input: z.object({}), - output: z - .object({ plan: z.string(), fetchedAt: z.number(), windows: z.array(window) }) - .nullable(), - }, + status: { input: z.object({}), output: status }, + claude: { input: z.object({}), output: status }, }, events: { updated: { schema: z.object({}) } }, }); diff --git a/src/tui.tsx b/src/tui.tsx index 1366658..4272845 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -61,24 +61,44 @@ export default Plugin.define({ }); const Slot = (props: { instance: string; centered?: boolean; visible?: () => boolean }) => { - const [status] = createResource( + const [statuses] = createResource( () => (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 }) => ( + + {(status: () => QuotaStatus) => ( + <> + + + {section.title} quota ({status().plan}) + + + + {(window) => ( + + {formatQuotaWindow(window)} + + )} + + + )} + ); const Content = () => ( - - ChatGPT quota ({status()!.plan}) - - - {(window) => ( - {formatQuotaWindow(window)} - )} - +
+
); return ( - + }> diff --git a/tests/claude-quota.test.ts b/tests/claude-quota.test.ts new file mode 100644 index 0000000..88512a4 --- /dev/null +++ b/tests/claude-quota.test.ts @@ -0,0 +1,158 @@ +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, + ); + 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(); +});