@bitkyc08/opencodex 2.55.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 (256) 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-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -3
  6. package/src/adapters/base.ts +21 -0
  7. package/src/adapters/codebuddy/adapter.ts +2 -1
  8. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  9. package/src/adapters/command-code.ts +1 -1
  10. package/src/adapters/cursor/envelope-echo.ts +8 -2
  11. package/src/adapters/cursor/transport-retry.ts +46 -1
  12. package/src/adapters/cursor.ts +4 -0
  13. package/src/adapters/google.ts +7 -7
  14. package/src/adapters/kiro/adapter.ts +42 -1
  15. package/src/adapters/kiro/payload.ts +17 -3
  16. package/src/adapters/kiro/reasoning.ts +70 -7
  17. package/src/adapters/kiro/stream.ts +8 -2
  18. package/src/adapters/kiro/wire.ts +2 -1
  19. package/src/adapters/kiro-events.ts +21 -13
  20. package/src/adapters/kiro-retry.ts +23 -4
  21. package/src/adapters/openai-chat/errors.ts +116 -0
  22. package/src/adapters/openai-chat/messages.ts +346 -0
  23. package/src/adapters/openai-chat/passthrough.ts +146 -0
  24. package/src/adapters/openai-chat/response-events.ts +117 -0
  25. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  26. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  27. package/src/adapters/openai-chat/tool-schema.ts +495 -0
  28. package/src/adapters/openai-chat/wire.ts +50 -0
  29. package/src/adapters/openai-chat.ts +40 -1452
  30. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  31. package/src/adapters/openai-responses/image-gen.ts +406 -0
  32. package/src/adapters/openai-responses/internal.ts +3 -0
  33. package/src/adapters/openai-responses/passthrough.ts +642 -0
  34. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  35. package/src/adapters/openai-responses/reasoning.ts +220 -0
  36. package/src/adapters/openai-responses/request-strips.ts +185 -0
  37. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  38. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  39. package/src/adapters/openai-responses/web-search.ts +156 -0
  40. package/src/adapters/openai-responses.ts +4 -2625
  41. package/src/bridge/errors.ts +58 -0
  42. package/src/bridge/internal.ts +174 -0
  43. package/src/bridge/response-json.ts +630 -0
  44. package/src/bridge/sse.ts +1462 -0
  45. package/src/bridge.ts +5 -2204
  46. package/src/chat/inbound.ts +12 -1
  47. package/src/claude/desktop-profile.ts +66 -9
  48. package/src/claude/outbound.ts +18 -0
  49. package/src/cli/account-main.ts +1 -1
  50. package/src/cli/capabilities.ts +2 -2
  51. package/src/cli/combo.ts +10 -1
  52. package/src/cli/index.ts +48 -5
  53. package/src/cli/registry.ts +2 -1
  54. package/src/cli/system-command.ts +4 -4
  55. package/src/clients/config-export.ts +7 -3
  56. package/src/codex/account-label.ts +14 -3
  57. package/src/codex/account-lifecycle.ts +3 -0
  58. package/src/codex/account-store.ts +184 -35
  59. package/src/codex/account-usability.ts +21 -0
  60. package/src/codex/auth-api/account-list.ts +507 -0
  61. package/src/codex/auth-api/http.ts +32 -0
  62. package/src/codex/auth-api/login-flow.ts +566 -0
  63. package/src/codex/auth-api/login-state.ts +64 -0
  64. package/src/codex/auth-api/main-account-probe.ts +331 -0
  65. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  66. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  67. package/src/codex/auth-api/reset-credit-service.ts +431 -0
  68. package/src/codex/auth-api/routes.ts +425 -0
  69. package/src/codex/auth-api/runtime-config.ts +48 -0
  70. package/src/codex/auth-api.ts +27 -3118
  71. package/src/codex/auth-context.ts +252 -35
  72. package/src/codex/catalog/aggregation.ts +80 -1
  73. package/src/codex/catalog/auto-review.ts +507 -0
  74. package/src/codex/catalog/build-entries.ts +981 -0
  75. package/src/codex/catalog/combo-member.ts +375 -0
  76. package/src/codex/catalog/derive-entry.ts +229 -0
  77. package/src/codex/catalog/effort.ts +0 -1
  78. package/src/codex/catalog/gated-native-warn.ts +63 -0
  79. package/src/codex/catalog/gather-capture.ts +533 -0
  80. package/src/codex/catalog/model-hints.ts +691 -0
  81. package/src/codex/catalog/model-visibility.ts +305 -0
  82. package/src/codex/catalog/provider-fetch.ts +52 -2942
  83. package/src/codex/catalog/provider-models.ts +685 -0
  84. package/src/codex/catalog/remote.ts +30 -0
  85. package/src/codex/catalog/restore.ts +132 -0
  86. package/src/codex/catalog/retained-sync.ts +714 -0
  87. package/src/codex/catalog/routed-gather.ts +895 -0
  88. package/src/codex/catalog/subagent-roster.ts +176 -0
  89. package/src/codex/catalog/sync.ts +52 -2698
  90. package/src/codex/cli-install-provenance.ts +7 -1
  91. package/src/codex/convergence.ts +7 -2
  92. package/src/codex/desktop-app/types.ts +11 -2
  93. package/src/codex/desktop-app/windows.ts +5 -5
  94. package/src/codex/inject/config-toml.ts +563 -0
  95. package/src/codex/inject/remove.ts +192 -0
  96. package/src/codex/inject/restore.ts +567 -0
  97. package/src/codex/inject/routing-classify.ts +109 -0
  98. package/src/codex/inject/routing-target.ts +125 -0
  99. package/src/codex/inject.ts +89 -1444
  100. package/src/codex/lineage.ts +458 -0
  101. package/src/codex/model-entitlements.ts +152 -15
  102. package/src/codex/pool-refresh-backoff.ts +161 -0
  103. package/src/codex/quota-rejection.ts +104 -15
  104. package/src/codex/routing/active-account.ts +194 -0
  105. package/src/codex/routing/cache-affinity.ts +70 -0
  106. package/src/codex/routing/cooldown-math.ts +285 -0
  107. package/src/codex/routing/health-store.ts +402 -0
  108. package/src/codex/routing/probe-lease.ts +358 -0
  109. package/src/codex/routing/selection.ts +780 -0
  110. package/src/codex/routing/thread-affinity.ts +586 -0
  111. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  112. package/src/codex/routing.ts +370 -2271
  113. package/src/codex/shim-fingerprint.ts +223 -0
  114. package/src/codex/shim-inspect.ts +175 -0
  115. package/src/codex/shim-probe.ts +367 -0
  116. package/src/codex/shim-restore-lock.ts +169 -0
  117. package/src/codex/shim-state-file.ts +151 -0
  118. package/src/codex/shim-templates.ts +265 -0
  119. package/src/codex/shim.ts +48 -1268
  120. package/src/codex/warmup.ts +1 -1
  121. package/src/combos/failover.ts +85 -0
  122. package/src/combos/request.ts +17 -10
  123. package/src/combos/types.ts +23 -2
  124. package/src/config/diagnostics.ts +705 -0
  125. package/src/config/feature-flags.ts +55 -0
  126. package/src/config/live-reconcile.ts +403 -0
  127. package/src/config/load-degrade.ts +880 -0
  128. package/src/config/mutation-lock.ts +244 -0
  129. package/src/config/openai-tier-backup.ts +268 -0
  130. package/src/config/pending-teardown.ts +31 -0
  131. package/src/config/persist-unlocked.ts +92 -0
  132. package/src/config/proxy-env.ts +188 -0
  133. package/src/config/salvage.ts +244 -0
  134. package/src/config/schema/config-schema.ts +640 -0
  135. package/src/config/schema/leaf-validators.ts +855 -0
  136. package/src/config/warn-memo.ts +28 -0
  137. package/src/config.ts +234 -4481
  138. package/src/generated/compatibility-version.json +649 -121
  139. package/src/images/loop.ts +1 -1
  140. package/src/lib/errors.ts +17 -0
  141. package/src/lib/request-execution-budget.ts +198 -23
  142. package/src/lib/spend-reservation-ledger.ts +958 -0
  143. package/src/lib/state-store-registrations.ts +6 -2
  144. package/src/lib/test-home-guard.ts +85 -1
  145. package/src/lib/upstream-retry.ts +132 -21
  146. package/src/lib/windows-elevation.ts +76 -14
  147. package/src/lib/workflow-budget.ts +553 -30
  148. package/src/oauth/index.ts +2 -2
  149. package/src/oauth/key-providers.ts +2 -2
  150. package/src/providers/kiro-models.ts +4 -3
  151. package/src/providers/label.ts +19 -1
  152. package/src/providers/model-discovery.ts +16 -0
  153. package/src/providers/quota/account-cache.ts +441 -0
  154. package/src/providers/quota/antigravity.ts +295 -0
  155. package/src/providers/quota/report-cache.ts +320 -0
  156. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  157. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  158. package/src/providers/quota.ts +324 -3079
  159. package/src/providers/registry/entries-core.ts +1228 -0
  160. package/src/providers/registry/entries-extended.ts +1213 -0
  161. package/src/providers/registry/model-seeds.ts +912 -0
  162. package/src/providers/registry/types.ts +352 -0
  163. package/src/providers/registry.ts +24 -3536
  164. package/src/responses/continuation-ownership.ts +29 -0
  165. package/src/responses/reasoning-envelope.ts +6 -3
  166. package/src/responses/state/replay-fingerprint.ts +80 -0
  167. package/src/responses/state/snapshot-codec.ts +104 -0
  168. package/src/responses/state/spill-failure.ts +118 -0
  169. package/src/responses/state/spill-queue.ts +665 -0
  170. package/src/responses/state/temp-recovery.ts +257 -0
  171. package/src/responses/state.ts +82 -1143
  172. package/src/routing/identity-domains.ts +456 -0
  173. package/src/routing/probe-lease.ts +613 -0
  174. package/src/server/chat-completions.ts +3 -1
  175. package/src/server/chat-native.ts +37 -9
  176. package/src/server/index/bounded-request.ts +88 -0
  177. package/src/server/index/live-sideband.ts +601 -0
  178. package/src/server/index/serve-options.ts +1766 -0
  179. package/src/server/index/startup-warnings.ts +213 -0
  180. package/src/server/index/websocket-handler.ts +339 -0
  181. package/src/server/index.ts +45 -2552
  182. package/src/server/inspection-tee.ts +107 -0
  183. package/src/server/live.ts +46 -1
  184. package/src/server/management/combo-routes.ts +10 -1
  185. package/src/server/management/route-registry.ts +26 -23
  186. package/src/server/management/shared.ts +8 -5
  187. package/src/server/management/workflow-budget-routes.ts +133 -0
  188. package/src/server/management-api.ts +12 -0
  189. package/src/server/relay-eager.ts +2 -0
  190. package/src/server/relay.ts +14 -19
  191. package/src/server/request-log-conversation.ts +9 -7
  192. package/src/server/request-log.ts +372 -4
  193. package/src/server/response-log-body.ts +153 -0
  194. package/src/server/responses/account-change-state.ts +307 -0
  195. package/src/server/responses/adapter-continuation.ts +540 -0
  196. package/src/server/responses/adapter-delivery.ts +208 -0
  197. package/src/server/responses/adapter-dispatch.ts +1042 -0
  198. package/src/server/responses/codex-ws-wire.ts +5 -0
  199. package/src/server/responses/collaboration.ts +74 -4
  200. package/src/server/responses/combo-session-recall.ts +68 -8
  201. package/src/server/responses/compact.ts +113 -17
  202. package/src/server/responses/completion-policy.ts +33 -0
  203. package/src/server/responses/core-auth.ts +529 -0
  204. package/src/server/responses/core-codex-account.ts +907 -0
  205. package/src/server/responses/core-combo-failure.ts +210 -0
  206. package/src/server/responses/core-combo.ts +787 -0
  207. package/src/server/responses/core-errors.ts +170 -0
  208. package/src/server/responses/core-lifetime.ts +95 -0
  209. package/src/server/responses/core-normalize.ts +350 -0
  210. package/src/server/responses/core-opaque-recovery.ts +380 -0
  211. package/src/server/responses/core-options.ts +159 -0
  212. package/src/server/responses/core-replay.ts +298 -0
  213. package/src/server/responses/core.ts +192 -8893
  214. package/src/server/responses/encrypted-payload.ts +0 -1
  215. package/src/server/responses/input-admission.ts +126 -6
  216. package/src/server/responses/passthrough-delivery.ts +869 -0
  217. package/src/server/responses/passthrough-dispatch.ts +1494 -0
  218. package/src/server/responses/passthrough-error.ts +38 -2
  219. package/src/server/responses/passthrough-execution.ts +54 -0
  220. package/src/server/responses/request-prepare.ts +1080 -0
  221. package/src/server/responses/request-send-budget.ts +259 -0
  222. package/src/server/responses/request-sidecar-auth.ts +149 -0
  223. package/src/server/responses/request-spend.ts +147 -0
  224. package/src/server/responses/request-transport.ts +803 -0
  225. package/src/server/responses/response-effects.ts +157 -0
  226. package/src/server/responses/run-turn-execution.ts +476 -0
  227. package/src/server/responses/sidecar-execution.ts +463 -0
  228. package/src/server/responses/terminal-guard.ts +65 -4
  229. package/src/server/responses-image-gen-repair.ts +1 -1
  230. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  231. package/src/server/workflow-refusal.ts +84 -0
  232. package/src/service/windows-ops.ts +210 -16
  233. package/src/service/windows-scheduler.ts +28 -21
  234. package/src/service.ts +1 -1
  235. package/src/types/config.ts +34 -1
  236. package/src/types/request.ts +8 -5
  237. package/src/types/tools.ts +24 -0
  238. package/src/types.ts +2 -0
  239. package/src/update/index.ts +10 -0
  240. package/src/update/stop-contract.d.mts +1 -0
  241. package/src/update/stop-contract.mjs +19 -0
  242. package/src/update/stop-decision.d.mts +1 -1
  243. package/src/update/stop-decision.mjs +12 -3
  244. package/src/usage/log.ts +147 -1
  245. package/src/usage/summary.ts +171 -21
  246. package/src/vision/anthropic-describe.ts +1 -1
  247. package/src/vision/describe.ts +5 -5
  248. package/src/web-search/anthropic-executor.ts +1 -1
  249. package/src/web-search/exa-executor.ts +1 -1
  250. package/src/web-search/executor.ts +1 -1
  251. package/src/web-search/gemini-executor.ts +1 -1
  252. package/src/web-search/loop.ts +1 -1
  253. package/src/web-search/ollama-executor.ts +1 -1
  254. package/src/web-search/parse.ts +67 -14
  255. package/src/web-search/passthrough-bridge.ts +64 -31
  256. package/src/web-search/xai-executor.ts +1 -1
