@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.
- package/LICENSE +21 -0
- package/README.md +509 -2
- package/bin/piquota.js +199 -0
- package/extensions/quota-panel.ts +963 -0
- package/package.json +59 -4
- package/src/auth/claude-code-auth.js +490 -0
- package/src/auth/jwt.js +84 -0
- package/src/auth/pi-auth.js +424 -0
- package/src/browser/cookies.js +557 -0
- package/src/browser/history.js +173 -0
- package/src/cache.js +224 -0
- package/src/cli/args.js +154 -0
- package/src/cli/commands/auth.js +216 -0
- package/src/cli/commands/gentle-pi.js +44 -0
- package/src/cli/commands/omarchy.js +75 -0
- package/src/cli/explain.js +99 -0
- package/src/cli/output.js +34 -0
- package/src/engine.js +404 -0
- package/src/exec.js +31 -0
- package/src/gentle-pi/rail-patch.js +335 -0
- package/src/http.js +182 -0
- package/src/model.js +332 -0
- package/src/omarchy/assets/antigravity.svg +3 -0
- package/src/omarchy/assets/claude.svg +1 -0
- package/src/omarchy/assets/codex.svg +1 -0
- package/src/omarchy/assets/github-actions.svg +3 -0
- package/src/omarchy/assets/opencode-go.svg +3 -0
- package/src/omarchy/publish.js +152 -0
- package/src/omarchy/record.js +462 -0
- package/src/opencode/dashboard.js +337 -0
- package/src/opencode/session.js +485 -0
- package/src/providers/antigravity-oauth.js +173 -0
- package/src/providers/antigravity.js +225 -0
- package/src/providers/backoff.js +146 -0
- package/src/providers/claude.js +194 -0
- package/src/providers/codex.js +151 -0
- package/src/providers/error-kind.js +128 -0
- package/src/providers/github-actions.js +379 -0
- package/src/providers/opencode-go.js +274 -0
- package/src/refresh.js +292 -0
- package/src/render/panel.js +411 -0
- package/src/render/theme.js +102 -0
- 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
|
+
}
|