@bitkyc08/opencodex 2.75.0 → 2.76.0

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 (217) hide show
  1. package/README.md +6 -1
  2. package/bin/ocx.mjs +12 -1
  3. package/gui/dist/assets/App-CddorHdA.js +51 -0
  4. package/gui/dist/assets/App-DMudtISW.css +1 -0
  5. package/gui/dist/assets/{Tray-CGAD2AGk.js → Tray-DjNwwrWS.js} +1 -1
  6. package/gui/dist/assets/index-Btze1I3T.css +1 -0
  7. package/gui/dist/assets/index-CvnY7E6i.js +86 -0
  8. package/gui/dist/assets/quota-summary-B_AWpHfP.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/gui/dist/provider-icons/opengateway.svg +12 -0
  11. package/package.json +1 -1
  12. package/src/adapters/anthropic/account-metadata.ts +17 -0
  13. package/src/adapters/anthropic/client-identity.ts +69 -0
  14. package/src/adapters/anthropic/passthrough.ts +23 -4
  15. package/src/adapters/anthropic.ts +18 -1
  16. package/src/adapters/command-code-project-context.ts +7 -11
  17. package/src/adapters/devin/cloud-direct/tool-schema.ts +12 -48
  18. package/src/adapters/kiro-retry.ts +23 -8
  19. package/src/adapters/openai-chat/messages.ts +1 -1
  20. package/src/adapters/registry.ts +8 -1
  21. package/src/adapters/zed.ts +377 -0
  22. package/src/bridge/sse.ts +4 -2
  23. package/src/chatgpt/app-server-shim/app-server-rewrite.ts +45 -0
  24. package/src/chatgpt/app-server-shim/bundle-trust.ts +77 -0
  25. package/src/chatgpt/app-server-shim/filter.ts +157 -0
  26. package/src/chatgpt/app-server-shim/gate-rewrite.ts +99 -0
  27. package/src/chatgpt/app-server-shim/launcher.ts +91 -0
  28. package/src/claude/desktop-3p-startup.ts +62 -0
  29. package/src/claude/desktop-3p.ts +5 -0
  30. package/src/claude/first-party-settings.ts +14 -0
  31. package/src/claude/intercept/cli-catalog.ts +150 -0
  32. package/src/claude/intercept/cli-picker.ts +82 -0
  33. package/src/claude/intercept/listener.ts +21 -6
  34. package/src/claude/intercept/picker-bootstrap.ts +23 -5
  35. package/src/claude/intercept/picker-models.ts +57 -13
  36. package/src/claude/intercept/runtime.ts +30 -2
  37. package/src/claude/intercept/settings.ts +3 -0
  38. package/src/cli/agent.ts +172 -0
  39. package/src/cli/alias.ts +2 -1
  40. package/src/cli/capabilities-command.ts +3 -2
  41. package/src/cli/capabilities.ts +87 -0
  42. package/src/cli/chatgpt-command.ts +140 -0
  43. package/src/cli/combo.ts +144 -19
  44. package/src/cli/config-command.ts +3 -1
  45. package/src/cli/dispatch.ts +10 -1
  46. package/src/cli/help.ts +1 -0
  47. package/src/cli/index.ts +5 -24
  48. package/src/cli/init.ts +16 -2
  49. package/src/cli/integrations.ts +13 -0
  50. package/src/cli/internal-command.ts +12 -5
  51. package/src/cli/observe.ts +6 -1
  52. package/src/cli/registry.ts +6 -0
  53. package/src/cli/route-policy.ts +3 -1
  54. package/src/cli/runtime-api.ts +5 -2
  55. package/src/cli/status-oauth.ts +5 -0
  56. package/src/cli/status.ts +8 -1
  57. package/src/cli/stop-approval.ts +55 -16
  58. package/src/cli/stop-report.ts +5 -0
  59. package/src/clients/lazycodex.ts +48 -0
  60. package/src/clients/omo-role-models.ts +111 -0
  61. package/src/codex/account-credit-use.ts +99 -0
  62. package/src/codex/account-lifecycle.ts +2 -0
  63. package/src/codex/agent-role-models.ts +171 -0
  64. package/src/codex/auth-api/account-list.ts +10 -3
  65. package/src/codex/auth-api/routes.ts +34 -0
  66. package/src/codex/auth-context.ts +52 -11
  67. package/src/codex/catalog/discovered-natives.ts +11 -1
  68. package/src/codex/catalog/gather-capture.ts +6 -1
  69. package/src/codex/catalog/model-hints.ts +9 -1
  70. package/src/codex/catalog/provider-models.ts +60 -0
  71. package/src/codex/catalog/routed-gather.ts +30 -5
  72. package/src/codex/convergence-types.ts +2 -1
  73. package/src/codex/main-account-external-usage.ts +62 -0
  74. package/src/codex/main-account-hard-lock.ts +24 -13
  75. package/src/codex/native-profile-processes.ts +53 -16
  76. package/src/codex/prompt-layers/toml-edit.ts +1 -1
  77. package/src/codex/quota-types.ts +3 -7
  78. package/src/codex/quota.ts +19 -4
  79. package/src/codex/role-auto-assign.ts +159 -0
  80. package/src/codex/role-sizing-limits.ts +2 -0
  81. package/src/codex/role-sizing.ts +216 -0
  82. package/src/codex/routing/selection.ts +36 -0
  83. package/src/codex/routing/thread-affinity.ts +2 -0
  84. package/src/codex/routing.ts +4 -4
  85. package/src/codex/subagent-model-fallback.ts +82 -26
  86. package/src/codex/subagent-selectable-models.ts +32 -0
  87. package/src/combos/index.ts +18 -0
  88. package/src/combos/jev-decision-contract.ts +20 -0
  89. package/src/combos/jev-dispatch.ts +18 -0
  90. package/src/combos/jev-model-backend.ts +127 -0
  91. package/src/combos/jev.ts +249 -33
  92. package/src/combos/types.ts +125 -2
  93. package/src/config/diagnostics.ts +18 -0
  94. package/src/config/macos-system-proxy.ts +72 -15
  95. package/src/config/owner-registry.ts +13 -4
  96. package/src/config/process-state.ts +17 -8
  97. package/src/config/proxy-env.ts +35 -5
  98. package/src/config/schema/config-schema.ts +30 -1
  99. package/src/config/schema/leaf-validators.ts +4 -0
  100. package/src/config/serving-runtimes.ts +95 -1
  101. package/src/generated/compatibility-version.json +336 -156
  102. package/src/lib/errors.ts +7 -7
  103. package/src/lib/jsonc.ts +13 -0
  104. package/src/lib/local-chat-completion.ts +144 -0
  105. package/src/lib/provider-outbound.ts +87 -1
  106. package/src/lib/provider-runtime-fetch.ts +23 -0
  107. package/src/lib/provider-tls-profile.ts +221 -0
  108. package/src/lib/request-execution-budget.ts +16 -6
  109. package/src/lib/spend-reservation-ledger.ts +54 -20
  110. package/src/lib/sse-decoder.ts +20 -17
  111. package/src/lib/synced-state-location.ts +96 -0
  112. package/src/lib/winsw.ts +14 -5
  113. package/src/oauth/anthropic-account-refusal.ts +53 -4
  114. package/src/oauth/anthropic-model-quota.ts +83 -0
  115. package/src/oauth/anthropic-rate-limit-policy.ts +37 -0
  116. package/src/oauth/anthropic-routing.ts +112 -58
  117. package/src/oauth/health.ts +39 -3
  118. package/src/oauth/index.ts +14 -0
  119. package/src/oauth/kiro-account-load.ts +10 -0
  120. package/src/oauth/zed.ts +134 -0
  121. package/src/providers/model-discovery.ts +19 -8
  122. package/src/providers/model-rename-fields.ts +1 -0
  123. package/src/providers/provider-id-rewrite.ts +9 -2
  124. package/src/providers/quota/account-cache.ts +29 -7
  125. package/src/providers/quota/anthropic-cooldown-recovery.ts +18 -0
  126. package/src/providers/quota/anthropic-family-headers.ts +25 -0
  127. package/src/providers/quota/vendor-probes-oauth.ts +5 -1
  128. package/src/providers/quota-types.ts +4 -0
  129. package/src/providers/registry/entries-core.ts +18 -0
  130. package/src/providers/registry/entries-extended.ts +55 -0
  131. package/src/providers/registry/types.ts +3 -0
  132. package/src/providers/xai-fast-model.ts +12 -4
  133. package/src/providers/zed.ts +598 -0
  134. package/src/responses/state/snapshot-select.ts +22 -9
  135. package/src/responses/state.ts +2 -3
  136. package/src/router.ts +9 -0
  137. package/src/server/anthropic-rate-limit-headers.ts +7 -0
  138. package/src/server/auth-cors.ts +7 -0
  139. package/src/server/chat-completions.ts +9 -4
  140. package/src/server/claude-messages.ts +59 -21
  141. package/src/server/index/claude-intercept-lifecycle.ts +71 -25
  142. package/src/server/index/optional-listeners.ts +4 -0
  143. package/src/server/index/spend-ledger-lifecycle.ts +4 -0
  144. package/src/server/management/agent-settings-routes.ts +74 -37
  145. package/src/server/management/claude-desktop-picker-routes.ts +7 -0
  146. package/src/server/management/claude-intercept-routes.ts +35 -0
  147. package/src/server/management/codex-agent-role-routes.ts +122 -0
  148. package/src/server/management/codex-role-auto-assign.ts +208 -0
  149. package/src/server/management/combo-routes.ts +62 -5
  150. package/src/server/management/config-routes.ts +35 -3
  151. package/src/server/management/context.ts +4 -1
  152. package/src/server/management/decision-discovery.ts +74 -0
  153. package/src/server/management/decision-model-validation.ts +80 -0
  154. package/src/server/management/decision-routes.ts +160 -0
  155. package/src/server/management/native-integration-routes.ts +16 -0
  156. package/src/server/management/provider-routes.ts +30 -34
  157. package/src/server/management/route-registry.ts +12 -0
  158. package/src/server/management/sibling-guard.ts +1 -0
  159. package/src/server/management/zed-provider-probe.ts +38 -0
  160. package/src/server/management-api.ts +12 -1
  161. package/src/server/messages-native-oauth.ts +7 -2
  162. package/src/server/messages-native.ts +7 -3
  163. package/src/server/relay.ts +3 -1
  164. package/src/server/request-log.ts +2 -0
  165. package/src/server/responses/adapter-continuation.ts +3 -3
  166. package/src/server/responses/adapter-dispatch.ts +18 -3
  167. package/src/server/responses/compact.ts +14 -1
  168. package/src/server/responses/core-auth.ts +2 -0
  169. package/src/server/responses/core-combo.ts +67 -11
  170. package/src/server/responses/core-normalize.ts +21 -1
  171. package/src/server/responses/core-options.ts +11 -1
  172. package/src/server/responses/core.ts +5 -3
  173. package/src/server/responses/fetch-helpers.ts +6 -3
  174. package/src/server/responses/jev-model-invoke.ts +190 -0
  175. package/src/server/responses/passthrough-delivery.ts +16 -0
  176. package/src/server/responses/policy-fallback.ts +55 -3
  177. package/src/server/responses/policy-request-scope.ts +80 -0
  178. package/src/server/responses/request-prepare.ts +70 -18
  179. package/src/server/responses/request-transport.ts +54 -23
  180. package/src/server/responses/run-turn-execution.ts +1 -0
  181. package/src/server/responses/sidecar-execution.ts +9 -3
  182. package/src/server/responses-hosted-image-display.ts +225 -0
  183. package/src/service/managing-cli.ts +34 -4
  184. package/src/service/systemd-env.ts +75 -0
  185. package/src/service/windows-guarded-manager.ts +60 -19
  186. package/src/service/windows-scheduler.ts +22 -3
  187. package/src/service/windows-taskxml.ts +32 -10
  188. package/src/service-manager-probe.ts +72 -19
  189. package/src/types/config.ts +31 -1
  190. package/src/types/provider.ts +2 -0
  191. package/src/types/request.ts +2 -0
  192. package/src/update/index.ts +39 -6
  193. package/src/update/job.ts +63 -63
  194. package/src/update/npm-cache-preflight.d.mts +33 -1
  195. package/src/update/npm-cache-preflight.mjs +131 -18
  196. package/src/update/ownership-transaction.ts +19 -1
  197. package/src/update/restart-ownership.ts +80 -6
  198. package/src/update/transactional-install.d.mts +2 -0
  199. package/src/update/transactional-install.mjs +6 -1
  200. package/src/usage/jev-stats.ts +28 -1
  201. package/src/usage/log.ts +1 -1
  202. package/src/vision/anthropic-describe.ts +5 -3
  203. package/src/vision/index.ts +1 -0
  204. package/src/vision/plan.ts +2 -1
  205. package/src/vision/routed-describe.ts +29 -121
  206. package/src/web-search/alpha-search.ts +3 -3
  207. package/src/web-search/anthropic-executor.ts +6 -4
  208. package/src/web-search/index.ts +1 -1
  209. package/src/web-search/loop.ts +3 -3
  210. package/src/web-search/passthrough-bridge.ts +2 -1
  211. package/src/web-search/run-turn-loop.ts +1 -1
  212. package/src/web-search/sidecar-providers.ts +2 -1
  213. package/gui/dist/assets/App-ia4U0ufY.css +0 -1
  214. package/gui/dist/assets/App-xm0zGNOe.js +0 -51
  215. package/gui/dist/assets/index-DBT3Q31Y.css +0 -1
  216. package/gui/dist/assets/index-D_RGAT6Q.js +0 -86
  217. package/gui/dist/assets/quota-summary-CUFq48t-.js +0 -1
