@jameslovespancakes/pi-plus 1.0.11 → 1.0.12

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 (120) hide show
  1. package/README.md +44 -20
  2. package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
  3. package/package.json +6 -3
  4. package/src/core/accounts/oauth-pool.ts +102 -0
  5. package/src/core/accounts/registry.ts +8 -25
  6. package/src/core/accounts/routing.ts +122 -0
  7. package/src/core/anthropic/client-identity.ts +9 -64
  8. package/src/core/anthropic/quota.ts +8 -53
  9. package/src/core/anthropic/routing.ts +40 -115
  10. package/src/core/anthropic/store.ts +7 -9
  11. package/src/core/catalog/quality.ts +33 -15
  12. package/src/core/codex/quota.ts +8 -2
  13. package/src/core/codex/store.ts +12 -9
  14. package/src/core/config.ts +4 -17
  15. package/src/core/store.ts +13 -12
  16. package/src/domains/models/provider-picker.ts +1 -1
  17. package/src/domains/setup/index.ts +23 -23
  18. package/src/domains/subscriptions/accounts-picker.ts +18 -37
  19. package/src/domains/subscriptions/accounts.ts +23 -29
  20. package/src/domains/subscriptions/footer.ts +48 -29
  21. package/src/domains/subscriptions/index.ts +11 -27
  22. package/src/domains/subscriptions/provider.ts +38 -103
  23. package/src/domains/subscriptions/providers/anthropic.ts +5 -17
  24. package/src/domains/subscriptions/providers/codex.ts +171 -4
  25. package/src/domains/subscriptions/providers/hosted.ts +18 -0
  26. package/src/domains/subscriptions/providers/oauth-pool.ts +273 -0
  27. package/src/domains/subscriptions/routing.ts +19 -11
  28. package/src/domains/workflows/LICENSE.md +21 -0
  29. package/src/domains/workflows/index.ts +836 -0
  30. package/src/domains/workflows/runtime/advisory-challenge.ts +75 -0
  31. package/src/domains/workflows/runtime/advisory-evidence.ts +90 -0
  32. package/src/domains/workflows/runtime/advisory-schema.ts +83 -0
  33. package/src/domains/workflows/runtime/agent-attempt.ts +147 -0
  34. package/src/domains/workflows/runtime/agent-limits.ts +43 -0
  35. package/src/domains/workflows/runtime/agent-replay.ts +399 -0
  36. package/src/domains/workflows/runtime/agent-retry.ts +116 -0
  37. package/src/domains/workflows/runtime/agent-runner-types.ts +96 -0
  38. package/src/domains/workflows/runtime/agent-runner.ts +203 -0
  39. package/src/domains/workflows/runtime/agent-session-identity.ts +256 -0
  40. package/src/domains/workflows/runtime/agent-session-providers.ts +50 -0
  41. package/src/domains/workflows/runtime/agent-session.ts +382 -0
  42. package/src/domains/workflows/runtime/agent-skills.ts +270 -0
  43. package/src/domains/workflows/runtime/agent-workspace.ts +103 -0
  44. package/src/domains/workflows/runtime/background-workflow-tool.ts +75 -0
  45. package/src/domains/workflows/runtime/background-workflows.ts +492 -0
  46. package/src/domains/workflows/runtime/budget.ts +53 -0
  47. package/src/domains/workflows/runtime/cancellation.ts +87 -0
  48. package/src/domains/workflows/runtime/command-completions.ts +36 -0
  49. package/src/domains/workflows/runtime/concurrency.ts +403 -0
  50. package/src/domains/workflows/runtime/debug.ts +3 -0
  51. package/src/domains/workflows/runtime/diff-capture.ts +81 -0
  52. package/src/domains/workflows/runtime/discovery.ts +137 -0
  53. package/src/domains/workflows/runtime/dynamax-shortcuts.ts +122 -0
  54. package/src/domains/workflows/runtime/dynamax.ts +330 -0
  55. package/src/domains/workflows/runtime/engine.ts +543 -0
  56. package/src/domains/workflows/runtime/filesystem-error.ts +4 -0
  57. package/src/domains/workflows/runtime/finalizers.ts +66 -0
  58. package/src/domains/workflows/runtime/identity-canonicalization.ts +138 -0
  59. package/src/domains/workflows/runtime/identity-fingerprint.ts +15 -0
  60. package/src/domains/workflows/runtime/inline-workflow.ts +403 -0
  61. package/src/domains/workflows/runtime/journal.ts +313 -0
  62. package/src/domains/workflows/runtime/model-profiles.ts +310 -0
  63. package/src/domains/workflows/runtime/options.ts +157 -0
  64. package/src/domains/workflows/runtime/perf.ts +146 -0
  65. package/src/domains/workflows/runtime/pi-compat.ts +32 -0
  66. package/src/domains/workflows/runtime/process-runner.ts +260 -0
  67. package/src/domains/workflows/runtime/progress-types.ts +59 -0
  68. package/src/domains/workflows/runtime/progress.ts +400 -0
  69. package/src/domains/workflows/runtime/provider-usage-limit.ts +191 -0
  70. package/src/domains/workflows/runtime/replay-path-identity.ts +48 -0
  71. package/src/domains/workflows/runtime/research-contract.ts +89 -0
  72. package/src/domains/workflows/runtime/research-evidence.ts +289 -0
  73. package/src/domains/workflows/runtime/resume-context.ts +751 -0
  74. package/src/domains/workflows/runtime/review/code-review-orchestration.ts +24 -0
  75. package/src/domains/workflows/runtime/review/github-pr-comments.ts +249 -0
  76. package/src/domains/workflows/runtime/review/patch-validation.ts +120 -0
  77. package/src/domains/workflows/runtime/review/review-actions.ts +180 -0
  78. package/src/domains/workflows/runtime/review/review-budget.ts +45 -0
  79. package/src/domains/workflows/runtime/review/review-fix-workflow.ts +153 -0
  80. package/src/domains/workflows/runtime/review/review-format.ts +133 -0
  81. package/src/domains/workflows/runtime/review/review-handoff.ts +38 -0
  82. package/src/domains/workflows/runtime/review/review-issues.ts +87 -0
  83. package/src/domains/workflows/runtime/review/review-report.ts +42 -0
  84. package/src/domains/workflows/runtime/review/review-results-flow.ts +52 -0
  85. package/src/domains/workflows/runtime/review/review-results-viewer.ts +312 -0
  86. package/src/domains/workflows/runtime/review/review-session-coordinator.ts +153 -0
  87. package/src/domains/workflows/runtime/review/review-snapshot.ts +294 -0
  88. package/src/domains/workflows/runtime/review-diff-target.ts +130 -0
  89. package/src/domains/workflows/runtime/session-identity.ts +6 -0
  90. package/src/domains/workflows/runtime/structured-output.ts +32 -0
  91. package/src/domains/workflows/runtime/tool-capabilities.ts +44 -0
  92. package/src/domains/workflows/runtime/tool-source-identity.ts +150 -0
  93. package/src/domains/workflows/runtime/tree-fingerprint.ts +404 -0
  94. package/src/domains/workflows/runtime/types.ts +255 -0
  95. package/src/domains/workflows/runtime/ui/display-text.ts +13 -0
  96. package/src/domains/workflows/runtime/ui/dynamax-editor-decoration.ts +220 -0
  97. package/src/domains/workflows/runtime/ui/workflow-format.ts +165 -0
  98. package/src/domains/workflows/runtime/ui/workflow-inspector.ts +398 -0
  99. package/src/domains/workflows/runtime/ui/workflow-result-renderer.ts +171 -0
  100. package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +51 -0
  101. package/src/domains/workflows/runtime/ui/workflow-widget.ts +78 -0
  102. package/src/domains/workflows/runtime/unknown-error.ts +8 -0
  103. package/src/domains/workflows/runtime/usage.ts +341 -0
  104. package/src/domains/workflows/runtime/workflow-advisory-utils.ts +245 -0
  105. package/src/domains/workflows/runtime/workflow-execution.ts +121 -0
  106. package/src/domains/workflows/runtime/workflow-module.ts +75 -0
  107. package/src/domains/workflows/runtime/workflow-run-background.ts +72 -0
  108. package/src/domains/workflows/runtime/workflow-run-controller.ts +342 -0
  109. package/src/domains/workflows/runtime/workflow-run-history.ts +162 -0
  110. package/src/domains/workflows/runtime/workflow-run-record.ts +635 -0
  111. package/src/domains/workflows/runtime/workflow-run-store.ts +176 -0
  112. package/src/domains/workflows/runtime/workflow-usage-limit-scheduler.ts +80 -0
  113. package/src/domains/workflows/runtime/workflows.ts +40 -0
  114. package/src/domains/workflows/runtime/worktree.ts +581 -0
  115. package/src/domains/workflows/workflows/code-review.ts +232 -0
  116. package/src/domains/workflows/workflows/diagnose.ts +154 -0
  117. package/src/domains/workflows/workflows/perf-review.ts +149 -0
  118. package/src/domains/workflows/workflows/refactor-scout.ts +143 -0
  119. package/src/domains/workflows/workflows/research.ts +169 -0
  120. package/src/services/usage-service.ts +6 -30
