@bitkyc08/opencodex 2.44.0 → 2.45.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 (38) hide show
  1. package/gui/dist/assets/index-CCfD72yq.js +115 -0
  2. package/gui/dist/assets/index-J96sug5C.css +1 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +1 -1
  5. package/src/adapters/openai-responses.ts +14 -0
  6. package/src/chat/outbound.ts +286 -35
  7. package/src/claude/compatibility.ts +192 -0
  8. package/src/claude/model-info.ts +14 -2
  9. package/src/cli/account-extended.ts +24 -1
  10. package/src/cli/init.ts +70 -13
  11. package/src/codex/catalog/provider-fetch.ts +38 -9
  12. package/src/codex/catalog/sync.ts +34 -2
  13. package/src/codex/catalog.ts +1 -1
  14. package/src/config/initialize.ts +132 -0
  15. package/src/config/rebase-provenance.ts +26 -0
  16. package/src/config.ts +50 -1
  17. package/src/generated/compatibility-version.json +41 -29
  18. package/src/lib/windows-secret-acl.ts +8 -4
  19. package/src/providers/quota.ts +26 -15
  20. package/src/responses/state.ts +48 -3
  21. package/src/server/chat-completions.ts +32 -36
  22. package/src/server/chat-native-sse.ts +23 -3
  23. package/src/server/chat-native.ts +15 -8
  24. package/src/server/claude-messages.ts +27 -0
  25. package/src/server/index.ts +5 -1
  26. package/src/server/management/agent-settings-routes.ts +101 -14
  27. package/src/server/management/logs-usage-routes.ts +9 -2
  28. package/src/server/request-log-cursor.ts +84 -0
  29. package/src/server/request-log.ts +46 -3
  30. package/src/server/responses/agent-task-recovery.ts +50 -14
  31. package/src/server/responses/codex-ws-exchange.ts +79 -0
  32. package/src/server/responses/compact.ts +39 -33
  33. package/src/server/responses/core.ts +43 -27
  34. package/src/storage/cleanup.ts +49 -35
  35. package/src/types/config.ts +4 -0
  36. package/src/usage/log.ts +22 -0
  37. package/gui/dist/assets/index-B7_K1Hsj.js +0 -115
  38. package/gui/dist/assets/index-ltx3L-WS.css +0 -1
@@ -8,7 +8,11 @@
8
8
  type Rec = Record<string, unknown>;
9
9
 
10
10
  import { decodeServerSentEvents, sseFieldValue } from "../lib/sse-decoder";
