@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.
Files changed (40) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +190 -190
  3. package/config/pi-plus.example.json +60 -60
  4. package/config/skills/model-routing/SKILL.md +86 -86
  5. package/images/pi-plus.svg +10 -10
  6. package/package.json +67 -67
  7. package/server/board-server.mjs +641 -641
  8. package/server/package.json +17 -17
  9. package/src/core/accounts/registry.ts +93 -93
  10. package/src/core/anthropic/client-identity.ts +241 -241
  11. package/src/core/catalog/quality.ts +314 -314
  12. package/src/core/config.ts +169 -169
  13. package/src/core/env.ts +58 -58
  14. package/src/core/exec/process.ts +146 -146
  15. package/src/core/exec/ssh-config.ts +157 -157
  16. package/src/core/policy/policy.ts +183 -183
  17. package/src/core/quota/pool.ts +64 -64
  18. package/src/core/quota/usage-source.ts +289 -289
  19. package/src/core/store.ts +43 -43
  20. package/src/domains/agents/board-setup.ts +409 -409
  21. package/src/domains/agents/index.ts +462 -462
  22. package/src/domains/models/catalog-tool.ts +361 -361
  23. package/src/domains/models/index.ts +14 -14
  24. package/src/domains/models/policy-gate.ts +169 -169
  25. package/src/domains/models/provider-picker.ts +207 -207
  26. package/src/domains/remote/config-path.ts +41 -41
  27. package/src/domains/remote/index.ts +866 -866
  28. package/src/domains/remote/setup.ts +425 -425
  29. package/src/domains/setup/index.ts +220 -220
  30. package/src/domains/subscriptions/accounts.ts +242 -242
  31. package/src/domains/subscriptions/footer.ts +182 -182
  32. package/src/domains/subscriptions/index.ts +42 -42
  33. package/src/domains/subscriptions/provider.ts +219 -219
  34. package/src/domains/subscriptions/providers/anthropic.ts +149 -149
  35. package/src/domains/subscriptions/providers/codex.ts +148 -148
  36. package/src/domains/subscriptions/routing.ts +72 -72
  37. package/src/services/usage-service.ts +186 -186
  38. package/src/ui/format.ts +73 -73
  39. package/src/ui/usage-bars.ts +154 -154
  40. package/src/vendor/anthropic.ts +109 -109
