@bitkyc08/opencodex 2.35.0 → 2.36.0-preview.20260830

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 (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DPl4nBMA.js +112 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +2 -1
  5. package/src/AGENTS.md +2 -1
  6. package/src/adapters/agentrouter.ts +50 -0
  7. package/src/adapters/anthropic.ts +1 -51
  8. package/src/adapters/cursor/call-id.ts +76 -8
  9. package/src/adapters/cursor/checkpoint-store.ts +6 -1
  10. package/src/adapters/cursor/cursor-errors.ts +44 -0
  11. package/src/adapters/cursor/native-exec.ts +13 -0
  12. package/src/adapters/cursor/protobuf-request.ts +651 -29
  13. package/src/adapters/cursor/tool-result-normalize.ts +3 -3
  14. package/src/adapters/cursor/transport-retry.ts +5 -1
  15. package/src/adapters/cursor.ts +15 -1
  16. package/src/adapters/empty-tool-output-annotation.ts +43 -0
  17. package/src/adapters/exec-tool-result-normalize.ts +70 -5
  18. package/src/adapters/google.ts +22 -2
  19. package/src/adapters/kiro.ts +26 -2
  20. package/src/adapters/ollama-native-url.ts +111 -0
  21. package/src/adapters/ollama-native.ts +1131 -0
  22. package/src/adapters/openai-chat.ts +30 -7
  23. package/src/adapters/openai-responses.ts +72 -4
  24. package/src/adapters/registry.ts +7 -0
  25. package/src/adapters/xai-web-search.ts +58 -0
  26. package/src/claude/desktop-3p.ts +21 -1
  27. package/src/claude/desktop-policy.ts +149 -0
  28. package/src/cli/account.ts +16 -2
  29. package/src/cli/claude-desktop.ts +13 -3
  30. package/src/cli/combo.ts +8 -5
  31. package/src/cli/doctor.ts +77 -11
  32. package/src/cli/help.ts +1 -1
  33. package/src/cli/index.ts +16 -0
  34. package/src/cli/models.ts +20 -3
  35. package/src/cli/registry.ts +2 -1
  36. package/src/cli/status.ts +140 -2
  37. package/src/cli/storage.ts +10 -1
  38. package/src/codex/account-runtime-state.ts +39 -5
  39. package/src/codex/account-store.ts +393 -13
  40. package/src/codex/account-usability.ts +11 -4
  41. package/src/codex/app-server-processes.ts +46 -5
  42. package/src/codex/auth-context.ts +160 -32
  43. package/src/codex/catalog/bundled.ts +7 -5
  44. package/src/codex/catalog/metadata.ts +1 -1
  45. package/src/codex/catalog/parsing.ts +57 -1
  46. package/src/codex/catalog/provider-fetch.ts +61 -4
  47. package/src/codex/catalog/sync.ts +4 -3
  48. package/src/codex/convergence.ts +3 -2
  49. package/src/codex/data/upstream-models.json +40 -8
  50. package/src/codex/inject-coordination.ts +111 -14
  51. package/src/codex/integration-record.ts +12 -2
  52. package/src/codex/main-account.ts +225 -1
  53. package/src/codex/model-entitlements.ts +339 -27
  54. package/src/codex/prompt-layers.ts +346 -7
  55. package/src/codex/prompt-text-probe.ts +272 -21
  56. package/src/codex/routing.ts +693 -132
  57. package/src/codex/runtime.ts +12 -0
  58. package/src/codex/subagent-model-fallback.ts +62 -24
  59. package/src/codex/user-identity.ts +33 -25
  60. package/src/combos/index.ts +1 -0
  61. package/src/combos/reset-window.ts +46 -0
  62. package/src/combos/resolve.ts +84 -2
  63. package/src/combos/types.ts +5 -2
  64. package/src/config/atomic-write.ts +104 -22
  65. package/src/config/provider-validation.ts +11 -0
  66. package/src/config.ts +75 -3
  67. package/src/generated/compatibility-version.json +207 -131
  68. package/src/generated/model-metadata.ts +1 -1
  69. package/src/grok/catalog.ts +71 -0
  70. package/src/grok/effort.ts +83 -0
  71. package/src/grok/inject.ts +952 -127
  72. package/src/grok/models.ts +56 -0
  73. package/src/grok/status.ts +21 -8
  74. package/src/grok/sync.ts +10 -18
  75. package/src/images/loop.ts +6 -3
  76. package/src/integrations/native/ownership-preflight.ts +4 -1
  77. package/src/lab/fabric/producer-isolate.ts +36 -3
  78. package/src/lib/destination-policy.ts +93 -7
  79. package/src/lib/redact.ts +6 -1
  80. package/src/lib/shadow-call.ts +38 -3
  81. package/src/lib/test-home-guard.ts +18 -3
  82. package/src/lib/upstream-retry.ts +43 -6
  83. package/src/lib/windows-secret-acl.ts +66 -0
  84. package/src/lib/windows-text.ts +28 -2
  85. package/src/lib/windows-user-principal.ts +35 -23
  86. package/src/oauth/account-quota-rank.ts +107 -0
  87. package/src/oauth/anthropic-routing.ts +125 -30
  88. package/src/oauth/chatgpt.ts +5 -1
  89. package/src/oauth/generic-account-failover.ts +114 -7
  90. package/src/oauth/index.ts +15 -8
  91. package/src/oauth/store.ts +16 -0
  92. package/src/providers/account-quota-disk.ts +79 -0
  93. package/src/providers/command-code-efforts.ts +24 -0
  94. package/src/providers/derive.ts +6 -0
  95. package/src/providers/key-failover.ts +33 -1
  96. package/src/providers/kiro-usage.ts +272 -0
  97. package/src/providers/ollama-show.ts +311 -0
  98. package/src/providers/openai-sidecar.ts +5 -0
  99. package/src/providers/quota-routing-cache.ts +32 -0
  100. package/src/providers/quota-types.ts +36 -0
  101. package/src/providers/quota-wire.ts +102 -0
  102. package/src/providers/quota.ts +208 -147
  103. package/src/providers/registry.ts +68 -8
  104. package/src/providers/slug-codec.ts +12 -4
  105. package/src/providers/vercel-gateway-routing.ts +108 -0
  106. package/src/router.ts +22 -12
  107. package/src/server/auth-cors.ts +26 -0
  108. package/src/server/catalog-download.ts +73 -0
  109. package/src/server/chat-native.ts +12 -2
  110. package/src/server/gui-static.ts +4 -1
  111. package/src/server/index.ts +132 -9
  112. package/src/server/management/agent-settings-routes.ts +38 -5
  113. package/src/server/management/codex-prompt-routes.ts +7 -1
  114. package/src/server/management/combo-routes.ts +10 -1
  115. package/src/server/management/config-routes.ts +9 -1
  116. package/src/server/management/context.ts +5 -0
  117. package/src/server/management/model-routes.ts +16 -6
  118. package/src/server/management/native-integration-routes.ts +12 -17
  119. package/src/server/management/oauth-account-routes.ts +13 -0
  120. package/src/server/management/provider-routes.ts +32 -5
  121. package/src/server/management/routing-profile-routes.ts +15 -0
  122. package/src/server/management/shadow-call-validation.ts +29 -0
  123. package/src/server/management-api.ts +7 -3
  124. package/src/server/request-log.ts +3 -5
  125. package/src/server/responses/agent-task-recovery-cache.ts +8 -0
  126. package/src/server/responses/agent-task-recovery.ts +52 -20
  127. package/src/server/responses/codex-auth-error.ts +26 -0
  128. package/src/server/responses/compact.ts +345 -10
  129. package/src/server/responses/core.ts +736 -108
  130. package/src/server/responses/empty-completion-guard.ts +16 -0
  131. package/src/server/responses/fetch-helpers.ts +42 -0
  132. package/src/server/responses/policy-fallback.ts +11 -6
  133. package/src/server/responses-undeclared-tool-guard.ts +16 -3
  134. package/src/server/startup-health-cache.ts +59 -13
  135. package/src/service-manager-probe.ts +115 -9
  136. package/src/service.ts +139 -40
  137. package/src/storage/cleanup.ts +10 -0
  138. package/src/storage/storage-mutation-coordinator.ts +14 -3
  139. package/src/tray/windows-tray.ps1 +10 -4
  140. package/src/tray/windows.ts +30 -2
  141. package/src/types/config.ts +27 -14
  142. package/src/types/provider.ts +54 -0
  143. package/src/types/tools.ts +13 -3
  144. package/src/types.ts +4 -0
  145. package/src/usage/summary.ts +421 -177
  146. package/src/vision/anthropic-describe.ts +3 -3
  147. package/src/vision/describe.ts +5 -3
  148. package/src/web-search/anthropic-executor.ts +9 -2
  149. package/src/web-search/exa-executor.ts +3 -3
  150. package/src/web-search/executor.ts +8 -3
  151. package/src/web-search/gemini-executor.ts +3 -3
  152. package/src/web-search/loop.ts +11 -3
  153. package/src/web-search/xai-executor.ts +3 -3
  154. package/gui/dist/assets/index-DNdRKXK9.js +0 -112
  155. package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { getAccountSet } from "./store";
18
18
  import { getValidAccessSnapshotForAccount, type OAuthAccessSnapshot } from "./index";
19
+ import { exhaustedCooldownMs, hasHeadroomEvidence, rankAccountsByHeadroom } from "./account-quota-rank";
19
20
  import { parseRetryAfterMs } from "../combos/failover";
20
21
  import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
21
22
  import type { OcxConfig, OcxProviderConfig } from "../types";
@@ -60,12 +61,29 @@ interface PresenceEntry {
60
61
  readAt: number;
61
62
  }
62
63
 
64
+ /**
65
+ * Ordered roster plus the active id, for the pre-dispatch preference.
66
+ *
67
+ * Same reasoning as the presence cache: `getAccountSet` reads through `loadAuthStore`,
68
+ * which chmods and re-parses the whole credential file on every call. Selection needs the
69
+ * ORDER and the active id, which the presence count cannot supply, so it gets its own
70
+ * TTL-bounded row. Ids and an active pointer only — never a credential.
71
+ */
72
+ interface RosterEntry {
73
+ ids: string[];
74
+ activeId: string | null;
75
+ readAt: number;
76
+ }
77
+
63
78
  /** Process-local, like the Anthropic pool's: a restart is allowed to forget a cooldown. */
64
79
  const health = new Map<string, AccountHealth>();
65
80
 
66
81
  /** Provider -> recent eligible-account count. TTL-bounded; never holds credential material. */
67
82
  const presence = new Map<string, PresenceEntry>();
68
83
 
84
+ /** Provider -> recently read roster. TTL-bounded; never holds credential material. */
85
+ const roster = new Map<string, RosterEntry>();
86
+
69
87
  const healthKey = (provider: string, accountId: string) => `${provider}\u0000${accountId}`;
70
88
 
71
89
  function isCooled(provider: string, accountId: string, now: number): boolean {
@@ -100,6 +118,24 @@ function eligibleAccountCount(providerName: string, now: number): number {
100
118
  return eligible;
101
119
  }
102
120
 
121
+ /**
122
+ * Roster ids and the active pointer, read at most once per TTL window.
123
+ *
124
+ * `needsReauth` accounts are excluded for the same reason the presence count excludes
125
+ * them: a revoked credential cannot serve the request we are about to send.
126
+ */
127
+ function cachedRoster(providerName: string, now: number): { ids: string[]; activeId: string | null } {
128
+ const cached = roster.get(providerName);
129
+ if (cached && now >= cached.readAt && now - cached.readAt < PRESENCE_CACHE_TTL_MS) {
130
+ return { ids: cached.ids, activeId: cached.activeId };
131
+ }
132
+ const set = getAccountSet(providerName);
133
+ const ids = set ? set.accounts.filter(a => a.needsReauth !== true).map(a => a.id) : [];
134
+ const activeId = set?.activeAccountId ?? null;
135
+ roster.set(providerName, { ids, activeId, readAt: now });
136
+ return { ids, activeId };
137
+ }
138
+
103
139
  /**
104
140
  * Presence IS consent (#2568d).
105
141
  *
@@ -167,7 +203,11 @@ export function rotateGenericOAuthAccountOn429(
167
203
  if (!set || set.accounts.length < 2) return null;
168
204
 
169
205
  const parsed = parseRetryAfterMs(retryAfterHeader, now);
170
- const cooldownMs = Math.min(parsed ?? DEFAULT_COOLDOWN_MS, MAX_COOLDOWN_MS);
206
+ // An account whose allowance is provably spent gets a reset-aligned cooldown instead of
207
+ // the default minute: retrying it every 60s until the window rolls over is pure waste.
208
+ // A Retry-After from upstream still wins — it is the server's own instruction.
209
+ const exhausted = parsed === null ? exhaustedCooldownMs(providerName, failedAccountId, now) : null;
210
+ const cooldownMs = exhausted ?? Math.min(parsed ?? DEFAULT_COOLDOWN_MS, MAX_COOLDOWN_MS);
171
211
  health.set(healthKey(providerName, failedAccountId), {
172
212
  cooldownUntil: now + cooldownMs,
173
213
  cooldownSource: parsed ? "retry-after" : "default",
@@ -179,15 +219,19 @@ export function rotateGenericOAuthAccountOn429(
179
219
  // A rotation means the roster in use just changed; do not answer the next activation question
180
220
  // from a count read before the failure.
181
221
  presence.delete(providerName);
222
+ // Same for the selection roster: the next request must not pick from a pre-failure read.
223
+ roster.delete(providerName);
182
224
  // Deterministic: start after the failed account so repeated 429s walk the roster instead of
183
- // hammering whichever id happens to sort first.
225
+ // hammering whichever id happens to sort first. The ring is built BEFORE ranking — ranking
226
+ // the store's own order would change which account a quota-less provider rotates to.
184
227
  const order = set.accounts.map(account => account.id);
185
228
  const start = order.indexOf(failedAccountId);
186
- for (let i = 1; i <= order.length; i++) {
187
- const candidate = order[(start + i) % order.length]!;
188
- if (candidate !== failedAccountId && eligible.includes(candidate)) return candidate;
189
- }
190
- return null;
229
+ const ring = start >= 0 ? [...order.slice(start + 1), ...order.slice(0, start)] : order;
230
+ const candidates = ring.filter(id => id !== failedAccountId && eligible.includes(id));
231
+ if (candidates.length === 0) return null;
232
+ // With no quota evidence this returns the ring untouched, so providers without
233
+ // per-account quota keep exactly the traversal they have today.
234
+ return rankAccountsByHeadroom(providerName, candidates)[0] ?? null;
191
235
  }
192
236
 
193
237
  /**
@@ -204,6 +248,61 @@ export async function failoverAccountSnapshot(
204
248
  return getValidAccessSnapshotForAccount(providerName, accountId);
205
249
  }
206
250
 
251
+ /**
252
+ * Which account should serve the FIRST attempt of a request.
253
+ *
254
+ * Rotation only ever ran after a 429, so a turn still opened on whichever account happened
255
+ * to be active — including one a previous probe already measured as spent. That costs a
256
+ * full upstream round trip and one of three rotations to rediscover what the cache knew.
257
+ *
258
+ * Returns null whenever the ordinary active-account path should be used unchanged: no
259
+ * quorum, rotation disabled, a single account, or no quota evidence to act on. This is a
260
+ * preference, never a gate — a cooled or unmeasured account is still perfectly usable, so
261
+ * an empty answer means "carry on", not "refuse".
262
+ */
263
+ export function preferredInitialAccount(
264
+ config: OcxConfig,
265
+ providerName: string,
266
+ now = Date.now(),
267
+ ): string | null {
268
+ if (!isGenericOAuthFailoverEnabled(config, providerName)) return null;
269
+ // This runs on the initial resolution of EVERY request, and `loadAuthStore` has no
270
+ // cache: each call chmods the config dir, chmods the secret, reads the whole file and
271
+ // normalizes it (store.ts:136-151). So the store is consulted at most ONCE here, behind
272
+ // the same TTL the presence check uses, and never at all for a single-account provider.
273
+ const { ids: order, activeId: active } = cachedRoster(providerName, now);
274
+ if (order.length < 2) return null;
275
+
276
+ // Evidence is required BEFORE eligibility narrows the field. Without this, a provider
277
+ // with no quota data at all could still be redirected: cool the active account with a
278
+ // 429 and the eligible list collapses to one candidate, which any ranking returns
279
+ // unchanged — an answer that looks ranked but was never measured. The no-op guarantee
280
+ // for quota-less providers has to be checked on the full roster.
281
+ if (!hasHeadroomEvidence(providerName, order)) return null;
282
+
283
+ // Cooldowns are respected here, unlike in the presence count: this picks the account to
284
+ // send to right now, and one inside its 429 window is the single candidate we hold
285
+ // positive evidence against.
286
+ const eligible = order.filter(id => !isCooled(providerName, id, now));
287
+ if (eligible.length === 0) return null;
288
+
289
+ // Start the ring at the active account so an unranked outcome reproduces today's choice.
290
+ const start = active ? order.indexOf(active) : -1;
291
+ const ring = start >= 0 ? [...order.slice(start), ...order.slice(0, start)] : order;
292
+ const candidates = ring.filter(id => eligible.includes(id));
293
+ if (candidates.length === 0) return null;
294
+
295
+ const best = rankAccountsByHeadroom(providerName, candidates)[0] ?? null;
296
+ // Nothing to do when the ranking agrees with the account we would have used anyway.
297
+ //
298
+ // The roster may be up to PRESENCE_CACHE_TTL_MS old, so this answer is a PREFERENCE the
299
+ // caller must be able to abandon: it resolves the account with `requireUsableAccount`,
300
+ // which rejects a removed or reauth-flagged account inside the store read it was already
301
+ // performing, and falls back to the active account. Validating here instead would mean a
302
+ // second uncached read of the credential file on every redirected request.
303
+ return best && best !== active ? best : null;
304
+ }
305
+
207
306
  /** Earliest remaining cooldown, for a client-facing Retry-After when every account is cooled. */
208
307
  export function genericFailoverRetryAfterSeconds(providerName: string, now = Date.now()): number | null {
209
308
  const set = getAccountSet(providerName);
@@ -217,14 +316,22 @@ export function genericFailoverRetryAfterSeconds(providerName: string, now = Dat
217
316
  return earliest === null ? null : Math.max(1, Math.ceil((earliest - now) / 1000));
218
317
  }
219
318
 
319
+ /** Test seam and manual-recovery hook. */
320
+ export function forgetGenericFailoverRoster(providerName: string): void {
321
+ roster.delete(providerName);
322
+ presence.delete(providerName);
323
+ }
324
+
220
325
  /** Test seam and manual-recovery hook. */
221
326
  export function clearGenericFailoverHealth(providerName?: string): void {
222
327
  if (!providerName) {
223
328
  health.clear();
224
329
  presence.clear();
330
+ roster.clear();
225
331
  return;
226
332
  }
227
333
  presence.delete(providerName);
334
+ roster.delete(providerName);
228
335
  for (const key of [...health.keys()]) {
229
336
  if (key.startsWith(`${providerName}\u0000`)) health.delete(key);
230
337
  }
@@ -4,7 +4,7 @@ import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
4
4
  import { loadConfig, resolveEnvValue, saveConfig } from "../config";
5
5
  import { maskEmail } from "../lib/privacy";
6
6
  import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro";
7
- import { getAccountCredential, getAccountSet, removeAccount, saveAccountCredential, saveCredential, setActiveAccount, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, markOAuthRefreshIntentStaleOwner, clearOAuthRefreshIntent, normalizeAuthStoreBuffer, OAuthMutationBusyError } from "./store";
7
+ import { getAccountCredential, getAccountCredentialWithStatus, getAccountSet, removeAccount, saveAccountCredential, saveCredential, setActiveAccount, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, markOAuthRefreshIntentStaleOwner, clearOAuthRefreshIntent, normalizeAuthStoreBuffer, OAuthMutationBusyError } from "./store";
8
8
  import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai";
9
9
  import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic";
10
10
  import { loginKimi, refreshKimiToken } from "./kimi";
@@ -62,10 +62,9 @@ export interface OAuthAccessSnapshot {
62
62
  /**
63
63
  * Allowlisted GitHub Copilot API origin belonging to THIS account.
64
64
  *
65
- * Copilot pins its bearer to an account-scoped regional host, and the initial route already
66
- * pairs the two (`core.ts` resolves transport with `getOAuthCredentialApiBaseUrl`). Account
67
- * failover must carry the pairing across the rotation; without it, account B's token is sent to
68
- * account A's origin (#2568d).
65
+ * Copilot pins its bearer to an account-scoped regional host. Initial routing, 401 refresh, and
66
+ * account failover must resolve transport from this same snapshot; rereading the active account
67
+ * can pair account A's token with account B's origin during a concurrent switch (#2568d).
69
68
  */
70
69
  apiBaseUrl?: string;
71
70
  }
@@ -437,11 +436,18 @@ async function resolveAccessSnapshotForAccount(
437
436
  provider: string,
438
437
  accountId: string,
439
438
  rejectedGeneration?: string,
439
+ requireUsableAccount = false,
440
440
  ): Promise<OAuthAccessSnapshot> {
441
441
  const def = OAUTH_PROVIDERS[provider];
442
442
  if (!def) throw new UnsupportedOAuthProviderError(provider);
443
- const cred = getAccountCredential(provider, accountId);
444
- if (!cred) throw new OAuthLoginRequiredError(provider);
443
+ // One store read answers both questions. A caller that opts in gets the account REJECTED
444
+ // when it needs reauthentication, which a bare credential read cannot detect: a revoked
445
+ // account keeps a readable credential, so resolution would otherwise succeed and the
446
+ // request would dispatch on an account already known to need a fresh login.
447
+ const row = getAccountCredentialWithStatus(provider, accountId);
448
+ if (!row) throw new OAuthLoginRequiredError(provider);
449
+ if (requireUsableAccount && row.needsReauth) throw new OAuthLoginRequiredError(provider);
450
+ const cred = row.credential;
445
451
  const current = accessSnapshot(provider, accountId, cred);
446
452
  if (rejectedGeneration !== undefined && current.generation !== rejectedGeneration) return current;
447
453
  if (rejectedGeneration === undefined && cred.expires > Date.now() + REFRESH_SKEW_MS) return current;
@@ -530,8 +536,9 @@ export async function getValidAccessTokenForAccount(provider: string, accountId:
530
536
  export async function getValidAccessSnapshotForAccount(
531
537
  provider: string,
532
538
  accountId: string,
539
+ opts: { requireUsableAccount?: boolean } = {},
533
540
  ): Promise<OAuthAccessSnapshot> {
534
- return resolveAccessSnapshotForAccount(provider, accountId);
541
+ return resolveAccessSnapshotForAccount(provider, accountId, undefined, opts.requireUsableAccount === true);
535
542
  }
536
543
 
537
544
  /** Terminal refresh failures (revoked/rotated-away grants) — retrying cannot succeed. */
@@ -641,6 +641,22 @@ export function getAccountCredential(provider: string, accountId: string): OAuth
641
641
  return loadAuthStore()[provider]?.accounts.find(a => a.id === accountId)?.credential ?? null;
642
642
  }
643
643
 
644
+ /**
645
+ * Credential plus the account's reauth flag, from ONE store read.
646
+ *
647
+ * A caller that checks `needsReauth` separately pays a second `loadAuthStore`, which
648
+ * chmods and re-parses the whole credential file. Returning both together lets an
649
+ * account-scoped resolver reject a revoked account without that extra read.
650
+ */
651
+ export function getAccountCredentialWithStatus(
652
+ provider: string,
653
+ accountId: string,
654
+ ): { credential: OAuthCredentials; needsReauth: boolean } | null {
655
+ const account = loadAuthStore()[provider]?.accounts.find(a => a.id === accountId);
656
+ if (!account?.credential) return null;
657
+ return { credential: account.credential, needsReauth: account.needsReauth === true };
658
+ }
659
+
644
660
  /** Persist a refreshed credential for a SPECIFIC account without touching activeAccountId. */
645
661
  export async function saveAccountCredential(
646
662
  provider: string,
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Last-known per-account provider quota, kept across restarts.
3
+ *
4
+ * The in-memory cache is process-local, so a restart forgets every measurement and the
5
+ * pool opens the next turn with no idea which account has room — exactly the state
6
+ * pre-dispatch selection exists to avoid. Codex solved this for its own pool with a small
7
+ * disk snapshot (`codex/quota.ts`), and this is the same shape for provider accounts.
8
+ *
9
+ * What is written is percentages and reset timestamps. No token, no email, no account
10
+ * label — the account id is the store's own opaque id, which is already what keys the
11
+ * in-memory cache.
12
+ */
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { atomicWriteFile, getConfigDir } from "../config";
16
+ import type { ProviderQuota } from "./quota-types";
17
+
18
+ const FILENAME = "provider-account-quota-cache.json";
19
+
20
+ /**
21
+ * Older than this and the snapshot is discarded on load.
22
+ *
23
+ * Six hours matches the Codex cache. A stale bar is still better than none for ORDERING —
24
+ * it decides which account to try first, and a wrong guess costs one 429 that rotation
25
+ * already handles — but a day-old reading of a monthly window has drifted far enough that
26
+ * it should not outrank a fresh probe.
27
+ */
28
+ const DISK_MAX_AGE_MS = 6 * 60 * 60_000;
29
+ const PERSIST_DEBOUNCE_MS = 250;
30
+
31
+ type DiskFile = {
32
+ version: 1;
33
+ /** provider\u0000accountId -> quota, the same key the in-memory cache uses. */
34
+ rows: Record<string, ProviderQuota>;
35
+ };
36
+
37
+ let persistTimer: ReturnType<typeof setTimeout> | null = null;
38
+
39
+ /** Read the snapshot. Returns an empty map for a missing, corrupt or stale file. */
40
+ export function readPersistedAccountQuotas(now = Date.now()): Map<string, ProviderQuota> {
41
+ const rows = new Map<string, ProviderQuota>();
42
+ try {
43
+ const path = join(getConfigDir(), FILENAME);
44
+ if (!existsSync(path)) return rows;
45
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as DiskFile;
46
+ if (!parsed || parsed.version !== 1 || !parsed.rows || typeof parsed.rows !== "object") return rows;
47
+ for (const [key, quota] of Object.entries(parsed.rows)) {
48
+ if (!quota || typeof quota !== "object" || typeof quota.updatedAt !== "number") continue;
49
+ if (now - quota.updatedAt > DISK_MAX_AGE_MS) continue;
50
+ rows.set(key, quota);
51
+ }
52
+ } catch {
53
+ // A corrupt cache must never block routing or the dashboard.
54
+ }
55
+ return rows;
56
+ }
57
+
58
+ /** Write the snapshot, debounced. Best-effort: a failed write is not an error. */
59
+ export function schedulePersistAccountQuotas(rows: () => Iterable<[string, ProviderQuota]>): void {
60
+ if (persistTimer) clearTimeout(persistTimer);
61
+ persistTimer = setTimeout(() => {
62
+ persistTimer = null;
63
+ try {
64
+ const out: Record<string, ProviderQuota> = {};
65
+ for (const [key, quota] of rows()) out[key] = quota;
66
+ const body: DiskFile = { version: 1, rows: out };
67
+ atomicWriteFile(join(getConfigDir(), FILENAME), `${JSON.stringify(body)}\n`);
68
+ } catch {
69
+ // Best-effort persistence only.
70
+ }
71
+ }, PERSIST_DEBOUNCE_MS);
72
+ }
73
+
74
+ /** Test seam: drop any pending write so a suite cannot leak one into the next file. */
75
+ export function cancelPendingAccountQuotaPersist(): void {
76
+ if (!persistTimer) return;
77
+ clearTimeout(persistTimer);
78
+ persistTimer = null;
79
+ }
@@ -73,6 +73,30 @@ const COMMAND_CODE_MODEL_EFFORTS = {
73
73
  efforts: ["low", "high", "max"],
74
74
  profileUrl: "https://commandcode.ai/models/glm-5-3",
75
75
  },
76
+ /*
77
+ * GLM-5.3-Flash (#2883). Reported as advertising NO efforts at all: the live
78
+ * route is `z-ai/glm-5.3-flash`, which shares neither vendor prefix nor model
79
+ * id with `zai-org/GLM-5.3` above, so `modelRecordValue` cannot bridge them
80
+ * (exact / colon-family / case-folded only — by design; a substring match here
81
+ * would merge two genuinely different models across two vendor namespaces).
82
+ *
83
+ * PROVENANCE: unlike the #2647 rows above, this ladder is MEASURED, not
84
+ * reported. commandcode.ai renders the profile client-side, but the delivered
85
+ * HTML ships a serialized React payload whose string table can be read
86
+ * directly: in the 2026-08-29 fetch of /models/glm-5-3-flash (HTTP 200,
87
+ * 228749 bytes) the indices resolve as 224=low, 225=medium, 226=high,
88
+ * 227=xhigh, 569=max, and this model's array is [224,226,569].
89
+ *
90
+ * The index map was cross-validated against every row in this table that the
91
+ * same page carries: deepseek-v4-pro and -flash [226,569], gpt-5.6-luna
92
+ * [224,225,226,227,569], gemini-3.7-flash [224,225,226], GLM-5.2 [226,569],
93
+ * GLM-5.3 [224,226,569] — six for six against the values already committed
94
+ * here. No authenticated upstream generate probe was performed.
95
+ */
96
+ "z-ai/glm-5.3-flash": {
97
+ efforts: ["low", "high", "max"],
98
+ profileUrl: "https://commandcode.ai/models/glm-5-3-flash",
99
+ },
76
100
  // Muse Spark: CLI currently prints "has no adjustable reasoning effort" and
77
101
  // blocks --effort locally, but the upstream /alpha/generate endpoint accepts
78
102
  // reasoning_effort low..max for meta/muse-spark-1.2-contributor (verified
@@ -254,6 +254,9 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
254
254
  ...(entry.requiresAdjacentResponsesToolResults !== undefined
255
255
  ? { requiresAdjacentResponsesToolResults: entry.requiresAdjacentResponsesToolResults }
256
256
  : {}),
257
+ ...(entry.annotateEmptyToolOutputs !== undefined
258
+ ? { annotateEmptyToolOutputs: entry.annotateEmptyToolOutputs }
259
+ : {}),
257
260
  ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}),
258
261
  ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
259
262
  ...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}),
@@ -501,6 +504,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
501
504
  if (prov.requiresAdjacentResponsesToolResults === undefined && seed.requiresAdjacentResponsesToolResults !== undefined) {
502
505
  prov.requiresAdjacentResponsesToolResults = seed.requiresAdjacentResponsesToolResults;
503
506
  }
507
+ if (prov.annotateEmptyToolOutputs === undefined && seed.annotateEmptyToolOutputs !== undefined) {
508
+ prov.annotateEmptyToolOutputs = seed.annotateEmptyToolOutputs;
509
+ }
504
510
  // Registry-only metadata (never seeded into saved config): backfill straight from
505
511
  // the entry so an explicit user value stays distinguishable from the default.
506
512
  if (prov.fastWire === undefined && entry.fastWire !== undefined) {
@@ -9,7 +9,7 @@
9
9
  * Modelled after src/codex/routing.ts cooldown logic but scoped to plain API-key pools.
10
10
  */
11
11
  import { saveConfigPreservingClaudeCode } from "../config";
12
- import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy } from "../types";
12
+ import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy, TransientRetryPolicy } from "../types";
13
13
  import { resolveProviderTransport, type OcxProviderTransport } from "./xai-transport";
14
14
  import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
15
15
 
@@ -34,6 +34,15 @@ const DEFAULT_RATE_LIMIT_RETRY = {
34
34
  respectRetryAfter: true,
35
35
  } as const satisfies Required<RateLimitRetryPolicy>;
36
36
 
37
+ /**
38
+ * Default transient-5xx retry used when a provider opts in with a bare
39
+ * `transientRetryOn5xx: {}`. `attempts` is a TOTAL send budget, not extra retries.
40
+ */
41
+ const DEFAULT_TRANSIENT_RETRY = {
42
+ enabled: true,
43
+ attempts: 3,
44
+ } as const satisfies Required<TransientRetryPolicy>;
45
+
37
46
  /** Map<`${providerName}\0${keyId}`, KeyCooldown> */
38
47
  const keyCooldowns = new Map<string, KeyCooldown>();
39
48
 
@@ -114,6 +123,29 @@ export function rateLimitRetryPolicyFor(
114
123
  };
115
124
  }
