@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
@@ -108,6 +108,11 @@ export type CodexWsStageRecord = Omit<CodexWsFailureStage, "requestBytes"> & {
108
108
  const codexWsStageByResponse = new WeakMap<Response, CodexWsStageRecord>();
109
109
 
110
110
  export function markCodexWsStage(response: Response, record: CodexWsStageRecord): void {
111
+ const current = codexWsStageByResponse.get(response);
112
+ if (current) {
113
+ Object.assign(current, record);
114
+ return;
115
+ }
111
116
  codexWsStageByResponse.set(response, record);
112
117
  }
113
118
 
@@ -30,7 +30,7 @@ import {
30
30
  } from "../../combos";
31
31
  import { isInjectionDebugEnabled } from "../../lib/debug-settings";
32
32
  import { injectionDebugLog } from "../../lib/injection-debug-log";
33
- import { dottedToolName, modelInList, namespacedToolName, toolChoiceToolPredicate } from "../../types";
33
+ import { dottedToolName, modelInList, namespacedToolName, NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES, toolChoiceToolPredicate } from "../../types";
34
34
  import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage } from "../../types";
35
35
  import {
36
36
  forceRefreshOAuthAccessSnapshot,
@@ -83,7 +83,6 @@ import {
83
83
  catalogModelSupportsServiceTier,
84
84
  finishRequestAttempt,
85
85
  inspectResponseLogJson,
86
- noteAttemptSend,
87
86
  readConfiguredCodexServiceTier,
88
87
  requestLogSpeedLabel,
89
88
  sealRequestAttemptIdentity,
@@ -152,6 +151,36 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
152
151
  dottedAliasOwners.set(t.name, null);
153
152
  }
154
153
  }
154
+ // Bare echo alias (`name` with no namespace spelling, #4679): some providers — observed
155
+ // on the muse family via Command Code — echo a namespaced tool by its bare name. The
156
+ // bare spelling is only a safe alias while it names ONE tool and cannot be read as
157
+ // another identity's canonical or dotted spelling.
158
+ // Code-mode helper spellings never gain a bare alias (#4679 review), whatever namespace
159
+ // declares them and whatever put the alias there. The list is owned by `src/types/tools.ts`,
160
+ // beside the names it protects, because the copy that used to live here drifted to a single
161
+ // namespace and had to be widened twice.
162
+ const bareAliasOwners = new Map<string, string | null>();
163
+ for (const t of authorizedTools) {
164
+ // Bare (no-namespace) declarations participate as owners too: a namespaced tool whose
165
+ // bare name equals a bare-declared function must not gain the bare alias, mirroring how
166
+ // the tool_choice bare path refuses ambiguous owners across the whole request catalog.
167
+ const identity = JSON.stringify([t.namespace ?? null, t.name]);
168
+ const owner = bareAliasOwners.get(t.name);
169
+ if (owner === undefined) bareAliasOwners.set(t.name, identity);
170
+ else if (owner !== identity) bareAliasOwners.set(t.name, null);
171
+ }
172
+ for (const t of authorizedTools) {
173
+ const canonical = namespacedToolName(t.namespace, t.name);
174
+ const owner = bareAliasOwners.get(canonical);
175
+ if (owner !== undefined && owner !== JSON.stringify([t.namespace, t.name])) {
176
+ bareAliasOwners.set(canonical, null);
177
+ }
178
+ const dotted = dottedToolName(t.namespace, t.name);
179
+ const dottedOwner = bareAliasOwners.get(dotted);
180
+ if (dottedOwner !== undefined && dottedOwner !== JSON.stringify([t.namespace, t.name])) {
181
+ bareAliasOwners.set(dotted, null);
182
+ }
183
+ }
155
184
  for (const t of authorizedTools) {
156
185
  // Upstream output is untrusted: only restore calls for tools the caller authorized.
157
186
  const wireName = namespacedToolName(t.namespace, t.name);
@@ -175,6 +204,30 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
175
204
  toolNsMap.set(dottedName, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) });
176
205
  if (t.parameters && typeof t.parameters === "object") toolParameterSchemas.set(dottedName, t.parameters);
177
206
  }
