@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.
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/config/pi-plus.example.json +60 -0
- package/config/skills/model-routing/SKILL.md +86 -0
- package/images/board_demo.png +0 -0
- package/images/pi-plus.svg +10 -0
- package/images/pi-plus_demo.png +0 -0
- package/images/provider_demo.png +0 -0
- package/images/remote_demo.png +0 -0
- package/images/usage_demo.png +0 -0
- package/package.json +67 -0
- package/server/board-server.mjs +641 -0
- package/server/package.json +17 -0
- package/src/core/accounts/registry.ts +93 -0
- package/src/core/anthropic/client-identity.ts +241 -0
- package/src/core/anthropic/models.ts +69 -0
- package/src/core/anthropic/oauth.ts +208 -0
- package/src/core/anthropic/quota.ts +253 -0
- package/src/core/anthropic/routing.ts +168 -0
- package/src/core/anthropic/store.ts +225 -0
- package/src/core/anthropic/vendor/README.md +36 -0
- package/src/core/anthropic/vendor/xxhash-wasm.LICENSE.md +25 -0
- package/src/core/anthropic/vendor/xxhash-wasm.js +2 -0
- package/src/core/anthropic/xxhash64.ts +33 -0
- package/src/core/catalog/quality.ts +314 -0
- package/src/core/codex/oauth.ts +129 -0
- package/src/core/codex/quota.ts +88 -0
- package/src/core/codex/store.ts +97 -0
- package/src/core/config.ts +169 -0
- package/src/core/env.ts +58 -0
- package/src/core/exec/process.ts +146 -0
- package/src/core/exec/ssh-config.ts +157 -0
- package/src/core/oauth/pkce.ts +88 -0
- package/src/core/policy/policy.ts +183 -0
- package/src/core/quota/pool.ts +64 -0
- package/src/core/quota/usage-source.ts +289 -0
- package/src/core/store.ts +43 -0
- package/src/domains/agents/board-setup.ts +409 -0
- package/src/domains/agents/index.ts +462 -0
- package/src/domains/models/catalog-tool.ts +361 -0
- package/src/domains/models/index.ts +14 -0
- package/src/domains/models/policy-gate.ts +169 -0
- package/src/domains/models/provider-picker.ts +208 -0
- package/src/domains/remote/config-path.ts +41 -0
- package/src/domains/remote/index.ts +866 -0
- package/src/domains/remote/setup.ts +425 -0
- package/src/domains/setup/index.ts +220 -0
- package/src/domains/subscriptions/accounts-picker.ts +178 -0
- package/src/domains/subscriptions/accounts.ts +242 -0
- package/src/domains/subscriptions/footer.ts +182 -0
- package/src/domains/subscriptions/index.ts +42 -0
- package/src/domains/subscriptions/provider.ts +219 -0
- package/src/domains/subscriptions/providers/anthropic.ts +149 -0
- package/src/domains/subscriptions/providers/codex.ts +148 -0
- package/src/domains/subscriptions/routing.ts +72 -0
- package/src/services/usage-service.ts +186 -0
- package/src/ui/format.ts +73 -0
- package/src/ui/usage-bars.ts +154 -0
- 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.
|