@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.
Files changed (42) hide show
  1. package/README.md +7 -3
  2. package/package.json +7 -6
  3. package/src/core/accounts/oauth-pool.ts +35 -0
  4. package/src/core/accounts/registry.ts +4 -0
  5. package/src/core/accounts/routing.ts +22 -0
  6. package/src/core/anthropic/identity.ts +54 -0
  7. package/src/core/anthropic/oauth.ts +3 -0
  8. package/src/core/anthropic/quota.ts +227 -28
  9. package/src/core/anthropic/store.ts +4 -1
  10. package/src/core/codex/quota.ts +43 -11
  11. package/src/core/codex/store.ts +21 -11
  12. package/src/core/config.ts +8 -10
  13. package/src/domains/agents/format.ts +39 -0
  14. package/src/domains/agents/index.ts +39 -50
  15. package/src/domains/remote/config-path.ts +0 -2
  16. package/src/domains/remote/index.ts +4 -41
  17. package/src/domains/subscriptions/accounts-picker.ts +44 -4
  18. package/src/domains/subscriptions/accounts.ts +31 -8
  19. package/src/domains/subscriptions/index.ts +2 -2
  20. package/src/domains/subscriptions/provider.ts +46 -5
  21. package/src/domains/subscriptions/providers/anthropic.ts +37 -5
  22. package/src/domains/subscriptions/providers/codex.ts +120 -254
  23. package/src/domains/subscriptions/providers/hosted.ts +2 -2
  24. package/src/domains/subscriptions/providers/oauth-pool.ts +124 -72
  25. package/src/domains/workflows/index.ts +1 -1
  26. package/src/domains/workflows/runtime/agent-attempt.ts +41 -11
  27. package/src/domains/workflows/runtime/agent-failure.ts +14 -0
  28. package/src/domains/workflows/runtime/agent-limits.ts +24 -1
  29. package/src/domains/workflows/runtime/agent-retry.ts +11 -6
  30. package/src/domains/workflows/runtime/agent-runner.ts +42 -20
  31. package/src/domains/workflows/runtime/agent-workspace.ts +0 -24
  32. package/src/domains/workflows/runtime/engine.ts +48 -12
  33. package/src/domains/workflows/runtime/progress.ts +2 -2
  34. package/src/domains/workflows/runtime/types.ts +4 -4
  35. package/src/domains/workflows/runtime/ui/display-text.ts +15 -0
  36. package/src/domains/workflows/runtime/ui/workflow-inspector.ts +51 -14
  37. package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +6 -6
  38. package/src/domains/workflows/runtime/workflow-run-history.ts +3 -1
  39. package/src/domains/workflows/runtime/workflow-run-record.ts +1 -0
  40. package/src/domains/workflows/runtime/worktree.ts +35 -1
  41. package/src/ui/usage-bars.ts +1 -2
  42. 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 storage = loadAccounts();
49
- return (storage?.accounts ?? [])
50
- .filter((account) => account.type === "oauth")
51
- .map((account) => ({
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 { randomUUID } from "node:crypto";
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 { AccountContext, AccountProvider, ManagedAccount, RoutingMode } from "../../../core/accounts/registry.ts";
6
- import { normalizeRoutingMode, selectRoutingCandidate, type AccountQuotaState } from "../../../core/accounts/routing.ts";
7
- import { authorizeCodex, exchangeCodex } from "../../../core/codex/oauth.ts";
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
- * Structurally the same as the Anthropic one, with two differences that come
22
- * from the provider rather than from us:
23
- * - quota arrives only through response headers, so there is no poll;
24
- * - every request must carry the account's `chatgpt-account-id`, so the
25
- * account id is stored alongside the tokens rather than derived later.
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
- return account.plan ? `${account.label ?? account.id.slice(0, 8)} (${account.plan})` : (account.label ?? account.id.slice(0, 8));
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 blockedUntil && blockedUntil > Date.now()
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) return undefined;
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: blockedUntil && blockedUntil > Date.now() ? 0 : remainingPercent,
65
+ remainingPercent: blocked ? 0 : remainingPercent,
190
66
  resetAt,
191
67
  checkedAt: quota.checkedAt ?? Date.now(),
192
- blockedUntil: blockedUntil && blockedUntil > Date.now()
193
- ? blockedUntil
194
- : remainingPercent <= 0 ? resetAt ?? Number.POSITIVE_INFINITY : undefined,
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
- export function chooseCodexCredential(primary: OAuthCredential): {
199
- readonly id: string;
200
- readonly credential: OAuthCredential;
201
- readonly account?: RoutableCodexAccount;
202
- } {
203
- const storage = loadCodexAccounts();
204
- const mode = codexRoutingMode(storage.routing?.mode);
205
- const candidates = [
206
- {
207
- id: MAIN_ACCOUNT_ID,
208
- order: 0,
209
- lastUsed: codexLastUsed.get(MAIN_ACCOUNT_ID) ?? storage.main?.lastUsed ?? 0,
210
- quota: quotaState(storage.main?.quota, storage.main?.blockedUntil),
211
- value: { id: MAIN_ACCOUNT_ID, credential: primary },
212
- },
213
- ...storage.accounts
214
- .filter(isRoutableCodexAccount)
215
- .map((account, index) => ({
216
- id: account.id,
217
- order: index + 1,
218
- lastUsed: codexLastUsed.get(account.id) ?? account.lastUsed ?? 0,
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
- return selectRoutingCandidate(candidates, mode)?.value ?? { id: MAIN_ACCOUNT_ID, credential: primary };
228
- }
106
+ };
107
+ },
229
108
 
230
- async function freshCodexAccount(
231
- oauth: OAuthAuth,
232
- account: RoutableCodexAccount,
233
- signal: AbortSignal,
234
- ): Promise<RoutableCodexAccount> {
235
- if (account.access && account.expires && account.expires > Date.now() + 60_000) return account;
236
- const active = refreshes.get(account.id);
237
- if (active) return active;
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
- const credential: OAuthCredential = { ...account, type: "oauth" };
240
- const refresh = oauth.refresh(credential, signal).then((updated) => {
241
- const next: RoutableCodexAccount = { ...account, ...updated };
242
- saveCodexAccount(next);
243
- return next;
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
- async function routedCodexAuth(
250
- oauth: OAuthAuth,
251
- primary: OAuthCredential,
252
- ): Promise<ModelAuth> {
253
- const selected = chooseCodexCredential(primary);
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 function registerCodexProvider(pi: ExtensionAPI): void {
287
- const provider = openaiCodexProvider();
288
- const oauth = provider.auth.oauth;
289
- if (!oauth) return;
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
- const withQuotaObserver = (options: any) => ({
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
- pi.registerProvider({
304
- ...provider,
305
- auth: {
306
- ...provider.auth,
307
- oauth: {
308
- ...oauth,
309
- toAuth: (primary) => routedCodexAuth(oauth, primary),
310
- },
311
- },
312
- stream: (model: any, context: any, options: any) => provider.stream(model, context, withQuotaObserver(options)),
313
- streamSimple: (model: any, context: any, options: any) => provider.streamSimple(model, context, withQuotaObserver(options)),
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,