@bitkyc08/opencodex 2.10.0 → 2.10.1

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 (274) hide show
  1. package/AGENTS_INSTALL.md +77 -0
  2. package/README.md +4 -10
  3. package/bin/ocx.mjs +71 -18
  4. package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
  5. package/gui/dist/assets/index-ChZQsmBY.js +70 -0
  6. package/gui/dist/index.html +2 -2
  7. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  8. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  9. package/gui/dist/provider-icons/claude-color.svg +1 -1
  10. package/gui/dist/provider-icons/cline-color.svg +16 -0
  11. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  12. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  13. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  14. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  15. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  16. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  17. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  18. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  19. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  20. package/gui/dist/provider-icons/grok.svg +1 -1
  21. package/gui/dist/provider-icons/groq-color.svg +1 -1
  22. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  23. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  24. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  25. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  26. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  27. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  28. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  29. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  30. package/gui/dist/provider-icons/openai.svg +1 -1
  31. package/gui/dist/provider-icons/opencode.svg +2 -1
  32. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  33. package/gui/dist/provider-icons/pi.svg +2 -2
  34. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  35. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  36. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  37. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  38. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  39. package/package.json +8 -4
  40. package/src/adapters/anthropic.ts +208 -14
  41. package/src/adapters/base.ts +16 -5
  42. package/src/adapters/cursor/effort-map.ts +3 -2
  43. package/src/adapters/cursor/framing.ts +39 -0
  44. package/src/adapters/cursor/live-transport.ts +105 -95
  45. package/src/adapters/cursor/native-exec.ts +32 -6
  46. package/src/adapters/cursor/protobuf-request.ts +20 -15
  47. package/src/adapters/cursor/request-builder.ts +21 -7
  48. package/src/adapters/cursor/types.ts +7 -0
  49. package/src/adapters/google-antigravity-replay.ts +237 -21
  50. package/src/adapters/google-truncation.ts +11 -0
  51. package/src/adapters/google.ts +50 -9
  52. package/src/adapters/identity.ts +39 -6
  53. package/src/adapters/kiro-errors.ts +11 -0
  54. package/src/adapters/kiro-events.ts +19 -1
  55. package/src/adapters/kiro-thinking.ts +10 -2
  56. package/src/adapters/kiro-tools.ts +10 -1
  57. package/src/adapters/kiro.ts +37 -11
  58. package/src/adapters/openai-chat.ts +284 -83
  59. package/src/adapters/openai-responses.ts +182 -24
  60. package/src/bridge.ts +177 -7
  61. package/src/chat/outbound.ts +78 -23
  62. package/src/claude/agents-inject.ts +27 -5
  63. package/src/claude/inbound.ts +11 -1
  64. package/src/claude/model-info.ts +13 -10
  65. package/src/claude/outbound.ts +17 -0
  66. package/src/cli/account-api.ts +24 -0
  67. package/src/cli/account-auth.ts +31 -6
  68. package/src/cli/account-main.ts +317 -0
  69. package/src/cli/account.ts +5 -0
  70. package/src/cli/claude.ts +2 -1
  71. package/src/cli/doctor.ts +93 -22
  72. package/src/cli/export-command.ts +26 -12
  73. package/src/cli/help.ts +8 -6
  74. package/src/cli/index.ts +56 -22
  75. package/src/cli/integrations.ts +84 -1
  76. package/src/cli/observe.ts +54 -1
  77. package/src/cli/opencode.ts +2 -1
  78. package/src/cli/provider-runtime.ts +18 -1
  79. package/src/cli/route-policy.ts +92 -0
  80. package/src/cli/runtime-api.ts +6 -3
  81. package/src/cli/star-prompt.ts +71 -15
  82. package/src/cli/status.ts +1 -1
  83. package/src/cli/v2.ts +36 -9
  84. package/src/clients/config-export.ts +687 -10
  85. package/src/codex/account-lifecycle.ts +30 -5
  86. package/src/codex/account-usability.ts +22 -2
  87. package/src/codex/admission.ts +255 -0
  88. package/src/codex/auth-api.ts +427 -140
  89. package/src/codex/auth-context.ts +155 -30
  90. package/src/codex/autostart-health.ts +8 -1
  91. package/src/codex/catalog/account-models.ts +62 -0
  92. package/src/codex/catalog/aggregation.ts +14 -1
  93. package/src/codex/catalog/bundled.ts +282 -32
  94. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  95. package/src/codex/catalog/metadata.ts +51 -6
  96. package/src/codex/catalog/parsing.ts +6 -3
  97. package/src/codex/catalog/provider-fetch.ts +576 -41
  98. package/src/codex/catalog/sync.ts +505 -66
  99. package/src/codex/catalog-admission.ts +197 -0
  100. package/src/codex/catalog-write-serialization.ts +241 -0
  101. package/src/codex/catalog.ts +2 -1
  102. package/src/codex/codex-write-lock.ts +372 -0
  103. package/src/codex/convergence-types.ts +593 -0
  104. package/src/codex/convergence.ts +441 -0
  105. package/src/codex/desired-state.ts +177 -0
  106. package/src/codex/features.ts +52 -8
  107. package/src/codex/generation.ts +202 -0
  108. package/src/codex/history-job.ts +257 -0
  109. package/src/codex/history-lock.ts +241 -0
  110. package/src/codex/history-migration-guardian.ts +18 -5
  111. package/src/codex/history-provider.ts +9 -2
  112. package/src/codex/history-transition.ts +105 -0
  113. package/src/codex/history-worker.ts +176 -0
  114. package/src/codex/inject-coordination.ts +245 -0
  115. package/src/codex/inject.ts +605 -124
  116. package/src/codex/integration-record.ts +266 -0
  117. package/src/codex/internal/catalog-writer.ts +203 -0
  118. package/src/codex/internal/history-writer.ts +80 -0
  119. package/src/codex/journal.ts +10 -1
  120. package/src/codex/main-account-cache.ts +24 -0
  121. package/src/codex/management-convergence.ts +114 -0
  122. package/src/codex/native-main-admission.ts +47 -0
  123. package/src/codex/native-main-auth-temp.ts +187 -0
  124. package/src/codex/native-main-claim.ts +167 -0
  125. package/src/codex/native-main-lock-file.ts +158 -0
  126. package/src/codex/native-main-owner.ts +315 -0
  127. package/src/codex/native-profile-api.ts +247 -0
  128. package/src/codex/native-profile-manager.ts +1512 -0
  129. package/src/codex/native-profile-processes.ts +121 -0
  130. package/src/codex/native-profile-recovery.ts +99 -0
  131. package/src/codex/native-profile-stage-store.ts +387 -0
  132. package/src/codex/native-profile-startup.ts +340 -0
  133. package/src/codex/native-profile-store.ts +855 -0
  134. package/src/codex/native-profile-types.ts +120 -0
  135. package/src/codex/native-residue.ts +557 -0
  136. package/src/codex/project-config-warnings.ts +18 -4
  137. package/src/codex/prompt-journal.ts +311 -0
  138. package/src/codex/prompt-layers.ts +967 -0
  139. package/src/codex/prompt-lock.ts +143 -0
  140. package/src/codex/quota-rejection.ts +224 -0
  141. package/src/codex/quota.ts +86 -3
  142. package/src/codex/routing.ts +299 -62
  143. package/src/codex/runtime.ts +159 -38
  144. package/src/codex/shim.ts +39 -13
  145. package/src/codex/subagent-model-fallback.ts +73 -12
  146. package/src/codex/transition-state.ts +604 -0
  147. package/src/codex/upstream-host-health.ts +70 -0
  148. package/src/codex/user-identity.ts +266 -0
  149. package/src/codex/write-coordination.ts +114 -0
  150. package/src/config.ts +562 -26
  151. package/src/generated/jawcode-model-metadata.ts +2 -2
  152. package/src/grok/inject.ts +15 -4
  153. package/src/grok/inspect.ts +45 -0
  154. package/src/images/loop.ts +113 -20
  155. package/src/integrations/config-io.ts +151 -0
  156. package/src/integrations/journal.ts +315 -0
  157. package/src/integrations/merge.ts +135 -0
  158. package/src/integrations/native/ownership-preflight.ts +165 -0
  159. package/src/integrations/ownership.ts +111 -0
  160. package/src/integrations/registry.ts +101 -0
  161. package/src/integrations/serialize.ts +235 -0
  162. package/src/integrations/state.ts +290 -0
  163. package/src/integrations/store.ts +103 -0
  164. package/src/integrations/writer.ts +492 -0
  165. package/src/lib/bounded-body.ts +46 -8
  166. package/src/lib/bun-runtime.ts +110 -1
  167. package/src/lib/bun-stream-caps.ts +2 -1
  168. package/src/lib/redact.ts +407 -2
  169. package/src/lib/shadow-call.ts +24 -0
  170. package/src/lib/translator-budget.ts +10 -0
  171. package/src/lib/upstream-reachability.ts +91 -0
  172. package/src/lib/upstream-retry.ts +154 -2
  173. package/src/lib/windows-secret-acl.ts +212 -11
  174. package/src/lib/winsw.ts +9 -3
  175. package/src/oauth/index.ts +61 -3
  176. package/src/oauth/key-providers.ts +4 -0
  177. package/src/oauth/kiro.ts +50 -6
  178. package/src/oauth/store.ts +31 -6
  179. package/src/oauth/token-guardian.ts +9 -3
  180. package/src/providers/codex-capacity.ts +288 -0
  181. package/src/providers/derive.ts +33 -1
  182. package/src/providers/free-directory.ts +3 -1
  183. package/src/providers/key-failover.ts +71 -3
  184. package/src/providers/openai-sidecar.ts +64 -4
  185. package/src/providers/openai-virtual-models.ts +1 -0
  186. package/src/providers/quota.ts +334 -26
  187. package/src/providers/registry.ts +284 -16
  188. package/src/providers/xai-transport.ts +11 -4
  189. package/src/responses/compaction.ts +8 -1
  190. package/src/responses/hosted-tool-policy.ts +9 -0
  191. package/src/responses/parser.ts +44 -2
  192. package/src/responses/reasoning-envelope.ts +9 -1
  193. package/src/responses/reasoning-replay-cache.ts +105 -0
  194. package/src/responses/spill-store.ts +45 -8
  195. package/src/responses/state.ts +161 -17
  196. package/src/router.ts +302 -16
  197. package/src/routing/analytics.ts +377 -0
  198. package/src/routing/capability.ts +204 -0
  199. package/src/routing/cost.ts +77 -0
  200. package/src/routing/evaluator.ts +444 -0
  201. package/src/routing/health.ts +401 -0
  202. package/src/routing/history/cursor.ts +43 -0
  203. package/src/routing/history/indexer.ts +590 -0
  204. package/src/routing/history/schema.ts +72 -0
  205. package/src/routing/profile.ts +423 -0
  206. package/src/routing/quota.ts +124 -0
  207. package/src/routing/request-evidence.ts +45 -0
  208. package/src/routing/trace.ts +686 -0
  209. package/src/server/auth-cors.ts +46 -6
  210. package/src/server/chat-completions.ts +28 -13
  211. package/src/server/claude-messages.ts +23 -15
  212. package/src/server/gui-static.ts +39 -10
  213. package/src/server/images.ts +10 -1
  214. package/src/server/index.ts +238 -52
  215. package/src/server/lifecycle.ts +228 -9
  216. package/src/server/live.ts +14 -3
  217. package/src/server/management/agent-settings-routes.ts +64 -14
  218. package/src/server/management/combo-routes.ts +5 -5
  219. package/src/server/management/config-routes.ts +1 -1
  220. package/src/server/management/context.ts +42 -2
  221. package/src/server/management/integration-routes.ts +538 -0
  222. package/src/server/management/logs-usage-routes.ts +1 -1
  223. package/src/server/management/model-routes.ts +32 -113
  224. package/src/server/management/model-rows.ts +117 -0
  225. package/src/server/management/native-integration-routes.ts +587 -0
  226. package/src/server/management/oauth-account-routes.ts +1 -1
  227. package/src/server/management/provider-routes.ts +218 -117
  228. package/src/server/management/request-history-routes.ts +191 -0
  229. package/src/server/management/routing-analytics-routes.ts +74 -0
  230. package/src/server/management/routing-profile-routes.ts +177 -0
  231. package/src/server/management/shared.ts +2 -2
  232. package/src/server/management/sidebar-routes.ts +47 -31
  233. package/src/server/management/sync-response.ts +69 -0
  234. package/src/server/management/system-restart.ts +276 -24
  235. package/src/server/management/system-routes.ts +4 -0
  236. package/src/server/management-api.ts +84 -9
  237. package/src/server/management-auth.ts +43 -5
  238. package/src/server/relay-eager.ts +82 -42
  239. package/src/server/relay.ts +120 -6
  240. package/src/server/request-log.ts +26 -6
  241. package/src/server/responses/collaboration.ts +63 -8
  242. package/src/server/responses/compact.ts +272 -41
  243. package/src/server/responses/core.ts +730 -132
  244. package/src/server/responses/fetch-helpers.ts +15 -1
  245. package/src/server/responses-item-id-repair.ts +32 -3
  246. package/src/server/responses-json-events.ts +52 -0
  247. package/src/server/responses-snapshot-repair.ts +621 -0
  248. package/src/server/search.ts +51 -6
  249. package/src/server/sse-payload-rewrite.ts +89 -12
  250. package/src/server/startup-health-cache.ts +7 -1
  251. package/src/server/ws-bridge.ts +11 -17
  252. package/src/service-manager-probe.ts +297 -0
  253. package/src/service.ts +222 -32
  254. package/src/tray/windows-tray.ps1 +9 -0
  255. package/src/tray/windows.ts +15 -7
  256. package/src/types.ts +194 -14
  257. package/src/update/index.ts +13 -13
  258. package/src/update/job.ts +24 -21
  259. package/src/update/notify.ts +7 -3
  260. package/src/usage/cost.ts +0 -0
  261. package/src/usage/expected-prices.ts +129 -10
  262. package/src/usage/log.ts +50 -15
  263. package/src/usage/summary.ts +4 -4
  264. package/src/vision/index.ts +6 -1
  265. package/src/web-search/loop.ts +161 -34
  266. package/gui/dist/assets/index-OY43ubAq.css +0 -1
  267. package/gui/dist/assets/index-YwNnKZcL.js +0 -67
  268. package/gui/dist/provider-icons/antigravity.svg +0 -1
  269. package/gui/dist/provider-icons/claude.svg +0 -1
  270. package/gui/dist/provider-icons/copilot.svg +0 -1
  271. package/gui/dist/provider-icons/cursor.svg +0 -2
  272. package/gui/dist/provider-icons/gemini.svg +0 -1
  273. package/gui/dist/provider-icons/grok-color.svg +0 -1
  274. package/gui/dist/provider-icons/kiro.svg +0 -14