@@ -5,50 +5,36 @@ import {
5
5
  } from "../../core/anthropic/client-identity.ts";
6
6
  import { ANTHROPIC_MODELS } from "../../core/anthropic/models.ts";
7
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
8
  import {
11
9
  MAIN_ACCOUNT_ID, familyForModel, selectAccount, type Candidate,
12
10
  } from "../../core/anthropic/routing.ts";
13
11
  import { getRoutingMode, loadAccounts, saveAccount } from "../../core/anthropic/store.ts";
14
12
 
15
13
  /**
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.
14
+ * Anthropic uses pi's Messages API client with per-request account routing.
15
+ * Routing reads cached quota; a 429 affects the next request, not its stream.
34
16
  */
35
17
 
18
+ /**
19
+ * The hook is global, so require both Anthropic's payload shape and a Claude
20
+ * model. This prevents `system` injection into other provider requests.
21
+ */
22
+ function isAnthropicMessagesPayload(payload: any): boolean {
23
+ if ("instructions" in payload || "input" in payload) return false;
24
+ if (!Array.isArray(payload.messages)) return false;
25
+ const model = typeof payload.model === "string" ? payload.model.toLowerCase() : "";
26
+ return model.startsWith("claude") || ANTHROPIC_MODELS.some((m: any) => m.id === payload.model);
27
+ }
28
+
36
29
  let lastSelected: { id: string; at: number } | undefined;