116
125
 
126
+ /**
127
+ * Normalize a provider's `transientRetryOn5xx` policy, or return null when it is absent,
128
+ * explicitly disabled, not key-auth, or not the `openai-chat` adapter.
129
+ *
130
+ * The adapter gate is part of the accepted scope, not incidental: this first version covers
131
+ * key-auth `openai-chat` only, and without an explicit check any generic key-auth adapter
132
+ * could opt in. Auth mode follows the same fail-closed rule as `rateLimitRetryPolicyFor` —
133
+ * explicit `key` or the documented omitted default, never OAuth, forward, local, or an
134
+ * unknown value.
135
+ */
136
+ export function transientRetryPolicyFor(
137
+ provider: Pick<OcxProviderConfig, "transientRetryOn5xx" | "authMode" | "adapter">,
138
+ ): Required<TransientRetryPolicy> | null {
139
+ const policy = provider.transientRetryOn5xx;
140
+ if (!policy || policy.enabled === false) return null;
141
+ if (provider.adapter !== "openai-chat") return null;
142
+ if (provider.authMode !== undefined && provider.authMode !== "key") return null;
143
+ return {
144
+ enabled: policy.enabled ?? DEFAULT_TRANSIENT_RETRY.enabled,
145
+ attempts: policy.attempts ?? DEFAULT_TRANSIENT_RETRY.attempts,
146
+ };
147
+ }
148
+
117
149
  /**
118
150
  * Wait before the next same-target replay: upstream Retry-After (seconds or HTTP-date) when
119
151
  * `respectRetryAfter` is on and the header parses, capped at `maxIntervalMs`; otherwise the