@jameslovespancakes/pi-plus 1.0.0

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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +190 -0
  3. package/config/pi-plus.example.json +60 -0
  4. package/config/skills/model-routing/SKILL.md +86 -0
  5. package/images/board_demo.png +0 -0
  6. package/images/pi-plus.svg +10 -0
  7. package/images/pi-plus_demo.png +0 -0
  8. package/images/provider_demo.png +0 -0
  9. package/images/remote_demo.png +0 -0
  10. package/images/usage_demo.png +0 -0
  11. package/package.json +67 -0
  12. package/server/board-server.mjs +641 -0
  13. package/server/package.json +17 -0
  14. package/src/core/accounts/registry.ts +93 -0
  15. package/src/core/anthropic/client-identity.ts +241 -0
  16. package/src/core/anthropic/models.ts +69 -0
  17. package/src/core/anthropic/oauth.ts +208 -0
  18. package/src/core/anthropic/quota.ts +253 -0
  19. package/src/core/anthropic/routing.ts +168 -0
  20. package/src/core/anthropic/store.ts +225 -0
  21. package/src/core/anthropic/vendor/README.md +36 -0
  22. package/src/core/anthropic/vendor/xxhash-wasm.LICENSE.md +25 -0
  23. package/src/core/anthropic/vendor/xxhash-wasm.js +2 -0
  24. package/src/core/anthropic/xxhash64.ts +33 -0
  25. package/src/core/catalog/quality.ts +314 -0
  26. package/src/core/codex/oauth.ts +129 -0
  27. package/src/core/codex/quota.ts +88 -0
  28. package/src/core/codex/store.ts +97 -0
  29. package/src/core/config.ts +169 -0
  30. package/src/core/env.ts +58 -0
  31. package/src/core/exec/process.ts +146 -0
  32. package/src/core/exec/ssh-config.ts +157 -0
  33. package/src/core/oauth/pkce.ts +88 -0
  34. package/src/core/policy/policy.ts +183 -0
  35. package/src/core/quota/pool.ts +64 -0
  36. package/src/core/quota/usage-source.ts +289 -0
  37. package/src/core/store.ts +43 -0
  38. package/src/domains/agents/board-setup.ts +409 -0
  39. package/src/domains/agents/index.ts +462 -0
  40. package/src/domains/models/catalog-tool.ts +361 -0
  41. package/src/domains/models/index.ts +14 -0
  42. package/src/domains/models/policy-gate.ts +169 -0
  43. package/src/domains/models/provider-picker.ts +208 -0
  44. package/src/domains/remote/config-path.ts +41 -0
  45. package/src/domains/remote/index.ts +866 -0
  46. package/src/domains/remote/setup.ts +425 -0
  47. package/src/domains/setup/index.ts +220 -0
  48. package/src/domains/subscriptions/accounts-picker.ts +178 -0
  49. package/src/domains/subscriptions/accounts.ts +242 -0
  50. package/src/domains/subscriptions/footer.ts +182 -0
  51. package/src/domains/subscriptions/index.ts +42 -0
  52. package/src/domains/subscriptions/provider.ts +219 -0
  53. package/src/domains/subscriptions/providers/anthropic.ts +149 -0
  54. package/src/domains/subscriptions/providers/codex.ts +148 -0
  55. package/src/domains/subscriptions/routing.ts +72 -0
  56. package/src/services/usage-service.ts +186 -0
  57. package/src/ui/format.ts +73 -0
  58. package/src/ui/usage-bars.ts +154 -0
  59. package/src/vendor/anthropic.ts +109 -0