@@ -1,9 +1,11 @@
1
+ import { anthropicFamilyRejected, anthropicFamilyRetryAt, anthropicModelExhausted, anthropicModelPercents, anthropicModelWeeklyPercent, clearAnthropicFamilyQuota } from "./anthropic-model-quota";
1
2
  /**
2
3
  * Opt-in Anthropic OAuth account pool (#294).
3
4
  *
4
5
  * Default OFF. When enabled:
5
6
  * - Sticky session affinity across requests that share a session key
6
- * - 429 and classified pre-output account 403s cool the failed account and fail over
7
+ * - Shared-quota 429s and classified pre-output account 403s cool the sender and fail over
8
+ * - Transient throttles preserve affinity; family refusals apply only to their model family
7
9
  * - New sessions use `strategy` (default quota): lowest known fiveHour usage (#493),
8
10
  * round-robin, or fill-first — affinity still wins for bound sessions
9
11
  *
@@ -15,6 +17,7 @@
15
17
  * backoff. Classified entitlement/billing 403s use a ten-minute default cooldown.
16
18
  * Token refresh failures retain the existing store needsReauth policy.
17
19
  */
20
+ import { anthropicRatePauseUntil, clearAnthropicRatePauses, classifyAnthropic429, anthropicRetryAfterMs, pauseAnthropicRateAdmission } from "./anthropic-rate-limit-policy";
18
21
  import { createHash } from "node:crypto";
