@jameslovespancakes/pi-plus 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +190 -190
- package/config/pi-plus.example.json +60 -60
- package/config/skills/model-routing/SKILL.md +86 -86
- package/images/pi-plus.svg +10 -10
- package/package.json +67 -67
- package/server/board-server.mjs +641 -641
- package/server/package.json +17 -17
- package/src/core/accounts/registry.ts +93 -93
- package/src/core/anthropic/client-identity.ts +241 -241
- package/src/core/catalog/quality.ts +314 -314
- package/src/core/config.ts +169 -169
- package/src/core/env.ts +58 -58
- package/src/core/exec/process.ts +146 -146
- package/src/core/exec/ssh-config.ts +157 -157
- package/src/core/policy/policy.ts +183 -183
- package/src/core/quota/pool.ts +64 -64
- package/src/core/quota/usage-source.ts +289 -289
- package/src/core/store.ts +43 -43
- package/src/domains/agents/board-setup.ts +409 -409
- package/src/domains/agents/index.ts +462 -462
- package/src/domains/models/catalog-tool.ts +361 -361
- package/src/domains/models/index.ts +14 -14
- package/src/domains/models/policy-gate.ts +169 -169
- package/src/domains/models/provider-picker.ts +207 -207
- package/src/domains/remote/config-path.ts +41 -41
- package/src/domains/remote/index.ts +866 -866
- package/src/domains/remote/setup.ts +425 -425
- package/src/domains/setup/index.ts +220 -220
- package/src/domains/subscriptions/accounts.ts +242 -242
- package/src/domains/subscriptions/footer.ts +182 -182
- package/src/domains/subscriptions/index.ts +42 -42
- package/src/domains/subscriptions/provider.ts +219 -219
- package/src/domains/subscriptions/providers/anthropic.ts +149 -149
- package/src/domains/subscriptions/providers/codex.ts +148 -148
- package/src/domains/subscriptions/routing.ts +72 -72
- package/src/services/usage-service.ts +186 -186
- package/src/ui/format.ts +73 -73
- package/src/ui/usage-bars.ts +154 -154
- package/src/vendor/anthropic.ts +109 -109
|
@@ -1,149 +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
|
-
};
|
|
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
|
+
};
|
|
@@ -1,148 +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
|
-
};
|
|
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
|
+
};
|