@@ -1,219 +1,219 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { authorize, exchange, refreshToken } from "../../core/anthropic/oauth.ts";
3
- import {
4
- billingHeader, clientIdentityHeaders, firstUserText, prependPromptBlock, signRequestBody, splitSystemPrompt,
5
- } from "../../core/anthropic/client-identity.ts";
6
- import { ANTHROPIC_MODELS } from "../../core/anthropic/models.ts";
7
- import { applyQuotaHeaders, refreshAllQuota } from "../../core/anthropic/quota.ts";
8
- import { applyCodexQuotaHeaders } from "../../core/codex/quota.ts";
9
- import { loadCodexAccounts } from "../../core/codex/store.ts";
10
- import {
11
- MAIN_ACCOUNT_ID, familyForModel, selectAccount, type Candidate,
12
- } from "../../core/anthropic/routing.ts";
13
- import { getRoutingMode, loadAccounts, saveAccount } from "../../core/anthropic/store.ts";
14
-
15
- /**
16
- * The Anthropic provider.
17
- *
18
- * Rather than reimplementing the Messages API, this registers pi's built-in
19
- * `anthropic-messages` implementation and supplies only the credential. pi
20
- * resolves auth per request, so `getApiKey` is the hook where account routing
21
- * happens: it returns the token of whichever account the router picked.
22
- *
23
- * Consequences of that choice, stated plainly:
24
- * - `getApiKey` is synchronous, so selection reads the CACHED quota snapshot.
25
- * A background timer keeps it fresh instead of polling inline.
26
- * - A mid-stream 429 is handled by pi's retry rather than by migrating the
27
- * in-flight request to another account. The next request routes elsewhere
28
- * once the failure is recorded, which is a real behavioural difference from
29
- * the vendored provider.
30
- *
31
- * What this buys: pi's streaming, tool conversion, cost accounting and retry
32
- * logic are reused unchanged, instead of a 1,500-line reimplementation whose
33
- * edge cases we could not see.
34
- */
35
-
36
- let lastSelected: { id: string; at: number } | undefined;
37
-
38
- /** Which account served the most recent request, for the UI. */
39
- export function lastRoutedAccount(): { id: string; at: number } | undefined {
40
- return lastSelected;
41
- }
42
-
43
- /**
44
- * Chooses an account and returns its access token.
45
- *
46
- * `primary` is pi's own credential, which participates as `main` at order 0.
47
- * Falls back to the primary token whenever routing has nothing better, so a
48
- * single-account setup behaves exactly as it did before any of this existed.
49
- */
50
- // `_sessionId` is accepted for signature compatibility with pi's per-request
51
- // hook; stickiness is derived from stored assignments rather than the id.
52
- export function routeAccessToken(primary: string, modelId?: string, _sessionId?: string): string {
53
- const storage = loadAccounts();
54
- if (!storage) return primary;
55
-
56
- const mode = getRoutingMode(storage);
57
- const family = familyForModel(modelId);
58
-
59
- const candidates: Candidate[] = [
60
- { id: MAIN_ACCOUNT_ID, access: primary, quota: storage.main?.quota as any, order: 0 },
61
- ...storage.accounts
62
- .filter((a) => a.enabled !== false && a.type === "oauth" && a.access)
63
- .map((a, index) => ({ id: a.id, access: a.access, quota: a.quota, order: index + 1, account: a })),
64
- ];
65
-
66
- // Nothing to choose between: keep pi's own credential.
67
- if (candidates.length <= 1) return primary;
68
-
69
- const picked = selectAccount({ candidates, family, modelId, mode });
70
- if (!picked) {
71
- // Everything is quota-blocked. Returning the primary lets Anthropic issue
72
- // the authoritative 429 rather than inventing a local failure.
73
- return primary;
74
- }
75
-
76
- lastSelected = { id: picked.candidate.id, at: Date.now() };
77
- if (picked.candidate.account) {
78
- saveAccount({ ...picked.candidate.account, lastUsed: Date.now() });
79
- }
80
- return picked.candidate.access ?? primary;
81
- }
82
-
83
- async function login(callbacks: any) {
84
- const auth = await authorize("max");
85
- callbacks.onAuth({ url: auth.url });
86
- const pasted = await callbacks.onPrompt({ message: "Paste the Claude OAuth callback URL or code:" });
87
- const result = await exchange(pasted, auth.verifier, auth.redirectUri, auth.state);
88
- if (result.type !== "success") throw new Error(`Anthropic OAuth failed: ${result.reason}`);
89
- return { access: result.access, refresh: result.refresh, expires: result.expires };
90
- }
91
-
92
- export function registerAnthropicProvider(pi: ExtensionAPI): void {
93
- pi.registerProvider("anthropic", {
94
- name: "Anthropic",
95
- baseUrl: "https://api.anthropic.com",
96
- api: "anthropic-messages",
97
- models: ANTHROPIC_MODELS,
98
- // See core/anthropic/client-identity.ts. Without these, Anthropic bills
99
- // requests to extra usage rather than plan limits. Delete that import and
100
- // this line to opt out; nothing else depends on it.
101
- headers: clientIdentityHeaders(),
102
- oauth: {
103
- name: "Anthropic Claude Pro/Max",
104
- isSubscription: true,
105
- login,
106
- refreshToken: async (credentials: any) => {
107
- const refreshed = await refreshToken({ refreshToken: credentials.refresh });
108
- return { access: refreshed.access, refresh: refreshed.refresh, expires: refreshed.expires };
109
- },
110
- // The routing hook. pi calls this per request.
111
- getApiKey: (credentials: any) => routeAccessToken(credentials.access),
112
- },
113
- });
114
-
115
- /**
116
- * Reshapes the request the way the endpoint requires, then signs it.
117
- *
118
- * Three things have to happen, in order:
119
- * 1. pi's system prompt is split. The documentation paragraph cannot live in
120
- * `system` at all, or Anthropic answers 400 "Third-party apps now draw
121
- * from your extra usage"; it moves into the first user message instead.
122
- * 2. The billing header goes first in `system`, alongside the Claude Code
123
- * identity line.
124
- * 3. The body is signed, which must happen last because the checksum covers
125
- * the canonicalised body.
126
- *
127
- * See core/anthropic/client-identity.ts for what all of this is and the
128
- * caveats around it.
129
- */
130
- pi.on("before_provider_request", async (event: any) => {
131
- const payload = event?.payload;
132
- if (!payload || typeof payload !== "object") return undefined;
133
-
134
- const serialized = typeof payload.system === "string"
135
- ? payload.system
136
- : (payload.system ?? []).map((b: any) => b?.text ?? "").join("\n\n");
137
- const split = splitSystemPrompt(serialized);
138
-
139
- const messages = structuredClone(payload.messages ?? []);
140
- prependPromptBlock(messages, split.messageText);
141
-
142
- const system = [
143
- { type: "text", text: billingHeader(firstUserText(messages)) },
144
- { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." },
145
- ...(split.systemText ? [{ type: "text", text: split.systemText }] : []),
146
- ];
147
-
148
- const signed = await signRequestBody(JSON.stringify({ ...payload, system, messages }));
149
- return JSON.parse(signed);
150
- });
151
-
152
- /**
153
- * Free quota refresh for whichever account just served a request.
154
- *
155
- * Anthropic returns the same utilisation numbers as the usage endpoint in
156
- * `anthropic-ratelimit-unified-*` response headers, so the active account
157
- * stays current without spending a request on asking.
158
- *
159
- * `lastRoutedAccount()` is set by `getApiKey` immediately before the call,
160
- * which is what lets the reply be attributed to the right account.
161
- */
162
- pi.on("after_provider_response", (event: any) => {
163
- const routed = lastRoutedAccount();
164
- // `main` lives in pi's auth.json, not in storage.accounts, so there is no
165
- // per-account record to update for it.
166
- if (!routed || routed.id === MAIN_ACCOUNT_ID) return;
167
- try {
168
- applyQuotaHeaders(routed.id, event?.headers);
169
- } catch {
170
- // Never let bookkeeping disturb a response.
171
- }
172
- });
173
-
174
- /**
175
- * The same trick for Codex, which reports usage as `x-codex-*` headers.
176
- *
177
- * Codex has no usage endpoint, so headers are the ONLY quota source: an
178
- * account that is not serving traffic keeps whatever snapshot it last had.
179
- * Attribution is by `chatgpt-account-id` on the request, since Codex
180
- * requests carry the account explicitly rather than only in the token.
181
- */
182
- pi.on("after_provider_response", (event: any) => {
183
- const headers = event?.headers;
184
- if (!headers) return;
185
- const hasCodexQuota = Object.keys(headers).some((k) => k.toLowerCase().startsWith("x-codex-"));
186
- if (!hasCodexQuota) return;
187
- try {
188
- const sent = event?.request?.headers ?? {};
189
- const accountId = sent["chatgpt-account-id"] ?? sent["Chatgpt-Account-Id"];
190
- const match = loadCodexAccounts().accounts.find(
191
- (a) => (accountId ? a.accountId === accountId : false) || a.enabled !== false,
192
- );
193
- if (match) applyCodexQuotaHeaders(match.id, headers);
194
- } catch {
195
- // Bookkeeping only.
196
- }
197
- });
198
-
199
- /**
200
- * Idle accounts still need polling, because routing compares accounts and
201
- * response headers only ever refresh the one that served traffic.
202
- *
203
- * Triggered by sending a message, not by a timer, and `refreshAllQuota`
204
- * skips accounts whose snapshot is still fresh. So the poll rate is at most
205
- * once per QUOTA_FRESH_MS however fast messages are sent, and an idle
206
- * session costs nothing at all. The previous 5 minute interval ran forever
207
- * regardless of activity and was being answered with 429s.
208
- *
209
- * `input` is the per-message hook; `turn_start` would fire once per LLM
210
- * response, which is many times per message in a tool-use loop.
211
- */
212
- pi.on("input", async () => {
213
- void refreshAllQuota().catch(() => {});
214
- });
215
-
216
- pi.on("session_start", async () => {
217
- void refreshAllQuota().catch(() => {});
218
- });
219
- }
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { authorize, exchange, refreshToken } from "../../core/anthropic/oauth.ts";
3
+ import {
4
+ billingHeader, clientIdentityHeaders, firstUserText, prependPromptBlock, signRequestBody, splitSystemPrompt,
5
+ } from "../../core/anthropic/client-identity.ts";
6
+ import { ANTHROPIC_MODELS } from "../../core/anthropic/models.ts";
7
+ import { applyQuotaHeaders, refreshAllQuota } from "../../core/anthropic/quota.ts";
8
+ import { applyCodexQuotaHeaders } from "../../core/codex/quota.ts";
9
+ import { loadCodexAccounts } from "../../core/codex/store.ts";
10
+ import {
11
+ MAIN_ACCOUNT_ID, familyForModel, selectAccount, type Candidate,
12
+ } from "../../core/anthropic/routing.ts";
13
+ import { getRoutingMode, loadAccounts, saveAccount } from "../../core/anthropic/store.ts";
14
+
15
+ /**
16
+ * The Anthropic provider.
17
+ *
18
+ * Rather than reimplementing the Messages API, this registers pi's built-in
19
+ * `anthropic-messages` implementation and supplies only the credential. pi
20
+ * resolves auth per request, so `getApiKey` is the hook where account routing
21
+ * happens: it returns the token of whichever account the router picked.
22
+ *
23
+ * Consequences of that choice, stated plainly:
24
+ * - `getApiKey` is synchronous, so selection reads the CACHED quota snapshot.
25
+ * A background timer keeps it fresh instead of polling inline.
26
+ * - A mid-stream 429 is handled by pi's retry rather than by migrating the
27
+ * in-flight request to another account. The next request routes elsewhere
28
+ * once the failure is recorded, which is a real behavioural difference from
29
+ * the vendored provider.
30
+ *
31
+ * What this buys: pi's streaming, tool conversion, cost accounting and retry
32
+ * logic are reused unchanged, instead of a 1,500-line reimplementation whose
33
+ * edge cases we could not see.
34
+ */
35
+
36
+ let lastSelected: { id: string; at: number } | undefined;
37
+
38
+ /** Which account served the most recent request, for the UI. */
39
+ export function lastRoutedAccount(): { id: string; at: number } | undefined {
40
+ return lastSelected;
41
+ }
42
+
43
+ /**
44
+ * Chooses an account and returns its access token.
45
+ *
46
+ * `primary` is pi's own credential, which participates as `main` at order 0.
47
+ * Falls back to the primary token whenever routing has nothing better, so a
48
+ * single-account setup behaves exactly as it did before any of this existed.
49
+ */
50
+ // `_sessionId` is accepted for signature compatibility with pi's per-request
51
+ // hook; stickiness is derived from stored assignments rather than the id.
52
+ export function routeAccessToken(primary: string, modelId?: string, _sessionId?: string): string {
53
+ const storage = loadAccounts();
54
+ if (!storage) return primary;
55
+
56
+ const mode = getRoutingMode(storage);
57
+ const family = familyForModel(modelId);
58
+
59
+ const candidates: Candidate[] = [
60
+ { id: MAIN_ACCOUNT_ID, access: primary, quota: storage.main?.quota as any, order: 0 },
61
+ ...storage.accounts
62
+ .filter((a) => a.enabled !== false && a.type === "oauth" && a.access)
63
+ .map((a, index) => ({ id: a.id, access: a.access, quota: a.quota, order: index + 1, account: a })),
64
+ ];
65
+
66
+ // Nothing to choose between: keep pi's own credential.
67
+ if (candidates.length <= 1) return primary;
68
+
69
+ const picked = selectAccount({ candidates, family, modelId, mode });
70
+ if (!picked) {
71
+ // Everything is quota-blocked. Returning the primary lets Anthropic issue
72
+ // the authoritative 429 rather than inventing a local failure.
73
+ return primary;
74
+ }
75
+
76
+ lastSelected = { id: picked.candidate.id, at: Date.now() };
77
+ if (picked.candidate.account) {
78
+ saveAccount({ ...picked.candidate.account, lastUsed: Date.now() });
79
+ }
80
+ return picked.candidate.access ?? primary;
81
+ }
82
+
83
+ async function login(callbacks: any) {
84
+ const auth = await authorize("max");
85
+ callbacks.onAuth({ url: auth.url });
86
+ const pasted = await callbacks.onPrompt({ message: "Paste the Claude OAuth callback URL or code:" });
87
+ const result = await exchange(pasted, auth.verifier, auth.redirectUri, auth.state);
88
+ if (result.type !== "success") throw new Error(`Anthropic OAuth failed: ${result.reason}`);
89
+ return { access: result.access, refresh: result.refresh, expires: result.expires };
90
+ }
91
+
92
+ export function registerAnthropicProvider(pi: ExtensionAPI): void {
93
+ pi.registerProvider("anthropic", {
94
+ name: "Anthropic",
95
+ baseUrl: "https://api.anthropic.com",
96
+ api: "anthropic-messages",
97
+ models: ANTHROPIC_MODELS,
98
+ // See core/anthropic/client-identity.ts. Without these, Anthropic bills
99
+ // requests to extra usage rather than plan limits. Delete that import and
100
+ // this line to opt out; nothing else depends on it.
101
+ headers: clientIdentityHeaders(),
102
+ oauth: {
103
+ name: "Anthropic Claude Pro/Max",
104
+ isSubscription: true,
105
+ login,
106
+ refreshToken: async (credentials: any) => {
107
+ const refreshed = await refreshToken({ refreshToken: credentials.refresh });
108
+ return { access: refreshed.access, refresh: refreshed.refresh, expires: refreshed.expires };
109
+ },
110
+ // The routing hook. pi calls this per request.
111
+ getApiKey: (credentials: any) => routeAccessToken(credentials.access),
112
+ },
113
+ });
114
+
115
+ /**
116
+ * Reshapes the request the way the endpoint requires, then signs it.
117
+ *
118
+ * Three things have to happen, in order:
119
+ * 1. pi's system prompt is split. The documentation paragraph cannot live in
120
+ * `system` at all, or Anthropic answers 400 "Third-party apps now draw
121
+ * from your extra usage"; it moves into the first user message instead.
122
+ * 2. The billing header goes first in `system`, alongside the Claude Code
123
+ * identity line.
124
+ * 3. The body is signed, which must happen last because the checksum covers
125
+ * the canonicalised body.
126
+ *
127
+ * See core/anthropic/client-identity.ts for what all of this is and the
128
+ * caveats around it.
129
+ */
130
+ pi.on("before_provider_request", async (event: any) => {
131
+ const payload = event?.payload;
132
+ if (!payload || typeof payload !== "object") return undefined;
133
+
134
+ const serialized = typeof payload.system === "string"
135
+ ? payload.system
136
+ : (payload.system ?? []).map((b: any) => b?.text ?? "").join("\n\n");
137
+ const split = splitSystemPrompt(serialized);
138
+
139
+ const messages = structuredClone(payload.messages ?? []);
140
+ prependPromptBlock(messages, split.messageText);
141
+
142
+ const system = [
143
+ { type: "text", text: billingHeader(firstUserText(messages)) },
144
+ { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." },
145
+ ...(split.systemText ? [{ type: "text", text: split.systemText }] : []),
146
+ ];
147
+
148
+ const signed = await signRequestBody(JSON.stringify({ ...payload, system, messages }));
149
+ return JSON.parse(signed);
150
+ });
151
+
152
+ /**
153
+ * Free quota refresh for whichever account just served a request.
154
+ *
155
+ * Anthropic returns the same utilisation numbers as the usage endpoint in
156
+ * `anthropic-ratelimit-unified-*` response headers, so the active account
157
+ * stays current without spending a request on asking.
158
+ *
159
+ * `lastRoutedAccount()` is set by `getApiKey` immediately before the call,
160
+ * which is what lets the reply be attributed to the right account.
161
+ */
162
+ pi.on("after_provider_response", (event: any) => {
163
+ const routed = lastRoutedAccount();
164
+ // `main` lives in pi's auth.json, not in storage.accounts, so there is no
165
+ // per-account record to update for it.
166
+ if (!routed || routed.id === MAIN_ACCOUNT_ID) return;
167
+ try {
168
+ applyQuotaHeaders(routed.id, event?.headers);
169
+ } catch {
170
+ // Never let bookkeeping disturb a response.
171
+ }
172
+ });
173
+
174
+ /**
175
+ * The same trick for Codex, which reports usage as `x-codex-*` headers.
176
+ *
177
+ * Codex has no usage endpoint, so headers are the ONLY quota source: an
178
+ * account that is not serving traffic keeps whatever snapshot it last had.
179
+ * Attribution is by `chatgpt-account-id` on the request, since Codex
180
+ * requests carry the account explicitly rather than only in the token.
181
+ */
182
+ pi.on("after_provider_response", (event: any) => {
183
+ const headers = event?.headers;
184
+ if (!headers) return;
185
+ const hasCodexQuota = Object.keys(headers).some((k) => k.toLowerCase().startsWith("x-codex-"));
186
+ if (!hasCodexQuota) return;
187
+ try {
188
+ const sent = event?.request?.headers ?? {};
189
+ const accountId = sent["chatgpt-account-id"] ?? sent["Chatgpt-Account-Id"];
190
+ const match = loadCodexAccounts().accounts.find(
191
+ (a) => (accountId ? a.accountId === accountId : false) || a.enabled !== false,
192
+ );
193
+ if (match) applyCodexQuotaHeaders(match.id, headers);
194
+ } catch {
195
+ // Bookkeeping only.
196
+ }
197
+ });
198
+
199
+ /**
200
+ * Idle accounts still need polling, because routing compares accounts and
201
+ * response headers only ever refresh the one that served traffic.
202
+ *
203
+ * Triggered by sending a message, not by a timer, and `refreshAllQuota`
204
+ * skips accounts whose snapshot is still fresh. So the poll rate is at most
205
+ * once per QUOTA_FRESH_MS however fast messages are sent, and an idle
206
+ * session costs nothing at all. The previous 5 minute interval ran forever
207
+ * regardless of activity and was being answered with 429s.
208
+ *
209
+ * `input` is the per-message hook; `turn_start` would fire once per LLM
210
+ * response, which is many times per message in a tool-use loop.
211
+ */
212
+ pi.on("input", async () => {
213
+ void refreshAllQuota().catch(() => {});
214
+ });
215
+
216
+ pi.on("session_start", async () => {
217
+ void refreshAllQuota().catch(() => {});
218
+ });
219
+ }