pi-squire 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/usage.ts ADDED
@@ -0,0 +1,432 @@
1
+ // Proactive quota checks: read a provider's usage before a launch, so a model
2
+ // whose quota is used up is skipped without spending a request. OpenCode Go and
3
+ // Anthropic models used through a Claude subscription login. A failed reading
4
+ // never throws and never blocks a launch.
5
+ import { spawn } from "node:child_process";
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { homedir, tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { isFree, type Mark, readMarks, recordProactive, removeMark } from "./limits.ts";
10
+
11
+ const PROVIDER = "opencode-go";
12
+ const CLAUDE = "anthropic";
13
+ /** A Claude cache older than this is refreshed, and the refresh runs at most this often. */
14
+ const CLAUDE_FETCH_AFTER_MS = 5 * 60_000;
15
+ const CLAUDE_TIMEOUT_MS = 30_000;
16
+ /** How long one reading is reused, so a burst of launches makes one request. */
17
+ const CACHE_MS = 60_000;
18
+ /** A marked provider is re-checked for an early clear at most this often. */
19
+ const RECHECK_MS = 10 * 60_000;
20
+ const HOUR_MS = 3_600_000;
21
+ const DAY_MS: number = 24 * HOUR_MS;
22
+
23
+ // --- Twin of ../dotfiles/pi/extensions/usage-status.ts (parseGoUsage, resetTime,
24
+ // --- num, goOriginAllowed, the fetch in refreshGo, parseClaudeCache,
25
+ // --- scopedApplies and refreshClaudeCache). Copied, not shared; keep the two in
26
+ // --- step until the user decides how to package them. Differences: Claude
27
+ // --- windows carry no credits (they never block); usage-status.ts
28
+ // --- reads nothing when CLAUDE_CONFIG_DIR is unset, while claudeCachePath falls
29
+ // --- back to ~/.claude.json, Claude Code's default location, so the check works
30
+ // --- without the user's alias (refreshClaude then spawns only when
31
+ // --- ~/.claude.json or ~/.claude exists, so it never runs for a user without
32
+ // --- Claude Code); and the 5-minute rate limit on refreshes lives in
33
+ // --- createProactive, not here.
34
+
35
+ const GO_TIMEOUT_MS = 20_000;
36
+ const GO_ORIGIN = "https://opencode.ai";
37
+ const GO_USAGE_URL = `${GO_ORIGIN}/zen/go/v1/usage`;
38
+
39
+ /** One quota window; `limited` is the provider's own `rate-limited` status. */
40
+ export type UsageWindow = {
41
+ label: string;
42
+ percent: number | null;
43
+ resetsAt: number | null;
44
+ limited: boolean;
45
+ /** The window's full length, which with `resetsAt` gives the start of its cycle. */
46
+ windowMs?: number;
47
+ };
48
+
49
+ function num(value: unknown): number | null {
50
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
51
+ }
52
+
53
+ /** Epoch ms from an ISO string or epoch seconds. */
54
+ function resetTime(value: unknown): number | null {
55
+ if (typeof value === "string" && value.trim()) {
56
+ const ms = Date.parse(value);
57
+ return Number.isNaN(ms) ? null : ms;
58
+ }
59
+ const seconds = num(value);
60
+ return seconds === null ? null : seconds * 1000;
61
+ }
62
+
63
+ export function parseGoUsage(payload: unknown): UsageWindow[] | null {
64
+ const usage = (
65
+ payload as { usage?: Record<string, { status?: unknown; percent?: unknown; resetsAt?: unknown }> } | null
66
+ )?.usage;
67
+ if (typeof usage !== "object" || usage === null) return null;
68
+ const windows: UsageWindow[] = [];
69
+ for (const [key, label, windowMs] of [
70
+ ["rolling", "5h", 5 * HOUR_MS],
71
+ ["weekly", "wk", 7 * DAY_MS],
72
+ ["monthly", "mo", 30 * DAY_MS],
73
+ ] as const) {
74
+ const raw = usage[key];
75
+ const ok = raw?.status === "ok" || raw?.status === "rate-limited";
76
+ windows.push({
77
+ label,
78
+ percent: ok ? num(raw?.percent) : null,
79
+ resetsAt: ok ? resetTime(raw?.resetsAt) : null,
80
+ limited: raw?.status === "rate-limited",
81
+ windowMs,
82
+ });
83
+ }
84
+ return windows.some((w) => w.percent !== null || w.limited) ? windows : null;
85
+ }
86
+
87
+ /** Every configured OpenCode Go base URL must stay on the origin the key belongs to. */
88
+ export function goOriginAllowed(urls: (string | undefined)[]): boolean {
89
+ return urls.every((url) => !url || !URL.canParse(url) || new URL(url).origin === GO_ORIGIN);
90
+ }
91
+
92
+ export type ClaudeUsage = {
93
+ fiveHour: UsageWindow;
94
+ sevenDay: UsageWindow;
95
+ scoped: UsageWindow | null;
96
+ fetchedAt: number | null;
97
+ };
98
+
99
+ type ClaudeRow = {
100
+ kind?: string;
101
+ percent?: unknown;
102
+ resets_at?: unknown;
103
+ scope?: { model?: { display_name?: unknown } };
104
+ };
105
+ type ClaudeLegacy = { utilization?: unknown; resets_at?: unknown };
106
+
107
+ export function parseClaudeCache(state: unknown): ClaudeUsage | null {
108
+ const entry = (
109
+ state as {
110
+ cachedUsageUtilization?: {
111
+ fetchedAtMs?: unknown;
112
+ utilization?: { limits?: unknown; five_hour?: ClaudeLegacy; seven_day?: ClaudeLegacy };
113
+ };
114
+ } | null
115
+ )?.cachedUsageUtilization;
116
+ const u = entry?.utilization;
117
+ if (typeof u !== "object" || u === null) return null;
118
+ const rows: ClaudeRow[] = Array.isArray(u.limits) ? u.limits : [];
119
+ const row = (kind: string): ClaudeRow | undefined => rows.find((r) => r?.kind === kind);
120
+ const window = (label: string, kind: string, legacy: ClaudeLegacy | undefined, windowMs: number): UsageWindow => {
121
+ const r = row(kind);
122
+ return {
123
+ label,
124
+ percent: num(r?.percent) ?? num(legacy?.utilization),
125
+ resetsAt: resetTime(r?.resets_at) ?? resetTime(legacy?.resets_at),
126
+ limited: false,
127
+ windowMs,
128
+ };
129
+ };
130
+ const scopedRow = row("weekly_scoped");
131
+ const scopedName = scopedRow?.scope?.model?.display_name;
132
+ return {
133
+ fiveHour: window("5h", "session", u.five_hour, 5 * HOUR_MS),
134
+ sevenDay: window("7d", "weekly_all", u.seven_day, 7 * DAY_MS),
135
+ scoped:
136
+ typeof scopedName === "string" && scopedName
137
+ ? {
138
+ label: scopedName,
139
+ percent: num(scopedRow?.percent),
140
+ resetsAt: resetTime(scopedRow?.resets_at),
141
+ limited: false,
142
+ windowMs: 7 * DAY_MS,
143
+ }
144
+ : null,
145
+ fetchedAt: num(entry?.fetchedAtMs),
146
+ };
147
+ }
148
+
149
+ /** The scoped week belongs to a model when its name contains the scope label. */
150
+ export function scopedApplies(scopedLabel: string, model: { name?: string; id?: string } | undefined): boolean {
151
+ const needle = scopedLabel.toLowerCase();
152
+ return [model?.name, model?.id].some((s) => typeof s === "string" && s.toLowerCase().includes(needle));
153
+ }
154
+
155
+ function claudeCachePath(): string {
156
+ const dir = process.env.CLAUDE_CONFIG_DIR;
157
+ return dir ? join(dir, ".claude.json") : join(homedir(), ".claude.json");
158
+ }
159
+
160
+ // --- End of the copied section.
161
+
162
+ export type ClaudeReading = ({ ok: true } & ClaudeUsage) | { ok: false; reason: string };
163
+
164
+ export type GoReading = { ok: true; windows: UsageWindow[] } | { ok: false; reason: string };
165
+
166
+ /** The test seam: reads usage. Implementations never throw. */
167
+ export type UsageClient = {
168
+ /** Read Go usage with `key`; `baseUrls` are every base URL configured for the provider, all of which must be on the Go origin. */
169
+ readGo(key: string | undefined, baseUrls: (string | undefined)[]): Promise<GoReading>;
170
+ /** Read Claude Code's usage cache as it stands; never waits for a refresh. */
171
+ readClaude(): ClaudeReading;
172
+ /** Start `claude -p /usage` in the background so Claude Code rewrites its cache. Fire and forget; does nothing without Claude Code state (see the guard). */
173
+ refreshClaude(): void;
174
+ };
175
+
176
+ export function createUsageClient(): UsageClient {
177
+ return {
178
+ readClaude(): ClaudeReading {
179
+ try {
180
+ const usage = parseClaudeCache(JSON.parse(readFileSync(claudeCachePath(), "utf8")));
181
+ return usage ? { ok: true, ...usage } : { ok: false, reason: "no usage in Claude Code's cache" };
182
+ } catch (err) {
183
+ return { ok: false, reason: `cache unreadable: ${String((err as Error)?.message).slice(0, 100)}` };
184
+ }
185
+ },
186
+ refreshClaude(): void {
187
+ try {
188
+ // Never spawn without Claude Code state: the config directory, or with
189
+ // CLAUDE_CONFIG_DIR unset, ~/.claude.json or ~/.claude.
190
+ const dir = process.env.CLAUDE_CONFIG_DIR;
191
+ if (
192
+ !(dir
193
+ ? existsSync(dir)
194
+ : existsSync(join(homedir(), ".claude.json")) || existsSync(join(homedir(), ".claude")))
195
+ )
196
+ return;
197
+ const child = spawn("claude", ["-p", "/usage", "--no-session-persistence"], { cwd: tmpdir(), stdio: "ignore" });
198
+ const kill = setTimeout(() => child.kill(), CLAUDE_TIMEOUT_MS);
199
+ kill.unref();
200
+ const done = () => clearTimeout(kill);
201
+ child.on("error", done); // a missing `claude` is not worth a word
202
+ child.on("close", done);
203
+ child.unref();
204
+ } catch {
205
+ // Fire and forget: a failed spawn costs nothing.
206
+ }
207
+ },
208
+ async readGo(key: string | undefined, baseUrls: (string | undefined)[]): Promise<GoReading> {
209
+ if (!goOriginAllowed(baseUrls)) return { ok: false, reason: `a base URL is not on ${GO_ORIGIN}; key not sent` };
210
+ if (!key) return { ok: false, reason: "no API key" };
211
+ try {
212
+ const response = await fetch(GO_USAGE_URL, {
213
+ headers: { Authorization: `Bearer ${key}`, Accept: "application/json" },
214
+ redirect: "error",
215
+ signal: AbortSignal.timeout(GO_TIMEOUT_MS),
216
+ });
217
+ if (!response.ok) return { ok: false, reason: `HTTP ${response.status}` };
218
+ const windows = parseGoUsage(await response.json());
219
+ return windows ? { ok: true, windows } : { ok: false, reason: "unreadable response" };
220
+ } catch (err) {
221
+ const e = err as Error;
222
+ return {
223
+ ok: false,
224
+ reason: e?.name === "TimeoutError" ? "timed out" : `network error: ${String(e?.message).slice(0, 100)}`,
225
+ };
226
+ }
227
+ },
228
+ };
229
+ }
230
+
231
+ /** A provider's last failed reading. */
232
+ export type ReadingError = { provider: string; reason: string; at: string };
233
+
234
+ /** The slice of Pi's model registry the check reads. */
235
+ type Registry = {
236
+ getApiKeyForProvider(provider: string): Promise<string | undefined>;
237
+ getProvider(provider: string): { baseUrl?: string } | undefined;
238
+ find(
239
+ provider: string,
240
+ id: string,
241
+ ):
242
+ | {
243
+ baseUrl?: string;
244
+ name?: string;
245
+ id?: string;
246
+ provider?: string;
247
+ cost?: { input?: number; output?: number };
248
+ }
249
+ | undefined;
250
+ isUsingOAuth(model: { provider?: string; id?: string }): boolean;
251
+ };
252
+
253
+ /** The windows that are used up, recorded as one proactive mark on `provider`. */
254
+ function recordFull(scope: string, windows: UsageWindow[], now: number, name = "Go"): Mark | undefined {
255
+ // A window at 100% whose reset time has passed has reset; the reading is stale.
256
+ const full = windows.filter(
257
+ (w) => w.limited || ((w.percent ?? 0) >= 100 && (w.resetsAt === null || w.resetsAt > now)),
258
+ );
259
+ if (full.length === 0) return undefined;
260
+ const resets = full.map((w) => w.resetsAt).filter((t): t is number => t !== null && t > now);
261
+ const reason = full
262
+ .map((w) =>
263
+ w.percent === null ? `${name} ${w.label} window rate-limited` : `${name} ${w.label} window at ${w.percent}%`,
264
+ )
265
+ .join(", ");
266
+ return recordProactive(scope, reason, resets.length > 0 ? Math.max(...resets) : undefined, now);
267
+ }
268
+
269
+ type Rechecker = {
270
+ readGo(models: Registry, provider: string, id: string, now: number): Promise<GoReading>;
271
+ readClaude(now: number): ClaudeReading;
272
+ /** When each provider was last re-checked, in memory only. */
273
+ rechecked: Map<string, number>;
274
+ };
275
+
276
+ /** The provider's windows, and the scoped one when it applies to the candidate; undefined when unreadable. */
277
+ async function windowsFor(
278
+ r: Rechecker,
279
+ models: Registry,
280
+ provider: string,
281
+ id: string,
282
+ now: number,
283
+ ): Promise<{ provider: UsageWindow[]; scoped?: UsageWindow } | undefined> {
284
+ if (provider === PROVIDER) {
285
+ const reading = await r.readGo(models, provider, id, now);
286
+ return reading.ok ? { provider: reading.windows } : undefined;
287
+ }
288
+ const model = models.find(CLAUDE, id);
289
+ if (!model || !models.isUsingOAuth(model)) return undefined;
290
+ const reading = r.readClaude(now);
291
+ if (!reading.ok) return undefined;
292
+ const { scoped } = reading;
293
+ return {
294
+ provider: [reading.fiveHour, reading.sevenDay],
295
+ scoped: scoped && scopedApplies(scoped.label, model) ? scoped : undefined,
296
+ };
297
+ }
298
+
299
+ /** Whether `w` began a new cycle after `at`; a window without a reset time or length cannot say. */
300
+ const resetSince = (w: UsageWindow | undefined, at: number): boolean =>
301
+ w !== undefined && w.resetsAt !== null && !!w.windowMs && w.resetsAt - w.windowMs > at;
302
+
303
+ /**
304
+ * Clear the active marks on `provider/id` whose quota has reset since they were
305
+ * recorded, at most once per provider every 10 minutes. Headroom alone proves
306
+ * nothing: every applying window must be under 100%, and for a provider mark one
307
+ * of them must have begun a new cycle after the mark (for a model mark, the
308
+ * scoped window). A failed reading keeps the marks.
309
+ */
310
+ async function recheckMarks(r: Rechecker, models: Registry, provider: string, id: string): Promise<string[]> {
311
+ const now = Date.now();
312
+ const marks = readMarks(now).filter(
313
+ (m) =>
314
+ m.clearsAt > now &&
315
+ now - m.recordedAt >= RECHECK_MS && // a mark this fresh is itself a recent check
316
+ (m.scope === `${provider}/${id}` || (m.scope === provider && !isFree(models, provider, id))),
317
+ );
318
+ if (marks.length === 0 || (provider !== PROVIDER && provider !== CLAUDE)) return [];
319
+ if (now - (r.rechecked.get(provider) ?? Number.NEGATIVE_INFINITY) < RECHECK_MS) return [];
320
+ r.rechecked.set(provider, now);
321
+ const found = await windowsFor(r, models, provider, id, now);
322
+ if (!found) return [];
323
+ const windows = [...found.provider, ...(found.scoped ? [found.scoped] : [])].filter(
324
+ (w) => w.limited || w.percent !== null,
325
+ );
326
+ if (windows.some((w) => w.limited || (w.percent ?? 0) >= 100)) return [];
327
+ return marks
328
+ .filter((m) =>
329
+ m.scope.includes("/") ? resetSince(found.scoped, m.recordedAt) : windows.some((w) => resetSince(w, m.recordedAt)),
330
+ )
331
+ .filter((m) => removeMark(m.scope, now))
332
+ .map((m) => `Cleared the usage-limit mark on ${m.scope}: its quota has reset since the mark was recorded.`);
333
+ }
334
+
335
+ /** The mark a Claude reading calls for on `anthropic/id`: the 5h or 7d window used up, else the scoped week when it names the model. */
336
+ function claudeMark(
337
+ reading: ClaudeUsage,
338
+ model: { name?: string; id?: string },
339
+ id: string,
340
+ now: number,
341
+ ): Mark | undefined {
342
+ const { scoped } = reading;
343
+ return (
344
+ recordFull(CLAUDE, [reading.fiveHour, reading.sevenDay], now, "Claude") ??
345
+ (scoped && scopedApplies(scoped.label, model) ? recordFull(`${CLAUDE}/${id}`, [scoped], now, "Claude") : undefined)
346
+ );
347
+ }
348
+
349
+ /** Proactive checks over one usage client, with their cache and last reading errors. */
350
+ export type Proactive = {
351
+ errors(): ReadingError[];
352
+ /** Forget cached readings, so the next check reads afresh. */
353
+ clear(): void;
354
+ check(models: Registry, provider: string, id: string): Promise<{ notice?: string; mark?: Mark }>;
355
+ /** Clear marks whose quota has reset since they were recorded; a line per mark cleared. */
356
+ recheck(models: Registry, provider: string, id: string): Promise<string[]>;
357
+ };
358
+
359
+ export function createProactive(client: UsageClient): Proactive {
360
+ let refreshedAt = Number.NEGATIVE_INFINITY;
361
+ let cached: { at: number; reading: GoReading } | undefined;
362
+ const lastErrors = new Map<string, ReadingError>();
363
+ const rechecked = new Map<string, number>();
364
+
365
+ async function read(models: Registry, provider: string, id: string, now: number): Promise<GoReading> {
366
+ if (cached !== undefined && now - cached.at < CACHE_MS) return cached.reading;
367
+ let reading: GoReading;
368
+ try {
369
+ reading = await client.readGo(await models.getApiKeyForProvider(provider), [
370
+ models.getProvider(provider)?.baseUrl,
371
+ models.find(provider, id)?.baseUrl,
372
+ ]);
373
+ } catch (err) {
374
+ reading = { ok: false, reason: `error: ${String((err as Error)?.message).slice(0, 100)}` };
375
+ }
376
+ cached = { at: now, reading };
377
+ if (reading.ok) lastErrors.delete(provider);
378
+ else lastErrors.set(provider, { provider, reason: reading.reason, at: new Date(now).toISOString() });
379
+ return reading;
380
+ }
381
+
382
+ /** Have Claude Code refetch its cache, at most every 5 minutes per process, never waited for. */
383
+ function refreshClaude(now: number): void {
384
+ if (now - refreshedAt < CLAUDE_FETCH_AFTER_MS) return;
385
+ refreshedAt = now;
386
+ try {
387
+ client.refreshClaude();
388
+ } catch {
389
+ // A refresh never blocks a launch.
390
+ }
391
+ }
392
+
393
+ /** Read Claude's cache, refreshing a stale one in the background, and keep the reading error. */
394
+ function readClaude(now: number): ClaudeReading {
395
+ const reading = client.readClaude();
396
+ if (!(reading.ok && reading.fetchedAt !== null && now - reading.fetchedAt <= CLAUDE_FETCH_AFTER_MS))
397
+ refreshClaude(now);
398
+ if (reading.ok) lastErrors.delete(CLAUDE);
399
+ else lastErrors.set(CLAUDE, { provider: CLAUDE, reason: reading.reason, at: new Date(now).toISOString() });
400
+ return reading;
401
+ }
402
+
403
+ /** Claude subscription (OAuth) logins only; API-key users rely on reactive marks. */
404
+ function checkClaude(models: Registry, id: string, now: number): { notice?: string; mark?: Mark } {
405
+ const model = models.find(CLAUDE, id);
406
+ if (!model || !models.isUsingOAuth(model)) return {};
407
+ const reading = readClaude(now);
408
+ if (!reading.ok)
409
+ return { notice: `Could not read ${CLAUDE} quota: ${reading.reason}; launched without a proactive check.` };
410
+ return { mark: claudeMark(reading, model, id, now) };
411
+ }
412
+
413
+ return {
414
+ /** The last failed reading per provider. */
415
+ errors: (): ReadingError[] => [...lastErrors.values()],
416
+ clear: (): void => {
417
+ cached = undefined;
418
+ },
419
+ recheck: (models: Registry, provider: string, id: string): Promise<string[]> =>
420
+ recheckMarks({ readGo: read, readClaude, rechecked }, models, provider, id),
421
+ /** Check `provider/id` against its quota: a `proactive` mark when a window is used up, or the fail-open `notice`. */
422
+ async check(models: Registry, provider: string, id: string): Promise<{ notice?: string; mark?: Mark }> {
423
+ const now = Date.now();
424
+ if (provider === CLAUDE) return checkClaude(models, id, now);
425
+ if (provider !== PROVIDER) return {};
426
+ const reading = await read(models, provider, id, now);
427
+ if (!reading.ok)
428
+ return { notice: `Could not read ${provider} quota: ${reading.reason}; launched without a proactive check.` };
429
+ return { mark: recordFull(provider, reading.windows, now) };
430
+ },
431
+ };
432
+ }