package/src/bridge.ts CHANGED
@@ -2,11 +2,13 @@ import type { AdapterEvent, OcxMessagePhase, OcxProviderContinuationState, OcxUs
2
2
  import { adapterFailureFromMessage, classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, type OcxErrorPayload } from "./lib/errors";
3
3
  import { encodeCompactionSummary } from "./responses/compaction";
4
4
  import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope";
5
+ import { rememberReasoningForCall } from "./responses/reasoning-replay-cache";
5
6
  import { resolveStallTimeoutSec } from "./stall-timeout";
6
7
  import { usageDisplayTotalTokens } from "./usage/totals";
7
8
  import {
8
9
  isTranslatorBudgetExceededError,
9
10
  releaseTranslatedEvent,
11
+ createTranslatorBudget,
10
12
  type TranslatorBudget,
11
13
  type TranslatorBufferKind,
12
14
  } from "./lib/translator-budget";
@@ -15,6 +17,13 @@ function uuid(): string {
15
17
  return crypto.randomUUID().replace(/-/g, "");
16
18
  }
17
19
 
20
+ /** Test-only: bound the abandoned-owned-budget watchdog delay (null restores). */
21
+ let ownedBudgetAbandonedMs = 10 * 60 * 1000;
22
+ const OWNED_BUDGET_ABANDONED_DEFAULT_MS = ownedBudgetAbandonedMs;
23
+ export function setOwnedBudgetAbandonedMsForTests(ms: number | null): void {
24
+ ownedBudgetAbandonedMs = ms ?? OWNED_BUDGET_ABANDONED_DEFAULT_MS;
25
+ }
26
+
18
27
  function sseEvent(name: string, data: Record<string, unknown>): string {
19
28
  return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
20
29
  }
@@ -107,13 +116,28 @@ function adapterFailureFromEvent(event: Extract<AdapterEvent, { type: "error" }>
107
116
  export { adapterFailureFromMessage } from "./lib/errors";
108
117
 
109
118
  /**
110
- * Build the native `WebSearchAction::Search` payload from the queries that ran. codex-rs prefers a
111
- * non-empty `query` over `queries` for the cell label, and only renders "<first> ..." when `query`
112
- * is absent and `queries.len() > 1`. So a single query → `{ query }`; multiple → `{ queries }` with
113
- * no singular `query`, so Codex shows the native plural ellipsis. Empty → `{ query: "" }`.
119
+ * Build the native `WebSearchAction::Search` payload from the queries that ran.
120
+ *
121
+ * Single query → `{ query, queries: [query] }`. Batch → `{ queries }` with NO singular
122
+ * `query`. Empty → `{ query: "", queries: [""] }`.
123
+ *
124
+ * The asymmetry is load-bearing in both directions. codex-rs prefers a non-empty `query`
125
+ * for the cell label and renders "<first> ..." only when `query` is ABSENT and
126
+ * `queries.len() > 1`, so adding `query` to a batch would collapse the plural ellipsis.
127
+ * Meanwhile DeepSeek's native Responses parser makes `queries` a required field, so a
128
+ * replayed one-term `web_search_call` — carried in the history of every subsequent turn
129
+ * — fails deserialization with `missing field 'queries'` and 400s the rest of the
130
+ * conversation (#930). Carrying both keys in the single case satisfies the strict parser
131
+ * without changing what codex-rs displays.
132
+ *
133
+ * This fixes items created from here on. History recorded before it is repaired at the
134
+ * replay boundary by `backfillWebSearchQueries()` in the Responses adapter.
114
135
  */
115
136
  function webSearchAction(queries: string[]): Record<string, unknown> {
116
- if (queries.length <= 1) return { type: "search", query: queries[0] ?? "" };
137
+ if (queries.length <= 1) {
138
+ const query = queries[0] ?? "";
139
+ return { type: "search", query, queries: [query] };
140
+ }
117
141
  return { type: "search", queries };
118
142
  }
119
143
 
@@ -157,6 +181,12 @@ export function bridgeToResponsesSSE(
157
181
  */
158
182
  onUsage?: (usage: OcxUsage | undefined) => void;
159
183
  translatorBudget?: TranslatorBudget;
184
+ /**
185
+ * Conversation identity for the reasoning replay cache (issue #950).
186
+ * Provider call ids are not globally unique; scoping by thread keeps one
187
+ * conversation's reasoning out of another's continuations.
188
+ */
189
+ replayCacheScope?: string;
160
190
  /**
161
191
  * Test seam for the wire/stall beat loop. Production omits this and uses the
162
192
  * global timers; injecting here must not change scheduling semantics.
@@ -167,6 +197,7 @@ export function bridgeToResponsesSSE(
167
197
  };
168
198
  },
169
199
  ): ReadableStream<Uint8Array> {
200
+ const replayCacheScope = options?.replayCacheScope ?? "global";
170
201
  const setBeatInterval = options?.timers?.setInterval ?? ((handler: () => void, ms: number) => setInterval(handler, ms));
171
202
  const clearBeatInterval = options?.timers?.clearInterval ?? ((id: unknown) => clearInterval(id as ReturnType<typeof setInterval>));
172
203
  // Freeform/custom tools (apply_patch) carry their body in `input`; the model is given a
@@ -208,7 +239,25 @@ export function bridgeToResponsesSSE(
208
239
  try { const o = JSON.parse(args); return o && typeof o === "object" ? o : {}; } catch { return {}; }
209
240
  };
210
241
  const encoder = new TextEncoder();
211
- const budget = options?.translatorBudget;
242
+ // Default-budget safety net: omission is SAFE (default turn limits), never
243
+ // unbounded. Production callers always pass one; an owned default is disposed
244
+ // at terminal/cancel below.
245
+ const ownsBudget = !options?.translatorBudget;
246
+ const budget = options?.translatorBudget ?? createTranslatorBudget();
247
+ // Idempotent: safe to call at every stream-death path; disposal must come
248
+ // AFTER the final charges (emitDone), never inside reportTerminal.
249
+ const disposeOwnedBudget = () => { if (ownsBudget) budget.dispose(); };
250
+ // A dropped stream (never read, never cancelled) reaches no terminal path,
251
+ // so the owned budget would sit in liveBudgets for the process lifetime.
252
+ // One unref'd watchdog per owned budget bounds that to a timeout and clears
253
+ // itself on any settle (the delay is test-overridable).
254
+ const ownedWatchdog = ownsBudget
255
+ ? setTimeout(() => disposeOwnedBudget(), ownedBudgetAbandonedMs)
256
+ : undefined;
257
+ ownedWatchdog?.unref?.();
258
+ const clearOwnedWatchdog = () => {
259
+ if (ownedWatchdog !== undefined) clearTimeout(ownedWatchdog);
260
+ };
212
261
  const bytesOf = (value: string): number => Buffer.byteLength(value);
213
262
  const appendString = (
214
263
  previous: string,
@@ -219,7 +268,6 @@ export function bridgeToResponsesSSE(
219
268
  ): { value: string; bytes: number } => {
220
269
  const fragmentBytes = bytesOf(fragment);
221
270
  const nextBytes = previousBytes + fragmentBytes;
222
- if (!budget) return { value: previous + fragment, bytes: nextBytes };
223
271
  const scope = { kind, ...(callId ? { callId } : {}) };
224
272
  const reservation = budget.reserveTransient(nextBytes, scope);
225
273
  try {
@@ -257,6 +305,7 @@ export function bridgeToResponsesSSE(
257
305
  if (terminalReported || clientCancelled || closed) return;
258
306
  terminalReported = true;
259
307
  try { options?.onTerminal?.(status); } catch { /* terminal metrics must not break the stream */ }
308
+ clearOwnedWatchdog();
260
309
  };
261
310
  // RC3 keep-alive: Codex's idle timer is timeout(idle_timeout, stream.next()) over an
262
311
  // eventsource_stream; ANY received event re-arms it, while an unknown type is ignored
@@ -289,6 +338,7 @@ export function bridgeToResponsesSSE(
289
338
  return;
290
339
  }
291
340
  closed = true;
341
+ disposeOwnedBudget();
292
342
  }
