@jameslovespancakes/pi-plus 1.0.12 → 1.0.14
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/README.md +7 -3
- package/package.json +7 -6
- package/src/core/accounts/oauth-pool.ts +35 -0
- package/src/core/accounts/registry.ts +4 -0
- package/src/core/accounts/routing.ts +22 -0
- package/src/core/anthropic/identity.ts +54 -0
- package/src/core/anthropic/oauth.ts +3 -0
- package/src/core/anthropic/quota.ts +227 -28
- package/src/core/anthropic/store.ts +4 -1
- package/src/core/codex/quota.ts +43 -11
- package/src/core/codex/store.ts +21 -11
- package/src/core/config.ts +8 -10
- package/src/domains/agents/format.ts +39 -0
- package/src/domains/agents/index.ts +39 -50
- package/src/domains/remote/config-path.ts +0 -2
- package/src/domains/remote/index.ts +4 -41
- package/src/domains/subscriptions/accounts-picker.ts +44 -4
- package/src/domains/subscriptions/accounts.ts +31 -8
- package/src/domains/subscriptions/index.ts +2 -2
- package/src/domains/subscriptions/provider.ts +46 -5
- package/src/domains/subscriptions/providers/anthropic.ts +37 -5
- package/src/domains/subscriptions/providers/codex.ts +120 -254
- package/src/domains/subscriptions/providers/hosted.ts +2 -2
- package/src/domains/subscriptions/providers/oauth-pool.ts +124 -72
- package/src/domains/workflows/index.ts +1 -1
- package/src/domains/workflows/runtime/agent-attempt.ts +41 -11
- package/src/domains/workflows/runtime/agent-failure.ts +14 -0
- package/src/domains/workflows/runtime/agent-limits.ts +24 -1
- package/src/domains/workflows/runtime/agent-retry.ts +11 -6
- package/src/domains/workflows/runtime/agent-runner.ts +42 -20
- package/src/domains/workflows/runtime/agent-workspace.ts +0 -24
- package/src/domains/workflows/runtime/engine.ts +48 -12
- package/src/domains/workflows/runtime/progress.ts +2 -2
- package/src/domains/workflows/runtime/types.ts +4 -4
- package/src/domains/workflows/runtime/ui/display-text.ts +15 -0
- package/src/domains/workflows/runtime/ui/workflow-inspector.ts +51 -14
- package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +6 -6
- package/src/domains/workflows/runtime/workflow-run-history.ts +3 -1
- package/src/domains/workflows/runtime/workflow-run-record.ts +1 -0
- package/src/domains/workflows/runtime/worktree.ts +35 -1
- package/src/ui/usage-bars.ts +1 -2
- package/src/core/codex/oauth.ts +0 -129
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { AccountContext, AccountProvider, ManagedAccount, RoutingMode } from "../../../core/accounts/registry.ts";
|
|
3
|
+
import { anthropicAccountIdentity } from "../../../core/anthropic/identity.ts";
|
|
3
4
|
import { authorize, exchange } from "../../../core/anthropic/oauth.ts";
|
|
5
|
+
import { ensureAccessToken } from "../../../core/anthropic/quota.ts";
|
|
4
6
|
import {
|
|
5
7
|
getRoutingMode,
|
|
6
8
|
loadAccounts,
|
|
7
9
|
saveAccount,
|
|
8
10
|
setRoutingMode,
|
|
11
|
+
type Account,
|
|
9
12
|
} from "../../../core/anthropic/store.ts";
|
|
10
13
|
|
|
11
14
|
/**
|
|
@@ -40,22 +43,45 @@ async function authenticate(ctx: AccountContext): Promise<{ access: string; refr
|
|
|
40
43
|
return { access: result.access, refresh: result.refresh, expires: result.expires };
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
/** Resolves and persists identity once; token rotation does not change it. */
|
|
47
|
+
async function stableIdentity(account: Account): Promise<string | undefined> {
|
|
48
|
+
if (account.identity) return account.identity;
|
|
49
|
+
let access = account.access;
|
|
50
|
+
if (!access || (typeof account.expires === "number" && account.expires <= Date.now())) {
|
|
51
|
+
try { access = await ensureAccessToken(account); } catch { return undefined; }
|
|
52
|
+
}
|
|
53
|
+
if (!access) return undefined;
|
|
54
|
+
const identity = await anthropicAccountIdentity(access);
|
|
55
|
+
if (!identity) return undefined;
|
|
56
|
+
|
|
57
|
+
const current = loadAccounts()?.accounts.find((candidate) => candidate.id === account.id);
|
|
58
|
+
if (current && !current.identity) saveAccount({ ...current, identity });
|
|
59
|
+
return identity;
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
export const anthropicAccounts: AccountProvider = {
|
|
44
63
|
id: "anthropic",
|
|
45
64
|
label: "Claude",
|
|
46
65
|
|
|
47
66
|
async list(): Promise<ManagedAccount[]> {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
67
|
+
const accounts = (loadAccounts()?.accounts ?? []).filter((account) => account.type === "oauth");
|
|
68
|
+
const result: ManagedAccount[] = [];
|
|
69
|
+
// Bootstrap is deliberately serial: this is a one-time migration for old
|
|
70
|
+
// rows, and concurrent profile requests can be throttled independently.
|
|
71
|
+
for (const account of accounts) {
|
|
72
|
+
result.push({
|
|
52
73
|
id: account.id,
|
|
53
74
|
label: account.label ?? account.id.slice(0, 8),
|
|
54
75
|
enabled: account.enabled !== false,
|
|
55
76
|
expiresAt: typeof account.expires === "number" ? account.expires : undefined,
|
|
56
|
-
|
|
77
|
+
identity: await stableIdentity(account),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
57
81
|
},
|
|
58
82
|
|
|
83
|
+
identify: anthropicAccountIdentity,
|
|
84
|
+
|
|
59
85
|
async add(ctx, label): Promise<string | undefined> {
|
|
60
86
|
const proceed = await ctx.ui.confirm(
|
|
61
87
|
"Add Claude account",
|
|
@@ -67,6 +93,7 @@ export const anthropicAccounts: AccountProvider = {
|
|
|
67
93
|
if (!result) return undefined;
|
|
68
94
|
|
|
69
95
|
const now = Date.now();
|
|
96
|
+
const identity = await anthropicAccountIdentity(result.access);
|
|
70
97
|
saveAccount({
|
|
71
98
|
id: randomUUID(),
|
|
72
99
|
label,
|
|
@@ -77,6 +104,7 @@ export const anthropicAccounts: AccountProvider = {
|
|
|
77
104
|
expires: result.expires,
|
|
78
105
|
addedAt: now,
|
|
79
106
|
lastRefreshedAt: now,
|
|
107
|
+
identity,
|
|
80
108
|
authLineageId: randomUUID(),
|
|
81
109
|
});
|
|
82
110
|
return label;
|
|
@@ -94,12 +122,16 @@ export const anthropicAccounts: AccountProvider = {
|
|
|
94
122
|
const result = await authenticate(ctx);
|
|
95
123
|
if (!result) return undefined;
|
|
96
124
|
|
|
125
|
+
const identity = await anthropicAccountIdentity(result.access);
|
|
97
126
|
saveAccount({
|
|
98
127
|
...account,
|
|
99
128
|
access: result.access,
|
|
100
129
|
refresh: result.refresh,
|
|
101
130
|
expires: result.expires,
|
|
102
131
|
lastRefreshedAt: Date.now(),
|
|
132
|
+
lastRefreshError: undefined,
|
|
133
|
+
identity,
|
|
134
|
+
quota: undefined,
|
|
103
135
|
authLineageId: randomUUID(),
|
|
104
136
|
});
|
|
105
137
|
return account.label ?? account.id;
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { ModelAuth, OAuthAuth, OAuthCredential } from "@earendil-works/pi-ai";
|
|
1
|
+
import type { OAuthCredential } from "@earendil-works/pi-ai";
|
|
3
2
|
import { openaiCodexProvider } from "@earendil-works/pi-ai/providers/openai-codex";
|
|
4
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import type {
|
|
6
|
-
import { normalizeRoutingMode,
|
|
7
|
-
import {
|
|
4
|
+
import type { RoutingMode } from "../../../core/accounts/registry.ts";
|
|
5
|
+
import { normalizeRoutingMode, type AccountQuotaState } from "../../../core/accounts/routing.ts";
|
|
6
|
+
import type { PooledOAuthAccount, PooledOAuthStore } from "../../../core/accounts/oauth-pool.ts";
|
|
8
7
|
import { applyCodexQuotaHeaders } from "../../../core/codex/quota.ts";
|
|
9
8
|
import {
|
|
10
9
|
MAIN_ACCOUNT_ID,
|
|
@@ -14,170 +13,47 @@ import {
|
|
|
14
13
|
saveCodexAccounts,
|
|
15
14
|
type CodexAccount,
|
|
16
15
|
} from "../../../core/codex/store.ts";
|
|
16
|
+
import {
|
|
17
|
+
chooseCredential,
|
|
18
|
+
createPooledOAuthAdapter,
|
|
19
|
+
registerPooledOAuthProvider,
|
|
20
|
+
type PooledOAuthProviderSpec,
|
|
21
|
+
} from "./oauth-pool.ts";
|
|
17
22
|
|
|
18
23
|
/**
|
|
19
24
|
* Codex adapter.
|
|
20
25
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
+
* Codex shares the pooled serving path with every other subscription provider
|
|
27
|
+
* (one `routedAuth`, one bounded refresh, one quota observer). Only the two
|
|
28
|
+
* genuinely provider-specific pieces are supplied here:
|
|
29
|
+
*
|
|
30
|
+
* - storage stays in `codex-accounts.json`, because it carries `accountId`
|
|
31
|
+
* (which the usage bars read) and a rich quota snapshot that the generic
|
|
32
|
+
* pool schema has no room for;
|
|
33
|
+
* - quota arrives through `x-codex-*` response headers rather than the
|
|
34
|
+
* generic `x-ratelimit-*` set, so there is no polling path at all.
|
|
35
|
+
*
|
|
36
|
+
* The ChatGPT account id does NOT need to be threaded through auth: the
|
|
37
|
+
* provider derives it from the access token's JWT claims and sets the
|
|
38
|
+
* `chatgpt-account-id` header itself, so swapping the token is self-consistent.
|
|
26
39
|
*/
|
|
27
40
|
|
|
28
|
-
async function authenticate(ctx: AccountContext) {
|
|
29
|
-
const auth = await authorizeCodex();
|
|
30
|
-
|
|
31
|
-
try {
|
|
32
|
-
await ctx.openBrowser(auth.url);
|
|
33
|
-
} catch {
|
|
34
|
-
ctx.ui.notify(`Open this authorization URL:\n${auth.url}`, "warning");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const callback = await ctx.ui.input("Codex OAuth", "Paste the callback URL or authorization code");
|
|
38
|
-
if (!callback) return undefined;
|
|
39
|
-
|
|
40
|
-
return exchangeCodex(callback, auth.verifier, auth.redirectUri, auth.state);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
41
|
function describePlan(account: CodexAccount): string {
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
type RoutableCodexAccount = CodexAccount & { access: string; refresh: string; expires: number };
|
|
48
|
-
|
|
49
|
-
const refreshes = new Map<string, Promise<RoutableCodexAccount>>();
|
|
50
|
-
const codexLastUsed = new Map<string, number>();
|
|
51
|
-
|
|
52
|
-
function isRoutableCodexAccount(account: CodexAccount): account is RoutableCodexAccount {
|
|
53
|
-
return account.enabled !== false
|
|
54
|
-
&& typeof account.access === "string"
|
|
55
|
-
&& typeof account.refresh === "string"
|
|
56
|
-
&& typeof account.expires === "number";
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export const codexAccounts: AccountProvider = {
|
|
60
|
-
id: "openai-codex",
|
|
61
|
-
label: "Codex",
|
|
62
|
-
|
|
63
|
-
async list(): Promise<ManagedAccount[]> {
|
|
64
|
-
return loadCodexAccounts().accounts.map((account) => ({
|
|
65
|
-
id: account.id,
|
|
66
|
-
label: describePlan(account),
|
|
67
|
-
enabled: account.enabled !== false,
|
|
68
|
-
expiresAt: typeof account.expires === "number" ? account.expires : undefined,
|
|
69
|
-
}));
|
|
70
|
-
},
|
|
71
|
-
|
|
72
|
-
async add(ctx, label): Promise<string | undefined> {
|
|
73
|
-
const proceed = await ctx.ui.confirm(
|
|
74
|
-
"Add Codex account",
|
|
75
|
-
"Sign in with a DIFFERENT ChatGPT account in the browser. Continue?",
|
|
76
|
-
);
|
|
77
|
-
if (!proceed) return undefined;
|
|
78
|
-
|
|
79
|
-
const tokens = await authenticate(ctx);
|
|
80
|
-
if (!tokens) return undefined;
|
|
81
|
-
|
|
82
|
-
const claims = claimsOf(tokens.access);
|
|
83
|
-
|
|
84
|
-
// Adding the same ChatGPT account twice gives routing two entries backed by
|
|
85
|
-
// one quota pool, which looks like capacity that does not exist.
|
|
86
|
-
const existing = loadCodexAccounts().accounts.find(
|
|
87
|
-
(a) => claims.accountId && a.accountId === claims.accountId,
|
|
88
|
-
);
|
|
89
|
-
if (existing) {
|
|
90
|
-
throw new Error(
|
|
91
|
-
`That ChatGPT account is already added as “${existing.label ?? existing.id.slice(0, 8)}”. `
|
|
92
|
-
+ "Use the browser's account switcher to sign in as a different account.",
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const now = Date.now();
|
|
97
|
-
saveCodexAccount({
|
|
98
|
-
id: randomUUID(),
|
|
99
|
-
label,
|
|
100
|
-
enabled: true,
|
|
101
|
-
access: tokens.access,
|
|
102
|
-
refresh: tokens.refresh,
|
|
103
|
-
expires: tokens.expires,
|
|
104
|
-
accountId: claims.accountId,
|
|
105
|
-
plan: claims.plan,
|
|
106
|
-
addedAt: now,
|
|
107
|
-
lastRefreshedAt: now,
|
|
108
|
-
});
|
|
109
|
-
return label;
|
|
110
|
-
},
|
|
111
|
-
|
|
112
|
-
async reauth(ctx, accountId): Promise<string | undefined> {
|
|
113
|
-
const account = loadCodexAccounts().accounts.find(
|
|
114
|
-
(a) => a.id === accountId || a.label === accountId,
|
|
115
|
-
);
|
|
116
|
-
if (!account) throw new Error(`Codex account “${accountId}” not found.`);
|
|
117
|
-
|
|
118
|
-
const tokens = await authenticate(ctx);
|
|
119
|
-
if (!tokens) return undefined;
|
|
120
|
-
|
|
121
|
-
const claims = claimsOf(tokens.access);
|
|
122
|
-
saveCodexAccount({
|
|
123
|
-
...account,
|
|
124
|
-
access: tokens.access,
|
|
125
|
-
refresh: tokens.refresh,
|
|
126
|
-
expires: tokens.expires,
|
|
127
|
-
accountId: claims.accountId ?? account.accountId,
|
|
128
|
-
plan: claims.plan ?? account.plan,
|
|
129
|
-
lastRefreshedAt: Date.now(),
|
|
130
|
-
});
|
|
131
|
-
return account.label ?? account.id;
|
|
132
|
-
},
|
|
133
|
-
|
|
134
|
-
async setEnabled(accountId: string, enabled: boolean): Promise<void> {
|
|
135
|
-
const storage = loadCodexAccounts();
|
|
136
|
-
const account = storage.accounts.find((a) => a.id === accountId);
|
|
137
|
-
if (!account) throw new Error(`Account “${accountId}” not found.`);
|
|
138
|
-
account.enabled = enabled;
|
|
139
|
-
saveCodexAccounts(storage);
|
|
140
|
-
},
|
|
141
|
-
|
|
142
|
-
async rename(accountId: string, label: string): Promise<void> {
|
|
143
|
-
const storage = loadCodexAccounts();
|
|
144
|
-
const account = storage.accounts.find((a) => a.id === accountId);
|
|
145
|
-
if (!account) throw new Error(`Account “${accountId}” not found.`);
|
|
146
|
-
account.label = label;
|
|
147
|
-
saveCodexAccounts(storage);
|
|
148
|
-
},
|
|
149
|
-
|
|
150
|
-
routing: {
|
|
151
|
-
async get(): Promise<RoutingMode> {
|
|
152
|
-
return codexRoutingMode(loadCodexAccounts().routing?.mode);
|
|
153
|
-
},
|
|
154
|
-
async set(mode: RoutingMode): Promise<RoutingMode> {
|
|
155
|
-
const storage = loadCodexAccounts();
|
|
156
|
-
storage.routing = { ...(storage.routing ?? {}), mode };
|
|
157
|
-
saveCodexAccounts(storage);
|
|
158
|
-
return mode;
|
|
159
|
-
},
|
|
160
|
-
describe(mode: RoutingMode): string {
|
|
161
|
-
return mode === "quota-aware"
|
|
162
|
-
? "Uses the Codex subscription with the most remaining quota."
|
|
163
|
-
: "Uses Codex subscriptions in order, moving on when one is exhausted.";
|
|
164
|
-
},
|
|
165
|
-
},
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
export function codexRoutingMode(value: string | undefined): RoutingMode {
|
|
169
|
-
return normalizeRoutingMode(value);
|
|
42
|
+
const name = account.label ?? account.id.slice(0, 8);
|
|
43
|
+
return account.plan ? `${name} (${account.plan})` : name;
|
|
170
44
|
}
|
|
171
45
|
|
|
46
|
+
/** Routing view of a stored Codex snapshot. */
|
|
172
47
|
function quotaState(quota: CodexAccount["quota"], blockedUntil?: number): AccountQuotaState | undefined {
|
|
48
|
+
const blocked = blockedUntil && blockedUntil > Date.now() ? blockedUntil : undefined;
|
|
173
49
|
if (!quota) {
|
|
174
|
-
return
|
|
175
|
-
? { remainingPercent: 0, checkedAt: Date.now(), blockedUntil }
|
|
176
|
-
: undefined;
|
|
50
|
+
return blocked ? { remainingPercent: 0, checkedAt: Date.now(), blockedUntil: blocked } : undefined;
|
|
177
51
|
}
|
|
178
52
|
const windows = [quota.five_hour, quota.seven_day, ...(quota.scoped ?? [])]
|
|
179
53
|
.filter((window) => window && Number.isFinite(window.remainingPercent));
|
|
180
|
-
if (windows.length === 0)
|
|
54
|
+
if (windows.length === 0) {
|
|
55
|
+
return blocked ? { remainingPercent: 0, checkedAt: quota.checkedAt ?? Date.now(), blockedUntil: blocked } : undefined;
|
|
56
|
+
}
|
|
181
57
|
|
|
182
58
|
const remainingPercent = Math.min(...windows.map((window) => window!.remainingPercent!));
|
|
183
59
|
const resetTimes = windows
|
|
@@ -186,89 +62,73 @@ function quotaState(quota: CodexAccount["quota"], blockedUntil?: number): Accoun
|
|
|
186
62
|
.filter(Number.isFinite);
|
|
187
63
|
const resetAt = resetTimes.length > 0 ? Math.min(...resetTimes) : undefined;
|
|
188
64
|
return {
|
|
189
|
-
remainingPercent:
|
|
65
|
+
remainingPercent: blocked ? 0 : remainingPercent,
|
|
190
66
|
resetAt,
|
|
191
67
|
checkedAt: quota.checkedAt ?? Date.now(),
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
68
|
+
// An exhausted window with no reset time is already non-viable, so there is
|
|
69
|
+
// no sentinel here: `Infinity` would serialize to null and silently unblock.
|
|
70
|
+
blockedUntil: blocked ?? (remainingPercent <= 0 ? resetAt : undefined),
|
|
195
71
|
};
|
|
196
72
|
}
|
|
197
73
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
74
|
+
/** True when the account has a complete credential and can serve a request. */
|
|
75
|
+
function isRoutable(account: CodexAccount): boolean {
|
|
76
|
+
return typeof account.access === "string"
|
|
77
|
+
&& typeof account.refresh === "string"
|
|
78
|
+
&& typeof account.expires === "number";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Adapts `codex-accounts.json` to the pooled store contract.
|
|
83
|
+
*
|
|
84
|
+
* `quota` and `identity` are derived views, so they are never written back:
|
|
85
|
+
* the pool would otherwise overwrite the rich snapshot the usage bars read
|
|
86
|
+
* with the flattened routing state.
|
|
87
|
+
*/
|
|
88
|
+
const CODEX_STORE: PooledOAuthStore = {
|
|
89
|
+
load() {
|
|
90
|
+
const storage = loadCodexAccounts();
|
|
91
|
+
return {
|
|
92
|
+
mode: normalizeRoutingMode(storage.routing?.mode),
|
|
93
|
+
accounts: storage.accounts.map((account) => ({
|
|
94
|
+
...account,
|
|
95
|
+
type: "oauth" as const,
|
|
96
|
+
// Blanking access on an incomplete credential keeps it out of routing
|
|
97
|
+
// while still listing it under `/accounts`.
|
|
98
|
+
access: isRoutable(account) ? account.access! : "",
|
|
99
|
+
refresh: account.refresh ?? "",
|
|
100
|
+
expires: account.expires ?? 0,
|
|
101
|
+
label: account.label ?? "",
|
|
102
|
+
identity: account.accountId,
|
|
103
|
+
addedAt: account.addedAt ?? 0,
|
|
219
104
|
quota: quotaState(account.quota, account.blockedUntil),
|
|
220
|
-
value: {
|
|
221
|
-
id: account.id,
|
|
222
|
-
credential: { ...account, type: "oauth" as const } satisfies OAuthCredential,
|
|
223
|
-
account,
|
|
224
|
-
},
|
|
225
105
|
})),
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
106
|
+
};
|
|
107
|
+
},
|
|
229
108
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
109
|
+
saveAccount(account: PooledOAuthAccount) {
|
|
110
|
+
const { quota: _quota, identity: _identity, type: _type, ...rest } = account as PooledOAuthAccount & CodexAccount;
|
|
111
|
+
const claims = claimsOf(rest.access);
|
|
112
|
+
saveCodexAccount({
|
|
113
|
+
...rest,
|
|
114
|
+
accountId: rest.accountId ?? claims.accountId,
|
|
115
|
+
plan: rest.plan ?? claims.plan,
|
|
116
|
+
});
|
|
117
|
+
},
|
|
238
118
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}).finally(() => refreshes.delete(account.id));
|
|
245
|
-
refreshes.set(account.id, refresh);
|
|
246
|
-
return refresh;
|
|
247
|
-
}
|
|
119
|
+
saveMode(mode) {
|
|
120
|
+
const storage = loadCodexAccounts();
|
|
121
|
+
storage.routing = { ...(storage.routing ?? {}), mode };
|
|
122
|
+
saveCodexAccounts(storage);
|
|
123
|
+
},
|
|
248
124
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const account = selected.account
|
|
255
|
-
? await freshCodexAccount(oauth, selected.account, new AbortController().signal)
|
|
256
|
-
: undefined;
|
|
257
|
-
const credential: OAuthCredential = account ? { ...account, type: "oauth" } : primary;
|
|
258
|
-
const now = Date.now();
|
|
259
|
-
codexLastUsed.set(selected.id, now);
|
|
260
|
-
const storage = loadCodexAccounts();
|
|
261
|
-
if (selected.id === MAIN_ACCOUNT_ID) {
|
|
262
|
-
if (now - (storage.main?.lastUsed ?? 0) >= 60_000) {
|
|
263
|
-
storage.main = { ...storage.main, lastUsed: now };
|
|
264
|
-
saveCodexAccounts(storage);
|
|
265
|
-
}
|
|
266
|
-
} else if (account && now - (account.lastUsed ?? 0) >= 60_000) {
|
|
267
|
-
saveCodexAccount({ ...account, lastUsed: now });
|
|
268
|
-
}
|
|
269
|
-
return oauth.toAuth(credential);
|
|
270
|
-
}
|
|
125
|
+
primaryQuota() {
|
|
126
|
+
const storage = loadCodexAccounts();
|
|
127
|
+
return quotaState(storage.main?.quota, storage.main?.blockedUntil);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
271
130
|
|
|
131
|
+
/** Persists a rate-limit block so routing skips the account until it clears. */
|
|
272
132
|
function markCodexRateLimited(accountId: string, headers: Record<string, string>): void {
|
|
273
133
|
const raw = Object.entries(headers).find(([key]) => key.toLowerCase() === "retry-after")?.[1];
|
|
274
134
|
const seconds = Number(raw);
|
|
@@ -283,33 +143,39 @@ function markCodexRateLimited(accountId: string, headers: Record<string, string>
|
|
|
283
143
|
if (account) saveCodexAccount({ ...account, blockedUntil });
|
|
284
144
|
}
|
|
285
145
|
|
|
286
|
-
export
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
146
|
+
export const CODEX_SPEC: PooledOAuthProviderSpec<"openai-codex-responses"> = {
|
|
147
|
+
id: "openai-codex",
|
|
148
|
+
label: "Codex",
|
|
149
|
+
createProvider: openaiCodexProvider,
|
|
150
|
+
store: CODEX_STORE,
|
|
151
|
+
addPrompt: "Sign in with a DIFFERENT ChatGPT account in the browser. Continue?",
|
|
152
|
+
describeAccount: (account) => describePlan(account as PooledOAuthAccount & CodexAccount),
|
|
153
|
+
// Two entries backed by one ChatGPT account would look like capacity that
|
|
154
|
+
// does not exist, so duplicates are matched on the token's account claim.
|
|
155
|
+
identityOf: (access) => claimsOf(access).accountId,
|
|
156
|
+
recordQuota(accountId, status, headers) {
|
|
157
|
+
// Never throws: swallows its own write failures.
|
|
158
|
+
applyCodexQuotaHeaders(accountId, headers);
|
|
159
|
+
if (status !== 429) return;
|
|
160
|
+
try {
|
|
161
|
+
markCodexRateLimited(accountId, headers);
|
|
162
|
+
} catch {
|
|
163
|
+
// Telemetry only — a failed block write must not fail the response.
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
};
|
|
290
167
|
|
|
291
|
-
|
|
292
|
-
...options,
|
|
293
|
-
onResponse: async (response: { status: number; headers: Record<string, string> }, model: unknown) => {
|
|
294
|
-
const storage = loadCodexAccounts();
|
|
295
|
-
const account = storage.accounts.find((candidate) => candidate.access === options?.apiKey);
|
|
296
|
-
const accountId = account?.id ?? MAIN_ACCOUNT_ID;
|
|
297
|
-
applyCodexQuotaHeaders(accountId, response.headers);
|
|
298
|
-
if (response.status === 429) markCodexRateLimited(accountId, response.headers);
|
|
299
|
-
await options?.onResponse?.(response, model);
|
|
300
|
-
},
|
|
301
|
-
});
|
|
168
|
+
export const codexAccounts = createPooledOAuthAdapter(CODEX_SPEC);
|
|
302
169
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
});
|
|
170
|
+
export function codexRoutingMode(value: string | undefined): RoutingMode {
|
|
171
|
+
return normalizeRoutingMode(value);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Retained for callers and tests that select a Codex account directly. */
|
|
175
|
+
export function chooseCodexCredential(primary: OAuthCredential) {
|
|
176
|
+
return chooseCredential(CODEX_SPEC, primary);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function registerCodexProvider(pi: ExtensionAPI): void {
|
|
180
|
+
registerPooledOAuthProvider(pi, CODEX_SPEC);
|
|
315
181
|
}
|
|
@@ -2,13 +2,13 @@ import { kimiCodingProvider } from "@earendil-works/pi-ai/providers/kimi-coding"
|
|
|
2
2
|
import { xaiProvider } from "@earendil-works/pi-ai/providers/xai";
|
|
3
3
|
import { createPooledOAuthAdapter, type PooledOAuthProviderSpec } from "./oauth-pool.ts";
|
|
4
4
|
|
|
5
|
-
export const KIMI_SPEC: PooledOAuthProviderSpec = {
|
|
5
|
+
export const KIMI_SPEC: PooledOAuthProviderSpec<"anthropic-messages"> = {
|
|
6
6
|
id: "kimi-coding",
|
|
7
7
|
label: "Kimi",
|
|
8
8
|
createProvider: kimiCodingProvider,
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
-
export const XAI_SPEC: PooledOAuthProviderSpec = {
|
|
11
|
+
export const XAI_SPEC: PooledOAuthProviderSpec<"openai-responses"> = {
|
|
12
12
|
id: "xai",
|
|
13
13
|
label: "Grok",
|
|
14
14
|
createProvider: xaiProvider,
|