Compare commits

..
Author SHA1 Message Date
StanPonomarev f6c69c7db0 feat: claude quotas
CI / verify (pull_request) Successful in 9s
2026-10-07 14:49:00 +02:00
StanPonomarev 0c7ac2b532 Merge pull request 'feat: split plugins and cleanup' (#19) from feat/split_plugins into main
Release / release (push) Successful in 11s
Reviewed-on: #19
2026-10-04 19:01:26 +02:00
6 changed files with 405 additions and 19 deletions
+2
View File
@@ -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:
+200
View File
@@ -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<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 disposed = false;
constructor(
private readonly changed: () => Promise<void>,
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<void> {
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<typeof credentials>["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;
}
}
+8 -2
View File
@@ -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();
};
},
+6 -6
View File
@@ -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({}) } },
});
+31 -11
View File
@@ -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 }) => (
<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 = () => (
<box flexDirection="column">
<text fg={context.theme.text.default}>
<b>ChatGPT quota ({status()!.plan})</b>
</text>
<For each={status()!.windows}>
{(window) => (
<text fg={context.theme.text.muted}>{formatQuotaWindow(window)}</text>
)}
</For>
<Section title="ChatGPT" status={statuses()!.chatgpt} />
<Section title="Claude" status={statuses()!.claude} />
</box>
);
return (
<Show when={status()}>
<Show when={statuses()}>
<Show when={props.centered} fallback={<Content />}>
<box position="absolute" top={2} left={0} width="100%" alignItems="center">
<Content />
+158
View File
@@ -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();
});