30
+ const accountLastUsed = new Map<string, number>();
37
31
 
38
32
  /** Which account served the most recent request, for the UI. */
39
33
  export function lastRoutedAccount(): { id: string; at: number } | undefined {
40
34
  return lastSelected;
41
35
  }
42
36
 
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.
37
+ /** Routes to a pooled account, falling back to pi's primary credential. */
52
38
  export function routeAccessToken(primary: string, modelId?: string, _sessionId?: string): string {
53
39
  const storage = loadAccounts();
54
40
  if (!storage) return primary;
@@ -57,10 +43,23 @@ export function routeAccessToken(primary: string, modelId?: string, _sessionId?:
57
43
  const family = familyForModel(modelId);
58
44
 
59
45
  const candidates: Candidate[] = [
60
- { id: MAIN_ACCOUNT_ID, access: primary, quota: storage.main?.quota as any, order: 0 },
46
+ {
47
+ id: MAIN_ACCOUNT_ID,
48
+ access: primary,
49
+ quota: storage.main?.quota as any,
50
+ order: 0,
51
+ lastUsed: accountLastUsed.get(MAIN_ACCOUNT_ID) ?? Number(storage.main?.lastUsed ?? 0),
52
+ },
61
53
  ...storage.accounts
62
54
  .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 })),
55
+ .map((a, index) => ({
56
+ id: a.id,
57
+ access: a.access,
58
+ quota: a.quota,
59
+ order: index + 1,
60
+ lastUsed: accountLastUsed.get(a.id) ?? a.lastUsed ?? 0,
61
+ account: a,
62
+ })),
64
63
  ];
65
64
 
66
65
  // Nothing to choose between: keep pi's own credential.