207
+ // Bare echo alias (`name` with no namespace spelling, #4679): same tool identity as
208
+ // the flattened wire name, so a provider that drops the namespace prefix still
209
+ // restores against this entry. Ambiguous bare names were resolved to null above;
210
+ // skipping them falls back to the spellings every provider can still echo.
211
+ // Code-mode helper spellings never gain a bare alias, from ANY namespace (#4792 review).
212
+ // Scoping this to `collaboration` was too narrow to be a boundary: a declaration such as
213
+ // `mcp__remote.exec` donated bare `exec` to the declared set, and normalizeDeclaredToolName
214
+ // then rewrote an undeclared `apply_patch`, `exec_command` or `write_stdin` onto it
215
+ // (src/types/tools.ts). No namespace may turn helper normalization on for a catalog that
216
+ // never declared the code-mode shell.
217
+ //
218
+ // The namespaced tool stays usable AS ITSELF: `ns__name` is added unconditionally above
219
+ // and `ns.name` whenever it is unambiguous, so only the namespace-dropping echo fallback
220
+ // is withdrawn, and only for these six spellings.
221
+ if (
222
+ bareAliasOwners.get(t.name) === JSON.stringify([t.namespace, t.name])
223
+ && !NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES.has(t.name)
224
+ ) {
225
+ budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
226
+ declaredToolNames.add(t.name);
227
+ budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([t.name, t.namespace, t.name])).byteLength, { kind: "retained_collectors" });
228
+ toolNsMap.set(t.name, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) });
229
+ if (t.parameters && typeof t.parameters === "object") toolParameterSchemas.set(t.name, t.parameters);
230
+ }
178
231
  }
179
232
  if (t.freeform) {
180
233
  budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
@@ -201,6 +254,20 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
201
254
  // Some routed providers echo a bare tool_choice selector instead of the flattened catalog
202
255
  // name. Accept only selectors the client actually sent and only when the full request catalog
203
256
  // contains one tool with that logical name.
257
+ //
258
+ // A helper spelling selected this way is split rather than refused (#4819). The two things a
259
+ // bare alias does are separable, and passthrough already relies on that: identity RESTORATION
260
+ // runs before authorization there, rewriting the echoed bare name to the namespaced identity
261
+ // the caller declared, and the guard then authorizes `ns__name`. DECLARATION is the part that
262
+ // is unsafe, because a declared-name set carrying bare `exec` is what makes
263
+ // `normalizeDeclaredToolName` rewrite an undeclared `apply_patch`, `exec_command` or
264
+ // `write_stdin` onto the selected tool (src/types/tools.ts).
265
+ //
266
+ // So a helper spelling gets the `toolNsMap` entry and not the `declaredToolNames` entry. The
267
+ // caller nominated exactly one tool by name, `bareNameCounts` proves nothing else answers to
268
+ // it, and restoring it authorizes nothing the request did not already declare. The echo path
269
+ // above withholds both, because a bare echo is a guess rather than a nomination and #4679
270
+ // pinned that shape (`tests/responses/bare-echo-alias.test.ts`).
204
271
  const choice = parsed.options.toolChoice;
205
272
  const bareChoiceNames = new Set(
206
273
  choice && typeof choice === "object"
@@ -213,8 +280,11 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
213
280
  }
214
281
  for (const t of authorizedTools) {
215
282
  if (!t.namespace || !bareChoiceNames.has(t.name) || bareNameCounts.get(t.name) !== 1 || declaredToolNames.has(t.name)) continue;
216
- budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
217
- declaredToolNames.add(t.name);
283
+ // Restore the identity; declare the name only when it is not a helper spelling.
284
+ if (!NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES.has(t.name)) {
285
+ budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
286
+ declaredToolNames.add(t.name);
287
+ }
218
288
  budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([t.name, t.namespace, t.name])).byteLength, { kind: "retained_collectors" });
219
289
  toolNsMap.set(t.name, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) });
