pi-freeflow 1.40.0 → 1.41.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.
@@ -16,9 +16,16 @@ import {
16
16
  WATCHDOG_SSE_MIN_SAMPLES,
17
17
  WATCHDOG_SSE_WINDOW,
18
18
  } from "./config.ts";
19
- import { cloakMessagesContent, looksLikeSse, type CaseRestoreMap, type FindGlobRestore } from "./opencode-fingerprint.ts";
19
+ import {
20
+ accumulateToolCallName,
21
+ cloakMessagesContent,
22
+ looksLikeSse,
23
+ type CaseRestoreMap,
24
+ type FindGlobRestore,
25
+ } from "./opencode-fingerprint.ts";
20
26
  import { restoreToolNameForCaller } from "./tool-translation.ts";
21
27
  import { repairToolArguments } from "./tool-args.ts";
28
+ import type { ToolcallRepairSink, ToolcallStreamAudit } from "./toolcall-audit.ts";
22
29
 
23
30
  /**
24
31
  * A premature stream end counts as "substantial" only when BOTH thresholds
@@ -27,6 +34,17 @@ import { repairToolArguments } from "./tool-args.ts";
27
34
  */
28
35
  export const SUBSTANTIAL_MIN_CHUNKS = 50;
29
36
  export const SUBSTANTIAL_MIN_BYTES = 100 * 1024;
37
+ // Body-stall cut for streamed upstream bodies (see pipeUpstreamStream).
38
+ // Generous: every healthy stream observed on this proxy re-arms it within a
39
+ // few hundred ms; a 120s byte-silence on an open socket is a wedged relay
40
+ // leg, not a building model.
41
+ let bodyStallCutMs = 120_000;
42
+ /** Test-only: override the body-stall cut; returns a restore function. */
43
+ export function _setBodyStallCutForTest(ms: number): () => void {
44
+ const prev = bodyStallCutMs;
45
+ bodyStallCutMs = ms;
46
+ return () => { bodyStallCutMs = prev; };
47
+ }
30
48
  // Bounded lookahead for a streaming response whose upstream omitted the
31
49
  // content-type: the pipe holds at most this many bytes while it waits for SSE
32
50
  // evidence, then classifies the body itself (raw passthrough on overflow —
@@ -49,6 +67,29 @@ const TOOL_CALL_MARKERS = [
49
67
  "custom_tool_call_input",
50
68
  '"tool_calls"',
51
69
  ];
70
+ // Same wire evidence for the tool-call audit gate, plus the Anthropic
71
+ // argument-fragment event: those deltas carry no tool_use marker of their own
72
+ // (the `content_block_start` did), yet the recorder needs every fragment to
73
+ // classify the call, so they must reach observe() too.
74
+ const AUDIT_TOOL_MARKERS = [...TOOL_CALL_MARKERS, '"type":"input_json_delta"'];
75
+ // Turn-end frames carry the finish reason but no tool marker. They are the
76
+ // only evidence a tool turn ended in plain text, so the audit gate must let
77
+ // them through; matching the populated form (not the ubiquitous
78
+ // `"finish_reason":null` delta) keeps the gate at one extra block per turn.
79
+ const AUDIT_TURN_END_MARKERS = [
80
+ '"finish_reason":"stop"',
81
+ '"finish_reason":"tool_calls"',
82
+ '"finish_reason":"length"',
83
+ '"finish_reason":"content_filter"',
84
+ '"stop_reason":"end_turn"',
85
+ '"stop_reason":"tool_use"',
86
+ '"stop_reason":"max_tokens"',
87
+ '"stop_reason":"stop_sequence"',
88
+ '"response.completed"',
89
+ '"response.incomplete"',
90
+ '"response.failed"',
91
+ ];
92
+ const AUDIT_MARKERS = [...AUDIT_TOOL_MARKERS, ...AUDIT_TURN_END_MARKERS];
52
93
 
