@bitkyc08/opencodex 2.69.0 → 2.70.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 (147) hide show
  1. package/gui/dist/assets/App-_MrbTrBe.js +51 -0
  2. package/gui/dist/assets/{Tray-uqKRqfvw.js → Tray-5PuUalET.js} +1 -1
  3. package/gui/dist/assets/index-DKrO2eF3.js +86 -0
  4. package/gui/dist/assets/quota-summary-BCLivc0Q.js +1 -0
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-model-contract.ts +119 -0
  8. package/src/adapters/anthropic.ts +14 -83
  9. package/src/adapters/base.ts +2 -0
  10. package/src/adapters/command-code-project-context.ts +542 -0
  11. package/src/adapters/command-code-tool-text.ts +99 -67
  12. package/src/adapters/command-code.ts +5 -1
  13. package/src/adapters/cursor/catalog.ts +11 -0
  14. package/src/adapters/cursor/effort-map.ts +3 -0
  15. package/src/adapters/devin/cloud-direct/catalog.ts +91 -0
  16. package/src/adapters/devin/cloud-direct/chat.ts +78 -48
  17. package/src/adapters/devin/cloud-direct/tool-schema.ts +121 -0
  18. package/src/adapters/devin/context-overflow.ts +75 -0
  19. package/src/adapters/devin/live-models.ts +174 -13
  20. package/src/adapters/devin/reasoning-signature.ts +100 -0
  21. package/src/adapters/devin.ts +310 -117
  22. package/src/adapters/kiro/payload.ts +10 -10
  23. package/src/adapters/kiro-images.ts +58 -7
  24. package/src/adapters/openai-chat/passthrough.ts +3 -1
  25. package/src/adapters/openai-chat.ts +2 -1
  26. package/src/adapters/openai-responses/passthrough.ts +2 -1
  27. package/src/bridge/response-json.ts +15 -1
  28. package/src/bridge/sse.ts +3 -1
  29. package/src/claude/intercept/model-bindings.ts +1 -0
  30. package/src/cli/account-extended.ts +31 -1
  31. package/src/cli/account.ts +3 -0
  32. package/src/cli/capabilities.ts +13 -0
  33. package/src/cli/client-start-fence.ts +36 -0
  34. package/src/cli/dispatch.ts +3 -2
  35. package/src/cli/index.ts +98 -117
  36. package/src/cli/provider-runtime.ts +23 -2
  37. package/src/cli/provider.ts +3 -0
  38. package/src/cli/resolve.ts +18 -0
  39. package/src/cli/start-owner-fence.ts +30 -0
  40. package/src/cli/stop-approval.ts +13 -1
  41. package/src/cli/stop-restore.ts +73 -0
  42. package/src/cli/tray-proxy.ts +221 -18
  43. package/src/cli/version-skew.ts +25 -23
  44. package/src/client/runtime.ts +8 -4
  45. package/src/codex/auth-api/account-list.ts +11 -5
  46. package/src/codex/auth-api/login-flow.ts +5 -3
  47. package/src/codex/auth-api/main-account-probe.ts +186 -110
  48. package/src/codex/auth-api/pool-mode-gate.ts +61 -7
  49. package/src/codex/auth-api/pool-quota-probe.ts +142 -90
  50. package/src/codex/auth-api/reset-credit-service.ts +1 -1
  51. package/src/codex/catalog/gather-capture.ts +1 -0
  52. package/src/codex/catalog/model-hints.ts +3 -3
  53. package/src/codex/catalog/provider-models.ts +2 -0
  54. package/src/codex/low-quota-events.ts +28 -0
  55. package/src/codex/low-quota-observer.ts +23 -0
  56. package/src/codex/low-quota-protection.ts +272 -0
  57. package/src/codex/quota-query-backoff.ts +148 -0
  58. package/src/codex/quota.ts +7 -2
  59. package/src/config/diagnostics.ts +10 -1
  60. package/src/config/load-degrade.ts +17 -0
  61. package/src/config/mutation-lock.ts +3 -4
  62. package/src/config/process-state.ts +1 -1
  63. package/src/config/provider-validation.ts +22 -0
  64. package/src/config/schema/blocked-model-redirects.ts +17 -0
  65. package/src/config/schema/config-schema.ts +4 -1
  66. package/src/config/schema/leaf-validators.ts +33 -2
  67. package/src/config/serving-runtimes.ts +412 -0
  68. package/src/generated/compatibility-version.json +211 -115
  69. package/src/generated/model-metadata.ts +3 -3
  70. package/src/images/loop.ts +3 -0
  71. package/src/lib/errors.ts +2 -1
  72. package/src/lib/handled-signal-exit.ts +23 -0
  73. package/src/lib/request-failure-model.ts +1 -0
  74. package/src/lib/self-launch-argv.ts +9 -0
  75. package/src/lib/shadow-call.ts +5 -2
  76. package/src/lib/strict-semver.ts +26 -0
  77. package/src/lib/transient-service-path.ts +16 -0
  78. package/src/lib/windows-service-wrappers.ts +10 -6
  79. package/src/lib/winsw.ts +3 -1
  80. package/src/oauth/anthropic-model-routes.ts +105 -0
  81. package/src/oauth/anthropic-routing.ts +85 -48
  82. package/src/oauth/devin.ts +135 -5
  83. package/src/oauth/index.ts +20 -3
  84. package/src/oauth/kiro-terminal-failover.ts +23 -9
  85. package/src/oauth/pool-settings-capability.ts +10 -2
  86. package/src/oauth/store.ts +1 -1
  87. package/src/protocols/encoders/adapter-events.ts +3 -1
  88. package/src/providers/github-copilot-context.ts +27 -0
  89. package/src/providers/kiro-models.ts +4 -0
  90. package/src/providers/model-rename-fields.ts +3 -0
  91. package/src/providers/quota/account-cache.ts +8 -3
  92. package/src/providers/quota/devin.ts +286 -0
  93. package/src/providers/quota/vendor-probes-oauth.ts +6 -2
  94. package/src/providers/quota.ts +1 -1
  95. package/src/providers/registry/entries-core.ts +1 -1
  96. package/src/providers/registry/model-seeds.ts +5 -2
  97. package/src/responses/compaction-images.ts +27 -0
  98. package/src/router.ts +104 -16
  99. package/src/routing/analytics.ts +1 -0
  100. package/src/server/auth-cors.ts +9 -1
  101. package/src/server/background-lifecycle.ts +21 -10
  102. package/src/server/chat-completions.ts +2 -1
  103. package/src/server/chat-native.ts +1 -0
  104. package/src/server/index.ts +4 -8
  105. package/src/server/inference/client-encoder-delivery.ts +3 -0
  106. package/src/server/management/context.ts +3 -0
  107. package/src/server/management/low-quota-routes.ts +14 -0
  108. package/src/server/management/oauth-account-routes.ts +21 -1
  109. package/src/server/management/provider-overwrite-carry.ts +79 -6
  110. package/src/server/management/provider-routes.ts +41 -28
  111. package/src/server/management/route-registry.ts +1 -0
  112. package/src/server/management-api.ts +7 -0
  113. package/src/server/responses/adapter-continuation.ts +3 -0
  114. package/src/server/responses/adapter-delivery.ts +3 -0
  115. package/src/server/responses/adapter-dispatch.ts +20 -4
  116. package/src/server/responses/antigravity-validation-refusal.ts +20 -0
  117. package/src/server/responses/core-auth.ts +1 -0
  118. package/src/server/responses/core-codex-account.ts +1 -0
  119. package/src/server/responses/core-normalize.ts +4 -0
  120. package/src/server/responses/passthrough-dispatch.ts +8 -1
  121. package/src/server/responses/request-sidecar-auth.ts +2 -0
  122. package/src/server/responses/request-transport.ts +27 -10
  123. package/src/server/responses/response-effects.ts +4 -1
  124. package/src/server/responses/run-turn-execution.ts +126 -42
  125. package/src/server/responses/sidecar-execution.ts +4 -1
  126. package/src/service/diagnostics.ts +3 -1
  127. package/src/service/guarded-manager-target.ts +39 -12
  128. package/src/service/launchd.ts +14 -6
  129. package/src/service/repair.ts +6 -1
  130. package/src/service/service-child-ownership.ts +168 -0
  131. package/src/service/state.ts +22 -4
  132. package/src/service/systemd.ts +2 -2
  133. package/src/service/windows-guarded-manager.ts +530 -0
  134. package/src/service/windows-taskxml.ts +2 -2
  135. package/src/service.ts +2 -0
  136. package/src/types/config.ts +26 -0
  137. package/src/types/provider.ts +12 -0
  138. package/src/types/request.ts +2 -0
  139. package/src/types.ts +1 -0
  140. package/src/usage/expected-prices.ts +13 -0
  141. package/src/usage/telemetry-contract.ts +1 -0
  142. package/src/vision/anthropic-describe.ts +2 -1
  143. package/src/web-search/anthropic-executor.ts +2 -1
  144. package/src/web-search/loop.ts +1 -0
  145. package/gui/dist/assets/App-DDbgOTh6.js +0 -51
  146. package/gui/dist/assets/index-CuaBJkG4.js +0 -86
  147. package/gui/dist/assets/quota-summary-COppQSVq.js +0 -1