@@ -75,10 +74,9 @@ export function routeAccessToken(primary: string, modelId?: string, _sessionId?:
75
74
 
76
75
  const now = Date.now();
77
76
  lastSelected = { id: picked.candidate.id, at: now };
77
+ accountLastUsed.set(picked.candidate.id, now);
78
78
  if (picked.candidate.account) {
79
- // `lastUsed` only orders the footer's account list, so second-level
80
- // precision buys nothing. Persisting it on every request rewrote two
81
- // credential files per call; once a minute is indistinguishable in the UI.
79
+ // Minute precision avoids a credential write on every request.
82
80
  const previous = picked.candidate.account.lastUsed ?? 0;
83
81
  if (now - previous > 60_000) {
84
82
  saveAccount({ ...picked.candidate.account, lastUsed: now });
@@ -102,9 +100,7 @@ export function registerAnthropicProvider(pi: ExtensionAPI): void {
102
100
  baseUrl: "https://api.anthropic.com",
103
101
  api: "anthropic-messages",
104
102
  models: ANTHROPIC_MODELS,
105
- // See core/anthropic/client-identity.ts. Without these, Anthropic bills
106
- // requests to extra usage rather than plan limits. Delete that import and
107
- // this line to opt out; nothing else depends on it.
103
+ // Required for subscription billing. See client-identity.ts.
108
104
  headers: clientIdentityHeaders(),
109
105
  oauth: {
110
106
  name: "Anthropic Claude Pro/Max",
@@ -114,29 +110,15 @@ export function registerAnthropicProvider(pi: ExtensionAPI): void {
114
110
  const refreshed = await refreshToken({ refreshToken: credentials.refresh });
115
111
  return { access: refreshed.access, refresh: refreshed.refresh, expires: refreshed.expires };
116
112
  },
117
- // The routing hook. pi calls this per request.
118
113
  getApiKey: (credentials: any) => routeAccessToken(credentials.access),
119
114
  },
120
115
  });
121
116
 
122
- /**
123
- * Reshapes the request the way the endpoint requires, then signs it.
124
- *
125
- * Three things have to happen, in order:
126
- * 1. pi's system prompt is split. The documentation paragraph cannot live in
127
- * `system` at all, or Anthropic answers 400 "Third-party apps now draw
128
- * from your extra usage"; it moves into the first user message instead.
129
- * 2. The billing header goes first in `system`, alongside the Claude Code
130
- * identity line.
131
- * 3. The body is signed, which must happen last because the checksum covers
132
- * the canonicalised body.
133
- *
134
- * See core/anthropic/client-identity.ts for what all of this is and the
135
- * caveats around it.
136
- */
117
+ /** Rebuilds the Anthropic prompt, then signs the final body. */
137
118
  pi.on("before_provider_request", async (event: any) => {
138
119
  const payload = event?.payload;
139
120
  if (!payload || typeof payload !== "object") return undefined;
121
+ if (!isAnthropicMessagesPayload(payload)) return undefined;
140
122
 
141
123
  const serialized = typeof payload.system === "string"
142
124
  ? payload.system
@@ -156,20 +138,10 @@ export function registerAnthropicProvider(pi: ExtensionAPI): void {
156
138
  return JSON.parse(signed);
157
139
  });
158
140
 
159
- /**
160
- * Free quota refresh for whichever account just served a request.
161
- *
162
- * Anthropic returns the same utilisation numbers as the usage endpoint in
163
- * `anthropic-ratelimit-unified-*` response headers, so the active account
164
- * stays current without spending a request on asking.
165
- *
166
- * `lastRoutedAccount()` is set by `getApiKey` immediately before the call,
167
- * which is what lets the reply be attributed to the right account.
168
- */
141
+ /** Updates the routed account from response quota headers. */
169
142
  pi.on("after_provider_response", (event: any) => {
170
143
  const routed = lastRoutedAccount();
171
- // `main` lives in pi's auth.json, not in storage.accounts, so there is no
172
- // per-account record to update for it.
144
+ // The primary account lives in pi's auth store.
173
145
  if (!routed || routed.id === MAIN_ACCOUNT_ID) return;
174
146
  try {
175
147
  applyQuotaHeaders(routed.id, event?.headers);
@@ -178,44 +150,7 @@ export function registerAnthropicProvider(pi: ExtensionAPI): void {
178
150
  }
179
151
  });
180
152
 
181
- /**
182
- * The same trick for Codex, which reports usage as `x-codex-*` headers.
183
- *
184
- * Codex has no usage endpoint, so headers are the ONLY quota source: an
185
- * account that is not serving traffic keeps whatever snapshot it last had.
186
- * Attribution is by `chatgpt-account-id` on the request, since Codex
187
- * requests carry the account explicitly rather than only in the token.
188
- */
189
- pi.on("after_provider_response", (event: any) => {
190
- const headers = event?.headers;
191
- if (!headers) return;
192
- const hasCodexQuota = Object.keys(headers).some((k) => k.toLowerCase().startsWith("x-codex-"));
193
- if (!hasCodexQuota) return;
194
- try {
195
- const sent = event?.request?.headers ?? {};
196
- const accountId = sent["chatgpt-account-id"] ?? sent["Chatgpt-Account-Id"];
197
- const match = loadCodexAccounts().accounts.find(
198
- (a) => (accountId ? a.accountId === accountId : false) || a.enabled !== false,
199
- );
200
- if (match) applyCodexQuotaHeaders(match.id, headers);
201
- } catch {
202
- // Bookkeeping only.
203
- }
204
- });
205
-
206
- /**
207
- * Idle accounts still need polling, because routing compares accounts and
208
- * response headers only ever refresh the one that served traffic.
209
- *
210
- * Triggered by sending a message, not by a timer, and `refreshAllQuota`
211
- * skips accounts whose snapshot is still fresh. So the poll rate is at most
212
- * once per QUOTA_FRESH_MS however fast messages are sent, and an idle
213
- * session costs nothing at all. The previous 5 minute interval ran forever
214
- * regardless of activity and was being answered with 429s.
215
- *
216
- * `input` is the per-message hook; `turn_start` would fire once per LLM
217
- * response, which is many times per message in a tool-use loop.
218
- */
153
+ /** Refreshes stale idle-account quota when a message starts. */
219
154
  pi.on("input", async () => {
220
155
  void refreshAllQuota().catch(() => {});
221
156
  });
@@ -6,7 +6,6 @@ import {
6
6
  loadAccounts,
7
7
  saveAccount,
8
8
  setRoutingMode,
9
- type RoutingMode as StoreRoutingMode,
10
9
  } from "../../../core/anthropic/store.ts";
11
10
 
12
11
  /**
@@ -22,16 +21,6 @@ import {
22
21
  * the extracted store directly.
23
22
  */
24
23
 
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
24
  async function authenticate(ctx: AccountContext): Promise<{ access: string; refresh: string; expires: number } | undefined> {
36
25
  const auth = await authorize("max");
37
26
 
@@ -133,17 +122,16 @@ export const anthropicAccounts: AccountProvider = {
133
122
 
134
123
  routing: {
135
124
  async get(): Promise<RoutingMode> {
136
- return fromStoreMode(getRoutingMode(loadAccounts()));
125
+ return getRoutingMode(loadAccounts());
137
126
  },
138
127
  async set(mode: RoutingMode): Promise<RoutingMode> {
139
- setRoutingMode(MODE_TO_STORE[mode]);
128
+ setRoutingMode(mode);
140
129
  return mode;
141
130
  },
142
131
  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.";
132
+ return mode === "quota-aware"
133
+ ? "Uses the Claude subscription with the most remaining quota."
134
+ : "Uses Claude subscriptions in order, moving on when one is exhausted.";
147
135
  },
148
136
  },
149
137
  };
@@ -1,7 +1,13 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import type { ModelAuth, OAuthAuth, OAuthCredential } from "@earendil-works/pi-ai";
3
+ import { openaiCodexProvider } from "@earendil-works/pi-ai/providers/openai-codex";
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
5
  import type { AccountContext, AccountProvider, ManagedAccount, RoutingMode } from "../../../core/accounts/registry.ts";
6
+ import { normalizeRoutingMode, selectRoutingCandidate, type AccountQuotaState } from "../../../core/accounts/routing.ts";
3
7
  import { authorizeCodex, exchangeCodex } from "../../../core/codex/oauth.ts";
8
+ import { applyCodexQuotaHeaders } from "../../../core/codex/quota.ts";
4
9
  import {
10
+ MAIN_ACCOUNT_ID,
5
11
  claimsOf,
6
12
  loadCodexAccounts,
7
13
  saveCodexAccount,
@@ -38,6 +44,18 @@ function describePlan(account: CodexAccount): string {
38
44
  return account.plan ? `${account.label ?? account.id.slice(0, 8)} (${account.plan})` : (account.label ?? account.id.slice(0, 8));
39
45
  }
40
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
+
41
59
  export const codexAccounts: AccountProvider = {
42
60
  id: "openai-codex",
43
61
  label: "Codex",
@@ -131,7 +149,7 @@ export const codexAccounts: AccountProvider = {
131
149
 
132
150
  routing: {
133
151
  async get(): Promise<RoutingMode> {
134
- return loadCodexAccounts().routing?.mode === "optimal" ? "optimal" : "standard";
152
+ return codexRoutingMode(loadCodexAccounts().routing?.mode);
135
153
  },
136
154
  async set(mode: RoutingMode): Promise<RoutingMode> {
137
155
  const storage = loadCodexAccounts();
@@ -140,9 +158,158 @@ export const codexAccounts: AccountProvider = {
140
158
  return mode;
141
159
  },
142
160
  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.";
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.";
146
164
  },
147
165
  },
148
166
  };
167
+
168
+ export function codexRoutingMode(value: string | undefined): RoutingMode {
169
+ return normalizeRoutingMode(value);
170
+ }
171
+
172
+ function quotaState(quota: CodexAccount["quota"], blockedUntil?: number): AccountQuotaState | undefined {
173
+ if (!quota) {
174
+ return blockedUntil && blockedUntil > Date.now()
175
+ ? { remainingPercent: 0, checkedAt: Date.now(), blockedUntil }
176
+ : undefined;
177
+ }
178
+ const windows = [quota.five_hour, quota.seven_day, ...(quota.scoped ?? [])]
179
+ .filter((window) => window && Number.isFinite(window.remainingPercent));
180
+ if (windows.length === 0) return undefined;
181
+
182
+ const remainingPercent = Math.min(...windows.map((window) => window!.remainingPercent!));
183
+ const resetTimes = windows
184
+ .filter((window) => (window!.remainingPercent ?? 0) <= 0 && window!.resetsAt)
185
+ .map((window) => Date.parse(window!.resetsAt!))
186
+ .filter(Number.isFinite);
187
+ const resetAt = resetTimes.length > 0 ? Math.min(...resetTimes) : undefined;
188
+ return {
189
+ remainingPercent: blockedUntil && blockedUntil > Date.now() ? 0 : remainingPercent,
190
+ resetAt,
191
+ checkedAt: quota.checkedAt ?? Date.now(),
192
+ blockedUntil: blockedUntil && blockedUntil > Date.now()
193
+ ? blockedUntil
194
+ : remainingPercent <= 0 ? resetAt ?? Number.POSITIVE_INFINITY : undefined,
195
+ };
196
+ }
197
+
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,
219
+ 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
+ })),
226
+ ];
227
+ return selectRoutingCandidate(candidates, mode)?.value ?? { id: MAIN_ACCOUNT_ID, credential: primary };
228
+ }
229
+
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;
238
+
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
+ }
248
+
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
+ }
271
+
272
+ function markCodexRateLimited(accountId: string, headers: Record<string, string>): void {
273
+ const raw = Object.entries(headers).find(([key]) => key.toLowerCase() === "retry-after")?.[1];
274
+ const seconds = Number(raw);
275
+ const blockedUntil = Date.now() + (Number.isFinite(seconds) && seconds >= 0 ? seconds * 1_000 : 60_000);
276
+ const storage = loadCodexAccounts();
277
+ if (accountId === MAIN_ACCOUNT_ID) {
278
+ storage.main = { ...storage.main, blockedUntil };
279
+ saveCodexAccounts(storage);
280
+ return;
281
+ }
282
+ const account = storage.accounts.find((candidate) => candidate.id === accountId);
283
+ if (account) saveCodexAccount({ ...account, blockedUntil });
284
+ }
285
+
286
+ export function registerCodexProvider(pi: ExtensionAPI): void {
287
+ const provider = openaiCodexProvider();
288
+ const oauth = provider.auth.oauth;
289
+ if (!oauth) return;
290
+
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
+ });
302
+
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
+ });
315
+ }
@@ -0,0 +1,18 @@
1
+ import { kimiCodingProvider } from "@earendil-works/pi-ai/providers/kimi-coding";
2
+ import { xaiProvider } from "@earendil-works/pi-ai/providers/xai";
3
+ import { createPooledOAuthAdapter, type PooledOAuthProviderSpec } from "./oauth-pool.ts";
4
+
5
+ export const KIMI_SPEC: PooledOAuthProviderSpec = {
6
+ id: "kimi-coding",
7
+ label: "Kimi",
8
+ createProvider: kimiCodingProvider,
9
+ };
10
+
11
+ export const XAI_SPEC: PooledOAuthProviderSpec = {
12
+ id: "xai",
13
+ label: "Grok",
14
+ createProvider: xaiProvider,
15
+ };
16
+
17
+ export const kimiAccounts = createPooledOAuthAdapter(KIMI_SPEC);
18
+ export const xaiAccounts = createPooledOAuthAdapter(XAI_SPEC);