@bitkyc08/opencodex 2.8.0 → 2.9.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 (175) hide show
  1. package/README.md +24 -0
  2. package/bin/ocx.mjs +32 -4
  3. package/gui/dist/assets/index-CHwf3tTD.css +1 -0
  4. package/gui/dist/assets/index-u5eFOv2y.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-image-normalize.ts +114 -20
  8. package/src/adapters/anthropic.ts +126 -10
  9. package/src/adapters/azure.ts +3 -3
  10. package/src/adapters/base.ts +7 -3
  11. package/src/adapters/cursor/discovery.ts +14 -4
  12. package/src/adapters/cursor/effort-map.ts +18 -6
  13. package/src/adapters/cursor/framing.ts +102 -27
  14. package/src/adapters/cursor/kv-store.ts +30 -3
  15. package/src/adapters/cursor/live-models.ts +22 -2
  16. package/src/adapters/cursor/live-transport.ts +245 -49
  17. package/src/adapters/cursor/mcp-manager.ts +105 -8
  18. package/src/adapters/cursor/native-exec-mcp.ts +5 -3
  19. package/src/adapters/cursor/native-exec-shell.ts +296 -14
  20. package/src/adapters/cursor/native-exec.ts +381 -33
  21. package/src/adapters/cursor/protobuf-events.ts +28 -1
  22. package/src/adapters/cursor/protobuf-request.ts +71 -39
  23. package/src/adapters/cursor/request-builder.ts +2 -2
  24. package/src/adapters/cursor/transport.ts +2 -0
  25. package/src/adapters/cursor.ts +13 -2
  26. package/src/adapters/google-antigravity-replay.ts +184 -17
  27. package/src/adapters/google.ts +58 -8
  28. package/src/adapters/kiro-thinking.ts +23 -9
  29. package/src/adapters/kiro-tools.ts +49 -18
  30. package/src/adapters/kiro.ts +377 -133
  31. package/src/adapters/mimo-free.ts +36 -4
  32. package/src/adapters/openai-chat.ts +143 -17
  33. package/src/adapters/openai-responses.ts +130 -14
  34. package/src/adapters/run-turn-queue.ts +7 -1
  35. package/src/bridge.ts +466 -69
  36. package/src/chat/outbound.ts +144 -38
  37. package/src/claude/inbound-debug.ts +53 -8
  38. package/src/claude/outbound.ts +224 -38
  39. package/src/cli/agent-driven.ts +34 -1
  40. package/src/cli/catalog-prewarm.ts +5 -2
  41. package/src/cli/claude-desktop.ts +2 -2
  42. package/src/cli/doctor.ts +12 -0
  43. package/src/cli/export-command.ts +187 -0
  44. package/src/cli/help.ts +11 -0
  45. package/src/cli/index.ts +13 -3
  46. package/src/cli/init.ts +129 -102
  47. package/src/cli/opencode.ts +36 -151
  48. package/src/cli/star-prompt.ts +13 -4
  49. package/src/cli/status-oauth.ts +12 -2
  50. package/src/clients/config-export.ts +377 -0
  51. package/src/codex/account-runtime-state.ts +19 -1
  52. package/src/codex/account-store.ts +162 -82
  53. package/src/codex/auth-api.ts +467 -159
  54. package/src/codex/auth-context.ts +15 -2
  55. package/src/codex/catalog/aggregation.ts +15 -0
  56. package/src/codex/catalog/effort.ts +16 -6
  57. package/src/codex/catalog/metadata.ts +6 -0
  58. package/src/codex/catalog/parsing.ts +3 -1
  59. package/src/codex/catalog/provider-fetch.ts +29 -0
  60. package/src/codex/catalog/sync.ts +64 -7
  61. package/src/codex/catalog.ts +2 -2
  62. package/src/codex/inject.ts +5 -5
  63. package/src/codex/main-account-cache.ts +8 -1
  64. package/src/codex/model-cache.ts +81 -2
  65. package/src/codex/pool-rotation.ts +39 -0
  66. package/src/codex/project-config-warnings.ts +12 -1
  67. package/src/codex/quota.ts +35 -3
  68. package/src/codex/routing.ts +46 -1
  69. package/src/codex/shim.ts +10 -4
  70. package/src/codex/subagent-model-fallback.ts +12 -0
  71. package/src/codex/websocket-registry.ts +27 -0
  72. package/src/combos/failover.ts +31 -1
  73. package/src/combos/request.ts +9 -0
  74. package/src/combos/resolve.ts +60 -4
  75. package/src/combos/types.ts +12 -0
  76. package/src/config.ts +510 -55
  77. package/src/github/star-state.ts +13 -1
  78. package/src/images/fulfill.ts +39 -1
  79. package/src/images/loop.ts +52 -12
  80. package/src/lib/admission.ts +83 -0
  81. package/src/lib/app-owned-memory-stores.ts +173 -0
  82. package/src/lib/app-owned-memory.ts +265 -0
  83. package/src/lib/bun-stream-caps.ts +31 -7
  84. package/src/lib/config-ownership.ts +33 -0
  85. package/src/lib/crash-guard.ts +65 -5
  86. package/src/lib/debug-log-buffer.ts +47 -6
  87. package/src/lib/destination-policy.ts +12 -1
  88. package/src/lib/errors.ts +3 -0
  89. package/src/lib/gcp-adc.ts +40 -2
  90. package/src/lib/injection-debug-log.ts +26 -2
  91. package/src/lib/provider-outbound.ts +3 -0
  92. package/src/lib/sidecar-tracker.ts +5 -2
  93. package/src/lib/sse-decoder.ts +257 -37
  94. package/src/lib/state-store-registrations.ts +109 -0
  95. package/src/lib/state-store-sweeper.ts +184 -0
  96. package/src/lib/translator-budget.ts +356 -0
  97. package/src/lib/windows-secret-acl.ts +33 -12
  98. package/src/lib/winsw.ts +14 -1
  99. package/src/oauth/anthropic-routing.ts +31 -7
  100. package/src/oauth/google-antigravity.ts +2 -1
  101. package/src/oauth/health.ts +30 -12
  102. package/src/oauth/index.ts +127 -23
  103. package/src/oauth/kiro-credentials.ts +72 -1
  104. package/src/oauth/kiro.ts +23 -4
  105. package/src/oauth/store.ts +165 -18
  106. package/src/oauth/token-guardian.ts +43 -4
  107. package/src/oauth/types.ts +2 -1
  108. package/src/providers/base-url-choices.ts +10 -0
  109. package/src/providers/derive.ts +12 -0
  110. package/src/providers/free-directory.ts +4 -1
  111. package/src/providers/key-failover.ts +12 -0
  112. package/src/providers/openai-sidecar.ts +4 -1
  113. package/src/providers/quota.ts +68 -7
  114. package/src/providers/registry.ts +279 -3
  115. package/src/responses/parser.ts +5 -1
  116. package/src/responses/spill-store.ts +394 -0
  117. package/src/responses/state.ts +520 -102
  118. package/src/router.ts +18 -1
  119. package/src/server/adapter-resolve.ts +20 -3
  120. package/src/server/auth-cors.ts +121 -28
  121. package/src/server/chat-completions.ts +57 -12
  122. package/src/server/claude-messages.ts +85 -13
  123. package/src/server/index.ts +242 -100
  124. package/src/server/lifecycle.ts +155 -25
  125. package/src/server/management/agent-settings-routes.ts +79 -36
  126. package/src/server/management/api-key-usage.ts +167 -0
  127. package/src/server/management/body.ts +35 -0
  128. package/src/server/management/combo-routes.ts +5 -1
  129. package/src/server/management/config-routes.ts +42 -12
  130. package/src/server/management/logs-usage-routes.ts +41 -21
  131. package/src/server/management/model-routes.ts +188 -54
  132. package/src/server/management/oauth-account-routes.ts +115 -26
  133. package/src/server/management/provider-routes.ts +56 -6
  134. package/src/server/management/shared.ts +16 -3
  135. package/src/server/management/sidebar-routes.ts +50 -1
  136. package/src/server/management/system-restart.ts +13 -6
  137. package/src/server/management/system-routes.ts +15 -3
  138. package/src/server/management/usage-summary-cache.ts +86 -0
  139. package/src/server/management-api.ts +39 -5
  140. package/src/server/management-auth.ts +65 -14
  141. package/src/server/port-reclaim.ts +58 -12
  142. package/src/server/ports.ts +2 -0
  143. package/src/server/proxy-liveness.ts +60 -14
  144. package/src/server/relay-eager.ts +20 -4
  145. package/src/server/relay.ts +548 -154
  146. package/src/server/request-decompress.ts +51 -4
  147. package/src/server/request-log.ts +134 -15
  148. package/src/server/responses/collaboration.ts +15 -4
  149. package/src/server/responses/compact.ts +3 -0
  150. package/src/server/responses/core.ts +241 -66
  151. package/src/server/responses-image-gen-repair.ts +19 -5
  152. package/src/server/responses-item-id-repair.ts +23 -5
  153. package/src/server/sse-payload-rewrite.ts +71 -12
  154. package/src/server/startup-health-cache.ts +14 -1
  155. package/src/server/system-env.ts +8 -1
  156. package/src/server/windows-tcp-drop.ts +15 -5
  157. package/src/server/ws-bridge.ts +25 -0
  158. package/src/service.ts +179 -13
  159. package/src/storage/policy-job.ts +93 -23
  160. package/src/storage/policy-worker.ts +6 -0
  161. package/src/storage/restore-job.ts +62 -16
  162. package/src/storage/restore-worker.ts +6 -0
  163. package/src/storage/storage-mutation-coordinator.ts +36 -6
  164. package/src/storage/worker-lifecycle.ts +181 -47
  165. package/src/tray/windows.ts +97 -25
  166. package/src/types.ts +39 -6
  167. package/src/update/index.ts +24 -5
  168. package/src/update/job.ts +598 -73
  169. package/src/usage/log.ts +115 -17
  170. package/src/usage/summary.ts +67 -2
  171. package/src/vision/index.ts +112 -22
  172. package/src/web-search/loop.ts +38 -6
  173. package/src/web-search/progress-stream.ts +14 -3
  174. package/gui/dist/assets/index-BDjpkcRN.js +0 -67
  175. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
