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.
- package/CHANGELOG.md +65 -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 +92 -11
- package/src/responses.ts +6 -0
- package/src/stream-pipe.ts +448 -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
|
|
@@ -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
|
|
292
|
-
* same way as the name
|
|
293
|
-
* fragments, so a malformed packet can only be judged once
|
|
294
|
-
* fragments are held here and re-emitted, repaired, on the
|
|
295
|
-
* event. Tools absent from `argSchemas` are never buffered,
|
|
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
|
-
|
|
303
|
-
|
|
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:
|
|
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
|
|
352
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
541
|
-
* buffered
|
|
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
|
-
|
|
754
|
+
flushedKeys: Set<string>,
|
|
755
|
+
flushedEntries: Set<Record<string, unknown>>,
|
|
549
756
|
): boolean {
|
|
550
|
-
|
|
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
|
|
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 (
|
|
559
|
-
|
|
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 [
|
|
565
|
-
|
|
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
|
-
|
|
568
|
-
|
|
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
|
|
579
|
-
|
|
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
|
|
588
|
-
*
|
|
589
|
-
*
|
|
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
|
|
627
|
-
// first and only time the client sees them. Runs
|
|
628
|
-
// branch because the terminal chunk usually carries
|
|
629
|
-
//
|
|
630
|
-
// per-delta loop below must not buffer them a second time.
|
|
631
|
-
const
|
|
632
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
668
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
685
|
-
if (
|
|
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 (
|
|
690
|
-
|
|
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:
|
|
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
|
-
|
|
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)
|
|
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;
|