11
- import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget";
11
+ import {
12
+ isTranslatorBudgetExceededError,
13
+ type TranslatorBudget,
14
+ type TranslatorTransientReservation,
15
+ } from "../lib/translator-budget";
12
16
  import {
13
17
  classifyError,
14
18
  cyberPolicyErrorType,
@@ -156,6 +160,14 @@ function appendedUtf8Bytes(previous: string, previousBytes: number, fragment: st
156
160
  return nextBytes;
157
161
  }
158
162
 
163
+ function refusalTranslationError(): ChatCompletionsStreamError {
164
+ // Never include provider-controlled refusal text or correlation IDs in diagnostics.
165
+ return new ChatCompletionsStreamError("upstream refusal representations are inconsistent", {
166
+ type: "upstream_error",
167
+ code: "invalid_refusal",
168
+ });
169
+ }
170
+
159
171
  /**
160
172
  * Streaming: Responses SSE bytes -> Chat Completions SSE bytes.
161
173
  */
@@ -188,6 +200,125 @@ export function responsesSseToChatCompletionsSse(
188
200
  let emittedFrames = 0;
189
201
  let stepping = false;
190
202
  let decoderStarted = false;
203
+ // Raw output/content positions are the ordering authority; IDs only constrain identity.
204
+ // Charge a fixed entry allowance as well as keys/IDs so empty parts remain bounded.
205
+ const refusalEntryBytes = 64;
206
+ const refusalItems = new Map<number, {
207
+ id?: string;
208
+ parts: Map<number, { text: string; bytes: number; present: boolean }>;
209
+ }>();
210
+ const refusalIndexById = new Map<string, number>();
211
+ let refusalMetadataBytes = 0;
212
+ let refusalTextBytes = 0;
213
+ const releaseRefusals = () => {
214
+ refusalItems.clear();
215
+ refusalIndexById.clear();
216
+ translatorBudget.releaseRetained(refusalMetadataBytes, { kind: "item_ids" });
217
+ translatorBudget.releaseRetained(refusalTextBytes, { kind: "retained_collectors" });
218
+ refusalMetadataBytes = 0;
219
+ refusalTextBytes = 0;
220
+ };
221
+ const position = (value: unknown): number => {
222
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
223
+ throw refusalTranslationError();
224
+ }
225
+ return value;
226
+ };
227
+ const chargeRefusalMetadata = (bytes: number) => {
228
+ translatorBudget.chargeRetained(bytes, { kind: "item_ids" });
229
+ refusalMetadataBytes += bytes;
230
+ };
231
+ const refusalItem = (outputIndex: unknown, source: Rec, idField: string) => {
232
+ const index = position(outputIndex);
233
+ const hasId = Object.hasOwn(source, idField);
234
+ const candidate = source[idField];
235
+ if (hasId && typeof candidate !== "string") throw refusalTranslationError();
236
+ let item = refusalItems.get(index);
237
+ if (!item) {
238
+ chargeRefusalMetadata(refusalEntryBytes + Buffer.byteLength(String(index)));
239
+ item = { parts: new Map() };
240
+ refusalItems.set(index, item);
241
+ }
242
+ if (hasId && typeof candidate === "string") {
243
+ const knownIndex = refusalIndexById.get(candidate);
244
+ if (knownIndex !== undefined && knownIndex !== index) throw refusalTranslationError();
245
+ if (item.id !== undefined && item.id !== candidate) throw refusalTranslationError();
246
+ if (item.id === undefined) {
247
+ chargeRefusalMetadata(refusalEntryBytes + Buffer.byteLength(candidate));
248
+ item.id = candidate;
249
+ refusalIndexById.set(candidate, index);
250
+ }
251
+ }
252
+ return item;
253
+ };
254
+ const retainRefusal = (outputIndex: unknown, contentIndex: unknown, source: Rec,
255
+ idField: string, evidence: Rec, field: string, delta = false) => {
256
+ const item = refusalItem(outputIndex, source, idField);
257
+ const index = position(contentIndex);
258
+ let part = item.parts.get(index);
259
+ if (!part) {
260
+ chargeRefusalMetadata(refusalEntryBytes + Buffer.byteLength(String(index)));
261
+ part = { text: "", bytes: 0, present: false };
262
+ item.parts.set(index, part);
263
+ }
264
+ if (!Object.hasOwn(evidence, field)) {
265
+ if (delta) throw refusalTranslationError();
266
+ return;
267
+ }
268
+ const candidate = evidence[field];
269
+ if (typeof candidate !== "string") throw refusalTranslationError();
270
+ part.present = true;
271
+ if (!delta) {
272
+ // Equal, empty, and stale-prefix snapshots add no evidence; never erase deltas.
273
+ if (part.text.startsWith(candidate)) return;
274
+ if (!candidate.startsWith(part.text)) throw refusalTranslationError();
275
+ }
276
+ const nextBytes = delta ? appendedUtf8Bytes(part.text, part.bytes, candidate) : Buffer.byteLength(candidate);
277
+ const reservation = translatorBudget.reserveTransient(nextBytes, { kind: "retained_collectors" });
278
+ try {
279
+ const next = delta ? part.text + candidate : candidate;
280
+ reservation.commitRetained();
281
+ translatorBudget.releaseRetained(part.bytes, { kind: "retained_collectors" });
282
+ refusalTextBytes += nextBytes - part.bytes;
283
+ part.text = next;
284
+ part.bytes = nextBytes;
285
+ } catch (error) {
286
+ reservation.release();
287
+ throw error;
288
+ }
289
+ };
290
+ const snapshotRefusalItem = (outputIndex: unknown, item: Rec) => {
291
+ const existing = typeof outputIndex === "number" ? refusalItems.get(outputIndex) : undefined;
292
+ // Sparse final snapshots may omit type/content but cannot change a known ID.
293
+ if (Object.hasOwn(item, "id")
294
+ && (existing || (typeof item.id === "string" && refusalIndexById.has(item.id)))) {
295
+ refusalItem(outputIndex, item, "id");
296
+ }
297
+ if (item.type !== "message") {
298
+ if (existing && existing.parts.size > 0 && item.type !== undefined) throw refusalTranslationError();
299
+ return;
300
+ }
301
+ // Unrelated sparse text messages historically need no position metadata.
302
+ if (outputIndex === undefined && (!Array.isArray(item.content)
303
+ || !item.content.some(part => isRec(part) && part.type === "refusal"))) return;
304
+ const known = refusalItem(outputIndex, item, "id");
305
+ if (!Array.isArray(item.content)) return;
306
+ item.content.forEach((part: unknown, contentIndex: number) => {
307
+ if (!isRec(part)) return;
308
+ if (part.type === "refusal") {
309
+ retainRefusal(outputIndex, contentIndex, item, "id", part, "refusal");
310
+ } else if (part.type !== undefined && known.parts.has(contentIndex)) {
311
+ throw refusalTranslationError();
312
+ }
313
+ });
314
+ };
315
+ const snapshotRefusals = (response: Rec) => {
316
+ if (!Array.isArray(response.output)) return;
317
+ response.output.forEach((item: unknown, outputIndex: number) => {
318
+ if (isRec(item)) snapshotRefusalItem(outputIndex, item);
319
+ });
320
+ };
321
+ let terminalBatch: Array<{ frame: Uint8Array; reservation: TranslatorTransientReservation }> | undefined;
191
322
  const queuedLiveFrameBytes: number[] = [];
192
323
  const enqueueLiveFrame = (frame: Uint8Array) => {
193
324
  const reservation = translatorBudget.reserveTransient(frame.byteLength, { kind: "live_transient" });
@@ -235,8 +366,20 @@ export function responsesSseToChatCompletionsSse(
235
366
  };
236
367
  const emit = (payload: Rec | "[DONE]") => {
237
368
  if (failed) return;
238
- enqueueLiveFrame(encoder.encode(dataFrame(payload)));
239
- emittedFrames++;
369
+ if (terminalBatch) {
370
+ const serialized = dataFrame(payload);
371
+ const stringReservation = translatorBudget.reserveTransient(Buffer.byteLength(serialized), { kind: "live_transient" });
372
+ try {
373
+ const frame = encoder.encode(serialized);
374
+ const reservation = translatorBudget.reserveTransient(frame.byteLength, { kind: "live_transient" });
375
+ terminalBatch.push({ frame, reservation });
376
+ } finally {
377
+ stringReservation.release();
378
+ }
379
+ } else {
380
+ enqueueLiveFrame(encoder.encode(dataFrame(payload)));
381
+ emittedFrames++;
382
+ }
240
383
  };
241
384
  const ensureRole = () => {
242
385
  if (started) return;
@@ -298,38 +441,68 @@ export function responsesSseToChatCompletionsSse(
298
441
  };
299
442
  const finish = (finishReason: string, usage: unknown) => {
300
443
  if (terminated) return;
301
- // A valid completed/incomplete terminal frame may arrive without output_item.done.
302
- // Preserve any known tool call before emitting its finish reason.
303
- flushPendingToolCalls();
444
+ // Admit every pending role/tool/refusal/finish/DONE frame before exposing any
445
+ // of this terminal batch. Serialization and encoded bytes coexist and both count.
446
+ const batch: NonNullable<typeof terminalBatch> = [];
447
+ terminalBatch = batch;
448
+ try {
449
+ flushPendingToolCalls();
450
+ ensureRole();
451
+ for (const [, item] of [...refusalItems.entries()].sort(([a], [b]) => a - b)) {
452
+ for (const [, part] of [...item.parts.entries()].sort(([a], [b]) => a - b)) {
453
+ if (!part.present) continue;
454
+ const refusal = chunkBase(id, model, created);
455
+ refusal.choices = [{ index: 0, delta: { refusal: part.text }, finish_reason: null }];
456
+ emit(refusal);
457
+ }
458
+ }
459
+ const frame = chunkBase(id, model, created);
460
+ frame.choices = [{ index: 0, delta: {}, finish_reason: finishReason }];
461
+ if (usage) frame.usage = chatCompletionsUsage(usage);
462
+ emit(frame);
463
+ emit("[DONE]");
464
+ } catch (error) {
465
+ for (const staged of batch) staged.reservation.release();
466
+ throw error;
467
+ } finally {
468
+ terminalBatch = undefined;
469
+ }
470
+ for (const staged of batch) {
471
+ controller.enqueue(staged.frame);
472
+ staged.reservation.commitRetained();
473
+ queuedLiveFrameBytes.push(staged.frame.byteLength);
474
+ emittedFrames++;
475
+ }
304
476
  terminated = true;
305
- ensureRole();
306
- const frame = chunkBase(id, model, created);
307
- frame.choices = [{ index: 0, delta: {}, finish_reason: finishReason }];
308
- if (usage) frame.usage = chatCompletionsUsage(usage);
309
- emit(frame);
310
- emit("[DONE]");
477
+ releaseRefusals();
311
478
  };
312
479
  const fail = (message: string, details?: { code?: string | null; type?: string; status?: number }) => {
313
480
  if (terminated) return;
314
481
  terminated = true;
315
482
  failed = true;
483
+ releaseRefusals();
484
+ closeToolCalls();
485
+ upstreamAbort.abort(new Error("upstream chat translation failed"));
486
+ try { void sseIterator?.return(undefined).catch(() => {}); } catch { /* already closed */ }
316
487
  // OpenAI-compatible clients need a real error event, not a success completion
317
488
  // that embeds `[error] ...` text followed by a clean [DONE].
318
489
  // Deliver the error frame then close the stream abnormally (no [DONE]).
319
490
  // Do not controller.error() — that can drop already-enqueued bytes from consumers
320
491
  // like response.text().
321
- const safeMessage = redactSecretString(message);
492
+ const translatorOverflow = details?.code === "translation_buffer_limit";
493
+ const safeMessage = translatorOverflow ? "upstream translation buffer exceeded the safe limit"
494
+ : details?.code === "invalid_refusal" ? "upstream refusal representations are inconsistent"
495
+ : redactSecretString(message);
322
496
  const statusHint = details?.status ?? streamErrorStatus(safeMessage);
323
497
  const classified = classifyError(statusHint, details?.type ?? "upstream_error", safeMessage);
324
- const translatorOverflow = details?.code === "translation_buffer_limit";
325
498
  if (translatorOverflow) {
326
- upstreamAbort.abort(new Error("upstream translation buffer exceeded the safe limit"));
327
- closeToolCalls();
328
- try { void sseIterator?.return(undefined).catch(() => {}); } catch { /* already closed */ }
329
499
  classified.code = "translation_buffer_limit";
330
500
  // Provider-controlled overflow is an upstream failure on every path:
331
501
  // streaming frame, collector, and defensive JSON agree on 502.
332
502
  classified.type = "upstream_error";
503
+ } else if (details?.code === "invalid_refusal") {
504
+ classified.code = details.code;
505
+ classified.type = "upstream_error";
333
506
  } else if (isCyberPolicyCode(details?.code) || classified.code === CYBER_POLICY_ERROR_CODE) {
334
507
  classified.code = CYBER_POLICY_ERROR_CODE;
335
508
  classified.type = cyberPolicyErrorType(details?.type);
@@ -345,9 +518,9 @@ export function responsesSseToChatCompletionsSse(
345
518
  code: classified.code,
346
519
  },
347
520
  }));
348
- // The budget is already exhausted. This bounded emergency frame is the sole
349
- // typed overflow closure and therefore cannot reserve from that budget again.
350
- if (translatorOverflow) controller.enqueue(frame);
521
+ // These fixed, bounded failures must survive even when decoder-owned input
522
+ // still fills the budget. They contain no provider text or IDs.
523
+ if (translatorOverflow || details?.code === "invalid_refusal") controller.enqueue(frame);
351
524
  else enqueueLiveFrame(frame);
352
525
  emittedFrames++;
353
526
  } catch {
@@ -371,8 +544,29 @@ export function responsesSseToChatCompletionsSse(
371
544
  if (typeof data.delta === "string") emitReasoning(data.delta);
372
545
  break;
373
546
  }
547
+ case "response.refusal.delta":
548
+ case "response.refusal.done": {
549
+ const delta = eventName === "response.refusal.delta";
550
+ retainRefusal(data.output_index, data.content_index, data, "item_id", data, delta ? "delta" : "refusal", delta);
551
+ break;
552
+ }
553
+ case "response.content_part.added":
554
+ case "response.content_part.done": {
555
+ const part = isRec(data.part) ? data.part : null;
556
+ if (part?.type === "refusal") {
557
+ retainRefusal(data.output_index, data.content_index, data, "item_id", part, "refusal");
558
+ } else if (typeof data.output_index === "number" && refusalItems.has(data.output_index)) {
559
+ const item = refusalItem(data.output_index, data, "item_id");
560
+ if (part?.type !== undefined && item.parts.has(position(data.content_index))) throw refusalTranslationError();
561
+ }
562
+ break;
563
+ }
374
564
  case "response.output_item.added": {
375
565
  const item = isRec(data.item) ? data.item : null;
566
+ if (item?.type === "message") {
567
+ snapshotRefusalItem(data.output_index, item);
568
+ if (Object.hasOwn(data, "item_id")) refusalItem(data.output_index, data, "item_id");
569
+ }
376
570
  if (!item || item.type !== "function_call") break;
377
571
  ensureRole();
378
572
  sawToolUse = true;
@@ -416,6 +610,8 @@ export function responsesSseToChatCompletionsSse(
416
610
  case "response.output_item.done": {
417
611
  const item = isRec(data.item) ? data.item : null;
418
612
  if (!item) break;
613
+ snapshotRefusalItem(data.output_index, item);
614
+ if (item.type === "message" && Object.hasOwn(data, "item_id")) refusalItem(data.output_index, data, "item_id");
419
615
  if (item.type === "function_call") {
420
616
  sawToolUse = true;
421
617
  const callId = typeof item.call_id === "string" ? item.call_id : "";
@@ -445,6 +641,7 @@ export function responsesSseToChatCompletionsSse(
445
641
  }
446
642
  case "response.completed": {
447
643
  const response = isRec(data.response) ? data.response : {};
644
+ snapshotRefusals(response);
448
645
  finish(sawToolUse ? "tool_calls" : "stop", response.usage);
449
646
  break;
450
647
  }
@@ -456,6 +653,7 @@ export function responsesSseToChatCompletionsSse(
456
653
  : undefined;
457
654
  if (reason !== undefined) {
458
655
  // Truthful OpenAI-compatible finish reasons: the turn ended, just early.
656
+ snapshotRefusals(response);
459
657
  finish(reason, response.usage);
460
658
  } else {
461
659
  // upstream_stall_timeout / adapter_eof / proxy-synthesized incompletes are
@@ -500,6 +698,7 @@ export function responsesSseToChatCompletionsSse(
500
698
  while (!cancelled && emittedFrames === emittedAtStart) {
501
699
  decoderStarted = true;
502
700
  const next = await sseIterator!.next();
701
+ if (cancelled) break;
503
702
  if (next.done) {
504
703
  if (!cancelled && !terminated) {
505
704
  fail("upstream stream ended before a terminal frame (truncated response)");
@@ -525,6 +724,8 @@ export function responsesSseToChatCompletionsSse(
525
724
  upstreamAbort.abort(err);
526
725
  closeToolCalls();
527
726
  fail(err.message, { status: 502, type: "upstream_error", code: err.code });
727
+ } else if (isChatCompletionsStreamError(err)) {
728
+ fail(err.message, { status: err.status, type: err.type, code: err.code });
528
729
  } else {
529
730
  fail(err instanceof Error ? err.message : String(err));
530
731
  }
@@ -545,6 +746,7 @@ export function responsesSseToChatCompletionsSse(
545
746
  },
546
747
  cancel(reason) {
547
748
  cancelled = true;
749
+ releaseRefusals();
548
750
  while (queuedLiveFrameBytes.length > 0) releaseDeliveredFrame();
549
751
  closeToolCalls();
550
752
  // Abort first: it cancels the decoder's underlying reader, settling any in-flight
@@ -560,55 +762,99 @@ export function responsesSseToChatCompletionsSse(
560
762
  }
561
763
 
562
764
  /** Non-streaming: /v1/responses JSON -> Chat Completions message JSON. */
563
- export function responsesJsonToChatCompletion(json: unknown, model: string): Rec {
765
+ export function responsesJsonToChatCompletion(json: unknown, model: string, translatorBudget?: TranslatorBudget): Rec {
564
766
  const body = isRec(json) ? json : {};
767
+ const incomplete = isRec(body.incomplete_details) ? body.incomplete_details : {};
768
+ let incompleteFinish: "length" | "content_filter" | undefined;
769
+ if (body.status === "incomplete") {
770
+ if (incomplete.reason === "max_output_tokens") incompleteFinish = "length";
771
+ else if (incomplete.reason === "content_filter") incompleteFinish = "content_filter";
772
+ else throw new ChatCompletionsStreamError("upstream response ended without a supported completion boundary", {
773
+ code: "upstream_incomplete", type: "upstream_error",
774
+ });
775
+ }
565
776
  const output = Array.isArray(body.output) ? body.output : [];
566
777
  let content = "";
778
+ let refusal: string | null = null;
779
+ let refusalBytes = 0;
567
780
  let reasoning = "";
781
+ let contentBytes = 0;
782
+ let reasoningBytes = 0;
568
783
  const toolCalls: Rec[] = [];
784
+ const append = (previous: string, previousBytes: number, fragment: string): { text: string; bytes: number } => {
785
+ if (!fragment) return { text: previous, bytes: previousBytes };
786
+ const scope = { kind: "retained_collectors" as const };
787
+ const nextBytes = appendedUtf8Bytes(previous, previousBytes, fragment);
788
+ const reservation = translatorBudget?.reserveTransient(nextBytes, scope);
789
+ try {
790
+ const next = previous + fragment;
791
+ reservation?.commitRetained();
792
+ translatorBudget?.releaseRetained(previousBytes, scope);
793
+ return { text: next, bytes: nextBytes };
794
+ } catch (error) {
795
+ reservation?.release();
796
+ throw error;
797
+ }
798
+ };
569
799
 
570
800
  for (const raw of output) {
571
801
  if (!isRec(raw)) continue;
572
802
  if (raw.type === "message" && Array.isArray(raw.content)) {
573
803
  for (const part of raw.content) {
574
804
  if (isRec(part) && part.type === "output_text" && typeof part.text === "string") {
575
- content += part.text;
805
+ ({ text: content, bytes: contentBytes } = append(content, contentBytes, part.text));
806
+ } else if (isRec(part) && part.type === "refusal" && Object.hasOwn(part, "refusal")) {
807
+ if (typeof part.refusal !== "string") throw refusalTranslationError();
808
+ const next = append(refusal ?? "", refusalBytes, part.refusal);
809
+ refusal = next.text;
810
+ refusalBytes = next.bytes;
576
811
  }
577
812
  }
578
813
  } else if (raw.type === "reasoning") {
579
814
  if (Array.isArray(raw.summary)) {
580
815
  for (const part of raw.summary) {
581
816
  if (isRec(part) && part.type === "summary_text" && typeof part.text === "string") {
582
- reasoning += part.text;
817
+ ({ text: reasoning, bytes: reasoningBytes } = append(reasoning, reasoningBytes, part.text));
583
818
  }
584
819
  }
585
820
  }
586
821
  if (Array.isArray(raw.content)) {
587
822
  for (const part of raw.content) {
588
823
  if (isRec(part) && part.type === "reasoning_text" && typeof part.text === "string") {
589
- reasoning += part.text;
824
+ ({ text: reasoning, bytes: reasoningBytes } = append(reasoning, reasoningBytes, part.text));
590
825
  }
591
826
  }
592
827
  }
593
828
  } else if (raw.type === "function_call") {
594
- toolCalls.push({
829
+ const call = {
595
830
  id: typeof raw.call_id === "string" ? raw.call_id : `call_${uuid().slice(0, 16)}`,
596
831
  type: "function",
597
832
  function: {
598
833
  name: typeof raw.name === "string" ? raw.name : "",
599
834
  arguments: typeof raw.arguments === "string" ? raw.arguments : "{}",
600
835
  },
836
+ };
837
+ // A complete buffered call still obeys the same per-call cap as live deltas.
838
+ // Reserve before serializing, then transfer ownership to the complete call.
839
+ // The internal scope stays nonempty even when an upstream call_id is empty.
840
+ const argumentsReservation = translatorBudget?.reserveTransient(Buffer.byteLength(call.function.arguments), {
841
+ kind: "tool_args", callId: `chat_json_${toolCalls.length}`,
601
842
  });
843
+ try {
844
+ translatorBudget?.chargeRetained(Buffer.byteLength(JSON.stringify(call)), { kind: "retained_collectors" });
845
+ toolCalls.push(call);
846
+ } finally {
847
+ argumentsReservation?.release();
848
+ }
602
849
  }
603
850
  }
604
851
 
605
- const finishReason = toolCalls.length > 0 ? "tool_calls"
606
- : body.status === "incomplete" ? "length"
607
- : "stop";
852
+ const finishReason = incompleteFinish ?? (toolCalls.length > 0 ? "tool_calls" : "stop");
608
853
 
609
854
  const message: Rec = {
610
855
  role: "assistant",
611
856
  content: content || null,
857
+ refusal,
612
858
  };
613
859
  if (reasoning) message.reasoning_content = reasoning;
614
860
  if (toolCalls.length > 0) message.tool_calls = toolCalls;
@@ -637,6 +883,7 @@ export async function collectChatCompletion(
637
883
  const decoder = new TextDecoder();
638
884
  let buffer = "";
639
885
  let content = "";
886
+ let refusal: string | null = null;
640
887
  let reasoning = "";
641
888
  const toolCalls = new Map<number, { id: string; name: string; arguments: string; argumentBytes: number }>();
642
889
  // Per-call budget scopes (2 MiB/call enforced by the budget): the map key is the
@@ -644,7 +891,6 @@ export async function collectChatCompletion(
644
891
  const callScope = (index: number) => `chat_collect_${index}`;
645
892
  let finishReason = "stop";
646
893
  let usage: unknown;
647
- let streamError: ChatCompletionsStreamError | null = null;
648
894
  const replaceRetained = (previous: string, next: string, kind: "live_transient" | "retained_collectors") => {
649
895
  const reservation = translatorBudget.reserveTransient(Buffer.byteLength(next), { kind });
650
896
  reservation.commitRetained();
@@ -697,12 +943,12 @@ export async function collectChatCompletion(
697
943
  : code === CYBER_POLICY_ERROR_CODE || isCyberPolicyMessage(message)
698
944
  ? 400
699
945
  : streamErrorStatus(message);
700
- streamError = new ChatCompletionsStreamError(message, {
946
+ const streamError = new ChatCompletionsStreamError(message, {
701
947
  status,
702
948
  type: code === "translation_buffer_limit" ? "upstream_error" : type,
703
949
  code,
704
950
  });
705
- continue;
951
+ throw streamError;
706
952
  }
707
953
  if (parsed.usage) usage = parsed.usage;
708
954
  const choices = Array.isArray(parsed.choices) ? parsed.choices : [];
@@ -712,6 +958,10 @@ export async function collectChatCompletion(
712
958
  const delta = isRec(choice.delta) ? choice.delta : null;
713
959
  if (!delta) continue;
714
960
  if (typeof delta.content === "string") content = replaceRetained(content, content + delta.content, "retained_collectors");
961
+ if (delta.refusal !== undefined && delta.refusal !== null) {
962
+ if (typeof delta.refusal !== "string") throw refusalTranslationError();
963
+ refusal = replaceRetained(refusal ?? "", (refusal ?? "") + delta.refusal, "retained_collectors");
964
+ }
715
965
  if (typeof delta.reasoning_content === "string") reasoning = replaceRetained(reasoning, reasoning + delta.reasoning_content, "retained_collectors");
716
966
  if (Array.isArray(delta.tool_calls)) {
717
967
  for (const tc of delta.tool_calls) {
@@ -749,6 +999,10 @@ export async function collectChatCompletion(
749
999
  }
750
1000
  }
751
1001
  } catch (error) {
1002
+ // Processing may fail between reads; cancel while we still own the lock so the
1003
+ // upstream translator releases its maps and stops any pending provider read.
1004
+ try { await reader.cancel(error); } catch { /* preserve the original failure */ }
1005
+ translatorBudget.releaseRetained(Buffer.byteLength(refusal ?? ""), { kind: "retained_collectors" });
752
1006
  // Never leak an open call scope on the error path; the turn budget's
753
1007
  // dispose is a backstop, not the owner of this transfer.
754
1008
  for (const index of toolCalls.keys()) translatorBudget.closeCall(callScope(index));
@@ -765,14 +1019,11 @@ export async function collectChatCompletion(
765
1019
  } finally {
766
1020
  reader.releaseLock();
767
1021
  }
768
- if (streamError) {
769
- for (const index of toolCalls.keys()) translatorBudget.closeCall(callScope(index));
770
- throw streamError;
771
- }
772
1022
 
773
1023
  const message: Rec = {
774
1024
  role: "assistant",
775
1025
  content: content || null,
1026
+ refusal,
776
1027
  };
777
1028
  if (reasoning) message.reasoning_content = reasoning;
778
1029
  if (toolCalls.size > 0) {