19
22
  import { captureOAuthAccountSelection, commitOAuthAccountSelection, credentialGeneration, getAccountSet, getAccountCredential, getAccountCredentialWithStatus } from "./store";
20
23
  import type { OAuthAccessSnapshot } from "./index";
@@ -33,7 +36,7 @@ import {
33
36
  import type { OcxAccountPoolQuotaWindow, OcxAccountPoolRotationStrategy, OcxConfig } from "../types";
34
37
  import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
35
38
  import { retainedUtf8Bytes } from "../lib/admission";
36
- import { routeCandidates, type AnthropicRouteDecision } from "./anthropic-model-routes";
39
+ import { resolveAnthropicModelRoute, routeCandidates, type AnthropicRouteDecision } from "./anthropic-model-routes";
37
40
  import type { ProviderQuota } from "../providers/quota-types";
38
41
  import {
39
42
  anthropicCooldownGeneration,
@@ -185,6 +188,11 @@ function parseRateLimitReset(
185
188
  if (delayUntil(resetAt, now) === undefined) continue;
186
189
  if (latest === undefined || resetAt > latest) latest = resetAt;
187
190
  }
191
+ if (latest === undefined && !rejectedQuotaWindows.length
192
+ && headers.get("anthropic-ratelimit-unified-status")?.trim() === "rejected") {
193
+ const resetAt = Number(headers.get("anthropic-ratelimit-unified-reset")?.trim()) * 1000;
194
+ if (delayUntil(resetAt, now) !== undefined) latest = resetAt;
195
+ }
188
196
  if (latest === undefined) return undefined;
189
197
  return { delayMs: latest - now, rejectedQuotaWindows };
190
198
  }
@@ -271,6 +279,8 @@ export function sweepExpiredAnthropicRoutingHealth(now = Date.now()): number {
271
279
  /** Test / logout helper. */
272
280
  export function clearAnthropicAccountPoolState(): void {
273
281
  upstreamHealth.clear();
282
+ clearAnthropicRatePauses();
283
+ clearAnthropicFamilyQuota();
274
284
  clearAnthropicCooldownGenerations();
275
285
  sessionAffinity.clear();
276
286
  manualPreference = undefined;
@@ -313,7 +323,8 @@ function exhausted5h(accountId: string): boolean {
313
323
  return fiveHourKnown(accountId) && fiveHourScore(accountId) >= 100;
314
324
  }
315
325
 
316
- function hasKnownUsage(config: OcxConfig, accountId: string): boolean {
326
+ function hasKnownUsage(config: OcxConfig, accountId: string, model?: string): boolean {
327
+ if (model) return hasKnownUsage(config, accountId) || anthropicModelWeeklyPercent(getCachedProviderAccountQuota(PROVIDER, accountId), model) !== undefined;
317
328
  const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
318
329
  switch (window) {
319
330
  case "five-hour": return fiveHourKnown(accountId);
@@ -322,7 +333,12 @@ function hasKnownUsage(config: OcxConfig, accountId: string): boolean {
322
333
  }
323
334
  }
324
335
 
325
- function usageScore(config: OcxConfig, accountId: string): number {
336
+ function usageScore(config: OcxConfig, accountId: string, model?: string): number {
337
+ if (model) {
338
+ const family = anthropicModelWeeklyPercent(getCachedProviderAccountQuota(PROVIDER, accountId), model);
339
+ if (family === undefined) return usageScore(config, accountId);
340
+ return hasKnownUsage(config, accountId) ? Math.max(family, usageScore(config, accountId)) : family;
341
+ }
326
342
  const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
327
343
  switch (window) {
328
344
  case "five-hour": return fiveHourScore(accountId);
@@ -348,13 +364,13 @@ function isPoolCredentialUsable(accountId: string, now: number): boolean {
348
364
  return cred.expires > now + TOKEN_SKEW_MS;
349
365
  }
350
366
 
351
- export function getEligibleAnthropicAccounts(now = Date.now()): string[] {
367
+ export function getEligibleAnthropicAccounts(now = Date.now(), model?: string): string[] {
352
368
  const set = getAccountSet(PROVIDER);
353
369
  if (!set) return [];
354
370
  return set.accounts
355
371
  .filter(account =>
356
372
  account.paused !== true && account.needsReauth !== true
357
- && !isCooled(account.id, now)
373
+ && !isCooled(account.id, now) && !anthropicRatePauseUntil(account.id, now) && !anthropicFamilyRejected(account.id, model, now)
358
374
  && isPoolCredentialUsable(account.id, now))
359
375
  .map(account => account.id);
360
376
  }
@@ -452,20 +468,21 @@ export function forgetAnthropicFailoverQuorum(): void {
452
468
  }
453
469
 
454
470
  /** Earliest remaining cooldown among cooled Anthropic accounts, for client Retry-After. */
455
- export function getAnthropicPoolRetryAfterSeconds(now = Date.now(), decision: AnthropicRouteDecision | null = null): number | null {
471
+ export function getAnthropicPoolRetryAfterSeconds(now = Date.now(), decision: AnthropicRouteDecision | null = null, model?: string): number | null {
456
472
  const set = getAccountSet(PROVIDER);
457
473
  if (!set) return null;
458
474
  // Once an explicit fallback route has no eligible declared account, selection may use
459
475
  // the ordinary pool. Its earliest usable cooldown must determine the advertised wait.
460
476
  const fallbackExpanded = decision?.fallback === true
461
- && !getEligibleAnthropicAccounts(now).some(id => decision.accounts.includes(id));
477
+ && !getEligibleAnthropicAccounts(now, model).some(id => decision.accounts.includes(id));
462
478
  let earliest: number | null = null;
463
479
  for (const account of set.accounts) {
464
480
  if (account.paused === true || account.needsReauth === true || !isPoolCredentialUsable(account.id, now)) continue;
465
481
  if (decision && !fallbackExpanded && !decision.accounts.includes(account.id)) continue;
466
482
  const snap = getAnthropicAccountHealthSnapshot(account.id, now);
467
- if (!snap?.cooldownUntil) continue;
468
- if (earliest === null || snap.cooldownUntil < earliest) earliest = snap.cooldownUntil;
483
+ const until = Math.max(snap?.cooldownUntil ?? 0, anthropicRatePauseUntil(account.id, now) ?? 0, anthropicFamilyRetryAt(account.id, model, now) ?? 0);
484
+ if (until <= now) continue;
485
+ if (earliest === null || until < earliest) earliest = until;
469
486
  }
470
487
  if (earliest === null || earliest <= now) return null;
471
488
  return Math.max(1, Math.ceil((earliest - now) / 1000));
@@ -491,23 +508,24 @@ function compareScoredAccounts(a: ScoredAccount, b: ScoredAccount): number {
491
508
  return a.score - b.score || a.fiveHourTieBreak - b.fiveHourTieBreak;
492
509
  }
493
510
 
494
- function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: number, decision: AnthropicRouteDecision | null = null): string | null {
511
+ function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: number, decision: AnthropicRouteDecision | null = null, model?: string): string | null {
495
512
  const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
496
- const unfiltered = routeCandidates(getEligibleAnthropicAccounts(now), decision).filter(id => id !== excludeId);
513
+ const unfiltered = routeCandidates(getEligibleAnthropicAccounts(now, model), decision).filter(id => id !== excludeId);
497
514
  const available = window === "weekly" ? unfiltered.filter(id => !exhausted5h(id)
498
515
  || isAnthropicAccountPoolEnabled(config) && anthropicAccountAutoSwitchThreshold(config, id) === 0) : unfiltered;
499
- const availableOrFallback = available.length > 0 ? available : unfiltered;
516
+ const modelAvailable = model ? available.filter(id => !anthropicModelExhausted(id, model)) : available;
517
+ const availableOrFallback = modelAvailable.length > 0 ? modelAvailable : available.length > 0 ? available : unfiltered;
500
518
  // Thresholds are preferences, never eligibility. Keep the old lowest-usage fallback
501
519
  // when every candidate is drained, and keep pool-off reactive recovery policy inert.
502
520
  const hasKnownUnderThreshold = isAnthropicAccountPoolEnabled(config)
503
- && availableOrFallback.some(id => hasKnownUsage(config, id) && isActiveUnderFillFirstThreshold(config, id));
521
+ && availableOrFallback.some(id => hasKnownUsage(config, id, model) && isActiveUnderFillFirstThreshold(config, id, model));
504
522
  const eligible = hasKnownUnderThreshold
505
- ? availableOrFallback.filter(id => isActiveUnderFillFirstThreshold(config, id)) : availableOrFallback;
523
+ ? availableOrFallback.filter(id => isActiveUnderFillFirstThreshold(config, id, model)) : availableOrFallback;
506
524
  if (eligible.length === 0) return null;
507
525
  const scored: ScoredAccount[] = eligible.map(accountId => ({
508
526
  accountId,
509
- hasKnownUsage: hasKnownUsage(config, accountId),
510
- score: usageScore(config, accountId),
527
+ hasKnownUsage: hasKnownUsage(config, accountId, model),
528
+ score: usageScore(config, accountId, model),
511
529
  fiveHourTieBreak: window === "five-hour" ? 0 : fiveHourScore(accountId),
512
530
  // Every window EXCEPT the legacy five-hour default is an explicit opt-in, so
513
531
  // known-before-unknown applies to all of them and to none of the default path.
@@ -533,11 +551,13 @@ function pickNextFillFirstAnthropicAccount(
533
551
  afterId: string,
534
552
  eligible: string[],
535
553
  decision: AnthropicRouteDecision | null,
554
+ model?: string,
536
555
  ): string | null {
537
556
  const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
538
557
  const available = window === "weekly" ? eligible.filter(id => !exhausted5h(id)
539
558
  || anthropicAccountAutoSwitchThreshold(config, id) === 0) : eligible;
540
- const candidates = available.length > 0 ? available : eligible;
559
+ const modelAvailable = model ? available.filter(id => !anthropicModelExhausted(id, model)) : available;
560
+ const candidates = modelAvailable.length > 0 ? modelAvailable : available.length > 0 ? available : eligible;
541
561
  if (candidates.length === 0) return null;
542
562
  const routeOrder = usesDeclaredRouteOrder(eligible, decision);
543
563
  const ordered = routeOrder ? candidates : [...candidates].sort((a, b) => a.localeCompare(b));
@@ -548,7 +568,7 @@ function pickNextFillFirstAnthropicAccount(
548
568
  const startIdx = stableAll.indexOf(afterId);
549
569
  if (startIdx < 0) {
550
570
  for (const id of ordered) {
551
- if (isActiveUnderFillFirstThreshold(config, id)) return id;
571
+ if (isActiveUnderFillFirstThreshold(config, id, model)) return id;
552
572
  }
553
573
  return ordered[0] ?? null;
554
574
  }
@@ -558,26 +578,27 @@ function pickNextFillFirstAnthropicAccount(
558
578
  const candidate = stableAll[(startIdx + step) % stableAll.length]!;
559
579
  if (!candidates.includes(candidate)) continue;
560
580
  if (!fallback) fallback = candidate;
561
- if (isActiveUnderFillFirstThreshold(config, candidate)) return candidate;
581
+ if (isActiveUnderFillFirstThreshold(config, candidate, model)) return candidate;
562
582
  }
563
583
  return fallback ?? ordered[0] ?? null;
564
584
  }
565
585
 
566
- function pickAlternateAnthropicAccount(
586
+ export function pickAlternateAnthropicAccount(
567
587
  config: OcxConfig,
568
588
  excludeId: string,
569
589
  now: number,
570
590
  decision: AnthropicRouteDecision | null,
591
+ model?: string,
571
592
  ): string | null {
572
593
  const strategy = anthropicPoolStrategy(config);
573
- const eligible = routeCandidates(getEligibleAnthropicAccounts(now), decision).filter(id => id !== excludeId);
594
+ const eligible = routeCandidates(getEligibleAnthropicAccounts(now, model), decision).filter(id => id !== excludeId);
574
595
  if (strategy === "round-robin") {
575
596
  return peekRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, stickyLimitForPool(config));
576
597
  }
577
598
  if (strategy === "fill-first") {
578
- return pickNextFillFirstAnthropicAccount(config, excludeId, eligible, decision);
599
+ return pickNextFillFirstAnthropicAccount(config, excludeId, eligible, decision, model);
579
600
  }
580
- return pickLowestUsage(config, excludeId, now, decision);
601
+ return pickLowestUsage(config, excludeId, now, decision, model);
581
602
  }
582
603
 
583
604
  function pruneExpiredAffinity(now: number): void {
@@ -617,39 +638,39 @@ function anthropicPoolStrategy(config: OcxConfig): OcxAccountPoolRotationStrateg
617
638
  return normalizeAccountPoolStrategy(anthropicAccountPoolConfig(config).strategy);
618
639
  }
619
640
 
620
- function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string): boolean {
641
+ function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string, model?: string): boolean {
621
642
  const threshold = anthropicAccountAutoSwitchThreshold(config, accountId);
622
643
  if (threshold <= 0) return true;
623
644
  const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
624
645
  if (window === "weekly" && exhausted5h(accountId)) return false;
625
646
  // Unknown usage must not force fill-first to abandon the active account.
626
- if (!hasKnownUsage(config, accountId)) return true;
627
- return usageScore(config, accountId) < threshold;
647
+ if (!hasKnownUsage(config, accountId, model)) return true;
648
+ return usageScore(config, accountId, model) < threshold;
628
649
  }
629
650
 
630
651
  /**
631
652
  * Fill-first: keep eligible active under threshold; otherwise advance to the next
632
653
  * eligible id in stable sorted order after the current active (wrapping).
633
654
  */
634
- function pickFillFirstAnthropicAccount(config: OcxConfig, now: number, decision: AnthropicRouteDecision | null): string | null {
635
- const eligible = routeCandidates(getEligibleAnthropicAccounts(now), decision);
655
+ function pickFillFirstAnthropicAccount(config: OcxConfig, now: number, decision: AnthropicRouteDecision | null, model?: string): string | null {
656
+ const eligible = routeCandidates(getEligibleAnthropicAccounts(now, model), decision);
636
657
  if (eligible.length === 0) return null;
637
658
 
638
659
  const set = getAccountSet(PROVIDER);
639
660
  const active = set?.activeAccountId;
640
- if (active && eligible.includes(active) && isActiveUnderFillFirstThreshold(config, active)) {
661
+ if (active && eligible.includes(active) && (!model || !anthropicModelExhausted(active, model)) && isActiveUnderFillFirstThreshold(config, active, model)) {
641
662
  return active;
642
663
  }
643
664
 
644
665
  if (!active || !set) {
645
666
  const ordered = usesDeclaredRouteOrder(eligible, decision) ? eligible : [...eligible].sort((a, b) => a.localeCompare(b));
646
667
  for (const id of ordered) {
647
- if (isActiveUnderFillFirstThreshold(config, id)) return id;
668
+ if (isActiveUnderFillFirstThreshold(config, id, model)) return id;
648
669
  }
649
670
  return ordered[0] ?? null;
650
671
  }
651
672
 
652
- return pickNextFillFirstAnthropicAccount(config, active, eligible, decision);
673
+ return pickNextFillFirstAnthropicAccount(config, active, eligible, decision, model);
653
674
  }
654
675
 
655
676
  /**
@@ -660,20 +681,22 @@ function pickUnboundStrategyAccount(
660
681
  config: OcxConfig,
661
682
  now: number,
662
683
  decision: AnthropicRouteDecision | null,
684
+ model?: string,
663
685
  ): { accountId: string; reason: "round-robin" | "fill-first" } | null {
664
686
  const strategy = anthropicPoolStrategy(config);
665
687
  if (strategy === "quota") return null;
666
688
 
667
689
  if (strategy === "round-robin") {
668
- const eligible = routeCandidates(getEligibleAnthropicAccounts(now), decision);
690
+ const eligible = routeCandidates(getEligibleAnthropicAccounts(now, model), decision);
669
691
  const limit = stickyLimitForPool(config);
670
- const picked = peekRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, limit);
692
+ const headroom = model ? eligible.filter(id => !anthropicModelExhausted(id, model)) : eligible;
693
+ const picked = peekRoundRobinAccount(POOL_KEY_ANTHROPIC, headroom.length ? headroom : eligible, limit);
671
694
  if (!picked) return null;
672
695
  return { accountId: picked, reason: "round-robin" };
673
696
  }
674
697
 
675
698
  if (strategy === "fill-first") {
676
- const picked = pickFillFirstAnthropicAccount(config, now, decision);
699
+ const picked = pickFillFirstAnthropicAccount(config, now, decision, model);
677
700
  if (!picked) return null;
678
701
  return { accountId: picked, reason: "fill-first" };
679
702
  }
@@ -690,6 +713,7 @@ export function resolveAnthropicAccountForSession(
690
713
  config: OcxConfig,
691
714
  now = Date.now(),
692
715
  decision: AnthropicRouteDecision | null = null,
716
+ model?: string,
693
717
  ): AnthropicAccountSelection {
694
718
  pruneExpiredAffinity(now);
695
719
  const set = getAccountSet(PROVIDER);
@@ -706,21 +730,21 @@ export function resolveAnthropicAccountForSession(
706
730
  : null;
707
731
  }
708
732
 
709
- const eligible = routeCandidates(getEligibleAnthropicAccounts(now), decision);
733
+ const eligible = routeCandidates(getEligibleAnthropicAccounts(now, model), decision);
710
734
  if (eligible.length === 0) {
711
735
  // Pause, removal and reauthentication are not cooldown evidence. Classify only
712
736
  // usable members of this strict route (or the ordinary pool after fallback widens).
713
737
  const recoverable = set.accounts.filter(account =>
714
738
  (!decision || decision.fallback || decision.accounts.includes(account.id))
715
739
  && account.paused !== true && account.needsReauth !== true && isPoolCredentialUsable(account.id, now));
716
- const cooled = recoverable.length > 0 && recoverable.every(account => isCooled(account.id, now));
740
+ const cooled = recoverable.length > 0 && recoverable.every(account => isCooled(account.id, now) || anthropicRatePauseUntil(account.id, now) || anthropicFamilyRejected(account.id, model, now));
717
741
  if (cooled || decision) return { accountId: null, reason: cooled ? "all-cooled" : "none", routePosition: decision?.position };
718
742
  }
719
743
 
720
744
  if (!isAnthropicAccountPoolEnabled(config)) {
721
745
  const active = set.accounts.find(account => account.id === set.activeAccountId);
722
746
  // Disabled proactive rotation does not authorize a paused slot or an entirely cooled pool.
723
- if (active?.paused || isCooled(set.activeAccountId, now)) {
747
+ if (active?.paused || isCooled(set.activeAccountId, now) || anthropicRatePauseUntil(set.activeAccountId, now) || anthropicFamilyRejected(set.activeAccountId, model, now)) {
724
748
  return { accountId: eligible[0] ?? null, reason: eligible.length > 0 ? "only-eligible" : "none" };
725
749
  }
726
750
  return { accountId: set.activeAccountId, reason: "pool-disabled" };
@@ -734,9 +758,7 @@ export function resolveAnthropicAccountForSession(
734
758
  } else {
735
759
  const chosen = manualPreference.accountId;
736
760
  const quota = getCachedProviderAccountQuota(PROVIDER, chosen);
737
- const exhausted = [quota?.fiveHourPercent, quota?.weeklyPercent, quota?.monthlyPercent,
738
- ...(quota?.customWindows ?? []).map(window => window.percent)]
739
- .some(percent => typeof percent === "number" && percent >= 100);
761
+ const exhausted = anthropicModelPercents(quota, model).some(percent => percent >= 100);
740
762
  if (!exhausted && eligible.includes(chosen)) {
741
763
  return { accountId: chosen, reason: "manual", routePosition: decision?.position };
742
764
  }
@@ -750,7 +772,7 @@ export function resolveAnthropicAccountForSession(
750
772
  const stillThere = set.accounts.some(a => a.id === affined.accountId && a.paused !== true && a.needsReauth !== true);
751
773
  const stillUsable = stillThere && !isCooled(affined.accountId, now)
752
774
  && isPoolCredentialUsable(affined.accountId, now);
753
- if (stillUsable && eligible.includes(affined.accountId)) {
775
+ if (stillUsable && eligible.includes(affined.accountId) && (!model || !anthropicModelExhausted(affined.accountId, model))) {
754
776
  return { accountId: affined.accountId, reason: "affinity", routePosition: decision?.position };
755
777
  }
756
778
  // A model route may exclude a healthy binding only for this request. Keep it for
@@ -766,13 +788,13 @@ export function resolveAnthropicAccountForSession(
766
788
  if (!key && (strategy === "round-robin" || strategy === "fill-first")) {
767
789
  const activeOk = set.accounts.some(a => a.id === set.activeAccountId && a.needsReauth !== true)
768
790
  && !isCooled(set.activeAccountId, now)
769
- && eligible.includes(set.activeAccountId);
791
+ && eligible.includes(set.activeAccountId) && (!model || !anthropicModelExhausted(set.activeAccountId, model));
770
792
  if (activeOk) {
771
793
  return { accountId: set.activeAccountId, reason: "active", routePosition: decision?.position };
772
794
  }
773
795
  }
774
796
 
775
- const strategyPick = pickUnboundStrategyAccount(config, now, decision);
797
+ const strategyPick = pickUnboundStrategyAccount(config, now, decision, model);
776
798
  if (strategyPick) {
777
799
  return { accountId: strategyPick.accountId, reason: strategyPick.reason, routePosition: decision?.position };
778
800
  }
@@ -780,7 +802,7 @@ export function resolveAnthropicAccountForSession(
780
802
  const threshold = anthropicAccountAutoSwitchThreshold(config, set.activeAccountId);
781
803
  const activeOk = set.accounts.some(a => a.id === set.activeAccountId && a.needsReauth !== true)
782
804
  && !isCooled(set.activeAccountId, now)
783
- && eligible.includes(set.activeAccountId);
805
+ && eligible.includes(set.activeAccountId) && (!model || !anthropicModelExhausted(set.activeAccountId, model));
784
806
 
785
807
  let accountId: string | null = null;
786
808
  let reason: AnthropicAccountSelectionReason = "none";
@@ -790,11 +812,11 @@ export function resolveAnthropicAccountForSession(
790
812
  // Unknown usage must NOT force a switch away from the healthy active account.
791
813
  if (activeOk
792
814
  && !(window === "weekly" && exhausted5h(set.activeAccountId))
793
- && (!hasKnownUsage(config, set.activeAccountId) || usageScore(config, set.activeAccountId) < threshold)) {
815
+ && (!hasKnownUsage(config, set.activeAccountId, model) || usageScore(config, set.activeAccountId, model) < threshold)) {
794
816
  accountId = set.activeAccountId;
795
817
  reason = "active";
796
818
  } else {
797
- const picked = pickLowestUsage(config, undefined, now, decision);
819
+ const picked = pickLowestUsage(config, undefined, now, decision, model);
798
820
  if (picked) {
799
821
  accountId = picked;
800
822
  reason = activeOk && picked === set.activeAccountId ? "active" : "lowest-usage";
@@ -807,7 +829,7 @@ export function resolveAnthropicAccountForSession(
807
829
  accountId = set.activeAccountId;
808
830
  reason = "active";
809
831
  } else {
810
- const picked = pickLowestUsage(config, set.activeAccountId, now, decision);
832
+ const picked = pickLowestUsage(config, set.activeAccountId, now, decision, model);
811
833
  if (picked) {
812
834
  accountId = picked;
813
835
  reason = "only-eligible";
@@ -827,13 +849,14 @@ export async function resolveAnthropicDispatchAccountId(
827
849
  config: OcxConfig,
828
850
  sessionKey: string | null = null,
829
851
  decision: AnthropicRouteDecision | null = null,
852
+ model?: string,
830
853
  ): Promise<string> {
831
854
  const now = Date.now();
832
- const selection = resolveAnthropicAccountForSession(sessionKey, config, now, decision);
855
+ const selection = resolveAnthropicAccountForSession(sessionKey, config, now, decision, model);
833
856
  if (selection.reason === "all-cooled") {
834
- throw new AnthropicAccountCooldownError(getAnthropicPoolRetryAfterSeconds(now, decision), decision?.position);
857
+ throw new AnthropicAccountCooldownError(getAnthropicPoolRetryAfterSeconds(now, decision, model), decision?.position);
835
858
  }
836
- if (selection.reason === "paused" || !selection.accountId || !getEligibleAnthropicAccounts(now).includes(selection.accountId)) {
859
+ if (selection.reason === "paused" || !selection.accountId || !getEligibleAnthropicAccounts(now, model).includes(selection.accountId)) {
837
860
  const { OAuthAccountPausedError, OAuthLoginRequiredError } = await import("./index");
838
861
  const active = getAccountSet(PROVIDER)?.activeAccountId;
839
862
  // Pool-off fresh admission resolves the active credential first. Preserve that
@@ -879,8 +902,9 @@ export function rotateAnthropicAccountOn429(
879
902
  now = Date.now(),
880
903
  rateLimitHeaders?: AnthropicRateLimitHeaders | null,
881
904
  decision: AnthropicRouteDecision | null = null,
905
+ model?: string,
882
906
  ): string | null {
883
- return rotateAnthropicAccountOnRefusal(config, failedAccountId, 429, retryAfterHeader, sessionKey, now, rateLimitHeaders, decision);
907
+ return rotateAnthropicAccountOnRefusal(config, failedAccountId, 429, retryAfterHeader, sessionKey, now, rateLimitHeaders, decision, model);
884
908
  }
885
909
 
886
910
  /** The caller proves that a 403 is an account refusal before entering this pool policy. */
@@ -893,6 +917,7 @@ export function rotateAnthropicAccountOnRefusal(
893
917
  now = Date.now(),
894
918
  rateLimitHeaders?: AnthropicRateLimitHeaders | null,
895
919
  decision: AnthropicRouteDecision | null = null,
920
+ model?: string,
896
921
  ): string | null {
897
922
  if (!recordAnthropicAccountRefusal(config, failedAccountId, status, retryAfterHeader, now, rateLimitHeaders)) return null;
898
923
 
@@ -901,8 +926,8 @@ export function rotateAnthropicAccountOnRefusal(
901
926
  // because those dormant values remain in config. The quota picker is the neutral
902
927
  // recovery policy already used by the default strategy.
903
928
  const next = isAnthropicAccountPoolEnabled(config)
904
- ? pickAlternateAnthropicAccount(config, failedAccountId, now, decision)
905
- : pickLowestUsage(config, failedAccountId, now);
929
+ ? pickAlternateAnthropicAccount(config, failedAccountId, now, decision, model)
930
+ : pickLowestUsage(config, failedAccountId, now, null, model);
906
931
  if (!next) {
907
932
  console.warn(`[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}no eligible replacement; returning ${status}`);
908
933
  return null;
@@ -946,6 +971,15 @@ export function recordAnthropicAccountRefusal(
946
971
  if (!isAnthropicAccountPoolEnabled(config) && !hasAnthropicFailoverQuorum(now)
947
972
  && !getAccountCredentialWithStatus(PROVIDER, failedAccountId)?.paused) return false;
948
973
 
974
+ if (status === 429) {
975
+ const headers = rateLimitHeaders ?? new Headers();
976
+ const kind = classifyAnthropic429({ get: name => name === "retry-after" ? retryAfterHeader ?? null : headers.get(name) }, now);
977
+ if (kind !== "shared-quota") {
978
+ if (kind === "transient-rate") pauseAnthropicRateAdmission(failedAccountId, now + (anthropicRetryAfterMs(retryAfterHeader, now) ?? 100));
979
+ return false;
980
+ }
981
+ }
982
+
949
983
  // Retry-After first: it is the header written FOR this decision. The rejected window's
950
984
  // reset is the fallback, because a 429 that omits Retry-After still carries it -- and
951
985
  // without that fallback such a refusal cools for the 60s default and the exhausted
@@ -977,6 +1011,7 @@ export interface AnthropicSelectionRoutingOptions {
977
1011
  reason?: AnthropicAccountSelectionReason;
978
1012
  expectedCredentialGeneration?: string;
979
1013
  routeDecision?: AnthropicRouteDecision | null;
1014
+ model?: string;
980
1015
  }
981
1016
 
982
1017
  /** Commit the selected account before dispatch; rejected proposals have no routing side effects. */
@@ -985,7 +1020,7 @@ export async function promoteAnthropicActiveAccount(
985
1020
  expectedSelection: OAuthAccountSelection | null,
986
1021
  options: AnthropicSelectionRoutingOptions,
987
1022
  ): Promise<OAuthAccountSelection | null> {
988
- if (!expectedSelection || !routeCandidates(getEligibleAnthropicAccounts(), options.routeDecision ?? null).includes(accountId)) return null;
1023
+ if (!expectedSelection || !routeCandidates(getEligibleAnthropicAccounts(Date.now(), options.model), options.routeDecision ?? null).includes(accountId)) return null;
989
1024
  const committed = await commitOAuthAccountSelection(PROVIDER, accountId, {
990
1025
  expectedSelection,
991
1026
  expectedCredentialGeneration: options.expectedCredentialGeneration,
@@ -1003,13 +1038,13 @@ export function commitAnthropicSelectionRouting(
1003
1038
  options: AnthropicSelectionRoutingOptions,
1004
1039
  ): boolean {
1005
1040
  if (getAccountCredentialWithStatus(PROVIDER, accountId)?.paused || committed.accountId !== accountId || (options.routeDecision
1006
- && !routeCandidates(getEligibleAnthropicAccounts(), options.routeDecision).includes(accountId))) return false;
1041
+ && !routeCandidates(getEligibleAnthropicAccounts(Date.now(), options.model), options.routeDecision).includes(accountId))) return false;
1007
1042
  const current = captureOAuthAccountSelection(PROVIDER);
1008
1043
  if (current?.accountId !== committed.accountId || current.revision !== committed.revision) return false;
1009
1044
  if (isAnthropicAccountPoolEnabled(options.config)) {
1010
1045
  if (anthropicPoolStrategy(options.config) === "round-robin" && options.reason !== "affinity") {
1011
1046
  const limit = stickyLimitForPool(options.config);
1012
- const picked = pickRoundRobinAccount(POOL_KEY_ANTHROPIC, routeCandidates(getEligibleAnthropicAccounts(), options.routeDecision ?? null), limit);
1047
+ const picked = pickRoundRobinAccount(POOL_KEY_ANTHROPIC, routeCandidates(getEligibleAnthropicAccounts(Date.now(), options.model), options.routeDecision ?? null), limit);
1013
1048
  if (picked !== accountId) seedPoolRotationAccount(POOL_KEY_ANTHROPIC, accountId);
1014
1049
  notePoolRotationSuccess(POOL_KEY_ANTHROPIC, accountId, limit);
1015
1050
  }
@@ -1020,7 +1055,9 @@ export function commitAnthropicSelectionRouting(
1020
1055
  && Date.now() - bound.lastUsedAt <= AFFINITY_IDLE_TTL_MS
1021
1056
  && eligibleAtCommit.includes(bound.accountId)
1022
1057
  && !routeCandidates(eligibleAtCommit, options.routeDecision).includes(bound.accountId);
1023
- if (!preserveExcludedAffinity) bindAnthropicSessionAffinity(options.sessionKey, accountId);
1058
+ const preservePausedAffinity = bound && (anthropicRatePauseUntil(bound.accountId) !== undefined
1059
+ || anthropicFamilyRejected(bound.accountId, options.model));
1060
+ if (!preserveExcludedAffinity && !preservePausedAffinity) bindAnthropicSessionAffinity(options.sessionKey, accountId);
1024
1061
  }
1025
1062
  if (manualPreference === undefined || (manualPreference?.accountId === expectedSelection.accountId
1026
1063
  && manualPreference.revision === expectedSelection.revision)) manualPreference = null;
@@ -1079,6 +1116,23 @@ export async function getAnthropicPoolAccessSnapshot(accountId: string): Promise
1079
1116
  return { provider: PROVIDER, accountId, accessToken, generation: credentialGeneration(row.credential) };
1080
1117
  }
1081
1118
 
1119
+ /** Resolve a direct Anthropic helper send through the same model route as primary traffic. */
1120
+ export async function getAnthropicSidecarAccessToken(
1121
+ providerName: string,
1122
+ model: string,
1123
+ config?: OcxConfig,
1124
+ ): Promise<string> {
1125
+ if (providerName !== PROVIDER || !config || !isAnthropicAccountPoolEnabled(config)) {
1126
+ const { getValidAccessToken } = await import("./index");
1127
+ return getValidAccessToken(providerName);
1128
+ }
1129
+ const route = resolveAnthropicModelRoute(config, model);
1130
+ if (route.error) throw new Error("Invalid Anthropic model route configuration");
1131
+ const selection = resolveAnthropicAccountForSession(null, config, Date.now(), route.decision, model);
1132
+ if (!selection.accountId) throw new Error("No permitted Anthropic account is available for this model");
1133
+ return (await getAnthropicPoolAccessSnapshot(selection.accountId)).accessToken;
1134
+ }
1135
+
1082
1136
  /**
1083
1137
  * Whether the pool may refresh this account's token. Background `local-cli` slots must not
1084
1138
  * adopt the global Claude CLI credential (same fail-closed rule as quota probes).
@@ -1,3 +1,5 @@
1
+ import type { MainAccountHardLockStatus } from "../codex/main-account-hard-lock";
2
+ import type { MainAccountExternalUsageWarning } from "../codex/main-account-external-usage";
1
3
  import { getCodexAccountHealthSnapshot, type CodexCooldownSource } from "../codex/routing";
2
4
  import { getAnthropicAccountHealthSnapshot } from "./anthropic-routing";
3
5
  import { isAccountNeedsReauth } from "../codex/account-runtime-state";
@@ -353,7 +355,10 @@ export function collectOAuthHealthEntries(
353
355
  return entries;
354
356
  }
355
357
 
358
+ export type CodexMainAccountPolicyHealth = MainAccountHardLockStatus & { externalUsage?: MainAccountExternalUsageWarning };
359
+
356
360
  type ProxyCodexAccountHealth = {
361
+ mainAccountHardLock?: unknown;
357
362
  id: string;
358
363
  health?: OAuthAccountHealth;
359
364
  needsReauth?: boolean;
@@ -372,11 +377,37 @@ function coerceRemoteAccountHealth(
372
377
  }
373
378
 
374
379
  type LiveProxyCodexHealthResult = {
380
+ mainAccountHardLock?: CodexMainAccountPolicyHealth;
375
381
  source: CodexHealthSource;
376
382
  entries: OAuthHealthEntry[] | null;
377
383
  };
378
384
 
379
- async function fetchCodexHealthFromLiveProxy(
385
+ /** Whitelisted projection: never forward account metadata or arbitrary management fields. */
386
+ export function projectMainAccountPolicyHealth(value: unknown): CodexMainAccountPolicyHealth | undefined {
387
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
388
+ const raw = value as Record<string, unknown>;
389
+ const thresholds = raw.thresholds as { short?: unknown; long?: unknown } | undefined;
390
+ const percent = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 100;
391
+ if (typeof raw.enabled !== "boolean" || typeof raw.state !== "string"
392
+ || !(raw.enabled ? ["unknown", "ready", "blocked"] : ["off"]).includes(raw.state)
393
+ || !thresholds || !percent(thresholds.short) || !percent(thresholds.long)
394
+ || !Number.isInteger(thresholds.short) || !Number.isInteger(thresholds.long)
395
+ || thresholds.short < 80 || thresholds.short > thresholds.long) return undefined;
396
+ const result: CodexMainAccountPolicyHealth = { enabled: raw.enabled,
397
+ state: raw.state as MainAccountHardLockStatus["state"], thresholds: { short: thresholds.short, long: thresholds.long } };
398
+ if (raw.window === "short" || raw.window === "long") result.window = raw.window;
399
+ if (typeof raw.resetAt === "number" && Number.isFinite(raw.resetAt)) result.resetAt = raw.resetAt;
400
+ const warning = raw.externalUsage as Partial<MainAccountExternalUsageWarning> | undefined;
401
+ if (warning && (warning.window === "short" || warning.window === "long")
402
+ && percent(warning.fromPercent) && percent(warning.toPercent)
403
+ && typeof warning.observedAt === "number" && Number.isFinite(warning.observedAt)) {
404
+ result.externalUsage = { window: warning.window, fromPercent: warning.fromPercent,
405
+ toPercent: warning.toPercent, observedAt: warning.observedAt };
406
+ }
407
+ return result;
408
+ }
409
+
410
+ export async function fetchCodexHealthFromLiveProxy(
380
411
  fetchImpl: typeof fetch | undefined = undefined,
381
412
  findLiveProxyImpl: typeof findLiveProxy = findLiveProxy,
382
413
  readRuntimePortImpl: typeof readRuntimePort = readRuntimePort,
@@ -404,7 +435,10 @@ async function fetchCodexHealthFromLiveProxy(
404
435
  if (!account?.id || typeof account.id !== "string") continue;
405
436
  pushEntry(entries, "codex", account.id, coerceRemoteAccountHealth(account));
406
437
  }
407
- return { source: "management-api", entries };
438
+ const mainAccountHardLock = projectMainAccountPolicyHealth(
439
+ json.accounts.find(account => account?.id === MAIN_CODEX_ACCOUNT_ID)?.mainAccountHardLock,
440
+ );
441
+ return { source: "management-api", entries, ...(mainAccountHardLock ? { mainAccountHardLock } : {}) };
408
442
  } catch {
409
443
  return { source: "management-api-unavailable", entries: null };
410
444
  }
@@ -418,6 +452,7 @@ export type CodexHealthSource =
418
452
  | "management-api-unavailable";
419
453
 
420
454
  export type OAuthCliHealthReport = {
455
+ mainAccountHardLock?: CodexMainAccountPolicyHealth;
421
456
  entries: OAuthHealthEntry[];
422
457
  codexHealthSource: CodexHealthSource;
423
458
  };
@@ -450,7 +485,8 @@ export async function collectOAuthHealthEntriesForCli(
450
485
  );
451
486
  if (remote.entries) {
452
487
  for (const entry of remote.entries) entries.push(entry);
453
- return { entries, codexHealthSource: "management-api" };
488
+ return { entries, codexHealthSource: "management-api",
489
+ ...(remote.mainAccountHardLock ? { mainAccountHardLock: remote.mainAccountHardLock } : {}) };
454
490
  }
455
491
  return { entries, codexHealthSource: remote.source };
456
492
  }
@@ -39,6 +39,7 @@ import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent, Re
39
39
  import { loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chatgpt";
40
40
  import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity";
41
41
  import { loginCursor, refreshCursorToken } from "./cursor";
42
+ import { loginZed, refreshZedToken } from "./zed";
42
43
  import { assertDevinCliAdoptionOwnership, loginDevin, refreshDevinToken } from "./devin";
43
44
  import { validateDevinApiBaseUrl } from "./devin/api-base";
44
45
  import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot";
@@ -100,6 +101,11 @@ export interface OAuthAccessSnapshot {
100
101
  * concurrent switch (#2568d).
101
102
  */
102
103
  apiBaseUrl?: string;
104
+ /**
105
+ * The upstream's own user id for providers that sign requests with it (Zed's `user_id`).
106
+ * `accountId` is the local store slot key, a hash, and must never stand in for it.
107
+ */
108
+ providerUserId?: string;
103
109
  }
104
110
 
105
111
  export interface ObservedOAuthAccessSnapshot extends OAuthAccessSnapshot {
@@ -319,6 +325,13 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
319
325
  providerConfig: oauthConfig("cursor"),
320
326
  defaultModel: oauthDefaultModel("cursor"),
321
327
  },
328
+ zed: {
329
+ login: ctrl => loginZed(ctrl),
330
+ refresh: refreshZedToken,
331
+ providerConfig: oauthConfig("zed"),
332
+ defaultModel: oauthDefaultModel("zed"),
333
+ defaultRefreshPolicy: "disabled",
334
+ },
322
335
  devin: {
323
336
  // Import-first: adopts a signed-in Devin CLI credential when one exists and
324
337
  // only then falls back to the Auth0 browser flow. forceLogin skips the
@@ -511,6 +524,7 @@ function accessSnapshot(provider: string, accountId: string, cred: OAuthCredenti
511
524
  accessToken: cred.access,
512
525
  ...(cred.projectId ? { projectId: cred.projectId } : {}),
513
526
  ...(accountApiBaseUrl ? { apiBaseUrl: accountApiBaseUrl } : {}),
527
+ ...(oauthProvider === "zed" && cred.accountId ? { providerUserId: cred.accountId } : {}),
514
528
  // Stored account metadata remains authoritative. Metadata-less legacy/environment credentials
515
529
  // may use explicit environment routing, but never borrow the currently signed-in local CLI account.
516
530
  ...(provider === "kiro"
@@ -21,6 +21,15 @@ interface AccountState { records: Map<number, LeaseRecord>; waiters: Waiter[] }
21
21
  const accounts = new Map<string, AccountState>();
22
22
  let nextLeaseId = 0;
23
23
  const keyOf = (provider: string, accountId: string) => `${provider}\u0000${accountId}`;
24
+ type QueuedHook = (provider: string, accountId: string) => void;
25
+ let queuedHookForTests: QueuedHook | undefined;
26
+
27
+ /** Observe capacity admission without replacing the real lease handoff. */
28
+ export function observeQueuedAccountLeaseForTests(hook: QueuedHook): () => void {
29
+ const previous = queuedHookForTests;
30
+ queuedHookForTests = hook;
31
+ return () => { queuedHookForTests = previous; };
32
+ }
24
33
 
25
34
  function handoff(state: AccountState, now: number): void {
26
35
  while (state.waiters.length > 0) {
@@ -103,5 +112,6 @@ export async function acquireAccountLease(
103
112
  const timer = setTimeout(() => finish(null), remaining);
104
113
  opts.signal?.addEventListener("abort", abort, { once: true });
105
114
  if (opts.signal?.aborted) finish(null);
115
+ if (!settled) queuedHookForTests?.(provider, accountId);
106
116
  });
107
117
  }