220
290
  if (t.parameters && typeof t.parameters === "object") {
@@ -8,14 +8,46 @@ interface ComboRecallEntry {
8
8
  target: Pick<OcxComboTarget, "provider" | "model">;
9
9
  responseModel: string;
10
10
  at: number;
11
+ /** UTF-8 size of `responseModel`, the only client-influenced field of unbounded length. */
12
+ bytes: number;
11
13
  }
12
14
 
13
15
  const RECALL_CAPACITY = 256;
14
16
  const RECALL_TTL_MS = 30 * 60 * 1000;
17
+ /**
18
+ * A model id is provider-reported and arrives on the response, so nothing upstream of here
19
+ * bounds its length. Lane keys are already SHA-256 digests, so the model string is the only
20
+ * field that can grow, and 256 lanes alone do not bound the bytes they hold.
21
+ */
22
+ const RECALL_MODEL_BYTES_MAX = 1024;
23
+ const RECALL_TOTAL_BYTES_MAX = 64 * 1024;
15
24
  const recall = new Map<string, ComboRecallEntry>();
25
+ let recallBytes = 0;
16
26
  let lastReconciledGeneration = 0;
17
27
  let liveOwners: Pick<GenerationContext, "comboIds" | "comboTargets" | "providerNames"> | undefined;
18
28
 
29
+ /** Every removal path goes through here so the byte counter can never drift from the map. */
30
+ function deleteEntry(lane: string): boolean {
31
+ const entry = recall.get(lane);
32
+ if (!entry) return false;
33
+ recall.delete(lane);
34
+ recallBytes -= entry.bytes;
35
+ return true;
36
+ }
37
+
38
+ /**
39
+ * UTF-8 size of a remembered model id, or null when it is too large to retain.
40
+ *
41
+ * The code-unit test runs first and is the part that matters: a UTF-8 encoding is never smaller
42
+ * than the code-unit count, so an oversized string is rejected without encoding it, and the
43
+ * bound cannot be defeated by paying the allocation it exists to prevent.
44
+ */
45
+ function boundedModelBytes(responseModel: string): number | null {
46
+ if (responseModel.length > RECALL_MODEL_BYTES_MAX) return null;
47
+ const bytes = Buffer.byteLength(responseModel, "utf8");
48
+ return bytes > RECALL_MODEL_BYTES_MAX ? null : bytes;
49
+ }
50
+
19
51
  function ownsEntry(context: Pick<GenerationContext, "comboIds" | "comboTargets" | "providerNames">, entry: ComboRecallEntry): boolean {
20
52
  return context.comboIds.has(entry.comboId)
21
53
  && context.providerNames.has(entry.target.provider)
@@ -32,14 +64,29 @@ export function rememberComboForLane(
32
64
  if (!lane || !comboId || !responseModel.trim()) return;
33
65
  // Reject even a same-named recreated owner: its previous in-flight turn is obsolete.
34
66
  if (writerGeneration < Math.max(lastReconciledGeneration, captureConfigGeneration())) return;
35
- const entry = { comboId, target: { provider: target.provider, model: target.model }, responseModel, at: Date.now() };
67
+ // An unretainable model id DECLINES the write; it must not clear the lane. Every other
68
+ // rejection above returns the same way, and clearing here would let a late completion erase
69
+ // a newer selection that this function has no ordering information to compare against.
70
+ const bytes = boundedModelBytes(responseModel);
71
+ if (bytes === null) return;
72
+ const entry = {
73
+ comboId,
74
+ target: { provider: target.provider, model: target.model },
75
+ responseModel,
76
+ at: Date.now(),
77
+ bytes,
78
+ };
36
79
  if (liveOwners && !ownsEntry(liveOwners, entry)) return;
37
- recall.delete(lane);
80
+ deleteEntry(lane);
38
81
  recall.set(lane, entry);
39
- while (recall.size > RECALL_CAPACITY) {
82
+ recallBytes += bytes;
83
+ // Insertion order is recency order, because every write re-inserts its lane at the back.
84
+ // Evicting from the front therefore drops the least recently written lane, never this one:
85
+ // a single entry is capped well below the aggregate budget, so it always fits.
86
+ while (recall.size > RECALL_CAPACITY || recallBytes > RECALL_TOTAL_BYTES_MAX) {
40
87
  const oldest = recall.keys().next().value;
41
- if (oldest === undefined) break;
42
- recall.delete(oldest);
88
+ if (oldest === undefined || oldest === lane) break;
89
+ deleteEntry(oldest);
43
90
  }
44
91
  }
45
92
 
@@ -57,12 +104,25 @@ export function recallComboForLane(
57
104
  || !Object.hasOwn(config.providers, entry.target.provider)
58
105
  || !provider || provider.disabled === true
59
106
  || !combo?.targets.some(target => targetKey(target) === targetKey(entry.target))) {
60
- recall.delete(lane);
107
+ deleteEntry(lane);
61
108
  return undefined;
62
109
  }
63
110
  return entry.responseModel === model ? entry.comboId : undefined;
64
111
  }
65
112
 
113
+ /**
114
+ * Periodic expiry. Without it a lane that is never read again and never touched by a config
115
+ * reconciliation holds its entry for the life of the process: the existing TTL is only
116
+ * evaluated on read or on generation change.
117
+ */
118
+ export function sweepExpiredComboRecall(now: number): number {
119
+ let removed = 0;
120
+ for (const [lane, entry] of recall) {
121
+ if (now - entry.at >= RECALL_TTL_MS && deleteEntry(lane)) removed += 1;
122
+ }
123
+ return removed;
124
+ }
125
+
66
126
  export function reconcileComboRecall(context: GenerationContext): number {
67
127
  if (context.generation <= lastReconciledGeneration) return 0;
68
128
  lastReconciledGeneration = context.generation;
@@ -74,8 +134,7 @@ export function reconcileComboRecall(context: GenerationContext): number {
74
134
  let removed = 0;
75
135
  for (const [lane, entry] of recall) {
76
136
  if (!ownsEntry(context, entry) || Date.now() - entry.at >= RECALL_TTL_MS) {
77
- recall.delete(lane);
78
- removed += 1;
137
+ if (deleteEntry(lane)) removed += 1;
79
138
  }
80
139
  }
81
140
  return removed;
@@ -84,6 +143,7 @@ export function reconcileComboRecall(context: GenerationContext): number {
84
143
  /** Test-only reset, alongside the combo rotation/cooldown resets. */
85
144
  export function clearComboRecallForTests(): void {
86
145
  recall.clear();
146
+ recallBytes = 0;
87
147
  lastReconciledGeneration = 0;
88
148
  liveOwners = undefined;
89
149
  }
@@ -51,7 +51,9 @@ import {
51
51
  materializeCodexUpstreamAuthAsync,
52
52
  isCodexAuthContextUsable,
53
53
  resolveCodexAuthContext,
54
+ codexPoolAffinityKey,
54
55
  codexProbeLeaseId,
56
+ codexTransientProbeGrant,
55
57
  codexProbeQuotaScope,
56
58
  releaseCodexAuthContextProbeLease,
57
59
  stripCodexRuntimeProviderFields,
@@ -67,6 +69,13 @@ import {
67
69
  recordCodexUpstreamOutcome,
68
70
  type CodexUpstreamOutcome,
69
71
  } from "../../codex/routing";
72
+ import {
73
+ applyAccountChangeConversationStateScrub,
74
+ conversationStateBindingFromAuth,
75
+ accountChangeFileReferenceRefusal,
76
+ rememberServingConversationStateIssuer,
77
+ conversationCarriesUploadedFiles,
78
+ } from "./account-change-state";
70
79
  import {
71
80
  TokenRefreshError,
72
81
  forceRefreshCodexPoolToken,
@@ -76,6 +85,7 @@ import {
76
85
  fetchWithResetRetry,
77
86
  fetchWithTransientRetry,
78
87
  applyUpstreamRecoveryInit,
88
+ isNonReplayableResponse,
79
89
  SendBudgetExhaustedError,
80
90
  TRANSIENT_RETRY_MAX_ATTEMPTS,
81
91
  type UpstreamSendRecovery,
@@ -134,7 +144,6 @@ import {
134
144
  catalogModelSupportsServiceTier,
135
145
  finishRequestAttempt,
136
146
  inspectResponseLogJson,
137
- noteAttemptSend,
138
147
  readConfiguredCodexServiceTier,
139
148
  requestLogSpeedLabel,
140
149
  sealRequestAttemptIdentity,
@@ -682,6 +691,13 @@ export async function handleResponsesCompact(
682
691
  // Combo-resolved targets skip native compact so failover can advance through the
683
692
  // combo target list when the picked model returns 429/5xx — the routed path below
684
693
  // dispatches through handleResponses → handleComboResponses with full failover.
694
+ //
695
+ // One holder for the WHOLE logical compact, declared above the native branch because the
696
+ // routed fallback below is not a different request: a native attempt that 404s, or a quota
697
+ // failure that hands off, continues here. The routed turn used to call handleResponses with
698
+ // no budget at all, so `handleResponsesInner` minted a fresh four after the native attempt
699
+ // had already spent some of the first one.
700
+ const sendBudget: RequestExecutionBudget = options.sendBudget ?? createRequestExecutionBudget();
685
701
  if (supportsNativeResponsesCompactEndpoint(route.providerName, route.provider) && !accountGatedCompactWireModel && !route.combo) {
686
702
  if (req.signal.aborted) {
687
703
  return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
@@ -722,6 +738,14 @@ export async function handleResponsesCompact(
722
738
  beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease),
723
739
  signal: req.signal,
724
740
  nativeMainRefreshDependencies: options.nativeMainRefreshDependencies,
741
+ // #4778: the same retention the regular Responses path passes at its final auth. The
742
+ // post-429 guard below only declines to move AFTER this resolution has already bound
743
+ // an account, so without the bit here a quota-driven rebind could have carried the
744
+ // conversation off its issuing account before that guard is ever consulted -- and an
745
+ // uploaded file is readable only by the account that received it. Answered from `raw`,
746
+ // the same object and the same predicate the guard below uses, so the two can never
747
+ // disagree about which conversations are in scope.
748
+ retainAccountForUploadedFiles: conversationCarriesUploadedFiles(raw),
725
749
  });
726
750
  logCtx.accountLogLabel = codexAuthContextLogLabel(authCtx, config);
727
751
  const selected = await materializeCodexUpstreamAuthAsync(req.headers, authCtx, {
@@ -773,10 +797,32 @@ export async function handleResponsesCompact(
773
797
  // buildRequest, but the compact endpoint forwards directly. Apply the same sanitizer here
774
798
  // so routed-model reasoning items (reasoning_text content) don't 400 the ChatGPT backend.
775
799
  const compactBody = sanitizeReasoningInputContent(compactBodyRaw) as typeof compactBodyRaw;
800
+ {
801
+ const binding = conversationStateBindingFromAuth(authCtx, codexPoolAffinityKey(req.headers));
802
+ if (binding) {
803
+ // Refused rather than scrubbed: an uploaded file is content the caller attached, not
804
+ // continuation state the turn can do without.
805
+ const refusal = accountChangeFileReferenceRefusal({
806
+ body: raw,
807
+ bindingKey: binding.bindingKey,
808
+ servingAccountId: binding.accountId,
809
+ });
810
+ if (refusal) return refusal;
811
+ applyAccountChangeConversationStateScrub({
812
+ body: raw,
813
+ bindingKey: binding.bindingKey,
814
+ servingAccountId: binding.accountId,
815
+ logCtx,
816
+ });
817
+ applyAccountChangeConversationStateScrub({
818
+ body: compactBody,
819
+ bindingKey: binding.bindingKey,
820
+ servingAccountId: binding.accountId,
821
+ logCtx,
822
+ });
823
+ }
824
+ }
776
825
  const compactUrl = `${base}/responses/compact`;
777
- // One holder for this logical compact, inherited by the handoff child so a second model
778
- // does not start over with a fresh four.
779
- const sendBudget: RequestExecutionBudget = options.sendBudget ?? createRequestExecutionBudget();
780
826
  const compactTargetKey = `${route.providerName}|${route.modelId}|compact`;
781
827
  const actualCompactHostKey = upstreamHostHealthKey(
782
828
  route.providerName,
@@ -842,6 +888,7 @@ export async function handleResponsesCompact(
842
888
  // replacement (#2887). Also covers the replay's own second 401.
843
889
  ...(ctx.kind === "pool" ? { credentialGeneration: ctx.generation } : {}),
844
890
  probeQuotaScope: codexProbeQuotaScope(ctx),
891
+ transientProbe: codexTransientProbeGrant(ctx),
845
892
  writerGeneration: ctx.kind === "pool" || ctx.kind === "main-pool"
846
893
  ? ctx.writerGeneration
847
894
  : undefined,
@@ -1041,6 +1088,10 @@ export async function handleResponsesCompact(
1041
1088
  // — reporting exhausted retries while another pool account sat idle (#913).
1042
1089
  if (
1043
1090
  (upstream.status === 429 || upstream.status === 402)
1091
+ // A replay refusal this proxy synthesized carries 429 for the client's benefit only.
1092
+ // It is not pool quota evidence, and the alternate account below is another send of a
1093
+ // compact turn that may already have been processed.
1094
+ && !isNonReplayableResponse(upstream)
1044
1095
  && !storedPool401ReplayAttempted
1045
1096
  && usesCodexForwardPoolAuth(authCtx, route.provider)
1046
1097
  && !authCtx.fixedAccount
@@ -1055,15 +1106,22 @@ export async function handleResponsesCompact(
1055
1106
  ].filter(Boolean);
1056
1107
  // Build the alternate COMPLETELY before cancelling the first body: if construction
1057
1108
  // throws, the first rejection is still intact and can be returned to the client.
1058
- const alternate = await resolveAlternateCompactContext({
1059
- req,
1060
- admission,
1061
- config,
1062
- route,
1063
- selectedModelId,
1064
- excludeAccountId: authCtx.accountId,
1065
- turnAdmissionLease,
1066
- });
1109
+ // The same reasoning refuses an uploaded-file move here: no alternate can read a file the
1110
+ // issuing account received, so which one is chosen is irrelevant and asking before the
1111
+ // resolution costs nothing. It also reuses the guarantee the comment above depends on --
1112
+ // the first body is still uncancelled -- so the client gets the original rejection rather
1113
+ // than an inaccessible-file error from account B (#4710).
1114
+ const alternate = conversationCarriesUploadedFiles(raw)
1115
+ ? undefined
1116
+ : await resolveAlternateCompactContext({
1117
+ req,
1118
+ admission,
1119
+ config,
1120
+ route,
1121
+ selectedModelId,
1122
+ excludeAccountId: authCtx.accountId,
1123
+ turnAdmissionLease,
1124
+ });
1067
1125
  // Resolution can await a credential refresh, so the client may have gone away
1068
1126
  // while we were choosing B. Re-check before spending anything: recording A,
1069
1127
  // cancelling its body, and sending B are all observable side effects, and B's
@@ -1096,6 +1154,30 @@ export async function handleResponsesCompact(
1096
1154
  await upstream.body?.cancel().catch(() => undefined);
1097
1155
  outcomeCtx = alternate.authCtx;
1098
1156
  logCtx.accountLogLabel = codexAuthContextLogLabel(alternate.authCtx, config);
1157
+ {
1158
+ const binding = conversationStateBindingFromAuth(
1159
+ alternate.authCtx,
1160
+ (authCtx.kind === "pool" || authCtx.kind === "main-pool")
1161
+ ? authCtx.affinityKey
1162
+ : codexPoolAffinityKey(req.headers),
1163
+ );
1164
+ if (binding) {
1165
+ applyAccountChangeConversationStateScrub({
1166
+ body: raw,
1167
+ bindingKey: binding.bindingKey,
1168
+ servingAccountId: binding.accountId,
1169
+ priorAccountId: authCtx.accountId,
1170
+ logCtx,
1171
+ });
1172
+ applyAccountChangeConversationStateScrub({
1173
+ body: compactBody,
1174
+ bindingKey: binding.bindingKey,
1175
+ servingAccountId: binding.accountId,
1176
+ priorAccountId: authCtx.accountId,
1177
+ logCtx,
1178
+ });
1179
+ }
1180
+ }
1099
1181
  try {
1100
1182
  upstream = await sendCompactAttempt(alternate.provider, alternate.headers, "single", alternate.authCtx);
1101
1183
  } catch (err) {
@@ -1142,8 +1224,14 @@ export async function handleResponsesCompact(
1142
1224
  const bufferedErrorText = buffered.ok
1143
1225
  ? ""
1144
1226
  : await buffered.clone().text().catch(() => "");
1145
- const explicitQuotaStatus = buffered.status === 429 || buffered.status === 402;
1146
- const bodyInferredQuota = !buffered.ok
1227
+ // The client-facing 429 of a synthesized replay refusal says nothing about this
1228
+ // account's quota. Pool accounting keeps reading it as the transport failure it is,
1229
+ // which is also what it recorded before the status was corrected for the client.
1230
+ const replayRefused = isNonReplayableResponse(upstream);
1231
+ const explicitQuotaStatus = !replayRefused
1232
+ && (buffered.status === 429 || buffered.status === 402);
1233
+ const bodyInferredQuota = !replayRefused
1234
+ && !buffered.ok
1147
1235
  && !explicitQuotaStatus
1148
1236
  && isRateLimitOrQuotaFailureMessage(bufferedErrorText);
1149
1237
  const quotaFailure = explicitQuotaStatus || bodyInferredQuota;
@@ -1156,13 +1244,18 @@ export async function handleResponsesCompact(
1156
1244
  // A body-confirmed quota failure can arrive behind a generic 5xx. Record it as
1157
1245
  // quota evidence; otherwise preserve the real upstream status so a local buffering
1158
1246
  // failure after a 200 cannot soft-avoid a healthy account or rotate a thread.
1159
- recordCompactPoolOutcome(outcomeCtx, bodyInferredQuota ? 429 : upstream.status, { retryAfter, resetAt });
1247
+ recordCompactPoolOutcome(
1248
+ outcomeCtx,
1249
+ bodyInferredQuota ? 429 : replayRefused ? 502 : upstream.status,
1250
+ { retryAfter, resetAt },
1251
+ );
1160
1252
  // Lift usage and response metadata from the buffered upstream JSON into the
1161
1253
  // request log; the routed branch gets the same through handleResponses. The
1162
1254
  // synthetic buffer errors are not upstream bodies and stay uninspected.
1163
1255
  if (buffered.ok) {
1164
1256
  inspectResponseLogJson(logCtx, await buffered.clone().text());
1165
1257
  forgetCompactHandoffRoute(req);
1258
+ rememberServingConversationStateIssuer(outcomeCtx, codexPoolAffinityKey(req.headers));
1166
1259
  } else if (quotaFailure && !storedPool401ReplayAttempted) {
1167
1260
  const fallbackModel = compactHandoffRoute(req, raw.model);
1168
1261
  if (fallbackModel && !req.signal.aborted) {
@@ -1223,7 +1316,10 @@ export async function handleResponsesCompact(
1223
1316
  body: JSON.stringify(internalBody),
1224
1317
  });
1225
1318
  linkRequestSessionLane(req, internalReq);
1226
- const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, ...(admission ? { admission } : {}) });
1319
+ // The routed compaction turn is a handoff inside the same logical request, so it draws the
1320
+ // REMAINDER. Minting here is what let a native attempt spend three sends and the routed
1321
+ // fallback spend four more.
1322
+ const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, sendBudget, ...(admission ? { admission } : {}) });
1227
1323
  if (!response.ok) return response;
1228
1324
  let json: { output?: unknown[]; status?: unknown; error?: unknown };
1229
1325
  if (response.headers.get("content-type")?.includes("text/event-stream")) {
@@ -0,0 +1,33 @@
1
+ import type { ResponsesRequestContext } from "./core-options";
2
+ import type { ResponsesSidecarAuth } from "./request-sidecar-auth";
3
+ import { emptyCompletionRetryEnabled } from "./empty-completion-guard";
4
+
5
+ /** One responsibility of the Responses request pipeline; state owners are explicit. */
6
+ export function createResponsesCompletionPolicy(
7
+ requestContext: Pick<ResponsesRequestContext, "config" | "options">,
8
+ sidecarState: Pick<ResponsesSidecarAuth, "routedCompaction">,
9
+ ) {
10
+ const { config, options } = requestContext;
11
+ const { routedCompaction } = sidecarState;
12
+
13
+
14
+ // Empty-completion guard (codex-router PR #145 port): a 200 that completes with no output
15
+ // text and no tool call is a failure the client cannot see — it silently records the turn as
16
+ // done. The guard holds pre-content adapter events, suppresses the terminal of an empty
17
+ // turn, retries the IDENTICAL request once, and surfaces a stated error when the retry is
18
+ // empty or fails. This is a top-level config opt-in; OCX_EMPTY_COMPLETION_RETRY=0 is a
19
+ // disable-only emergency override. Compaction turns and combo attempts keep their own
20
+ // machinery (the combo preflight already handles empty streams). Native Chat-to-Chat
21
+ // requests return from handleChatCompletions before entering Responses core, so they are
22
+ // intentionally outside this guard and retain their existing one-send wire behavior.
23
+ const emptyCompletionGuardEnabled =
24
+ emptyCompletionRetryEnabled(config)
25
+ && !options.comboAttempt
26
+ && !routedCompaction;
27
+
28
+ return {
29
+ emptyCompletionGuardEnabled,
30
+ };
31
+ }
32
+
33
+ export type ResponsesCompletionPolicy = Exclude<ReturnType<typeof createResponsesCompletionPolicy>, Response>;