53
94
  export function isSubstantial(chunks: number, bytes: number): boolean {
54
95
  return chunks > SUBSTANTIAL_MIN_CHUNKS && bytes > SUBSTANTIAL_MIN_BYTES;
@@ -279,38 +320,112 @@ export interface StreamCloakOptions {
279
320
  export interface SseStreamCloakState {
280
321
  responsesDroppedByIndex: Map<number, boolean>;
281
322
  responsesDroppedByItemId: Map<string, boolean>;
282
- chatDroppedByIndex: Map<number, boolean>;
283
323
  messagesDroppedByIndex: Map<number, boolean>;
284
324
  /** Output index -> tool name, so nameless argument deltas can find their schema. */
285
325
  responsesNameByIndex: Map<number, string>;
286
326
  /** Anthropic block index -> tool name, same purpose for input_json deltas. */
287
327
  messagesNameByIndex: Map<number, string>;
288
- /** Chat tool-call index -> restored tool name. */
289
- chatNameByIndex: Map<number, string>;
290
328
  /**
291
- * Argument fragments for tool calls whose schema admits a repair, keyed the
292
- * same way as the name maps above. Streaming APIs deliver arguments in
293
- * fragments, so a malformed packet can only be judged once complete: those
294
- * fragments are held here and re-emitted, repaired, on the call's terminal
295
- * event. Tools absent from `argSchemas` are never buffered, so their
296
- * arguments still stream incrementally exactly as before.
329
+ * Argument fragments for Anthropic tool_use blocks whose schema admits a
330
+ * repair, keyed the same way as the name map above. Streaming APIs deliver
331
+ * arguments in fragments, so a malformed packet can only be judged once
332
+ * complete: those fragments are held here and re-emitted, repaired, on the
333
+ * block's terminal event. Tools absent from `argSchemas` are never buffered,
334
+ * so their arguments still stream incrementally exactly as before.
297
335
  */
298
- chatArgsByIndex: Map<number, string>;
299
336
  messagesArgsByIndex: Map<number, string>;
337
+ /**
338
+ * Chat tool calls tracked PER CALL (defect K1): keyed by the upstream
339
+ * tool-call id whenever the provider sends one (`id:X`), else by the legacy
340
+ * index bucket (`idx:N`). Free models label EVERY parallel call `index: 0`
341
+ * while still sending per-call ids, so index-only keying merged their
342
+ * fragment streams: one call was silently lost and the other received a
343
+ * salvage of the merged junk. Drops, names, buffered arguments and the
344
+ * once-only flush guard are per call; an injected placeholder's drop
345
+ * verdict is never inherited by a real call that reuses its index.
346
+ */
347
+ chatCalls: Map<string, ChatTrackedCall>;
348
+ /**
349
+ * Observed delta index -> key of the call its id-less fragments currently
350
+ * belong to. Resolution mirrors the aggregate cloak exactly
351
+ * (sseToChatCompletionJson): an id-bearing delta (re)binds the index to its
352
+ * id-call; id-less deltas follow the current owner, else the legacy
353
+ * `idx:N` bucket. Never consulted for host-visible indices — that is what
354
+ * {@link chatIndexClaims} is for.
355
+ */
356
+ chatIndexOwner: Map<number, string>;
357
+ /** Host-index claims: index -> key of the call that FIRST assigned it. Set once, never rebound. */
358
+ chatIndexClaims: Map<number, string>;
359
+ /**
360
+ * Fresh sequential index handed out when a call's upstream index is already
361
+ * claimed by another tracked call; strictly above every index seen or
362
+ * assigned so far in this stream.
363
+ */
364
+ chatNextIndex: number;
365
+ /**
366
+ * Per-turn tool-call audit sink for the rewrites this state performs
367
+ * (index collisions, dropped name restatements, withheld arguments, repaired
368
+ * arguments). Absent when the caller did not request an audit.
369
+ */
370
+ audit?: ToolcallRepairSink;
300
371
  }
301
372
 
302
- export function createSseStreamCloakState(): SseStreamCloakState {
303
- return {
373
+ /**
374
+ * Streaming state of ONE chat tool call. Exported only because it hangs off
375
+ * the exported per-stream state; treat it as pipe-internal.
376
+ */
377
+ export interface ChatTrackedCall {
378
+ /** Never emit this call: injected placeholder, or a tool-less caller's call. */
379
+ dropped: boolean;
380
+ /**
381
+ * Restored host-facing name, folded from every name-bearing delta:
382
+ * fragments append, a delta that also carries arguments restates the whole
383
+ * name and replaces (see accumulateToolCallName).
384
+ */
385
+ name: string | undefined;
386
+ /**
387
+ * True once a name-bearing delta decided this tool is repairable and its
388
+ * fragments must be withheld: the packet is only judgeable once whole, so
389
+ * the fragments re-emerge whole+repaired on the call's terminal event.
390
+ */
391
+ buffered: boolean;
392
+ /** Buffered argument prefix while {@link buffered} is true ("" until a fragment arrives). */
393
+ args: string;
394
+ /**
395
+ * Host-visible index: the call's FIRST assigned index — its first observed
396
+ * index — unless that upstream index was claimed by another tracked call,
397
+ * in which case a fresh sequential index was handed out in arrival order.
398
+ */
399
+ hostIndex: number;
400
+ /** Emitted-once guard: a flush entry already went out for this call (terminal or end-of-stream). */
401
+ flushed: boolean;
402
+ }
403
+
404
+ /** Map key of a chat tool call that announced an upstream id. */
405
+ function chatCallIdKey(id: string): string {
406
+ return `id:${id}`;
407
+ }
408
+
409
+ /** Legacy map key of the id-less fragment bucket for one observed delta index. */
410
+ function chatIndexKey(idx: number): string {
411
+ return `idx:${idx}`;
412
+ }
413
+
414
+ export function createSseStreamCloakState(audit?: ToolcallRepairSink): SseStreamCloakState {
415
+ const state: SseStreamCloakState = {
304
416
  responsesDroppedByIndex: new Map(),
305
417
  responsesDroppedByItemId: new Map(),
306
- chatDroppedByIndex: new Map(),
307
418
  messagesDroppedByIndex: new Map(),
308
419
  responsesNameByIndex: new Map(),
309
420
  messagesNameByIndex: new Map(),
310
- chatNameByIndex: new Map(),
311
- chatArgsByIndex: new Map(),
312
421
  messagesArgsByIndex: new Map(),
422
+ chatCalls: new Map(),
423
+ chatIndexOwner: new Map(),
424
+ chatIndexClaims: new Map(),
425
+ chatNextIndex: 0,
313
426
  };
427
+ if (audit !== undefined) state.audit = audit;
428
+ return state;
314
429
  }
315
430
 
316
431
  /**
@@ -334,11 +449,13 @@ export function flushBufferedToolArgs(
334
449
  const blocks: string[] = [];
335
450
  for (const [index, args] of state.messagesArgsByIndex) {
336
451
  const name = state.messagesNameByIndex.get(index) ?? "";
452
+ const repaired = repairToolArguments(name, args, cloak.argSchemas);
453
+ if (repaired !== args) state.audit?.repair("arguments-repaired", name);
337
454
  blocks.push(
338
455
  `event: content_block_delta\ndata: ${JSON.stringify({
339
456
  type: "content_block_delta",
340
457
  index,
341
- delta: { type: "input_json_delta", partial_json: repairToolArguments(name, args, cloak.argSchemas) },
458
+ delta: { type: "input_json_delta", partial_json: repaired },
342
459
  })}\n\n`,
343
460
  );
344
461
  }
@@ -346,15 +463,21 @@ export function flushBufferedToolArgs(
346
463
  state.messagesNameByIndex.clear();
347
464
  return blocks.join("");
348
465
  }
349
- if (state.chatArgsByIndex.size === 0) return null;
350
466
  const calls: unknown[] = [];
351
- for (const [index, args] of state.chatArgsByIndex) {
352
- const name = state.chatNameByIndex.get(index);
467
+ for (const call of state.chatCalls.values()) {
468
+ // The flushed guard makes this one-shot even when a terminal flush
469
+ // already emitted the call whole earlier in the stream.
470
+ if (!call.buffered || call.flushed) continue;
471
+ const name = call.name;
353
472
  if (name === undefined) continue;
354
- calls.push({ index, type: "function", function: { name, arguments: repairToolArguments(name, args, cloak.argSchemas) } });
473
+ const args = call.args;
474
+ call.flushed = true;
475
+ call.buffered = false;
476
+ call.args = "";
477
+ const repairedArgs = repairToolArguments(name, args, cloak.argSchemas);
478
+ if (repairedArgs !== args) state.audit?.repair("arguments-repaired", name);
479
+ calls.push({ index: call.hostIndex, type: "function", function: { name, arguments: repairedArgs } });
355
480
  }
356
- state.chatArgsByIndex.clear();
357
- state.chatNameByIndex.clear();
358
481
  if (calls.length === 0) return null;
359
482
  return `data: ${JSON.stringify({ object: "chat.completion.chunk", choices: [{ index: 0, delta: { tool_calls: calls } }] })}\n\n`;
360
483
  }
@@ -462,7 +585,10 @@ function rewriteResponsesBlock(
462
585
  if (typeof parsed.output_index === "number") state.responsesNameByIndex.set(parsed.output_index, restored);
463
586
  if (type === "response.output_item.done") {
464
587
  rec.name = restored;
465
- rec.arguments = repairToolArguments(restored, typeof rec.arguments === "string" ? rec.arguments : "", cloak.argSchemas);
588
+ const originalArgs = typeof rec.arguments === "string" ? rec.arguments : "";
589
+ const repairedArgs = repairToolArguments(restored, originalArgs, cloak.argSchemas);
590
+ if (repairedArgs !== originalArgs) state.audit?.repair("arguments-repaired", restored);
591
+ rec.arguments = repairedArgs;
466
592
  return emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
467
593
  }
468
594
  if (restored !== rec.name) {
@@ -487,6 +613,7 @@ function rewriteResponsesBlock(
487
613
  if (name !== undefined) {
488
614
  const repairedArgs = repairToolArguments(name, parsed.arguments, cloak.argSchemas);
489
615
  if (repairedArgs !== parsed.arguments) {
616
+ state.audit?.repair("arguments-repaired", name);
490
617
  parsed.arguments = repairedArgs;
491
618
  return emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
492
619
  }
@@ -535,48 +662,140 @@ function cloakStreamResponsesObject(resp: unknown, cloak: StreamCloakOptions): v
535
662
  (resp as Record<string, unknown>).output = kept;
536
663
  }
537
664
 
665
+ /**
666
+ * Resolve ONE chat tool_calls record to its tracked call, registering the
667
+ * call on first sight.
668
+ *
669
+ * Keying mirrors the aggregate cloak (sseToChatCompletionJson): a record with
670
+ * a non-empty id is keyed `id:<id>`; an id-less record joins whatever call
671
+ * currently OWNS its index (`chatIndexOwner`, which id-bearing deltas
672
+ * rebind), else the legacy `idx:<index>` bucket — so id-less parallel calls
673
+ * sharing `index: 0` still merge into one fragment stream, exactly as
674
+ * before. Host index = the call's first observed index unless that upstream
675
+ * index is ALREADY CLAIMED by another tracked call (`chatIndexClaims`), in
676
+ * which case the next fresh sequential index is handed out in arrival order.
677
+ * The claim deliberately counts dropped calls too: the ticket rule is
678
+ * "claimed by ANOTHER tracked call", and a dropped placeholder has already
679
+ * moved the claim bookkeeping even though it emits nothing.
680
+ */
681
+ function chatTrackedCall(
682
+ state: SseStreamCloakState,
683
+ rec: Record<string, unknown>,
684
+ ): { call: ChatTrackedCall; key: string; idx: number } {
685
+ const idx = typeof rec.index === "number" ? rec.index : 0;
686
+ if (idx + 1 > state.chatNextIndex) state.chatNextIndex = idx + 1;
687
+ const id = typeof rec.id === "string" && rec.id !== "" ? rec.id : undefined;
688
+ let key: string;
689
+ if (id !== undefined) {
690
+ key = chatCallIdKey(id);
691
+ // Id-bearing deltas (re)bind the index owner; later id-less fragments
692
+ // follow, and a real call reusing an injected placeholder's index takes
693
+ // the connection over instead of inheriting its drop verdict.
694
+ state.chatIndexOwner.set(idx, key);
695
+ } else {
696
+ key = state.chatIndexOwner.get(idx) ?? chatIndexKey(idx);
697
+ }
698
+ const known = state.chatCalls.get(key);
699
+ if (known !== undefined) return { call: known, key, idx };
700
+ // First sight: assign the host-visible index. A claimed upstream index
701
+ // forces a fresh sequential index above every index seen or assigned so
702
+ // far; the fresh index is itself claimed so a later real call keeps clear.
703
+ const claim = state.chatIndexClaims.get(idx);
704
+ let call: ChatTrackedCall;
705
+ if (claim === undefined) {
706
+ state.chatIndexClaims.set(idx, key);
707
+ call = { dropped: false, name: undefined, buffered: false, args: "", hostIndex: idx, flushed: false };
708
+ } else {
709
+ call = { dropped: false, name: undefined, buffered: false, args: "", hostIndex: state.chatNextIndex++, flushed: false };
710
+ state.chatIndexClaims.set(call.hostIndex, key);
711
+ // Audit: two tracked calls claimed one upstream index; the host index was
712
+ // re-homed sequentially so the wire stays well-formed.
713
+ state.audit?.repair("parallel-index-collision", `idx:${idx}`);
714
+ }
715
+ state.chatCalls.set(key, call);
716
+ return { call, key, idx };
717
+ }
718
+
719
+ /**
720
+ * Point one EMITTED chat tool_call delta at its call's host index. The
721
+ * rewrite must only ever fire in index-collision cases, so byte-identity
722
+ * holds for every single-call stream: an unchanged index is left untouched,
723
+ * and a record with NO index field (host-defaulted 0) only gains one when
724
+ * the host index is not 0.
725
+ */
726
+ function chatRewriteDeltaIndex(rec: Record<string, unknown>, call: ChatTrackedCall): boolean {
727
+ if (typeof rec.index === "number") {
728
+ if (rec.index === call.hostIndex) return false;
729
+ rec.index = call.hostIndex;
730
+ return true;
731
+ }
732
+ if (call.hostIndex === 0) return false;
733
+ rec.index = call.hostIndex;
734
+ return true;
735
+ }
736
+
538
737
  /**
539
738
  * Terminal chunk for chat choice: absorb any tool-call argument fragment
540
- * riding the finish_reason chunk into the buffered prefix, then re-emit every
541
- * buffered tool call's whole argument packet, repaired.
542
- * Returns true if delta was mutated.
739
+ * riding the finish_reason chunk into its call's buffered prefix, then
740
+ * re-emit every buffered call's whole argument packet, repaired, at ITS host
741
+ * index. Returns true if delta was mutated.
742
+ *
743
+ * Flush-entry id policy: entries keep today's exact shape
744
+ * {index, type:"function", function:{name,arguments}} WITHOUT the call id.
745
+ * Hosts assemble streamed tool_calls by index, and every delta of the call —
746
+ * including the id-bearing name-delta — is emitted at the same rewritten
747
+ * host index, so the host has already bound the id; the stable shape also
748
+ * keeps the single-call flush byte-identical.
543
749
  */
544
750
  function flushChatTerminalToolCalls(
545
751
  d: Record<string, unknown>,
546
752
  state: SseStreamCloakState,
547
753
  cloak: StreamCloakOptions,
548
- flushedIndices: Set<number>,
754
+ flushedKeys: Set<string>,
755
+ flushedEntries: Set<Record<string, unknown>>,
549
756
  ): boolean {
550
- if (state.chatArgsByIndex.size === 0) return false;
757
+ let rewrote = false;
551
758
 
552
759
  for (const tc of Array.isArray(d.tool_calls) ? d.tool_calls : []) {
553
760
  if (!tc || typeof tc !== "object" || Array.isArray(tc)) continue;
554
761
  const rec = tc as Record<string, unknown>;
555
- const idx = typeof rec.index === "number" ? rec.index : 0;
762
+ const { call } = chatTrackedCall(state, rec);
556
763
  const fn = rec.function;
557
764
  const fnRec = fn && typeof fn === "object" && !Array.isArray(fn) ? (fn as Record<string, unknown>) : null;
558
- if (fnRec && state.chatArgsByIndex.has(idx) && typeof fnRec.arguments === "string") {
559
- state.chatArgsByIndex.set(idx, (state.chatArgsByIndex.get(idx) ?? "") + fnRec.arguments);
765
+ if (call.buffered && !call.flushed && fnRec && typeof fnRec.arguments === "string") {
766
+ call.args += fnRec.arguments;
560
767
  }
561
768
  }
562
769
 
563
770
  const flushed: unknown[] = [];
564
- for (const [idx, args] of state.chatArgsByIndex) {
565
- const name = state.chatNameByIndex.get(idx);
771
+ for (const [key, call] of state.chatCalls) {
772
+ if (!call.buffered || call.flushed) continue;
773
+ const name = call.name;
566
774
  if (name === undefined) continue;
567
- flushedIndices.add(idx);
568
- flushed.push({ index: idx, type: "function", function: { name, arguments: repairToolArguments(name, args, cloak.argSchemas) } });
775
+ call.flushed = true;
776
+ call.buffered = false;
777
+ const args = call.args;
778
+ call.args = "";
779
+ flushedKeys.add(key);
780
+ const repairedArgs = repairToolArguments(name, args, cloak.argSchemas);
781
+ if (repairedArgs !== args) state.audit?.repair("arguments-repaired", name);
782
+ const entry = { index: call.hostIndex, type: "function", function: { name, arguments: repairedArgs } };
783
+ flushedEntries.add(entry);
784
+ flushed.push(entry);
569
785
  }
570
- state.chatArgsByIndex.clear();
571
- state.chatNameByIndex.clear();
572
786
 
573
- if (flushed.length === 0) return false;
787
+ if (flushed.length === 0 && !rewrote) return false;
574
788
 
789
+ // Records riding the finish chunk whose call was just flushed are removed
790
+ // (their fragments were absorbed above); surviving records keep flowing but
791
+ // are first pointed at their call's host index.
575
792
  const remaining = (Array.isArray(d.tool_calls) ? d.tool_calls : []).filter((tc) => {
576
793
  if (!tc || typeof tc !== "object" || Array.isArray(tc)) return true;
577
794
  const rec = tc as Record<string, unknown>;
578
- const idx = typeof rec.index === "number" ? rec.index : 0;
579
- return !flushedIndices.has(idx);
795
+ const { call, key } = chatTrackedCall(state, rec);
796
+ if (flushedKeys.has(key)) return false;
797
+ if (chatRewriteDeltaIndex(rec, call)) rewrote = true;
798
+ return true;
580
799
  });
581
800
  d.tool_calls = [...remaining, ...flushed];
582
801
  return true;
@@ -584,9 +803,12 @@ function flushChatTerminalToolCalls(
584
803
 
585
804
  /**
586
805
  * Rewrite one Chat Completions SSE block: drop injected delta.tool_calls
587
- * (including nameless argument continuations via per-index tracking), restore
588
- * surviving names. Tool-less callers lose all tool_calls with arguments
589
- * folded to content, mirroring the aggregate cloak. Null drops the block.
806
+ * (including nameless continuations via per-call tracking), restore surviving
807
+ * names, and keep host-visible indices true: collisions on one upstream
808
+ * index (free models label every parallel call index 0) are rewritten to
809
+ * distinct sequential indices while the call stays one call.
810
+ * Tool-less callers lose all tool_calls with arguments folded to content,
811
+ * mirroring the aggregate cloak. Null drops the block.
590
812
  */
591
813
  function rewriteChatBlock(
592
814
  block: string,
@@ -623,13 +845,14 @@ function rewriteChatBlock(
623
845
  }
624
846
  const d = delta as Record<string, unknown>;
625
847
  // Terminal chunk for this choice: re-emit every buffered tool call's whole
626
- // argument packet, repaired. The fragments never went out, so this is the
627
- // first and only time the client sees them. Runs before the tool_calls
628
- // branch because the terminal chunk usually carries an empty delta.
629
- // Indices flushed on this block: their arguments are final, so the
630
- // per-delta loop below must not buffer them a second time.
631
- const flushedIndices = new Set<number>();
632
- if (c.finish_reason && flushChatTerminalToolCalls(d, state, cloak, flushedIndices)) {
848
+ // argument packet, repaired, at its host index. The fragments never went
849
+ // out, so this is the first and only time the client sees them. Runs
850
+ // before the tool_calls branch because the terminal chunk usually carries
851
+ // an empty delta. Call keys flushed on this block: their arguments are
852
+ // final, so the per-delta loop below must not buffer them a second time.
853
+ const flushedKeys = new Set<string>();
854
+ const flushedEntries = new Set<Record<string, unknown>>();
855
+ if (c.finish_reason && flushChatTerminalToolCalls(d, state, cloak, flushedKeys, flushedEntries)) {
633
856
  changed = true;
634
857
  }
635
858
  if (!Array.isArray(d.tool_calls)) {
@@ -640,13 +863,21 @@ function rewriteChatBlock(
640
863
  let folded = "";
641
864
  for (const tc of d.tool_calls as unknown[]) {
642
865
  if (!tc || typeof tc !== "object" || Array.isArray(tc)) continue;
866
+ // Flush entry rebuilt by the terminal flush above: pure passthrough.
867
+ // These carry no id, so key resolution would misbind them to whichever
868
+ // call currently owns the index — today's code skipped them the same way
869
+ // via the index-keyed flushedIndices set.
870
+ if (flushedEntries.has(tc as Record<string, unknown>)) {
871
+ kept.push(tc);
872
+ continue;
873
+ }
643
874
  const rec = tc as Record<string, unknown>;
644
- const idx = typeof rec.index === "number" ? rec.index : 0;
875
+ const { call, key } = chatTrackedCall(state, rec);
645
876
  const fn = rec.function;
646
877
  const fnRec = fn && typeof fn === "object" && !Array.isArray(fn) ? (fn as Record<string, unknown>) : null;
647
878
  const name = fnRec && typeof fnRec.name === "string" && fnRec.name !== "" ? (fnRec.name as string) : "";
648
879
  if (!cloak.callerHadTools) {
649
- state.chatDroppedByIndex.set(idx, true);
880
+ call.dropped = true;
650
881
  if (fnRec) {
651
882
  folded += (typeof fnRec.arguments === "string" && fnRec.arguments) || name;
652
883
  }
@@ -655,44 +886,75 @@ function rewriteChatBlock(
655
886
  }
656
887
  if (name !== "") {
657
888
  if (isInjectedName(name, cloak.injected)) {
658
- state.chatDroppedByIndex.set(idx, true);
889
+ // The verdict is the CALL's, not the index's: a real id-bearing call
890
+ // reusing this index rebinds the fragment owner (chatIndexOwner) and
891
+ // inherits nothing.
892
+ call.dropped = true;
659
893
  changed = true;
660
894
  continue;
661
895
  }
662
896
  const restored = restoreStreamName(name, cloak);
663
- if (restored !== name && fnRec) {
897
+ // Fold this delta's name into the call's accumulated name: a name-only
898
+ // delta is a fragment (append), a delta that ALSO carries arguments
899
+ // restates the whole name (replace) — same rule as the aggregate cloak,
900
+ // see accumulateToolCallName. When the restated name is the one the host
901
+ // already holds, it is dropped from the wire instead of re-sent: the host
902
+ // concatenates streamed name deltas, so a second copy would hand it
903
+ // `get_weatherget_weather`.
904
+ const previousName = call.name;
905
+ const hasArgs = fnRec !== null && typeof fnRec.arguments === "string" && fnRec.arguments !== "";
906
+ const toolName = accumulateToolCallName(previousName, restored, hasArgs);
907
+ const nameRepeated = hasArgs && previousName !== undefined && toolName === previousName;
908
+ call.name = toolName;
909
+ if (nameRepeated && fnRec) {
910
+ delete fnRec.name;
911
+ changed = true;
912
+ // Audit: the upstream restated a name the host already holds; the copy
913
+ // was kept off the wire so the host does not concatenate it.
914
+ state.audit?.repair("name-restated", toolName);
915
+ } else if (restored !== name && fnRec) {
664
916
  fnRec.name = restored;
665
917
  changed = true;
666
918
  }
667
- state.chatDroppedByIndex.delete(idx);
668
- state.chatNameByIndex.set(idx, restored);
669
- if (isRepairableTool(restored, cloak.argSchemas) && !flushedIndices.has(idx)) {
919
+ call.dropped = false;
920
+ if (isRepairableTool(toolName, cloak.argSchemas) && !call.flushed && !flushedKeys.has(key)) {
670
921
  // Hold this call's arguments: the packet is only judgeable once whole,
671
922
  // so it is re-emitted repaired on the terminal finish_reason chunk.
672
- state.chatArgsByIndex.set(idx, "");
923
+ // Reset-then-absorb mirrors the old per-index buffer exactly.
924
+ if (!call.buffered) state.audit?.repair("arguments-buffered", toolName);
925
+ call.buffered = true;
926
+ call.args = "";
673
927
  if (fnRec && typeof fnRec.arguments === "string" && fnRec.arguments !== "") {
674
- state.chatArgsByIndex.set(idx, fnRec.arguments);
928
+ call.args = fnRec.arguments;
675
929
  delete fnRec.arguments;
676
930
  changed = true;
677
931
  }
932
+ if (nameRepeated && fnRec && Object.keys(fnRec).length === 0) {
933
+ // Repeat header whose name and arguments both moved elsewhere: the
934
+ // delta has nothing left to tell the host.
935
+ continue;
936
+ }
678
937
  kept.push(tc);
938
+ if (chatRewriteDeltaIndex(rec, call)) changed = true;
679
939
  continue;
680
940
  }
681
941
  kept.push(tc);
942
+ if (chatRewriteDeltaIndex(rec, call)) changed = true;
682
943
  continue;
683
944
  }
684
- // Nameless continuation (argument chunk): follows the index verdict.
685
- if (state.chatDroppedByIndex.get(idx) === true) {
945
+ // Nameless continuation (argument chunk): follows the CALL's verdict.
946
+ if (call.dropped) {
686
947
  changed = true;
687
948
  continue;
688
949
  }
689
- if (state.chatArgsByIndex.has(idx) && fnRec && typeof fnRec.arguments === "string") {
690
- state.chatArgsByIndex.set(idx, (state.chatArgsByIndex.get(idx) ?? "") + fnRec.arguments);
950
+ if (call.buffered && fnRec && typeof fnRec.arguments === "string") {
951
+ call.args += fnRec.arguments;
691
952
  delete fnRec.arguments;
692
953
  changed = true;
693
954
  continue;
694
955
  }
695
956
  kept.push(tc);
957
+ if (chatRewriteDeltaIndex(rec, call)) changed = true;
696
958
  }
697
959
  if (kept.length > 0) {
698
960
  d.tool_calls = kept;
@@ -802,6 +1064,7 @@ function rewriteMessagesBlock(
802
1064
  rec.name = restored;
803
1065
  state.messagesNameByIndex.set(index, restored);
804
1066
  if (isRepairableTool(restored, cloak.argSchemas)) {
1067
+ if (!state.messagesArgsByIndex.has(index)) state.audit?.repair("arguments-buffered", restored);
805
1068
  state.messagesArgsByIndex.set(index, typeof rec.input === "string" ? rec.input : "");
806
1069
  }
807
1070
  return emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
@@ -831,10 +1094,12 @@ function rewriteMessagesBlock(
831
1094
  const name = state.messagesNameByIndex.get(index) ?? "";
832
1095
  state.messagesArgsByIndex.delete(index);
833
1096
  state.messagesNameByIndex.delete(index);
1097
+ const repaired = repairToolArguments(name, buffered, cloak.argSchemas);
1098
+ if (repaired !== buffered) state.audit?.repair("arguments-repaired", name);
834
1099
  const flushed: Record<string, unknown> = {
835
1100
  type: "content_block_delta",
836
1101
  index,
837
- delta: { type: "input_json_delta", partial_json: repairToolArguments(name, buffered, cloak.argSchemas) },
1102
+ delta: { type: "input_json_delta", partial_json: repaired },
838
1103
  };
839
1104
  const stopBlock = emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
840
1105
  return `${emitSseBlock("content_block_delta", JSON.stringify(flushed), true)}\n\n${stopBlock}`;
@@ -905,6 +1170,14 @@ export interface StreamPipeOptions {
905
1170
  * upstream omitted it; the pipe then classifies the buffered start itself.
906
1171
  */
907
1172
  upstreamContentType?: string | null;
1173
+ /**
1174
+ * Per-turn tool-call audit recorder (see toolcall-audit.ts). `observe` gets
1175
+ * every forwarded SSE block that carries a tool marker — cloaked and
1176
+ * bypassed streams alike, after any rewrite; `finish` is idempotent and the
1177
+ * pipe calls it exactly once per turn (raw non-SSE bodies finish with
1178
+ * `terminal: false`). Cloak rewrites also report through its `repair`.
1179
+ */
1180
+ audit?: ToolcallStreamAudit;
908
1181
  }
909
1182
  /**
910
1183
  * Pipes an upstream readable stream to a client HTTP response.
@@ -958,7 +1231,17 @@ export function pipeUpstreamStream(
958
1231
  // `end` already ran; `close` always trails it on a later tick).
959
1232
  let refirePending = false;
960
1233
  // Streaming cloak: per-stream rewrite state (null = byte-identical passthrough).
961
- const cloakState = cloak && !shouldBypassStreamCloak(cloak) ? createSseStreamCloakState() : null;
1234
+ // The repair sink is wrapped because a throwing sink must never drop a chunk.
1235
+ const auditRepairs = opts?.audit
1236
+ ? {
1237
+ repair: (kind: string, detail?: string): void => {
1238
+ try {
1239
+ opts.audit!.repair(kind, detail);
1240
+ } catch { }
1241
+ },
1242
+ }
1243
+ : undefined;
1244
+ const cloakState = cloak && !shouldBypassStreamCloak(cloak) ? createSseStreamCloakState(auditRepairs) : null;
962
1245
  // Holds the trailing incomplete SSE block across chunks; complete blocks are
963
1246
  // rewritten and forwarded, so a function_call name split across chunks still
964
1247
  // matches its injected record.
@@ -992,6 +1275,59 @@ export function pipeUpstreamStream(
992
1275
  };
993
1276
  // One-shot resume guard: the refire hook below fires at most once per turn.
994
1277
  let refireUsed = false;
1278
+ // Tool-call audit completion: exactly one finish per turn. A normal stream
1279
+ // reports from both `end` and `close`, and a rescue hands the turn to a
1280
+ // replacement stream — the guard keeps the report single (the recorder is
1281
+ // idempotent; an external sink need not be).
1282
+ let auditFinished = false;
1283
+ const finishAudit = (terminal: boolean, unclassifiable = false): void => {
1284
+ if (auditFinished) return;
1285
+ auditFinished = true;
1286
+ // An external sink must never change what the client receives.
1287
+ try {
1288
+ opts?.audit?.finish(unclassifiable ? { terminal, unclassifiable: true } : { terminal });
1289
+ } catch { }
1290
+ };
1291
+
1292
+ // Body-stall watchdog: after response headers a streaming body has no fixed
1293
+ // total deadline (See relay.ts: the per-attempt timer is headers-only).
1294
+ // The only failure left is a wedged leg that keeps the socket open
1295
+ // and sends nothing — protected by a per-stream idle cut well above every
1296
+ // observed healthy inter-chunk gap (thinking-heavy streams emit sub-second,
1297
+ // even the slowest logged TTFB is tens of seconds). On expiry the CURRENT
1298
+ // stream is destroyed with a relay-fault error so the truncation path
1299
+ // (health mark + one mid-stream refire) owns the turn. Every forwarded byte
1300
+ // re-arms it; end/close/error disarm it; a rescue re-arms the replacement.
1301
+ let stallCutTimer: NodeJS.Timeout | undefined;
1302
+ let stallCutStream: Readable | null = null;
1303
+ const fireStallCut = (target: Readable): void => {
1304
+ stallCutTimer = undefined;
1305
+ stallCutStream = null;
1306
+ if (target.destroyed || clientAborted || target !== nodeStream) return;
1307
+ const stallErr = new Error(
1308
+ `upstream body idle (no data for ${bodyStallCutMs}ms)`,
1309
+ ) as Error & { code?: string };
1310
+ stallErr.code = "FF_BODY_STALL";
1311
+ stallErr.name = "TimeoutError";
1312
+ target.destroy(stallErr);
1313
+ };
1314
+ const scheduleStallCut = (s: Readable): void => {
1315
+ clearTimeout(stallCutTimer);
1316
+ stallCutStream = s;
1317
+ const timer = setTimeout(() => fireStallCut(s), bodyStallCutMs);
1318
+ timer.unref?.();
1319
+ stallCutTimer = timer;
1320
+ };
1321
+ const breatheStallCut = (): void => {
1322
+ if (stallCutTimer !== undefined && stallCutStream === nodeStream) {
1323
+ scheduleStallCut(nodeStream);
1324
+ }
1325
+ };
1326
+ const disarmStallCut = (): void => {
1327
+ clearTimeout(stallCutTimer);
1328
+ stallCutTimer = undefined;
1329
+ stallCutStream = null;
1330
+ };
995
1331
 
996
1332
  const sniffThinking = (chunk: Buffer | string): boolean => {
997
1333
  const s =
@@ -1033,7 +1369,12 @@ export function pipeUpstreamStream(
1033
1369
  chunk: string | Buffer,
1034
1370
  reason: "model" | "rate_limit",
1035
1371
  ): boolean => {
1036
- if (refirePending || heldFailureChunk !== null || sawToolCall) return false;
1372
+ if (refirePending || heldFailureChunk !== null || sawToolCall) {
1373
+ // Audit: the only veto worth recording is the tool-turn one — a resume
1374
+ // would regenerate calls the host already received.
1375
+ if (sawToolCall) opts?.audit?.refireSkipped();
1376
+ return false;
1377
+ }
1037
1378
  if (clientAborted || refireUsed || !opts?.refire || res.writableEnded || res.destroyed) return false;
1038
1379
  refireUsed = true;
1039
1380
  refirePending = true;
@@ -1115,6 +1456,10 @@ export function pipeUpstreamStream(
1115
1456
  try {
1116
1457
  if (!res.writableEnded) res.end();
1117
1458
  } catch { }
1459
+ // The cut stream's own end/close may already have passed while this
1460
+ // rescue was in flight, so completion is reported here instead (idempotent
1461
+ // across the later close).
1462
+ finishAudit(hasTerminalEvent);
1118
1463
  return;
1119
1464
  }
1120
1465
  heldFailureChunk = null;
@@ -1202,6 +1547,17 @@ export function pipeUpstreamStream(
1202
1547
  hasTerminalEvent = true;
1203
1548
  }
1204
1549
  terminalScanCarry = Buffer.from(scanBuf.subarray(Math.max(0, scanBuf.length - 32)));
1550
+ // Per-turn tool-call audit: hand over the EXACT forwarded bytes of any block
1551
+ // that carries a tool call (chat deltas, responses items, messages
1552
+ // tool_use + argument fragments) or a populated turn-end marker (so a tool
1553
+ // turn that ended in plain text records its finish reason). The recorder
1554
+ // parses the block itself, so plain text/reasoning blocks are never copied.
1555
+ // Dropped blocks returned before this point and are therefore never observed.
1556
+ if (opts?.audit !== undefined && AUDIT_MARKERS.some((m) => text.includes(m))) {
1557
+ try {
1558
+ opts.audit.observe(text);
1559
+ } catch { }
1560
+ }
1205
1561
  deliver(text);
1206
1562
  };
1207
1563
  const sseBoundary = /\r?\n\r?\n/;
@@ -1262,6 +1618,11 @@ export function pipeUpstreamStream(
1262
1618
  const tryRefire = async (): Promise<boolean> => {
1263
1619
  if (clientAborted || refireUsed || !opts?.refire) return false;
1264
1620
  if (sawToolCall) {
1621
+ // Audit: the resume budget was not spent because the turn already
1622
+ // forwarded tool calls (a regeneration would duplicate them).
1623
+ try {
1624
+ opts?.audit?.refireSkipped();
1625
+ } catch { }
1265
1626
  log("debug", "mid-stream refire skipped: turn already forwarded tool calls", { totalChunks, totalBytes }, rid);
1266
1627
  return false;
1267
1628
  }
@@ -1287,6 +1648,7 @@ export function pipeUpstreamStream(
1287
1648
  sseDecoder = new StringDecoder("utf8");
1288
1649
  terminalScanCarry = Buffer.alloc(0);
1289
1650
  watch(next);
1651
+ scheduleStallCut(next);
1290
1652
  log("debug", "mid-stream refire: continuing turn on replacement stream", { totalChunks, totalBytes }, rid);
1291
1653
  return true;
1292
1654
  };
@@ -1341,6 +1703,7 @@ export function pipeUpstreamStream(
1341
1703
  };
1342
1704
  const onData = (chunk: Buffer | string): void => {
1343
1705
  try {
1706
+ breatheStallCut();
1344
1707
  // While a replacement stream is being fetched the cut stream is stale:
1345
1708
  // its late frames must not interleave with the rescued turn.
1346
1709
  if (refirePending) return;
@@ -1386,6 +1749,7 @@ export function pipeUpstreamStream(
1386
1749
  };
1387
1750
 
1388
1751
  const onError = async (e: unknown): Promise<void> => {
1752
+ disarmStallCut();
1389
1753
  // A rescue is already in flight (its outcome decides the turn): a late
1390
1754
  // error from the cut stream must not inject a terminal mid-rescue.
1391
1755
  if (refirePending) return;
@@ -1433,10 +1797,14 @@ export function pipeUpstreamStream(
1433
1797
  if (!res.writableEnded) {
1434
1798
  res.end();
1435
1799
  }
1800
+ // Audit completion: no terminal SSE event was forwarded on this path (or
1801
+ // one was, before the error); a rescue returned above and owns the report.
1802
+ finishAudit(hasTerminalEvent);
1436
1803
  } catch { }
1437
1804
  };
1438
1805
 
1439
1806
  const onEnd = async (): Promise<void> => {
1807
+ disarmStallCut();
1440
1808
  // A rescue is in flight (its outcome owns the turn): the cut stream's FIN
1441
1809
  // must neither inject a terminal nor end res mid-rescue.
1442
1810
  if (refirePending) return;
@@ -1453,6 +1821,10 @@ export function pipeUpstreamStream(
1453
1821
  try {
1454
1822
  if (!res.writableEnded) res.end();
1455
1823
  } catch { }
1824
+ // Audit: a raw (non-SSE) body carries no event stream to inspect, so the
1825
+ // turn is not classifiable: the summary says `remap: "unknown"` rather
1826
+ // than accusing the model of answering without tools.
1827
+ finishAudit(false, true);
1456
1828
  log(
1457
1829
  "debug",
1458
1830
  `non-SSE upstream body passed through verbatim — ${totalChunks} chunks (${(totalBytes / 1024).toFixed(1)}KB)`,
@@ -1539,9 +1911,12 @@ export function pipeUpstreamStream(
1539
1911
  try {
1540
1912
  if (!res.writableEnded) res.end();
1541
1913
  } catch { }
1914
+ // Audit completion for the turn (idempotent; `close` trails this call).
1915
+ finishAudit(hasTerminalEvent);
1542
1916
  };
1543
1917
 
1544
1918
  const onClose = async (): Promise<void> => {
1919
+ disarmStallCut();
1545
1920
  // A stalled `close` trailing an `end` that is mid-rescue must neither
1546
1921
  // inject a terminal nor end res — the replacement stream owns the turn now.
1547
1922
  if (refirePending) return;
@@ -1592,6 +1967,11 @@ export function pipeUpstreamStream(
1592
1967
  }
1593
1968
  }
1594
1969
  if (!res.writableEnded) res.end();
1970
+ // Audit completion: `end` normally already reported (the guard makes this
1971
+ // a no-op then); a socket death or rescue teardown reports from here. Raw
1972
+ // (non-SSE) bodies carry no event stream, so they are never terminal and
1973
+ // never classifiable (`remap: "unknown"`).
1974
+ finishAudit(bodyGate === "sse" && hasTerminalEvent, bodyGate !== "sse");
1595
1975
  } catch { }
1596
1976
  };
1597
1977
  const watch = (s: Readable): void => {
@@ -1603,6 +1983,7 @@ export function pipeUpstreamStream(
1603
1983
  s.on("close", () => { if (s === nodeStream) void onClose(); });
1604
1984
  };
1605
1985
  watch(nodeStream);
1986
+ scheduleStallCut(nodeStream);
1606
1987
 
1607
1988
  req.on("aborted", () => {
1608
1989
  clientAborted = true;