pi-freeflow 1.40.1 → 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
@@ -60,6 +67,29 @@ const TOOL_CALL_MARKERS = [
60
67
  "custom_tool_call_input",
61
68
  '"tool_calls"',
62
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];
63
93
 
64
94
  export function isSubstantial(chunks: number, bytes: number): boolean {
65
95
  return chunks > SUBSTANTIAL_MIN_CHUNKS && bytes > SUBSTANTIAL_MIN_BYTES;
@@ -290,38 +320,112 @@ export interface StreamCloakOptions {
290
320
  export interface SseStreamCloakState {
291
321
  responsesDroppedByIndex: Map<number, boolean>;
292
322
  responsesDroppedByItemId: Map<string, boolean>;
293
- chatDroppedByIndex: Map<number, boolean>;
294
323
  messagesDroppedByIndex: Map<number, boolean>;
295
324
  /** Output index -> tool name, so nameless argument deltas can find their schema. */
296
325
  responsesNameByIndex: Map<number, string>;
297
326
  /** Anthropic block index -> tool name, same purpose for input_json deltas. */
298
327
  messagesNameByIndex: Map<number, string>;
299
- /** Chat tool-call index -> restored tool name. */
300
- chatNameByIndex: Map<number, string>;
301
328
  /**
302
- * Argument fragments for tool calls whose schema admits a repair, keyed the
303
- * same way as the name maps above. Streaming APIs deliver arguments in
304
- * fragments, so a malformed packet can only be judged once complete: those
305
- * fragments are held here and re-emitted, repaired, on the call's terminal
306
- * event. Tools absent from `argSchemas` are never buffered, so their
307
- * 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.
308
335
  */
309
- chatArgsByIndex: Map<number, string>;
310
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;
311
371
  }
312
372
 
313
- export function createSseStreamCloakState(): SseStreamCloakState {
314
- 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 = {
315
416
  responsesDroppedByIndex: new Map(),
316
417
  responsesDroppedByItemId: new Map(),
317
- chatDroppedByIndex: new Map(),
318
418
  messagesDroppedByIndex: new Map(),
319
419
  responsesNameByIndex: new Map(),
320
420
  messagesNameByIndex: new Map(),
321
- chatNameByIndex: new Map(),
322
- chatArgsByIndex: new Map(),
323
421
  messagesArgsByIndex: new Map(),
422
+ chatCalls: new Map(),
423
+ chatIndexOwner: new Map(),
424
+ chatIndexClaims: new Map(),
425
+ chatNextIndex: 0,
324
426
  };
427
+ if (audit !== undefined) state.audit = audit;
428
+ return state;
325
429
  }
326
430
 
327
431
  /**
@@ -345,11 +449,13 @@ export function flushBufferedToolArgs(
345
449
  const blocks: string[] = [];
346
450
  for (const [index, args] of state.messagesArgsByIndex) {
347
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);
348
454
  blocks.push(
349
455
  `event: content_block_delta\ndata: ${JSON.stringify({
350
456
  type: "content_block_delta",
351
457
  index,
352
- delta: { type: "input_json_delta", partial_json: repairToolArguments(name, args, cloak.argSchemas) },
458
+ delta: { type: "input_json_delta", partial_json: repaired },
353
459
  })}\n\n`,
354
460
  );
355
461
  }
@@ -357,15 +463,21 @@ export function flushBufferedToolArgs(
357
463
  state.messagesNameByIndex.clear();
358
464
  return blocks.join("");
359
465
  }
360
- if (state.chatArgsByIndex.size === 0) return null;
361
466
  const calls: unknown[] = [];
362
- for (const [index, args] of state.chatArgsByIndex) {
363
- 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;
364
472
  if (name === undefined) continue;
365
- 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 } });
366
480
  }
367
- state.chatArgsByIndex.clear();
368
- state.chatNameByIndex.clear();
369
481
  if (calls.length === 0) return null;
370
482
  return `data: ${JSON.stringify({ object: "chat.completion.chunk", choices: [{ index: 0, delta: { tool_calls: calls } }] })}\n\n`;
371
483
  }
@@ -473,7 +585,10 @@ function rewriteResponsesBlock(
473
585
  if (typeof parsed.output_index === "number") state.responsesNameByIndex.set(parsed.output_index, restored);
474
586
  if (type === "response.output_item.done") {
475
587
  rec.name = restored;
476
- 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;
477
592
  return emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
478
593
  }
479
594
  if (restored !== rec.name) {
@@ -498,6 +613,7 @@ function rewriteResponsesBlock(
498
613
  if (name !== undefined) {
499
614
  const repairedArgs = repairToolArguments(name, parsed.arguments, cloak.argSchemas);
500
615
  if (repairedArgs !== parsed.arguments) {
616
+ state.audit?.repair("arguments-repaired", name);
501
617
  parsed.arguments = repairedArgs;
502
618
  return emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
503
619
  }
@@ -546,48 +662,140 @@ function cloakStreamResponsesObject(resp: unknown, cloak: StreamCloakOptions): v
546
662
  (resp as Record<string, unknown>).output = kept;
547
663
  }
548
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
+
549
737
  /**
550
738
  * Terminal chunk for chat choice: absorb any tool-call argument fragment
551
- * riding the finish_reason chunk into the buffered prefix, then re-emit every
552
- * buffered tool call's whole argument packet, repaired.
553
- * 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.
554
749
  */
555
750
  function flushChatTerminalToolCalls(
556
751
  d: Record<string, unknown>,
557
752
  state: SseStreamCloakState,
558
753
  cloak: StreamCloakOptions,
559
- flushedIndices: Set<number>,
754
+ flushedKeys: Set<string>,
755
+ flushedEntries: Set<Record<string, unknown>>,
560
756
  ): boolean {
561
- if (state.chatArgsByIndex.size === 0) return false;
757
+ let rewrote = false;
562
758
 
563
759
  for (const tc of Array.isArray(d.tool_calls) ? d.tool_calls : []) {
564
760
  if (!tc || typeof tc !== "object" || Array.isArray(tc)) continue;
565
761
  const rec = tc as Record<string, unknown>;
566
- const idx = typeof rec.index === "number" ? rec.index : 0;
762
+ const { call } = chatTrackedCall(state, rec);
567
763
  const fn = rec.function;
568
764
  const fnRec = fn && typeof fn === "object" && !Array.isArray(fn) ? (fn as Record<string, unknown>) : null;
569
- if (fnRec && state.chatArgsByIndex.has(idx) && typeof fnRec.arguments === "string") {
570
- 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;
571
767
  }
572
768
  }
573
769
 
574
770
  const flushed: unknown[] = [];
575
- for (const [idx, args] of state.chatArgsByIndex) {
576
- 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;
577
774
  if (name === undefined) continue;
578
- flushedIndices.add(idx);
579
- 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);
580
785
  }
581
- state.chatArgsByIndex.clear();
582
- state.chatNameByIndex.clear();
583
786
 
584
- if (flushed.length === 0) return false;
787
+ if (flushed.length === 0 && !rewrote) return false;
585
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.
586
792
  const remaining = (Array.isArray(d.tool_calls) ? d.tool_calls : []).filter((tc) => {
587
793
  if (!tc || typeof tc !== "object" || Array.isArray(tc)) return true;
588
794
  const rec = tc as Record<string, unknown>;
589
- const idx = typeof rec.index === "number" ? rec.index : 0;
590
- 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;
591
799
  });
592
800
  d.tool_calls = [...remaining, ...flushed];
593
801
  return true;
@@ -595,9 +803,12 @@ function flushChatTerminalToolCalls(
595
803
 
596
804
  /**
597
805
  * Rewrite one Chat Completions SSE block: drop injected delta.tool_calls
598
- * (including nameless argument continuations via per-index tracking), restore
599
- * surviving names. Tool-less callers lose all tool_calls with arguments
600
- * 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.
601
812
  */
602
813
  function rewriteChatBlock(
603
814
  block: string,
@@ -634,13 +845,14 @@ function rewriteChatBlock(
634
845
  }
635
846
  const d = delta as Record<string, unknown>;
636
847
  // Terminal chunk for this choice: re-emit every buffered tool call's whole
637
- // argument packet, repaired. The fragments never went out, so this is the
638
- // first and only time the client sees them. Runs before the tool_calls
639
- // branch because the terminal chunk usually carries an empty delta.
640
- // Indices flushed on this block: their arguments are final, so the
641
- // per-delta loop below must not buffer them a second time.
642
- const flushedIndices = new Set<number>();
643
- 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)) {
644
856
  changed = true;
645
857
  }
646
858
  if (!Array.isArray(d.tool_calls)) {
@@ -651,13 +863,21 @@ function rewriteChatBlock(
651
863
  let folded = "";
652
864
  for (const tc of d.tool_calls as unknown[]) {
653
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
+ }
654
874
  const rec = tc as Record<string, unknown>;
655
- const idx = typeof rec.index === "number" ? rec.index : 0;
875
+ const { call, key } = chatTrackedCall(state, rec);
656
876
  const fn = rec.function;
657
877
  const fnRec = fn && typeof fn === "object" && !Array.isArray(fn) ? (fn as Record<string, unknown>) : null;
658
878
  const name = fnRec && typeof fnRec.name === "string" && fnRec.name !== "" ? (fnRec.name as string) : "";
659
879
  if (!cloak.callerHadTools) {
660
- state.chatDroppedByIndex.set(idx, true);
880
+ call.dropped = true;
661
881
  if (fnRec) {
662
882
  folded += (typeof fnRec.arguments === "string" && fnRec.arguments) || name;
663
883
  }
@@ -666,44 +886,75 @@ function rewriteChatBlock(
666
886
  }
667
887
  if (name !== "") {
668
888
  if (isInjectedName(name, cloak.injected)) {
669
- 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;
670
893
  changed = true;
671
894
  continue;
672
895
  }
673
896
  const restored = restoreStreamName(name, cloak);
674
- 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) {
675
916
  fnRec.name = restored;
676
917
  changed = true;
677
918
  }
678
- state.chatDroppedByIndex.delete(idx);
679
- state.chatNameByIndex.set(idx, restored);
680
- if (isRepairableTool(restored, cloak.argSchemas) && !flushedIndices.has(idx)) {
919
+ call.dropped = false;
920
+ if (isRepairableTool(toolName, cloak.argSchemas) && !call.flushed && !flushedKeys.has(key)) {
681
921
  // Hold this call's arguments: the packet is only judgeable once whole,
682
922
  // so it is re-emitted repaired on the terminal finish_reason chunk.
683
- 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 = "";
684
927
  if (fnRec && typeof fnRec.arguments === "string" && fnRec.arguments !== "") {
685
- state.chatArgsByIndex.set(idx, fnRec.arguments);
928
+ call.args = fnRec.arguments;
686
929
  delete fnRec.arguments;
687
930
  changed = true;
688
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
+ }
689
937
  kept.push(tc);
938
+ if (chatRewriteDeltaIndex(rec, call)) changed = true;
690
939
  continue;
691
940
  }
692
941
  kept.push(tc);
942
+ if (chatRewriteDeltaIndex(rec, call)) changed = true;
693
943
  continue;
694
944
  }
695
- // Nameless continuation (argument chunk): follows the index verdict.
696
- if (state.chatDroppedByIndex.get(idx) === true) {
945
+ // Nameless continuation (argument chunk): follows the CALL's verdict.
946
+ if (call.dropped) {
697
947
  changed = true;
698
948
  continue;
699
949
  }
700
- if (state.chatArgsByIndex.has(idx) && fnRec && typeof fnRec.arguments === "string") {
701
- state.chatArgsByIndex.set(idx, (state.chatArgsByIndex.get(idx) ?? "") + fnRec.arguments);
950
+ if (call.buffered && fnRec && typeof fnRec.arguments === "string") {
951
+ call.args += fnRec.arguments;
702
952
  delete fnRec.arguments;
703
953
  changed = true;
704
954
  continue;
705
955
  }
706
956
  kept.push(tc);
957
+ if (chatRewriteDeltaIndex(rec, call)) changed = true;
707
958
  }
708
959
  if (kept.length > 0) {
709
960
  d.tool_calls = kept;
@@ -813,6 +1064,7 @@ function rewriteMessagesBlock(
813
1064
  rec.name = restored;
814
1065
  state.messagesNameByIndex.set(index, restored);
815
1066
  if (isRepairableTool(restored, cloak.argSchemas)) {
1067
+ if (!state.messagesArgsByIndex.has(index)) state.audit?.repair("arguments-buffered", restored);
816
1068
  state.messagesArgsByIndex.set(index, typeof rec.input === "string" ? rec.input : "");
817
1069
  }
818
1070
  return emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
@@ -842,10 +1094,12 @@ function rewriteMessagesBlock(
842
1094
  const name = state.messagesNameByIndex.get(index) ?? "";
843
1095
  state.messagesArgsByIndex.delete(index);
844
1096
  state.messagesNameByIndex.delete(index);
1097
+ const repaired = repairToolArguments(name, buffered, cloak.argSchemas);
1098
+ if (repaired !== buffered) state.audit?.repair("arguments-repaired", name);
845
1099
  const flushed: Record<string, unknown> = {
846
1100
  type: "content_block_delta",
847
1101
  index,
848
- delta: { type: "input_json_delta", partial_json: repairToolArguments(name, buffered, cloak.argSchemas) },
1102
+ delta: { type: "input_json_delta", partial_json: repaired },
849
1103
  };
850
1104
  const stopBlock = emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
851
1105
  return `${emitSseBlock("content_block_delta", JSON.stringify(flushed), true)}\n\n${stopBlock}`;
@@ -916,6 +1170,14 @@ export interface StreamPipeOptions {
916
1170
  * upstream omitted it; the pipe then classifies the buffered start itself.
917
1171
  */
918
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;
919
1181
  }
920
1182
  /**
921
1183
  * Pipes an upstream readable stream to a client HTTP response.
@@ -969,7 +1231,17 @@ export function pipeUpstreamStream(
969
1231
  // `end` already ran; `close` always trails it on a later tick).
970
1232
  let refirePending = false;
971
1233
  // Streaming cloak: per-stream rewrite state (null = byte-identical passthrough).
972
- 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;
973
1245
  // Holds the trailing incomplete SSE block across chunks; complete blocks are
974
1246
  // rewritten and forwarded, so a function_call name split across chunks still
975
1247
  // matches its injected record.
@@ -1003,6 +1275,19 @@ export function pipeUpstreamStream(
1003
1275
  };
1004
1276
  // One-shot resume guard: the refire hook below fires at most once per turn.
1005
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
+ };
1006
1291
 
1007
1292
  // Body-stall watchdog: after response headers a streaming body has no fixed
1008
1293
  // total deadline (See relay.ts: the per-attempt timer is headers-only).
@@ -1084,7 +1369,12 @@ export function pipeUpstreamStream(
1084
1369
  chunk: string | Buffer,
1085
1370
  reason: "model" | "rate_limit",
1086
1371
  ): boolean => {
1087
- 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
+ }
1088
1378
  if (clientAborted || refireUsed || !opts?.refire || res.writableEnded || res.destroyed) return false;
1089
1379
  refireUsed = true;
1090
1380
  refirePending = true;
@@ -1166,6 +1456,10 @@ export function pipeUpstreamStream(
1166
1456
  try {
1167
1457
  if (!res.writableEnded) res.end();
1168
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);
1169
1463
  return;
1170
1464
  }
1171
1465
  heldFailureChunk = null;
@@ -1253,6 +1547,17 @@ export function pipeUpstreamStream(
1253
1547
  hasTerminalEvent = true;
1254
1548
  }
1255
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
+ }
1256
1561
  deliver(text);
1257
1562
  };
1258
1563
  const sseBoundary = /\r?\n\r?\n/;
@@ -1313,6 +1618,11 @@ export function pipeUpstreamStream(
1313
1618
  const tryRefire = async (): Promise<boolean> => {
1314
1619
  if (clientAborted || refireUsed || !opts?.refire) return false;
1315
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 { }
1316
1626
  log("debug", "mid-stream refire skipped: turn already forwarded tool calls", { totalChunks, totalBytes }, rid);
1317
1627
  return false;
1318
1628
  }
@@ -1487,6 +1797,9 @@ export function pipeUpstreamStream(
1487
1797
  if (!res.writableEnded) {
1488
1798
  res.end();
1489
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);
1490
1803
  } catch { }
1491
1804
  };
1492
1805
 
@@ -1508,6 +1821,10 @@ export function pipeUpstreamStream(
1508
1821
  try {
1509
1822
  if (!res.writableEnded) res.end();
1510
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);
1511
1828
  log(
1512
1829
  "debug",
1513
1830
  `non-SSE upstream body passed through verbatim — ${totalChunks} chunks (${(totalBytes / 1024).toFixed(1)}KB)`,
@@ -1594,6 +1911,8 @@ export function pipeUpstreamStream(
1594
1911
  try {
1595
1912
  if (!res.writableEnded) res.end();
1596
1913
  } catch { }
1914
+ // Audit completion for the turn (idempotent; `close` trails this call).
1915
+ finishAudit(hasTerminalEvent);
1597
1916
  };
1598
1917
 
1599
1918
  const onClose = async (): Promise<void> => {
@@ -1648,6 +1967,11 @@ export function pipeUpstreamStream(
1648
1967
  }
1649
1968
  }
1650
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");
1651
1975
  } catch { }
1652
1976
  };
1653
1977
  const watch = (s: Readable): void => {