@bitkyc08/opencodex 2.36.0-preview.20260829 → 2.36.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 (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DO8liQVL.js +112 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +2 -1
  5. package/src/AGENTS.md +2 -1
  6. package/src/adapters/agentrouter.ts +50 -0
  7. package/src/adapters/anthropic.ts +1 -51
  8. package/src/adapters/cursor/call-id.ts +76 -8
  9. package/src/adapters/cursor/checkpoint-store.ts +6 -1
  10. package/src/adapters/cursor/cursor-errors.ts +44 -0
  11. package/src/adapters/cursor/native-exec.ts +13 -0
  12. package/src/adapters/cursor/protobuf-request.ts +651 -29
  13. package/src/adapters/cursor/tool-result-normalize.ts +3 -3
  14. package/src/adapters/cursor/transport-retry.ts +5 -1
  15. package/src/adapters/cursor.ts +15 -1
  16. package/src/adapters/empty-tool-output-annotation.ts +43 -0
  17. package/src/adapters/exec-tool-result-normalize.ts +70 -5
  18. package/src/adapters/google.ts +22 -2
  19. package/src/adapters/kiro.ts +26 -2
  20. package/src/adapters/ollama-native-url.ts +111 -0
  21. package/src/adapters/ollama-native.ts +1131 -0
  22. package/src/adapters/openai-chat.ts +30 -7
  23. package/src/adapters/openai-responses.ts +72 -4
  24. package/src/adapters/registry.ts +7 -0
  25. package/src/adapters/xai-web-search.ts +58 -0
  26. package/src/claude/desktop-3p.ts +21 -1
  27. package/src/claude/desktop-policy.ts +149 -0
  28. package/src/cli/account.ts +16 -2
  29. package/src/cli/claude-desktop.ts +13 -3
  30. package/src/cli/combo.ts +8 -5
  31. package/src/cli/doctor.ts +77 -11
  32. package/src/cli/help.ts +1 -1
  33. package/src/cli/index.ts +16 -0
  34. package/src/cli/models.ts +20 -3
  35. package/src/cli/registry.ts +2 -1
  36. package/src/cli/status.ts +140 -2
  37. package/src/cli/storage.ts +10 -1
  38. package/src/codex/account-runtime-state.ts +39 -5
  39. package/src/codex/account-store.ts +393 -13
  40. package/src/codex/account-usability.ts +11 -4
  41. package/src/codex/app-server-processes.ts +46 -5
  42. package/src/codex/auth-context.ts +160 -32
  43. package/src/codex/catalog/bundled.ts +7 -5
  44. package/src/codex/catalog/metadata.ts +1 -1
  45. package/src/codex/catalog/parsing.ts +57 -1
  46. package/src/codex/catalog/provider-fetch.ts +61 -4
  47. package/src/codex/catalog/sync.ts +4 -3
  48. package/src/codex/convergence.ts +3 -2
  49. package/src/codex/data/upstream-models.json +40 -8
  50. package/src/codex/inject-coordination.ts +111 -14
  51. package/src/codex/integration-record.ts +12 -2
  52. package/src/codex/main-account.ts +225 -1
  53. package/src/codex/model-entitlements.ts +339 -27
  54. package/src/codex/prompt-layers.ts +346 -7
  55. package/src/codex/prompt-text-probe.ts +272 -21
  56. package/src/codex/routing.ts +693 -132
  57. package/src/codex/runtime.ts +12 -0
  58. package/src/codex/subagent-model-fallback.ts +62 -24
  59. package/src/codex/user-identity.ts +33 -25
  60. package/src/combos/index.ts +1 -0
  61. package/src/combos/reset-window.ts +46 -0
  62. package/src/combos/resolve.ts +84 -2
  63. package/src/combos/types.ts +5 -2
  64. package/src/config/atomic-write.ts +104 -22
  65. package/src/config/provider-validation.ts +11 -0
  66. package/src/config.ts +75 -3
  67. package/src/generated/compatibility-version.json +207 -131
  68. package/src/generated/model-metadata.ts +1 -1
  69. package/src/grok/catalog.ts +71 -0
  70. package/src/grok/effort.ts +83 -0
  71. package/src/grok/inject.ts +952 -127
  72. package/src/grok/models.ts +56 -0
  73. package/src/grok/status.ts +21 -8
  74. package/src/grok/sync.ts +10 -18
  75. package/src/images/loop.ts +6 -3
  76. package/src/integrations/native/ownership-preflight.ts +4 -1
  77. package/src/lab/fabric/producer-isolate.ts +36 -3
  78. package/src/lib/destination-policy.ts +93 -7
  79. package/src/lib/redact.ts +6 -1
  80. package/src/lib/shadow-call.ts +38 -3
  81. package/src/lib/test-home-guard.ts +18 -3
  82. package/src/lib/upstream-retry.ts +43 -6
  83. package/src/lib/windows-secret-acl.ts +66 -0
  84. package/src/lib/windows-text.ts +28 -2
  85. package/src/lib/windows-user-principal.ts +35 -23
  86. package/src/oauth/account-quota-rank.ts +107 -0
  87. package/src/oauth/anthropic-routing.ts +125 -30
  88. package/src/oauth/chatgpt.ts +5 -1
  89. package/src/oauth/generic-account-failover.ts +114 -7
  90. package/src/oauth/index.ts +15 -8
  91. package/src/oauth/store.ts +16 -0
  92. package/src/providers/account-quota-disk.ts +79 -0
  93. package/src/providers/command-code-efforts.ts +24 -0
  94. package/src/providers/derive.ts +6 -0
  95. package/src/providers/key-failover.ts +33 -1
  96. package/src/providers/kiro-usage.ts +272 -0
  97. package/src/providers/ollama-show.ts +311 -0
  98. package/src/providers/openai-sidecar.ts +5 -0
  99. package/src/providers/quota-routing-cache.ts +32 -0
  100. package/src/providers/quota-types.ts +36 -0
  101. package/src/providers/quota-wire.ts +102 -0
  102. package/src/providers/quota.ts +208 -147
  103. package/src/providers/registry.ts +68 -8
  104. package/src/providers/slug-codec.ts +12 -4
  105. package/src/providers/vercel-gateway-routing.ts +108 -0
  106. package/src/router.ts +22 -12
  107. package/src/server/auth-cors.ts +26 -0
  108. package/src/server/catalog-download.ts +73 -0
  109. package/src/server/chat-native.ts +12 -2
  110. package/src/server/gui-static.ts +4 -1
  111. package/src/server/index.ts +132 -9
  112. package/src/server/management/agent-settings-routes.ts +38 -5
  113. package/src/server/management/codex-prompt-routes.ts +7 -1
  114. package/src/server/management/combo-routes.ts +10 -1
  115. package/src/server/management/config-routes.ts +9 -1
  116. package/src/server/management/context.ts +5 -0
  117. package/src/server/management/model-routes.ts +16 -6
  118. package/src/server/management/native-integration-routes.ts +12 -17
  119. package/src/server/management/oauth-account-routes.ts +13 -0
  120. package/src/server/management/provider-routes.ts +32 -5
  121. package/src/server/management/routing-profile-routes.ts +15 -0
  122. package/src/server/management/shadow-call-validation.ts +29 -0
  123. package/src/server/management-api.ts +7 -3
  124. package/src/server/request-log.ts +3 -5
  125. package/src/server/responses/agent-task-recovery-cache.ts +8 -0
  126. package/src/server/responses/agent-task-recovery.ts +52 -20
  127. package/src/server/responses/codex-auth-error.ts +26 -0
  128. package/src/server/responses/compact.ts +345 -10
  129. package/src/server/responses/core.ts +736 -108
  130. package/src/server/responses/empty-completion-guard.ts +16 -0
  131. package/src/server/responses/fetch-helpers.ts +42 -0
  132. package/src/server/responses/policy-fallback.ts +11 -6
  133. package/src/server/responses-undeclared-tool-guard.ts +16 -3
  134. package/src/server/startup-health-cache.ts +59 -13
  135. package/src/service-manager-probe.ts +115 -9
  136. package/src/service.ts +139 -40
  137. package/src/storage/cleanup.ts +10 -0
  138. package/src/storage/storage-mutation-coordinator.ts +14 -3
  139. package/src/tray/windows-tray.ps1 +10 -4
  140. package/src/tray/windows.ts +30 -2
  141. package/src/types/config.ts +27 -14
  142. package/src/types/provider.ts +54 -0
  143. package/src/types/tools.ts +13 -3
  144. package/src/types.ts +4 -0
  145. package/src/usage/summary.ts +421 -177
  146. package/src/vision/anthropic-describe.ts +3 -3
  147. package/src/vision/describe.ts +5 -3
  148. package/src/web-search/anthropic-executor.ts +9 -2
  149. package/src/web-search/exa-executor.ts +3 -3
  150. package/src/web-search/executor.ts +8 -3
  151. package/src/web-search/gemini-executor.ts +3 -3
  152. package/src/web-search/loop.ts +11 -3
  153. package/src/web-search/xai-executor.ts +3 -3
  154. package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
  155. package/gui/dist/assets/index-DX0RGj3e.js +0 -112
@@ -5,7 +5,7 @@ import { codexAccountLogLabel } from "./account-label";
5
5
  import { isCodexAccountPaused } from "./account-pause";
6
6
  import { clearCodexAccountPin, codexAccountPriorityLookup, pinnedCodexAccountId } from "./account-priority";
7
7
  import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "./account-usability";
8
- import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
8
+ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
9
9
  import {
10
10
  POOL_KEY_CODEX,
11
11
  normalizeAccountPoolStickyLimit,
@@ -19,7 +19,11 @@ import {
19
19
  } from "./pool-rotation";
20
20
  import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
21
21
  import { isThirtyDayOnlyCodexPlan } from "./plan";
22
- import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./main-account";
22
+ import {
23
+ MAIN_CODEX_ACCOUNT_ID,
24
+ getMainAccountPlan,
25
+ hasMainAccountRefreshGrant,
26
+ } from "./main-account";
23
27
  import { isSelectableCodexPoolAccount } from "./account-id";
24
28
  import type { OcxConfig } from "../types";
25
29
  import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
@@ -87,6 +91,15 @@ type CodexUpstreamHealth = {
87
91
  * flaky account without throwing CodexAccountCooldownError (hard-only).
88
92
  */
89
93
  softAvoidUntil?: number;
94
+ /**
95
+ * Credential generation a 401/403 quarantine was derived from (#2892 gap 4).
96
+ *
97
+ * Provenance lives ON the entry rather than in a side map keyed by account id. A side map spends
98
+ * "whatever health is current when the old credential is found dead", which deletes a later
99
+ * unrelated entry: a G1 401, then a G2 save, then a genuine G2 503 would lose the 503. Only the
100
+ * entry that carries this field can be spent, and any later write simply replaces it.
101
+ */
102
+ credentialFailureGeneration?: number;
90
103
  };
91
104
 
92
105
  const CODEX_DEFAULT_QUOTA_COOLDOWN_MS = 60_000;
@@ -123,6 +136,22 @@ const upstreamHealth = new Map<string, CodexUpstreamHealth>();
123
136
  * from account-wide Retry-After/default throttles and transient health.
124
137
  */
125
138
  const quotaScopedHealth = new Map<string, Map<CodexQuotaScope, CodexUpstreamHealth>>();
139
+ /**
140
+ * Spend a credential-failure health entry whose credential no longer exists (#2892 gap 4).
141
+ *
142
+ * A 401/403 describes one CREDENTIAL, not an account, and a replacement can land at any point after
143
+ * the outcome is recorded — so re-reading the store inside `recordCodexUpstreamOutcome` narrows the
144
+ * window without closing it. The reader decides instead, and it may only spend an entry that
145
+ * actually carries credential provenance: a later transient or quota write replaces the entry and
146
+ * with it the tag, so this can never delete evidence that belongs to a different failure.
147
+ */
148
+ function dropSpentCredentialFailure(accountId: string): void {
149
+ const health = upstreamHealth.get(accountId);
150
+ const generation = health?.credentialFailureGeneration;
151
+ if (health === undefined || generation === undefined) return;
152
+ if (isCodexAccountGenerationLive(accountId, generation)) return;
153
+ upstreamHealth.delete(accountId);
154
+ }
126
155
  let lastReconciledGeneration = 0;
127
156
  let liveHealthAccountIds = new Set<string>();
128
157
 
@@ -158,22 +187,21 @@ export type CodexQuotaRecoveryProbeProof = {
158
187
  * affinity so a Spark failover cannot displace the same thread's Terra/Luna
159
188
  * account (and vice versa).
160
189
  */
161
- type ThreadAffinityScope = CodexQuotaScope | "legacy";
190
+ type BaseThreadAffinityScope = CodexQuotaScope | "legacy";
191
+ type ModelDetourAffinityScope = `model-detour:${BaseThreadAffinityScope}:${string}`;
192
+ type ThreadAffinityScope = BaseThreadAffinityScope | ModelDetourAffinityScope;
162
193
  const LEGACY_THREAD_AFFINITY_SCOPE = "legacy" as const;
163
194
  const threadAccountMap = new Map<string, Map<ThreadAffinityScope, ThreadAffinityEntry>>();
195
+ let threadAffinityEntryTotal = 0;
196
+
197
+ function isModelDetourAffinityScope(scope: ThreadAffinityScope): scope is ModelDetourAffinityScope {
198
+ return scope.startsWith("model-detour:");
199
+ }
164
200
 
165
201
  const NATIVE_MODEL_QUOTA_SCOPES: Readonly<Record<string, CodexQuotaScope>> = {
166
202
  "gpt-5.3-codex-spark": "spark",
167
203
  };
168
204
 
169
- // A thread can have one legacy binding plus one binding for each known scope.
170
- // This upper-bound guard avoids an exact map scan until it can be over capacity.
171
- const MAX_THREAD_AFFINITY_SCOPES = new Set([
172
- LEGACY_THREAD_AFFINITY_SCOPE,
173
- "shared",
174
- ...Object.values(NATIVE_MODEL_QUOTA_SCOPES),
175
- ]).size;
176
-
177
205
  export function codexQuotaScopeForModel(modelId: string | undefined): CodexQuotaScope | undefined {
178
206
  if (!modelId?.trim()) return undefined;
179
207
  return NATIVE_MODEL_QUOTA_SCOPES[modelId.trim().toLowerCase()] ?? "shared";
@@ -228,6 +256,15 @@ export type CodexUpstreamOutcomeMeta = {
228
256
  promoteAccountId?: string;
229
257
  /** Generation captured when this routed account was selected. */
230
258
  writerGeneration?: number;
259
+ /**
260
+ * Credential generation this request's bearer was read at. Distinct from
261
+ * `writerGeneration`, which tracks the config store.
262
+ *
263
+ * A 401 that arrives after the credential was already replaced is evidence about a
264
+ * token nobody is using any more, so it must not quarantine the replacement. Absent
265
+ * means the caller cannot supply lineage and the historical unfenced handling stands.
266
+ */
267
+ credentialGeneration?: number;
231
268
  };
232
269
 
233
270
  function hasConfiguredPoolAccount(
@@ -253,12 +290,15 @@ export function listLiveCodexAccountIds(config: OcxConfig): ReadonlySet<string>
253
290
 
254
291
  export function clearThreadAccountMap(): void {
255
292
  threadAccountMap.clear();
293
+ threadAffinityEntryTotal = 0;
256
294
  }
257
295
 
258
296
  export function clearThreadAccountMapForAccount(accountId: string): void {
259
297
  for (const [threadId, affinities] of threadAccountMap) {
260
298
  for (const [scope, entry] of affinities) {
261
- if (entry.accountId === accountId) affinities.delete(scope);
299
+ if (entry.accountId === accountId && affinities.delete(scope)) {
300
+ threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1);
301
+ }
262
302
  }
263
303
  if (affinities.size === 0) threadAccountMap.delete(threadId);
264
304
  }
@@ -296,6 +336,7 @@ export function reconcileCodexRoutingHealth(context: GenerationContext): number
296
336
  export function getCodexUpstreamHealth(
297
337
  accountId: string,
298
338
  ): CodexUpstreamHealth | null {
339
+ dropSpentCredentialFailure(accountId);
299
340
  return upstreamHealth.get(accountId) ?? null;
300
341
  }
301
342
 
@@ -675,7 +716,13 @@ function withProbeLeaseReleased(health: CodexUpstreamHealth, now: number): Codex
675
716
  */
676
717
  function preservedCooldownFields(health: CodexUpstreamHealth | undefined): Partial<CodexUpstreamHealth> {
677
718
  if (!health) return {};
678
- const { consecutiveFailures: _f, consecutiveSuccesses: _s, lastFailureStatus: _st, lastFailureAt: _at, softAvoidUntil: _sa, ...cooldownFields } = health;
719
+ // `credentialFailureGeneration` is provenance for ONE credential failure, so it must not survive
720
+ // into a later transient or quota entry — otherwise that entry inherits the tag and gets spent
721
+ // when the old credential dies, deleting evidence that was never about it (#2892 gap 4 review).
722
+ const {
723
+ consecutiveFailures: _f, consecutiveSuccesses: _s, lastFailureStatus: _st, lastFailureAt: _at,
724
+ softAvoidUntil: _sa, credentialFailureGeneration: _cg, ...cooldownFields
725
+ } = health;
679
726
  return cooldownFields;
680
727
  }
681
728
 
@@ -830,7 +877,7 @@ function isCodexAccountSelectable(
830
877
  && isCodexAccountUsable(config, accountId, selectionOptions);
831
878
  }
832
879
 
833
- function threadAffinityScope(quotaScope?: CodexQuotaScope): ThreadAffinityScope {
880
+ function threadAffinityScope(quotaScope?: CodexQuotaScope): BaseThreadAffinityScope {
834
881
  return quotaScope ?? LEGACY_THREAD_AFFINITY_SCOPE;
835
882
  }
836
883
 
@@ -838,34 +885,74 @@ function admissibleAffinityComponent(value: string): boolean {
838
885
  return retainedUtf8Bytes(value) <= MAX_AFFINITY_COMPONENT_BYTES;
839
886
  }
840
887
 
841
- function getThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): ThreadAffinityEntry | undefined {
888
+ function modelDetourAffinityScope(
889
+ modelId: string | undefined,
890
+ quotaScope?: CodexQuotaScope,
891
+ ): ModelDetourAffinityScope | undefined {
892
+ const canonicalModelId = modelId?.trim().toLowerCase();
893
+ if (!canonicalModelId || !admissibleAffinityComponent(canonicalModelId)) return undefined;
894
+ return `model-detour:${threadAffinityScope(quotaScope)}:${canonicalModelId}`;
895
+ }
896
+
897
+ function getThreadAffinityForScope(
898
+ threadId: string,
899
+ scope: ThreadAffinityScope,
900
+ ): ThreadAffinityEntry | undefined {
842
901
  if (!admissibleAffinityComponent(threadId)) return undefined;
843
- return threadAccountMap.get(threadId)?.get(threadAffinityScope(quotaScope));
902
+ return threadAccountMap.get(threadId)?.get(scope);
844
903
  }
845
904
 
846
- function deleteThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): void {
905
+ function getThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): ThreadAffinityEntry | undefined {
906
+ return getThreadAffinityForScope(threadId, threadAffinityScope(quotaScope));
907
+ }
908
+
909
+ function getModelDetourAffinity(
910
+ threadId: string,
911
+ modelId: string | undefined,
912
+ quotaScope?: CodexQuotaScope,
913
+ ): ThreadAffinityEntry | undefined {
914
+ const scope = modelDetourAffinityScope(modelId, quotaScope);
915
+ return scope ? getThreadAffinityForScope(threadId, scope) : undefined;
916
+ }
917
+
918
+ function deleteThreadAffinityForScope(threadId: string, scope: ThreadAffinityScope): void {
847
919
  if (!admissibleAffinityComponent(threadId)) return;
848
920
  const affinities = threadAccountMap.get(threadId);
849
921
  if (!affinities) return;
850
- affinities.delete(threadAffinityScope(quotaScope));
922
+ if (affinities.delete(scope)) {
923
+ threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1);
924
+ }
851
925
  if (affinities.size === 0) threadAccountMap.delete(threadId);
852
926
  }
853
927
 
928
+ function deleteThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): void {
929
+ deleteThreadAffinityForScope(threadId, threadAffinityScope(quotaScope));
930
+ }
931
+
932
+ function deleteModelDetourAffinity(
933
+ threadId: string,
934
+ modelId: string | undefined,
935
+ quotaScope?: CodexQuotaScope,
936
+ ): void {
937
+ const scope = modelDetourAffinityScope(modelId, quotaScope);
938
+ if (scope) deleteThreadAffinityForScope(threadId, scope);
939
+ }
940
+
854
941
  /** Remove only the matching failed account's affinities for one thread. */
855
942
  function deleteThreadAffinitiesForAccount(threadId: string, accountId: string): void {
856
943
  if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return;
857
944
  const affinities = threadAccountMap.get(threadId);
858
945
  if (!affinities) return;
859
946
  for (const [scope, entry] of affinities) {
860
- if (entry.accountId === accountId) affinities.delete(scope);
947
+ if (entry.accountId === accountId && affinities.delete(scope)) {
948
+ threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1);
949
+ }
861
950
  }
862
951
  if (affinities.size === 0) threadAccountMap.delete(threadId);
863
952
  }
864
953
 
865
954
  function threadAffinityEntryCount(): number {
866
- let count = 0;
867
- for (const affinities of threadAccountMap.values()) count += affinities.size;
868
- return count;
955
+ return threadAffinityEntryTotal;
869
956
  }
870
957
 
871
958
  function isThreadAffinityExpired(entry: ThreadAffinityEntry, now: number): boolean {
@@ -877,46 +964,102 @@ function isThreadAffinityGenerationLive(entry: ThreadAffinityEntry): boolean {
877
964
  return isCodexAccountGenerationLive(entry.accountId, entry.generation);
878
965
  }
879
966
 
967
+ /** Generations this account's affinity entries are bound at. Test observability only. */
968
+ export function debugCodexAffinityGenerations(accountId: string): number[] {
969
+ const generations: number[] = [];
970
+ for (const affinities of threadAccountMap.values()) {
971
+ for (const entry of affinities.values()) {
972
+ if (entry.accountId === accountId) generations.push(entry.generation);
973
+ }
974
+ }
975
+ return generations;
976
+ }
977
+
978
+ /**
979
+ * Advance this account's affinity entries from the generation a rejected credential
980
+ * was bound under to the generation its own refresh produced.
981
+ *
982
+ * A 401 refresh-and-replay keeps the request on the same account, but the CAS write
983
+ * moves the credential from G to G+1, and {@link isThreadAffinityGenerationLive}
984
+ * demands exact equality — so without this the entry the replay just preserved is
985
+ * dead on the next request. Not quarantining an account is not the same as keeping
986
+ * its affinity.
987
+ *
988
+ * Lineage is proven by the CALLER, which must pass only a generation its own refresh
989
+ * produced. Re-deriving it here from `replacedAt` cannot work: the caller reads that
990
+ * field after the refresh and this function would re-read the same record, so the
991
+ * comparison is tautological and an external replacement passes it. An external
992
+ * replacement must retire the affinity, because that credential may belong to a
993
+ * different upstream identity.
994
+ */
995
+ export function handOffThreadAffinityGeneration(
996
+ accountId: string,
997
+ fromGeneration: number,
998
+ toGeneration: number,
999
+ ): boolean {
1000
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) return false;
1001
+ if (toGeneration !== fromGeneration + 1) return false;
1002
+ const record = readCodexAccountRecord(accountId);
1003
+ if (!record?.credential || record.deletedAt != null) return false;
1004
+ if (record.generation !== toGeneration) return false;
1005
+ let handedOff = false;
1006
+ for (const affinities of threadAccountMap.values()) {
1007
+ for (const entry of affinities.values()) {
1008
+ if (entry.accountId !== accountId || entry.generation !== fromGeneration) continue;
1009
+ entry.generation = toGeneration;
1010
+ handedOff = true;
1011
+ }
1012
+ }
1013
+ return handedOff;
1014
+ }
1015
+
880
1016
  function pruneExpiredThreadAffinities(now: number): void {
881
1017
  for (const [threadId, affinities] of threadAccountMap) {
882
1018
  for (const [scope, entry] of affinities) {
883
- if (isThreadAffinityExpired(entry, now)) affinities.delete(scope);
1019
+ if (isThreadAffinityExpired(entry, now) && affinities.delete(scope)) {
1020
+ threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1);
1021
+ }
884
1022
  }
885
1023
  if (affinities.size === 0) threadAccountMap.delete(threadId);
886
1024
  }
887
1025
  }
888
1026
 
889
1027
  function pruneLruThreadAffinities(): void {
890
- if (threadAccountMap.size * MAX_THREAD_AFFINITY_SCOPES <= CODEX_THREAD_AFFINITY_MAX_ENTRIES) return;
1028
+ if (threadAffinityEntryCount() <= CODEX_THREAD_AFFINITY_MAX_ENTRIES) return;
891
1029
  while (threadAffinityEntryCount() > CODEX_THREAD_AFFINITY_MAX_ENTRIES) {
892
1030
  let oldestThreadId: string | null = null;
893
1031
  let oldestScope: ThreadAffinityScope | null = null;
894
1032
  let oldestLastUsedAt = Number.POSITIVE_INFINITY;
1033
+ let oldestIsDetour = false;
895
1034
  for (const [threadId, affinities] of threadAccountMap) {
896
1035
  for (const [scope, entry] of affinities) {
897
- if (entry.lastUsedAt < oldestLastUsedAt) {
1036
+ const candidateIsDetour = isModelDetourAffinityScope(scope);
1037
+ if (
1038
+ (candidateIsDetour && !oldestIsDetour)
1039
+ || (candidateIsDetour === oldestIsDetour && entry.lastUsedAt < oldestLastUsedAt)
1040
+ ) {
898
1041
  oldestThreadId = threadId;
899
1042
  oldestScope = scope;
900
1043
  oldestLastUsedAt = entry.lastUsedAt;
1044
+ oldestIsDetour = candidateIsDetour;
901
1045
  }
902
1046
  }
903
1047
  }
904
1048
  if (!oldestThreadId || !oldestScope) return;
905
- deleteThreadAffinity(oldestThreadId, oldestScope === LEGACY_THREAD_AFFINITY_SCOPE ? undefined : oldestScope);
1049
+ deleteThreadAffinityForScope(oldestThreadId, oldestScope);
906
1050
  }
907
1051
  }
908
1052
 
909
- function bindThreadAffinity(
1053
+ function bindThreadAffinityForScope(
910
1054
  threadId: string,
911
1055
  accountId: string,
912
1056
  now: number,
913
- quotaScope?: CodexQuotaScope,
1057
+ scope: ThreadAffinityScope,
914
1058
  ): void {
915
1059
  if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return;
916
1060
  const record = accountId === MAIN_CODEX_ACCOUNT_ID ? undefined : readCodexAccountRecord(accountId);
917
1061
  if (accountId !== MAIN_CODEX_ACCOUNT_ID && (!record?.credential || record.deletedAt != null)) return;
918
1062
  pruneExpiredThreadAffinities(now);
919
- const scope = threadAffinityScope(quotaScope);
920
1063
  const affinities = threadAccountMap.get(threadId) ?? new Map<ThreadAffinityScope, ThreadAffinityEntry>();
921
1064
  const previous = affinities.get(scope);
922
1065
  affinities.set(scope, {
@@ -926,22 +1069,45 @@ function bindThreadAffinity(
926
1069
  lastUsedAt: now,
927
1070
  lastReevalAt: now,
928
1071
  });
1072
+ if (!previous) threadAffinityEntryTotal += 1;
929
1073
  threadAccountMap.set(threadId, affinities);
930
1074
  pruneLruThreadAffinities();
931
1075
  }
932
1076
 
1077
+ function bindThreadAffinity(
1078
+ threadId: string,
1079
+ accountId: string,
1080
+ now: number,
1081
+ quotaScope?: CodexQuotaScope,
1082
+ ): void {
1083
+ bindThreadAffinityForScope(threadId, accountId, now, threadAffinityScope(quotaScope));
1084
+ }
1085
+
1086
+ function bindModelDetourAffinity(
1087
+ threadId: string,
1088
+ accountId: string,
1089
+ now: number,
1090
+ modelId: string | undefined,
1091
+ quotaScope?: CodexQuotaScope,
1092
+ ): void {
1093
+ const scope = modelDetourAffinityScope(modelId, quotaScope);
1094
+ if (scope) bindThreadAffinityForScope(threadId, accountId, now, scope);
1095
+ }
1096
+
933
1097
  function getEligiblePoolAccounts(
934
1098
  config: OcxConfig,
935
1099
  excludeId?: string,
936
1100
  now = Date.now(),
937
1101
  quotaScope?: CodexQuotaScope,
938
1102
  selectionOptions?: CodexAccountUsabilityOptions,
1103
+ skipFailoverReadyCandidates = false,
939
1104
  ): readonly string[] {
940
1105
  const ids = (config.codexAccounts ?? [])
941
1106
  .filter(account => isSelectableCodexPoolAccount(account)
942
1107
  && account.id !== excludeId
943
1108
  && !isCodexAccountPaused(config, account.id)
944
- && !isAccountNeedsReauth(account.id))
1109
+ && !isAccountNeedsReauth(account.id)
1110
+ && (!skipFailoverReadyCandidates || !shouldFailover(config, account.id, now)))
945
1111
  .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null)
946
1112
  .filter(account => !isCodexAccountSoftAvoided(account.id, now))
947
1113
  .filter(account => isCodexAccountUsable(config, account.id, selectionOptions))
@@ -951,9 +1117,10 @@ function getEligiblePoolAccounts(
951
1117
  if (
952
1118
  excludeId !== MAIN_CODEX_ACCOUNT_ID
953
1119
  && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
954
- && !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)
1120
+ && (!isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) || hasMainAccountRefreshGrant())
955
1121
  && getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null
956
1122
  && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now)
1123
+ && (!skipFailoverReadyCandidates || !shouldFailover(config, MAIN_CODEX_ACCOUNT_ID, now))
957
1124
  && isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions)
958
1125
  ) {
959
1126
  ids.unshift(MAIN_CODEX_ACCOUNT_ID);
@@ -964,7 +1131,7 @@ function getEligiblePoolAccounts(
964
1131
  return selectPriorityTier(
965
1132
  ids,
966
1133
  codexAccountPriorityLookup(config),
967
- id => hasCodexQuotaHeadroom(config, id),
1134
+ id => hasCodexQuotaHeadroom(config, id, selectionOptions),
968
1135
  pinnedCodexAccountId(config),
969
1136
  );
970
1137
  }
@@ -992,10 +1159,17 @@ function stickyLimitForConfig(config: OcxConfig): number {
992
1159
  * primed. A genuinely exhausted account 429s into cooldown and leaves
993
1160
  * eligibility on its own.
994
1161
  */
995
- function hasCodexQuotaHeadroom(config: OcxConfig, accountId: string): boolean {
1162
+ function hasCodexQuotaHeadroom(
1163
+ config: OcxConfig,
1164
+ accountId: string,
1165
+ selectionOptions?: CodexAccountUsabilityOptions,
1166
+ ): boolean {
996
1167
  const threshold = config.autoSwitchThreshold ?? 80;
997
1168
  if (threshold <= 0) return true;
998
- const usage = computeCodexUsageScore(getAccountQuota(accountId), getPoolAccountPlan(config, accountId));
1169
+ const usage = computeCodexUsageScore(
1170
+ getAccountQuota(accountId),
1171
+ getPoolAccountPlanForSelection(config, accountId, selectionOptions),
1172
+ );
999
1173
  if (isUnknownUsage(usage)) return true;
1000
1174
  return usage < threshold;
1001
1175
  }
@@ -1014,7 +1188,7 @@ function pickFillFirstCodexAccount(
1014
1188
  if (eligible.length === 0) return null;
1015
1189
 
1016
1190
  const active = getEffectiveActiveCodexAccountId(config);
1017
- if (active && eligible.includes(active) && hasCodexQuotaHeadroom(config, active)) {
1191
+ if (active && eligible.includes(active) && hasCodexQuotaHeadroom(config, active, selectionOptions)) {
1018
1192
  return active;
1019
1193
  }
1020
1194
 
@@ -1034,7 +1208,7 @@ function pickNextFillFirstCodexAccount(
1034
1208
  if (!afterId) {
1035
1209
  // Prefer an under-threshold account when starting with no active cursor.
1036
1210
  for (const id of ordered) {
1037
- if (hasCodexQuotaHeadroom(config, id)) return id;
1211
+ if (hasCodexQuotaHeadroom(config, id, selectionOptions)) return id;
1038
1212
  }
1039
1213
  return ordered[0] ?? null;
1040
1214
  }
@@ -1049,7 +1223,7 @@ function pickNextFillFirstCodexAccount(
1049
1223
  const startIdx = stableAll.indexOf(afterId);
1050
1224
  if (startIdx < 0) {
1051
1225
  for (const id of ordered) {
1052
- if (hasCodexQuotaHeadroom(config, id)) return id;
1226
+ if (hasCodexQuotaHeadroom(config, id, selectionOptions)) return id;
1053
1227
  }
1054
1228
  return ordered[0] ?? null;
1055
1229
  }
@@ -1060,7 +1234,7 @@ function pickNextFillFirstCodexAccount(
1060
1234
  const candidate = stableAll[(startIdx + step) % stableAll.length]!;
1061
1235
  if (!eligible.includes(candidate)) continue;
1062
1236
  if (!fallback) fallback = candidate;
1063
- if (hasCodexQuotaHeadroom(config, candidate)) return candidate;
1237
+ if (hasCodexQuotaHeadroom(config, candidate, selectionOptions)) return candidate;
1064
1238
  }
1065
1239
  return fallback ?? ordered[0] ?? null;
1066
1240
  }
@@ -1069,10 +1243,10 @@ function pickNextFillFirstCodexAccount(
1069
1243
  * Unbound new-session pick for round-robin / fill-first. Returns null to fall through
1070
1244
  * to the legacy quota path (or when the strategy is quota).
1071
1245
  *
1072
- * When `commit` is true (resolve path), remembers active in-memory, binds thread affinity, and
1073
- * notes RR success. When `commit` is false (preview), returns the same RR/fill-first
1074
- * account resolve would pick via a dry-run peek without mutating ring weights,
1075
- * activeKey, sticky counters, config, or affinity.
1246
+ * When `commit` is true (resolve path), advances RR state. `commitSharedActive`
1247
+ * and `commitAffinity` independently control the two cross-request side effects:
1248
+ * model-scoped entitlement selection can bind a new task without replacing an
1249
+ * existing task binding or global active choice. Preview remains a dry-run peek.
1076
1250
  *
1077
1251
  * Automatic strategy picks never sync-write config; only manual selection persists active.
1078
1252
  *
@@ -1087,6 +1261,8 @@ function pickUnboundStrategyAccount(
1087
1261
  commit: boolean,
1088
1262
  quotaScope?: CodexQuotaScope,
1089
1263
  selectionOptions?: CodexAccountUsabilityOptions,
1264
+ commitSharedActive = commit,
1265
+ commitAffinity = commit,
1090
1266
  ): string | null {
1091
1267
  const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
1092
1268
  if (strategy === "quota") return null;
@@ -1101,8 +1277,10 @@ function pickUnboundStrategyAccount(
1101
1277
  }
1102
1278
  picked = pickRoundRobinAccount(poolKey, eligible, limit);
1103
1279
  if (!picked) return null;
1104
- if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked);
1105
- if (threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
1280
+ if (commitSharedActive) {
1281
+ if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked);
1282
+ }
1283
+ if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
1106
1284
  notePoolRotationSuccess(poolKey, picked, limit);
1107
1285
  return picked;
1108
1286
  }
@@ -1110,10 +1288,10 @@ function pickUnboundStrategyAccount(
1110
1288
  if (strategy === "fill-first") {
1111
1289
  picked = pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions);
1112
1290
  if (!picked) return null;
1113
- if (commit) {
1291
+ if (commitSharedActive) {
1114
1292
  if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked);
1115
- if (threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
1116
1293
  }
1294
+ if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
1117
1295
  return picked;
1118
1296
  }
1119
1297
 
@@ -1126,6 +1304,36 @@ export function getPoolAccountPlan(config: OcxConfig, accountId: string): string
1126
1304
  .find(account => isSelectableCodexPoolAccount(account) && account.id === accountId)?.plan;
1127
1305
  }
1128
1306
 
1307
+ /** Selection-only main routing must not lazily read the fenced native credential for its plan. */
1308
+ function getPoolAccountPlanForSelection(
1309
+ config: OcxConfig,
1310
+ accountId: string,
1311
+ selectionOptions?: CodexAccountUsabilityOptions,
1312
+ ): string | undefined {
1313
+ if (accountId === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) {
1314
+ return undefined;
1315
+ }
1316
+ return getPoolAccountPlan(config, accountId);
1317
+ }
1318
+
1319
+ /** Shared routing state must ignore a request-scoped entitlement roster. */
1320
+ function sharedStateSelectionOptions(
1321
+ selectionOptions?: CodexAccountUsabilityOptions,
1322
+ ): Pick<
1323
+ CodexAccountUsabilityOptions,
1324
+ "nativeMainSelectionOnly" | "isMainAccountTokenLive"
1325
+ > | undefined {
1326
+ if (!selectionOptions) return undefined;
1327
+ return {
1328
+ ...(selectionOptions.nativeMainSelectionOnly !== undefined
1329
+ ? { nativeMainSelectionOnly: selectionOptions.nativeMainSelectionOnly }
1330
+ : {}),
1331
+ ...(selectionOptions.isMainAccountTokenLive
1332
+ ? { isMainAccountTokenLive: selectionOptions.isMainAccountTokenLive }
1333
+ : {}),
1334
+ };
1335
+ }
1336
+
1129
1337
  function pickLowerUsageAccount(
1130
1338
  config: OcxConfig,
1131
1339
  active: string,
@@ -1133,11 +1341,22 @@ function pickLowerUsageAccount(
1133
1341
  now: number,
1134
1342
  quotaScope?: CodexQuotaScope,
1135
1343
  selectionOptions?: CodexAccountUsabilityOptions,
1344
+ skipFailoverReadyCandidates = false,
1136
1345
  ): string {
1137
1346
  let best = active;
1138
1347
  let bestUsage = activeUsage;
1139
- for (const id of getEligiblePoolAccounts(config, active, now, quotaScope, selectionOptions)) {
1140
- const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id));
1348
+ for (const id of getEligiblePoolAccounts(
1349
+ config,
1350
+ active,
1351
+ now,
1352
+ quotaScope,
1353
+ selectionOptions,
1354
+ skipFailoverReadyCandidates,
1355
+ )) {
1356
+ const usage = computeCodexUsageScore(
1357
+ getAccountQuota(id),
1358
+ getPoolAccountPlanForSelection(config, id, selectionOptions),
1359
+ );
1141
1360
  if (usage < bestUsage) {
1142
1361
  best = id;
1143
1362
  bestUsage = usage;
@@ -1147,11 +1366,18 @@ function pickLowerUsageAccount(
1147
1366
  }
1148
1367
 
1149
1368
  /** Coolest account in an already-selected candidate list; first index wins ties. */
1150
- function pickLowestUsageAmong(config: OcxConfig, ids: readonly string[]): string | null {
1369
+ function pickLowestUsageAmong(
1370
+ config: OcxConfig,
1371
+ ids: readonly string[],
1372
+ selectionOptions?: CodexAccountUsabilityOptions,
1373
+ ): string | null {
1151
1374
  let best: string | null = null;
1152
1375
  let bestUsage = Number.POSITIVE_INFINITY;
1153
1376
  for (const id of ids) {
1154
- const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id));
1377
+ const usage = computeCodexUsageScore(
1378
+ getAccountQuota(id),
1379
+ getPoolAccountPlanForSelection(config, id, selectionOptions),
1380
+ );
1155
1381
  if (usage < bestUsage) {
1156
1382
  best = id;
1157
1383
  bestUsage = usage;
@@ -1167,7 +1393,11 @@ export function pickLowestUsageCodexAccount(
1167
1393
  quotaScope?: CodexQuotaScope,
1168
1394
  selectionOptions?: CodexAccountUsabilityOptions,
1169
1395
  ): string | null {
1170
- return pickLowestUsageAmong(config, getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions));
1396
+ return pickLowestUsageAmong(
1397
+ config,
1398
+ getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions),
1399
+ selectionOptions,
1400
+ );
1171
1401
  }
1172
1402
 
1173
1403
  /**
@@ -1307,12 +1537,20 @@ function pickPriorityPreemption(
1307
1537
  // A live pin already lowered the tier ceiling; never preempt past an explicit
1308
1538
  // operator choice. Same liveness test the tier filter applies, so preview and
1309
1539
  // resolve agree even before the pin is garbage-collected.
1310
- if (pinned !== undefined && eligible.includes(pinned) && hasCodexQuotaHeadroom(config, pinned)) return null;
1540
+ if (
1541
+ pinned !== undefined
1542
+ && eligible.includes(pinned)
1543
+ && hasCodexQuotaHeadroom(config, pinned, selectionOptions)
1544
+ ) return null;
1311
1545
  const priorityOf = codexAccountPriorityLookup(config);
1312
1546
  if (priorityOf(eligible[0]!) <= priorityOf(active)) return null;
1313
1547
  // Members without headroom are in the tier only because a sibling has some;
1314
1548
  // picking one would hand the request straight back to a drained account.
1315
- return pickLowestUsageAmong(config, eligible.filter(id => hasCodexQuotaHeadroom(config, id)));
1549
+ return pickLowestUsageAmong(
1550
+ config,
1551
+ eligible.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions)),
1552
+ selectionOptions,
1553
+ );
1316
1554
  }
1317
1555
 
1318
1556
  /**
@@ -1322,13 +1560,27 @@ function pickPriorityPreemption(
1322
1560
  * on its own. Clearing the pin also removes the condition, so this writes at
1323
1561
  * most once per pin.
1324
1562
  */
1325
- function releaseDrainedCodexAccountPin(config: OcxConfig): void {
1563
+ function releaseDrainedCodexAccountPin(
1564
+ config: OcxConfig,
1565
+ selectionOptions?: Pick<
1566
+ CodexAccountUsabilityOptions,
1567
+ "nativeMainSelectionOnly" | "isMainAccountTokenLive"
1568
+ >,
1569
+ ): void {
1326
1570
  const pinned = pinnedCodexAccountId(config);
1327
1571
  if (pinned === undefined) return;
1328
- const drained = !isCodexAccountUsable(config, pinned)
1329
- || isAccountNeedsReauth(pinned)
1330
- || isCodexAccountPaused(config, pinned)
1331
- || !hasCodexQuotaHeadroom(config, pinned);
1572
+ const knownUnavailable = isAccountNeedsReauth(pinned) || isCodexAccountPaused(config, pinned);
1573
+ if (knownUnavailable) {
1574
+ clearCodexAccountPin(config);
1575
+ saveConfigPreservingClaudeCode(config);
1576
+ return;
1577
+ }
1578
+ // Temporary drain deliberately forbids every native-main read. A pin on main
1579
+ // cannot be classified by credential liveness or quota until the fenced profile
1580
+ // is readable. Cached reauth and configured pause state were handled above.
1581
+ if (pinned === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) return;
1582
+ const drained = !isCodexAccountUsable(config, pinned, selectionOptions)
1583
+ || !hasCodexQuotaHeadroom(config, pinned, selectionOptions);
1332
1584
  if (!drained) return;
1333
1585
  clearCodexAccountPin(config);
1334
1586
  saveConfigPreservingClaudeCode(config);
@@ -1340,18 +1592,24 @@ function applyQuotaAutoSwitch(
1340
1592
  now: number,
1341
1593
  quotaScope?: CodexQuotaScope,
1342
1594
  selectionOptions?: CodexAccountUsabilityOptions,
1595
+ commitSharedSelection = true,
1343
1596
  ): string {
1344
1597
  const threshold = config.autoSwitchThreshold ?? 80;
1345
1598
  if (threshold <= 0) return active;
1346
1599
  const quota = getAccountQuota(active);
1347
- const activeUsage = computeCodexUsageScore(quota, getPoolAccountPlan(config, active));
1600
+ const activeUsage = computeCodexUsageScore(
1601
+ quota,
1602
+ getPoolAccountPlanForSelection(config, active, selectionOptions),
1603
+ );
1348
1604
  // Unknown usage is not evidence that a user's explicit selection crossed the
1349
1605
  // threshold. Wait for quota priming instead of rotating among guesses.
1350
1606
  if (isUnknownUsage(activeUsage)) return active;
1351
1607
  if (activeUsage < threshold) return active;
1352
1608
  const best = pickLowerUsageAccount(config, active, activeUsage, now, quotaScope, selectionOptions);
1353
1609
  if (best !== active) {
1354
- if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, best);
1610
+ if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)) {
1611
+ setActiveCodexAccount(config, best);
1612
+ }
1355
1613
  return best;
1356
1614
  }
1357
1615
 
@@ -1361,17 +1619,55 @@ function applyQuotaAutoSwitch(
1361
1619
  function shouldFailover(config: OcxConfig, accountId: string, now: number): boolean {
1362
1620
  const threshold = config.upstreamFailoverThreshold ?? 3;
1363
1621
  if (threshold <= 0) return false;
1622
+ dropSpentCredentialFailure(accountId);
1364
1623
  const health = upstreamHealth.get(accountId);
1365
1624
  if (health?.lastFailureAt && now - health.lastFailureAt > CODEX_FAILURE_WINDOW_MS) return false;
1366
1625
  return !!health && health.consecutiveFailures >= threshold;
1367
1626
  }
1368
1627
 
1628
+ function isHealthySharedCodexSelection(
1629
+ config: OcxConfig,
1630
+ accountId: string,
1631
+ now: number,
1632
+ quotaScope: CodexQuotaScope | undefined,
1633
+ selectionOptions: CodexAccountUsabilityOptions | undefined,
1634
+ ): boolean {
1635
+ return isCodexAccountSelectable(config, accountId, now, quotaScope, selectionOptions)
1636
+ && hasCodexQuotaHeadroom(config, accountId, selectionOptions)
1637
+ && !shouldFailover(config, accountId, now);
1638
+ }
1639
+
1640
+ function strategySelectionOptionsForModelDetour(
1641
+ config: OcxConfig,
1642
+ now: number,
1643
+ quotaScope: CodexQuotaScope | undefined,
1644
+ selectionOptions: CodexAccountUsabilityOptions | undefined,
1645
+ ): CodexAccountUsabilityOptions | undefined {
1646
+ if (selectionOptions?.modelEligibleAccountIds === undefined) return selectionOptions;
1647
+ const sharedSelectionOptions = sharedStateSelectionOptions(selectionOptions) ?? {};
1648
+ return {
1649
+ ...selectionOptions,
1650
+ modelEligibleAccountIds: new Set(
1651
+ [...selectionOptions.modelEligibleAccountIds].filter(accountId =>
1652
+ isHealthySharedCodexSelection(
1653
+ config,
1654
+ accountId,
1655
+ now,
1656
+ quotaScope,
1657
+ sharedSelectionOptions,
1658
+ )
1659
+ ),
1660
+ ),
1661
+ };
1662
+ }
1663
+
1369
1664
  function applyFailureFailover(
1370
1665
  config: OcxConfig,
1371
1666
  active: string,
1372
1667
  now: number,
1373
1668
  quotaScope?: CodexQuotaScope,
1374
1669
  selectionOptions?: CodexAccountUsabilityOptions,
1670
+ commitSharedSelection = true,
1375
1671
  ): string {
1376
1672
  if (!shouldFailover(config, active, now)) return active;
1377
1673
  const best = pickAlternateCodexAccount(config, active, now, quotaScope, selectionOptions);
@@ -1382,7 +1678,9 @@ function applyFailureFailover(
1382
1678
  // the moment of the failure; the streak outlives the soft avoid, so a later
1383
1679
  // scoped resolve reaches here with the streak still tripped and would otherwise
1384
1680
  // move the shared cursor after all.
1385
- if (!isIndependentCodexQuotaScope(quotaScope)) promoteActiveCodexAccount(config, best);
1681
+ if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)) {
1682
+ promoteActiveCodexAccount(config, best);
1683
+ }
1386
1684
  return best;
1387
1685
  }
1388
1686
  return active;
@@ -1398,6 +1696,88 @@ export function resolveCodexAccountForThread(
1398
1696
  return resolution.status === "selected" ? resolution.accountId : null;
1399
1697
  }
1400
1698
 
1699
+ function previewReusableAffinityAccount(
1700
+ entry: ThreadAffinityEntry | undefined,
1701
+ config: OcxConfig,
1702
+ now: number,
1703
+ quotaScope?: CodexQuotaScope,
1704
+ selectionOptions?: CodexAccountUsabilityOptions,
1705
+ ): string | null {
1706
+ if (
1707
+ !entry
1708
+ || isThreadAffinityExpired(entry, now)
1709
+ || !isThreadAffinityGenerationLive(entry)
1710
+ || !isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions)
1711
+ || shouldFailover(config, entry.accountId, now)
1712
+ ) {
1713
+ return null;
1714
+ }
1715
+ // Quota strategy only: non-quota strategies keep affinity for ongoing threads
1716
+ // (new-session-only rotation — docs / affinity policy A).
1717
+ if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") {
1718
+ const threshold = config.autoSwitchThreshold ?? 80;
1719
+ if (threshold > 0) {
1720
+ const usage = computeCodexUsageScore(
1721
+ getAccountQuota(entry.accountId),
1722
+ getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions),
1723
+ );
1724
+ if (!isUnknownUsage(usage) && usage >= threshold) {
1725
+ const best = pickLowerUsageAccount(
1726
+ config,
1727
+ entry.accountId,
1728
+ usage,
1729
+ now,
1730
+ quotaScope,
1731
+ selectionOptions,
1732
+ true,
1733
+ );
1734
+ if (best !== entry.accountId) return best;
1735
+ }
1736
+ }
1737
+ }
1738
+ return entry.accountId;
1739
+ }
1740
+
1741
+ /**
1742
+ * Re-evaluate an affined account under the quota strategy. Returns a strictly
1743
+ * cooler replacement, or null when the current binding should remain.
1744
+ */
1745
+ function reevaluateAffinityQuota(
1746
+ entry: ThreadAffinityEntry,
1747
+ config: OcxConfig,
1748
+ now: number,
1749
+ quotaScope?: CodexQuotaScope,
1750
+ selectionOptions?: CodexAccountUsabilityOptions,
1751
+ ): string | null {
1752
+ if (normalizeAccountPoolStrategy(config.accountPoolStrategy) !== "quota") return null;
1753
+ const threshold = config.autoSwitchThreshold ?? 80;
1754
+ const usage = threshold > 0
1755
+ ? computeCodexUsageScore(
1756
+ getAccountQuota(entry.accountId),
1757
+ getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions),
1758
+ )
1759
+ : 0;
1760
+ const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold;
1761
+ if (
1762
+ !overThreshold
1763
+ && now - entry.lastReevalAt < CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS
1764
+ ) {
1765
+ return null;
1766
+ }
1767
+ entry.lastReevalAt = now;
1768
+ if (!overThreshold) return null;
1769
+ const best = pickLowerUsageAccount(
1770
+ config,
1771
+ entry.accountId,
1772
+ usage,
1773
+ now,
1774
+ quotaScope,
1775
+ selectionOptions,
1776
+ true,
1777
+ );
1778
+ return best === entry.accountId ? null : best;
1779
+ }
1780
+
1401
1781
  /**
1402
1782
  * Side-effect-free preview of the Codex pool account native routing would prefer.
1403
1783
  * Used for subagent fallback quota decisions before final auth.
@@ -1412,42 +1792,31 @@ export function previewCodexAccountForRequest(
1412
1792
  now = Date.now(),
1413
1793
  quotaScope?: CodexQuotaScope,
1414
1794
  selectionOptions?: CodexAccountUsabilityOptions,
1795
+ modelId?: string,
1415
1796
  ): string | null {
1416
- const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
1417
- if (threadId && entry) {
1418
- if (
1419
- !isThreadAffinityExpired(entry, now)
1420
- && isThreadAffinityGenerationLive(entry)
1421
- && isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions)
1422
- && !shouldFailover(config, entry.accountId, now)
1423
- ) {
1424
- // Quota strategy only: non-quota strategies keep affinity for ongoing threads
1425
- // (new-session-only rotation — docs / affinity policy A).
1426
- const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
1427
- if (strategy === "quota") {
1428
- const threshold = config.autoSwitchThreshold ?? 80;
1429
- if (threshold > 0) {
1430
- const usage = computeCodexUsageScore(
1431
- getAccountQuota(entry.accountId),
1432
- getPoolAccountPlan(config, entry.accountId),
1433
- );
1434
- if (!isUnknownUsage(usage) && usage >= threshold) {
1435
- const best = pickLowerUsageAccount(
1436
- config,
1437
- entry.accountId,
1438
- usage,
1439
- now,
1440
- quotaScope,
1441
- selectionOptions,
1442
- );
1443
- if (best !== entry.accountId) return best;
1444
- }
1445
- }
1446
- }
1447
- return entry.accountId;
1448
- }
1449
- // Stale/unusable affinity is ignored for preview (no map mutation).
1797
+ // A request-scoped model detour keeps its own serving-account affinity. Preview
1798
+ // reads it before the ordinary lane, but never repairs or deletes it. Roster
1799
+ // expansion therefore preserves the already-serving account, and preview mirrors
1800
+ // final resolution even when the ordinary lane was independently retired.
1801
+ if (threadId && selectionOptions?.modelEligibleAccountIds !== undefined) {
1802
+ const detourPreview = previewReusableAffinityAccount(
1803
+ getModelDetourAffinity(threadId, modelId, quotaScope),
1804
+ config,
1805
+ now,
1806
+ quotaScope,
1807
+ selectionOptions,
1808
+ );
1809
+ if (detourPreview) return detourPreview;
1450
1810
  }
1811
+ const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
1812
+ const ordinaryPreview = previewReusableAffinityAccount(
1813
+ entry,
1814
+ config,
1815
+ now,
1816
+ quotaScope,
1817
+ selectionOptions,
1818
+ );
1819
+ if (ordinaryPreview) return ordinaryPreview;
1451
1820
 
1452
1821
  const strategyPick = pickUnboundStrategyAccount(
1453
1822
  config,
@@ -1455,7 +1824,7 @@ export function previewCodexAccountForRequest(
1455
1824
  now,
1456
1825
  false,
1457
1826
  quotaScope,
1458
- selectionOptions,
1827
+ strategySelectionOptionsForModelDetour(config, now, quotaScope, selectionOptions),
1459
1828
  );
1460
1829
  if (strategyPick) return strategyPick;
1461
1830
 
@@ -1476,7 +1845,10 @@ export function previewCodexAccountForRequest(
1476
1845
 
1477
1846
  const threshold = config.autoSwitchThreshold ?? 80;
1478
1847
  if (threshold > 0) {
1479
- const usage = computeCodexUsageScore(getAccountQuota(active), getPoolAccountPlan(config, active));
1848
+ const usage = computeCodexUsageScore(
1849
+ getAccountQuota(active),
1850
+ getPoolAccountPlanForSelection(config, active, selectionOptions),
1851
+ );
1480
1852
  if (!isUnknownUsage(usage) && usage >= threshold) {
1481
1853
  active = pickLowerUsageAccount(config, active, usage, now, quotaScope, selectionOptions);
1482
1854
  }
@@ -1501,12 +1873,64 @@ export function resolveCodexAccountForThreadDetailed(
1501
1873
  now = Date.now(),
1502
1874
  quotaScope?: CodexQuotaScope,
1503
1875
  selectionOptions?: CodexAccountUsabilityOptions,
1876
+ modelId?: string,
1504
1877
  ): CodexThreadResolution {
1878
+ // An entitlement roster constrains only this model request. It must not rewrite
1879
+ // the operator's shared active/pin choice or the task's ordinary-model affinity.
1880
+ const modelScopedSelection = selectionOptions?.modelEligibleAccountIds !== undefined;
1881
+ let preserveExistingModelScopedAffinity = false;
1882
+ const sharedSelectionOptions: CodexAccountUsabilityOptions | undefined = modelScopedSelection
1883
+ ? sharedStateSelectionOptions(selectionOptions) ?? {}
1884
+ : selectionOptions;
1505
1885
  // Retiring a spent manual pin is independent of affinity: an existing thread
1506
1886
  // keeps its account below, but the operator's tier ceiling must not silently
1507
1887
  // revive after quota resets. Independent model scopes must never persist a
1508
1888
  // change to shared routing state.
1509
- if (!isIndependentCodexQuotaScope(quotaScope)) releaseDrainedCodexAccountPin(config);
1889
+ if (!isIndependentCodexQuotaScope(quotaScope)) {
1890
+ releaseDrainedCodexAccountPin(config, sharedStateSelectionOptions(selectionOptions));
1891
+ }
1892
+ const sharedActiveBeforeSelection = getEffectiveActiveCodexAccountId(config);
1893
+ const preserveSharedSelectionForModelDetour = modelScopedSelection && (
1894
+ sharedActiveBeforeSelection === undefined
1895
+ || isHealthySharedCodexSelection(
1896
+ config,
1897
+ sharedActiveBeforeSelection,
1898
+ now,
1899
+ quotaScope,
1900
+ sharedSelectionOptions,
1901
+ )
1902
+ );
1903
+
1904
+ if (threadId && modelScopedSelection) {
1905
+ const detourEntry = getModelDetourAffinity(threadId, modelId, quotaScope);
1906
+ if (detourEntry) {
1907
+ const detourReusable = !isThreadAffinityExpired(detourEntry, now)
1908
+ && isThreadAffinityGenerationLive(detourEntry)
1909
+ && isCodexAccountSelectable(config, detourEntry.accountId, now, quotaScope, selectionOptions)
1910
+ && !shouldFailover(config, detourEntry.accountId, now);
1911
+ if (detourReusable) {
1912
+ detourEntry.lastUsedAt = now;
1913
+ // Model detours follow the same affinity policy as ordinary bindings:
1914
+ // RR/fill-first stay sticky, while quota strategy may re-evaluate an
1915
+ // over-threshold account without changing the ordinary lane.
1916
+ const cooler = reevaluateAffinityQuota(
1917
+ detourEntry,
1918
+ config,
1919
+ now,
1920
+ quotaScope,
1921
+ selectionOptions,
1922
+ );
1923
+ if (cooler) {
1924
+ bindModelDetourAffinity(threadId, cooler, now, modelId, quotaScope);
1925
+ return { status: "selected", accountId: cooler };
1926
+ }
1927
+ return { status: "selected", accountId: detourEntry.accountId };
1928
+ }
1929
+ // Detour expiry or invalidation must not expire the ordinary task. Drop only
1930
+ // this model lane and select from ordinary/shared state below.
1931
+ deleteModelDetourAffinity(threadId, modelId, quotaScope);
1932
+ }
1933
+ }
1510
1934
 
1511
1935
  const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
1512
1936
  if (threadId && entry) {
@@ -1514,12 +1938,20 @@ export function resolveCodexAccountForThreadDetailed(
1514
1938
  deleteThreadAffinity(threadId, quotaScope);
1515
1939
  return { status: "expired", accountId: entry.accountId };
1516
1940
  }
1941
+ const generationLive = isThreadAffinityGenerationLive(entry);
1942
+ const selectableForSharedState = generationLive
1943
+ && isCodexAccountSelectable(config, entry.accountId, now, quotaScope, sharedSelectionOptions);
1944
+ const selectableForRequest = selectableForSharedState
1945
+ && isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions);
1946
+ const failoverReady = shouldFailover(config, entry.accountId, now);
1947
+ const healthyForSharedAffinity = selectableForSharedState
1948
+ && hasCodexQuotaHeadroom(config, entry.accountId, sharedSelectionOptions)
1949
+ && !failoverReady;
1517
1950
  if (
1518
- isThreadAffinityGenerationLive(entry)
1519
- && isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions)
1951
+ selectableForRequest
1520
1952
  // Affined threads must leave a failing account once the streak trips failover
1521
1953
  // (soft-avoid covers the first-hit case; this catches post-avoid residual streaks).
1522
- && !shouldFailover(config, entry.accountId, now)
1954
+ && !failoverReady
1523
1955
  ) {
1524
1956
  entry.lastUsedAt = now;
1525
1957
  // Periodic quota re-eval: a long-lived bound thread must still switch when
@@ -1530,48 +1962,107 @@ export function resolveCodexAccountForThreadDetailed(
1530
1962
  // serving for up to 60s after a secondary with quota is available (#584).
1531
1963
  // Non-quota strategies (RR / fill-first) keep affinity for ongoing threads —
1532
1964
  // rotation is new-session-only (affinity policy A).
1533
- const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
1534
- if (strategy === "quota") {
1535
- const threshold = config.autoSwitchThreshold ?? 80;
1536
- const usage = threshold > 0
1537
- ? computeCodexUsageScore(
1538
- getAccountQuota(entry.accountId),
1539
- getPoolAccountPlan(config, entry.accountId),
1540
- )
1541
- : 0;
1542
- const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold;
1543
- if (overThreshold || now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) {
1544
- entry.lastReevalAt = now;
1545
- if (overThreshold) {
1546
- const best = pickLowerUsageAccount(config, entry.accountId, usage, now, quotaScope, selectionOptions);
1547
- if (best !== entry.accountId) {
1548
- if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, best);
1549
- bindThreadAffinity(threadId, best, now, quotaScope); // rebinds + resets clocks
1550
- return { status: "selected", accountId: best };
1551
- }
1552
- }
1965
+ const cooler = reevaluateAffinityQuota(entry, config, now, quotaScope, selectionOptions);
1966
+ if (cooler) {
1967
+ if (!isIndependentCodexQuotaScope(quotaScope)) {
1968
+ setActiveCodexAccount(config, cooler);
1553
1969
  }
1970
+ bindThreadAffinity(threadId, cooler, now, quotaScope); // rebinds + resets clocks
1971
+ return { status: "selected", accountId: cooler };
1554
1972
  }
1555
1973
  return { status: "selected", accountId: entry.accountId };
1556
1974
  }
1557
- deleteThreadAffinity(threadId, quotaScope);
1975
+ // A model-only exclusion does not invalidate the shared task binding. Health,
1976
+ // generation, pause, cooldown, and failure evidence still retire it normally.
1977
+ if (!modelScopedSelection || !healthyForSharedAffinity) {
1978
+ deleteThreadAffinity(threadId, quotaScope);
1979
+ } else {
1980
+ preserveExistingModelScopedAffinity = true;
1981
+ }
1558
1982
  }
1559
1983
 
1560
- const strategyPick = pickUnboundStrategyAccount(config, threadId, now, true, quotaScope, selectionOptions);
1561
- if (strategyPick) return { status: "selected", accountId: strategyPick };
1984
+ // A request-scoped roster may still contain unhealthy candidates. Non-quota strategies return
1985
+ // before the quota/failover helpers below, so prefer only shared-healthy roster members here;
1986
+ // otherwise RR/fill-first can immediately re-pick a known failing account even when another
1987
+ // entitled account is healthy. If no healthy member exists, the normal fallback path below
1988
+ // still decides whether the sole eligible candidate must be used.
1989
+ const strategySelectionOptions = strategySelectionOptionsForModelDetour(
1990
+ config,
1991
+ now,
1992
+ quotaScope,
1993
+ selectionOptions,
1994
+ );
1995
+ const strategyPick = pickUnboundStrategyAccount(
1996
+ config,
1997
+ threadId,
1998
+ now,
1999
+ true,
2000
+ quotaScope,
2001
+ strategySelectionOptions,
2002
+ !modelScopedSelection,
2003
+ !preserveExistingModelScopedAffinity,
2004
+ );
2005
+ if (strategyPick) {
2006
+ if (threadId && preserveExistingModelScopedAffinity) {
2007
+ bindModelDetourAffinity(threadId, strategyPick, now, modelId, quotaScope);
2008
+ }
2009
+ if (
2010
+ modelScopedSelection
2011
+ && !preserveSharedSelectionForModelDetour
2012
+ && !isIndependentCodexQuotaScope(quotaScope)
2013
+ ) {
2014
+ promoteActiveCodexAccount(config, strategyPick);
2015
+ }
2016
+ return { status: "selected", accountId: strategyPick };
2017
+ }
1562
2018
 
1563
2019
  let active = getEffectiveActiveCodexAccountId(config);
1564
2020
  if (!active) {
1565
2021
  const selected = pickLowestUsageCodexAccount(config, undefined, now, quotaScope, selectionOptions);
1566
- if (!selected) return { status: "none" };
1567
- if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, selected);
2022
+ if (!selected) {
2023
+ if (
2024
+ selectionOptions?.nativeMainSelectionOnly === true
2025
+ && selectionOptions.modelEligibleAccountIds !== undefined
2026
+ ) {
2027
+ return { status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID };
2028
+ }
2029
+ return { status: "none" };
2030
+ }
2031
+ if (!isIndependentCodexQuotaScope(quotaScope) && !modelScopedSelection) {
2032
+ setActiveCodexAccount(config, selected);
2033
+ }
1568
2034
  active = selected;
1569
2035
  }
2036
+ const activeSelectableForSharedState = isCodexAccountSelectable(
2037
+ config,
2038
+ active,
2039
+ now,
2040
+ quotaScope,
2041
+ sharedSelectionOptions,
2042
+ );
2043
+ const activeHealthyForSharedSelection = activeSelectableForSharedState
2044
+ && hasCodexQuotaHeadroom(config, active, sharedSelectionOptions)
2045
+ && !shouldFailover(config, active, now);
1570
2046
  if (!isCodexAccountSelectable(config, active, now, quotaScope, selectionOptions)) {
1571
2047
  const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions);
1572
2048
  if (fallback) {
1573
- if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, fallback);
2049
+ const modelOnlyMove = modelScopedSelection
2050
+ && preserveSharedSelectionForModelDetour
2051
+ && activeHealthyForSharedSelection;
2052
+ if (!isIndependentCodexQuotaScope(quotaScope) && !modelOnlyMove) {
2053
+ setActiveCodexAccount(config, fallback);
2054
+ }
1574
2055
  active = fallback;
2056
+ } else if (
2057
+ selectionOptions?.nativeMainSelectionOnly === true
2058
+ && selectionOptions.modelEligibleAccountIds !== undefined
2059
+ ) {
2060
+ // Entitlement discovery intentionally excludes main while a temporary drain
2061
+ // fences its credential. Once every eligible non-main candidate is unavailable,
2062
+ // return main only as a non-mutating sentinel so the caller's atomic claim can
2063
+ // classify maintenance. Do not fall through to the configured-but-ineligible
2064
+ // active account or persist/bind this synthetic selection.
2065
+ return { status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID };
1575
2066
  } else if (
1576
2067
  hasConfiguredPoolAccount(config, active, selectionOptions)
1577
2068
  && !isCodexAccountPaused(config, active)
@@ -1589,11 +2080,30 @@ export function resolveCodexAccountForThreadDetailed(
1589
2080
  // stays the operator's selection and getEffectiveActiveCodexAccountId is what
1590
2081
  // surfaces this to the API and dashboard. An independent quota group must not
1591
2082
  // move the shared cursor at all — its ordering decision is its own.
1592
- if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, preempted);
2083
+ if (
2084
+ !preserveSharedSelectionForModelDetour
2085
+ && !isIndependentCodexQuotaScope(quotaScope)
2086
+ ) {
2087
+ rememberActiveCodexAccount(config, preempted);
2088
+ }
1593
2089
  active = preempted;
1594
2090
  }
1595
- active = applyQuotaAutoSwitch(config, active, now, quotaScope, selectionOptions);
1596
- active = applyFailureFailover(config, active, now, quotaScope, selectionOptions);
2091
+ active = applyQuotaAutoSwitch(
2092
+ config,
2093
+ active,
2094
+ now,
2095
+ quotaScope,
2096
+ selectionOptions,
2097
+ !preserveSharedSelectionForModelDetour,
2098
+ );
2099
+ active = applyFailureFailover(
2100
+ config,
2101
+ active,
2102
+ now,
2103
+ quotaScope,
2104
+ selectionOptions,
2105
+ !preserveSharedSelectionForModelDetour,
2106
+ );
1597
2107
  if (!isCodexAccountUsable(config, active, selectionOptions)) {
1598
2108
  return hasConfiguredPoolAccount(config, active, selectionOptions)
1599
2109
  ? { status: "selected", accountId: active }
@@ -1605,7 +2115,13 @@ export function resolveCodexAccountForThreadDetailed(
1605
2115
  ? { status: "selected", accountId: active }
1606
2116
  : { status: "none" };
1607
2117
  }
1608
- if (threadId) bindThreadAffinity(threadId, active, now, quotaScope);
2118
+ if (threadId) {
2119
+ if (preserveExistingModelScopedAffinity) {
2120
+ bindModelDetourAffinity(threadId, active, now, modelId, quotaScope);
2121
+ } else {
2122
+ bindThreadAffinity(threadId, active, now, quotaScope);
2123
+ }
2124
+ }
1609
2125
  return { status: "selected", accountId: active };
1610
2126
  }
1611
2127
 
@@ -1628,6 +2144,16 @@ export function recordCodexUpstreamOutcome(
1628
2144
  const now = meta.now ?? Date.now();
1629
2145
  const outcomeClass = classifyCodexUpstreamOutcome(outcome, meta.denial);
1630
2146
  const quotaScope = codexQuotaScopeForModel(meta.modelId);
2147
+ /*
2148
+ * Spend a stale credential failure BEFORE any branch reads health (#2892 gap 4 review).
2149
+ *
2150
+ * Reader-side spending alone is not enough: the transient and workspace branches derive their new
2151
+ * entry from the current one, so a spent G1 401 would donate its `consecutiveFailures` to G2's
2152
+ * first genuine 503 and drop the tag while doing it. The account then reaches the failover
2153
+ * threshold one failure early, and no later read can tell. Clearing it here means every branch
2154
+ * starts from evidence that still describes a live credential.
2155
+ */
2156
+ dropSpentCredentialFailure(accountId);
1631
2157
  if (outcomeClass === "success") {
1632
2158
  const scopedProbe = meta.probeQuotaScope
1633
2159
  ? scopedHealthFor(accountId, meta.probeQuotaScope)
@@ -1721,13 +2247,48 @@ export function recordCodexUpstreamOutcome(
1721
2247
  if (outcomeClass === "credential") {
1722
2248
  // 401/403 quarantines the account for reauth. That supersedes quota state
1723
2249
  // entirely: a cooldown (and any probe lease) on an unusable account is moot.
2250
+ // Unless the rejected credential is already gone: a stale 401 racing a
2251
+ // replacement would otherwise take the fresh credential out of rotation and
2252
+ // sweep affinities that belong to it (#2887).
2253
+ if (
2254
+ meta.credentialGeneration !== undefined
2255
+ && !isCodexAccountGenerationLive(accountId, meta.credentialGeneration)
2256
+ ) {
2257
+ return;
2258
+ }
2259
+ /*
2260
+ * The pre-check above closes the same-process race, but not a cross-process one (#2892 gap 4).
2261
+ * `isCodexAccountGenerationLive` is an unlocked read while credential writers coordinate under
2262
+ * the mutation lock, and OS preemption needs no `await` — so another process can replace the
2263
+ * credential after this check, or at any point after this whole function returns. No re-read here
2264
+ * can close that: a replacement is always free to land one instruction later.
2265
+ *
2266
+ * Taking the credential lock is not an option either: it runs with `busy_timeout=0`, so acquiring
2267
+ * it per outcome would turn ordinary contention into thrown request-path errors.
2268
+ *
2269
+ * So the evidence is TAGGED with the credential it describes and judged when it is READ. The
2270
+ * health entry carries `credentialFailureGeneration` and the reauth map carries the same
2271
+ * generation; `dropSpentCredentialFailure` and `isAccountNeedsReauth` discard an entry whose
2272
+ * credential is gone. A later transient or quota write replaces the entry along with its tag, and
2273
+ * `preservedCooldownFields` drops the tag explicitly, so this provenance can never be spent
2274
+ * against a failure it did not describe.
2275
+ *
2276
+ * Affinity sweeping needs no tag: an affinity entry already carries a credential generation and
2277
+ * self-invalidates on the next check, and re-adding swept entries would be a worse bug.
2278
+ */
1724
2279
  upstreamHealth.set(accountId, {
1725
2280
  consecutiveFailures: 1,
1726
2281
  lastFailureStatus,
1727
2282
  lastFailureAt: now,
2283
+ // Provenance rides on the entry: only this failure can be spent when its credential dies.
2284
+ ...(meta.credentialGeneration !== undefined
2285
+ ? { credentialFailureGeneration: meta.credentialGeneration }
2286
+ : {}),
1728
2287
  });
1729
2288
  quotaScopedHealth.delete(accountId);
1730
- markAccountNeedsReauth(accountId, writerGeneration);
2289
+ // The reauth flag carries the same provenance, so a replacement landing after this call cannot
2290
+ // inherit a quarantine that was never about it.
2291
+ markAccountNeedsReauth(accountId, writerGeneration, meta.credentialGeneration);
1731
2292
  clearThreadAccountMapForAccount(accountId);
1732
2293
  return;
1733
2294
  }