@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,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
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { AccountContext, AccountProvider, ManagedAccount, RoutingMode } from "../../../core/accounts/registry.ts";
|
|
3
|
+
import { authorize, exchange } from "../../../core/anthropic/oauth.ts";
|
|
4
|
+
import {
|
|
5
|
+
getRoutingMode,
|
|
6
|
+
loadAccounts,
|
|
7
|
+
saveAccount,
|
|
8
|
+
setRoutingMode,
|
|
9
|
+
type RoutingMode as StoreRoutingMode,
|
|
10
|
+
} from "../../../core/anthropic/store.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Anthropic adapter.
|
|
14
|
+
*
|
|
15
|
+
* pi itself holds one Anthropic credential; the additional accounts live in
|
|
16
|
+
* our own store and tokens are swapped per request. Everything
|
|
17
|
+
* provider-specific lives here so `/accounts` and `/routing` stay generic.
|
|
18
|
+
*
|
|
19
|
+
* This used to read through `vendor/anthropic.ts`, which loaded the cortexkit
|
|
20
|
+
* package. That package was removed when the provider was extracted, so every
|
|
21
|
+
* call here threw and the account list silently came back empty. It now uses
|
|
22
|
+
* the extracted store directly.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The registry's vocabulary maps onto the store's routing modes. */
|
|
26
|
+
const MODE_TO_STORE: Record<RoutingMode, StoreRoutingMode> = {
|
|
27
|
+
standard: "main-first",
|
|
28
|
+
optimal: "sticky-balanced",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function fromStoreMode(mode: string): RoutingMode {
|
|
32
|
+
return mode === "sticky-balanced" ? "optimal" : "standard";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function authenticate(ctx: AccountContext): Promise<{ access: string; refresh: string; expires: number } | undefined> {
|
|
36
|
+
const auth = await authorize("max");
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
await ctx.openBrowser(auth.url);
|
|
40
|
+
} catch {
|
|
41
|
+
ctx.ui.notify(`Open this authorization URL:\n${auth.url}`, "warning");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const callback = await ctx.ui.input("Claude OAuth", "Paste the callback URL or authorization code");
|
|
45
|
+
if (!callback) return undefined;
|
|
46
|
+
|
|
47
|
+
const result = await exchange(callback, auth.verifier, auth.redirectUri, auth.state);
|
|
48
|
+
if (result.type !== "success") {
|
|
49
|
+
throw new Error(`Claude OAuth exchange failed: ${result.reason}`);
|
|
50
|
+
}
|
|
51
|
+
return { access: result.access, refresh: result.refresh, expires: result.expires };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const anthropicAccounts: AccountProvider = {
|
|
55
|
+
id: "anthropic",
|
|
56
|
+
label: "Claude",
|
|
57
|
+
|
|
58
|
+
async list(): Promise<ManagedAccount[]> {
|
|
59
|
+
const storage = loadAccounts();
|
|
60
|
+
return (storage?.accounts ?? [])
|
|
61
|
+
.filter((account) => account.type === "oauth")
|
|
62
|
+
.map((account) => ({
|
|
63
|
+
id: account.id,
|
|
64
|
+
label: account.label ?? account.id.slice(0, 8),
|
|
65
|
+
enabled: account.enabled !== false,
|
|
66
|
+
expiresAt: typeof account.expires === "number" ? account.expires : undefined,
|
|
67
|
+
}));
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
async add(ctx, label): Promise<string | undefined> {
|
|
71
|
+
const proceed = await ctx.ui.confirm(
|
|
72
|
+
"Add Claude account",
|
|
73
|
+
"Use a different authorized Anthropic account in the browser. Continue?",
|
|
74
|
+
);
|
|
75
|
+
if (!proceed) return undefined;
|
|
76
|
+
|
|
77
|
+
const result = await authenticate(ctx);
|
|
78
|
+
if (!result) return undefined;
|
|
79
|
+
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
saveAccount({
|
|
82
|
+
id: randomUUID(),
|
|
83
|
+
label,
|
|
84
|
+
type: "oauth",
|
|
85
|
+
enabled: true,
|
|
86
|
+
access: result.access,
|
|
87
|
+
refresh: result.refresh,
|
|
88
|
+
expires: result.expires,
|
|
89
|
+
addedAt: now,
|
|
90
|
+
lastRefreshedAt: now,
|
|
91
|
+
authLineageId: randomUUID(),
|
|
92
|
+
});
|
|
93
|
+
return label;
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async reauth(ctx, accountId): Promise<string | undefined> {
|
|
97
|
+
const storage = loadAccounts();
|
|
98
|
+
const account = storage?.accounts.find(
|
|
99
|
+
(candidate) => candidate.id === accountId || candidate.label === accountId,
|
|
100
|
+
);
|
|
101
|
+
if (!account || account.type !== "oauth") {
|
|
102
|
+
throw new Error(`OAuth account “${accountId}” not found.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const result = await authenticate(ctx);
|
|
106
|
+
if (!result) return undefined;
|
|
107
|
+
|
|
108
|
+
saveAccount({
|
|
109
|
+
...account,
|
|
110
|
+
access: result.access,
|
|
111
|
+
refresh: result.refresh,
|
|
112
|
+
expires: result.expires,
|
|
113
|
+
lastRefreshedAt: Date.now(),
|
|
114
|
+
authLineageId: randomUUID(),
|
|
115
|
+
});
|
|
116
|
+
return account.label ?? account.id;
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
async setEnabled(accountId: string, enabled: boolean): Promise<void> {
|
|
120
|
+
const storage = loadAccounts();
|
|
121
|
+
const account = storage?.accounts.find((candidate) => candidate.id === accountId);
|
|
122
|
+
if (!account) throw new Error(`Account “${accountId}” not found.`);
|
|
123
|
+
// Credentials are preserved; only the eligibility flag changes, so a
|
|
124
|
+
// disabled account can be re-enabled without another OAuth round trip.
|
|
125
|
+
saveAccount({ ...account, enabled });
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
async rename(accountId: string, label: string): Promise<void> {
|
|
129
|
+
const account = loadAccounts()?.accounts.find((candidate) => candidate.id === accountId);
|
|
130
|
+
if (!account) throw new Error(`Account “${accountId}” not found.`);
|
|
131
|
+
saveAccount({ ...account, label });
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
routing: {
|
|
135
|
+
async get(): Promise<RoutingMode> {
|
|
136
|
+
return fromStoreMode(getRoutingMode(loadAccounts()));
|
|
137
|
+
},
|
|
138
|
+
async set(mode: RoutingMode): Promise<RoutingMode> {
|
|
139
|
+
setRoutingMode(MODE_TO_STORE[mode]);
|
|
140
|
+
return mode;
|
|
141
|
+
},
|
|
142
|
+
describe(mode: RoutingMode): string {
|
|
143
|
+
return mode === "optimal"
|
|
144
|
+
? "Balances sessions across subscriptions using remaining quota and time until reset. "
|
|
145
|
+
+ "Keeps session caches sticky; migrates on confirmed exhaustion."
|
|
146
|
+
: "Main subscription first, then eligible fallbacks.";
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { AccountContext, AccountProvider, ManagedAccount, RoutingMode } from "../../../core/accounts/registry.ts";
|
|
3
|
+
import { authorizeCodex, exchangeCodex } from "../../../core/codex/oauth.ts";
|
|
4
|
+
import {
|
|
5
|
+
claimsOf,
|
|
6
|
+
loadCodexAccounts,
|
|
7
|
+
saveCodexAccount,
|
|
8
|
+
saveCodexAccounts,
|
|
9
|
+
type CodexAccount,
|
|
10
|
+
} from "../../../core/codex/store.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Codex adapter.
|
|
14
|
+
*
|
|
15
|
+
* Structurally the same as the Anthropic one, with two differences that come
|
|
16
|
+
* from the provider rather than from us:
|
|
17
|
+
* - quota arrives only through response headers, so there is no poll;
|
|
18
|
+
* - every request must carry the account's `chatgpt-account-id`, so the
|
|
19
|
+
* account id is stored alongside the tokens rather than derived later.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
async function authenticate(ctx: AccountContext) {
|
|
23
|
+
const auth = await authorizeCodex();
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
await ctx.openBrowser(auth.url);
|
|
27
|
+
} catch {
|
|
28
|
+
ctx.ui.notify(`Open this authorization URL:\n${auth.url}`, "warning");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const callback = await ctx.ui.input("Codex OAuth", "Paste the callback URL or authorization code");
|
|
32
|
+
if (!callback) return undefined;
|
|
33
|
+
|
|
34
|
+
return exchangeCodex(callback, auth.verifier, auth.redirectUri, auth.state);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function describePlan(account: CodexAccount): string {
|
|
38
|
+
return account.plan ? `${account.label ?? account.id.slice(0, 8)} (${account.plan})` : (account.label ?? account.id.slice(0, 8));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const codexAccounts: AccountProvider = {
|
|
42
|
+
id: "openai-codex",
|
|
43
|
+
label: "Codex",
|
|
44
|
+
|
|
45
|
+
async list(): Promise<ManagedAccount[]> {
|
|
46
|
+
return loadCodexAccounts().accounts.map((account) => ({
|
|
47
|
+
id: account.id,
|
|
48
|
+
label: describePlan(account),
|
|
49
|
+
enabled: account.enabled !== false,
|
|
50
|
+
expiresAt: typeof account.expires === "number" ? account.expires : undefined,
|
|
51
|
+
}));
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
async add(ctx, label): Promise<string | undefined> {
|
|
55
|
+
const proceed = await ctx.ui.confirm(
|
|
56
|
+
"Add Codex account",
|
|
57
|
+
"Sign in with a DIFFERENT ChatGPT account in the browser. Continue?",
|
|
58
|
+
);
|
|
59
|
+
if (!proceed) return undefined;
|
|
60
|
+
|
|
61
|
+
const tokens = await authenticate(ctx);
|
|
62
|
+
if (!tokens) return undefined;
|
|
63
|
+
|
|
64
|
+
const claims = claimsOf(tokens.access);
|
|
65
|
+
|
|
66
|
+
// Adding the same ChatGPT account twice gives routing two entries backed by
|
|
67
|
+
// one quota pool, which looks like capacity that does not exist.
|
|
68
|
+
const existing = loadCodexAccounts().accounts.find(
|
|
69
|
+
(a) => claims.accountId && a.accountId === claims.accountId,
|
|
70
|
+
);
|
|
71
|
+
if (existing) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`That ChatGPT account is already added as “${existing.label ?? existing.id.slice(0, 8)}”. `
|
|
74
|
+
+ "Use the browser's account switcher to sign in as a different account.",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
saveCodexAccount({
|
|
80
|
+
id: randomUUID(),
|
|
81
|
+
label,
|
|
82
|
+
enabled: true,
|
|
83
|
+
access: tokens.access,
|
|
84
|
+
refresh: tokens.refresh,
|
|
85
|
+
expires: tokens.expires,
|
|
86
|
+
accountId: claims.accountId,
|
|
87
|
+
plan: claims.plan,
|
|
88
|
+
addedAt: now,
|
|
89
|
+
lastRefreshedAt: now,
|
|
90
|
+
});
|
|
91
|
+
return label;
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
async reauth(ctx, accountId): Promise<string | undefined> {
|
|
95
|
+
const account = loadCodexAccounts().accounts.find(
|
|
96
|
+
(a) => a.id === accountId || a.label === accountId,
|
|
97
|
+
);
|
|
98
|
+
if (!account) throw new Error(`Codex account “${accountId}” not found.`);
|
|
99
|
+
|
|
100
|
+
const tokens = await authenticate(ctx);
|
|
101
|
+
if (!tokens) return undefined;
|
|
102
|
+
|
|
103
|
+
const claims = claimsOf(tokens.access);
|
|
104
|
+
saveCodexAccount({
|
|
105
|
+
...account,
|
|
106
|
+
access: tokens.access,
|
|
107
|
+
refresh: tokens.refresh,
|
|
108
|
+
expires: tokens.expires,
|
|
109
|
+
accountId: claims.accountId ?? account.accountId,
|
|
110
|
+
plan: claims.plan ?? account.plan,
|
|
111
|
+
lastRefreshedAt: Date.now(),
|
|
112
|
+
});
|
|
113
|
+
return account.label ?? account.id;
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async setEnabled(accountId: string, enabled: boolean): Promise<void> {
|
|
117
|
+
const storage = loadCodexAccounts();
|
|
118
|
+
const account = storage.accounts.find((a) => a.id === accountId);
|
|
119
|
+
if (!account) throw new Error(`Account “${accountId}” not found.`);
|
|
120
|
+
account.enabled = enabled;
|
|
121
|
+
saveCodexAccounts(storage);
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async rename(accountId: string, label: string): Promise<void> {
|
|
125
|
+
const storage = loadCodexAccounts();
|
|
126
|
+
const account = storage.accounts.find((a) => a.id === accountId);
|
|
127
|
+
if (!account) throw new Error(`Account “${accountId}” not found.`);
|
|
128
|
+
account.label = label;
|
|
129
|
+
saveCodexAccounts(storage);
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
routing: {
|
|
133
|
+
async get(): Promise<RoutingMode> {
|
|
134
|
+
return loadCodexAccounts().routing?.mode === "optimal" ? "optimal" : "standard";
|
|
135
|
+
},
|
|
136
|
+
async set(mode: RoutingMode): Promise<RoutingMode> {
|
|
137
|
+
const storage = loadCodexAccounts();
|
|
138
|
+
storage.routing = { ...(storage.routing ?? {}), mode };
|
|
139
|
+
saveCodexAccounts(storage);
|
|
140
|
+
return mode;
|
|
141
|
+
},
|
|
142
|
+
describe(mode: RoutingMode): string {
|
|
143
|
+
return mode === "optimal"
|
|
144
|
+
? "Balances across Codex subscriptions using remaining quota and time until reset."
|
|
145
|
+
: "Main Codex subscription first, then eligible fallbacks.";
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { accountProvider, routableProviders, type RoutingMode } from "../../core/accounts/registry.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `/routing [standard|optimal] [provider]`
|
|
6
|
+
*
|
|
7
|
+
* standard main account first, fall back only when exhausted
|
|
8
|
+
* optimal balance across accounts by remaining quota and time to reset
|
|
9
|
+
*
|
|
10
|
+
* With no provider named, the mode is applied to every provider that supports
|
|
11
|
+
* routing. With no mode, the current modes are reported.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const MODES: RoutingMode[] = ["standard", "optimal"];
|
|
15
|
+
|
|
16
|
+
function isMode(value: string): value is RoutingMode {
|
|
17
|
+
return (MODES as string[]).includes(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function registerRoutingCommands(pi: ExtensionAPI): void {
|
|
21
|
+
pi.registerCommand("routing", {
|
|
22
|
+
description: "Account routing: standard (main first) or optimal (quota balanced)",
|
|
23
|
+
getArgumentCompletions: (prefix) =>
|
|
24
|
+
MODES.filter((mode) => mode.startsWith(prefix)).map((mode) => ({
|
|
25
|
+
value: mode,
|
|
26
|
+
label: mode === "optimal" ? "optimal: balance by remaining quota" : "standard: main account first",
|
|
27
|
+
})),
|
|
28
|
+
handler: async (args, ctx) => {
|
|
29
|
+
const [first, second] = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
30
|
+
const providers = second ? [accountProvider(second)].filter((entry) => !!entry) : routableProviders();
|
|
31
|
+
|
|
32
|
+
if (providers.length === 0) {
|
|
33
|
+
ctx.ui.notify(
|
|
34
|
+
second ? `“${second}” does not support account routing.` : "No providers support account routing.",
|
|
35
|
+
"warning",
|
|
36
|
+
);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// No mode given: report current state.
|
|
41
|
+
if (!first) {
|
|
42
|
+
const lines: string[] = [];
|
|
43
|
+
for (const provider of providers) {
|
|
44
|
+
try {
|
|
45
|
+
const mode = await provider.routing!.get();
|
|
46
|
+
lines.push(`${provider.label} (${provider.id}): ${mode}`, ` ${provider.routing!.describe(mode)}`);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
lines.push(`${provider.label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!isMode(first)) {
|
|
56
|
+
ctx.ui.notify(`Usage: /routing [standard|optimal] [provider]`, "warning");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const results: string[] = [];
|
|
61
|
+
for (const provider of providers) {
|
|
62
|
+
try {
|
|
63
|
+
const mode = await provider.routing!.set(first);
|
|
64
|
+
results.push(`${provider.label}: ${mode}`, ` ${provider.routing!.describe(mode)}`);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
results.push(`${provider.label}: failed, ${error instanceof Error ? error.message : String(error)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
ctx.ui.notify(results.join("\n"), "info");
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|