293
343
  };
294
344
  const emitDone = () => {
@@ -384,8 +434,15 @@ export function bridgeToResponsesSSE(
384
434
  // encodeReasoningEnvelope: takeReasoningEnvelope's sig/red guard would drop txt-only.
385
435
  let hiddenRawReasoningText = "";
386
436
  let hiddenRawReasoningBytes = 0;
437
+ // Raw reasoning text flushed most recently, waiting for the tool call it
438
+ // preceded. Recorded into the replay cache on tool_call_start so a later
439
+ // continuation can re-attach it when history lost the reasoning item
440
+ // (issue #950). Kept until new reasoning/text arrives: parallel tool
441
+ // calls share the same preceding reasoning block.
442
+ let rawReasoningForNextToolCall = "";
387
443
  const flushHiddenRawReasoning = () => {
388
444
  if (!hiddenRawReasoningText) return;
445
+ rawReasoningForNextToolCall = hiddenRawReasoningText;
389
446
  const previousBytes = hiddenRawReasoningBytes;
390
447
  const encrypted = encodeReasoningEnvelope({ txt: hiddenRawReasoningText });
391
448
  const reservation = budget?.reserveTransient(bytesOf(encrypted), { kind: "reasoning" });
@@ -400,6 +457,29 @@ export function bridgeToResponsesSSE(
400
457
  retainFinishedItem(item as OutputItem, bytesOf(encrypted), "reasoning");
401
458
  outputIndex++;
402
459
  };
460
+ // Kiro reasoning round-trip. Kiro sends its encrypted blob at the END of a turn, while the
461
+ // assistant message is still open, so this CANNOT emit on arrival: the open message still
462
+ // owns `outputIndex` (it only advances on close), and an item emitted here would both reuse
463
+ // that index and land BEFORE the message — where the parser's backwards pairing drops it as
464
+ // orphaned. Stash it and flush after `done` has closed every open item instead.
465
+ let pendingKiroRedacted: string | undefined;
466
+ let pendingKiroRedactedBytes = 0;
467
+ const flushKiroRedactedReasoning = () => {
468
+ if (!pendingKiroRedacted) return;
469
+ const previousBytes = pendingKiroRedactedBytes;
470
+ const encrypted = encodeReasoningEnvelope({ krc: pendingKiroRedacted });
471
+ const reservation = budget?.reserveTransient(bytesOf(encrypted), { kind: "reasoning" });
472
+ pendingKiroRedacted = undefined;
473
+ pendingKiroRedactedBytes = 0;
474
+ reservation?.commitRetained();
475
+ budget?.releaseRetained(previousBytes, { kind: "reasoning" });
476
+ const itemId = `rs_${uuid()}`;
477
+ const item = { type: "reasoning", id: itemId, summary: [] as never[], encrypted_content: encrypted };
478
+ emit("response.output_item.added", { output_index: outputIndex, item });
479
+ emit("response.output_item.done", { output_index: outputIndex, item });
480
+ retainFinishedItem(item as OutputItem, bytesOf(encrypted), "reasoning");
481
+ outputIndex++;
482
+ };
403
483
  // Full assistant text of a compaction turn (across message boundaries) — becomes the
404
484
  // synthetic compaction item's payload on done.
405
485
  let compactionText = "";
@@ -477,6 +557,7 @@ export function bridgeToResponsesSSE(
477
557
 
478
558
  const closeCurrentRawReasoning = () => {
479
559
  if (!currentRawReasoning) return;
560
+ rawReasoningForNextToolCall = currentRawReasoning.text;
480
561
  const item = {
481
562
  type: "reasoning", id: currentRawReasoning.itemId, summary: [],
482
563
  content: [{ type: "reasoning_text", text: currentRawReasoning.text }],
@@ -682,6 +763,7 @@ export function bridgeToResponsesSSE(
682
763
  beat = undefined;
683
764
  try { controller.close(); } catch { /* already closed */ }
684
765
  closed = true;
766
+ disposeOwnedBudget();
685
767
  gated = true;
686
768
  stepping = false;
687
769
  };
@@ -694,6 +776,15 @@ export function bridgeToResponsesSSE(
694
776
  while (!terminated && !closed && emittedFrames === emittedAtStart) {
695
777
  iteratorStarted = true;
696
778
  const next = await it.next();
779
+ // A cancel during this await disposes the owned budget; a late event
780
+ // must never be processed or charged against it. Exit step() outright:
781
+ // falling into EOF synthesis would let closeCurrentMessage() charge
782
+ // finished-item retention against the disposed budget.
783
+ if (closed || clientCancelled) {
784
+ gated = true;
785
+ stepping = false;
786
+ return;
787
+ }
697
788
  if (next.done) { upstreamDone = true; break; }
698
789
  const event = next.value;
699
790
  let terminalEvent = false;
@@ -727,6 +818,7 @@ export function bridgeToResponsesSSE(
727
818
  if (currentReasoning) closeCurrentReasoning();
728
819
  if (currentRawReasoning) closeCurrentRawReasoning();
729
820
  flushHiddenRawReasoning();
821
+ rawReasoningForNextToolCall = "";
730
822
  if (currentToolCall) closeCurrentToolCall();
731
823
  flushHiddenReasoningEnvelope();
732
824
  break;
@@ -735,6 +827,8 @@ export function bridgeToResponsesSSE(
735
827
  if (currentReasoning) closeCurrentReasoning();
736
828
  if (currentRawReasoning) closeCurrentRawReasoning();
737
829
  flushHiddenRawReasoning();
830
+ // Reasoning consumed by a text turn, not a tool call: no cache target.
831
+ rawReasoningForNextToolCall = "";
738
832
  if (currentToolCall) closeCurrentToolCall();
739
833
  // Only flush on an explicit phase change. A later delta that omits `phase` must
740
834
  // keep appending to the current message rather than wiping the earlier phase.
@@ -769,6 +863,12 @@ export function bridgeToResponsesSSE(
769
863
  }
770
864
  case "thinking_delta": {
771
865
  if (options?.hideThinkingSummary) {
866
+ // The hidden branch returns early, so flush any raw reasoning
867
+ // that preceded the thinking block and clear the replay-cache
868
+ // candidate — otherwise a stale reasoning_raw_delta would be
869
+ // recorded for a LATER tool call (CodeRabbit on #971).
870
+ flushHiddenRawReasoning();
871
+ rawReasoningForNextToolCall = "";
772
872
  ({ value: hiddenThinkingText, bytes: hiddenThinkingBytes } = appendString(
773
873
  hiddenThinkingText,
774
874
  hiddenThinkingBytes,
@@ -780,6 +880,7 @@ export function bridgeToResponsesSSE(
780
880
  if (currentMsg) closeCurrentMessage("commentary");
781
881
  if (currentRawReasoning) closeCurrentRawReasoning();
782
882
  flushHiddenRawReasoning();
883
+ rawReasoningForNextToolCall = "";
783
884
  if (currentToolCall) closeCurrentToolCall();
784
885
  if (!currentReasoning) {
785
886
  const itemId = `rs_${uuid()}`;
@@ -817,6 +918,12 @@ export function bridgeToResponsesSSE(
817
918
  pendingRedacted.push(event.data);
818
919
  break;
819
920
  }
921
+ case "kiro_redacted_reasoning": {
922
+ // Stash only — see flushKiroRedactedReasoning. One blob per turn, so last wins.
923
+ pendingKiroRedactedBytes = replaceRetainedString(pendingKiroRedactedBytes, event.data, "reasoning");
924
+ pendingKiroRedacted = event.data;
925
+ break;
926
+ }
820
927
  case "reasoning_raw_delta": {
821
928
  if (options?.hideThinkingSummary) {
822
929
  ({ value: hiddenRawReasoningText, bytes: hiddenRawReasoningBytes } = appendString(
@@ -853,6 +960,9 @@ export function bridgeToResponsesSSE(
853
960
  if (currentReasoning) closeCurrentReasoning();
854
961
  if (currentRawReasoning) closeCurrentRawReasoning();
855
962
  flushHiddenRawReasoning();
963
+ if (rawReasoningForNextToolCall) {
964
+ rememberReasoningForCall(event.id, rawReasoningForNextToolCall, replayCacheScope);
965
+ }
856
966
  if (currentToolCall) closeCurrentToolCall();
857
967
  const mapped = toolNsMap?.get(event.name);
858
968
  const realName = mapped?.name ?? event.name;
@@ -987,6 +1097,9 @@ export function bridgeToResponsesSSE(
987
1097
  // Redacted-only turns (or hidden thinking without a trailing signature event) still
988
1098
  // need their envelope-only reasoning item so the blocks replay next turn.
989
1099
  flushHiddenReasoningEnvelope();
1100
+ // After every close above, so the blob lands AFTER the assistant message it belongs
1101
+ // to and the parser's backwards pairing finds it.
1102
+ flushKiroRedactedReasoning();
990
1103
  if (options?.compaction) {
991
1104
  // Exactly one compaction item per turn; codex-rs takes the first and fatals on 0.
992
1105
  const item = {
@@ -1141,6 +1254,7 @@ export function bridgeToResponsesSSE(
1141
1254
  /* already closed (e.g. client cancelled) */
1142
1255
  }
1143
1256
  closed = true;
1257
+ disposeOwnedBudget();
1144
1258
  gated = true;
1145
1259
  stepping = false;
1146
1260
  };
@@ -1176,6 +1290,7 @@ export function bridgeToResponsesSSE(
1176
1290
  beat = undefined;
1177
1291
  try { controller.close(); } catch { /* already closed */ }
1178
1292
  closed = true;
1293
+ disposeOwnedBudget();
1179
1294
  return;
1180
1295
  }
1181
1296
  // Wire silence is independent of upstream adapter heartbeats.
@@ -1188,6 +1303,7 @@ export function bridgeToResponsesSSE(
1188
1303
  emittedFrames++;
1189
1304
  } catch {
1190
1305
  closed = true;
1306
+ disposeOwnedBudget();
1191
1307
  }
1192
1308
  }, heartbeatMs);
1193
1309
  };
@@ -1205,13 +1321,31 @@ export function bridgeToResponsesSSE(
1205
1321
  // cancelled turn does not leak the upstream stream or keep draining tokens (RC2).
1206
1322
  clientCancelled = true;
1207
1323
  closed = true;
1324
+ clearOwnedWatchdog();
1208
1325
  if (beat !== undefined) clearBeatInterval(beat);
1209
1326
  cancelUpstreamOnce();
1327
+ disposeOwnedBudget();
1210
1328
  },
1211
1329
  });
1212
1330
  }
1213
1331
 
1214
1332
  export function buildResponseJSON(
1333
+ events: AdapterEvent[],
1334
+ modelId: string,
1335
+ options?: Parameters<typeof buildResponseJSONWithBudget>[2],
1336
+ ): Record<string, unknown> {
1337
+ // Default-budget safety net: a caller that omits the budget gets a bounded
1338
+ // default (disposed with the call), never the unbounded append path.
1339
+ if (options?.translatorBudget) return buildResponseJSONWithBudget(events, modelId, options);
1340
+ const budget = createTranslatorBudget();
1341
+ try {
1342
+ return buildResponseJSONWithBudget(events, modelId, { ...options, translatorBudget: budget });
1343
+ } finally {
1344
+ budget.dispose();
1345
+ }
1346
+ }
1347
+
1348
+ function buildResponseJSONWithBudget(
1215
1349
  events: AdapterEvent[],
1216
1350
  modelId: string,
1217
1351
  options?: {
@@ -1225,9 +1359,12 @@ export function buildResponseJSON(
1225
1359
  /** Raw adapter-reported usage before wire normalization (see bridgeToResponsesSSE onUsage). */
1226
1360
  onUsage?: (usage: OcxUsage | undefined) => void;
1227
1361
  translatorBudget?: TranslatorBudget;
1362
+ /** Conversation identity for the reasoning replay cache (issue #950). */
1363
+ replayCacheScope?: string;
1228
1364
  },
1229
1365
  ): Record<string, unknown> {
1230
1366
  const responseId = `resp_${uuid()}`;
1367
+ const replayCacheScope = options?.replayCacheScope ?? "global";
1231
1368
  const output: OutputItem[] = [];
1232
1369
  const budget = options?.translatorBudget;
1233
1370
  const encoder = new TextEncoder();
@@ -1283,11 +1420,18 @@ export function buildResponseJSON(
1283
1420
  let currentSummaryReasoningBytes = 0;
1284
1421
  let currentRawReasoning = "";
1285
1422
  let currentRawReasoningBytes = 0;
1423
+ // Same replay-cache handoff as the streaming path (issue #950): the most
1424
+ // recently flushed raw reasoning waits for the tool call it preceded.
1425
+ let rawReasoningForNextToolCall = "";
1286
1426
  // Anthropic extended-thinking round-trip (batch): see bridgeToResponsesSSE counterpart.
1287
1427
  let batchSignature: string | undefined;
1288
1428
  let batchSignatureBytes = 0;
1289
1429
  let batchRedacted: string[] = [];
1290
1430
  let batchRedactedBytes = 0;
1431
+ // Kiro reasoning blob, held until after the trailing flushes so it lands AFTER the assistant
1432
+ // message (see the streaming path). Retained because it outlives releaseTranslatedEvent.
1433
+ let batchKiroRedacted: string | undefined;
1434
+ let batchKiroRedactedBytes = 0;
1291
1435
  let currentToolCallId = "";
1292
1436
  let currentToolCallName = "";
1293
1437
  let currentToolCallArgs = "";
@@ -1350,6 +1494,7 @@ export function buildResponseJSON(
1350
1494
  };
1351
1495
  const flushRawReasoning = () => {
1352
1496
  if (!currentRawReasoning) return;
1497
+ rawReasoningForNextToolCall = currentRawReasoning;
1353
1498
  if (options?.hideThinkingSummary === true) {
1354
1499
  // Same contract as the streaming path: no visible reasoning, txt-only envelope round-trip.
1355
1500
  pushOutput({
@@ -1407,6 +1552,7 @@ export function buildResponseJSON(
1407
1552
  flushText("commentary");
1408
1553
  flushSummaryReasoning();
1409
1554
  flushRawReasoning();
1555
+ rawReasoningForNextToolCall = "";
1410
1556
  flushToolCall();
1411
1557
  break;
1412
1558
  case "text_delta":
@@ -1415,6 +1561,7 @@ export function buildResponseJSON(
1415
1561
  if (currentText && e.phase !== undefined && currentTextPhase !== e.phase) flushText("commentary");
1416
1562
  if (currentSummaryReasoning) flushSummaryReasoning();
1417
1563
  if (currentRawReasoning) flushRawReasoning();
1564
+ rawReasoningForNextToolCall = "";
1418
1565
  if (currentToolCallId) flushToolCall();
1419
1566
  // Compaction turns keep the summary out of normal message output (replay dedup — see
1420
1567
  // bridgeToResponsesSSE); it ships only inside the synthetic compaction item below.
@@ -1433,6 +1580,7 @@ export function buildResponseJSON(
1433
1580
  case "thinking_delta":
1434
1581
  if (currentText) flushText("commentary");
1435
1582
  if (currentRawReasoning) flushRawReasoning();
1583
+ rawReasoningForNextToolCall = "";
1436
1584
  if (currentToolCallId) flushToolCall();
1437
1585
  {
1438
1586
  ({ value: currentSummaryReasoning, bytes: currentSummaryReasoningBytes } = appendBatchString(
@@ -1455,6 +1603,16 @@ export function buildResponseJSON(
1455
1603
  }
1456
1604
  batchRedacted.push(e.data);
1457
1605
  break;
1606
+ case "kiro_redacted_reasoning":
1607
+ // Stash only — pushed after the trailing flushes. One blob per turn, so last wins.
1608
+ {
1609
+ const dataBytes = bytesOf(e.data);
1610
+ budget?.chargeRetained(dataBytes, { kind: "reasoning" });
1611
+ if (batchKiroRedactedBytes > 0) budget?.releaseRetained(batchKiroRedactedBytes, { kind: "reasoning" });
1612
+ batchKiroRedactedBytes = dataBytes;
1613
+ }
1614
+ batchKiroRedacted = e.data;
1615
+ break;
1458
1616
  case "reasoning_raw_delta":
1459
1617
  if (currentText) flushText("commentary");
1460
1618
  if (currentSummaryReasoning) flushSummaryReasoning();
@@ -1469,6 +1627,9 @@ export function buildResponseJSON(
1469
1627
  if (currentText) flushText("commentary");
1470
1628
  if (currentSummaryReasoning) flushSummaryReasoning();
1471
1629
  if (currentRawReasoning) flushRawReasoning();
1630
+ if (rawReasoningForNextToolCall) {
1631
+ rememberReasoningForCall(e.id, rawReasoningForNextToolCall, replayCacheScope);
1632
+ }
1472
1633
  flushToolCall();
1473
1634
  currentToolCallId = e.id;
1474
1635
  budget?.openCall(e.id);
@@ -1553,6 +1714,15 @@ export function buildResponseJSON(
1553
1714
  flushRawReasoning();
1554
1715
  // Open tool call on a failed/incomplete turn must not land as status:"completed".
1555
1716
  if (currentToolCallId) flushToolCall(errorEvent || incompleteEvent ? "incomplete" : "completed");
1717
+ if (batchKiroRedacted) {
1718
+ // pushOutput reserves the item itself and releases the retained raw blob it replaces.
1719
+ pushOutput({
1720
+ type: "reasoning", id: `rs_${uuid()}`, summary: [],
1721
+ encrypted_content: encodeReasoningEnvelope({ krc: batchKiroRedacted }),
1722
+ }, batchKiroRedactedBytes, "reasoning");
1723
+ batchKiroRedacted = undefined;
1724
+ batchKiroRedactedBytes = 0;
1725
+ }
1556
1726
  // A truncated turn must never be installed as replacement history: emit the
1557
1727
  // compaction item only when the turn actually completed (#422).
1558
1728
  if (
@@ -319,7 +319,9 @@ export function responsesSseToChatCompletionsSse(
319
319
  closeToolCalls();
320
320
  try { void sseIterator?.return(undefined).catch(() => {}); } catch { /* already closed */ }
321
321
  classified.code = "translation_buffer_limit";
322
- classified.type = "invalid_request_error";
322
+ // Provider-controlled overflow is an upstream failure on every path:
323
+ // streaming frame, collector, and defensive JSON agree on 502.
324
+ classified.type = "upstream_error";
323
325
  } else if (isCyberPolicyCode(details?.code) || classified.code === CYBER_POLICY_ERROR_CODE) {
324
326
  classified.code = CYBER_POLICY_ERROR_CODE;
325
327
  classified.type = "invalid_request_error";
@@ -468,7 +470,7 @@ export function responsesSseToChatCompletionsSse(
468
470
  fail(message, {
469
471
  code,
470
472
  ...(code === "translation_buffer_limit"
471
- ? { status: 413, type: "invalid_request_error" }
473
+ ? { status: 502, type: "upstream_error" }
472
474
  : { type, ...(code === CYBER_POLICY_ERROR_CODE ? { status: 400 } : {}) }),
473
475
  });
474
476
  break;
@@ -514,7 +516,7 @@ export function responsesSseToChatCompletionsSse(
514
516
  if (isTranslatorBudgetExceededError(err)) {
515
517
  upstreamAbort.abort(err);
516
518
  closeToolCalls();
517
- fail(err.message, { status: 413, type: "invalid_request_error", code: err.code });
519
+ fail(err.message, { status: 502, type: "upstream_error", code: err.code });
518
520
  } else {
519
521
  fail(err instanceof Error ? err.message : String(err));
520
522
  }
@@ -629,6 +631,9 @@ export async function collectChatCompletion(
629
631
  let content = "";
630
632
  let reasoning = "";
631
633
  const toolCalls = new Map<number, { id: string; name: string; arguments: string; argumentBytes: number }>();
634
+ // Per-call budget scopes (2 MiB/call enforced by the budget): the map key is the
635
+ // wire index, which is stable across deltas and present before the call id.
636
+ const callScope = (index: number) => `chat_collect_${index}`;
632
637
  let finishReason = "stop";
633
638
  let usage: unknown;
634
639
  let streamError: ChatCompletionsStreamError | null = null;
@@ -648,9 +653,11 @@ export async function collectChatCompletion(
648
653
  } catch (err) {
649
654
  if (isChatCompletionsStreamError(err)) throw err;
650
655
  if (isTranslatorBudgetExceededError(err)) {
656
+ // Provider-controlled overflow is an upstream failure, not a client
657
+ // request error: match the adapter/bridge contract (502 upstream_error).
651
658
  throw new ChatCompletionsStreamError(err.message, {
652
- status: 413,
653
- type: "invalid_request_error",
659
+ status: 502,
660
+ type: "upstream_error",
654
661
  code: err.code,
655
662
  });
656
663
  }
@@ -677,11 +684,15 @@ export async function collectChatCompletion(
677
684
  const type = typeof parsed.error.type === "string" ? parsed.error.type : "server_error";
678
685
  const code = typeof parsed.error.code === "string" ? parsed.error.code : null;
679
686
  const status = code === "translation_buffer_limit"
680
- ? 413
687
+ ? 502
681
688
  : code === CYBER_POLICY_ERROR_CODE || isCyberPolicyMessage(message)
682
689
  ? 400
683
690
  : streamErrorStatus(message);
684
- streamError = new ChatCompletionsStreamError(message, { status, type, code });
691
+ streamError = new ChatCompletionsStreamError(message, {
692
+ status,
693
+ type: code === "translation_buffer_limit" ? "upstream_error" : type,
694
+ code,
695
+ });
685
696
  continue;
686
697
  }
687
698
  if (parsed.usage) usage = parsed.usage;
@@ -697,7 +708,12 @@ export async function collectChatCompletion(
697
708
  for (const tc of delta.tool_calls) {
698
709
  if (!isRec(tc)) continue;
699
710
  const index = typeof tc.index === "number" ? tc.index : 0;
700
- const current = toolCalls.get(index) ?? { id: "", name: "", arguments: "", argumentBytes: 0 };
711
+ let current = toolCalls.get(index);
712
+ if (!current) {
713
+ current = { id: "", name: "", arguments: "", argumentBytes: 0 };
714
+ toolCalls.set(index, current);
715
+ translatorBudget.openCall(callScope(index));
716
+ }
701
717
  if (typeof tc.id === "string") current.id = tc.id;
702
718
  const fn = isRec(tc.function) ? tc.function : {};
703
719
  // Done-frame final arguments are authoritative last-write-wins snapshots.
@@ -707,27 +723,43 @@ export async function collectChatCompletion(
707
723
  const nextBytes = replace
708
724
  ? Buffer.byteLength(fn.arguments)
709
725
  : appendedUtf8Bytes(current.arguments, current.argumentBytes, fn.arguments);
710
- const reservation = translatorBudget.reserveTransient(nextBytes, { kind: "retained_collectors" });
726
+ const reservation = translatorBudget.reserveTransient(nextBytes, { kind: "tool_args", callId: callScope(index) });
711
727
  try {
712
728
  current.arguments = replace ? fn.arguments : current.arguments + fn.arguments;
713
729
  reservation.commitRetained();
714
- translatorBudget.releaseRetained(current.argumentBytes, { kind: "retained_collectors" });
730
+ translatorBudget.releaseRetained(current.argumentBytes, { kind: "tool_args", callId: callScope(index) });
715
731
  current.argumentBytes = nextBytes;
716
732
  } catch (error) {
717
733
  reservation.release();
718
734
  throw error;
719
735
  }
720
736
  }
721
- toolCalls.set(index, current);
722
737
  }
723
738
  }
724
739
  }
725
740
  }
726
741
  }
742
+ } catch (error) {
743
+ // Never leak an open call scope on the error path; the turn budget's
744
+ // dispose is a backstop, not the owner of this transfer.
745
+ for (const index of toolCalls.keys()) translatorBudget.closeCall(callScope(index));
746
+ // Processing-time overflow (per-call or turn cap) gets the same typed
747
+ // contract as read-time overflow: 502 upstream_error.
748
+ if (isTranslatorBudgetExceededError(error)) {
749
+ throw new ChatCompletionsStreamError(error.message, {
750
+ status: 502,
751
+ type: "upstream_error",
752
+ code: error.code,
753
+ });
754
+ }
755
+ throw error;
727
756
  } finally {
728
757
  reader.releaseLock();
729
758
  }
730
- if (streamError) throw streamError;
759
+ if (streamError) {
760
+ for (const index of toolCalls.keys()) translatorBudget.closeCall(callScope(index));
761
+ throw streamError;
762
+ }
731
763
 
732
764
  const message: Rec = {
733
765
  role: "assistant",
@@ -735,17 +767,40 @@ export async function collectChatCompletion(
735
767
  };
736
768
  if (reasoning) message.reasoning_content = reasoning;
737
769
  if (toolCalls.size > 0) {
738
- message.tool_calls = [...toolCalls.entries()]
739
- .sort((a, b) => a[0] - b[0])
740
- .map(([, tc]) => {
741
- const copy = {
742
- id: tc.id || `call_${uuid().slice(0, 16)}`,
743
- type: "function",
744
- function: { name: tc.name, arguments: tc.arguments },
745
- };
746
- translatorBudget.chargeRetained(Buffer.byteLength(JSON.stringify(copy)), { kind: "retained_collectors" });
747
- return copy;
748
- });
770
+ // Final owner transfer is itself a charging operation: if it overflows,
771
+ // release every copy already charged in this loop and close every scope
772
+ // still open — the error must not escape as a raw budget exception.
773
+ const chargedCopies: number[] = [];
774
+ try {
775
+ message.tool_calls = [...toolCalls.entries()]
776
+ .sort((a, b) => a[0] - b[0])
777
+ .map(([index, tc]) => {
778
+ const copy = {
779
+ id: tc.id || `call_${uuid().slice(0, 16)}`,
780
+ type: "function",
781
+ function: { name: tc.name, arguments: tc.arguments },
782
+ };
783
+ const copyBytes = Buffer.byteLength(JSON.stringify(copy));
784
+ translatorBudget.chargeRetained(copyBytes, { kind: "retained_collectors" });
785
+ chargedCopies.push(copyBytes);
786
+ // The serialized owner is charged; release the per-call accumulation.
787
+ translatorBudget.closeCall(callScope(index));
788
+ return copy;
789
+ });
790
+ } catch (error) {
791
+ for (const copyBytes of chargedCopies) {
792
+ translatorBudget.releaseRetained(copyBytes, { kind: "retained_collectors" });
793
+ }
794
+ for (const index of toolCalls.keys()) translatorBudget.closeCall(callScope(index));
795
+ if (isTranslatorBudgetExceededError(error)) {
796
+ throw new ChatCompletionsStreamError(error.message, {
797
+ status: 502,
798
+ type: "upstream_error",
799
+ code: error.code,
800
+ });
801
+ }
802
+ throw error;
803
+ }
749
804
  if (finishReason === "stop") finishReason = "tool_calls";
750
805
  }
751
806