@@ -0,0 +1,613 @@
1
+ /**
2
+ * Half-open recovery for a held account, and the pool-wide retry/probe budget
3
+ * that sits above it (#4546, wp3 follow-up).
4
+ *
5
+ * The transient hold (#4616) keeps a thread's binding while its account serves a
6
+ * 5xx streak, and detours requests to a healthy sibling. What it cannot answer is
7
+ * whether the held account is actually back: a soft-avoided account receives no
8
+ * traffic, so the two-success clearing rule can only fire through the "held"
9
+ * fallback, which hands the failing account back to every pinned thread at once.
10
+ * This module is the bounded trial that closes that gap -- a single-holder probe
11
+ * lease keyed on the health domain (the quota-cooldown domain already has its own
12
+ * lease in src/codex/routing.ts and is a different thing).
13
+ *
14
+ * Three rules the lease enforces:
15
+ *
16
+ * - While an account is held, exactly one in-flight probe may test it. Every
17
+ * other request keeps the remembered detour, so a failed probe costs the
18
+ * caller nothing -- the detour's identity is never dropped to run the trial.
19
+ * - The lease has a deadline and is released on success, failure, or expiry. A
20
+ * response that arrives after its lease was lost is STALE: it must not
21
+ * overwrite a newer binding or a newer failure state, so every lease carries
22
+ * the generation it was issued under and a settle that fails the fence
23
+ * mutates nothing.
24
+ * - When every candidate is held the caller gets a typed "binding remembered,
25
+ * dispatch withheld" outcome -- not a send to an account already known to be
26
+ * failing.
27
+ *
28
+ * The pool-wide limiter exists because per-request send budgets do not prevent
29
+ * a retry storm: thousands of requests each staying inside their own allowance
30
+ * still compose into an unbounded rate against an already-failing upstream.
31
+ * Recovery dispatches (retries and probes, never the initial send of a new
32
+ * request) are admitted only while they stay under a ratio of observed initial
33
+ * sends in a sliding window -- the standard overload-guidance shape.
34
+ */
35
+
36
+ /** How long a granted probe may be in flight before its lease is forfeit. */
37
+ export const TRANSIENT_PROBE_LEASE_MS = 30_000;
38
+ /**
39
+ * Minimum spacing between probes of the same held account. Without it every
40
+ * request that follows a settled probe becomes the next probe, which is the
41
+ * same storm the single-holder rule exists to bound -- just serialized.
42
+ */
43
+ export const TRANSIENT_PROBE_INTERVAL_MS = 15_000;
44
+
45
+ /**
46
+ * Grace kept on top of an entry's pacing and lease deadlines before it may be forgotten.
47
+ * Inside it a late settle can still answer "expired" rather than "stale", which is the
48
+ * distinction the settle contract exists to report.
49
+ */
50
+ const PROBE_STATE_RETENTION_MS = 60_000;
51
+ /**
52
+ * Hard ceiling on remembered accounts. Pacing state is per account id, and account ids churn
53
+ * with configuration: without a ceiling a long-lived proxy accumulates one entry per id it
54
+ * ever probed. Above the ceiling the entries whose pacing lapses soonest are dropped, which
55
+ * at worst lets one dormant account be probed earlier than its interval; an entry holding a
56
+ * LIVE lease is never dropped, because that would hand out a second concurrent probe and
57
+ * break the single-holder rule the lease exists to enforce.
58
+ */
59
+ export const MAX_TRANSIENT_PROBE_STATES = 1_024;
60
+ /** Below this the map is too small to be worth scanning on a grant. */
61
+ const PROBE_STATE_SWEEP_THRESHOLD = 64;
62
+ /**
63
+ * Eviction target once the ceiling is reached. Clearing a block at a time keeps the ordering
64
+ * pass off the common grant path: it runs once per block of new accounts instead of once per
65
+ * grant forever after the first time the ceiling is touched.
66
+ */
67
+ const PROBE_STATE_EVICTION_LOW_WATER = Math.floor(MAX_TRANSIENT_PROBE_STATES * 0.9);
68
+
69
+ export interface TransientProbeLease {
70
+ readonly accountId: string;
71
+ readonly leaseId: string;
72
+ /** Epoch the lease was issued under; a settle must match the CURRENT epoch. */
73
+ readonly generation: number;
74
+ readonly expiresAt: number;
75
+ }
76
+
77
+ export type TransientProbeOutcome = "recovered" | "failed";
78
+
79
+ /**
80
+ * What a settle did to the lease.
81
+ *
82
+ * - `applied`: the probe still held the lease inside its deadline; the caller
83
+ * may act on the outcome (clear the hold, or record the fresh failure).
84
+ * - `stale`: the lease was already lost -- expired and re-issued, or invalidated
85
+ * by newer authoritative state. The result is dropped; nothing is overwritten.
86
+ * - `expired`: the probe finished after its own deadline. The lease is dead
87
+ * either way; this answer exists so the caller can tell "lost a race" from
88
+ * "ran long".
89
+ */
90
+ export type TransientProbeSettle = "applied" | "stale" | "expired";
91
+
92
+ interface AccountProbeState {
93
+ /** Bumped on every lease grant and every external invalidation. */
94
+ generation: number;
95
+ leaseId?: string;
96
+ leaseExpiresAt?: number;
97
+ lastProbeAt?: number;
98
+ /**
99
+ * Moment this account's pacing interval lapses, recorded at grant time from the interval
100
+ * that grant actually used. Kept alongside `lastProbeAt` so cleanup honours a caller's
101
+ * longer interval instead of assuming the default.
102
+ */
103
+ pacedUntil?: number;
104
+ lastOutcome?: TransientProbeOutcome;
105
+ }
106
+
107
+ const probeStates = new Map<string, AccountProbeState>();
108
+ let probeLeaseSeq = 0;
109
+
110
+ function probeStateFor(accountId: string): AccountProbeState {
111
+ let state = probeStates.get(accountId);
112
+ if (!state) {
113
+ state = { generation: 0 };
114
+ probeStates.set(accountId, state);
115
+ }
116
+ return state;
117
+ }
118
+
119
+ function liveLease(state: AccountProbeState, now: number): boolean {
120
+ return state.leaseId !== undefined && state.leaseExpiresAt !== undefined && state.leaseExpiresAt > now;
121
+ }
122
+
123
+ /**
124
+ * Moment an entry stops carrying anything a future decision can read: its pacing interval and
125
+ * any unsettled lease deadline, plus the grace above.
126
+ */
127
+ function probeStateRetiresAt(state: AccountProbeState): number {
128
+ return Math.max(state.pacedUntil ?? 0, state.leaseExpiresAt ?? 0) + PROBE_STATE_RETENTION_MS;
129
+ }
130
+
131
+ /**
132
+ * Bound the remembered accounts. Called on the one path that can grow the map -- a grant is
133
+ * the only insertion -- so the ceiling holds without a timer.
134
+ *
135
+ * The first pass drops only entries that can no longer change an answer: no live lease, the
136
+ * pacing interval lapsed, and the grace elapsed. Re-creating such an entry later yields the
137
+ * same decisions it would have produced, and a late settle against it still cannot be applied
138
+ * because lease ids are issued from a monotonic counter and never repeat.
139
+ */
140
+ function sweepProbeStates(now: number): void {
141
+ if (probeStates.size <= PROBE_STATE_SWEEP_THRESHOLD) return;
142
+ for (const [accountId, state] of probeStates) {
143
+ if (liveLease(state, now)) continue;
144
+ if (now >= probeStateRetiresAt(state)) probeStates.delete(accountId);
145
+ }
146
+ if (probeStates.size <= MAX_TRANSIENT_PROBE_STATES) return;
147
+ // Still over the ceiling with nothing retired: churn is faster than the retention window.
148
+ // Evict in retirement order so the entries closest to meaningless go first, and never one
149
+ // holding a live lease.
150
+ const evictable = Array.from(probeStates)
151
+ .filter(([, state]) => !liveLease(state, now))
152
+ .sort((a, b) => probeStateRetiresAt(a[1]) - probeStateRetiresAt(b[1]));
153
+ let excess = probeStates.size - PROBE_STATE_EVICTION_LOW_WATER;
154
+ for (const [accountId] of evictable) {
155
+ if (excess <= 0) break;
156
+ probeStates.delete(accountId);
157
+ excess -= 1;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Grant the single in-flight probe for a held account, or null when another
163
+ * probe is already out or the pacing interval has not elapsed. The grant bumps
164
+ * the epoch, so a result from any earlier lease is stale the moment it lands.
165
+ */
166
+ export function tryAcquireTransientProbe(
167
+ accountId: string,
168
+ now = Date.now(),
169
+ options?: { leaseMs?: number; minIntervalMs?: number },
170
+ ): TransientProbeLease | null {
171
+ const state = probeStateFor(accountId);
172
+ if (liveLease(state, now)) return null;
173
+ const interval = options?.minIntervalMs ?? TRANSIENT_PROBE_INTERVAL_MS;
174
+ if (state.lastProbeAt !== undefined && now - state.lastProbeAt < interval) return null;
175
+ const leaseMs = options?.leaseMs ?? TRANSIENT_PROBE_LEASE_MS;
176
+ const leaseId = `tprobe-${(probeLeaseSeq += 1).toString(36)}`;
177
+ const expiresAt = now + Math.max(1, leaseMs);
178
+ state.generation += 1;
179
+ state.leaseId = leaseId;
180
+ state.leaseExpiresAt = expiresAt;
181
+ state.lastProbeAt = now;
182
+ state.pacedUntil = now + Math.max(0, interval);
183
+ // After the grant, not before it: the entry this call just wrote holds a live lease and is
184
+ // therefore the one entry the sweep may never touch, so the ceiling is a real ceiling
185
+ // rather than "the ceiling plus whatever was inserted after the scan".
186
+ sweepProbeStates(now);
187
+ return {
188
+ accountId,
189
+ leaseId,
190
+ generation: state.generation,
191
+ expiresAt,
192
+ };
193
+ }
194
+
195
+ /** Side-effect-free mirror of {@link tryAcquireTransientProbe} eligibility. */
196
+ export function canAcquireTransientProbe(
197
+ accountId: string,
198
+ now = Date.now(),
199
+ options?: { minIntervalMs?: number },
200
+ ): boolean {
201
+ const state = probeStates.get(accountId);
202
+ if (!state) return true;
203
+ if (liveLease(state, now)) return false;
204
+ const interval = options?.minIntervalMs ?? TRANSIENT_PROBE_INTERVAL_MS;
205
+ return state.lastProbeAt === undefined || now - state.lastProbeAt >= interval;
206
+ }
207
+
208
+ /**
209
+ * Report a probe's outcome. Only the current lease holder inside its deadline
210
+ * applies: anything else is a late answer from a probe that already lost, and
211
+ * dropping it is what keeps it from overwriting a newer binding or a newer
212
+ * failure state. An applied settle clears the lease so the next probe is paced
213
+ * by the interval, not by the expiry.
214
+ */
215
+ export function settleTransientProbe(
216
+ lease: TransientProbeLease,
217
+ outcome: TransientProbeOutcome,
218
+ now = Date.now(),
219
+ ): TransientProbeSettle {
220
+ const state = probeStates.get(lease.accountId);
221
+ if (!state || state.leaseId !== lease.leaseId || state.generation !== lease.generation) {
222
+ return "stale";
223
+ }
224
+ // `>=`, matching liveLease: at exactly the deadline the lease is already gone, so applying
225
+ // the outcome there would let a probe act on a lease the grant path would refuse to
226
+ // recognise -- two answers to the same instant.
227
+ if (now >= lease.expiresAt) return "expired";
228
+ state.leaseId = undefined;
229
+ state.leaseExpiresAt = undefined;
230
+ state.lastOutcome = outcome;
231
+ return "applied";
232
+ }
233
+
234
+ /**
235
+ * Hand a lease back with no outcome -- the probe never reached upstream, so
236
+ * there is nothing to record. Only the holder may release; a stale lease is
237
+ * already dead and needs no cleanup.
238
+ */
239
+ export function releaseTransientProbe(lease: TransientProbeLease): void {
240
+ const state = probeStates.get(lease.accountId);
241
+ if (!state || state.leaseId !== lease.leaseId || state.generation !== lease.generation) return;
242
+ state.leaseId = undefined;
243
+ state.leaseExpiresAt = undefined;
244
+ }
245
+
246
+ /**
247
+ * Fence the epoch against newer authoritative state. A fresh failure recorded
248
+ * through the ordinary outcome path, or a binding that moved on, must not be
249
+ * overwritten by a probe result that was issued before it -- bumping the epoch
250
+ * makes every outstanding lease stale without waiting for its deadline.
251
+ */
252
+ export function invalidateTransientProbe(accountId: string): void {
253
+ const state = probeStates.get(accountId);
254
+ if (!state) return;
255
+ state.generation += 1;
256
+ state.leaseId = undefined;
257
+ state.leaseExpiresAt = undefined;
258
+ }
259
+
260
+ export interface TransientProbeDiagnostics {
261
+ readonly held: boolean;
262
+ readonly generation: number;
263
+ readonly leaseId?: string;
264
+ readonly leaseExpiresAt?: number;
265
+ readonly lastProbeAt?: number;
266
+ readonly lastOutcome?: TransientProbeOutcome;
267
+ }
268
+
269
+ /** Current lease state for one account, for diagnostics. Never mutates. */
270
+ export function transientProbeDiagnostics(accountId: string, now = Date.now()): TransientProbeDiagnostics {
271
+ const state = probeStates.get(accountId);
272
+ if (!state) return { held: false, generation: 0 };
273
+ return {
274
+ held: liveLease(state, now),
275
+ generation: state.generation,
276
+ ...(state.leaseId !== undefined ? { leaseId: state.leaseId, leaseExpiresAt: state.leaseExpiresAt } : {}),
277
+ ...(state.lastProbeAt !== undefined ? { lastProbeAt: state.lastProbeAt } : {}),
278
+ ...(state.lastOutcome !== undefined ? { lastOutcome: state.lastOutcome } : {}),
279
+ };
280
+ }
281
+
282
+ /** Test seam: lease state is module-global and must not leak between cases. */
283
+ export function clearTransientProbeLeasesForTests(): void {
284
+ probeStates.clear();
285
+ }
286
+
287
+ /**
288
+ * How many accounts currently carry probe state. Diagnostic, and the assertion surface for
289
+ * the {@link MAX_TRANSIENT_PROBE_STATES} bound.
290
+ */
291
+ export function transientProbeStateCount(): number {
292
+ return probeStates.size;
293
+ }
294
+
295
+ /**
296
+ * What a request may do while its bound account is held.
297
+ *
298
+ * - `probe`: this caller holds the lease and may send ONE trial to the held
299
+ * account.
300
+ * - `detour`: a probe is already out (or was refused); keep the remembered
301
+ * detour. The detour's identity survives the whole probing window -- a failed
302
+ * trial must not cost the caller its working route.
303
+ * - `withheld`: every candidate is held. The binding is remembered and dispatch
304
+ * is refused; `retryAt` is the earliest moment a probe could next go out.
305
+ * Sending anyway here is exactly the "must not send, sends anyway" defect the
306
+ * hold was added to close.
307
+ */
308
+ export type HeldAccountDispatch =
309
+ | { kind: "probe"; lease: TransientProbeLease }
310
+ | { kind: "detour"; accountId: string }
311
+ | { kind: "withheld"; boundAccountId: string; detourAccountId?: string; retryAt: number };
312
+
313
+ /**
314
+ * Decide what a request bound to a held account may do this turn. The probe is
315
+ * tried first -- somebody has to find out whether the account is back, and the
316
+ * lease guarantees it is exactly one somebody. Everyone else keeps the detour,
317
+ * and a caller with no detour left is told to wait rather than sent at an
318
+ * account already known to be failing.
319
+ */
320
+ export function resolveHeldAccountDispatch(input: {
321
+ boundAccountId: string;
322
+ detourAccountId?: string;
323
+ now?: number;
324
+ leaseMs?: number;
325
+ minProbeIntervalMs?: number;
326
+ backpressure?: PoolBackpressureLimiter;
327
+ }): HeldAccountDispatch {
328
+ const now = input.now ?? Date.now();
329
+ const limiter = input.backpressure ?? sharedPoolBackpressure();
330
+ // The lease check runs before the budget charge: a probe another holder already has out is
331
+ // not a dispatch, and charging the pool for it would shrink the recovery budget by phantom
332
+ // sends. Between the check and the grant there is no await, so eligibility cannot change.
333
+ if (
334
+ canAcquireTransientProbe(input.boundAccountId, now, {
335
+ ...(input.minProbeIntervalMs !== undefined ? { minIntervalMs: input.minProbeIntervalMs } : {}),
336
+ })
337
+ && limiter.tryPermitProbeDispatch(now)
338
+ ) {
339
+ const lease = tryAcquireTransientProbe(input.boundAccountId, now, {
340
+ ...(input.leaseMs !== undefined ? { leaseMs: input.leaseMs } : {}),
341
+ ...(input.minProbeIntervalMs !== undefined ? { minIntervalMs: input.minProbeIntervalMs } : {}),
342
+ });
343
+ if (lease) return { kind: "probe", lease };
344
+ }
345
+ if (input.detourAccountId !== undefined && input.detourAccountId !== input.boundAccountId) {
346
+ return { kind: "detour", accountId: input.detourAccountId };
347
+ }
348
+ return {
349
+ kind: "withheld",
350
+ boundAccountId: input.boundAccountId,
351
+ ...(input.detourAccountId !== undefined ? { detourAccountId: input.detourAccountId } : {}),
352
+ // Both bounds, not just the probe pacing. A request refused by the RATIO has no probe state
353
+ // of its own yet, so `nextProbeAt` answered `now` and the refusal told the caller to try
354
+ // again immediately -- a withheld dispatch that busy-loops is the same load as the dispatch
355
+ // it refused. The limiter is the only thing that knows when its window moves.
356
+ retryAt: Math.max(
357
+ nextProbeAt(input.boundAccountId, now, input.minProbeIntervalMs),
358
+ limiter.nextRecoveryAt(now),
359
+ ),
360
+ };
361
+ }
362
+
363
+ /** Earliest moment a probe of this account could next be granted. */
364
+ function nextProbeAt(accountId: string, now: number, minIntervalMs?: number): number {
365
+ const state = probeStates.get(accountId);
366
+ if (!state) return now;
367
+ const interval = minIntervalMs ?? TRANSIENT_PROBE_INTERVAL_MS;
368
+ const paced = state.lastProbeAt !== undefined ? state.lastProbeAt + interval : now;
369
+ const leased = liveLease(state, now) ? state.leaseExpiresAt! : now;
370
+ return Math.max(paced, leased);
371
+ }
372
+
373
+ /* ------------------------------------------------------------------ */
374
+ /* Pool-wide recovery backpressure */
375
+ /* ------------------------------------------------------------------ */
376
+
377
+ export interface PoolBackpressurePolicy {
378
+ /** Sliding window the ratio is measured over. */
379
+ readonly windowMs: number;
380
+ /**
381
+ * Recovery dispatches (retries + probes) admitted per observed initial send.
382
+ * 0.2 is the standard overload-guidance budget: at most one recovery send for
383
+ * every five new requests.
384
+ */
385
+ readonly maxRetryRatio: number;
386
+ /**
387
+ * Floor under the ratio so a quiet pool can still recover: with almost no
388
+ * traffic a strict ratio admits nothing, which would wedge every held
389
+ * account behind a probe that can never run.
390
+ */
391
+ readonly minRecoveryAllowance: number;
392
+ }
393
+
394
+ export const DEFAULT_POOL_BACKPRESSURE_POLICY: PoolBackpressurePolicy = {
395
+ windowMs: 10_000,
396
+ maxRetryRatio: 0.2,
397
+ minRecoveryAllowance: 3,
398
+ };
399
+
400
+ export interface PoolBackpressureState {
401
+ readonly windowMs: number;
402
+ readonly initialSends: number;
403
+ readonly recoveryDispatches: number;
404
+ /** Dispatches admitted under the current window's allowance. */
405
+ readonly allowance: number;
406
+ /** Lifetime refusals, including windows already rotated out. */
407
+ readonly refusedTotal: number;
408
+ readonly ratioLimit: number;
409
+ }
410
+
411
+ export interface PoolBackpressureLimiter {
412
+ /** A new request's FIRST send. Always recorded, never refused. */
413
+ recordInitialSend(now?: number): void;
414
+ /** Admit one retry dispatch, or refuse when the window's ratio is spent. */
415
+ tryPermitRetryDispatch(now?: number): boolean;
416
+ /** Admit one probe dispatch under the same shared recovery budget. */
417
+ tryPermitProbeDispatch(now?: number): boolean;
418
+ /**
419
+ * Earliest moment this limiter could admit another recovery dispatch.
420
+ *
421
+ * A refusal has to hand back a time, or the caller has nothing to wait on and busy-loops
422
+ * against a pool that is already failing -- which is the load this limiter exists to remove.
423
+ * `now` when the allowance is not spent; otherwise the moment the oldest bucket still inside
424
+ * the window falls out of it, which is strictly in the future and is a real change point
425
+ * rather than a guess.
426
+ */
427
+ nextRecoveryAt(now?: number): number;
428
+ state(now?: number): PoolBackpressureState;
429
+ }
430
+
431
+ const BACKPRESSURE_BUCKETS = 10;
432
+
433
+ /**
434
+ * Ratio limiter over a bucketed sliding window. Buckets give a sliding answer
435
+ * without keeping per-event state: the window is the sum of the buckets whose
436
+ * span falls inside it, and grant/refuse decisions read that sum.
437
+ */
438
+ export function createPoolBackpressureLimiter(
439
+ policy: PoolBackpressurePolicy = DEFAULT_POOL_BACKPRESSURE_POLICY,
440
+ ): PoolBackpressureLimiter {
441
+ const bucketMs = Math.max(1, Math.floor(policy.windowMs / BACKPRESSURE_BUCKETS));
442
+ const buckets: Array<{ start: number; initials: number; recoveries: number }> = [];
443
+ let refusedTotal = 0;
444
+
445
+ function bucketFor(now: number): { start: number; initials: number; recoveries: number } {
446
+ const start = Math.floor(now / bucketMs) * bucketMs;
447
+ const last = buckets[buckets.length - 1];
448
+ if (last && last.start === start) return last;
449
+ while (buckets.length > 0 && buckets[0]!.start <= start - policy.windowMs) buckets.shift();
450
+ const bucket = { start, initials: 0, recoveries: 0 };
451
+ buckets.push(bucket);
452
+ return bucket;
453
+ }
454
+
455
+ function totals(now: number): { initials: number; recoveries: number } {
456
+ let initials = 0;
457
+ let recoveries = 0;
458
+ for (const bucket of buckets) {
459
+ if (bucket.start <= now - policy.windowMs) continue;
460
+ initials += bucket.initials;
461
+ recoveries += bucket.recoveries;
462
+ }
463
+ return { initials, recoveries };
464
+ }
465
+
466
+ function allowanceFor(initials: number): number {
467
+ return Math.max(policy.minRecoveryAllowance, Math.floor(initials * policy.maxRetryRatio));
468
+ }
469
+
470
+ function tryPermit(now: number): boolean {
471
+ const bucket = bucketFor(now);
472
+ const { initials, recoveries } = totals(now);
473
+ if (recoveries + 1 > allowanceFor(initials)) {
474
+ refusedTotal += 1;
475
+ return false;
476
+ }
477
+ bucket.recoveries += 1;
478
+ return true;
479
+ }
480
+
481
+ function nextRecoveryAt(now: number): number {
482
+ const { initials, recoveries } = totals(now);
483
+ if (recoveries + 1 <= allowanceFor(initials)) return now;
484
+ // The window has to move before another recovery fits. The earliest that can happen is the
485
+ // moment the oldest bucket still inside it leaves, and every such bucket started after
486
+ // `now - windowMs`, so the answer is always strictly in the future.
487
+ for (const bucket of buckets) {
488
+ if (bucket.start <= now - policy.windowMs) continue;
489
+ return bucket.start + policy.windowMs;
490
+ }
491
+ return now + policy.windowMs;
492
+ }
493
+
494
+ return {
495
+ recordInitialSend(now = Date.now()): void {
496
+ bucketFor(now).initials += 1;
497
+ },
498
+ tryPermitRetryDispatch(now = Date.now()): boolean {
499
+ return tryPermit(now);
500
+ },
501
+ tryPermitProbeDispatch(now = Date.now()): boolean {
502
+ return tryPermit(now);
503
+ },
504
+ nextRecoveryAt(now = Date.now()): number {
505
+ return nextRecoveryAt(now);
506
+ },
507
+ state(now = Date.now()): PoolBackpressureState {
508
+ const { initials, recoveries } = totals(now);
509
+ return {
510
+ windowMs: policy.windowMs,
511
+ initialSends: initials,
512
+ recoveryDispatches: recoveries,
513
+ allowance: allowanceFor(initials),
514
+ refusedTotal,
515
+ ratioLimit: policy.maxRetryRatio,
516
+ };
517
+ },
518
+ };
519
+ }
520
+
521
+ let sharedLimiter: PoolBackpressureLimiter | undefined;
522
+
523
+ /**
524
+ * The process-wide limiter every recovery dispatch shares. A per-request
525
+ * limiter cannot see the storm, which is the entire reason this layer exists.
526
+ */
527
+ export function sharedPoolBackpressure(): PoolBackpressureLimiter {
528
+ sharedLimiter ??= createPoolBackpressureLimiter();
529
+ return sharedLimiter;
530
+ }
531
+
532
+ /**
533
+ * Point the shared limiter at a different policy. The ceiling is deliberately
534
+ * configurable here and not yet plumbed into OcxConfig -- the wiring lane owns
535
+ * that seam; this is the knob it turns.
536
+ */
537
+ export function configureSharedPoolBackpressure(policy: PoolBackpressurePolicy): void {
538
+ sharedLimiter = createPoolBackpressureLimiter(policy);
539
+ }
540
+
541
+ /** Test seam: the shared limiter is module-global. */
542
+ export function resetSharedPoolBackpressureForTests(): void {
543
+ sharedLimiter = undefined;
544
+ }
545
+
546
+ /**
547
+ * Forget every account's probe pacing AND the shared recovery window.
548
+ *
549
+ * Called when the pool's routing state is reset wholesale -- a roster change, a config reload,
550
+ * an account removal. Both halves describe a pool that no longer exists: pacing is keyed on
551
+ * account ids that may be gone, and the window's buckets count sends made by a roster that
552
+ * changed underneath them. Keeping either across such a reset lets one context's recovery
553
+ * decisions govern the next one, which is also how it leaks between test files.
554
+ *
555
+ * This is the production reset. The two `ForTests` seams above stay separate because a test
556
+ * frequently wants exactly one half of it.
557
+ */
558
+ export function clearPoolRecoveryState(): void {
559
+ probeStates.clear();
560
+ sharedLimiter = undefined;
561
+ }
562
+
563
+ /**
564
+ * What one physical send IS, as far as the recovery window is concerned.
565
+ *
566
+ * The window measures recovery traffic against observed demand, so it needs the distinction
567
+ * made where the send happens -- and the transport wrapper cannot make it. That layer sees a
568
+ * URL and an init; whether this is a conversation's first attempt, its third retry, or the one
569
+ * trial admitted against a held account is knowledge only the caller has. So the caller names
570
+ * it, and the classification lives here with the window rather than in the transport, which
571
+ * owns no routing policy and has an enforced import boundary saying so.
572
+ *
573
+ * - `initial`: a new request's first send. Recorded, never refused -- it is the denominator,
574
+ * and refusing it would make this a throughput cap rather than a recovery bound.
575
+ * - `retry`: a re-send of a request that already reached upstream once. Admitted only while
576
+ * recovery traffic stays under its ratio of observed demand.
577
+ * - `probe`: the half-open trial against a held account. It ALREADY paid at selection, inside
578
+ * {@link resolveHeldAccountDispatch}; charging it again would bill one send twice and shrink
579
+ * the very budget it was admitted from.
580
+ */
581
+ export type PoolRecoveryDispatchClass = "initial" | "retry" | "probe";
582
+
583
+ export interface PoolRecoveryDispatchDecision {
584
+ readonly admitted: boolean;
585
+ /**
586
+ * Earliest moment another recovery dispatch could be admitted. `now` when the send was
587
+ * admitted; otherwise a real change point strictly in the future, so a refused caller has
588
+ * something to wait on instead of busy-looping against a pool that is already failing.
589
+ */
590
+ readonly retryAt: number;
591
+ }
592
+
593
+ /**
594
+ * Admit one physical send against the process-wide recovery window.
595
+ *
596
+ * Per-request send budgets cannot see a storm: thousands of requests each staying inside their
597
+ * own allowance still compose into an unbounded rate against one failing upstream. This is the
598
+ * layer above them, and it is shared by construction.
599
+ */
600
+ export function classifyPoolRecoveryDispatch(
601
+ dispatchClass: PoolRecoveryDispatchClass,
602
+ now = Date.now(),
603
+ limiter: PoolBackpressureLimiter = sharedPoolBackpressure(),
604
+ ): PoolRecoveryDispatchDecision {
605
+ if (dispatchClass === "initial") {
606
+ limiter.recordInitialSend(now);
607
+ return { admitted: true, retryAt: now };
608
+ }
609
+ if (dispatchClass === "probe") return { admitted: true, retryAt: now };
610
+ return limiter.tryPermitRetryDispatch(now)
611
+ ? { admitted: true, retryAt: now }
612
+ : { admitted: false, retryAt: limiter.nextRecoveryAt(now) };
613
+ }
@@ -170,7 +170,9 @@ async function handleChatCompletionsWithBudget(
170
170
  if (chatBody.tools !== undefined) parts.push(JSON.stringify(chatBody.tools));
171
171
  logCtx.usageLogInputTokens = Math.max(1, estimateTokens(parts.join("\n"), requestedModel));
172
172
  }
173
- if (!effortRow && isNativeChatRouteEligible(route, chatBody, config)) chatNativeRoute = route;
173
+ // Combos must enter the Responses routing path so child selection, forced default
174
+ // effort, failover, and per-attempt telemetry run before any native Chat send.
175
+ if (!route.combo && !effortRow && isNativeChatRouteEligible(route, chatBody, config)) chatNativeRoute = route;
174
176
  } catch (err) {
175
177
  if (err instanceof UnknownRoutingPolicyError) {
176
178
  logCtx.requestedModel = requestedModel;