@cad0p/pi-usage 0.0.0-stage → 0.11.2

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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +509 -2
  3. package/bin/piquota.js +199 -0
  4. package/extensions/quota-panel.ts +963 -0
  5. package/package.json +59 -4
  6. package/src/auth/claude-code-auth.js +490 -0
  7. package/src/auth/jwt.js +84 -0
  8. package/src/auth/pi-auth.js +424 -0
  9. package/src/browser/cookies.js +557 -0
  10. package/src/browser/history.js +173 -0
  11. package/src/cache.js +224 -0
  12. package/src/cli/args.js +154 -0
  13. package/src/cli/commands/auth.js +216 -0
  14. package/src/cli/commands/gentle-pi.js +44 -0
  15. package/src/cli/commands/omarchy.js +75 -0
  16. package/src/cli/explain.js +99 -0
  17. package/src/cli/output.js +34 -0
  18. package/src/engine.js +404 -0
  19. package/src/exec.js +31 -0
  20. package/src/gentle-pi/rail-patch.js +335 -0
  21. package/src/http.js +182 -0
  22. package/src/model.js +332 -0
  23. package/src/omarchy/assets/antigravity.svg +3 -0
  24. package/src/omarchy/assets/claude.svg +1 -0
  25. package/src/omarchy/assets/codex.svg +1 -0
  26. package/src/omarchy/assets/github-actions.svg +3 -0
  27. package/src/omarchy/assets/opencode-go.svg +3 -0
  28. package/src/omarchy/publish.js +152 -0
  29. package/src/omarchy/record.js +462 -0
  30. package/src/opencode/dashboard.js +337 -0
  31. package/src/opencode/session.js +485 -0
  32. package/src/providers/antigravity-oauth.js +173 -0
  33. package/src/providers/antigravity.js +225 -0
  34. package/src/providers/backoff.js +146 -0
  35. package/src/providers/claude.js +194 -0
  36. package/src/providers/codex.js +151 -0
  37. package/src/providers/error-kind.js +128 -0
  38. package/src/providers/github-actions.js +379 -0
  39. package/src/providers/opencode-go.js +274 -0
  40. package/src/refresh.js +292 -0
  41. package/src/render/panel.js +411 -0
  42. package/src/render/theme.js +102 -0
  43. package/src/sticky.js +319 -0
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Antigravity (Google AI Pro / Cloud Code) quota from a Pi OAuth access token.
3
+ *
4
+ * Endpoint: POST https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary
5
+ * Body: { "project": "<cloudaicompanionProject>" }
6
+ *
7
+ * The CLI `User-Agent` is mandatory: without it the Cloud Code backend answers
8
+ * 403 "You do not have a valid license of this product" even for a healthy
9
+ * token. Verbose but verified.
10
+ *
11
+ * Response shape: `groups[].buckets[]` with
12
+ * `{ bucketId, displayName, window, resetTime, remainingFraction, description }`.
13
+ *
14
+ * Google does not rotate this refresh token, but Pi owns the credential and this
15
+ * project is read-only, so no refresh is performed here either.
16
+ */
17
+
18
+ import { buildWindow, clampPercent, degradedResult, displayIdentity, finiteNumber, stringValue, windowFromName } from "../model.js";
19
+ import { requestJson } from "../http.js";
20
+ import { describeOAuthClient, ensureFreshAccessToken } from "./antigravity-oauth.js";
21
+
22
+ const CLOUD_CODE_BASES = [
23
+ "https://daily-cloudcode-pa.googleapis.com",
24
+ "https://cloudcode-pa.googleapis.com",
25
+ ];
26
+ const CLI_USER_AGENT =
27
+ "antigravity/cli/1.1.13 (aidev_client; os_type=linux; arch=amd64; cl=964361259; auth_method=consumer)";
28
+
29
+ /**
30
+ * @param {unknown} value
31
+ * @returns {Record<string, unknown> | null}
32
+ */
33
+ function record(value) {
34
+ return value && typeof value === "object" && !Array.isArray(value)
35
+ ? /** @type {Record<string, unknown>} */ (value)
36
+ : null;
37
+ }
38
+
39
+ /**
40
+ * Resolve a Cloud Code project id through loadCodeAssist.
41
+ *
42
+ * @param {string} access
43
+ * @param {string} base
44
+ * @param {typeof fetch | undefined} fetchFn
45
+ * @param {number | undefined} timeoutMs
46
+ * @returns {Promise<string | null>}
47
+ */
48
+ async function resolveProjectId(access, base, fetchFn, timeoutMs) {
49
+ const response = await requestJson(`${base}/v1internal:loadCodeAssist`, {
50
+ method: "POST",
51
+ headers: {
52
+ Authorization: `Bearer ${access}`,
53
+ "Content-Type": "application/json",
54
+ Accept: "application/json",
55
+ "User-Agent": CLI_USER_AGENT,
56
+ },
57
+ body: JSON.stringify({ metadata: { ideType: "ANTIGRAVITY" } }),
58
+ fetchFn,
59
+ timeoutMs,
60
+ });
61
+ if (!response.ok) return null;
62
+ const body = record(response.body);
63
+ const project = body?.cloudaicompanionProject;
64
+ if (typeof project === "string" && project) return project;
65
+ const projectRecord = record(project);
66
+ return stringValue(projectRecord?.id) ?? stringValue(projectRecord?.name);
67
+ }
68
+
69
+ /**
70
+ * Short group tag so multi-group accounts read as "Gemini · 5h window" and
71
+ * "Claude/GPT · 5h window" instead of two identical rows.
72
+ *
73
+ * @param {string} groupName
74
+ * @returns {string}
75
+ */
76
+ function shortGroup(groupName) {
77
+ const text = groupName.toLowerCase();
78
+ if (text.includes("gemini")) return "Gemini";
79
+ if (text.includes("claude") || text.includes("gpt")) return "Claude/GPT";
80
+ return groupName.split(/\s+/)[0] || "Models";
81
+ }
82
+
83
+ /**
84
+ * @param {Record<string, unknown>} bucket
85
+ * @param {{ groupName: string, multiGroup: boolean }} context
86
+ * @param {number} now
87
+ * @returns {import("../model.js").QuotaWindow | null}
88
+ */
89
+ function windowFromBucket(bucket, context, now) {
90
+ const remainingFraction = finiteNumber(bucket.remainingFraction ?? bucket.remaining_fraction);
91
+ if (remainingFraction === null) return null;
92
+
93
+ const windowName = stringValue(bucket.window) ?? stringValue(bucket.bucketId) ?? "";
94
+ const mapped = windowFromName(windowName.replace(/^[a-z]+-/, ""));
95
+ const displayName = stringValue(bucket.displayName);
96
+ const description = stringValue(bucket.description);
97
+ const tag = context.multiGroup ? `${shortGroup(context.groupName)} · ` : "";
98
+ // Short, uniform vocabulary so Antigravity's windows read like the other
99
+ // providers'; the API's own wording is preserved in the note.
100
+ const kind = mapped.id === "5h" || mapped.id === "weekly" || mapped.id === "monthly" ? mapped.id : mapped.label;
101
+
102
+ return buildWindow({
103
+ id: context.multiGroup ? `${shortGroup(context.groupName).toLowerCase().replace(/[^a-z]+/g, "-")}-${mapped.id}` : mapped.id,
104
+ label: `${tag}${kind}`,
105
+ remainingPercent: clampPercent(remainingFraction * 100),
106
+ resetsAt: stringValue(bucket.resetTime),
107
+ note: [displayName, description].filter(Boolean).join(" — ") || null,
108
+ now,
109
+ });
110
+ }
111
+
112
+ /**
113
+ * @param {import("../auth/pi-auth.js").PiCredential} credential
114
+ * @param {{ now?: number, fetchFn?: typeof fetch, timeoutMs?: number, expiresInMin?: number | null, env?: Record<string, string | undefined>, refresh?: boolean }} [options]
115
+ * @returns {Promise<import("../model.js").QuotaResult>}
116
+ */
117
+ export async function fetchQuota(credential, options = {}) {
118
+ const now = options.now ?? Date.now();
119
+ const base = {
120
+ family: "antigravity",
121
+ label: "Antigravity (Pi)",
122
+ account: displayIdentity(credential),
123
+ source: credential.source,
124
+ sourceKind: credential.sourceKind ?? "pi",
125
+ expiresInMin: options.expiresInMin ?? null,
126
+ };
127
+
128
+ if (!credential.access && !credential.refresh) {
129
+ return degradedResult({ ...base, error: "Pi store has no antigravity credential; run /login antigravity in Pi" });
130
+ }
131
+
132
+ const fetchFn = options.fetchFn;
133
+ const account = { access: credential.access, refresh: credential.refresh, expiresAtMs: credential.expiresAtMs ?? null };
134
+
135
+ // In-memory refresh only: Google does not rotate this refresh token, so the
136
+ // copy Pi stores stays valid and nothing is written back.
137
+ const freshness = await ensureFreshAccessToken(account, {
138
+ fetchFn,
139
+ timeoutMs: options.timeoutMs,
140
+ now,
141
+ enabled: options.refresh,
142
+ });
143
+ let projectId = credential.projectId ?? null;
144
+
145
+ /** @type {string | null} */
146
+ let lastError = null;
147
+ let authFailed = false;
148
+
149
+ for (const cloudBase of CLOUD_CODE_BASES) {
150
+ if (!projectId && account.access) {
151
+ projectId = await resolveProjectId(account.access, cloudBase, fetchFn, options.timeoutMs);
152
+ }
153
+ if (!projectId) {
154
+ lastError = "Antigravity credential has no Cloud Code project id and loadCodeAssist did not return one";
155
+ continue;
156
+ }
157
+ if (!account.access) {
158
+ lastError = freshness.error
159
+ ? `Antigravity access token unusable: ${freshness.error}`
160
+ : "Antigravity access token is missing from the Pi store";
161
+ continue;
162
+ }
163
+
164
+ const response = await requestJson(`${cloudBase}/v1internal:retrieveUserQuotaSummary`, {
165
+ method: "POST",
166
+ headers: {
167
+ Authorization: `Bearer ${account.access}`,
168
+ "Content-Type": "application/json",
169
+ Accept: "application/json",
170
+ "User-Agent": CLI_USER_AGENT,
171
+ },
172
+ body: JSON.stringify({ project: projectId }),
173
+ fetchFn,
174
+ timeoutMs: options.timeoutMs,
175
+ });
176
+
177
+ if (!response.ok) {
178
+ authFailed = Boolean(response.authError);
179
+ lastError = response.authError
180
+ ? "Antigravity token rejected; run /login antigravity in Pi"
181
+ : `Antigravity quota request failed: ${response.error}`;
182
+ continue;
183
+ }
184
+
185
+ authFailed = false;
186
+
187
+ const body = record(response.body) ?? {};
188
+ const groups = (Array.isArray(body.groups) ? body.groups : [])
189
+ .map((rawGroup) => record(rawGroup))
190
+ .filter((group) => Array.isArray(group?.buckets) && group.buckets.length > 0);
191
+ const multiGroup = groups.length > 1;
192
+
193
+ /** @type {import("../model.js").QuotaWindow[]} */
194
+ const windows = [];
195
+ for (const group of groups) {
196
+ if (!group) continue;
197
+ const groupName = stringValue(group.displayName) ?? "Models";
198
+ const buckets = Array.isArray(group.buckets) ? group.buckets : [];
199
+ for (const rawBucket of buckets) {
200
+ const bucket = record(rawBucket);
201
+ if (!bucket) continue;
202
+ const window = windowFromBucket(bucket, { groupName, multiGroup }, now);
203
+ if (window) windows.push(window);
204
+ }
205
+ }
206
+
207
+ if (windows.length === 0) {
208
+ lastError = "Antigravity quota response contained no buckets (free tier may expose none for this account)";
209
+ continue;
210
+ }
211
+
212
+ return {
213
+ ...base,
214
+ account: projectId ? `${displayIdentity(credential)} · ${projectId.slice(0, 8)}…` : displayIdentity(credential),
215
+ plan: stringValue(body.paidTier) ?? stringValue(body.currentTier),
216
+ windows,
217
+ error: null,
218
+ ok: true,
219
+ updatedAt: new Date(now).toISOString(),
220
+ };
221
+ }
222
+
223
+ const suffix = authFailed ? ` (${describeOAuthClient({ env: options.env })})` : "";
224
+ return degradedResult({ ...base, error: `${lastError ?? "Antigravity quota unavailable"}${suffix}` });
225
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Per-provider backoff for throttled usage endpoints.
3
+ *
4
+ * The lesson this encodes: polling Anthropic's usage endpoint once a minute
5
+ * gets it rate-limited, and the `retry-after` header is not always honest
6
+ * (`retry in 0s` while still answering 429). Without a persisted backoff, every
7
+ * process restart resumes hammering and the card stays degraded for longer than
8
+ * it needs to.
9
+ *
10
+ * A throttled family is skipped entirely until its backoff expires; the Moshi
11
+ * layer then republishes the previous good snapshot, so the card survives.
12
+ */
13
+
14
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { dirname, join } from "node:path";
17
+
18
+ const DEFAULT_BACKOFF_MS = 5 * 60 * 1000;
19
+ const MAX_BACKOFF_MS = 30 * 60 * 1000;
20
+
21
+ /**
22
+ * @param {{ env?: Record<string, string | undefined>, home?: string, path?: string }} [options]
23
+ * @returns {string}
24
+ */
25
+ export function resolveBackoffPath(options = {}) {
26
+ if (options.path) return options.path;
27
+ const env = options.env ?? process.env;
28
+ const cacheHome = options.home ? join(options.home, ".cache") : (env.XDG_CACHE_HOME || join(homedir(), ".cache"));
29
+ return join(cacheHome, "pi-quota", "backoff.json");
30
+ }
31
+
32
+ /**
33
+ * @param {{ env?: Record<string, string | undefined>, home?: string, path?: string }} [options]
34
+ * @returns {Record<string, number>}
35
+ */
36
+ export function readBackoff(options = {}) {
37
+ try {
38
+ const parsed = JSON.parse(readFileSync(resolveBackoffPath(options), "utf-8"));
39
+ return parsed && typeof parsed === "object" ? parsed : {};
40
+ } catch {
41
+ return {};
42
+ }
43
+ }
44
+
45
+ /**
46
+ * @param {Record<string, number>} state
47
+ * @param {{ env?: Record<string, string | undefined>, home?: string, path?: string }} [options]
48
+ * @returns {boolean}
49
+ */
50
+ function writeBackoff(state, options = {}) {
51
+ const path = resolveBackoffPath(options);
52
+ try {
53
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
54
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
55
+ writeFileSync(temporary, JSON.stringify(state, null, 2), { encoding: "utf-8", mode: 0o600 });
56
+ try {
57
+ chmodSync(temporary, 0o600);
58
+ } catch {
59
+ // Best-effort in environments where chmod is not supported.
60
+ }
61
+ renameSync(temporary, path);
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * @param {string} family
70
+ * @param {{ now?: number, env?: Record<string, string | undefined>, home?: string, path?: string }} [options]
71
+ * @returns {{ active: boolean, untilMs: number | null, secondsLeft: number | null }}
72
+ */
73
+ export function backoffState(family, options = {}) {
74
+ const now = options.now ?? Date.now();
75
+ const untilMs = readBackoff(options)[family] ?? null;
76
+ if (untilMs === null || untilMs <= now) {
77
+ return { active: false, untilMs, secondsLeft: null };
78
+ }
79
+ return { active: true, untilMs, secondsLeft: Math.round((untilMs - now) / 1000) };
80
+ }
81
+
82
+ /**
83
+ * Record a throttle for a family.
84
+ *
85
+ * @param {string} family
86
+ * @param {{ retryAfterSec?: number | null, now?: number, env?: Record<string, string | undefined>, home?: string, path?: string }} [options]
87
+ * @returns {{ untilMs: number, seconds: number }}
88
+ */
89
+ export function recordBackoff(family, options = {}) {
90
+ const now = options.now ?? Date.now();
91
+ const retryAfterMs =
92
+ typeof options.retryAfterSec === "number" && options.retryAfterSec > 0
93
+ ? options.retryAfterSec * 1000
94
+ : DEFAULT_BACKOFF_MS;
95
+ const duration = Math.min(Math.max(retryAfterMs, DEFAULT_BACKOFF_MS), MAX_BACKOFF_MS);
96
+ const state = readBackoff(options);
97
+ state[family] = now + duration;
98
+ writeBackoff(state, options);
99
+ return { untilMs: state[family], seconds: Math.round(duration / 1000) };
100
+ }
101
+
102
+ /**
103
+ * @param {string} family
104
+ * @param {{ env?: Record<string, string | undefined>, home?: string, path?: string }} [options]
105
+ * @returns {boolean}
106
+ */
107
+ export function clearBackoff(family, options = {}) {
108
+ const state = readBackoff(options);
109
+ if (!(family in state)) return false;
110
+ delete state[family];
111
+ return writeBackoff(state, options);
112
+ }
113
+
114
+ /**
115
+ * True when an error is a throttle worth backing off from.
116
+ *
117
+ * @param {string | null} error
118
+ * @returns {boolean}
119
+ */
120
+ export function isThrottled(error) {
121
+ return typeof error === "string" && /HTTP 429|rate limited/i.test(error);
122
+ }
123
+
124
+ /**
125
+ * True when an error represents an authentication or credential failure rather than
126
+ * a rate-limiting throttle. Authentication failures supersede throttling because
127
+ * waiting out a backoff window cannot fix an expired or invalid credential.
128
+ *
129
+ * @param {string | null} error
130
+ * @returns {boolean}
131
+ */
132
+ export function isAuthFailure(error) {
133
+ if (!error || typeof error !== "string") return false;
134
+ return /401|403|unauthorized|forbidden|token expired|sign-in expired|expired or rejected|re-authenticate|invalid_grant|no .*access token/i.test(error);
135
+ }
136
+
137
+ /**
138
+ * Extract a retry hint from an error string such as "retry in 212s".
139
+ *
140
+ * @param {string | null} error
141
+ * @returns {number | null}
142
+ */
143
+ export function retryAfterFromError(error) {
144
+ const match = typeof error === "string" ? error.match(/retry in (\d+)s/) : null;
145
+ return match ? Number(match[1]) : null;
146
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Claude (Anthropic) quota from a Pi OAuth access token.
3
+ *
4
+ * Endpoint: GET https://api.anthropic.com/api/oauth/usage
5
+ * Required: the OAuth beta header; a plain Bearer token is rejected without it.
6
+ * Windows: `five_hour` and `seven_day`, each `{ utilization, resets_at }`.
7
+ *
8
+ * Two sources feed this provider, and the caller picks one:
9
+ *
10
+ * - Pi's own `anthropic` entry in auth.json;
11
+ * - the Claude Code CLI store (`~/.claude/.credentials.json`), which the
12
+ * `pi-claude-code-provider` package drives. On a subscription whose usage
13
+ * credits are disabled, Pi's own entry can answer HTTP 400 for every request,
14
+ * and this is the source that still works.
15
+ *
16
+ * Either way no refresh is attempted. Anthropic rotates refresh tokens, so
17
+ * refreshing here would invalidate the copy the owning tool has stored — and for
18
+ * Claude Code that tool is the installed CLI, which keeps its own token fresh.
19
+ */
20
+
21
+ import { degradedResult, buildWindow, clampPercent, displayIdentity, finiteNumber, parseReset, stringValue } from "../model.js";
22
+ import { requestJson } from "../http.js";
23
+
24
+ const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
25
+
26
+ /**
27
+ * @param {unknown} value
28
+ * @returns {Record<string, unknown> | null}
29
+ */
30
+ function record(value) {
31
+ return value && typeof value === "object" && !Array.isArray(value)
32
+ ? /** @type {Record<string, unknown>} */ (value)
33
+ : null;
34
+ }
35
+
36
+ /**
37
+ * @param {Record<string, unknown>} window
38
+ * @param {string} id
39
+ * @param {string} label
40
+ * @param {string | null} note
41
+ * @param {number} now
42
+ * @returns {import("../model.js").QuotaWindow | null}
43
+ */
44
+ function windowFromObject(window, id, label, note, now) {
45
+ const used = clampPercent(window.utilization ?? window.percent ?? window.used_percent);
46
+ if (used === null) return null;
47
+ const resets = parseReset(window.resets_at, now);
48
+ return buildWindow({
49
+ id,
50
+ label,
51
+ usedPercent: used,
52
+ resetsAt: resets.resetsAt,
53
+ note,
54
+ now,
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Some accounts report windows only through the newer `limits[]` array.
60
+ *
61
+ * @param {unknown} limits
62
+ * @param {string} id
63
+ * @param {number} now
64
+ * @returns {import("../model.js").QuotaWindow | null}
65
+ */
66
+ function windowFromLimits(limits, id, now) {
67
+ if (!Array.isArray(limits)) return null;
68
+ for (const entry of limits) {
69
+ const limit = record(entry);
70
+ if (!limit || limit.is_active === false) continue;
71
+ const kind = `${stringValue(limit.kind) ?? ""} ${stringValue(limit.group) ?? ""}`.toLowerCase();
72
+ const matches =
73
+ id === "5h"
74
+ ? kind.includes("session") || kind.includes("five") || kind.includes("5h")
75
+ : kind.includes("week") || kind.includes("7d");
76
+ if (!matches) continue;
77
+ const used = clampPercent(limit.percent);
78
+ if (used === null) continue;
79
+ const resets = parseReset(limit.resets_at, now);
80
+ return buildWindow({
81
+ id,
82
+ label: id === "5h" ? "5h window" : "Weekly window",
83
+ usedPercent: used,
84
+ resetsAt: resets.resetsAt,
85
+ note: stringValue(limit.severity) ? `severity ${stringValue(limit.severity)}` : null,
86
+ now,
87
+ });
88
+ }
89
+ return null;
90
+ }
91
+
92
+ /**
93
+ * Render the extra-usage (overage) figure using the provider's own decimal
94
+ * scale, so 2908 credits with decimal_places=2 reads as "29.08 USD".
95
+ *
96
+ * @param {Record<string, unknown>} extra
97
+ * @returns {string | null}
98
+ */
99
+ function formatOverage(extra) {
100
+ if (extra.is_enabled !== true) return null;
101
+ const credits = finiteNumber(extra.used_credits);
102
+ if (credits === null) return null;
103
+ const decimals = finiteNumber(extra.decimal_places);
104
+ const amount = decimals === null ? credits : credits / Math.pow(10, decimals);
105
+ const currency = stringValue(extra.currency);
106
+ return `extra usage ${amount.toFixed(decimals ?? 0)}${currency ? ` ${currency}` : ""}`;
107
+ }
108
+
109
+ /**
110
+ * @param {import("../auth/pi-auth.js").PiCredential} credential
111
+ * @param {{ now?: number, fetchFn?: typeof fetch, timeoutMs?: number, expiresInMin?: number | null }} [options]
112
+ * @returns {Promise<import("../model.js").QuotaResult>}
113
+ */
114
+ export async function fetchQuota(credential, options = {}) {
115
+ const now = options.now ?? Date.now();
116
+ const fromClaudeCode = credential.sourceKind === "claude-code";
117
+ const base = {
118
+ family: "claude",
119
+ // The label never changes: it marks the card as ours in Moshi, and which
120
+ // store was read is reported by `sourceKind` instead, where it is diagnostic.
121
+ label: credential.label ?? "Claude (Pi)",
122
+ account: displayIdentity(credential),
123
+ source: credential.source,
124
+ sourceKind: credential.sourceKind ?? "pi",
125
+ expiresInMin: options.expiresInMin ?? null,
126
+ };
127
+
128
+ if (!credential.access) {
129
+ return degradedResult({
130
+ ...base,
131
+ error: fromClaudeCode
132
+ ? `Claude Code store has no access token; run \`claude\` once to sign in (${credential.source})`
133
+ : "Pi store has no anthropic access token; run /login anthropic in Pi",
134
+ });
135
+ }
136
+
137
+ const response = await requestJson(USAGE_URL, {
138
+ headers: {
139
+ Authorization: `Bearer ${credential.access}`,
140
+ "anthropic-version": "2023-06-01",
141
+ "anthropic-beta": "oauth-2025-04-20",
142
+ },
143
+ fetchFn: options.fetchFn,
144
+ timeoutMs: options.timeoutMs,
145
+ });
146
+
147
+ if (!response.ok) {
148
+ const error = response.authError
149
+ ? fromClaudeCode
150
+ ? "Claude Code token expired or rejected; run `claude` once to refresh it"
151
+ : "Claude token expired or rejected; use any Claude model in Pi to refresh it"
152
+ : `Claude usage request failed: ${response.error}`;
153
+ return degradedResult({ ...base, error });
154
+ }
155
+
156
+ const body = record(response.body) ?? {};
157
+ const extra = record(body.extra_usage);
158
+ const overageNote = extra ? formatOverage(extra) : null;
159
+
160
+ /** @type {import("../model.js").QuotaWindow[]} */
161
+ const windows = [];
162
+ const fiveHour = record(body.five_hour);
163
+ const sevenDay = record(body.seven_day);
164
+ const fromFiveHour = fiveHour ? windowFromObject(fiveHour, "5h", "5h window", null, now) : null;
165
+ const fromSevenDay = sevenDay ? windowFromObject(sevenDay, "weekly", "Weekly window", overageNote, now) : null;
166
+ if (fromFiveHour) windows.push(fromFiveHour);
167
+ if (fromSevenDay) windows.push(fromSevenDay);
168
+ if (!fromFiveHour) {
169
+ const fallback = windowFromLimits(body.limits, "5h", now);
170
+ if (fallback) windows.push(fallback);
171
+ }
172
+ if (!fromSevenDay) {
173
+ const fallback = windowFromLimits(body.limits, "weekly", now);
174
+ if (fallback) windows.push(fallback);
175
+ }
176
+
177
+ if (windows.length === 0) {
178
+ return degradedResult({
179
+ ...base,
180
+ error: "Claude usage response had no recognizable rate-limit windows (API shape may have changed)",
181
+ });
182
+ }
183
+
184
+ return {
185
+ ...base,
186
+ // Claude Code reports the subscription tier it signed in with; Pi's own entry
187
+ // carries no plan field at all.
188
+ plan: credential.planType ?? null,
189
+ windows,
190
+ error: null,
191
+ ok: true,
192
+ updatedAt: new Date(now).toISOString(),
193
+ };
194
+ }