@jameslovespancakes/pi-plus 1.0.0 → 1.0.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/LICENSE +21 -21
- package/README.md +190 -190
- package/config/pi-plus.example.json +60 -60
- package/config/skills/model-routing/SKILL.md +86 -86
- package/images/pi-plus.svg +10 -10
- package/package.json +67 -67
- package/server/board-server.mjs +641 -641
- package/server/package.json +17 -17
- package/src/core/accounts/registry.ts +93 -93
- package/src/core/anthropic/client-identity.ts +241 -241
- package/src/core/catalog/quality.ts +314 -314
- package/src/core/config.ts +169 -169
- package/src/core/env.ts +58 -58
- package/src/core/exec/process.ts +146 -146
- package/src/core/exec/ssh-config.ts +157 -157
- package/src/core/policy/policy.ts +183 -183
- package/src/core/quota/pool.ts +64 -64
- package/src/core/quota/usage-source.ts +289 -289
- package/src/core/store.ts +43 -43
- package/src/domains/agents/board-setup.ts +409 -409
- package/src/domains/agents/index.ts +462 -462
- package/src/domains/models/catalog-tool.ts +361 -361
- package/src/domains/models/index.ts +14 -14
- package/src/domains/models/policy-gate.ts +169 -169
- package/src/domains/models/provider-picker.ts +207 -207
- package/src/domains/remote/config-path.ts +41 -41
- package/src/domains/remote/index.ts +866 -866
- package/src/domains/remote/setup.ts +425 -425
- package/src/domains/setup/index.ts +220 -220
- package/src/domains/subscriptions/accounts.ts +242 -242
- package/src/domains/subscriptions/footer.ts +182 -182
- package/src/domains/subscriptions/index.ts +42 -42
- package/src/domains/subscriptions/provider.ts +219 -219
- package/src/domains/subscriptions/providers/anthropic.ts +149 -149
- package/src/domains/subscriptions/providers/codex.ts +148 -148
- package/src/domains/subscriptions/routing.ts +72 -72
- package/src/services/usage-service.ts +186 -186
- package/src/ui/format.ts +73 -73
- package/src/ui/usage-bars.ts +154 -154
- package/src/vendor/anthropic.ts +109 -109
|
@@ -1,289 +1,289 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { loadAccounts, saveAccount, type Account as AnthropicAccount } from "../anthropic/store.ts";
|
|
5
|
-
import { refreshToken } from "../anthropic/oauth.ts";
|
|
6
|
-
import { loadCodexAccounts } from "../codex/store.ts";
|
|
7
|
-
import type { UsageRow } from "./pool.ts";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Fetches subscription quota from the Claude and Codex endpoints.
|
|
11
|
-
*
|
|
12
|
-
* Pure data access: no pi imports, no module-level mutable state, no rendering.
|
|
13
|
-
* Everything here returns values so it can be tested without a live agent.
|
|
14
|
-
* Scheduling, caching and retention belong to services/usage-service.ts.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
const TIMEOUT_MS = 10_000;
|
|
18
|
-
|
|
19
|
-
export interface SourceResult {
|
|
20
|
-
rows: UsageRow[];
|
|
21
|
-
errors: string[];
|
|
22
|
-
groups: string[];
|
|
23
|
-
codexPlan?: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function pct(value: unknown): number | undefined {
|
|
27
|
-
if (value == null || typeof value === "boolean" || (typeof value === "string" && !value.trim())) return undefined;
|
|
28
|
-
const n = typeof value === "number" ? value : Number(value);
|
|
29
|
-
return Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : undefined;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function resetToMs(value: unknown): number | undefined {
|
|
33
|
-
if (typeof value === "number" && Number.isFinite(value)) return value < 1e12 ? value * 1000 : value;
|
|
34
|
-
if (typeof value === "string") {
|
|
35
|
-
const parsed = Date.parse(value);
|
|
36
|
-
if (!Number.isNaN(parsed)) return parsed;
|
|
37
|
-
}
|
|
38
|
-
return undefined;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async function claudeUsage(group: string, token: string): Promise<{ rows: UsageRow[]; error?: string }> {
|
|
42
|
-
const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
43
|
-
headers: { Authorization: `Bearer ${token}`, "anthropic-beta": "oauth-2025-04-20", Accept: "application/json" },
|
|
44
|
-
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
45
|
-
});
|
|
46
|
-
if (!response.ok) return { rows: [], error: `${group}: HTTP ${response.status}` };
|
|
47
|
-
const body = await response.json() as any;
|
|
48
|
-
|
|
49
|
-
const rows: UsageRow[] = [];
|
|
50
|
-
const push = (label: string, window: any) => {
|
|
51
|
-
const used = pct(window?.utilization);
|
|
52
|
-
if (used === undefined) return;
|
|
53
|
-
rows.push({
|
|
54
|
-
group,
|
|
55
|
-
label,
|
|
56
|
-
remaining: 100 - used,
|
|
57
|
-
resetAt: resetToMs(window?.resets_at),
|
|
58
|
-
capacity: typeof window?.limit_dollars === "number" && window.limit_dollars > 0 ? window.limit_dollars : undefined,
|
|
59
|
-
});
|
|
60
|
-
};
|
|
61
|
-
push("5h", body.five_hour);
|
|
62
|
-
push("7d", body.seven_day);
|
|
63
|
-
push("7d Opus", body.seven_day_opus ?? body.seven_day_omelette);
|
|
64
|
-
push("7d Sonnet", body.seven_day_sonnet);
|
|
65
|
-
|
|
66
|
-
for (const limit of Array.isArray(body.limits) ? body.limits : []) {
|
|
67
|
-
const scoped = limit?.scope?.model?.display_name;
|
|
68
|
-
const used = pct(limit?.percent);
|
|
69
|
-
if (!scoped || used === undefined) continue;
|
|
70
|
-
const label = `7d ${scoped}`;
|
|
71
|
-
if (rows.some((row) => row.label === label)) continue;
|
|
72
|
-
rows.push({ group, label, remaining: 100 - used, resetAt: resetToMs(limit?.resets_at) });
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const extra = body.extra_usage;
|
|
76
|
-
if (extra?.is_enabled && pct(extra?.utilization) !== undefined) {
|
|
77
|
-
rows.push({ group, label: "Extra", remaining: 100 - pct(extra.utilization)! });
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return rows.length
|
|
81
|
-
? { rows: rows.map((row) => ({ ...row, checkedAt: Date.now() })) }
|
|
82
|
-
: { rows: [], error: `${group}: usage windows unavailable` };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* HUD polling must never wait indefinitely on OAuth refresh or retry it behind
|
|
87
|
-
* a live agent turn. The provider owns request-time refresh/retry policy.
|
|
88
|
-
*/
|
|
89
|
-
async function fallbackAccountToken(account: AnthropicAccount): Promise<string | undefined> {
|
|
90
|
-
const valid = typeof account.expires === "number" && Date.now() + 60_000 < account.expires;
|
|
91
|
-
if (valid && account.access) return account.access;
|
|
92
|
-
if (!account.refresh) return account.access;
|
|
93
|
-
|
|
94
|
-
const refreshed = await refreshToken({ refreshToken: account.refresh, maxRetries: 0 });
|
|
95
|
-
saveAccount({
|
|
96
|
-
...account,
|
|
97
|
-
access: refreshed.access,
|
|
98
|
-
refresh: refreshed.refresh,
|
|
99
|
-
expires: refreshed.expires,
|
|
100
|
-
lastRefreshedAt: Date.now(),
|
|
101
|
-
});
|
|
102
|
-
return refreshed.access;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Builds usage rows from a stored quota snapshot.
|
|
108
|
-
*
|
|
109
|
-
* Snapshots are refreshed for free from response headers on every request, so
|
|
110
|
-
* serving the HUD from them avoids touching `/api/oauth/usage` at all. That
|
|
111
|
-
* endpoint rate limits aggressively, and /usage polling several accounts was a
|
|
112
|
-
* reliable way to get 429s and then show nothing.
|
|
113
|
-
*
|
|
114
|
-
* Returns undefined when there is no snapshot yet, so the caller can fetch.
|
|
115
|
-
*/
|
|
116
|
-
function rowsFromSnapshot(group: string, quota: any): UsageRow[] | undefined {
|
|
117
|
-
if (!quota) return undefined;
|
|
118
|
-
const rows: UsageRow[] = [];
|
|
119
|
-
const push = (label: string, window: any) => {
|
|
120
|
-
if (typeof window?.remainingPercent !== "number") return;
|
|
121
|
-
rows.push({
|
|
122
|
-
group,
|
|
123
|
-
label,
|
|
124
|
-
remaining: window.remainingPercent,
|
|
125
|
-
resetAt: resetToMs(window.resetsAt),
|
|
126
|
-
// Required: pool.isFresh discards any row without it, which would make
|
|
127
|
-
// every cached row pool as "n/a".
|
|
128
|
-
checkedAt: window.checkedAt ?? quota.checkedAt ?? Date.now(),
|
|
129
|
-
});
|
|
130
|
-
};
|
|
131
|
-
push("5h", quota.five_hour);
|
|
132
|
-
push("7d", quota.seven_day);
|
|
133
|
-
for (const scoped of Array.isArray(quota.scoped) ? quota.scoped : []) {
|
|
134
|
-
if (typeof scoped?.remainingPercent !== "number" || !scoped?.id) continue;
|
|
135
|
-
push(`7d ${scoped.id}`, scoped);
|
|
136
|
-
}
|
|
137
|
-
return rows.length ? rows : undefined;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export async function fetchClaudeRows(ctx: any): Promise<{ rows: UsageRow[]; errors: string[]; groups: string[] }> {
|
|
141
|
-
const rows: UsageRow[] = [];
|
|
142
|
-
const errors: string[] = [];
|
|
143
|
-
const accounts: Array<{ group: string; token?: string; account?: AnthropicAccount }> = [];
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
const token = (await ctx.modelRegistry.getProviderAuth("anthropic"))?.auth?.apiKey;
|
|
147
|
-
if (token) accounts.push({ group: "Claude Personal", token });
|
|
148
|
-
else errors.push("Claude Personal: not logged in");
|
|
149
|
-
} catch (error) {
|
|
150
|
-
errors.push(`Claude Personal: ${error instanceof Error ? error.message : String(error)}`);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
try {
|
|
154
|
-
const storage = loadAccounts();
|
|
155
|
-
for (const account of storage?.accounts ?? []) {
|
|
156
|
-
if (account.type !== "oauth" || account.enabled === false) continue;
|
|
157
|
-
accounts.push({ group: `Claude ${account.label ?? account.id.slice(0, 8)}`, account });
|
|
158
|
-
}
|
|
159
|
-
} catch (error) {
|
|
160
|
-
errors.push(`Claude accounts: ${error instanceof Error ? error.message : String(error)}`);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
for (const entry of accounts) {
|
|
164
|
-
try {
|
|
165
|
-
// Cached snapshot first: it is free, current, and cannot be throttled.
|
|
166
|
-
const cached = rowsFromSnapshot(entry.group, entry.account?.quota);
|
|
167
|
-
if (cached) {
|
|
168
|
-
rows.push(...cached);
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const token = entry.token ?? (entry.account ? await fallbackAccountToken(entry.account) : undefined);
|
|
173
|
-
if (!token) {
|
|
174
|
-
errors.push(`${entry.group}: no token`);
|
|
175
|
-
continue;
|
|
176
|
-
}
|
|
177
|
-
const result = await claudeUsage(entry.group, token);
|
|
178
|
-
rows.push(...result.rows);
|
|
179
|
-
if (result.error) errors.push(result.error);
|
|
180
|
-
} catch (error) {
|
|
181
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
182
|
-
errors.push(/invalid_grant/i.test(message)
|
|
183
|
-
? `${entry.group}: login expired, run /accounts reauth ${entry.account?.label ?? entry.account?.id ?? ""}`.trim()
|
|
184
|
-
: `${entry.group}: ${message}`);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return { rows, errors, groups: [...new Set(["Claude Personal", ...accounts.map((entry) => entry.group)])] };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function codexAccountId(): string | undefined {
|
|
192
|
-
// Prefer an enabled account from our own pool, so /usage reflects the
|
|
193
|
-
// accounts /accounts manages rather than only pi's single credential.
|
|
194
|
-
try {
|
|
195
|
-
const pooled = loadCodexAccounts().accounts.find((a) => a.enabled !== false && a.accountId);
|
|
196
|
-
if (pooled?.accountId) return pooled.accountId;
|
|
197
|
-
} catch { /* fall through */ }
|
|
198
|
-
try {
|
|
199
|
-
const auth = JSON.parse(readFileSync(join(homedir(), ".pi", "agent", "auth.json"), "utf8"));
|
|
200
|
-
const credential = auth["openai-codex"];
|
|
201
|
-
if (credential?.accountId) return credential.accountId;
|
|
202
|
-
if (credential?.account_id) return credential.account_id;
|
|
203
|
-
} catch { /* fall through */ }
|
|
204
|
-
try {
|
|
205
|
-
const codex = JSON.parse(readFileSync(join(homedir(), ".codex", "auth.json"), "utf8"));
|
|
206
|
-
return codex?.tokens?.account_id ?? codex?.tokens?.accountId;
|
|
207
|
-
} catch {
|
|
208
|
-
return undefined;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
export async function fetchCodexRows(ctx: any): Promise<{ rows: UsageRow[]; error?: string; plan?: string }> {
|
|
213
|
-
try {
|
|
214
|
-
const token = (await ctx.modelRegistry.getProviderAuth("openai-codex"))?.auth?.apiKey;
|
|
215
|
-
const accountId = codexAccountId();
|
|
216
|
-
if (!token) return { rows: [], error: "Codex: not logged in" };
|
|
217
|
-
if (!accountId) return { rows: [], error: "Codex: no ChatGPT account id" };
|
|
218
|
-
|
|
219
|
-
const response = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
220
|
-
headers: {
|
|
221
|
-
Authorization: `Bearer ${token}`,
|
|
222
|
-
"ChatGPT-Account-Id": accountId,
|
|
223
|
-
Accept: "application/json",
|
|
224
|
-
Origin: "https://chatgpt.com",
|
|
225
|
-
Referer: "https://chatgpt.com/",
|
|
226
|
-
"User-Agent": "Mozilla/5.0",
|
|
227
|
-
},
|
|
228
|
-
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
229
|
-
});
|
|
230
|
-
if (!response.ok) return { rows: [], error: `Codex: HTTP ${response.status}` };
|
|
231
|
-
const body = await response.json() as any;
|
|
232
|
-
|
|
233
|
-
const rows: UsageRow[] = [];
|
|
234
|
-
const push = (label: string, window: any) => {
|
|
235
|
-
const used = pct(window?.used_percent);
|
|
236
|
-
if (used === undefined) return;
|
|
237
|
-
rows.push({ group: "Codex", label, remaining: 100 - used, resetAt: resetToMs(window?.reset_at), checkedAt: Date.now() });
|
|
238
|
-
};
|
|
239
|
-
|
|
240
|
-
const kindOf = (window: any): string | undefined => {
|
|
241
|
-
const seconds = Number(window?.limit_window_seconds);
|
|
242
|
-
if (!Number.isFinite(seconds)) return undefined;
|
|
243
|
-
if (seconds <= 21_600) return "5h";
|
|
244
|
-
if (seconds >= 500_000) return "weekly";
|
|
245
|
-
return `${Math.round(seconds / 86400)}d`;
|
|
246
|
-
};
|
|
247
|
-
|
|
248
|
-
for (const window of [body.rate_limit?.primary_window, body.rate_limit?.secondary_window]) {
|
|
249
|
-
const kind = kindOf(window);
|
|
250
|
-
if (kind) push(kind, window);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
for (const extra of Array.isArray(body.additional_rate_limits) ? body.additional_rate_limits : []) {
|
|
254
|
-
const name = String(extra?.limit_name ?? "extra").replace(/^GPT-[\d.]+-Codex-/i, "");
|
|
255
|
-
for (const window of [extra?.rate_limit?.primary_window, extra?.rate_limit?.secondary_window]) {
|
|
256
|
-
const kind = kindOf(window);
|
|
257
|
-
const used = pct(window?.used_percent);
|
|
258
|
-
if (!kind || used === undefined) continue;
|
|
259
|
-
rows.push({
|
|
260
|
-
group: "Codex",
|
|
261
|
-
label: `${name} ${kind}`,
|
|
262
|
-
remaining: 100 - used,
|
|
263
|
-
resetAt: resetToMs(window?.reset_at),
|
|
264
|
-
checkedAt: Date.now(),
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const credits = body.credits;
|
|
270
|
-
if (credits?.has_credits && credits?.balance) {
|
|
271
|
-
rows.push({ group: "Codex", label: `credits ${credits.balance}`, remaining: 100, checkedAt: Date.now() });
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
return { rows, plan: typeof body.plan_type === "string" ? body.plan_type : undefined };
|
|
275
|
-
} catch (error) {
|
|
276
|
-
return { rows: [], error: `Codex: ${error instanceof Error ? error.message : String(error)}` };
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/** One full poll of every configured subscription source. */
|
|
281
|
-
export async function fetchAll(ctx: any): Promise<SourceResult> {
|
|
282
|
-
const [claude, codex] = await Promise.all([fetchClaudeRows(ctx), fetchCodexRows(ctx)]);
|
|
283
|
-
return {
|
|
284
|
-
rows: [...claude.rows, ...codex.rows],
|
|
285
|
-
errors: [...claude.errors, codex.error].filter((error): error is string => !!error),
|
|
286
|
-
groups: claude.groups,
|
|
287
|
-
codexPlan: codex.plan,
|
|
288
|
-
};
|
|
289
|
-
}
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { loadAccounts, saveAccount, type Account as AnthropicAccount } from "../anthropic/store.ts";
|
|
5
|
+
import { refreshToken } from "../anthropic/oauth.ts";
|
|
6
|
+
import { loadCodexAccounts } from "../codex/store.ts";
|
|
7
|
+
import type { UsageRow } from "./pool.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Fetches subscription quota from the Claude and Codex endpoints.
|
|
11
|
+
*
|
|
12
|
+
* Pure data access: no pi imports, no module-level mutable state, no rendering.
|
|
13
|
+
* Everything here returns values so it can be tested without a live agent.
|
|
14
|
+
* Scheduling, caching and retention belong to services/usage-service.ts.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const TIMEOUT_MS = 10_000;
|
|
18
|
+
|
|
19
|
+
export interface SourceResult {
|
|
20
|
+
rows: UsageRow[];
|
|
21
|
+
errors: string[];
|
|
22
|
+
groups: string[];
|
|
23
|
+
codexPlan?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function pct(value: unknown): number | undefined {
|
|
27
|
+
if (value == null || typeof value === "boolean" || (typeof value === "string" && !value.trim())) return undefined;
|
|
28
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
29
|
+
return Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resetToMs(value: unknown): number | undefined {
|
|
33
|
+
if (typeof value === "number" && Number.isFinite(value)) return value < 1e12 ? value * 1000 : value;
|
|
34
|
+
if (typeof value === "string") {
|
|
35
|
+
const parsed = Date.parse(value);
|
|
36
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function claudeUsage(group: string, token: string): Promise<{ rows: UsageRow[]; error?: string }> {
|
|
42
|
+
const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
43
|
+
headers: { Authorization: `Bearer ${token}`, "anthropic-beta": "oauth-2025-04-20", Accept: "application/json" },
|
|
44
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
45
|
+
});
|
|
46
|
+
if (!response.ok) return { rows: [], error: `${group}: HTTP ${response.status}` };
|
|
47
|
+
const body = await response.json() as any;
|
|
48
|
+
|
|
49
|
+
const rows: UsageRow[] = [];
|
|
50
|
+
const push = (label: string, window: any) => {
|
|
51
|
+
const used = pct(window?.utilization);
|
|
52
|
+
if (used === undefined) return;
|
|
53
|
+
rows.push({
|
|
54
|
+
group,
|
|
55
|
+
label,
|
|
56
|
+
remaining: 100 - used,
|
|
57
|
+
resetAt: resetToMs(window?.resets_at),
|
|
58
|
+
capacity: typeof window?.limit_dollars === "number" && window.limit_dollars > 0 ? window.limit_dollars : undefined,
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
push("5h", body.five_hour);
|
|
62
|
+
push("7d", body.seven_day);
|
|
63
|
+
push("7d Opus", body.seven_day_opus ?? body.seven_day_omelette);
|
|
64
|
+
push("7d Sonnet", body.seven_day_sonnet);
|
|
65
|
+
|
|
66
|
+
for (const limit of Array.isArray(body.limits) ? body.limits : []) {
|
|
67
|
+
const scoped = limit?.scope?.model?.display_name;
|
|
68
|
+
const used = pct(limit?.percent);
|
|
69
|
+
if (!scoped || used === undefined) continue;
|
|
70
|
+
const label = `7d ${scoped}`;
|
|
71
|
+
if (rows.some((row) => row.label === label)) continue;
|
|
72
|
+
rows.push({ group, label, remaining: 100 - used, resetAt: resetToMs(limit?.resets_at) });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const extra = body.extra_usage;
|
|
76
|
+
if (extra?.is_enabled && pct(extra?.utilization) !== undefined) {
|
|
77
|
+
rows.push({ group, label: "Extra", remaining: 100 - pct(extra.utilization)! });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return rows.length
|
|
81
|
+
? { rows: rows.map((row) => ({ ...row, checkedAt: Date.now() })) }
|
|
82
|
+
: { rows: [], error: `${group}: usage windows unavailable` };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* HUD polling must never wait indefinitely on OAuth refresh or retry it behind
|
|
87
|
+
* a live agent turn. The provider owns request-time refresh/retry policy.
|
|
88
|
+
*/
|
|
89
|
+
async function fallbackAccountToken(account: AnthropicAccount): Promise<string | undefined> {
|
|
90
|
+
const valid = typeof account.expires === "number" && Date.now() + 60_000 < account.expires;
|
|
91
|
+
if (valid && account.access) return account.access;
|
|
92
|
+
if (!account.refresh) return account.access;
|
|
93
|
+
|
|
94
|
+
const refreshed = await refreshToken({ refreshToken: account.refresh, maxRetries: 0 });
|
|
95
|
+
saveAccount({
|
|
96
|
+
...account,
|
|
97
|
+
access: refreshed.access,
|
|
98
|
+
refresh: refreshed.refresh,
|
|
99
|
+
expires: refreshed.expires,
|
|
100
|
+
lastRefreshedAt: Date.now(),
|
|
101
|
+
});
|
|
102
|
+
return refreshed.access;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Builds usage rows from a stored quota snapshot.
|
|
108
|
+
*
|
|
109
|
+
* Snapshots are refreshed for free from response headers on every request, so
|
|
110
|
+
* serving the HUD from them avoids touching `/api/oauth/usage` at all. That
|
|
111
|
+
* endpoint rate limits aggressively, and /usage polling several accounts was a
|
|
112
|
+
* reliable way to get 429s and then show nothing.
|
|
113
|
+
*
|
|
114
|
+
* Returns undefined when there is no snapshot yet, so the caller can fetch.
|
|
115
|
+
*/
|
|
116
|
+
function rowsFromSnapshot(group: string, quota: any): UsageRow[] | undefined {
|
|
117
|
+
if (!quota) return undefined;
|
|
118
|
+
const rows: UsageRow[] = [];
|
|
119
|
+
const push = (label: string, window: any) => {
|
|
120
|
+
if (typeof window?.remainingPercent !== "number") return;
|
|
121
|
+
rows.push({
|
|
122
|
+
group,
|
|
123
|
+
label,
|
|
124
|
+
remaining: window.remainingPercent,
|
|
125
|
+
resetAt: resetToMs(window.resetsAt),
|
|
126
|
+
// Required: pool.isFresh discards any row without it, which would make
|
|
127
|
+
// every cached row pool as "n/a".
|
|
128
|
+
checkedAt: window.checkedAt ?? quota.checkedAt ?? Date.now(),
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
push("5h", quota.five_hour);
|
|
132
|
+
push("7d", quota.seven_day);
|
|
133
|
+
for (const scoped of Array.isArray(quota.scoped) ? quota.scoped : []) {
|
|
134
|
+
if (typeof scoped?.remainingPercent !== "number" || !scoped?.id) continue;
|
|
135
|
+
push(`7d ${scoped.id}`, scoped);
|
|
136
|
+
}
|
|
137
|
+
return rows.length ? rows : undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function fetchClaudeRows(ctx: any): Promise<{ rows: UsageRow[]; errors: string[]; groups: string[] }> {
|
|
141
|
+
const rows: UsageRow[] = [];
|
|
142
|
+
const errors: string[] = [];
|
|
143
|
+
const accounts: Array<{ group: string; token?: string; account?: AnthropicAccount }> = [];
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const token = (await ctx.modelRegistry.getProviderAuth("anthropic"))?.auth?.apiKey;
|
|
147
|
+
if (token) accounts.push({ group: "Claude Personal", token });
|
|
148
|
+
else errors.push("Claude Personal: not logged in");
|
|
149
|
+
} catch (error) {
|
|
150
|
+
errors.push(`Claude Personal: ${error instanceof Error ? error.message : String(error)}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const storage = loadAccounts();
|
|
155
|
+
for (const account of storage?.accounts ?? []) {
|
|
156
|
+
if (account.type !== "oauth" || account.enabled === false) continue;
|
|
157
|
+
accounts.push({ group: `Claude ${account.label ?? account.id.slice(0, 8)}`, account });
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
errors.push(`Claude accounts: ${error instanceof Error ? error.message : String(error)}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const entry of accounts) {
|
|
164
|
+
try {
|
|
165
|
+
// Cached snapshot first: it is free, current, and cannot be throttled.
|
|
166
|
+
const cached = rowsFromSnapshot(entry.group, entry.account?.quota);
|
|
167
|
+
if (cached) {
|
|
168
|
+
rows.push(...cached);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const token = entry.token ?? (entry.account ? await fallbackAccountToken(entry.account) : undefined);
|
|
173
|
+
if (!token) {
|
|
174
|
+
errors.push(`${entry.group}: no token`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const result = await claudeUsage(entry.group, token);
|
|
178
|
+
rows.push(...result.rows);
|
|
179
|
+
if (result.error) errors.push(result.error);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
182
|
+
errors.push(/invalid_grant/i.test(message)
|
|
183
|
+
? `${entry.group}: login expired, run /accounts reauth ${entry.account?.label ?? entry.account?.id ?? ""}`.trim()
|
|
184
|
+
: `${entry.group}: ${message}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { rows, errors, groups: [...new Set(["Claude Personal", ...accounts.map((entry) => entry.group)])] };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function codexAccountId(): string | undefined {
|
|
192
|
+
// Prefer an enabled account from our own pool, so /usage reflects the
|
|
193
|
+
// accounts /accounts manages rather than only pi's single credential.
|
|
194
|
+
try {
|
|
195
|
+
const pooled = loadCodexAccounts().accounts.find((a) => a.enabled !== false && a.accountId);
|
|
196
|
+
if (pooled?.accountId) return pooled.accountId;
|
|
197
|
+
} catch { /* fall through */ }
|
|
198
|
+
try {
|
|
199
|
+
const auth = JSON.parse(readFileSync(join(homedir(), ".pi", "agent", "auth.json"), "utf8"));
|
|
200
|
+
const credential = auth["openai-codex"];
|
|
201
|
+
if (credential?.accountId) return credential.accountId;
|
|
202
|
+
if (credential?.account_id) return credential.account_id;
|
|
203
|
+
} catch { /* fall through */ }
|
|
204
|
+
try {
|
|
205
|
+
const codex = JSON.parse(readFileSync(join(homedir(), ".codex", "auth.json"), "utf8"));
|
|
206
|
+
return codex?.tokens?.account_id ?? codex?.tokens?.accountId;
|
|
207
|
+
} catch {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function fetchCodexRows(ctx: any): Promise<{ rows: UsageRow[]; error?: string; plan?: string }> {
|
|
213
|
+
try {
|
|
214
|
+
const token = (await ctx.modelRegistry.getProviderAuth("openai-codex"))?.auth?.apiKey;
|
|
215
|
+
const accountId = codexAccountId();
|
|
216
|
+
if (!token) return { rows: [], error: "Codex: not logged in" };
|
|
217
|
+
if (!accountId) return { rows: [], error: "Codex: no ChatGPT account id" };
|
|
218
|
+
|
|
219
|
+
const response = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
220
|
+
headers: {
|
|
221
|
+
Authorization: `Bearer ${token}`,
|
|
222
|
+
"ChatGPT-Account-Id": accountId,
|
|
223
|
+
Accept: "application/json",
|
|
224
|
+
Origin: "https://chatgpt.com",
|
|
225
|
+
Referer: "https://chatgpt.com/",
|
|
226
|
+
"User-Agent": "Mozilla/5.0",
|
|
227
|
+
},
|
|
228
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
229
|
+
});
|
|
230
|
+
if (!response.ok) return { rows: [], error: `Codex: HTTP ${response.status}` };
|
|
231
|
+
const body = await response.json() as any;
|
|
232
|
+
|
|
233
|
+
const rows: UsageRow[] = [];
|
|
234
|
+
const push = (label: string, window: any) => {
|
|
235
|
+
const used = pct(window?.used_percent);
|
|
236
|
+
if (used === undefined) return;
|
|
237
|
+
rows.push({ group: "Codex", label, remaining: 100 - used, resetAt: resetToMs(window?.reset_at), checkedAt: Date.now() });
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const kindOf = (window: any): string | undefined => {
|
|
241
|
+
const seconds = Number(window?.limit_window_seconds);
|
|
242
|
+
if (!Number.isFinite(seconds)) return undefined;
|
|
243
|
+
if (seconds <= 21_600) return "5h";
|
|
244
|
+
if (seconds >= 500_000) return "weekly";
|
|
245
|
+
return `${Math.round(seconds / 86400)}d`;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
for (const window of [body.rate_limit?.primary_window, body.rate_limit?.secondary_window]) {
|
|
249
|
+
const kind = kindOf(window);
|
|
250
|
+
if (kind) push(kind, window);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
for (const extra of Array.isArray(body.additional_rate_limits) ? body.additional_rate_limits : []) {
|
|
254
|
+
const name = String(extra?.limit_name ?? "extra").replace(/^GPT-[\d.]+-Codex-/i, "");
|
|
255
|
+
for (const window of [extra?.rate_limit?.primary_window, extra?.rate_limit?.secondary_window]) {
|
|
256
|
+
const kind = kindOf(window);
|
|
257
|
+
const used = pct(window?.used_percent);
|
|
258
|
+
if (!kind || used === undefined) continue;
|
|
259
|
+
rows.push({
|
|
260
|
+
group: "Codex",
|
|
261
|
+
label: `${name} ${kind}`,
|
|
262
|
+
remaining: 100 - used,
|
|
263
|
+
resetAt: resetToMs(window?.reset_at),
|
|
264
|
+
checkedAt: Date.now(),
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const credits = body.credits;
|
|
270
|
+
if (credits?.has_credits && credits?.balance) {
|
|
271
|
+
rows.push({ group: "Codex", label: `credits ${credits.balance}`, remaining: 100, checkedAt: Date.now() });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { rows, plan: typeof body.plan_type === "string" ? body.plan_type : undefined };
|
|
275
|
+
} catch (error) {
|
|
276
|
+
return { rows: [], error: `Codex: ${error instanceof Error ? error.message : String(error)}` };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** One full poll of every configured subscription source. */
|
|
281
|
+
export async function fetchAll(ctx: any): Promise<SourceResult> {
|
|
282
|
+
const [claude, codex] = await Promise.all([fetchClaudeRows(ctx), fetchCodexRows(ctx)]);
|
|
283
|
+
return {
|
|
284
|
+
rows: [...claude.rows, ...codex.rows],
|
|
285
|
+
errors: [...claude.errors, codex.error].filter((error): error is string => !!error),
|
|
286
|
+
groups: claude.groups,
|
|
287
|
+
codexPlan: codex.plan,
|
|
288
|
+
};
|
|
289
|
+
}
|
package/src/core/store.ts
CHANGED
|
@@ -1,43 +1,43 @@
|
|
|
1
|
-
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Small JSON config helper shared by every domain.
|
|
7
|
-
*
|
|
8
|
-
* Writes go through a temp file + rename so a crash mid-write cannot leave a
|
|
9
|
-
* truncated config behind; several of these files hold auth state.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
export function agentDir(): string {
|
|
13
|
-
return process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function agentPath(...parts: string[]): string {
|
|
17
|
-
return join(agentDir(), ...parts);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function readJson<T>(path: string, fallback: T): T {
|
|
21
|
-
try {
|
|
22
|
-
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
23
|
-
} catch {
|
|
24
|
-
return fallback;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Best-effort atomic write. Returns false instead of throwing. */
|
|
29
|
-
export function writeJson(path: string, value: unknown, pretty = false): boolean {
|
|
30
|
-
const temp = `${path}.${process.pid}.tmp`;
|
|
31
|
-
try {
|
|
32
|
-
writeFileSync(temp, JSON.stringify(value, undefined, pretty ? 2 : undefined), "utf8");
|
|
33
|
-
renameSync(temp, path);
|
|
34
|
-
return true;
|
|
35
|
-
} catch {
|
|
36
|
-
try {
|
|
37
|
-
writeFileSync(path, JSON.stringify(value, undefined, pretty ? 2 : undefined), "utf8");
|
|
38
|
-
return true;
|
|
39
|
-
} catch {
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}
|
|
1
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Small JSON config helper shared by every domain.
|
|
7
|
+
*
|
|
8
|
+
* Writes go through a temp file + rename so a crash mid-write cannot leave a
|
|
9
|
+
* truncated config behind; several of these files hold auth state.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function agentDir(): string {
|
|
13
|
+
return process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function agentPath(...parts: string[]): string {
|
|
17
|
+
return join(agentDir(), ...parts);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function readJson<T>(path: string, fallback: T): T {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
23
|
+
} catch {
|
|
24
|
+
return fallback;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Best-effort atomic write. Returns false instead of throwing. */
|
|
29
|
+
export function writeJson(path: string, value: unknown, pretty = false): boolean {
|
|
30
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
31
|
+
try {
|
|
32
|
+
writeFileSync(temp, JSON.stringify(value, undefined, pretty ? 2 : undefined), "utf8");
|
|
33
|
+
renameSync(temp, path);
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
try {
|
|
37
|
+
writeFileSync(path, JSON.stringify(value, undefined, pretty ? 2 : undefined), "utf8");
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|