@@ -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
@@ -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
@@ -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
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user