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.
- package/CHANGELOG.md +55 -0
- package/README.md +36 -19
- package/package.json +1 -1
- package/src/catalog.ts +33 -21
- package/src/cline-accounts.ts +4 -3
- package/src/config.ts +13 -0
- package/src/models.ts +240 -64
- package/src/opencode-fingerprint.ts +78 -17
- package/src/proxy.ts +339 -12
- package/src/relay.ts +38 -0
- package/src/responses.ts +6 -0
- package/src/stream-pipe.ts +391 -67
- package/src/tool-translation.ts +15 -12
- package/src/toolcall-audit.ts +580 -0
package/src/stream-pipe.ts
CHANGED
|
@@ -16,9 +16,16 @@ import {
|
|
|
16
16
|
WATCHDOG_SSE_MIN_SAMPLES,
|
|
17
17
|
WATCHDOG_SSE_WINDOW,
|
|
18
18
|
} from "./config.ts";
|
|
19
|
-
import {
|
|
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
|
|
303
|
-
* same way as the name
|
|
304
|
-
* fragments, so a malformed packet can only be judged once
|
|
305
|
-
* fragments are held here and re-emitted, repaired, on the
|
|
306
|
-
* event. Tools absent from `argSchemas` are never buffered,
|
|
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
|
-
|
|
314
|
-
|
|
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:
|
|
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
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
552
|
-
* buffered
|
|
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
|
-
|
|
754
|
+
flushedKeys: Set<string>,
|
|
755
|
+
flushedEntries: Set<Record<string, unknown>>,
|
|
560
756
|
): boolean {
|
|
561
|
-
|
|
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
|
|
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 (
|
|
570
|
-
|
|
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 [
|
|
576
|
-
|
|
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
|
-
|
|
579
|
-
|
|
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
|
|
590
|
-
|
|
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
|
|
599
|
-
*
|
|
600
|
-
*
|
|
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
|
|
638
|
-
// first and only time the client sees them. Runs
|
|
639
|
-
// branch because the terminal chunk usually carries
|
|
640
|
-
//
|
|
641
|
-
// per-delta loop below must not buffer them a second time.
|
|
642
|
-
const
|
|
643
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
679
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
696
|
-
if (
|
|
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 (
|
|
701
|
-
|
|
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:
|
|
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
|
-
|
|
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)
|
|
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 => {
|