@bitkyc08/opencodex 2.56.0 → 2.57.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 (145) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  3. package/gui/dist/assets/{index-D4zuyIxQ.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +3 -3
  6. package/src/adapters/codebuddy/adapter.ts +2 -1
  7. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  8. package/src/adapters/command-code.ts +1 -1
  9. package/src/adapters/cursor/envelope-echo.ts +8 -2
  10. package/src/adapters/google.ts +7 -7
  11. package/src/adapters/kiro/payload.ts +17 -3
  12. package/src/adapters/kiro/reasoning.ts +70 -7
  13. package/src/adapters/kiro/stream.ts +8 -2
  14. package/src/adapters/kiro/wire.ts +2 -1
  15. package/src/adapters/kiro-events.ts +21 -13
  16. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  17. package/src/adapters/openai-chat/tool-schema.ts +25 -7
  18. package/src/adapters/openai-chat.ts +8 -8
  19. package/src/adapters/openai-responses/passthrough.ts +32 -1
  20. package/src/bridge/errors.ts +26 -2
  21. package/src/bridge/response-json.ts +7 -1
  22. package/src/bridge/sse.ts +19 -1
  23. package/src/claude/desktop-profile.ts +66 -9
  24. package/src/claude/outbound.ts +18 -0
  25. package/src/cli/account-main.ts +1 -1
  26. package/src/cli/capabilities.ts +2 -2
  27. package/src/cli/combo.ts +10 -1
  28. package/src/cli/index.ts +48 -5
  29. package/src/cli/registry.ts +2 -1
  30. package/src/cli/system-command.ts +4 -4
  31. package/src/clients/config-export.ts +7 -3
  32. package/src/codex/account-label.ts +14 -3
  33. package/src/codex/account-store.ts +113 -26
  34. package/src/codex/account-usability.ts +21 -0
  35. package/src/codex/auth-api/login-flow.ts +14 -2
  36. package/src/codex/auth-api/reset-credit-service.ts +11 -2
  37. package/src/codex/auth-context.ts +157 -7
  38. package/src/codex/catalog/aggregation.ts +80 -1
  39. package/src/codex/catalog/model-visibility.ts +1 -0
  40. package/src/codex/catalog/remote.ts +30 -0
  41. package/src/codex/catalog/retained-sync.ts +9 -1
  42. package/src/codex/catalog/routed-gather.ts +38 -1
  43. package/src/codex/cli-install-provenance.ts +7 -1
  44. package/src/codex/convergence.ts +7 -2
  45. package/src/codex/desktop-app/types.ts +11 -2
  46. package/src/codex/desktop-app/windows.ts +5 -5
  47. package/src/codex/inject/restore.ts +29 -2
  48. package/src/codex/inject.ts +9 -9
  49. package/src/codex/model-entitlements.ts +152 -15
  50. package/src/codex/pool-refresh-backoff.ts +12 -3
  51. package/src/codex/quota-rejection.ts +104 -15
  52. package/src/codex/routing/cache-affinity.ts +70 -0
  53. package/src/codex/routing/cooldown-math.ts +10 -0
  54. package/src/codex/routing/selection.ts +79 -2
  55. package/src/codex/routing/thread-affinity.ts +50 -2
  56. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  57. package/src/codex/routing.ts +29 -49
  58. package/src/codex/warmup.ts +1 -1
  59. package/src/combos/failover.ts +85 -0
  60. package/src/combos/request.ts +17 -10
  61. package/src/combos/types.ts +23 -2
  62. package/src/config/pending-teardown.ts +31 -0
  63. package/src/generated/compatibility-version.json +163 -135
  64. package/src/images/loop.ts +1 -1
  65. package/src/lib/errors.ts +17 -0
  66. package/src/lib/request-execution-budget.ts +147 -21
  67. package/src/lib/spend-reservation-ledger.ts +18 -0
  68. package/src/lib/state-store-registrations.ts +6 -2
  69. package/src/lib/test-home-guard.ts +85 -1
  70. package/src/lib/upstream-retry.ts +77 -10
  71. package/src/lib/windows-elevation.ts +76 -14
  72. package/src/oauth/index.ts +2 -2
  73. package/src/oauth/key-providers.ts +2 -2
  74. package/src/providers/kiro-models.ts +4 -3
  75. package/src/providers/label.ts +19 -1
  76. package/src/providers/model-discovery.ts +16 -0
  77. package/src/providers/registry/entries-core.ts +7 -0
  78. package/src/providers/registry/entries-extended.ts +9 -0
  79. package/src/providers/registry/model-seeds.ts +4 -0
  80. package/src/responses/reasoning-envelope.ts +6 -3
  81. package/src/routing/identity-domains.ts +21 -14
  82. package/src/routing/probe-lease.ts +103 -1
  83. package/src/server/chat-completions.ts +3 -1
  84. package/src/server/chat-native.ts +37 -9
  85. package/src/server/index/live-sideband.ts +37 -1
  86. package/src/server/index/websocket-handler.ts +6 -2
  87. package/src/server/index.ts +5 -5
  88. package/src/server/inspection-tee.ts +107 -0
  89. package/src/server/live.ts +46 -1
  90. package/src/server/management/combo-routes.ts +10 -1
  91. package/src/server/relay-eager.ts +2 -0
  92. package/src/server/relay.ts +14 -19
  93. package/src/server/request-log.ts +127 -3
  94. package/src/server/response-log-body.ts +153 -0
  95. package/src/server/responses/account-change-state.ts +74 -0
  96. package/src/server/responses/adapter-continuation.ts +33 -7
  97. package/src/server/responses/adapter-delivery.ts +5 -11
  98. package/src/server/responses/adapter-dispatch.ts +84 -13
  99. package/src/server/responses/codex-ws-wire.ts +5 -0
  100. package/src/server/responses/collaboration.ts +74 -4
  101. package/src/server/responses/combo-session-recall.ts +68 -8
  102. package/src/server/responses/compact.ts +54 -13
  103. package/src/server/responses/core-auth.ts +2 -0
  104. package/src/server/responses/core-codex-account.ts +51 -3
  105. package/src/server/responses/core-combo.ts +103 -23
  106. package/src/server/responses/core-errors.ts +18 -0
  107. package/src/server/responses/core-replay.ts +105 -32
  108. package/src/server/responses/core.ts +3 -3
  109. package/src/server/responses/encrypted-payload.ts +0 -1
  110. package/src/server/responses/input-admission.ts +126 -6
  111. package/src/server/responses/passthrough-delivery.ts +19 -6
  112. package/src/server/responses/passthrough-dispatch.ts +28 -10
  113. package/src/server/responses/passthrough-error.ts +38 -2
  114. package/src/server/responses/request-prepare.ts +132 -22
  115. package/src/server/responses/request-send-budget.ts +97 -2
  116. package/src/server/responses/request-spend.ts +147 -0
  117. package/src/server/responses/request-transport.ts +62 -3
  118. package/src/server/responses/run-turn-execution.ts +59 -31
  119. package/src/server/responses/sidecar-execution.ts +7 -13
  120. package/src/server/responses/terminal-guard.ts +65 -4
  121. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  122. package/src/service/windows-ops.ts +210 -16
  123. package/src/service/windows-scheduler.ts +28 -21
  124. package/src/service.ts +1 -1
  125. package/src/types/config.ts +4 -1
  126. package/src/types/request.ts +8 -5
  127. package/src/types/tools.ts +24 -0
  128. package/src/types.ts +2 -0
  129. package/src/update/index.ts +10 -0
  130. package/src/update/stop-contract.d.mts +1 -0
  131. package/src/update/stop-contract.mjs +19 -0
  132. package/src/update/stop-decision.d.mts +1 -1
  133. package/src/update/stop-decision.mjs +12 -3
  134. package/src/usage/log.ts +1 -1
  135. package/src/vision/anthropic-describe.ts +1 -1
  136. package/src/vision/describe.ts +5 -5
  137. package/src/web-search/anthropic-executor.ts +1 -1
  138. package/src/web-search/exa-executor.ts +1 -1
  139. package/src/web-search/executor.ts +1 -1
  140. package/src/web-search/gemini-executor.ts +1 -1
  141. package/src/web-search/loop.ts +1 -1
  142. package/src/web-search/ollama-executor.ts +1 -1
  143. package/src/web-search/parse.ts +67 -14
  144. package/src/web-search/passthrough-bridge.ts +64 -31
  145. package/src/web-search/xai-executor.ts +1 -1
@@ -5,6 +5,7 @@ import {
5
5
  } from "../quota";
6
6
  import { isThirtyDayOnlyCodexPlan } from "../plan";
7
7
  import type { CodexQuotaScope } from "./health-store";
8
+ import type { TransientProbeGrant } from "./thread-affinity";
8
9
 
9
10
  export const CODEX_DEFAULT_QUOTA_COOLDOWN_MS = 60_000;
10
11
  export const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000;
@@ -78,6 +79,15 @@ export type CodexUpstreamOutcomeMeta = {
78
79
  probeLeaseId?: string;
79
80
  /** Scope of `probeLeaseId` when it was granted against a model-scoped cooldown. */
80
81
  probeQuotaScope?: CodexQuotaScope;
82
+ /**
83
+ * The half-open TRANSIENT-HOLD probe this request was granted, when it was the one request
84
+ * admitted to test a held account (#4701). A different lease to `probeLeaseId` above, in a
85
+ * different domain: that one governs a quota cooldown, this one governs a 5xx hold. The two
86
+ * are mutually exclusive by construction -- `isTransientOnlyAffinityBlock` refuses to
87
+ * recognise a transient hold on an account that carries quota health -- so a request never
88
+ * holds both and never pays two recovery permits for one send.
89
+ */
90
+ transientProbe?: TransientProbeGrant;
81
91
  /**
82
92
  * Already-chosen alternate for same-request 429 retry. When set, promotion
83
93
  * reuses this account instead of calling {@link pickAlternateCodexAccount}
@@ -123,6 +123,39 @@ export function codexAccountBlockReason(
123
123
  return undefined;
124
124
  }
125
125
 
126
+ /**
127
+ * Drop accounts a confirmed roster says cannot serve this model, unless that leaves nothing.
128
+ *
129
+ * The restore-on-empty is the whole safety argument, not a defensive afterthought. Roster
130
+ * evidence can be wrong in the direction that matters: a shard that has not caught up reports a
131
+ * denial for a model the account genuinely owns, and #3022 is what happens when absence is
132
+ * allowed to remove a model outright. Because this can only ever return a non-empty subset of a
133
+ * list the caller already computed, no pool that would have found a working account can be left
134
+ * without one — the worst case is the selection that ships today.
135
+ *
136
+ * It is an ordering rule rather than an eligibility one for the same reason. Nothing below
137
+ * reports `model_not_entitled`, nothing refuses before dispatch, and the existing bounded
138
+ * alternate-account retry on an exact unsupported-model 400 stays exactly where it is as the
139
+ * safety net. This only stops the pool from CHOOSING an account that has already told us it
140
+ * cannot serve the model (#4768).
141
+ *
142
+ * An operator's manual pin is never dropped. Roster evidence orders the pool's own discretion;
143
+ * it does not overrule an explicit human choice, and removing the pinned account here would do
144
+ * more than demote it -- `selectPriorityTier` reads the pin to lower the tier ceiling, so a pin
145
+ * filtered out beforehand stops acting as a ceiling at all and silently re-enables tiers the
146
+ * operator had excluded. An operator who pins an account upstream will refuse still gets the
147
+ * alternate-account retry; what they do not get is the pool quietly deciding they were wrong.
148
+ */
149
+ export function withoutModelDeniedAccounts(
150
+ ids: readonly string[],
151
+ denied: ReadonlySet<string> | undefined,
152
+ pinned?: string,
153
+ ): readonly string[] {
154
+ if (denied === undefined || ids.length === 0) return ids;
155
+ const remaining = ids.filter(id => !denied.has(id) || id === pinned);
156
+ return remaining.length > 0 ? remaining : ids;
157
+ }
158
+
126
159
  export function getEligiblePoolAccounts(
127
160
  config: OcxConfig,
128
161
  excludeId?: string,
@@ -168,11 +201,16 @@ export function getEligiblePoolAccounts(
168
201
  // Single choke point for selection order: every strategy, failover, and preview
169
202
  // reaches the pool through here, so tiering applies once rather than per picker.
170
203
  // Eligibility above is unchanged — this only narrows an already-eligible list.
204
+ //
205
+ // Model entitlement is applied BEFORE the priority tier, because a tier is a quota-ordering
206
+ // question and an account that cannot serve the model at all should not be the reason a tier
207
+ // is selected. Both steps narrow an already-eligible list and neither can empty it.
208
+ const pinned = pinnedCodexAccountId(config);
171
209
  return selectPriorityTier(
172
- ids,
210
+ withoutModelDeniedAccounts(ids, selectionOptions?.deniedModelAccountIds, pinned),
173
211
  codexAccountPriorityLookup(config),
174
212
  id => hasCodexQuotaHeadroom(config, id, selectionOptions, now),
175
- pinnedCodexAccountId(config),
213
+ pinned,
176
214
  );
177
215
  }
178
216
 
@@ -563,6 +601,45 @@ export function isUnknownUsage(usage: number): boolean {
563
601
  return usage >= CODEX_UNKNOWN_USAGE_SCORE;
564
602
  }
565
603
 
604
+ /**
605
+ * Correct a shared cursor that names an account this model's own roster denies (#4768).
606
+ *
607
+ * {@link getEligiblePoolAccounts} is not the only door into selection. An account that is already
608
+ * ACTIVE is served straight from {@link isCodexAccountSelectable} and never passes through the
609
+ * eligible list, so ordering that list alone left the exact case the issue reports: once the Free
610
+ * account becomes the cursor, every Sol/Astra request keeps going to it and keeps taking the
611
+ * upstream unsupported-model 400. {@link pickPriorityPreemption} does not cover it either -- it
612
+ * refuses to move toward a tier that does not strictly outrank the active one, which is the usual
613
+ * shape here.
614
+ *
615
+ * Three properties keep this inside "order the already-eligible set" rather than widening it.
616
+ * It admits nothing: the replacement comes from {@link getEligiblePoolAccounts}, so every
617
+ * eligibility guard has already passed on it. It cannot fail: with no entitled alternative the
618
+ * active account is returned unchanged, so this can never turn a served request into `none`.
619
+ * And it changes nothing without evidence: absent `deniedModelAccountIds`, or an active account
620
+ * nobody denied, it is the identity function.
621
+ *
622
+ * The caller must NOT persist the result. This is one request's correction for one model, in the
623
+ * same spirit as a model detour; the operator's cursor is theirs. A pinned active account is
624
+ * exempt outright, for the reason {@link withoutModelDeniedAccounts} gives.
625
+ */
626
+ export function preferModelEntitledAccount(
627
+ config: OcxConfig,
628
+ active: string,
629
+ now: number,
630
+ quotaScope?: CodexQuotaScope,
631
+ selectionOptions?: CodexAccountUsabilityOptions,
632
+ ): string {
633
+ const denied = selectionOptions?.deniedModelAccountIds;
634
+ if (denied === undefined || !denied.has(active)) return active;
635
+ if (pinnedCodexAccountId(config) === active) return active;
636
+ // The eligible list restores denied members when filtering would empty it, so re-filter here:
637
+ // moving from one denied account to another buys nothing and costs the warm prefix.
638
+ const entitled = getEligiblePoolAccounts(config, active, now, quotaScope, selectionOptions)
639
+ .filter(id => !denied.has(id));
640
+ return pickLowestUsageAmong(config, entitled, selectionOptions, now) ?? active;
641
+ }
642
+
566
643
  /**
567
644
  * Move an unbound request back up when a higher tier regains headroom — the
568
645
  * weekly-reset case. Returns null when nothing should change.
@@ -4,6 +4,8 @@ import { retainedUtf8Bytes } from "../../lib/admission";
4
4
  import { clearAllCodexPoolRefreshFailures } from "../pool-refresh-backoff";
5
5
  import type { CodexThreadLineage } from "../lineage";
6
6
  import type { CodexQuotaScope } from "./health-store";
7
+ import type { TransientProbeLease } from "../../routing/probe-lease";
8
+ import { clearPoolRecoveryState } from "../../routing/probe-lease";
7
9
 
8
10
  export type ThreadAffinityEntry = {
9
11
  accountId: string;
@@ -25,10 +27,51 @@ export type ThreadAffinityEntry = {
25
27
  transientDetourAccountId?: string;
26
28
  };
27
29
 
30
+ /**
31
+ * The half-open trial this request was granted against its own held account (#4701).
32
+ *
33
+ * The lease alone is not enough to settle safely. Its generation is an account-local PROBE
34
+ * epoch, while {@link ThreadAffinityEntry.generation} is the selected CREDENTIAL generation,
35
+ * and the two move independently: a credential replaced while the probe is in flight leaves
36
+ * the probe epoch untouched, so a settle that checked only the lease would write an answer
37
+ * about a credential that no longer exists. Capturing the affinity generation here is what
38
+ * lets the settle refuse that case.
39
+ */
40
+ export interface TransientProbeGrant {
41
+ readonly lease: TransientProbeLease;
42
+ /** Credential generation the binding held when the probe was granted. */
43
+ readonly affinityGeneration: number;
44
+ }
45
+
28
46
  export type CodexThreadResolution =
29
- | { status: "selected"; accountId: string; affinity?: CodexAffinityDecision }
47
+ | {
48
+ status: "selected";
49
+ accountId: string;
50
+ affinity?: CodexAffinityDecision;
51
+ /**
52
+ * Present only when this request is the single admitted probe of a held account. The
53
+ * holder owes the lease a settle or a release; nothing else may act on it.
54
+ */
55
+ transientProbe?: TransientProbeGrant;
56
+ }
30
57
  | { status: "none"; affinity?: CodexAffinityDecision }
31
- | { status: "expired"; accountId: string; affinity?: CodexAffinityDecision };
58
+ | { status: "expired"; accountId: string; affinity?: CodexAffinityDecision }
59
+ /**
60
+ * Every candidate for this binding is held and no detour is left, so there is no account
61
+ * this request may be sent to. Distinct from `none`: the binding is REMEMBERED and the
62
+ * caller is told when to come back, rather than being handed the account already known to
63
+ * be failing. Returning `selected` here is the "must not send, sends anyway" defect
64
+ * (#4701); the caller must refuse before any upstream I/O.
65
+ */
66
+ | {
67
+ status: "withheld";
68
+ accountId: string;
69
+ /** Earliest moment a recovery dispatch could be admitted. Always strictly in the future. */
70
+ retryAt: number;
71
+ /** The remembered detour, when one exists but is itself unusable right now. */
72
+ detourAccountId?: string;
73
+ affinity?: CodexAffinityDecision;
74
+ };
32
75
 
33
76
  /** What happened to this thread's binding on this request (#4546). */
34
77
  export type CodexAffinityMove =
@@ -163,6 +206,11 @@ export function clearThreadAccountMap(): void {
163
206
  // A refresh cooldown is per-account runtime state learned alongside these bindings. Leaving it
164
207
  // behind here keeps an account out of selection after the roster it belonged to is gone.
165
208
  clearAllCodexPoolRefreshFailures();
209
+ // Same argument for recovery state (#4701): probe pacing is keyed on account ids this reset
210
+ // may have just retired, and the recovery window counts sends made by the roster that is
211
+ // going away. A held account nobody may probe because of a lease issued against the previous
212
+ // roster is a recovery that never starts.
213
+ clearPoolRecoveryState();
166
214
  conversationStateIssuerMap.clear();
167
215
  }
168
216
 
@@ -0,0 +1,141 @@
1
+ import { isCodexAccountGenerationLive } from "../account-store";
2
+ // From `../account-id`, which declares the constant and imports nothing, rather than from
3
+ // `../main-account`, which re-exports it and sits inside the routing/account-lifecycle import
4
+ // cycle. Neither reference here runs at module load, but a leaf import keeps this module out of
5
+ // that cycle entirely instead of relying on that staying true.
6
+ import { MAIN_CODEX_ACCOUNT_ID } from "../account-id";
7
+ import {
8
+ invalidateTransientProbe,
9
+ releaseTransientProbe,
10
+ resolveHeldAccountDispatch,
11
+ settleTransientProbe,
12
+ } from "../../routing/probe-lease";
13
+ import {
14
+ CODEX_TRANSIENT_AFFINITY_HOLD_MS,
15
+ type CodexThreadResolution,
16
+ type ThreadAffinityEntry,
17
+ type TransientProbeGrant,
18
+ } from "./thread-affinity";
19
+ import type { CodexUpstreamOutcomeClass, CodexUpstreamOutcomeMeta } from "./cooldown-math";
20
+
21
+ /**
22
+ * What a request bound to a HELD account may actually do, and how its trial ends (#4701).
23
+ *
24
+ * The transient hold (#4546) keeps a thread's binding while its own account serves a 5xx
25
+ * streak and detours the request to a healthy sibling. Both of the selector's hold branches
26
+ * used to end the same way when no sibling could take it: they returned the held account as
27
+ * `selected`, and the caller sent at an account already known to be failing. Under a
28
+ * provider-wide 503 that is every bound request at once -- the amplification the hold exists
29
+ * to prevent rather than cause.
30
+ *
31
+ * This module is the seam between the selector and the bounded answer in
32
+ * `src/routing/probe-lease.ts`. It is separate from `./probe-lease` in this same directory,
33
+ * which is the unrelated QUOTA-COOLDOWN lease; the two govern different domains and must never
34
+ * settle each other's probe.
35
+ */
36
+
37
+ /** Has a held binding waited longer than a transient failure can reasonably explain? */
38
+ export function isTransientHoldExpired(entry: ThreadAffinityEntry, now: number): boolean {
39
+ return entry.transientHoldSince !== undefined
40
+ && now - entry.transientHoldSince > CODEX_TRANSIENT_AFFINITY_HOLD_MS;
41
+ }
42
+
43
+ /**
44
+ * Where a request bound to a held account goes this turn.
45
+ *
46
+ * {@link resolveHeldAccountDispatch} bounds the answer: one probe tests the held account, and a
47
+ * caller with nowhere else to go is WITHHELD and told when to come back rather than sent at the
48
+ * failure.
49
+ *
50
+ * A usable detour is taken BEFORE that resolver is consulted, which inverts its own probe-first
51
+ * ordering. Deliberately: a healthy sibling is always a better answer for a live request than an
52
+ * account carrying a failure streak, and turning the first request after a hold into the trial
53
+ * would spend a real user's turn on it. The ordering is not what #4701 bounds -- the defect is
54
+ * the third answer the selector used to give, "send at the failing account anyway", and that is
55
+ * reached only when no detour exists. Recovery is still discovered there, because that is
56
+ * exactly the case where nothing else can find out.
57
+ *
58
+ * The caller has already committed `transientHoldSince`/`lastUsedAt`; this decides only where
59
+ * the request goes. A withheld answer deliberately leaves `transientDetourAccountId` alone:
60
+ * being unable to send right now says nothing about which sibling was serving this thread.
61
+ */
62
+ export function resolveTransientHoldDispatch(
63
+ entry: ThreadAffinityEntry,
64
+ detour: string | null,
65
+ now: number,
66
+ ): CodexThreadResolution {
67
+ if (detour !== null && detour !== entry.accountId) {
68
+ entry.transientDetourAccountId = detour;
69
+ // Deliberately no promotion and no rebind: this is one request routing around a blip, not
70
+ // the pool deciding where the conversation now lives.
71
+ return { status: "selected", accountId: detour, affinity: { move: "detour", reason: "transient" } };
72
+ }
73
+ const dispatch = resolveHeldAccountDispatch({ boundAccountId: entry.accountId, now });
74
+ if (dispatch.kind === "probe") {
75
+ return {
76
+ status: "selected",
77
+ accountId: entry.accountId,
78
+ affinity: { move: "held", reason: "transient" },
79
+ // The credential generation travels with the lease so a settle can refuse an answer about
80
+ // a credential this binding no longer has. See {@link TransientProbeGrant}.
81
+ transientProbe: { lease: dispatch.lease, affinityGeneration: entry.generation },
82
+ };
83
+ }
84
+ if (dispatch.kind === "withheld") {
85
+ return {
86
+ status: "withheld",
87
+ accountId: dispatch.boundAccountId,
88
+ retryAt: dispatch.retryAt,
89
+ // The remembered sibling, when there is one. It is unusable right now -- that is why this
90
+ // request is refused -- but it is what has been serving this thread, and a refusal that
91
+ // dropped it would make the next resolve re-pick cold.
92
+ ...(entry.transientDetourAccountId !== undefined
93
+ ? { detourAccountId: entry.transientDetourAccountId }
94
+ : {}),
95
+ affinity: { move: "held", reason: "transient" },
96
+ };
97
+ }
98
+ // Unreachable: no detour was handed in, so the resolver has none to hand back. Kept total
99
+ // rather than cast away, because the cost of being wrong here is a send at a failing account.
100
+ entry.transientDetourAccountId = dispatch.accountId;
101
+ return { status: "selected", accountId: dispatch.accountId, affinity: { move: "detour", reason: "transient" } };
102
+ }
103
+
104
+ /** Does the credential a probe was granted against still exist at that generation? */
105
+ function transientProbeCredentialLive(accountId: string, generation: number): boolean {
106
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) return generation === 0;
107
+ return isCodexAccountGenerationLive(accountId, generation);
108
+ }
109
+
110
+ /**
111
+ * Conclude the half-open recovery probe this request was holding.
112
+ *
113
+ * Three answers, because three things can be true of a probe that just ended:
114
+ *
115
+ * - The credential moved under it. Its result describes an identity the binding no longer has,
116
+ * so the epoch is BURNED instead of settled -- invalidating makes every outstanding lease on
117
+ * this account stale at once, which is what stops a late answer from reviving a dead account.
118
+ * - The answer says nothing about the account. A 3xx, a 400, or an unclassifiable status is the
119
+ * request's problem, not the account's, so the lease is handed back unspent and the next
120
+ * request may run a real trial instead of waiting out a recovery nobody observed.
121
+ * - Otherwise it is evidence: success means recovered, everything else means still failing.
122
+ *
123
+ * A no-op when this request held no trial, so the outcome recorder calls it unconditionally.
124
+ */
125
+ export function settleTransientProbeForOutcome(
126
+ accountId: string,
127
+ meta: Pick<CodexUpstreamOutcomeMeta, "transientProbe" | "now">,
128
+ outcomeClass: CodexUpstreamOutcomeClass,
129
+ ): void {
130
+ const grant: TransientProbeGrant | undefined = meta.transientProbe;
131
+ if (!grant) return;
132
+ if (!transientProbeCredentialLive(accountId, grant.affinityGeneration)) {
133
+ invalidateTransientProbe(accountId);
134
+ return;
135
+ }
136
+ if (outcomeClass === "neutral" || outcomeClass === "caller" || outcomeClass === "unknown") {
137
+ releaseTransientProbe(grant.lease);
138
+ return;
139
+ }
140
+ settleTransientProbe(grant.lease, outcomeClass === "success" ? "recovered" : "failed", meta.now ?? Date.now());
141
+ }
@@ -54,6 +54,13 @@ import {
54
54
  type CodexUpstreamHealth,
55
55
  } from "./routing/health-store";
56
56
  import { ownsProbeLease, probeMayClearCooldown, withProbeLeaseReleased } from "./routing/probe-lease";
57
+ // `./routing/probe-lease` above is the QUOTA-COOLDOWN lease; the module below owns the
58
+ // unrelated TRANSIENT-HOLD trial and the pool-wide recovery bound above it (#4701).
59
+ import {
60
+ isTransientHoldExpired,
61
+ resolveTransientHoldDispatch,
62
+ settleTransientProbeForOutcome,
63
+ } from "./routing/transient-hold-dispatch";
57
64
  import {
58
65
  adoptLegacyLineageAffinity,
59
66
  affinityAfterRelease,
@@ -85,7 +92,6 @@ import {
85
92
  getPoolAccountPlanForSelection,
86
93
  hasCodexQuotaHeadroom,
87
94
  isCodexAccountPlanExcluded,
88
- isCacheAffinityEnabled,
89
95
  isCodexAccountSelectable,
90
96
  isHealthySharedCodexSelection,
91
97
  isUnknownUsage,
@@ -96,11 +102,13 @@ import {
96
102
  pickPriorityPreemption,
97
103
  pickResetFirstCodexAccount,
98
104
  pickUnboundStrategyAccount,
105
+ preferModelEntitledAccount,
99
106
  sharedStateSelectionOptions,
100
107
  strategySelectionOptionsForModelDetour,
101
108
  shouldFailover,
102
109
  peekAlternateCodexAccount,
103
110
  } from "./routing/selection";
111
+ import { mayRebindAffinityForQuota } from "./routing/cache-affinity";
104
112
  import {
105
113
  clearAllManualPreferences,
106
114
  consumeManualPreference,
@@ -181,6 +189,7 @@ export type {
181
189
  CodexAffinityMove,
182
190
  CodexAffinityReason,
183
191
  CodexAffinityDecision,
192
+ TransientProbeGrant,
184
193
  } from "./routing/thread-affinity";
185
194
  export {
186
195
  isCodexAccountPlanExcluded,
@@ -276,12 +285,6 @@ function isTransientOnlyAffinityBlock(
276
285
  || isCodexPoolRefreshCooling(entry.accountId, now);
277
286
  }
278
287
 
279
- /** Has a held binding waited longer than a transient failure can reasonably explain? */
280
- function isTransientHoldExpired(entry: ThreadAffinityEntry, now: number): boolean {
281
- return entry.transientHoldSince !== undefined
282
- && now - entry.transientHoldSince > CODEX_TRANSIENT_AFFINITY_HOLD_MS;
283
- }
284
-
285
288
  /**
286
289
  * Is every pin this thread holds on the failing account past its hold window?
287
290
  *
@@ -477,6 +480,8 @@ export function resolveCodexAccountForThread(
477
480
  lineage?: CodexThreadLineage,
478
481
  ): string | null {
479
482
  const resolution = resolveCodexAccountForThreadDetailed(threadId, config, now, quotaScope, undefined, undefined, lineage);
483
+ // A WITHHELD dispatch is deliberately not an account here: this wrapper cannot carry a retry
484
+ // time, and answering with the held account is the send the hold prevents. Fails closed.
480
485
  return resolution.status === "selected" ? resolution.accountId : null;
481
486
  }
482
487
 
@@ -583,34 +588,6 @@ function previewReusableAffinityAccount(
583
588
  return entry.accountId;
584
589
  }
585
590
 
586
- /**
587
- * May a LIVE binding be moved for quota reasons?
588
- *
589
- * Default: no. The bar is genuine exhaustion, because moving a bound conversation discards
590
- * the prompt cache warmed on its account and a threshold crossing is a hint that the account
591
- * is getting busy rather than evidence it cannot serve (#4546). Deliberately NOT
592
- * `hasCodexQuotaHeadroom`, which reads `usage < autoSwitchThreshold` and would reproduce the
593
- * old rule under a new name.
594
- *
595
- * With `pool.cacheAffinity: false` the historical rule comes back: a crossing of
596
- * `autoSwitchThreshold` is enough. That is capacity-first routing, and an operator who wants
597
- * it keeps it -- but it is no longer what an install gets by never having heard of the flag.
598
- */
599
- function mayRebindAffinityForQuota(
600
- config: OcxConfig,
601
- accountId: string,
602
- usage: number,
603
- threshold: number,
604
- selectionOptions?: CodexAccountUsabilityOptions,
605
- ): boolean {
606
- const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold;
607
- if (!isCacheAffinityEnabled(config)) return overThreshold;
608
- // The usable half is already guaranteed by both callers, which gate on
609
- // isCodexAccountSelectable; kept explicit so the predicate reads correctly on its own.
610
- return !isCodexAccountUsable(config, accountId, selectionOptions)
611
- || (!isUnknownUsage(usage) && usage >= 100);
612
- }
613
-
614
591
  /** Reset ordering may move a binding only under the existing cache-affinity release policy. */
615
592
  function resetFirstAffinityReplacement(
616
593
  entry: ThreadAffinityEntry,
@@ -843,6 +820,9 @@ export function previewCodexAccountForRequest(
843
820
  const best = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions);
844
821
  if (best) active = best;
845
822
  }
823
+ // Same correction resolve applies, for the same reason: preview must name the account the
824
+ // request will actually use, or subagent fallback scores a model against the wrong one.
825
+ active = preferModelEntitledAccount(config, active, now, quotaScope, selectionOptions);
846
826
  if (!isCodexAccountUsable(config, active, selectionOptions)) {
847
827
  return hasConfiguredPoolAccount(config, active, selectionOptions) ? active : null;
848
828
  }
@@ -936,15 +916,12 @@ export function resolveCodexAccountForThreadDetailed(
936
916
  const lane = transientDetourAccount(config, detourEntry, now, quotaScope, selectionOptions);
937
917
  detourEntry.transientHoldSince ??= now;
938
918
  detourEntry.lastUsedAt = now;
939
- if (lane !== null && lane !== detourEntry.accountId) {
940
- detourEntry.transientDetourAccountId = lane;
941
- return { status: "selected", accountId: lane, affinity: { move: "detour", reason: "transient" } };
942
- }
943
919
  // A provider-wide outage soft-avoids every sibling, so there is nowhere to detour.
944
920
  // That is a statement about where this request can go, not about who owns the
945
921
  // conversation: dropping the pin here would rebuild the cold prefix elsewhere for
946
- // exactly the failure mode the hold exists to survive.
947
- return { status: "selected", accountId: detourEntry.accountId, affinity: { move: "held", reason: "transient" } };
922
+ // exactly the failure the hold exists to survive -- nor a licence to send at the
923
+ // failing account, which is what the dispatch resolver bounds (#4701).
924
+ return resolveTransientHoldDispatch(detourEntry, lane, now);
948
925
  }
949
926
  // Detour expiry or invalidation must not expire the ordinary task. Drop only
950
927
  // this model lane and select from ordinary/shared state below.
@@ -1018,16 +995,11 @@ export function resolveCodexAccountForThreadDetailed(
1018
995
  const detour = transientDetourAccount(config, entry, now, quotaScope, selectionOptions);
1019
996
  entry.transientHoldSince ??= now;
1020
997
  entry.lastUsedAt = now;
1021
- if (detour !== null && detour !== entry.accountId) {
1022
- entry.transientDetourAccountId = detour;
1023
- // Deliberately no promoteActiveCodexAccount and no rebind: this is one request routing
1024
- // around a blip, not the pool deciding where the conversation now lives.
1025
- return { status: "selected", accountId: detour, affinity: { move: "detour", reason: "transient" } };
1026
- }
1027
998
  // No sibling can take it either -- the usual shape of a provider-wide 503. The binding
1028
999
  // survives: "cannot send right now" and "forget which account owns this conversation"
1029
- // are different answers, and conflating them is what the hold was added to stop.
1030
- return { status: "selected", accountId: entry.accountId, affinity: { move: "held", reason: "transient" } };
1000
+ // are different answers. So is the third answer this used to give -- "send at the
1001
+ // failing account" -- now a bounded probe or a typed refusal (#4701).
1002
+ return resolveTransientHoldDispatch(entry, detour, now);
1031
1003
  }
1032
1004
  // A model-only exclusion does not invalidate the shared task binding. Health,
1033
1005
  // generation, pause, cooldown, and failure evidence still retire it normally.
@@ -1241,6 +1213,10 @@ export function resolveCodexAccountForThreadDetailed(
1241
1213
  selectionOptions,
1242
1214
  !preserveSharedSelectionForModelDetour,
1243
1215
  );
1216
+ // The shared cursor can name an account whose own roster denies this model, and an active
1217
+ // account never passes through the eligible list. Correct it for THIS request only -- nothing
1218
+ // is persisted -- and only toward an account eligibility already admitted (#4768).
1219
+ active = preferModelEntitledAccount(config, active, now, quotaScope, selectionOptions);
1244
1220
  if (!isCodexAccountUsable(config, active, selectionOptions)) {
1245
1221
  return hasConfiguredPoolAccount(config, active, selectionOptions)
1246
1222
  ? { status: "selected", accountId: active, affinity: affinityAfterRelease(threadId, releaseReason) }
@@ -1277,6 +1253,10 @@ export function recordCodexUpstreamOutcome(
1277
1253
  recordUpstreamHostFailure(meta.hostKey, { code: meta.lastFailureCode, now: meta.now ?? Date.now() });
1278
1254
  }
1279
1255
  if (!accountId) return;
1256
+ // Conclude the half-open recovery trial BEFORE the admissibility gate below (#4701): an
1257
+ // outcome that gate drops still ended this request, and a lease nobody hands back leaves the
1258
+ // next trial waiting out its deadline. The settle carries its own fences, so this is safe here.
1259
+ settleTransientProbeForOutcome(accountId, meta, classifyCodexUpstreamOutcome(outcome, meta.denial));
1280
1260
  const writerGeneration = meta.writerGeneration ?? captureConfigGeneration();
1281
1261
  if (!isHealthAccountAdmissible(accountId, writerGeneration)) return;
1282
1262
  const now = meta.now ?? Date.now();
@@ -44,7 +44,7 @@ async function drainErrorBody(res: Response, signal: AbortSignal): Promise<void>
44
44
  fatalUtf8: true,
45
45
  });
46
46
  } catch (error) {
47
- if (signal.aborted) {
47
+ if (signal.aborted && res.status !== 429) {
48
48
  throw new CodexWarmupError("transport", "Codex warmup request failed", {
49
49
  cause: error,
50
50
  });
@@ -401,11 +401,15 @@ export function comboFailureCooldownScope(
401
401
  ): ComboFailureCooldownScope {
402
402
  const code = normalizedFailureCode(options?.code);
403
403
  // Request-shape refusals first: an oversized request must not cool a healthy target.
404
+ // A native transport can surface a zero-output model overflow as a generic
405
+ // upstream_server_error carrying precise context-window prose, so consult the bounded
406
+ // message classifier too: that target is healthy, the turn was simply too large for it.
404
407
  if (
405
408
  status === 413
406
409
  || REQUEST_SHAPE_FAILURE_CODES.has(code)
407
410
  || isRequestLocalFreePromptCap(status, message, options?.code)
408
411
  || isProviderTargetContextOverflow(status, message, options?.code)
412
+ || isDefiniteContextOverflow(status, message)
409
413
  || isRequestLocalTargetIncompatibility(status, message, options?.code)
410
414
  ) return "none";
411
415
  if (isProviderScopedQuotaCap(status, message, options?.code)) return "provider";
@@ -451,6 +455,74 @@ function isProviderTargetContextOverflow(
451
455
  && /\bprompt\s+\d+\s*>\s*\d+\s+maximum context length\b/i.test(message);
452
456
  }
453
457
 
458
+ /** A status can carry a verdict about the REQUEST; 401/403/429 speak about the credential. */
459
+ const CONTEXT_VERDICT_STATUSES: ReadonlySet<number> = new Set([400, 413, 422]);
460
+
461
+ /**
462
+ * Phrases a provider emits when the INPUT does not fit this model's context window. Matched
463
+ * against the innermost provider message only, so an unrelated refusal that merely quotes one
464
+ * of these tokens in a code field cannot authorize a replay.
465
+ */
466
+ const DEFINITE_CONTEXT_OVERFLOW_PHRASES = [
467
+ "exceeds the context window",
468
+ "exceed the context window",
469
+ "context window exceeded",
470
+ "context length exceeded",
471
+ "maximum context length",
472
+ "maximum context window",
473
+ "too many tokens",
474
+ ];
475
+
476
+ /** Wrapper envelopes unwrapped before the leaf message is read. */
477
+ const MAX_CONTEXT_OVERFLOW_ENVELOPES = 4;
478
+
479
+ function isDefiniteContextOverflowMessage(text: string): boolean {
480
+ const normalized = text.toLowerCase();
481
+ return normalized === "context_length_exceeded"
482
+ || DEFINITE_CONTEXT_OVERFLOW_PHRASES.some(phrase => normalized.includes(phrase));
483
+ }
484
+
485
+ /**
486
+ * Confirm a context overflow from the provider MESSAGE rather than from a code token that
487
+ * merely appears somewhere in the envelope. An upstream controls both fields and can emit a
488
+ * contradictory pair -- `context_length_exceeded` beside `Unsupported parameter: user` -- and
489
+ * that is not evidence the turn is too large for this model. `classifyError` reads the whole
490
+ * blob, which is exactly the looseness this must not inherit.
491
+ *
492
+ * A JSON-shaped body that fails to parse is truncated or corrupt, not prose: `classificationText`
493
+ * is capped at 500 characters by `normalizeUpstreamErrorText` before it reaches this function, so
494
+ * a long envelope arrives here as a JSON prefix. Reading that prefix as plain text would let an
495
+ * arbitrary field that happens to sit in the first 500 bytes authorize a hop, so it fails closed.
496
+ *
497
+ * Only the exact proxy wrapper is unwrapped, within a fixed envelope budget and 16,384 characters.
498
+ */
499
+ function isDefiniteContextOverflow(status: number, message: string): boolean {
500
+ if (!CONTEXT_VERDICT_STATUSES.has(status) && status < 500) return false;
501
+ if (message.length > 16_384) return false;
502
+ let text = message.trim();
503
+ // One pass per unwrapped envelope, plus one for the leaf the last envelope yields.
504
+ for (let unwrapped = 0; unwrapped <= MAX_CONTEXT_OVERFLOW_ENVELOPES; unwrapped += 1) {
505
+ const providerPrefix = /^Provider error \d{3}:\s*/.exec(text);
506
+ if (providerPrefix) text = text.slice(providerPrefix[0].length).trim();
507
+ if (!text.startsWith("{")) return isDefiniteContextOverflowMessage(text);
508
+ if (unwrapped === MAX_CONTEXT_OVERFLOW_ENVELOPES) return false;
509
+ let payload: unknown;
510
+ try { payload = JSON.parse(text); } catch { return false; }
511
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
512
+ const record = payload as Record<string, unknown>;
513
+ const response = record.response && typeof record.response === "object" && !Array.isArray(record.response)
514
+ ? record.response as Record<string, unknown>
515
+ : undefined;
516
+ const source = [record.error, response?.error, response?.last_error, record.last_error, record]
517
+ .find((candidate): candidate is Record<string, unknown> =>
518
+ !!candidate && typeof candidate === "object" && !Array.isArray(candidate)
519
+ && typeof (candidate as Record<string, unknown>).message === "string");
520
+ if (!source) return false;
521
+ text = (source.message as string).trim();
522
+ }
523
+ return false;
524
+ }
525
+
454
526
  export function comboFailureDecision(
455
527
  status: number,
456
528
  message: string,
@@ -458,6 +530,10 @@ export function comboFailureDecision(
458
530
  ): ComboFailureDecision {
459
531
  if (status === 499) return "stop";
460
532
  if (message.toLowerCase().includes("origin_rejected")) return "stop";
533
+ // Structured form of the same hard refusal. The prose test above misses it when the origin
534
+ // reports the code out of band, and every hop rule below -- including the context-overflow
535
+ // one -- must stay subordinate to it.
536
+ if (normalizedFailureCode(options?.code) === "origin_rejected") return "stop";
461
537
  // The origin may already be executing this turn (the Codex WebSocket relay sent the create
462
538
  // frame and never saw a response event). Hopping would send the same request to a second
463
539
  // target while the first may still be generating; the honest status goes to the client.
@@ -476,6 +552,15 @@ export function comboFailureDecision(
476
552
  // (for example 5059 + invalid_request_prompt_too_long). That is evidence that this
477
553
  // target is too small, not that every later combo target is incapable of serving it.
478
554
  if (isProviderTargetContextOverflow(status, message, options?.code)) return "hop";
555
+ // A definite context-window refusal is target-local inside a heterogeneous combo: this model
556
+ // cannot hold the turn, but a later target may have a larger window. Two boundaries keep this
557
+ // safe. It is reached only after cancellation, structured origin/cyber refusals and
558
+ // non-replayable post-send codes have already stopped. And it only ever classifies a failure
559
+ // the combo stream preflight already proved emitted no output: `comboStreamPayloadCommitsOutput`
560
+ // commits the child on any text, tool call or unknown event, and only a zero-output terminal
561
+ // becomes a failure response at all, so a turn whose text the client already saw is never
562
+ // reclassified here.
563
+ if (isDefiniteContextOverflow(status, message)) return "hop";
479
564
  // A local input-admission refusal (#1524) says "this candidate cannot fit the request",
480
565
  // not "the request is impossible": the next candidate may have a larger context window.
481
566
  //