@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
@@ -8,6 +8,7 @@
8
8
  type Rec = Record<string, unknown>;
9
9
 
10
10
  import { decodeServerSentEvents } from "../lib/sse-decoder";
11
+ import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget";
11
12
  import { classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, isCyberPolicyMessage } from "../lib/errors";
12
13
 
13
14
  function isRec(v: unknown): v is Rec {
@@ -135,13 +136,28 @@ function chunkBase(id: string, model: string, created: number): Rec {
135
136
  };
136
137
  }
137
138
 
139
+ function appendedUtf8Bytes(previous: string, previousBytes: number, fragment: string): number {
140
+ let nextBytes = previousBytes + Buffer.byteLength(fragment);
141
+ const previousLast = previous.charCodeAt(previous.length - 1);
142
+ const fragmentFirst = fragment.charCodeAt(0);
143
+ if (previousLast >= 0xd800 && previousLast <= 0xdbff
144
+ && fragmentFirst >= 0xdc00 && fragmentFirst <= 0xdfff) {
145
+ // Buffer.byteLength() replaces each isolated surrogate with three bytes, while the joined
146
+ // pair is one four-byte scalar. Preserve full-string sizing without re-encoding the prefix.
147
+ nextBytes -= 2;
148
+ }
149
+ return nextBytes;
150
+ }
151
+
138
152
  /**
139
153
  * Streaming: Responses SSE bytes -> Chat Completions SSE bytes.
140
154
  */