@@ -0,0 +1,105 @@
1
+ import type { AnthropicModelRoute, OcxConfig } from "../types/config";
2
+
3
+ export interface AnthropicRouteDecision {
4
+ /** One-based position in the saved rule list; safe for request logs. */
5
+ position: number;
6
+ accounts: readonly string[];
7
+ fallback: boolean;
8
+ }
9
+
10
+ type ParseResult = { ok: true; routes: AnthropicModelRoute[] } | { ok: false; error: string };
11
+ export type AnthropicRoutesRead = { routes: AnthropicModelRoute[] | null; routesError?: string };
12
+ const MAX_ROUTES = 32;
13
+ const MAX_ACCOUNTS = 32;
14
+ const MAX_TEXT = 128;
15
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
16
+ const PATTERN = /^[A-Za-z0-9_./:*?-]+$/;
17
+
18
+ /** Validate the stored rule, not current roster membership: re-adding an account restores it. */
19
+ export function parseAnthropicModelRoutes(raw: unknown): ParseResult {
20
+ if (!Array.isArray(raw) || raw.length > MAX_ROUTES) {
21
+ return { ok: false, error: `routes must be an array of at most ${MAX_ROUTES} rules` };
22
+ }
23
+ const names = new Set<string>();
24
+ const patterns = new Set<string>();
25
+ const routes: AnthropicModelRoute[] = [];
26
+ for (const [index, value] of raw.entries()) {
27
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { ok: false, error: `routes[${index}] must be an object` };
28
+ const row = value as Record<string, unknown>;
29
+ if (Object.keys(row).some(key => !["name", "match", "accounts", "fallback"].includes(key))) {
30
+ return { ok: false, error: `routes[${index}] has an unknown field` };
31
+ }
32
+ if (typeof row.name !== "string" || !NAME.test(row.name) || names.has(row.name)) {
33
+ return { ok: false, error: `routes[${index}].name must be unique, nonempty and at most 64 safe characters` };
34
+ }
35
+ if (typeof row.match !== "string" || !row.match || row.match.length > MAX_TEXT
36
+ || !PATTERN.test(row.match) || patterns.has(row.match)) {
37
+ return { ok: false, error: `routes[${index}].match must be a unique model glob of at most ${MAX_TEXT} characters` };
38
+ }
39
+ if (!Array.isArray(row.accounts) || row.accounts.length < 1 || row.accounts.length > MAX_ACCOUNTS
40
+ || row.accounts.some(id => typeof id !== "string" || !id || id.length > MAX_TEXT || id.trim() !== id || /[\x00-\x1f\x7f]/.test(id))
41
+ || new Set(row.accounts).size !== row.accounts.length) {
42
+ return { ok: false, error: `routes[${index}].accounts must contain 1-${MAX_ACCOUNTS} unique stored IDs` };
43
+ }
44
+ if (row.fallback !== undefined && typeof row.fallback !== "boolean") {
45
+ return { ok: false, error: `routes[${index}].fallback must be boolean` };
46
+ }
47
+ names.add(row.name);
48
+ patterns.add(row.match);
49
+ routes.push({ name: row.name, match: row.match, accounts: [...row.accounts], ...(row.fallback === undefined ? {} : { fallback: row.fallback }) });
50
+ }
51
+ return { ok: true, routes };
52
+ }
53
+
54
+ /** A malformed hand edit stays on disk for correction, but must never look valid to readers. */
55
+ export function readAnthropicModelRoutes(raw: unknown): AnthropicRoutesRead {
56
+ if (raw === undefined) return { routes: null };
57
+ const parsed = parseAnthropicModelRoutes(raw);
58
+ return parsed.ok ? { routes: parsed.routes } : { routes: null, routesError: parsed.error };
59
+ }
60
+
61
+ function matches(pattern: string, modelId: string): boolean {
62
+ // Linear wildcard matching avoids regex backtracking on operator-declared glob strings.
63
+ let p = 0;
64
+ let m = 0;
65
+ let star = -1;
66
+ let resume = 0;
67
+ while (m < modelId.length) {
68
+ if (p < pattern.length && (pattern[p] === modelId[m] || pattern[p] === "?")) {
69
+ p++; m++;
70
+ } else if (pattern[p] === "*") {
71
+ star = p++;
72
+ resume = m;
73
+ } else if (star >= 0) {
74
+ p = star + 1;
75
+ m = ++resume;
76
+ } else return false;
77
+ }
78
+ while (pattern[p] === "*") p++;
79
+ return p === pattern.length;
80
+ }
81
+
82
+ export function resolveAnthropicModelRoute(
83
+ config: OcxConfig,
84
+ modelId: string,
85
+ ): { decision: AnthropicRouteDecision | null; error?: string } {
86
+ if (config.anthropicAccountPool?.enabled !== true) return { decision: null };
87
+ const raw = (config.anthropicAccountPool as { routes?: unknown } | undefined)?.routes;
88
+ if (raw === undefined) return { decision: null };
89
+ const parsed = parseAnthropicModelRoutes(raw);
90
+ if (!parsed.ok) return { decision: null, error: parsed.error };
91
+ for (const [index, route] of parsed.routes.entries()) {
92
+ if (matches(route.match, modelId)) {
93
+ return { decision: { position: index + 1, accounts: route.accounts, fallback: route.fallback === true } };
94
+ }
95
+ }
96
+ return { decision: null };
97
+ }
98
+
99
+ /** Preserve declared account order; widen only when the routed set is empty. */
100
+ export function routeCandidates(eligible: readonly string[], decision: AnthropicRouteDecision | null): string[] {
101
+ if (!decision) return [...eligible];
102
+ const available = new Set(eligible);
103
+ const routed = decision.accounts.filter(id => available.has(id));
104
+ return routed.length > 0 || !decision.fallback ? routed : [...eligible];
105
+ }
@@ -33,6 +33,7 @@ import {
33
33
  import type { OcxAccountPoolQuotaWindow, OcxAccountPoolRotationStrategy, OcxConfig } from "../types";
34
34
  import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
35
35
  import { retainedUtf8Bytes } from "../lib/admission";
36
+ import { routeCandidates, type AnthropicRouteDecision } from "./anthropic-model-routes";
36
37
 
37
38
  /**
38
39
  * The read side of a `Headers` object, so a caller can pass the live upstream response's
@@ -54,17 +55,7 @@ const VALID_QUOTA_WINDOWS = new Set<OcxAccountPoolQuotaWindow>(["five-hour", "we
54
55
  /** Cap same-request 429 rotations so short Retry-After cannot infinite-loop. */
55
56
  export const ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST = 3;
56
57
 
57
- export interface AnthropicAccountPoolConfig {
58
- enabled?: boolean;
59
- /** Usage % for new-session pick. Default 80. 0 = disable quota-based pick (active / affinity only). */
60
- autoSwitchThreshold?: number;
61
- /** New-session rotation strategy. Default quota (today's behaviour). */
62
- strategy?: OcxAccountPoolRotationStrategy;
63
- /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */
64
- stickyLimit?: number;
65
- /** Usage window for quota-based scoring. Default "five-hour" (today's behaviour). */
66
- quotaWindow?: OcxAccountPoolQuotaWindow;
67
- }
58
+ export type AnthropicAccountPoolConfig = NonNullable<OcxConfig["anthropicAccountPool"]>;
68
59
 
69
60
  /**
70
61
  * Where a cooldown's length came from. Same vocabulary as `CodexCooldownSource`, because it
@@ -355,11 +346,17 @@ export function forgetAnthropicFailoverQuorum(): void {
355
346
  }
356
347
 
357
348
  /** Earliest remaining cooldown among cooled Anthropic accounts, for client Retry-After. */
358
- export function getAnthropicPoolRetryAfterSeconds(now = Date.now()): number | null {
349
+ export function getAnthropicPoolRetryAfterSeconds(now = Date.now(), decision: AnthropicRouteDecision | null = null): number | null {
359
350
  const set = getAccountSet(PROVIDER);
360
351
  if (!set) return null;
352
+ // Once an explicit fallback route has no eligible declared account, selection may use
353
+ // the ordinary pool. Its earliest usable cooldown must determine the advertised wait.
354
+ const fallbackExpanded = decision?.fallback === true
355
+ && !getEligibleAnthropicAccounts(now).some(id => decision.accounts.includes(id));
361
356
  let earliest: number | null = null;
362
357
  for (const account of set.accounts) {
358
+ if (decision && !fallbackExpanded && !decision.accounts.includes(account.id)) continue;
359
+ if (fallbackExpanded && (account.needsReauth === true || !isPoolCredentialUsable(account.id, now))) continue;
363
360
  const snap = getAnthropicAccountHealthSnapshot(account.id, now);
364
361
  if (!snap?.cooldownUntil) continue;
365
362
  if (earliest === null || snap.cooldownUntil < earliest) earliest = snap.cooldownUntil;
@@ -388,9 +385,9 @@ function compareScoredAccounts(a: ScoredAccount, b: ScoredAccount): number {
388
385
  return a.score - b.score || a.fiveHourTieBreak - b.fiveHourTieBreak;
389
386
  }
390
387
 
391
- function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: number): string | null {
388
+ function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: number, decision: AnthropicRouteDecision | null = null): string | null {
392
389
  const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
393
- const unfiltered = getEligibleAnthropicAccounts(now).filter(id => id !== excludeId);
390
+ const unfiltered = routeCandidates(getEligibleAnthropicAccounts(now), decision).filter(id => id !== excludeId);
394
391
  const available = window === "weekly" ? unfiltered.filter(id => !exhausted5h(id)) : unfiltered;
395
392
  const eligible = available.length > 0 ? available : unfiltered;
396
393
  if (eligible.length === 0) return null;
@@ -412,19 +409,26 @@ function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now:
412
409
  return best.accountId;
413
410
  }
414
411
 
412
+ /** A fallback route uses ordinary ordering only after it widens beyond its declared accounts. */
413
+ function usesDeclaredRouteOrder(eligible: readonly string[], decision: AnthropicRouteDecision | null): boolean {
414
+ return decision !== null && (!decision.fallback || eligible.some(id => decision.accounts.includes(id)));
415
+ }
416
+
415
417
  /** Next eligible Anthropic account in stable order after `afterId` (wrapping). */
416
418
  function pickNextFillFirstAnthropicAccount(
417
419
  config: OcxConfig,
418
420
  afterId: string,
419
421
  eligible: string[],
422
+ decision: AnthropicRouteDecision | null,
420
423
  ): string | null {
421
424
  const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
422
425
  const available = window === "weekly" ? eligible.filter(id => !exhausted5h(id)) : eligible;
423
426
  const candidates = available.length > 0 ? available : eligible;
424
427
  if (candidates.length === 0) return null;
425
- const ordered = [...candidates].sort((a, b) => a.localeCompare(b));
428
+ const routeOrder = usesDeclaredRouteOrder(eligible, decision);
429
+ const ordered = routeOrder ? candidates : [...candidates].sort((a, b) => a.localeCompare(b));
426
430
  const set = getAccountSet(PROVIDER);
427
- const stableAll = set
431
+ const stableAll = routeOrder ? [...decision!.accounts] : set
428
432
  ? [...set.accounts.map(a => a.id)].sort((a, b) => a.localeCompare(b))
429
433
  : ordered;
430
434
  const startIdx = stableAll.indexOf(afterId);
@@ -449,16 +453,17 @@ function pickAlternateAnthropicAccount(
449
453
  config: OcxConfig,
450
454
  excludeId: string,
451
455
  now: number,
456
+ decision: AnthropicRouteDecision | null,
452
457
  ): string | null {
453
458
  const strategy = anthropicPoolStrategy(config);
454
- const eligible = getEligibleAnthropicAccounts(now).filter(id => id !== excludeId);
459
+ const eligible = routeCandidates(getEligibleAnthropicAccounts(now), decision).filter(id => id !== excludeId);
455
460
  if (strategy === "round-robin") {
456
461
  return peekRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, stickyLimitForPool(config));
457
462
  }
458
463
  if (strategy === "fill-first") {
459
- return pickNextFillFirstAnthropicAccount(config, excludeId, eligible);
464
+ return pickNextFillFirstAnthropicAccount(config, excludeId, eligible, decision);
460
465
  }
461
- return pickLowestUsage(config, excludeId, now);
466
+ return pickLowestUsage(config, excludeId, now, decision);
462
467
  }
463
468
 
464
469
  function pruneExpiredAffinity(now: number): void {
@@ -486,6 +491,7 @@ export type AnthropicAccountSelectionReason =
486
491
  export interface AnthropicAccountSelection {
487
492
  accountId: string | null;
488
493
  reason: AnthropicAccountSelectionReason;
494
+ routePosition?: number;
489
495
  }
490
496
 
491
497
  function stickyLimitForPool(config: OcxConfig): number {
@@ -510,8 +516,8 @@ function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string):
510
516
  * Fill-first: keep eligible active under threshold; otherwise advance to the next
511
517
  * eligible id in stable sorted order after the current active (wrapping).
512
518
  */
513
- function pickFillFirstAnthropicAccount(config: OcxConfig, now: number): string | null {
514
- const eligible = getEligibleAnthropicAccounts(now);
519
+ function pickFillFirstAnthropicAccount(config: OcxConfig, now: number, decision: AnthropicRouteDecision | null): string | null {
520
+ const eligible = routeCandidates(getEligibleAnthropicAccounts(now), decision);
515
521
  if (eligible.length === 0) return null;
516
522
 
517
523
  const set = getAccountSet(PROVIDER);
@@ -521,14 +527,14 @@ function pickFillFirstAnthropicAccount(config: OcxConfig, now: number): string |
521
527
  }
522
528
 
523
529
  if (!active || !set) {
524
- const ordered = [...eligible].sort((a, b) => a.localeCompare(b));
530
+ const ordered = usesDeclaredRouteOrder(eligible, decision) ? eligible : [...eligible].sort((a, b) => a.localeCompare(b));
525
531
  for (const id of ordered) {
526
532
  if (isActiveUnderFillFirstThreshold(config, id)) return id;
527
533
  }
528
534
  return ordered[0] ?? null;
529
535
  }
530
536
 
531
- return pickNextFillFirstAnthropicAccount(config, active, eligible);
537
+ return pickNextFillFirstAnthropicAccount(config, active, eligible, decision);
532
538
  }
533
539
 
534
540
  /**
@@ -538,12 +544,13 @@ function pickFillFirstAnthropicAccount(config: OcxConfig, now: number): string |
538
544
  function pickUnboundStrategyAccount(
539
545
  config: OcxConfig,
540
546
  now: number,
547
+ decision: AnthropicRouteDecision | null,
541
548
  ): { accountId: string; reason: "round-robin" | "fill-first" } | null {
542
549
  const strategy = anthropicPoolStrategy(config);
543
550
  if (strategy === "quota") return null;
544
551
 
545
552
  if (strategy === "round-robin") {
546
- const eligible = getEligibleAnthropicAccounts(now);
553
+ const eligible = routeCandidates(getEligibleAnthropicAccounts(now), decision);
547
554
  const limit = stickyLimitForPool(config);
548
555
  const picked = peekRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, limit);
549
556
  if (!picked) return null;
@@ -551,7 +558,7 @@ function pickUnboundStrategyAccount(
551
558
  }
552
559
 
553
560
  if (strategy === "fill-first") {
554
- const picked = pickFillFirstAnthropicAccount(config, now);
561
+ const picked = pickFillFirstAnthropicAccount(config, now, decision);
555
562
  if (!picked) return null;
556
563
  return { accountId: picked, reason: "fill-first" };
557
564
  }
@@ -567,10 +574,11 @@ export function resolveAnthropicAccountForSession(
567
574
  sessionKey: string | null | undefined,
568
575
  config: OcxConfig,
569
576
  now = Date.now(),
577
+ decision: AnthropicRouteDecision | null = null,
570
578
  ): AnthropicAccountSelection {
571
579
  pruneExpiredAffinity(now);
572
580
  const set = getAccountSet(PROVIDER);
573
- if (!set || set.accounts.length === 0) return { accountId: null, reason: "none" };
581
+ if (!set || set.accounts.length === 0) return { accountId: null, reason: "none", ...(decision ? { routePosition: decision.position } : {}) };
574
582
 
575
583
  if (manualPreference === undefined) {
576
584
  manualPreference = set.selectionRevision !== undefined
@@ -582,6 +590,21 @@ export function resolveAnthropicAccountForSession(
582
590
  return { accountId: set.activeAccountId, reason: "pool-disabled" };
583
591
  }
584
592
 
593
+ const eligible = routeCandidates(getEligibleAnthropicAccounts(now), decision);
594
+ if (decision && eligible.length === 0) {
595
+ let cooled: boolean;
596
+ if (decision.fallback) {
597
+ // A removed route member is not a candidate, but fallback can still use the
598
+ // ordinary pool once those accounts recover. Only usable stored accounts count.
599
+ const ordinary = set.accounts.filter(account =>
600
+ account.needsReauth !== true && isPoolCredentialUsable(account.id, now));
601
+ cooled = ordinary.length > 0 && ordinary.every(account => isCooled(account.id, now));
602
+ } else {
603
+ cooled = decision.accounts.every(id => set.accounts.some(account => account.id === id && isCooled(id, now)));
604
+ }
605
+ return { accountId: null, reason: cooled ? "all-cooled" : "none", routePosition: decision.position };
606
+ }
607
+
585
608
  // A manual choice is a one-dispatch preference, not a lower-priority quota hint.
586
609
  // Consume it only after admission commits, so a failed token lookup cannot spend it.
587
610
  if (manualPreference) {
@@ -593,8 +616,8 @@ export function resolveAnthropicAccountForSession(
593
616
  const exhausted = [quota?.fiveHourPercent, quota?.weeklyPercent, quota?.monthlyPercent,
594
617
  ...(quota?.customWindows ?? []).map(window => window.percent)]
595
618
  .some(percent => typeof percent === "number" && percent >= 100);
596
- if (!exhausted && getEligibleAnthropicAccounts(now).includes(chosen)) {
597
- return { accountId: chosen, reason: "manual" };
619
+ if (!exhausted && eligible.includes(chosen)) {
620
+ return { accountId: chosen, reason: "manual", routePosition: decision?.position };
598
621
  }
599
622
  }
600
623
  }
@@ -604,10 +627,14 @@ export function resolveAnthropicAccountForSession(
604
627
  const affined = sessionAffinity.get(key);
605
628
  if (affined && now - affined.lastUsedAt <= AFFINITY_IDLE_TTL_MS) {
606
629
  const stillThere = set.accounts.some(a => a.id === affined.accountId && a.needsReauth !== true);
607
- if (stillThere && !isCooled(affined.accountId, now) && isPoolCredentialUsable(affined.accountId, now)) {
608
- return { accountId: affined.accountId, reason: "affinity" };
630
+ const stillUsable = stillThere && !isCooled(affined.accountId, now)
631
+ && isPoolCredentialUsable(affined.accountId, now);
632
+ if (stillUsable && eligible.includes(affined.accountId)) {
633
+ return { accountId: affined.accountId, reason: "affinity", routePosition: decision?.position };
609
634
  }
610
- sessionAffinity.delete(key);
635
+ // A model route may exclude a healthy binding only for this request. Keep it for
636
+ // another model; remove bindings only when the account itself became unusable.
637
+ if (!stillUsable) sessionAffinity.delete(key);
611
638
  }
612
639
  }
613
640
 
@@ -618,21 +645,21 @@ export function resolveAnthropicAccountForSession(
618
645
  if (!key && (strategy === "round-robin" || strategy === "fill-first")) {
619
646
  const activeOk = set.accounts.some(a => a.id === set.activeAccountId && a.needsReauth !== true)
620
647
  && !isCooled(set.activeAccountId, now)
621
- && isPoolCredentialUsable(set.activeAccountId, now);
648
+ && eligible.includes(set.activeAccountId);
622
649
  if (activeOk) {
623
- return { accountId: set.activeAccountId, reason: "active" };
650
+ return { accountId: set.activeAccountId, reason: "active", routePosition: decision?.position };
624
651
  }
625
652
  }
626
653
 
627
- const strategyPick = pickUnboundStrategyAccount(config, now);
654
+ const strategyPick = pickUnboundStrategyAccount(config, now, decision);
628
655
  if (strategyPick) {
629
- return { accountId: strategyPick.accountId, reason: strategyPick.reason };
656
+ return { accountId: strategyPick.accountId, reason: strategyPick.reason, routePosition: decision?.position };
630
657
  }
631
658
 
632
659
  const threshold = anthropicAutoSwitchThreshold(config);
633
660
  const activeOk = set.accounts.some(a => a.id === set.activeAccountId && a.needsReauth !== true)
634
661
  && !isCooled(set.activeAccountId, now)
635
- && isPoolCredentialUsable(set.activeAccountId, now);
662
+ && eligible.includes(set.activeAccountId);
636
663
 
637
664
  let accountId: string | null = null;
638
665
  let reason: AnthropicAccountSelectionReason = "none";
@@ -646,7 +673,7 @@ export function resolveAnthropicAccountForSession(
646
673
  accountId = set.activeAccountId;
647
674
  reason = "active";
648
675
  } else {
649
- const picked = pickLowestUsage(config, undefined, now);
676
+ const picked = pickLowestUsage(config, undefined, now, decision);
650
677
  if (picked) {
651
678
  accountId = picked;
652
679
  reason = activeOk && picked === set.activeAccountId ? "active" : "lowest-usage";
@@ -659,7 +686,7 @@ export function resolveAnthropicAccountForSession(
659
686
  accountId = set.activeAccountId;
660
687
  reason = "active";
661
688
  } else {
662
- const picked = pickLowestUsage(config, set.activeAccountId, now);
689
+ const picked = pickLowestUsage(config, set.activeAccountId, now, decision);
663
690
  if (picked) {
664
691
  accountId = picked;
665
692
  reason = "only-eligible";
@@ -667,11 +694,11 @@ export function resolveAnthropicAccountForSession(
667
694
  }
668
695
 
669
696
  if (!accountId) {
670
- const anyCooled = set.accounts.some(a => isCooled(a.id, now));
671
- return { accountId: null, reason: anyCooled ? "all-cooled" : "none" };
697
+ const anyCooled = set.accounts.some(a => (!decision || decision.accounts.includes(a.id)) && isCooled(a.id, now));
698
+ return { accountId: null, reason: anyCooled ? "all-cooled" : "none", routePosition: decision?.position };
672
699
  }
673
700
 
674
- return { accountId, reason };
701
+ return { accountId, reason, routePosition: decision?.position };
675
702
  }
676
703
 
677
704
  export function bindAnthropicSessionAffinity(
@@ -707,6 +734,7 @@ export function rotateAnthropicAccountOn429(
707
734
  sessionKey?: string | null,
708
735
  now = Date.now(),
709
736
  rateLimitHeaders?: AnthropicRateLimitHeaders | null,
737
+ decision: AnthropicRouteDecision | null = null,
710
738
  ): string | null {
711
739
  // Reactive 429 failover is NOT gated on the pool flag. That flag buys PROACTIVE routing --
712
740
  // session affinity, quota-ranked new-session selection, autoSwitchThreshold, strategy -- all
@@ -741,15 +769,15 @@ export function rotateAnthropicAccountOn429(
741
769
  // because those dormant values remain in config. The quota picker is the neutral
742
770
  // recovery policy already used by the default strategy.
743
771
  const next = isAnthropicAccountPoolEnabled(config)
744
- ? pickAlternateAnthropicAccount(config, failedAccountId, now)
772
+ ? pickAlternateAnthropicAccount(config, failedAccountId, now, decision)
745
773
  : pickLowestUsage(config, failedAccountId, now);
746
774
  if (!next) {
747
- console.warn("[anthropic-pool] all eligible Anthropic OAuth accounts are in cooldown; returning 429");
775
+ console.warn(`[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}no eligible replacement; returning 429`);
748
776
  return null;
749
777
  }
750
778
 
751
779
  console.warn(
752
- `[anthropic-pool] 429 on ${formatAnthropicAccountOrdinal(failedAccountId)}; failing over to ${formatAnthropicAccountOrdinal(next)}`,
780
+ `[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}429 on ${formatAnthropicAccountOrdinal(failedAccountId)}; failing over to ${formatAnthropicAccountOrdinal(next)}`,
753
781
  );
754
782
  return next;
755
783
  }
@@ -759,6 +787,7 @@ export interface AnthropicSelectionRoutingOptions {
759
787
  sessionKey?: string | null;
760
788
  reason?: AnthropicAccountSelectionReason;
761
789
  expectedCredentialGeneration?: string;
790
+ routeDecision?: AnthropicRouteDecision | null;
762
791
  }
763
792
 
764
793
  /** Commit the selected account before dispatch; rejected proposals have no routing side effects. */
@@ -767,7 +796,7 @@ export async function promoteAnthropicActiveAccount(
767
796
  expectedSelection: OAuthAccountSelection | null,
768
797
  options: AnthropicSelectionRoutingOptions,
769
798
  ): Promise<OAuthAccountSelection | null> {
770
- if (!expectedSelection || !isPoolCredentialUsable(accountId, Date.now()) || isCooled(accountId, Date.now())) return null;
799
+ if (!expectedSelection || !routeCandidates(getEligibleAnthropicAccounts(), options.routeDecision ?? null).includes(accountId)) return null;
771
800
  const committed = await commitOAuthAccountSelection(PROVIDER, accountId, {
772
801
  expectedSelection,
773
802
  expectedCredentialGeneration: options.expectedCredentialGeneration,
@@ -784,17 +813,25 @@ export function commitAnthropicSelectionRouting(
784
813
  committed: OAuthAccountSelection,
785
814
  options: AnthropicSelectionRoutingOptions,
786
815
  ): boolean {
787
- if (committed.accountId !== accountId) return false;
816
+ if (committed.accountId !== accountId || (options.routeDecision
817
+ && !routeCandidates(getEligibleAnthropicAccounts(), options.routeDecision).includes(accountId))) return false;
788
818
  const current = captureOAuthAccountSelection(PROVIDER);
789
819
  if (current?.accountId !== committed.accountId || current.revision !== committed.revision) return false;
790
820
  if (isAnthropicAccountPoolEnabled(options.config)) {
791
821
  if (anthropicPoolStrategy(options.config) === "round-robin" && options.reason !== "affinity") {
792
822
  const limit = stickyLimitForPool(options.config);
793
- const picked = pickRoundRobinAccount(POOL_KEY_ANTHROPIC, getEligibleAnthropicAccounts(), limit);
823
+ const picked = pickRoundRobinAccount(POOL_KEY_ANTHROPIC, routeCandidates(getEligibleAnthropicAccounts(), options.routeDecision ?? null), limit);
794
824
  if (picked !== accountId) seedPoolRotationAccount(POOL_KEY_ANTHROPIC, accountId);
795
825
  notePoolRotationSuccess(POOL_KEY_ANTHROPIC, accountId, limit);
796
826
  }
797
- bindAnthropicSessionAffinity(options.sessionKey, accountId);
827
+ const key = normalizeAffinityComponent(options.sessionKey);
828
+ const bound = key ? sessionAffinity.get(key) : undefined;
829
+ const eligibleAtCommit = options.routeDecision && bound ? getEligibleAnthropicAccounts() : [];
830
+ const preserveExcludedAffinity = options.routeDecision && bound && bound.accountId !== accountId
831
+ && Date.now() - bound.lastUsedAt <= AFFINITY_IDLE_TTL_MS
832
+ && eligibleAtCommit.includes(bound.accountId)
833
+ && !routeCandidates(eligibleAtCommit, options.routeDecision).includes(bound.accountId);
834
+ if (!preserveExcludedAffinity) bindAnthropicSessionAffinity(options.sessionKey, accountId);
798
835
  }
799
836
  if (manualPreference === undefined || (manualPreference?.accountId === expectedSelection.accountId
800
837
  && manualPreference.revision === expectedSelection.revision)) manualPreference = null;
@@ -19,7 +19,8 @@ import { DEFAULT_REGION, type WindsurfRegion } from "./devin/types";
19
19
  import { registerUser } from "./devin/register-user";
20
20
  import { DEVIN_DEFAULT_API_SERVER, resolveDevinApiBaseUrl, validateDevinApiBaseUrl } from "./devin/api-base";
21
21
  import { readDevinCliCredentialOutcome } from "./devin/cli-import";
22
- import { getCredential, listAccounts } from "./store";
22
+ import { CloudAuthError, mintUserJwt } from "../adapters/devin/cloud-direct/auth";
23
+ import { getCredential, listAccounts, type AuthStore } from "./store";
23
24
  import { DEPRECATED_OAUTH_PROVIDER_ALIASES } from "./index";
24
25
 
25
26
  export { DEVIN_DEFAULT_API_SERVER } from "./devin/api-base";
@@ -263,14 +264,143 @@ export async function loginDevin(
263
264
  return loginDevinBrowser(ctrl, DEFAULT_REGION);
264
265
  }
265
266
 
267
+ /**
268
+ * A CLI-imported account follows the CLI: after `devin auth login` rewrites the
269
+ * credential file, the copy stored at import time is stale while the file holds
270
+ * a live key. The session JWT carries no expiry, so the upstream 401 is the only
271
+ * signal, and this re-read runs only on that forced refresh.
272
+ *
273
+ * Adoption fails closed on identity. The session token carries only a
274
+ * session_id, so the file key's account is established by minting a user_jwt
275
+ * with it (GetUserJwt answers with auth_uid and email). A key Cognition refuses
276
+ * (401/403) or whose token lacks auth_uid is refused; a mint that fails for any
277
+ * other reason throws a non-terminal error, so the account is not flagged and
278
+ * the next 401 retries. The minted identity must then
279
+ * not contradict the slot's recorded accountId or email, and no other stored
280
+ * account may own the key or that identity.
281
+ *
282
+ * A CLI-imported slot has no bound identity. It cannot adopt a changed key;
283
+ * explicit `ocx login devin` is required after its first rotation.
284
+ */
285
+ /** An identity probe must not hold the per-account refresh lock for the mint's full 30s. */
286
+ const DEVIN_IDENTITY_MINT_TIMEOUT_MS = 5_000;
287
+
288
+ /** Neither a live key nor a dead one: the refresh must fail without flagging the account. */
289
+ class DevinIdentityProbeUnavailableError extends Error {
290
+ constructor() {
291
+ super("Could not confirm the Devin CLI session identity right now; retry shortly.");
292
+ this.name = "DevinIdentityProbeUnavailableError";
293
+ }
294
+ }
295
+
296
+ const normalizedEmail = (value: string | undefined): string | undefined =>
297
+ value?.trim().toLowerCase() || undefined;
298
+
299
+ const devinMintedIdentities = new WeakMap<OAuthCredentials, ReadonlySet<string>>();
300
+
301
+ /** Recheck the minted identity and key against the locked, freshly read store. */
302
+ export function assertDevinCliAdoptionOwnership(
303
+ store: AuthStore,
304
+ provider: string,
305
+ accountId: string,
306
+ credential: OAuthCredentials,
307
+ ): void {
308
+ const mintedIds = devinMintedIdentities.get(credential);
309
+ if (!mintedIds) return;
310
+ const email = normalizedEmail(credential.email);
311
+ for (const slot of [provider, ...devinAliasCredentialSlots(provider)]) {
312
+ for (const row of store[slot]?.accounts ?? []) {
313
+ // A legacy alias can still hold the same account id during rekey.
314
+ if (row.id === accountId) continue;
315
+ if (row.credential.access === credential.access
316
+ || (row.credential.accountId !== undefined && mintedIds.has(row.credential.accountId))
317
+ || (email !== undefined && normalizedEmail(row.credential.email) === email)) {
318
+ throw new DevinIdentityProbeUnavailableError();
319
+ }
320
+ }
321
+ }
322
+ }
323
+
324
+ async function rereadDevinCliCredential(
325
+ stored: OAuthCredentials,
326
+ signal: AbortSignal | undefined,
327
+ currentAccountId: string | undefined,
328
+ ): Promise<OAuthCredentials | undefined> {
329
+ const outcome = readDevinCliCredentialOutcome();
330
+ // A file that exists but cannot be read or parsed may be mid-write by `devin auth login`
331
+ // or briefly locked; like a failed identity mint, that says nothing about the key.
332
+ if (outcome.kind === "unreadable" || outcome.kind === "incomplete") throw new DevinIdentityProbeUnavailableError();
333
+ if (outcome.kind !== "ok" || outcome.file.apiKey === stored.access) return undefined;
334
+ // Without a stored identity, the CLI file may now belong to another user.
335
+ if (!stored.accountId && !normalizedEmail(stored.email)?.includes("@")) return undefined;
336
+ const apiBaseUrl = validateDevinApiBaseUrl(outcome.file.apiServerUrl);
337
+ if (apiBaseUrl === undefined) return undefined;
338
+ // A detached alias rekey can already hold this account's new key. Only a
339
+ // different account's key is a conflict; the locked write checks again.
340
+ for (const slot of ["devin", ...devinAliasCredentialSlots("devin")]) {
341
+ if (listAccounts(slot).some(({ id, credential }) =>
342
+ id !== currentAccountId && credential.access === outcome.file.apiKey)) return undefined;
343
+ }
344
+ let minted: Record<string, unknown> | undefined;
345
+ try {
346
+ const timeout = AbortSignal.timeout(DEVIN_IDENTITY_MINT_TIMEOUT_MS);
347
+ const probeSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
348
+ minted = decodeJwtPayload((await mintUserJwt(outcome.file.apiKey, apiBaseUrl, probeSignal)).jwt);
349
+ } catch (error) {
350
+ // Only Cognition refusing the key is evidence the file holds no live session. A timeout,
351
+ // DNS failure, 5xx or 429 says nothing about the key; flagging the account on one would
352
+ // strand it, because a needsReauth slot is never refreshed again.
353
+ if (error instanceof CloudAuthError && (error.status === 401 || error.status === 403)) return undefined;
354
+ throw new DevinIdentityProbeUnavailableError();
355
+ }
356
+ const authUid = typeof minted?.auth_uid === "string" && minted.auth_uid ? minted.auth_uid : undefined;
357
+ if (authUid === undefined) return undefined;
358
+ // identityFromApiKey records `sub ?? auth_uid`, so a stored id may be either claim.
359
+ const mintedIds = new Set([authUid, ...(typeof minted?.sub === "string" && minted.sub ? [minted.sub] : [])]);
360
+ const rawEmail = typeof minted?.email === "string" ? minted.email.trim() : "";
361
+ const email = rawEmail || undefined;
362
+ if (stored.accountId && !mintedIds.has(stored.accountId)) return undefined;
363
+ const storedEmail = normalizedEmail(stored.email);
364
+ if (storedEmail?.includes("@") && storedEmail !== normalizedEmail(email)) return undefined;
365
+ if (devinIdentityOwnedElsewhere(stored, currentAccountId, mintedIds, normalizedEmail(email))) return undefined;
366
+ const adopted = { ...credentialsFromApiKey(outcome.file.apiKey, apiBaseUrl, "local-cli"), accountId: authUid, ...(email ? { email } : {}) };
367
+ devinMintedIdentities.set(adopted, mintedIds);
368
+ return adopted;
369
+ }
370
+
371
+ /**
372
+ * Every stored Devin row except the one being refreshed, across the alias-linked slots
373
+ * (the active row `getCredential` reads is one of these). Rows are skipped by id; only a
374
+ * caller that cannot name the row falls back to matching its key.
375
+ */
376
+ function devinIdentityOwnedElsewhere(
377
+ stored: OAuthCredentials,
378
+ currentAccountId: string | undefined,
379
+ mintedIds: ReadonlySet<string>,
380
+ email: string | undefined,
381
+ ): boolean {
382
+ for (const slot of ["devin", ...devinAliasCredentialSlots("devin")]) {
383
+ for (const { id, credential } of listAccounts(slot)) {
384
+ if (currentAccountId !== undefined ? id === currentAccountId : credential.access === stored.access) continue;
385
+ if (credential.accountId !== undefined && mintedIds.has(credential.accountId)) return true;
386
+ if (email && normalizedEmail(credential.email) === email) return true;
387
+ }
388
+ }
389
+ return false;
390
+ }
391
+
266
392
  export async function refreshDevinToken(
267
393
  _refreshToken: string,
268
- _signal?: AbortSignal,
269
- _credential?: OAuthCredentials,
394
+ signal?: AbortSignal,
395
+ credential?: OAuthCredentials,
396
+ accountId?: string,
270
397
  ): Promise<OAuthCredentials> {
398
+ const reread = credential?.source === "local-cli"
399
+ ? await rereadDevinCliCredential(credential, signal, accountId) : undefined;
400
+ if (reread) return reread;
271
401
  // Cognition has no refresh endpoint. Extending the stored expiry here is what
272
402
  // the carried implementation did, and it makes a revoked key look valid
273
- // forever. Throwing lets the request path mark the account needsReauth the
274
- // first time a forced refresh happens.
403
+ // forever. Throwing on the upstream-401 forced refresh is what marks the
404
+ // account needsReauth.
275
405
  throw new Error("invalid_grant: Devin API keys do not refresh. Run ocx login devin again.");
276
406
  }