package/src/bridge.ts CHANGED
@@ -4,6 +4,12 @@ import { encodeCompactionSummary } from "./responses/compaction";
4
4
  import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope";
5
5
  import { resolveStallTimeoutSec } from "./stall-timeout";
6
6
  import { usageDisplayTotalTokens } from "./usage/totals";
7
+ import {
8
+ isTranslatorBudgetExceededError,
9
+ releaseTranslatedEvent,
10
+ type TranslatorBudget,
11
+ type TranslatorBufferKind,
12
+ } from "./lib/translator-budget";
7
13
 
8
14
  function uuid(): string {
9
15
  return crypto.randomUUID().replace(/-/g, "");
@@ -63,6 +69,23 @@ function responseError(status: number, type: string, message: string): OcxErrorP
63
69
  return classifyError(status, type, message);
64
70
  }
65
71
 
72
+ /**
73
+ * Whether assembled function-call arguments are usable JSON.
74
+ * An empty buffer is valid (no-arg tools send no deltas). Non-empty must parse —
75
+ * once fragments have been streamed to the client they cannot be repaired the way
76
+ * non-stream adapters degrade a bad payload to `{}`.
77
+ */
78
+ function toolCallArgumentsUsable(args: string): boolean {
79
+ const trimmed = args.trim();
80
+ if (!trimmed) return true;
81
+ try {
82
+ JSON.parse(trimmed);
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
66
89
  function adapterFailureFromEvent(event: Extract<AdapterEvent, { type: "error" }>): { httpStatus: number; error: OcxErrorPayload } {
67
90
  if (event.status === undefined && event.errorType === undefined && event.code === undefined) {
68
91
  return adapterFailureFromMessage(event.message);
@@ -133,6 +156,7 @@ export function bridgeToResponsesSSE(
133
156
  * from this callback instead of re-parsing the bridged SSE.
134
157
  */
135
158
  onUsage?: (usage: OcxUsage | undefined) => void;
159
+ translatorBudget?: TranslatorBudget;
136
160
  /**
137
161
  * Test seam for the wire/stall beat loop. Production omits this and uses the
138
162
  * global timers; injecting here must not change scheduling semantics.
@@ -184,6 +208,43 @@ export function bridgeToResponsesSSE(
184
208
  try { const o = JSON.parse(args); return o && typeof o === "object" ? o : {}; } catch { return {}; }
185
209
  };
186
210
  const encoder = new TextEncoder();
211
+ const budget = options?.translatorBudget;
212
+ const bytesOf = (value: string): number => Buffer.byteLength(value);
213
+ const appendString = (
214
+ previous: string,
215
+ previousBytes: number,
216
+ fragment: string,
217
+ kind: TranslatorBufferKind,
218
+ callId?: string,
219
+ ): { value: string; bytes: number } => {
220
+ const fragmentBytes = bytesOf(fragment);
221
+ const nextBytes = previousBytes + fragmentBytes;
222
+ if (!budget) return { value: previous + fragment, bytes: nextBytes };
223
+ const scope = { kind, ...(callId ? { callId } : {}) };
224
+ const reservation = budget.reserveTransient(nextBytes, scope);
225
+ try {
226
+ const value = previous + fragment;
227
+ reservation.commitRetained();
228
+ budget.releaseRetained(previousBytes, scope);
229
+ return { value, bytes: nextBytes };
230
+ } catch (error) {
231
+ reservation.release();
232
+ throw error;
233
+ }
234
+ };
235
+ const replaceRetainedString = (previousBytes: number, next: string, kind: TranslatorBufferKind): number => {
236
+ const nextBytes = bytesOf(next);
237
+ if (!budget) return nextBytes;
238
+ const reservation = budget.reserveTransient(nextBytes, { kind });
239
+ reservation.commitRetained();
240
+ budget.releaseRetained(previousBytes, { kind });
241
+ return nextBytes;
242
+ };
243
+ const chargeValue = (value: unknown, kind: TranslatorBufferKind): number => {
244
+ const bytes = bytesOf(JSON.stringify(value));
245
+ budget?.chargeRetained(bytes, { kind });
246
+ return bytes;
247
+ };
187
248
  const responseId = options?.responseId ?? `resp_${uuid()}`;
188
249
  let seq = 0;
189
250
  // Set once the client is gone (cancel) or an enqueue throws on a torn-down controller, so we
@@ -195,7 +256,7 @@ export function bridgeToResponsesSSE(
195
256
  const reportTerminal = (status: ResponsesTerminalStatus) => {
196
257
  if (terminalReported || clientCancelled || closed) return;
197
258
  terminalReported = true;
198
- options?.onTerminal?.(status);
259
+ try { options?.onTerminal?.(status); } catch { /* terminal metrics must not break the stream */ }
199
260
  };
200
261
  // RC3 keep-alive: Codex's idle timer is timeout(idle_timeout, stream.next()) over an
201
262
  // eventsource_stream; ANY received event re-arms it, while an unknown type is ignored
@@ -209,22 +270,43 @@ export function bridgeToResponsesSSE(
209
270
  let emittedFrames = 0;
210
271
  let gated = false;
211
272
  let stepping = false;
212
- const emit = (name: string, data: Record<string, unknown>) => {
273
+ let terminateForTranslatorOverflow: ((error: unknown) => void) | undefined;
274
+ const emit = (name: string, data: Record<string, unknown>) => {
213
275
  if (closed) return;
214
276
  wireActivity = true;
215
277
  try {
216
- controller.enqueue(encoder.encode(sseEvent(name, { type: name, sequence_number: seq++, ...data })));
278
+ const frameText = sseEvent(name, { type: name, sequence_number: seq++, ...data });
279
+ const frameBytes = bytesOf(frameText);
280
+ const reservation = budget?.reserveTransient(frameBytes, { kind: "live_transient" });
281
+ const frame = encoder.encode(frameText);
282
+ reservation?.commitRetained();
283
+ controller.enqueue(frame);
284
+ budget?.releaseRetained(frameBytes, { kind: "live_transient" });
217
285
  emittedFrames++;
218
- } catch {
286
+ } catch (error) {
287
+ if (isTranslatorBudgetExceededError(error)) {
288
+ terminateForTranslatorOverflow?.(error);
289
+ return;
290
+ }
219
291
  closed = true;
220
292
  }
221
293
  };
222
294
  const emitDone = () => {
223
295
  if (closed) return;
224
296
  try {
225
- controller.enqueue(encoder.encode("data: [DONE]\n\n"));
297
+ const done = "data: [DONE]\n\n";
298
+ const doneBytes = bytesOf(done);
299
+ const reservation = budget?.reserveTransient(doneBytes, { kind: "live_transient" });
300
+ const frame = encoder.encode(done);
301
+ reservation?.commitRetained();
302
+ controller.enqueue(frame);
303
+ budget?.releaseRetained(doneBytes, { kind: "live_transient" });
226
304
  emittedFrames++;
227
- } catch {
305
+ } catch (error) {
306
+ if (isTranslatorBudgetExceededError(error)) {
307
+ terminateForTranslatorOverflow?.(error);
308
+ return;
309
+ }
228
310
  closed = true;
229
311
  }
230
312
  };
@@ -232,6 +314,13 @@ export function bridgeToResponsesSSE(
232
314
  const createdAt = Math.floor(Date.now() / 1000);
233
315
  let outputIndex = 0;
234
316
  const finishedItems: OutputItem[] = [];
317
+ const retainFinishedItem = (item: OutputItem, replacedBytes = 0, kind: TranslatorBufferKind = "retained_collectors") => {
318
+ const itemBytes = bytesOf(JSON.stringify(item));
319
+ const reservation = budget?.reserveTransient(itemBytes, { kind });
320
+ finishedItems.push(item);
321
+ reservation?.commitRetained();
322
+ if (replacedBytes > 0) budget?.releaseRetained(replacedBytes, { kind });
323
+ };
235
324
 
236
325
  const responseSnapshot = (status: string, output: OutputItem[], endTurn?: boolean) => ({
237
326
  id: responseId, object: "response", created_at: createdAt,
@@ -244,37 +333,48 @@ export function bridgeToResponsesSSE(
244
333
  const stallSec = resolveStallTimeoutSec(options?.stallTimeoutSec);
245
334
  const maxStallTicks = Math.ceil((stallSec * 1000) / heartbeatMs);
246
335
 
247
- let currentMsg: { itemId: string; outputIndex: number; text: string; phase?: OcxMessagePhase } | null = null;
248
- let currentReasoning: { itemId: string; outputIndex: number; text: string } | null = null;
249
- let currentRawReasoning: { itemId: string; outputIndex: number; text: string } | null = null;
336
+ let currentMsg: { itemId: string; outputIndex: number; text: string; textBytes: number; phase?: OcxMessagePhase } | null = null;
337
+ let currentReasoning: { itemId: string; outputIndex: number; text: string; textBytes: number } | null = null;
338
+ let currentRawReasoning: { itemId: string; outputIndex: number; text: string; textBytes: number } | null = null;
250
339
  // Anthropic extended-thinking round-trip state: the signature signs the CURRENT thinking
251
340
  // block; redacted blocks are opaque payloads replayed verbatim. Attached to the reasoning
252
341
  // item as an ocxr1 encrypted_content envelope on close. hiddenThinkingText collects the
253
342
  // suppressed text under hideThinkingSummary so the signed text still round-trips.
254
343
  let pendingSignature: string | undefined;
344
+ let pendingSignatureBytes = 0;
255
345
  let pendingRedacted: string[] = [];
256
346
  let hiddenThinkingText = "";
347
+ let hiddenThinkingBytes = 0;
257
348
  const takeReasoningEnvelope = (hiddenText?: string): string | undefined => {
258
349
  if (!pendingSignature && pendingRedacted.length === 0) return undefined;
259
350
  const envelope: ReasoningEnvelope = {};
260
351
  if (pendingSignature) envelope.sig = pendingSignature;
261
352
  if (pendingRedacted.length > 0) envelope.red = pendingRedacted;
262
353
  if (hiddenText) envelope.txt = hiddenText;
354
+ const previousBytes = pendingSignatureBytes
355
+ + pendingRedacted.reduce((sum, value) => sum + bytesOf(value), 0)
356
+ + (hiddenText ? hiddenThinkingBytes : 0);
357
+ const encoded = encodeReasoningEnvelope(envelope);
358
+ const reservation = budget?.reserveTransient(bytesOf(encoded), { kind: "reasoning" });
263
359
  pendingSignature = undefined;
360
+ pendingSignatureBytes = 0;
264
361
  pendingRedacted = [];
265
- return encodeReasoningEnvelope(envelope);
362
+ reservation?.commitRetained();
363
+ budget?.releaseRetained(previousBytes, { kind: "reasoning" });
364
+ return encoded;
266
365
  };
267
366
  // hideThinkingSummary path: no visible reasoning item exists, but a signed thinking block
268
367
  // must still round-trip — emit an envelope-only reasoning item (empty summary, no text leak).
269
368
  const flushHiddenReasoningEnvelope = () => {
270
369
  const encrypted = takeReasoningEnvelope(hiddenThinkingText || undefined);
271
370
  hiddenThinkingText = "";
371
+ hiddenThinkingBytes = 0;
272
372
  if (!encrypted) return;
273
373
  const itemId = `rs_${uuid()}`;
274
374
  const item = { type: "reasoning", id: itemId, summary: [] as never[], encrypted_content: encrypted };
275
375
  emit("response.output_item.added", { output_index: outputIndex, item });
276
376
  emit("response.output_item.done", { output_index: outputIndex, item });
277
- finishedItems.push(item as OutputItem);
377
+ retainFinishedItem(item as OutputItem, bytesOf(encrypted), "reasoning");
278
378
  outputIndex++;
279
379
  };
280
380
  // hideThinkingSummary for RAW reasoning (openai-chat reasoning_content, kiro tags): no
@@ -283,21 +383,28 @@ export function bridgeToResponsesSSE(
283
383
  // preserveReasoningContentModels replay (GLM interleaved thinking) keeps working. Direct
284
384
  // encodeReasoningEnvelope: takeReasoningEnvelope's sig/red guard would drop txt-only.
285
385
  let hiddenRawReasoningText = "";
386
+ let hiddenRawReasoningBytes = 0;
286
387
  const flushHiddenRawReasoning = () => {
287
388
  if (!hiddenRawReasoningText) return;
389
+ const previousBytes = hiddenRawReasoningBytes;
288
390
  const encrypted = encodeReasoningEnvelope({ txt: hiddenRawReasoningText });
391
+ const reservation = budget?.reserveTransient(bytesOf(encrypted), { kind: "reasoning" });
289
392
  hiddenRawReasoningText = "";
393
+ hiddenRawReasoningBytes = 0;
394
+ reservation?.commitRetained();
395
+ budget?.releaseRetained(previousBytes, { kind: "reasoning" });
290
396
  const itemId = `rs_${uuid()}`;
291
397
  const item = { type: "reasoning", id: itemId, summary: [] as never[], encrypted_content: encrypted };
292
398
  emit("response.output_item.added", { output_index: outputIndex, item });
293
399
  emit("response.output_item.done", { output_index: outputIndex, item });
294
- finishedItems.push(item as OutputItem);
400
+ retainFinishedItem(item as OutputItem, bytesOf(encrypted), "reasoning");
295
401
  outputIndex++;
296
402
  };
297
403
  // Full assistant text of a compaction turn (across message boundaries) — becomes the
298
404
  // synthetic compaction item's payload on done.
299
405
  let compactionText = "";
300
- let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string } | null = null;
406
+ let compactionTextBytes = 0;
407
+ let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; argsBytes: number; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string } | null = null;
301
408
  // Open native web-search cell (between begin and end). Holds the output index allocated on
302
409
  // begin so the matching done reuses it; closed as `failed` if the stream terminates early.
303
410
  let currentWebSearch: { itemId: string; eventId: string; outputIndex: number } | null = null;
@@ -310,7 +417,12 @@ export function bridgeToResponsesSSE(
310
417
  const anns = pendingWebSources.map(s => ({
311
418
  type: "url_citation", url: s.url, ...(s.title ? { title: s.title } : {}), start_index: 0, end_index: 0,
312
419
  }));
420
+ const sourceBytes = pendingWebSources.reduce((sum, source) => sum + bytesOf(JSON.stringify(source)), 0);
421
+ const annotationBytes = bytesOf(JSON.stringify(anns));
422
+ const reservation = budget?.reserveTransient(annotationBytes, { kind: "retained_collectors" });
313
423
  pendingWebSources = [];
424
+ reservation?.commitRetained();
425
+ budget?.releaseRetained(sourceBytes, { kind: "tool_search_sources" });
314
426
  return anns;
315
427
  };
316
428
 
@@ -337,7 +449,7 @@ export function bridgeToResponsesSSE(
337
449
  ...(phase ? { phase } : {}),
338
450
  };
339
451
  emit("response.output_item.done", { output_index: currentMsg.outputIndex, item });
340
- finishedItems.push(item as OutputItem);
452
+ retainFinishedItem(item as OutputItem, currentMsg.textBytes + bytesOf(JSON.stringify(annotations)));
341
453
  outputIndex++;
342
454
  currentMsg = null;
343
455
  };
@@ -358,7 +470,7 @@ export function bridgeToResponsesSSE(
358
470
  ...(encrypted ? { encrypted_content: encrypted } : {}),
359
471
  };
360
472
  emit("response.output_item.done", { output_index: currentReasoning.outputIndex, item });
361
- finishedItems.push(item as OutputItem);
473
+ retainFinishedItem(item as OutputItem, currentReasoning.textBytes + bytesOf(encrypted ?? ""), "reasoning");
362
474
  outputIndex++;
363
475
  currentReasoning = null;
364
476
  };
@@ -370,7 +482,7 @@ export function bridgeToResponsesSSE(
370
482
  content: [{ type: "reasoning_text", text: currentRawReasoning.text }],
371
483
  };
372
484
  emit("response.output_item.done", { output_index: currentRawReasoning.outputIndex, item });
373
- finishedItems.push(item as OutputItem);
485
+ retainFinishedItem(item as OutputItem, currentRawReasoning.textBytes, "reasoning");
374
486
  outputIndex++;
375
487
  currentRawReasoning = null;
376
488
  };
@@ -412,11 +524,52 @@ export function bridgeToResponsesSSE(
412
524
  ...(currentToolCall.namespace ? { namespace: currentToolCall.namespace } : {}),
413
525
  };
414
526
  emit("response.output_item.done", { output_index: currentToolCall.outputIndex, item });
415
- finishedItems.push(item as OutputItem);
527
+ retainFinishedItem(item as OutputItem);
528
+ budget?.closeCall(currentToolCall.callId);
416
529
  outputIndex++;
417
530
  currentToolCall = null;
418
531
  };
419
532
 
533
+ // Terminal-error / incomplete path for an open tool call (#765 remainder).
534
+ // Closing via closeCurrentToolCall() would emit function_call_arguments.done and
535
+ // status:"completed" BEFORE response.failed — the client still sees an issued call.
536
+ // Cancel instead: no *.done argument frames, status:"incomplete" (same pattern as an
537
+ // in-flight web_search_call closing as "failed"). Args still serialize as "{}" when
538
+ // empty so echoed items cannot poison the next turn with JSON.parse("").
539
+ const failCurrentToolCall = () => {
540
+ if (!currentToolCall) return;
541
+ const argsStr = currentToolCall.args || "{}";
542
+ const item = currentToolCall.toolSearch
543
+ ? {
544
+ type: "tool_search_call", id: currentToolCall.itemId,
545
+ call_id: currentToolCall.callId, execution: "client",
546
+ arguments: parseArgsObj(currentToolCall.args), status: "incomplete",
547
+ }
548
+ : currentToolCall.freeform
549
+ ? {
550
+ type: "custom_tool_call", id: currentToolCall.itemId,
551
+ call_id: currentToolCall.callId, name: currentToolCall.name,
552
+ input: freeformInput(currentToolCall.args), status: "incomplete",
553
+ }
554
+ : {
555
+ type: "function_call", id: currentToolCall.itemId,
556
+ call_id: currentToolCall.callId, name: currentToolCall.name,
557
+ arguments: argsStr, status: "incomplete",
558
+ ...(currentToolCall.namespace ? { namespace: currentToolCall.namespace } : {}),
559
+ };
560
+ emit("response.output_item.done", { output_index: currentToolCall.outputIndex, item });
561
+ retainFinishedItem(item as OutputItem);
562
+ budget?.closeCall(currentToolCall.callId);
563
+ outputIndex++;
564
+ currentToolCall = null;
565
+ };
566
+
567
+ const abortCurrentToolCallForTranslatorOverflow = () => {
568
+ if (!currentToolCall) return;
569
+ budget?.closeCall(currentToolCall.callId);
570
+ currentToolCall = null;
571
+ };
572
+
420
573
  // Finalize an open web-search cell. `status` is "completed" on a normal end, or "failed" when
421
574
  // the stream terminates (error/incomplete) while a search was still in flight, so Codex never
422
575
  // leaves a "Searching the web" spinner spinning forever.
@@ -430,15 +583,19 @@ export function bridgeToResponsesSSE(
430
583
  ...(sources && sources.length > 0 ? { sources } : {}),
431
584
  };
432
585
  emit("response.output_item.done", { output_index: currentWebSearch.outputIndex, item });
433
- finishedItems.push(item as OutputItem);
586
+ retainFinishedItem(item as OutputItem);
434
587
  outputIndex++;
435
588
  currentWebSearch = null;
436
589
  };
437
590
 
438
591
  // RC1: guarantee the Responses stream always ends with exactly one terminal event. Set true
439
592
  // when a done/error/catch terminal is emitted; if the adapter generator returns without one
440
- // we synthesize response.completed below, so Codex never hits the parser's
593
+ // we synthesize a terminal below, so Codex never hits the parser's
441
594
  // "stream closed before response.completed" (responses.rs) -> ApiError::Stream.
595
+ // That synthesized terminal is response.incomplete with reason "adapter_eof", NOT
596
+ // response.completed: a generator that returns without a terminal event is a truncated
597
+ // stream, and reporting it as a clean finish is the failure mode this whole path exists
598
+ // to avoid. The comment said "completed" long after the code stopped doing that.
442
599
  let terminated = false;
443
600
  let firstOutputReported = false;
444
601
  const reportFirstOutput = (event: AdapterEvent): void => {
@@ -482,6 +639,52 @@ export function bridgeToResponsesSSE(
482
639
  }
483
640
  finishReturn();
484
641
  };
642
+ let upstreamCancelled = false;
643
+ const cancelUpstreamOnce = () => {
644
+ if (upstreamCancelled) return;
645
+ upstreamCancelled = true;
646
+ try { onCancel?.(); } catch { /* cancellation must not strand the client stream */ }
647
+ returnIterator();
648
+ };
649
+ let handlingTranslatorOverflow = false;
650
+ terminateForTranslatorOverflow = _error => {
651
+ if (handlingTranslatorOverflow || terminated || clientCancelled || closed) return;
652
+ handlingTranslatorOverflow = true;
653
+ abortCurrentToolCallForTranslatorOverflow();
654
+ currentWebSearch = null;
655
+ const failure = adapterFailureFromEvent({
656
+ type: "error",
657
+ status: 502,
658
+ errorType: "upstream_error",
659
+ code: "translation_buffer_limit",
660
+ message: "upstream translation buffer exceeded the safe limit",
661
+ }).error;
662
+ const failedFrame = sseEvent("response.failed", {
663
+ type: "response.failed",
664
+ sequence_number: seq++,
665
+ response: {
666
+ ...responseSnapshot("failed", finishedItems),
667
+ error: failure,
668
+ last_error: failure,
669
+ },
670
+ });
671
+ try {
672
+ controller.enqueue(encoder.encode(failedFrame));
673
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
674
+ emittedFrames += 2;
675
+ } catch {
676
+ /* client already tore down the stream */
677
+ }
678
+ reportTerminal("failed");
679
+ terminated = true;
680
+ cancelUpstreamOnce();
681
+ if (beat !== undefined) clearBeatInterval(beat);
682
+ beat = undefined;
683
+ try { controller.close(); } catch { /* already closed */ }
684
+ closed = true;
685
+ gated = true;
686
+ stepping = false;
687
+ };
485
688
  const step = async () => {
486
689
  if (stepping || closed) return;
487
690
  stepping = true;
@@ -505,7 +708,15 @@ export function bridgeToResponsesSSE(
505
708
  // expansion (rememberResponseState stores input + output). Codex ignores extra items but
506
709
  // its compaction UI renders nothing mid-turn, so nothing is lost visually.
507
710
  if (options?.compaction) {
508
- if (event.type === "text_delta") { compactionText += event.text; continue; }
711
+ if (event.type === "text_delta") {
712
+ ({ value: compactionText, bytes: compactionTextBytes } = appendString(
713
+ compactionText,
714
+ compactionTextBytes,
715
+ event.text,
716
+ "retained_collectors",
717
+ ));
718
+ continue;
719
+ }
509
720
  if (event.type !== "done" && event.type !== "incomplete" && event.type !== "error") continue;
510
721
  }
511
722
  switch (event.type) {
@@ -542,9 +753,14 @@ export function bridgeToResponsesSSE(
542
753
  item_id: itemId, output_index: outputIndex, content_index: 0,
543
754
  part: { type: "output_text", text: "", annotations: [] },
544
755
  });
545
- currentMsg = { itemId, outputIndex, text: "", ...(event.phase ? { phase: event.phase } : {}) };
756
+ currentMsg = { itemId, outputIndex, text: "", textBytes: 0, ...(event.phase ? { phase: event.phase } : {}) };
546
757
  }
547
- currentMsg.text += event.text;
758
+ ({ value: currentMsg.text, bytes: currentMsg.textBytes } = appendString(
759
+ currentMsg.text,
760
+ currentMsg.textBytes,
761
+ event.text,
762
+ "retained_collectors",
763
+ ));
548
764
  emit("response.output_text.delta", {
549
765
  item_id: currentMsg.itemId, output_index: currentMsg.outputIndex,
550
766
  content_index: 0, delta: event.text,
@@ -552,7 +768,15 @@ export function bridgeToResponsesSSE(
552
768
  break;
553
769
  }
554
770
  case "thinking_delta": {
555
- if (options?.hideThinkingSummary) { hiddenThinkingText += event.thinking; break; }
771
+ if (options?.hideThinkingSummary) {
772
+ ({ value: hiddenThinkingText, bytes: hiddenThinkingBytes } = appendString(
773
+ hiddenThinkingText,
774
+ hiddenThinkingBytes,
775
+ event.thinking,
776
+ "reasoning",
777
+ ));
778
+ break;
779
+ }
556
780
  if (currentMsg) closeCurrentMessage("commentary");
557
781
  if (currentRawReasoning) closeCurrentRawReasoning();
558
782
  flushHiddenRawReasoning();
@@ -565,9 +789,14 @@ export function bridgeToResponsesSSE(
565
789
  item_id: itemId, output_index: outputIndex, summary_index: 0,
566
790
  part: { type: "summary_text", text: "" },
567
791
  });
568
- currentReasoning = { itemId, outputIndex, text: "" };
792
+ currentReasoning = { itemId, outputIndex, text: "", textBytes: 0 };
569
793
  }
570
- currentReasoning.text += event.thinking;
794
+ ({ value: currentReasoning.text, bytes: currentReasoning.textBytes } = appendString(
795
+ currentReasoning.text,
796
+ currentReasoning.textBytes,
797
+ event.thinking,
798
+ "reasoning",
799
+ ));
571
800
  emit("response.reasoning_summary_text.delta", {
572
801
  item_id: currentReasoning.itemId, output_index: currentReasoning.outputIndex,
573
802
  summary_index: 0, delta: event.thinking,
@@ -575,6 +804,7 @@ export function bridgeToResponsesSSE(
575
804
  break;
576
805
  }
577
806
  case "thinking_signature": {
807
+ pendingSignatureBytes = replaceRetainedString(pendingSignatureBytes, event.signature, "reasoning");
578
808
  pendingSignature = event.signature;
579
809
  // Signature arrives at the end of the thinking block. With a visible reasoning item
580
810
  // open, closeCurrentReasoning attaches the envelope; hidden/suppressed blocks flush
@@ -583,11 +813,20 @@ export function bridgeToResponsesSSE(
583
813
  break;
584
814
  }
585
815
  case "redacted_thinking": {
816
+ budget?.chargeRetained(bytesOf(event.data), { kind: "reasoning" });
586
817
  pendingRedacted.push(event.data);
587
818
  break;
588
819
  }
589
820
  case "reasoning_raw_delta": {
590
- if (options?.hideThinkingSummary) { hiddenRawReasoningText += event.text; break; }
821
+ if (options?.hideThinkingSummary) {
822
+ ({ value: hiddenRawReasoningText, bytes: hiddenRawReasoningBytes } = appendString(
823
+ hiddenRawReasoningText,
824
+ hiddenRawReasoningBytes,
825
+ event.text,
826
+ "reasoning",
827
+ ));
828
+ break;
829
+ }
591
830
  if (currentMsg) closeCurrentMessage("commentary");
592
831
  if (currentReasoning) closeCurrentReasoning();
593
832
  if (currentToolCall) closeCurrentToolCall();
@@ -595,9 +834,14 @@ export function bridgeToResponsesSSE(
595
834
  const itemId = `rs_${uuid()}`;
596
835
  const item = { type: "reasoning", id: itemId, summary: [] as never[], content: [] as { type: string; text: string }[] };
597
836
  emit("response.output_item.added", { output_index: outputIndex, item });
598
- currentRawReasoning = { itemId, outputIndex, text: "" };
837
+ currentRawReasoning = { itemId, outputIndex, text: "", textBytes: 0 };
599
838
  }
600
- currentRawReasoning.text += event.text;
839
+ ({ value: currentRawReasoning.text, bytes: currentRawReasoning.textBytes } = appendString(
840
+ currentRawReasoning.text,
841
+ currentRawReasoning.textBytes,
842
+ event.text,
843
+ "reasoning",
844
+ ));
601
845
  emit("response.reasoning_text.delta", {
602
846
  item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex,
603
847
  content_index: 0, delta: event.text,
@@ -622,12 +866,19 @@ export function bridgeToResponsesSSE(
622
866
  ? { type: "custom_tool_call", id: itemId, call_id: event.id, name: realName, input: "", status: "in_progress" }
623
867
  : { type: "function_call", id: itemId, call_id: event.id, name: realName, arguments: "", status: "in_progress", ...(ns ? { namespace: ns } : {}) };
624
868
  emit("response.output_item.added", { output_index: outputIndex, item });
625
- currentToolCall = { itemId, outputIndex, callId: event.id, name: realName, args: "", namespace: ns, freeform, toolSearch };
869
+ currentToolCall = { itemId, outputIndex, callId: event.id, name: realName, args: "", argsBytes: 0, namespace: ns, freeform, toolSearch };
870
+ budget?.openCall(event.id);
626
871
  break;
627
872
  }
628
873
  case "tool_call_delta": {
629
874
  if (currentToolCall) {
630
- currentToolCall.args += event.arguments;
875
+ ({ value: currentToolCall.args, bytes: currentToolCall.argsBytes } = appendString(
876
+ currentToolCall.args,
877
+ currentToolCall.argsBytes,
878
+ event.arguments,
879
+ "tool_args",
880
+ currentToolCall.callId,
881
+ ));
631
882
  if (!currentToolCall.freeform && !currentToolCall.toolSearch) {
632
883
  emit("response.function_call_arguments.delta", {
633
884
  item_id: currentToolCall.itemId, output_index: currentToolCall.outputIndex,
@@ -653,6 +904,32 @@ export function bridgeToResponsesSSE(
653
904
  break;
654
905
  }
655
906
  case "tool_call_end": {
907
+ // Fragments already streamed cannot be repaired. Refuse to complete a function call
908
+ // whose assembled arguments do not parse — cancel the item and fail the turn so the
909
+ // client never sees status:"completed" for unusable args (#765 stream remainder).
910
+ if (
911
+ currentToolCall
912
+ && !currentToolCall.freeform
913
+ && !currentToolCall.toolSearch
914
+ && !toolCallArgumentsUsable(currentToolCall.args)
915
+ ) {
916
+ failCurrentToolCall();
917
+ const failure = responseError(
918
+ 502,
919
+ "upstream_error",
920
+ "upstream stream produced malformed tool call arguments",
921
+ );
922
+ emit("response.failed", {
923
+ response: {
924
+ ...responseSnapshot("failed", finishedItems),
925
+ error: failure,
926
+ last_error: failure,
927
+ },
928
+ });
929
+ reportTerminal("failed");
930
+ terminalEvent = true;
931
+ break;
932
+ }
656
933
  closeCurrentToolCall();
657
934
  break;
658
935
  }
@@ -691,7 +968,11 @@ export function bridgeToResponsesSSE(
691
968
  if (event.sources) {
692
969
  const seen = new Set(pendingWebSources.map(s => s.url));
693
970
  for (const s of event.sources) {
694
- if (!seen.has(s.url)) { seen.add(s.url); pendingWebSources.push(s); }
971
+ if (!seen.has(s.url)) {
972
+ seen.add(s.url);
973
+ chargeValue(s, "tool_search_sources");
974
+ pendingWebSources.push(s);
975
+ }
695
976
  }
696
977
  }
697
978
  break;
@@ -713,7 +994,7 @@ export function bridgeToResponsesSSE(
713
994
  encrypted_content: encodeCompactionSummary(compactionText),
714
995
  };
715
996
  emit("response.output_item.done", { output_index: outputIndex, item });
716
- finishedItems.push(item as OutputItem);
997
+ retainFinishedItem(item as OutputItem, compactionTextBytes);
717
998
  outputIndex++;
718
999
  }
719
1000
  if (event.stopReason === "max_tokens" || event.stopReason === "content_filter") {
@@ -749,7 +1030,7 @@ export function bridgeToResponsesSSE(
749
1030
  if (currentReasoning) closeCurrentReasoning();
750
1031
  if (currentRawReasoning) closeCurrentRawReasoning();
751
1032
  flushHiddenRawReasoning();
752
- if (currentToolCall) closeCurrentToolCall();
1033
+ if (currentToolCall) failCurrentToolCall();
753
1034
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
754
1035
  flushHiddenReasoningEnvelope();
755
1036
  options?.onUsage?.(event.usage);
@@ -769,11 +1050,15 @@ export function bridgeToResponsesSSE(
769
1050
  break;
770
1051
  }
771
1052
  case "error": {
1053
+ if (event.code === "translation_buffer_limit") {
1054
+ terminateForTranslatorOverflow(event);
1055
+ return;
1056
+ }
772
1057
  if (currentMsg) closeCurrentMessage();
773
1058
  if (currentReasoning) closeCurrentReasoning();
774
1059
  if (currentRawReasoning) closeCurrentRawReasoning();
775
1060
  flushHiddenRawReasoning();
776
- if (currentToolCall) closeCurrentToolCall();
1061
+ if (currentToolCall) failCurrentToolCall();
777
1062
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
778
1063
  const failure = adapterFailureFromEvent(event);
779
1064
  if (event.usage) options?.onUsage?.(event.usage);
@@ -794,15 +1079,19 @@ export function bridgeToResponsesSSE(
794
1079
  }
795
1080
  }
796
1081
  if (terminalEvent) {
797
- onCancel?.();
1082
+ cancelUpstreamOnce();
798
1083
  terminated = true;
799
- returnIterator();
800
1084
  break;
801
1085
  }
802
1086
  }
803
1087
  } catch (err) {
1088
+ if (isTranslatorBudgetExceededError(err)) {
1089
+ terminateForTranslatorOverflow(err);
1090
+ return;
1091
+ }
804
1092
  if (!terminated) {
805
1093
  flushHiddenRawReasoning();
1094
+ if (currentToolCall) failCurrentToolCall();
806
1095
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
807
1096
  emit("response.failed", {
808
1097
  response: {
@@ -812,9 +1101,8 @@ export function bridgeToResponsesSSE(
812
1101
  },
813
1102
  });
814
1103
  reportTerminal("failed");
815
- onCancel?.();
1104
+ cancelUpstreamOnce();
816
1105
  terminated = true;
817
- returnIterator();
818
1106
  }
819
1107
  }
820
1108
 
@@ -832,7 +1120,7 @@ export function bridgeToResponsesSSE(
832
1120
  if (currentReasoning) closeCurrentReasoning();
833
1121
  if (currentRawReasoning) closeCurrentRawReasoning();
834
1122
  flushHiddenRawReasoning();
835
- if (currentToolCall) closeCurrentToolCall();
1123
+ if (currentToolCall) failCurrentToolCall();
836
1124
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
837
1125
  options?.onUsage?.(undefined);
838
1126
  emit("response.incomplete", {
@@ -872,7 +1160,7 @@ export function bridgeToResponsesSSE(
872
1160
  if (currentReasoning) closeCurrentReasoning();
873
1161
  if (currentRawReasoning) closeCurrentRawReasoning();
874
1162
  flushHiddenRawReasoning();
875
- if (currentToolCall) closeCurrentToolCall();
1163
+ if (currentToolCall) failCurrentToolCall();
876
1164
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
877
1165
  emit("response.incomplete", {
878
1166
  response: {
@@ -881,9 +1169,8 @@ export function bridgeToResponsesSSE(
881
1169
  },
882
1170
  });
883
1171
  reportTerminal("incomplete");
884
- onCancel?.();
1172
+ cancelUpstreamOnce();
885
1173
  terminated = true;
886
- returnIterator();
887
1174
  emitDone();
888
1175
  if (beat !== undefined) clearBeatInterval(beat);
889
1176
  beat = undefined;
@@ -919,8 +1206,7 @@ export function bridgeToResponsesSSE(
919
1206
  clientCancelled = true;
920
1207
  closed = true;
921
1208
  if (beat !== undefined) clearBeatInterval(beat);
922
- onCancel?.();
923
- returnIterator();
1209
+ cancelUpstreamOnce();
924
1210
  },
925
1211
  });
926
1212
  }
@@ -938,10 +1224,49 @@ export function buildResponseJSON(
938
1224
  onProviderState?: (state: OcxProviderContinuationState) => void;
939
1225
  /** Raw adapter-reported usage before wire normalization (see bridgeToResponsesSSE onUsage). */
940
1226
  onUsage?: (usage: OcxUsage | undefined) => void;
1227
+ translatorBudget?: TranslatorBudget;
941
1228
  },
942
1229
  ): Record<string, unknown> {
943
1230
  const responseId = `resp_${uuid()}`;
944
1231
  const output: OutputItem[] = [];
1232
+ const budget = options?.translatorBudget;
1233
+ const encoder = new TextEncoder();
1234
+ const bytesOf = (value: string): number => Buffer.byteLength(value);
1235
+ const appendBatchString = (
1236
+ previous: string,
1237
+ previousBytes: number,
1238
+ fragment: string,
1239
+ kind: TranslatorBufferKind,
1240
+ callId?: string,
1241
+ ): { value: string; bytes: number } => {
1242
+ const nextBytes = previousBytes + bytesOf(fragment);
1243
+ if (!budget) return { value: previous + fragment, bytes: nextBytes };
1244
+ const scope = { kind, ...(callId ? { callId } : {}) };
1245
+ const reservation = budget.reserveTransient(nextBytes, scope);
1246
+ try {
1247
+ const value = previous + fragment;
1248
+ reservation.commitRetained();
1249
+ budget.releaseRetained(previousBytes, scope);
1250
+ return { value, bytes: nextBytes };
1251
+ } catch (error) {
1252
+ reservation.release();
1253
+ throw error;
1254
+ }
1255
+ };
1256
+ const replaceBatchRetainedString = (previousBytes: number, next: string, kind: TranslatorBufferKind): number => {
1257
+ const nextBytes = bytesOf(next);
1258
+ if (!budget) return nextBytes;
1259
+ const reservation = budget.reserveTransient(nextBytes, { kind });
1260
+ reservation.commitRetained();
1261
+ budget.releaseRetained(previousBytes, { kind });
1262
+ return nextBytes;
1263
+ };
1264
+ const pushOutput = (item: OutputItem, replacedBytes = 0, kind: TranslatorBufferKind = "retained_collectors") => {
1265
+ const reservation = budget?.reserveTransient(bytesOf(JSON.stringify(item)), { kind });
1266
+ output.push(item);
1267
+ reservation?.commitRetained();
1268
+ if (replacedBytes > 0) budget?.releaseRetained(replacedBytes, { kind });
1269
+ };
945
1270
  let usage: OcxUsage | undefined;
946
1271
  let errorEvent: Extract<AdapterEvent, { type: "error" }> | undefined;
947
1272
  let incompleteEvent: Extract<AdapterEvent, { type: "incomplete" }> | undefined;
@@ -949,17 +1274,24 @@ export function buildResponseJSON(
949
1274
  let stopReason: string | undefined;
950
1275
  let cleanDone = false;
951
1276
  let compactionText = "";
1277
+ let compactionTextBytes = 0;
952
1278
 
953
1279
  let currentText = "";
1280
+ let currentTextBytes = 0;
954
1281
  let currentTextPhase: OcxMessagePhase | undefined;
955
1282
  let currentSummaryReasoning = "";
1283
+ let currentSummaryReasoningBytes = 0;
956
1284
  let currentRawReasoning = "";
1285
+ let currentRawReasoningBytes = 0;
957
1286
  // Anthropic extended-thinking round-trip (batch): see bridgeToResponsesSSE counterpart.
958
1287
  let batchSignature: string | undefined;
1288
+ let batchSignatureBytes = 0;
959
1289
  let batchRedacted: string[] = [];
1290
+ let batchRedactedBytes = 0;
960
1291
  let currentToolCallId = "";
961
1292
  let currentToolCallName = "";
962
1293
  let currentToolCallArgs = "";
1294
+ let currentToolCallArgsBytes = 0;
963
1295
  // Web-search citations awaiting the next assistant message (attached as url_citation annotations).
964
1296
  let pendingWebSources: { url: string; title?: string }[] = [];
965
1297
 
@@ -978,12 +1310,14 @@ export function buildResponseJSON(
978
1310
  type: "url_citation", url: s.url, ...(s.title ? { title: s.title } : {}), start_index: 0, end_index: 0,
979
1311
  }));
980
1312
  pendingWebSources = [];
981
- output.push({
1313
+ const item = {
982
1314
  type: "message", id: `msg_${uuid()}`, role: "assistant", status: "completed",
983
1315
  content: [{ type: "output_text", text: currentText, annotations }],
984
1316
  ...(phase ? { phase } : {}),
985
- });
1317
+ } as OutputItem;
1318
+ pushOutput(item, currentTextBytes);
986
1319
  currentText = "";
1320
+ currentTextBytes = 0;
987
1321
  currentTextPhase = undefined;
988
1322
  };
989
1323
  const flushSummaryReasoning = () => {
@@ -994,34 +1328,46 @@ export function buildResponseJSON(
994
1328
  const hidden = options?.hideThinkingSummary === true;
995
1329
  if (hidden && currentSummaryReasoning && (envelope.sig || envelope.red)) envelope.txt = currentSummaryReasoning;
996
1330
  const encrypted = envelope.sig || envelope.red || envelope.txt ? encodeReasoningEnvelope(envelope) : undefined;
1331
+ const sourceBytes = currentSummaryReasoningBytes + batchSignatureBytes + batchRedactedBytes;
997
1332
  batchSignature = undefined;
1333
+ batchSignatureBytes = 0;
998
1334
  batchRedacted = [];
999
- if (hidden && !encrypted) { currentSummaryReasoning = ""; return; }
1000
- output.push({
1335
+ batchRedactedBytes = 0;
1336
+ if (hidden && !encrypted) {
1337
+ budget?.releaseRetained(sourceBytes, { kind: "reasoning" });
1338
+ currentSummaryReasoning = "";
1339
+ currentSummaryReasoningBytes = 0;
1340
+ return;
1341
+ }
1342
+ const item = {
1001
1343
  type: "reasoning", id: `rs_${uuid()}`,
1002
1344
  summary: !hidden && currentSummaryReasoning ? [{ type: "summary_text", text: currentSummaryReasoning }] : [],
1003
1345
  ...(encrypted ? { encrypted_content: encrypted } : {}),
1004
- });
1346
+ } as OutputItem;
1347
+ pushOutput(item, sourceBytes, "reasoning");
1005
1348
  currentSummaryReasoning = "";
1349
+ currentSummaryReasoningBytes = 0;
1006
1350
  };
1007
1351
  const flushRawReasoning = () => {
1008
1352
  if (!currentRawReasoning) return;
1009
1353
  if (options?.hideThinkingSummary === true) {
1010
1354
  // Same contract as the streaming path: no visible reasoning, txt-only envelope round-trip.
1011
- output.push({
1355
+ pushOutput({
1012
1356
  type: "reasoning", id: `rs_${uuid()}`, summary: [],
1013
1357
  encrypted_content: encodeReasoningEnvelope({ txt: currentRawReasoning }),
1014
- });
1358
+ }, currentRawReasoningBytes, "reasoning");
1015
1359
  currentRawReasoning = "";
1360
+ currentRawReasoningBytes = 0;
1016
1361
  return;
1017
1362
  }
1018
- output.push({
1363
+ pushOutput({
1019
1364
  type: "reasoning", id: `rs_${uuid()}`, summary: [],
1020
1365
  content: [{ type: "reasoning_text", text: currentRawReasoning }],
1021
- });
1366
+ }, currentRawReasoningBytes, "reasoning");
1022
1367
  currentRawReasoning = "";
1368
+ currentRawReasoningBytes = 0;
1023
1369
  };
1024
- const flushToolCall = () => {
1370
+ const flushToolCall = (status: "completed" | "incomplete" = "completed") => {
1025
1371
  if (!currentToolCallId) return;
1026
1372
  const mapped = options?.toolNsMap?.get(currentToolCallName);
1027
1373
  const realName = mapped?.name ?? currentToolCallName;
@@ -1029,28 +1375,30 @@ export function buildResponseJSON(
1029
1375
  const toolSearch = options?.toolSearchToolNames?.has(realName) ?? false;
1030
1376
  const freeform = !toolSearch && (options?.freeformToolNames?.has(realName) ?? false);
1031
1377
  if (toolSearch) {
1032
- output.push({
1378
+ pushOutput({
1033
1379
  type: "tool_search_call", id: `tsc_${uuid()}`,
1034
1380
  call_id: currentToolCallId, execution: "client",
1035
- arguments: parseArgsObj(currentToolCallArgs), status: "completed",
1381
+ arguments: parseArgsObj(currentToolCallArgs), status,
1036
1382
  });
1037
1383
  } else if (freeform) {
1038
- output.push({
1384
+ pushOutput({
1039
1385
  type: "custom_tool_call", id: `ctc_${uuid()}`,
1040
1386
  call_id: currentToolCallId, name: realName,
1041
- input: freeformInput(currentToolCallArgs), status: "completed",
1387
+ input: freeformInput(currentToolCallArgs), status,
1042
1388
  });
1043
1389
  } else {
1044
- output.push({
1390
+ pushOutput({
1045
1391
  type: "function_call", id: `fc_${uuid()}`,
1046
1392
  call_id: currentToolCallId, name: realName,
1047
- arguments: currentToolCallArgs || "{}", status: "completed",
1393
+ arguments: currentToolCallArgs || "{}", status,
1048
1394
  ...(ns ? { namespace: ns } : {}),
1049
1395
  });
1050
1396
  }
1397
+ budget?.closeCall(currentToolCallId);
1051
1398
  currentToolCallId = "";
1052
1399
  currentToolCallName = "";
1053
1400
  currentToolCallArgs = "";
1401
+ currentToolCallArgsBytes = 0;
1054
1402
  };
1055
1403
 
1056
1404
  for (const e of events) {
@@ -1070,32 +1418,52 @@ export function buildResponseJSON(
1070
1418
  if (currentToolCallId) flushToolCall();
1071
1419
  // Compaction turns keep the summary out of normal message output (replay dedup — see
1072
1420
  // bridgeToResponsesSSE); it ships only inside the synthetic compaction item below.
1073
- if (options?.compaction) compactionText += e.text;
1421
+ if (options?.compaction) {
1422
+ ({ value: compactionText, bytes: compactionTextBytes } = appendBatchString(
1423
+ compactionText, compactionTextBytes, e.text, "retained_collectors",
1424
+ ));
1425
+ }
1074
1426
  else {
1075
1427
  if (e.phase !== undefined) currentTextPhase = e.phase;
1076
- currentText += e.text;
1428
+ ({ value: currentText, bytes: currentTextBytes } = appendBatchString(
1429
+ currentText, currentTextBytes, e.text, "retained_collectors",
1430
+ ));
1077
1431
  }
1078
1432
  break;
1079
1433
  case "thinking_delta":
1080
1434
  if (currentText) flushText("commentary");
1081
1435
  if (currentRawReasoning) flushRawReasoning();
1082
1436
  if (currentToolCallId) flushToolCall();
1083
- currentSummaryReasoning += e.thinking;
1437
+ {
1438
+ ({ value: currentSummaryReasoning, bytes: currentSummaryReasoningBytes } = appendBatchString(
1439
+ currentSummaryReasoning, currentSummaryReasoningBytes, e.thinking, "reasoning",
1440
+ ));
1441
+ }
1084
1442
  break;
1085
1443
  case "thinking_signature":
1086
1444
  // End of the current thinking block — flush it WITH the signature envelope so the
1087
1445
  // block/signature pairing survives multi-block turns.
1446
+ batchSignatureBytes = replaceBatchRetainedString(batchSignatureBytes, e.signature, "reasoning");
1088
1447
  batchSignature = e.signature;
1089
1448
  flushSummaryReasoning();
1090
1449
  break;
1091
1450
  case "redacted_thinking":
1451
+ {
1452
+ const dataBytes = bytesOf(e.data);
1453
+ budget?.chargeRetained(dataBytes, { kind: "reasoning" });
1454
+ batchRedactedBytes += dataBytes;
1455
+ }
1092
1456
  batchRedacted.push(e.data);
1093
1457
  break;
1094
1458
  case "reasoning_raw_delta":
1095
1459
  if (currentText) flushText("commentary");
1096
1460
  if (currentSummaryReasoning) flushSummaryReasoning();
1097
1461
  if (currentToolCallId) flushToolCall();
1098
- currentRawReasoning += e.text;
1462
+ {
1463
+ ({ value: currentRawReasoning, bytes: currentRawReasoningBytes } = appendBatchString(
1464
+ currentRawReasoning, currentRawReasoningBytes, e.text, "reasoning",
1465
+ ));
1466
+ }
1099
1467
  break;
1100
1468
  case "tool_call_start":
1101
1469
  if (currentText) flushText("commentary");
@@ -1103,13 +1471,36 @@ export function buildResponseJSON(
1103
1471
  if (currentRawReasoning) flushRawReasoning();
1104
1472
  flushToolCall();
1105
1473
  currentToolCallId = e.id;
1474
+ budget?.openCall(e.id);
1106
1475
  currentToolCallName = e.name;
1107
1476
  currentToolCallArgs = "";
1477
+ currentToolCallArgsBytes = 0;
1108
1478
  break;
1109
1479
  case "tool_call_delta":
1110
- currentToolCallArgs += e.arguments;
1480
+ {
1481
+ ({ value: currentToolCallArgs, bytes: currentToolCallArgsBytes } = appendBatchString(
1482
+ currentToolCallArgs, currentToolCallArgsBytes, e.arguments, "tool_args", currentToolCallId,
1483
+ ));
1484
+ }
1111
1485
  break;
1112
1486
  case "tool_call_end":
1487
+ if (!toolCallArgumentsUsable(currentToolCallArgs) && currentToolCallId) {
1488
+ // Mirror the streaming path: refuse to complete unusable arguments.
1489
+ const mapped = options?.toolNsMap?.get(currentToolCallName);
1490
+ const realName = mapped?.name ?? currentToolCallName;
1491
+ const toolSearch = options?.toolSearchToolNames?.has(realName) ?? false;
1492
+ const freeform = !toolSearch && (options?.freeformToolNames?.has(realName) ?? false);
1493
+ if (!freeform && !toolSearch) {
1494
+ flushToolCall("incomplete");
1495
+ errorEvent = {
1496
+ type: "error",
1497
+ message: "upstream stream produced malformed tool call arguments",
1498
+ status: 502,
1499
+ errorType: "upstream_error",
1500
+ };
1501
+ break;
1502
+ }
1503
+ }
1113
1504
  flushToolCall();
1114
1505
  break;
1115
1506
  case "web_search_call_begin":
@@ -1121,7 +1512,7 @@ export function buildResponseJSON(
1121
1512
  if (currentSummaryReasoning) flushSummaryReasoning();
1122
1513
  if (currentRawReasoning) flushRawReasoning();
1123
1514
  flushToolCall();
1124
- output.push({
1515
+ pushOutput({
1125
1516
  type: "web_search_call", id: `ws_${uuid()}`, status: e.status ?? "completed",
1126
1517
  action: webSearchAction(e.queries),
1127
1518
  ...(e.sources && e.sources.length > 0 ? { sources: e.sources } : {}),
@@ -1129,7 +1520,11 @@ export function buildResponseJSON(
1129
1520
  if (e.sources) {
1130
1521
  const seen = new Set(pendingWebSources.map(s => s.url));
1131
1522
  for (const s of e.sources) {
1132
- if (!seen.has(s.url)) { seen.add(s.url); pendingWebSources.push(s); }
1523
+ if (!seen.has(s.url)) {
1524
+ seen.add(s.url);
1525
+ budget?.chargeRetained(bytesOf(JSON.stringify(s)), { kind: "tool_search_sources" });
1526
+ pendingWebSources.push(s);
1527
+ }
1133
1528
  }
1134
1529
  }
1135
1530
  break;
@@ -1151,11 +1546,13 @@ export function buildResponseJSON(
1151
1546
  if (e.stopReason === "max_tokens" || e.stopReason === "content_filter") stopReason = e.stopReason;
1152
1547
  break;
1153
1548
  }
1549
+ if (budget) releaseTranslatedEvent(e, budget);
1154
1550
  }
1155
1551
  flushText(cleanDone && !errorEvent && !incompleteEvent ? "final_answer" : undefined);
1156
1552
  flushSummaryReasoning();
1157
1553
  flushRawReasoning();
1158
- flushToolCall();
1554
+ // Open tool call on a failed/incomplete turn must not land as status:"completed".
1555
+ if (currentToolCallId) flushToolCall(errorEvent || incompleteEvent ? "incomplete" : "completed");
1159
1556
  // A truncated turn must never be installed as replacement history: emit the
1160
1557
  // compaction item only when the turn actually completed (#422).
1161
1558
  if (
@@ -1165,7 +1562,7 @@ export function buildResponseJSON(
1165
1562
  && stopReason !== "max_tokens"
1166
1563
  && stopReason !== "content_filter"
1167
1564
  ) {
1168
- output.push({ type: "compaction", id: `cmp_${uuid()}`, encrypted_content: encodeCompactionSummary(compactionText) });
1565
+ pushOutput({ type: "compaction", id: `cmp_${uuid()}`, encrypted_content: encodeCompactionSummary(compactionText) }, compactionTextBytes);
1169
1566
  }
1170
1567
 
1171
1568
  const failure = errorEvent ? adapterFailureFromEvent(errorEvent) : undefined;