141
155
  export function responsesSseToChatCompletionsSse(
142
156
  upstream: ReadableStream<Uint8Array>,
143
157
  model: string,
158
+ opts: { translatorBudget: TranslatorBudget },
144
159
  ): ReadableStream<Uint8Array> {
160
+ const translatorBudget = opts.translatorBudget;
145
161
  const encoder = new TextEncoder();
146
162
  let terminated = false;
147
163
  let cancelled = false;
@@ -155,6 +171,7 @@ export function responsesSseToChatCompletionsSse(
155
171
  const toolCallIdByIndex = new Map<number, string>();
156
172
  const toolNameByIndex = new Map<number, string>();
157
173
  const toolArgumentsByIndex = new Map<number, string>();
174
+ const toolArgumentBytesByIndex = new Map<number, number>();
158
175
  const emittedToolIndexes = new Set<number>();
159
176
  let nextToolIndex = 0;
160
177
  let sseIterator: AsyncGenerator<{ event?: string; data: string }> | undefined;
@@ -164,9 +181,54 @@ export function responsesSseToChatCompletionsSse(
164
181
  let emittedFrames = 0;
165
182
  let stepping = false;
166
183
  let decoderStarted = false;
184
+ const queuedLiveFrameBytes: number[] = [];
185
+ const enqueueLiveFrame = (frame: Uint8Array) => {
186
+ const reservation = translatorBudget.reserveTransient(frame.byteLength, { kind: "live_transient" });
187
+ controller.enqueue(frame);
188
+ reservation.commitRetained();
189
+ queuedLiveFrameBytes.push(frame.byteLength);
190
+ };
191
+ const releaseDeliveredFrame = () => {
192
+ const bytes = queuedLiveFrameBytes.shift();
193
+ if (bytes !== undefined) translatorBudget.releaseRetained(bytes, { kind: "live_transient" });
194
+ };
195
+ const closeToolCalls = () => {
196
+ for (const callId of toolIndexByCallId.keys()) translatorBudget.closeCall(callId);
197
+ };
198
+ const replaceToolArguments = (toolIndex: number, next: string) => {
199
+ const previous = toolArgumentsByIndex.get(toolIndex) ?? "";
200
+ if (previous === next && toolArgumentsByIndex.has(toolIndex)) return;
201
+ const previousBytes = toolArgumentBytesByIndex.get(toolIndex) ?? 0;
202
+ const nextBytes = Buffer.byteLength(next);
203
+ const callId = toolCallIdByIndex.get(toolIndex);
204
+ const scope = { kind: "tool_args" as const, ...(callId ? { callId } : {}) };
205
+ const reservation = translatorBudget.reserveTransient(nextBytes, scope);
206
+ toolArgumentsByIndex.set(toolIndex, next);
207
+ toolArgumentBytesByIndex.set(toolIndex, nextBytes);
208
+ reservation.commitRetained();
209
+ translatorBudget.releaseRetained(previousBytes, scope);
210
+ };
211
+ const appendToolArguments = (toolIndex: number, fragment: string) => {
212
+ const previous = toolArgumentsByIndex.get(toolIndex) ?? "";
213
+ const previousBytes = toolArgumentBytesByIndex.get(toolIndex) ?? 0;
214
+ const nextBytes = appendedUtf8Bytes(previous, previousBytes, fragment);
215
+ const callId = toolCallIdByIndex.get(toolIndex);
216
+ const scope = { kind: "tool_args" as const, ...(callId ? { callId } : {}) };
217
+ const reservation = translatorBudget.reserveTransient(nextBytes, scope);
218
+ try {
219
+ const next = previous + fragment;
220
+ toolArgumentsByIndex.set(toolIndex, next);
221
+ toolArgumentBytesByIndex.set(toolIndex, nextBytes);
222
+ reservation.commitRetained();
223
+ translatorBudget.releaseRetained(previousBytes, scope);
224
+ } catch (error) {
225
+ reservation.release();
226
+ throw error;
227
+ }
228
+ };
167
229
  const emit = (payload: Rec | "[DONE]") => {
168
230
  if (failed) return;
169
- controller.enqueue(encoder.encode(dataFrame(payload)));
231
+ enqueueLiveFrame(encoder.encode(dataFrame(payload)));
170
232
  emittedFrames++;
171
233
  };
172
234
  const ensureRole = () => {
@@ -251,21 +313,32 @@ export function responsesSseToChatCompletionsSse(
251
313
  // like response.text().
252
314
  const statusHint = details?.status ?? streamErrorStatus(message);
253
315
  const classified = classifyError(statusHint, details?.type ?? "upstream_error", message);
254
- if (isCyberPolicyCode(details?.code) || classified.code === CYBER_POLICY_ERROR_CODE) {
316
+ const translatorOverflow = details?.code === "translation_buffer_limit";
317
+ if (translatorOverflow) {
318
+ upstreamAbort.abort(new Error("upstream translation buffer exceeded the safe limit"));
319
+ closeToolCalls();
320
+ try { void sseIterator?.return(undefined).catch(() => {}); } catch { /* already closed */ }
321
+ classified.code = "translation_buffer_limit";
322
+ classified.type = "invalid_request_error";
323
+ } else if (isCyberPolicyCode(details?.code) || classified.code === CYBER_POLICY_ERROR_CODE) {
255
324
  classified.code = CYBER_POLICY_ERROR_CODE;
256
325
  classified.type = "invalid_request_error";
257
326
  } else if (details?.code !== undefined && details.code !== null && !classified.code) {
258
327
  classified.code = details.code;
259
328
  }
260
329
  try {
261
- controller.enqueue(encoder.encode(dataFrame({
330
+ const frame = encoder.encode(dataFrame({
262
331
  error: {
263
332
  message: classified.message,
264
333
  type: classified.type,
265
334
  param: null,
266
335
  code: classified.code,
267
336
  },
268
- })));
337
+ }));
338
+ // The budget is already exhausted. This bounded emergency frame is the sole
339
+ // typed overflow closure and therefore cannot reserve from that budget again.
340
+ if (translatorOverflow) controller.enqueue(frame);
341
+ else enqueueLiveFrame(frame);
269
342
  emittedFrames++;
270
343
  } catch {
271
344
  /* controller may already be closed */
@@ -303,12 +376,11 @@ export function responsesSseToChatCompletionsSse(
303
376
  toolCallIdByIndex.set(toolIndex, callId);
304
377
  if (typeof item.id === "string") toolIndexByItemId.set(item.id, toolIndex);
305
378
  if (name) toolNameByIndex.set(toolIndex, name);
306
- if (!toolArgumentsByIndex.has(toolIndex)) {
307
- toolArgumentsByIndex.set(
308
- toolIndex,
309
- typeof item.arguments === "string" ? item.arguments : "",
310
- );
311
- }
379
+ translatorBudget.openCall(callId);
380
+ if (!toolArgumentsByIndex.has(toolIndex)) replaceToolArguments(
381
+ toolIndex,
382
+ typeof item.arguments === "string" ? item.arguments : "",
383
+ );
312
384
  break;
313
385
  }
314
386
  case "response.function_call_arguments.delta": {
@@ -317,10 +389,7 @@ export function responsesSseToChatCompletionsSse(
317
389
  const toolIndex = (itemId ? toolIndexByItemId.get(itemId) : undefined)
318
390
  ?? (nextToolIndex > 0 ? nextToolIndex - 1 : 0);
319
391
  ensureRole();
320
- toolArgumentsByIndex.set(
321
- toolIndex,
322
- (toolArgumentsByIndex.get(toolIndex) ?? "") + data.delta,
323
- );
392
+ appendToolArguments(toolIndex, data.delta);
324
393
  break;
325
394
  }
326
395
  case "response.function_call_arguments.done": {
@@ -331,10 +400,7 @@ export function responsesSseToChatCompletionsSse(
331
400
  const name = typeof data.name === "string" ? data.name : "";
332
401
  if (name) toolNameByIndex.set(toolIndex, name);
333
402
  const streamedArgs = toolArgumentsByIndex.get(toolIndex) ?? "";
334
- toolArgumentsByIndex.set(
335
- toolIndex,
336
- resolveFinalArguments(data.arguments, streamedArgs),
337
- );
403
+ replaceToolArguments(toolIndex, resolveFinalArguments(data.arguments, streamedArgs));
338
404
  break;
339
405
  }
340
406
  case "response.output_item.done": {
@@ -351,6 +417,7 @@ export function responsesSseToChatCompletionsSse(
351
417
  const toolIndex = existingIndex ?? nextToolIndex++;
352
418
  toolIndexByCallId.set(callId, toolIndex);
353
419
  toolCallIdByIndex.set(toolIndex, callId);
420
+ translatorBudget.openCall(callId);
354
421
  if (itemId) toolIndexByItemId.set(itemId, toolIndex);
355
422
  if (name) toolNameByIndex.set(toolIndex, name);
356
423
  const finalName = name || toolNameByIndex.get(toolIndex) || "";
@@ -361,7 +428,7 @@ export function responsesSseToChatCompletionsSse(
361
428
  // accumulate them. Emit one complete call here; finish() flushes any item whose
362
429
  // done event was omitted, and replace-style clients still get a complete object.
363
430
  const args = resolveFinalArguments(item.arguments, streamedArgs);
364
- toolArgumentsByIndex.set(toolIndex, args);
431
+ replaceToolArguments(toolIndex, args);
365
432
  emitToolCall(toolIndex, callId, finalName, args);
366
433
  }
367
434
  break;
@@ -400,8 +467,9 @@ export function responsesSseToChatCompletionsSse(
400
467
  const type = typeof error.type === "string" ? error.type : undefined;
401
468
  fail(message, {
402
469
  code,
403
- type,
404
- ...(code === CYBER_POLICY_ERROR_CODE ? { status: 400 } : {}),
470
+ ...(code === "translation_buffer_limit"
471
+ ? { status: 413, type: "invalid_request_error" }
472
+ : { type, ...(code === CYBER_POLICY_ERROR_CODE ? { status: 400 } : {}) }),
405
473
  });
406
474
  break;
407
475
  }
@@ -413,7 +481,7 @@ export function responsesSseToChatCompletionsSse(
413
481
  // Shared spec-shaped SSE decoder: handles CRLF framing, arbitrary chunk boundaries,
414
482
  // multi-line data, and a terminal event without a trailing blank line (Sol audit
415
483
  // blocker 3 — the hand-rolled "\n\n" splitter misreported those as truncation).
416
- sseIterator = decodeServerSentEvents(upstream, { signal: upstreamAbort.signal });
484
+ sseIterator = decodeServerSentEvents(upstream, { signal: upstreamAbort.signal, translatorBudget });
417
485
  const step = async () => {
418
486
  if (stepping || cancelled) return;
419
487
  stepping = true;
@@ -443,7 +511,13 @@ export function responsesSseToChatCompletionsSse(
443
511
  handleFrame(eventName, data);
444
512
  }
445
513
  } catch (err) {
446
- fail(err instanceof Error ? err.message : String(err));
514
+ if (isTranslatorBudgetExceededError(err)) {
515
+ upstreamAbort.abort(err);
516
+ closeToolCalls();
517
+ fail(err.message, { status: 413, type: "invalid_request_error", code: err.code });
518
+ } else {
519
+ fail(err instanceof Error ? err.message : String(err));
520
+ }
447
521
  } finally {
448
522
  stepping = false;
449
523
  }
@@ -456,10 +530,13 @@ export function responsesSseToChatCompletionsSse(
456
530
  // decoding pauses until the chat consumer creates demand again.
457
531
  },
458
532
  pull() {
533
+ releaseDeliveredFrame();
459
534
  return step();
460
535
  },
461
536
  cancel(reason) {
462
537
  cancelled = true;
538
+ while (queuedLiveFrameBytes.length > 0) releaseDeliveredFrame();
539
+ closeToolCalls();
463
540
  // Abort first: it cancels the decoder's underlying reader, settling any in-flight
464
541
  // read() so the generator's return() below resolves promptly instead of hanging
465
542
  // behind an idle upstream (Sol re-verification blocker).
@@ -545,15 +622,22 @@ export function responsesJsonToChatCompletion(json: unknown, model: string): Rec
545
622
  export async function collectChatCompletion(
546
623
  stream: ReadableStream<Uint8Array>,
547
624
  model: string,
625
+ translatorBudget: TranslatorBudget,
548
626
  ): Promise<Rec> {
549
627
  const decoder = new TextDecoder();
550
628
  let buffer = "";
551
629
  let content = "";
552
630
  let reasoning = "";
553
- const toolCalls = new Map<number, { id: string; name: string; arguments: string }>();
631
+ const toolCalls = new Map<number, { id: string; name: string; arguments: string; argumentBytes: number }>();
554
632
  let finishReason = "stop";
555
633
  let usage: unknown;
556
634
  let streamError: ChatCompletionsStreamError | null = null;
635
+ const replaceRetained = (previous: string, next: string, kind: "live_transient" | "retained_collectors") => {
636
+ const reservation = translatorBudget.reserveTransient(Buffer.byteLength(next), { kind });
637
+ reservation.commitRetained();
638
+ translatorBudget.releaseRetained(Buffer.byteLength(previous), { kind });
639
+ return next;
640
+ };
557
641
  const reader = stream.getReader();
558
642
  try {
559
643
  for (;;) {
@@ -563,15 +647,22 @@ export async function collectChatCompletion(
563
647
  ({ done, value } = await reader.read());
564
648
  } catch (err) {
565
649
  if (isChatCompletionsStreamError(err)) throw err;
650
+ if (isTranslatorBudgetExceededError(err)) {
651
+ throw new ChatCompletionsStreamError(err.message, {
652
+ status: 413,
653
+ type: "invalid_request_error",
654
+ code: err.code,
655
+ });
656
+ }
566
657
  throw new ChatCompletionsStreamError(err instanceof Error ? err.message : String(err));
567
658
  }
568
659
  if (done) break;
569
660
  if (!value) continue;
570
- buffer += decoder.decode(value, { stream: true });
661
+ buffer = replaceRetained(buffer, buffer + decoder.decode(value, { stream: true }), "live_transient");
571
662
  let sep: number;
572
663
  while ((sep = buffer.indexOf("\n\n")) !== -1) {
573
664
  const rawFrame = buffer.slice(0, sep);
574
- buffer = buffer.slice(sep + 2);
665
+ buffer = replaceRetained(buffer, buffer.slice(sep + 2), "live_transient");
575
666
  for (const line of rawFrame.split("\n")) {
576
667
  if (!line.startsWith("data: ")) continue;
577
668
  const data = line.slice(6).trim();
@@ -585,9 +676,11 @@ export async function collectChatCompletion(
585
676
  : "upstream request failed";
586
677
  const type = typeof parsed.error.type === "string" ? parsed.error.type : "server_error";
587
678
  const code = typeof parsed.error.code === "string" ? parsed.error.code : null;
588
- const status = code === CYBER_POLICY_ERROR_CODE || isCyberPolicyMessage(message)
589
- ? 400
590
- : streamErrorStatus(message);
679
+ const status = code === "translation_buffer_limit"
680
+ ? 413
681
+ : code === CYBER_POLICY_ERROR_CODE || isCyberPolicyMessage(message)
682
+ ? 400
683
+ : streamErrorStatus(message);
591
684
  streamError = new ChatCompletionsStreamError(message, { status, type, code });
592
685
  continue;
593
686
  }
@@ -598,22 +691,31 @@ export async function collectChatCompletion(
598
691
  if (typeof choice.finish_reason === "string") finishReason = choice.finish_reason;
599
692
  const delta = isRec(choice.delta) ? choice.delta : null;
600
693
  if (!delta) continue;
601
- if (typeof delta.content === "string") content += delta.content;
602
- if (typeof delta.reasoning_content === "string") reasoning += delta.reasoning_content;
694
+ if (typeof delta.content === "string") content = replaceRetained(content, content + delta.content, "retained_collectors");
695
+ if (typeof delta.reasoning_content === "string") reasoning = replaceRetained(reasoning, reasoning + delta.reasoning_content, "retained_collectors");
603
696
  if (Array.isArray(delta.tool_calls)) {
604
697
  for (const tc of delta.tool_calls) {
605
698
  if (!isRec(tc)) continue;
606
699
  const index = typeof tc.index === "number" ? tc.index : 0;
607
- const current = toolCalls.get(index) ?? { id: "", name: "", arguments: "" };
700
+ const current = toolCalls.get(index) ?? { id: "", name: "", arguments: "", argumentBytes: 0 };
608
701
  if (typeof tc.id === "string") current.id = tc.id;
609
702
  const fn = isRec(tc.function) ? tc.function : {};
610
703
  // Done-frame final arguments are authoritative last-write-wins snapshots.
611
704
  if (typeof fn.name === "string" && fn.name.length > 0) current.name = fn.name;
612
705
  if (typeof fn.arguments === "string") {
613
- if (fn.arguments.startsWith("{") || fn.arguments.startsWith("[") || current.arguments.length === 0) {
614
- current.arguments = fn.arguments;
615
- } else {
616
- current.arguments += fn.arguments;
706
+ const replace = fn.arguments.startsWith("{") || fn.arguments.startsWith("[") || current.arguments.length === 0;
707
+ const nextBytes = replace
708
+ ? Buffer.byteLength(fn.arguments)
709
+ : appendedUtf8Bytes(current.arguments, current.argumentBytes, fn.arguments);
710
+ const reservation = translatorBudget.reserveTransient(nextBytes, { kind: "retained_collectors" });
711
+ try {
712
+ current.arguments = replace ? fn.arguments : current.arguments + fn.arguments;
713
+ reservation.commitRetained();
714
+ translatorBudget.releaseRetained(current.argumentBytes, { kind: "retained_collectors" });
715
+ current.argumentBytes = nextBytes;
716
+ } catch (error) {
717
+ reservation.release();
718
+ throw error;
617
719
  }
618
720
  }
619
721
  toolCalls.set(index, current);
@@ -635,11 +737,15 @@ export async function collectChatCompletion(
635
737
  if (toolCalls.size > 0) {
636
738
  message.tool_calls = [...toolCalls.entries()]
637
739
  .sort((a, b) => a[0] - b[0])
638
- .map(([, tc]) => ({
740
+ .map(([, tc]) => {
741
+ const copy = {
639
742
  id: tc.id || `call_${uuid().slice(0, 16)}`,
640
743
  type: "function",
641
744
  function: { name: tc.name, arguments: tc.arguments },
642
- }));
745
+ };
746
+ translatorBudget.chargeRetained(Buffer.byteLength(JSON.stringify(copy)), { kind: "retained_collectors" });
747
+ return copy;
748
+ });
643
749
  if (finishReason === "stop") finishReason = "tool_calls";
644
750
  }
645
751
 
@@ -14,6 +14,8 @@
14
14
  */
15
15
  import { createHmac, randomBytes } from "node:crypto";
16
16
  import { isClaudeDebugEnabled } from "../lib/debug-settings";
17
+ import { retainedUtf8Bytes, truncateRetainedUtf8 } from "../lib/admission";
18
+ import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
17
19
 
18
20
  export interface ClaudeInboundDebugEntry {
19
21
  /** Monotonic capture id — unique even when several entries share Date.now(). */
@@ -28,6 +30,8 @@ export interface ClaudeInboundDebugEntry {
28
30
  thinkingBudgetTokens?: number;
29
31
  outputConfigEffort?: string;
30
32
  metadataKeys?: string[];
33
+ metadataKeysDropped?: number;
34
+ rowTruncated?: true;
31
35
  hasMetadataUserId: boolean;
32
36
  hasSystem: boolean;
33
37
  /** Raw anthropic-beta header (comma list) — carries context-1m / effort betas. */
@@ -38,7 +42,11 @@ export interface ClaudeInboundDebugEntry {
38
42
  }
39
43
 
40
44
  const RING_LIMIT = 20;
45
+ const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024;
46
+ const MAX_CLAUDE_INBOUND_METADATA_KEYS = 64;
47
+ const MAX_CLAUDE_INBOUND_ROW_BYTES = 32 * 1024;
41
48
  const ring: ClaudeInboundDebugEntry[] = [];
49
+ let ringBytes = 0;
42
50
  const salt = randomBytes(16).toString("hex");
43
51
  let lastEnabled = false;
44
52
  let nextCaptureId = 0;
@@ -64,6 +72,18 @@ function systemText(system: unknown): string | undefined {
64
72
  return undefined;
65
73
  }
66
74
 
75
+ function entryBytes(entry: ClaudeInboundDebugEntry): number {
76
+ return retainedUtf8Bytes(JSON.stringify(entry));
77
+ }
78
+
79
+ function removeOldest(): number {
80
+ const removed = ring.shift();
81
+ if (!removed) return 0;
82
+ const bytes = entryBytes(removed);
83
+ ringBytes -= bytes;
84
+ return bytes;
85
+ }
86
+
67
87
  /** Record one inbound request. No-op (and ring flush) when the claude debug flag is off. */
68
88
  export function captureClaudeInbound(
69
89
  endpoint: "messages" | "count_tokens",
@@ -73,7 +93,7 @@ export function captureClaudeInbound(
73
93
  ): void {
74
94
  const enabled = isClaudeDebugEnabled();
75
95
  if (!enabled) {
76
- if (lastEnabled) ring.length = 0; // flag turned off: drop captured entries
96
+ if (lastEnabled) clearClaudeInboundDebug(); // flag turned off: drop captured entries
77
97
  lastEnabled = false;
78
98
  return;
79
99
  }
@@ -84,26 +104,42 @@ export function captureClaudeInbound(
84
104
  const metadata = isRec(body.metadata) ? body.metadata : undefined;
85
105
  const userId = metadata && typeof metadata.user_id === "string" ? metadata.user_id : undefined;
86
106
  const system = systemText(body.system);
107
+ const metadataNames = metadata ? Object.keys(metadata) : [];
87
108
  const entry: ClaudeInboundDebugEntry = {
88
109
  id: ++nextCaptureId,
89
110
  at: Date.now(),
90
111
  endpoint,
91
- model: typeof body.model === "string" ? body.model : "unknown",
92
- ...(resolvedModel ? { resolvedModel } : {}),
112
+ model: truncateRetainedUtf8(typeof body.model === "string" ? body.model : "unknown", MAX_DIAGNOSTIC_VALUE_BYTES),
113
+ ...(resolvedModel ? { resolvedModel: truncateRetainedUtf8(resolvedModel, MAX_DIAGNOSTIC_VALUE_BYTES) } : {}),
93
114
  ...(typeof body.stream === "boolean" ? { stream: body.stream } : {}),
94
115
  ...(typeof body.max_tokens === "number" ? { maxTokens: body.max_tokens } : {}),
95
- ...(thinking && typeof thinking.type === "string" ? { thinkingType: thinking.type } : {}),
116
+ ...(thinking && typeof thinking.type === "string" ? { thinkingType: truncateRetainedUtf8(thinking.type, MAX_DIAGNOSTIC_VALUE_BYTES) } : {}),
96
117
  ...(thinking && typeof thinking.budget_tokens === "number" ? { thinkingBudgetTokens: thinking.budget_tokens } : {}),
97
- ...(outputConfig && typeof outputConfig.effort === "string" ? { outputConfigEffort: outputConfig.effort } : {}),
98
- ...(metadata ? { metadataKeys: Object.keys(metadata) } : {}),
118
+ ...(outputConfig && typeof outputConfig.effort === "string" ? { outputConfigEffort: truncateRetainedUtf8(outputConfig.effort, MAX_DIAGNOSTIC_VALUE_BYTES) } : {}),
119
+ ...(metadata ? { metadataKeys: metadataNames.slice(0, MAX_CLAUDE_INBOUND_METADATA_KEYS).map(key => truncateRetainedUtf8(key, MAX_DIAGNOSTIC_VALUE_BYTES)) } : {}),
120
+ ...(metadataNames.length > MAX_CLAUDE_INBOUND_METADATA_KEYS ? { metadataKeysDropped: metadataNames.length - MAX_CLAUDE_INBOUND_METADATA_KEYS } : {}),
99
121
  hasMetadataUserId: userId !== undefined,
100
122
  hasSystem: system !== undefined,
101
- ...(anthropicBeta ? { anthropicBeta } : {}),
123
+ ...(anthropicBeta ? { anthropicBeta: truncateRetainedUtf8(anthropicBeta, MAX_DIAGNOSTIC_VALUE_BYTES) } : {}),
102
124
  ...(userId !== undefined ? { userIdTag: tag(userId) } : {}),
103
125
  ...(system !== undefined ? { systemTag: tag(system) } : {}),
104
126
  };
127
+ while (entryBytes(entry) > MAX_CLAUDE_INBOUND_ROW_BYTES && entry.metadataKeys?.length) {
128
+ entry.metadataKeys.pop();
129
+ entry.metadataKeysDropped = (entry.metadataKeysDropped ?? 0) + 1;
130
+ entry.rowTruncated = true;
131
+ }
132
+ for (const key of ["anthropicBeta", "resolvedModel", "outputConfigEffort", "thinkingType"] as const) {
133
+ if (entryBytes(entry) <= MAX_CLAUDE_INBOUND_ROW_BYTES) break;
134
+ if (entry[key] !== undefined) {
135
+ delete entry[key];
136
+ entry.rowTruncated = true;
137
+ }
138
+ }
105
139
  ring.push(entry);
106
- if (ring.length > RING_LIMIT) ring.shift();
140
+ ringBytes += entryBytes(entry);
141
+ if (ring.length > RING_LIMIT) removeOldest();
142
+ enforceAppOwnedMemoryBudget();
107
143
  }
108
144
 
109
145
  /** Newest-first snapshot for /api/claude/inbound-debug. */
@@ -114,5 +150,14 @@ export function getClaudeInboundDebugEntries(): ClaudeInboundDebugEntry[] {
114
150
  /** Test isolation / explicit clear. */
115
151
  export function clearClaudeInboundDebug(): void {
116
152
  ring.length = 0;
153
+ ringBytes = 0;
117
154
  lastEnabled = false;
118
155
  }
156
+
157
+ export function claudeInboundDebugMetrics(): { entries: number; bytes: number; oldestAt: number | null } {
158
+ return { entries: ring.length, bytes: ringBytes, oldestAt: ring[0]?.at ?? null };
159
+ }
160
+
161
+ export function evictOldestClaudeInboundForBudget(): number {
162
+ return removeOldest();
163
+ }