@@ -0,0 +1,253 @@
1
+ import { loadAccounts, saveAccount, type Account, type QuotaSnapshot } from "./store.ts";
2
+ import { refreshToken } from "./oauth.ts";
3
+
4
+ /**
5
+ * Quota tracking.
6
+ *
7
+ * There are two sources, and the cheap one is strongly preferred:
8
+ *
9
+ * 1. Response headers. Every `/v1/messages` reply carries the same numbers
10
+ * as the usage endpoint, so the account actually serving traffic keeps
11
+ * its snapshot current at no request cost at all.
12
+ * 2. The usage endpoint. Only needed for accounts that are NOT serving
13
+ * traffic, since routing compares accounts and an idle one would
14
+ * otherwise never update.
15
+ *
16
+ * Polling used to run on a 5 minute timer regardless of activity. That is what
17
+ * the backoff below exists for: the endpoint rate limits, it answers 429 with
18
+ * `retry-after: 0`, and `pollQuota` reports failure as `undefined`, so the
19
+ * throttling was invisible and simply left routing on stale data.
20
+ */
21
+
22
+ const QUOTA_URL = "https://api.anthropic.com/api/oauth/usage";
23
+ const TIMEOUT_MS = 10_000;
24
+ /**
25
+ * Selection tolerates a snapshot this old before it is worth re-polling.
26
+ *
27
+ * This doubles as the poll rate limiter. Polling is triggered by sending a
28
+ * message, so this is the floor between polls however fast you type: send a
29
+ * message after the window and it polls, send ten inside it and it polls once.
30
+ */
31
+ export const QUOTA_FRESH_MS = 10 * 60_000;
32
+ /** After a 429, wait at least this long before touching the endpoint again. */
33
+ export const QUOTA_BACKOFF_MS = 15 * 60_000;
34
+
35
+ /** Per-account earliest next poll, set when the endpoint rate limits us. */
36
+ const blockedUntil = new Map<string, number>();
37
+
38
+ /** True when a recent 429 means this account must not be polled yet. */
39
+ export function isPollBlocked(accountId: string, now = Date.now()): boolean {
40
+ const until = blockedUntil.get(accountId);
41
+ if (until === undefined) return false;
42
+ if (now >= until) { blockedUntil.delete(accountId); return false; }
43
+ return true;
44
+ }
45
+
46
+ /** Visible for tests; clears the backoff table. */
47
+ export function resetPollBackoff(): void {
48
+ blockedUntil.clear();
49
+ }
50
+
51
+ const pct = (value: unknown): number | undefined => {
52
+ const n = typeof value === "number" ? value : Number(value);
53
+ return Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : undefined;
54
+ };
55
+
56
+ /** Normalises Anthropic's payload into the snapshot shape the store holds. */
57
+ export function parseQuota(body: any, now = Date.now()): QuotaSnapshot {
58
+ const window = (raw: any) => {
59
+ const used = pct(raw?.utilization);
60
+ if (used === undefined) return undefined;
61
+ return {
62
+ usedPercent: used,
63
+ remainingPercent: 100 - used,
64
+ resetsAt: typeof raw?.resets_at === "string" ? raw.resets_at : undefined,
65
+ checkedAt: now,
66
+ };
67
+ };
68
+
69
+ const scoped = (Array.isArray(body?.limits) ? body.limits : [])
70
+ .map((limit: any) => {
71
+ const used = pct(limit?.percent);
72
+ if (used === undefined) return undefined;
73
+ return {
74
+ id: String(limit?.scope?.model?.display_name ?? limit?.id ?? "scoped").toLowerCase(),
75
+ usedPercent: used,
76
+ remainingPercent: 100 - used,
77
+ resetsAt: typeof limit?.resets_at === "string" ? limit.resets_at : undefined,
78
+ checkedAt: now,
79
+ };
80
+ })
81
+ .filter(Boolean);
82
+
83
+ return {
84
+ five_hour: window(body?.five_hour),
85
+ seven_day: window(body?.seven_day),
86
+ scoped: scoped.length ? scoped : undefined,
87
+ checkedAt: now,
88
+ source: "poll",
89
+ };
90
+ }
91
+
92
+ export function isFresh(quota: QuotaSnapshot | undefined, now = Date.now()): boolean {
93
+ const at = quota?.checkedAt ?? 0;
94
+ return at > 0 && now - at < QUOTA_FRESH_MS;
95
+ }
96
+
97
+ /**
98
+ * Reads a quota snapshot out of `/v1/messages` response headers.
99
+ *
100
+ * Anthropic reports utilisation here as a FRACTION (`0.16`), while the usage
101
+ * endpoint reports a PERCENT (`16`). Verified against the same account at the
102
+ * same moment, including matching reset timestamps. Scaling by 100 is what
103
+ * makes the two sources comparable, so do not drop it.
104
+ *
105
+ * Returns undefined when the headers are absent, which is normal: they do not
106
+ * appear on 4xx replies, and non-Anthropic transports may not expose them.
107
+ */
108
+ export function parseQuotaHeaders(
109
+ headers: Record<string, unknown> | undefined,
110
+ now = Date.now(),
111
+ ): QuotaSnapshot | undefined {
112
+ if (!headers) return undefined;
113
+
114
+ // Header casing is not guaranteed across transports.
115
+ const get = (name: string): string | undefined => {
116
+ const key = `anthropic-ratelimit-unified-${name}`;
117
+ const direct = headers[key] ?? headers[key.toUpperCase()];
118
+ if (direct !== undefined && direct !== null) return String(direct);
119
+ const found = Object.keys(headers).find((k) => k.toLowerCase() === key);
120
+ return found === undefined ? undefined : String(headers[found]);
121
+ };
122
+
123
+ const window = (prefix: string) => {
124
+ const raw = get(`${prefix}-utilization`);
125
+ if (raw === undefined) return undefined;
126
+ const fraction = Number(raw);
127
+ if (!Number.isFinite(fraction)) return undefined;
128
+
129
+ const used = Math.min(100, Math.max(0, fraction * 100));
130
+ const resetSeconds = Number(get(`${prefix}-reset`));
131
+ return {
132
+ usedPercent: used,
133
+ remainingPercent: 100 - used,
134
+ resetsAt: Number.isFinite(resetSeconds) && resetSeconds > 0
135
+ ? new Date(resetSeconds * 1000).toISOString()
136
+ : undefined,
137
+ checkedAt: now,
138
+ };
139
+ };
140
+
141
+ const five_hour = window("5h");
142
+ const seven_day = window("7d");
143
+ if (!five_hour && !seven_day) return undefined;
144
+
145
+ return { five_hour, seven_day, checkedAt: now, source: "headers" };
146
+ }
147
+
148
+ /**
149
+ * Merges a header-derived snapshot into an account.
150
+ *
151
+ * Scoped per-model limits only come from the usage endpoint, so they are
152
+ * carried over from the previous snapshot rather than dropped.
153
+ */
154
+ export function applyQuotaHeaders(
155
+ accountId: string,
156
+ headers: Record<string, unknown> | undefined,
157
+ now = Date.now(),
158
+ ): boolean {
159
+ const fresh = parseQuotaHeaders(headers, now);
160
+ if (!fresh) return false;
161
+
162
+ const storage = loadAccounts();
163
+ const account = storage?.accounts.find((a) => a.id === accountId);
164
+ if (!account) return false;
165
+
166
+ saveAccount({ ...account, quota: { ...fresh, scoped: account.quota?.scoped } });
167
+ return true;
168
+ }
169
+
170
+ /**
171
+ * Polls one account. Returns undefined rather than throwing on failure.
172
+ *
173
+ * Pass `accountId` so a 429 registers backoff; without it the caller can
174
+ * hammer a throttled endpoint and silently keep stale quota.
175
+ */
176
+ export async function pollQuota(
177
+ accessToken: string,
178
+ accountId?: string,
179
+ ): Promise<QuotaSnapshot | undefined> {
180
+ if (accountId && isPollBlocked(accountId)) return undefined;
181
+ try {
182
+ const response = await fetch(QUOTA_URL, {
183
+ headers: {
184
+ Authorization: `Bearer ${accessToken}`,
185
+ "anthropic-beta": "oauth-2025-04-20",
186
+ Accept: "application/json",
187
+ },
188
+ signal: AbortSignal.timeout(TIMEOUT_MS),
189
+ });
190
+ if (response.status === 429 && accountId) {
191
+ // `retry-after` is commonly 0 here, which is not a usable hint, so the
192
+ // floor is ours rather than the server's.
193
+ const hint = Number(response.headers.get("retry-after")) * 1000;
194
+ const wait = Number.isFinite(hint) && hint > 0 ? hint : QUOTA_BACKOFF_MS;
195
+ blockedUntil.set(accountId, Date.now() + Math.max(wait, QUOTA_BACKOFF_MS));
196
+ return undefined;
197
+ }
198
+ if (!response.ok) return undefined;
199
+ return parseQuota(await response.json());
200
+ } catch {
201
+ return undefined;
202
+ }
203
+ }
204
+
205
+ /** A token good for at least a minute, refreshing and persisting if needed. */
206
+ export async function ensureAccessToken(account: Account): Promise<string | undefined> {
207
+ if (account.access && typeof account.expires === "number" && Date.now() + 60_000 < account.expires) {
208
+ return account.access;
209
+ }
210
+ if (!account.refresh) return account.access;
211
+
212
+ const refreshed = await refreshToken({ refreshToken: account.refresh, maxRetries: 0 });
213
+ // Persist immediately: Anthropic may rotate the refresh token, and losing the
214
+ // new one would invalidate the account.
215
+ saveAccount({
216
+ ...account,
217
+ access: refreshed.access,
218
+ refresh: refreshed.refresh,
219
+ expires: refreshed.expires,
220
+ lastRefreshedAt: Date.now(),
221
+ });
222
+ return refreshed.access;
223
+ }
224
+
225
+ /**
226
+ * Refreshes stale quota for every usable account, in parallel.
227
+ * Best-effort: a failure leaves the previous snapshot in place.
228
+ */
229
+ export async function refreshAllQuota(force = false): Promise<number> {
230
+ const storage = loadAccounts();
231
+ if (!storage) return 0;
232
+ const now = Date.now();
233
+
234
+ const stale = storage.accounts.filter(
235
+ (a) => a.enabled !== false && a.type === "oauth"
236
+ && (force || !isFresh(a.quota, now))
237
+ && !isPollBlocked(a.id, now));
238
+
239
+ const results = await Promise.all(stale.map(async (account) => {
240
+ try {
241
+ const token = await ensureAccessToken(account);
242
+ if (!token) return false;
243
+ const quota = await pollQuota(token, account.id);
244
+ if (!quota) return false;
245
+ saveAccount({ ...account, quota });
246
+ return true;
247
+ } catch {
248
+ return false;
249
+ }
250
+ }));
251
+
252
+ return results.filter(Boolean).length;
253
+ }
@@ -0,0 +1,168 @@
1
+ import { MAIN_ACCOUNT_ID, type Account, type QuotaSnapshot, type QuotaWindow, type RoutingMode } from "./store.ts";
2
+
3
+ /**
4
+ * Account selection and sticky session assignment.
5
+ *
6
+ * Two jobs:
7
+ * 1. score each candidate by how much work it can absorb
8
+ * 2. keep a session on one account, because Anthropic's prompt cache is
9
+ * per-account and migrating throws it away
10
+ */
11
+
12
+ /** How long each window takes to refill completely. */
13
+ export const WINDOW_HOURS = { five_hour: 5, seven_day: 168, scoped: 168 } as const;
14
+ export type WindowKey = keyof typeof WINDOW_HOURS;
15
+
16
+ export type Family = "fable" | "opus" | "general";
17
+
18
+ export function familyForModel(model: string | undefined): Family {
19
+ const id = (model ?? "").toLowerCase();
20
+ if (id.includes("fable") || id.includes("mythos")) return "fable";
21
+ if (id.includes("opus")) return "opus";
22
+ return "general";
23
+ }
24
+
25
+ export interface Candidate {
26
+ id: string;
27
+ access?: string;
28
+ quota?: QuotaSnapshot;
29
+ /** Config order; `main` is 0, so it wins ties. */
30
+ order: number;
31
+ account?: Account;
32
+ }
33
+
34
+ /**
35
+ * Hours of wall-clock recovery per point of quota spent.
36
+ *
37
+ * Expressed against the window's own length rather than its distance to reset:
38
+ * a 5h window regenerates 34x faster than a 7d one, so a point spent there is
39
+ * far cheaper. Returns Infinity at or below zero, which collapses the account's
40
+ * weight to 0 and removes it from selection.
41
+ */
42
+ export function recoveryCost(window: QuotaWindow | undefined, key: WindowKey, reserve = 0): number {
43
+ const remaining = window?.remainingPercent;
44
+ if (!Number.isFinite(remaining)) return Infinity;
45
+ const spendable = Math.max(0, (remaining as number) - reserve);
46
+ if (spendable <= 0) return Infinity;
47
+ return WINDOW_HOURS[key] / spendable;
48
+ }
49
+
50
+ /** Scoped windows are model-specific; match the one governing this model. */
51
+ export function scopedWindowFor(quota: QuotaSnapshot | undefined, modelId?: string): QuotaWindow | undefined {
52
+ const scoped = quota?.scoped;
53
+ if (!Array.isArray(scoped) || scoped.length === 0) return undefined;
54
+ if (!modelId) return scoped[0];
55
+ const id = modelId.toLowerCase();
56
+ return scoped.find((w) => {
57
+ const name = String((w as any).id ?? (w as any).scope?.model?.display_name ?? "").toLowerCase();
58
+ return name && (id.includes(name) || name.includes(id));
59
+ }) ?? scoped[0];
60
+ }
61
+
62
+ /**
63
+ * Capacity score. Higher is better, 0 means unusable.
64
+ *
65
+ * Costs are summed and inverted rather than taking the minimum window, so a
66
+ * cheap fast-refilling window still contributes instead of being masked by a
67
+ * slow one. Any window at zero yields Infinity and zeroes the account, which
68
+ * preserves the hard exclusion rule.
69
+ */
70
+ export function candidateWeight(candidate: Candidate, family: Family, modelId?: string, reserve = 0): number {
71
+ const quota = candidate.quota;
72
+ if (!quota) return 0;
73
+
74
+ const costs = [
75
+ recoveryCost(quota.five_hour, "five_hour", reserve),
76
+ recoveryCost(quota.seven_day, "seven_day", reserve),
77
+ ];
78
+
79
+ // Fable requests are additionally gated by their scoped window.
80
+ if (family === "fable") {
81
+ const scoped = scopedWindowFor(quota, modelId);
82
+ if (scoped) costs.push(recoveryCost(scoped, "scoped", reserve));
83
+ }
84
+
85
+ const total = costs.reduce((sum, c) => sum + c, 0);
86
+ return Number.isFinite(total) && total > 0 ? 1 / total : 0;
87
+ }
88
+
89
+ export interface Assignment {
90
+ accountId: string;
91
+ family: Family;
92
+ assignedAt: number;
93
+ lastSeenAt: number;
94
+ }
95
+
96
+ export interface SelectInput {
97
+ candidates: Candidate[];
98
+ family: Family;
99
+ modelId?: string;
100
+ mode: RoutingMode;
101
+ /** Existing sticky assignment for this session, if any. */
102
+ assignment?: Assignment;
103
+ now?: number;
104
+ }
105
+
106
+ export interface Selection {
107
+ candidate: Candidate;
108
+ reason: "sticky" | "weighted" | "main-first" | "fallback-first" | "only";
109
+ }
110
+
111
+ /**
112
+ * Picks an account.
113
+ *
114
+ * `main-first` and `fallback-first` are simple orderings. `sticky-balanced`
115
+ * keeps the session where it is while that account is still viable, and
116
+ * otherwise takes the highest-weight candidate.
117
+ */
118
+ export function selectAccount(input: SelectInput): Selection | undefined {
119
+ const usable = input.candidates.filter((c) => c.access);
120
+ if (usable.length === 0) return undefined;
121
+ if (usable.length === 1) return { candidate: usable[0], reason: "only" };
122
+
123
+ if (input.mode === "main-first" || input.mode === "fallback-first") {
124
+ const ordered = [...usable].sort((a, b) =>
125
+ input.mode === "main-first" ? a.order - b.order : b.order - a.order);
126
+ const viable = ordered.find((c) => candidateWeight(c, input.family, input.modelId) > 0);
127
+ return viable ? { candidate: viable, reason: input.mode } : undefined;
128
+ }
129
+
130
+ const weighted = usable
131
+ .map((candidate) => ({ candidate, weight: candidateWeight(candidate, input.family, input.modelId) }))
132
+ .filter((entry) => entry.weight > 0);
133
+ if (weighted.length === 0) return undefined;
134
+
135
+ // Stay put while the assigned account can still serve: migrating discards
136
+ // the prompt cache, which costs more than a slightly better weight gains.
137
+ if (input.assignment) {
138
+ const held = weighted.find((e) => e.candidate.id === input.assignment!.accountId);
139
+ if (held) return { candidate: held.candidate, reason: "sticky" };
140
+ }
141
+
142
+ weighted.sort((a, b) =>
143
+ b.weight - a.weight
144
+ || a.candidate.order - b.candidate.order
145
+ || a.candidate.id.localeCompare(b.candidate.id));
146
+ return { candidate: weighted[0].candidate, reason: "weighted" };
147
+ }
148
+
149
+ /** Seconds until the soonest window that would unblock this model resets. */
150
+ export function retryAfterSeconds(candidates: Candidate[], family: Family, modelId?: string, now = Date.now()): number {
151
+ const resets: number[] = [];
152
+ for (const candidate of candidates) {
153
+ const quota = candidate.quota;
154
+ if (!quota) continue;
155
+ const windows: (QuotaWindow | undefined)[] = [quota.five_hour, quota.seven_day];
156
+ if (family === "fable") windows.push(scopedWindowFor(quota, modelId));
157
+ for (const w of windows) {
158
+ if (!w?.resetsAt) continue;
159
+ if ((w.remainingPercent ?? 0) > 0) continue;
160
+ const at = Date.parse(w.resetsAt);
161
+ if (Number.isFinite(at) && at > now) resets.push(at);
162
+ }
163
+ }
164
+ if (resets.length === 0) return 60;
165
+ return Math.max(1, Math.ceil((Math.min(...resets) - now) / 1000));
166
+ }
167
+
168
+ export { MAIN_ACCOUNT_ID };
@@ -0,0 +1,225 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ /**
6
+ * Anthropic account store.
7
+ *
8
+ * Byte-compatible with @cortexkit/anthropic-auth-core so both implementations
9
+ * can read the same files and reverting stays possible. The split is theirs and
10
+ * is worth preserving: durable identity in `anthropic-auth.json`, live secrets
11
+ * and volatile quota in `anthropic-auth-state.json`.
12
+ *
13
+ * anthropic-auth.json id, label, type, enabled, addedAt, routing
14
+ * anthropic-auth-state.json access, refresh, expires, quota, lastUsed, ...
15
+ *
16
+ * Keeping secrets out of the config file means the config can be inspected,
17
+ * diffed or backed up without exposing tokens.
18
+ */
19
+
20
+ export const CONFIG_FILE = "anthropic-auth.json";
21
+ export const STATE_FILE = "anthropic-auth-state.json";
22
+
23
+ export interface QuotaWindow {
24
+ remainingPercent?: number;
25
+ usedPercent?: number;
26
+ resetsAt?: string;
27
+ checkedAt?: number;
28
+ id?: string;
29
+ }
30
+
31
+ export interface QuotaSnapshot {
32
+ five_hour?: QuotaWindow;
33
+ seven_day?: QuotaWindow;
34
+ scoped?: QuotaWindow[];
35
+ checkedAt?: number;
36
+ source?: "poll" | "headers";
37
+ [key: string]: unknown;
38
+ }
39
+
40
+ export interface Account {
41
+ id: string;
42
+ label?: string;
43
+ type: "oauth" | "api";
44
+ enabled?: boolean;
45
+ addedAt?: number;
46
+ // state-file fields
47
+ access?: string;
48
+ refresh?: string;
49
+ expires?: number;
50
+ lastUsed?: number;
51
+ lastRefreshedAt?: number;
52
+ authLineageId?: string;
53
+ quota?: QuotaSnapshot;
54
+ apiKey?: string;
55
+ }
56
+
57
+ export type RoutingMode = "main-first" | "fallback-first" | "sticky-balanced";
58
+
59
+ export interface Storage {
60
+ version?: number;
61
+ mainAccountId?: string;
62
+ main?: Record<string, unknown>;
63
+ accounts: Account[];
64
+ routing?: { mode?: RoutingMode };
65
+ [key: string]: unknown;
66
+ }
67
+
68
+ /** Fields that belong in the durable config file. */
69
+ const CONFIG_FIELDS = ["id", "label", "type", "enabled", "addedAt", "baseURL", "authHeader"] as const;
70
+ /** Fields that belong in the state file. */
71
+ const STATE_FIELDS = [
72
+ "authLineageId", "access", "refresh", "expires", "lastUsed",
73
+ "lastRefreshedAt", "lastRefreshError", "lastQuotaRefreshError", "quota", "profile", "prime", "apiKey",
74
+ ] as const;
75
+
76
+ function agentDir(): string {
77
+ return process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
78
+ }
79
+
80
+ export function configPath(): string {
81
+ return process.env.PI_ANTHROPIC_AUTH_FILE ?? join(agentDir(), CONFIG_FILE);
82
+ }
83
+
84
+ export function statePath(config = configPath()): string {
85
+ return config.endsWith(CONFIG_FILE) ? join(dirname(config), STATE_FILE) : `${config}.state.json`;
86
+ }
87
+
88
+ function readJson<T>(path: string): T | undefined {
89
+ try {
90
+ return JSON.parse(readFileSync(path, "utf8")) as T;
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ }
95
+
96
+ /** Temp-file + rename, so a crash cannot leave a half-written credential file. */
97
+ function writeJson(path: string, value: unknown): void {
98
+ mkdirSync(dirname(path), { recursive: true });
99
+ const temp = `${path}.${process.pid}.tmp`;
100
+ writeFileSync(temp, JSON.stringify(value, undefined, 2), { encoding: "utf8", mode: 0o600 });
101
+ renameSync(temp, path);
102
+ }
103
+
104
+ const pick = <T extends object>(source: any, keys: readonly string[]): T =>
105
+ Object.fromEntries(keys.filter((k) => source?.[k] !== undefined).map((k) => [k, source[k]])) as T;
106
+
107
+ export function emptyStorage(): Storage {
108
+ return { version: 1, accounts: [], routing: { mode: "main-first" } };
109
+ }
110
+
111
+ /**
112
+ * Reads both files and rejoins them into whole accounts.
113
+ *
114
+ * A state file can exist without a config file: a main-account refresh writes
115
+ * state but never touches config. Returns undefined only when neither exists.
116
+ */
117
+ export function loadAccounts(config = configPath()): Storage | undefined {
118
+ const cfg = readJson<Storage>(config);
119
+ const state = readJson<{ main?: any; accounts?: Record<string, any> }>(statePath(config));
120
+ if (!cfg && !state) return undefined;
121
+
122
+ const base: Storage = cfg ?? emptyStorage();
123
+ const byId = state?.accounts ?? {};
124
+
125
+ return {
126
+ ...base,
127
+ main: { ...(base.main ?? {}), ...(state?.main ?? {}) },
128
+ accounts: (base.accounts ?? []).map((account) => ({ ...account, ...(byId[account.id] ?? {}) })),
129
+ };
130
+ }
131
+
132
+ /** Writes both files, routing each field to the correct one. */
133
+ export function saveAccounts(storage: Storage, config = configPath()): void {
134
+ const existingCfg = readJson<Record<string, unknown>>(config) ?? {};
135
+ const existingState = readJson<Record<string, any>>(statePath(config)) ?? {};
136
+
137
+ const cfg = {
138
+ ...existingCfg,
139
+ ...storage,
140
+ version: storage.version ?? 1,
141
+ accounts: storage.accounts.map((a) => pick(a, CONFIG_FIELDS)),
142
+ };
143
+ delete (cfg as any).main;
144
+
145
+ const state = {
146
+ ...existingState,
147
+ version: 1,
148
+ main: storage.main && Object.keys(storage.main).length ? storage.main : undefined,
149
+ accounts: Object.fromEntries(storage.accounts.map((a) => [a.id, pick(a, STATE_FIELDS)])),
150
+ };
151
+ if (state.main === undefined) delete state.main;
152
+
153
+ writeJson(config, cfg);
154
+ writeJson(statePath(config), state);
155
+ }
156
+
157
+ /** Inserts or replaces one account, leaving every other account untouched. */
158
+ export function saveAccount(account: Account, config = configPath()): void {
159
+ const storage = loadAccounts(config) ?? emptyStorage();
160
+ const index = storage.accounts.findIndex((a) => a.id === account.id);
161
+ if (index >= 0) storage.accounts[index] = { ...storage.accounts[index], ...account };
162
+ else storage.accounts.push(account);
163
+ saveAccounts(storage, config);
164
+ }
165
+
166
+ export function removeAccount(id: string, config = configPath()): boolean {
167
+ const storage = loadAccounts(config);
168
+ if (!storage) return false;
169
+ const before = storage.accounts.length;
170
+ storage.accounts = storage.accounts.filter((a) => a.id !== id);
171
+ if (storage.accounts.length === before) return false;
172
+ saveAccounts(storage, config);
173
+ return true;
174
+ }
175
+
176
+ export const isOAuthAccount = (a: Account): boolean => a.type === "oauth" && !!a.refresh;
177
+ export const isUsable = (a: Account): boolean => a.enabled !== false && isOAuthAccount(a);
178
+
179
+ export function getRoutingMode(storage: Storage | undefined): RoutingMode {
180
+ const mode = storage?.routing?.mode;
181
+ return mode === "sticky-balanced" || mode === "fallback-first" || mode === "main-first" ? mode : "main-first";
182
+ }
183
+
184
+ export function setRoutingMode(mode: RoutingMode, config = configPath()): Storage {
185
+ const storage = loadAccounts(config) ?? emptyStorage();
186
+ storage.routing = { ...(storage.routing ?? {}), mode };
187
+ saveAccounts(storage, config);
188
+ return storage;
189
+ }
190
+
191
+ /**
192
+ * The pseudo-account id for pi's own Anthropic credential.
193
+ *
194
+ * `main` is NOT one of `storage.accounts`. Its tokens live in pi's `auth.json`
195
+ * and reach the provider as the primary access token at request time;
196
+ * `mainAccountId` is only a stable name for it, and an empty `main` block in
197
+ * the state file is normal.
198
+ *
199
+ * This is easy to mistake for stale data and delete. Doing so breaks
200
+ * `main-first` routing and orphans every routing assignment targeting `main`.
201
+ */
202
+ export const MAIN_ACCOUNT_ID = "main";
203
+
204
+ /**
205
+ * Drops only references that genuinely cannot resolve: config accounts with no
206
+ * credentials in the state file.
207
+ *
208
+ * Deliberately leaves `mainAccountId` and an empty `main` block alone, for the
209
+ * reason above.
210
+ */
211
+ export function pruneStale(config = configPath()): string[] {
212
+ const storage = loadAccounts(config);
213
+ if (!storage) return [];
214
+ const fixes: string[] = [];
215
+
216
+ const hasCreds = (a: Account) => !!(a.access || a.refresh || a.apiKey);
217
+ const before = storage.accounts.length;
218
+ storage.accounts = storage.accounts.filter(hasCreds);
219
+ if (storage.accounts.length !== before) {
220
+ fixes.push(`removed ${before - storage.accounts.length} credential-less account(s)`);
221
+ }
222
+
223
+ if (fixes.length) saveAccounts(storage, config);
224
+ return fixes;
225
+ }
@@ -0,0 +1,36 @@
1
+ # Vendored: xxhash-wasm
2
+
3
+ `xxhash-wasm.js` is the ESM build of [xxhash-wasm](https://github.com/jungomi/xxhash-wasm),
4
+ MIT licensed (see `xxhash-wasm.LICENSE.md`), copied in verbatim.
5
+
6
+ ## Why vendored rather than depended on
7
+
8
+ The billing checksum in `../client-identity.ts` needs xxHash64. Two things were
9
+ tried before this:
10
+
11
+ 1. **A hand-written BigInt implementation.** It produced correct values for
12
+ empty and very short inputs and wrong values for everything longer. Four
13
+ separate bugs were found and fixed, each time with the output still wrong.
14
+ Guessing further at a hash function was not converging.
15
+ 2. **`xxhash-wasm` as an npm dependency.** Rejected: the point of the extraction
16
+ is that no unaudited package sits in the auth path.
17
+
18
+ Vendoring gets both: the algorithm is known-correct, and the code lives in this
19
+ repository with no install-time dependency.
20
+
21
+ ## What this is
22
+
23
+ A single self-contained ~11 KB `.js` file with the compiled WebAssembly inlined
24
+ as a byte array. It is not fetched, built, or downloaded at runtime. The only
25
+ import is the file itself.
26
+
27
+ ## Regenerating
28
+
29
+ ```sh
30
+ npm pack xxhash-wasm@<version>
31
+ tar -xzf xxhash-wasm-*.tgz
32
+ cp package/esm/xxhash-wasm.js src/core/anthropic/vendor/
33
+ ```
34
+
35
+ Record the version when you do. The current copy came from `xxhash-wasm` as
36
+ installed by pi's own tree on 2026-09-19.