pi-freeflow 1.44.0 → 1.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +11 -13
- package/package.json +1 -1
- package/src/catalog.ts +38 -14
- package/src/client-recovery.ts +238 -0
- package/src/client-staging.ts +316 -0
- package/src/client.ts +117 -472
- package/src/cline-accounts.ts +26 -1223
- package/src/cline-device-auth.ts +5 -3
- package/src/cline-pool.ts +691 -0
- package/src/cline-roll.ts +642 -0
- package/src/commands-cline.ts +283 -0
- package/src/commands-relay.ts +808 -0
- package/src/commands-startup.ts +264 -0
- package/src/commands.ts +119 -1202
- package/src/config-paths.ts +254 -0
- package/src/config.ts +6 -687
- package/src/daemon.ts +16 -1
- package/src/deploy.ts +3 -1
- package/src/fingerprint-env.ts +334 -0
- package/src/fingerprint-tools.ts +348 -0
- package/src/index.ts +10 -6
- package/src/lane-translation.ts +263 -0
- package/src/models.ts +70 -112
- package/src/opencode-fingerprint.ts +29 -1310
- package/src/opencode-identity.ts +464 -0
- package/src/proxy-lanes.ts +1125 -0
- package/src/proxy-ports.ts +317 -0
- package/src/proxy.ts +74 -1385
- package/src/relay-codec.ts +365 -0
- package/src/relay-state.ts +10 -367
- package/src/relay.ts +78 -31
- package/src/sse-aggregator.ts +659 -0
- package/src/stream-cloak.ts +882 -0
- package/src/stream-pipe.ts +22 -871
- package/src/system-ca-fetch.ts +15 -4
- package/src/token.ts +7 -2
- package/src/tool-args.ts +10 -3
- package/src/tool-translation.ts +1 -236
- package/src/toolcall-audit.ts +20 -7
- package/src/update-checker.ts +17 -1
- package/src/update-core.ts +5 -2
- package/src/updater.ts +8 -3
- package/src/upstream-health.ts +22 -2
- package/src/zen-sidecar.ts +1 -0
- package/src/zen-transport.ts +48 -18
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request streaming cloak for the OpenCode fingerprint placeholder tools.
|
|
3
|
+
*
|
|
4
|
+
* The non-streaming aggregator (convertSseToJson in sse-aggregator.ts)
|
|
5
|
+
* strips injected empty-schema placeholders and restores caller names before
|
|
6
|
+
* the host ever sees them. Raw SSE passthrough would leak those placeholders
|
|
7
|
+
* on the streamed path, so this section mirrors the aggregate semantics
|
|
8
|
+
* event-by-event: function_call deltas whose name is in this request's
|
|
9
|
+
* injected list are dropped, surviving names get caller casing (Bash) and the
|
|
10
|
+
* Pi find->glob rename restored. Completed tool arguments are additionally
|
|
11
|
+
* repaired against `argSchemas` on the terminal events that carry the whole
|
|
12
|
+
* payload. State is per-stream (no globals); the non-stream path is untouched.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { CaseRestoreMap } from "./fingerprint-tools.ts";
|
|
16
|
+
import { restoreToolNameForCaller, type FindGlobRestore } from "./tool-translation.ts";
|
|
17
|
+
import { repairToolArguments } from "./tool-args.ts";
|
|
18
|
+
import type { ToolcallRepairSink } from "./toolcall-audit.ts";
|
|
19
|
+
import {
|
|
20
|
+
accumulateToolCallName,
|
|
21
|
+
cloakMessagesContent,
|
|
22
|
+
SSE_FIELD_LINE_RE,
|
|
23
|
+
} from "./sse-aggregator.ts";
|
|
24
|
+
|
|
25
|
+
/** Re-export for callers expecting single SSE field-line definition access. */
|
|
26
|
+
export { SSE_FIELD_LINE_RE };
|
|
27
|
+
|
|
28
|
+
/** Detect leading event: prefix on an SSE block. */
|
|
29
|
+
const EVENT_PREFIX_RE = /^\s*event:/m;
|
|
30
|
+
|
|
31
|
+
export interface StreamCloakOptions {
|
|
32
|
+
/** False when the caller declared no tools (chat folds dropped args to text). */
|
|
33
|
+
callerHadTools: boolean;
|
|
34
|
+
/** Lowercase placeholder -> caller casing (Bash), built per request. */
|
|
35
|
+
caseRestore?: CaseRestoreMap;
|
|
36
|
+
/** True when the caller sent Pi `find` (upstream `glob` maps back). */
|
|
37
|
+
findGlob?: FindGlobRestore;
|
|
38
|
+
/** Lowercase names this request injected (only these are ever dropped). */
|
|
39
|
+
injected?: readonly string[];
|
|
40
|
+
/** Request pathname; decides the Responses vs Chat event shapes. */
|
|
41
|
+
pathname: string;
|
|
42
|
+
/** Caller tool schemas for argument repair; undefined/empty means byte-identical. */
|
|
43
|
+
argSchemas?: ReadonlyMap<string, Record<string, unknown>>;
|
|
44
|
+
/** Normalize delta.reasoning to delta.reasoning_content for OpenAI/OMP compatibility. */
|
|
45
|
+
normalizeReasoning?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Per-stream drop tracking (delta events carry no name, only an index/id). */
|
|
49
|
+
export interface SseStreamCloakState {
|
|
50
|
+
responsesDroppedByIndex: Map<number, boolean>;
|
|
51
|
+
responsesDroppedByItemId: Map<string, boolean>;
|
|
52
|
+
messagesDroppedByIndex: Map<number, boolean>;
|
|
53
|
+
/** Output index -> tool name, so nameless argument deltas can find their schema. */
|
|
54
|
+
responsesNameByIndex: Map<number, string>;
|
|
55
|
+
/** Anthropic block index -> tool name, same purpose for input_json deltas. */
|
|
56
|
+
messagesNameByIndex: Map<number, string>;
|
|
57
|
+
/**
|
|
58
|
+
* Argument fragments for Anthropic tool_use blocks whose schema admits a
|
|
59
|
+
* repair, keyed the same way as the name map above. Streaming APIs deliver
|
|
60
|
+
* arguments in fragments, so a malformed packet can only be judged once
|
|
61
|
+
* complete: those fragments are held here and re-emitted, repaired, on the
|
|
62
|
+
* block's terminal event. Tools absent from `argSchemas` are never buffered,
|
|
63
|
+
* so their arguments still stream incrementally exactly as before.
|
|
64
|
+
*/
|
|
65
|
+
messagesArgsByIndex: Map<number, string>;
|
|
66
|
+
/**
|
|
67
|
+
* Chat tool calls tracked PER CALL (defect K1): keyed by the upstream
|
|
68
|
+
* tool-call id whenever the provider sends one (`id:X`), else by the legacy
|
|
69
|
+
* index bucket (`idx:N`). Free models label EVERY parallel call `index: 0`
|
|
70
|
+
* while still sending per-call ids, so index-only keying merged their
|
|
71
|
+
* fragment streams: one call was silently lost and the other received a
|
|
72
|
+
* salvage of the merged junk. Drops, names, buffered arguments and the
|
|
73
|
+
* once-only flush guard are per call; an injected placeholder's drop
|
|
74
|
+
* verdict is never inherited by a real call that reuses its index.
|
|
75
|
+
*/
|
|
76
|
+
chatCalls: Map<string, ChatTrackedCall>;
|
|
77
|
+
/**
|
|
78
|
+
* Observed delta index -> key of the call its id-less fragments currently
|
|
79
|
+
* belong to. Resolution mirrors the aggregate cloak exactly
|
|
80
|
+
* (sseToChatCompletionJson): an id-bearing delta (re)binds the index to its
|
|
81
|
+
* id-call; id-less deltas follow the current owner, else the legacy
|
|
82
|
+
* `idx:N` bucket. Never consulted for host-visible indices — that is what
|
|
83
|
+
* {@link chatIndexClaims} is for.
|
|
84
|
+
*/
|
|
85
|
+
chatIndexOwner: Map<number, string>;
|
|
86
|
+
/** Host-index claims: index -> key of the call that FIRST assigned it. Set once, never rebound. */
|
|
87
|
+
chatIndexClaims: Map<number, string>;
|
|
88
|
+
/**
|
|
89
|
+
* Fresh sequential index handed out when a call's upstream index is already
|
|
90
|
+
* claimed by another tracked call; strictly above every index seen or
|
|
91
|
+
* assigned so far in this stream.
|
|
92
|
+
*/
|
|
93
|
+
chatNextIndex: number;
|
|
94
|
+
/**
|
|
95
|
+
* Per-turn tool-call audit sink for the rewrites this state performs
|
|
96
|
+
* (index collisions, dropped name restatements, withheld arguments, repaired
|
|
97
|
+
* arguments). Absent when the caller did not request an audit.
|
|
98
|
+
*/
|
|
99
|
+
audit?: ToolcallRepairSink;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Streaming state of ONE chat tool call. Exported only because it hangs off
|
|
104
|
+
* the exported per-stream state; treat it as pipe-internal.
|
|
105
|
+
*/
|
|
106
|
+
export interface ChatTrackedCall {
|
|
107
|
+
/** Never emit this call: injected placeholder, or a tool-less caller's call. */
|
|
108
|
+
dropped: boolean;
|
|
109
|
+
/**
|
|
110
|
+
* Restored host-facing name, folded from every name-bearing delta:
|
|
111
|
+
* fragments append, a delta that also carries arguments restates the whole
|
|
112
|
+
* name and replaces (see accumulateToolCallName).
|
|
113
|
+
*/
|
|
114
|
+
name: string | undefined;
|
|
115
|
+
/**
|
|
116
|
+
* True once a name-bearing delta decided this tool is repairable and its
|
|
117
|
+
* fragments must be withheld: the packet is only judgeable once whole, so
|
|
118
|
+
* the fragments re-emerge whole+repaired on the call's terminal event.
|
|
119
|
+
*/
|
|
120
|
+
buffered: boolean;
|
|
121
|
+
/** Buffered argument prefix while {@link buffered} is true ("" until a fragment arrives). */
|
|
122
|
+
args: string;
|
|
123
|
+
/**
|
|
124
|
+
* Host-visible index: the call's FIRST assigned index — its first observed
|
|
125
|
+
* index — unless that upstream index was claimed by another tracked call,
|
|
126
|
+
* in which case a fresh sequential index was handed out in arrival order.
|
|
127
|
+
*/
|
|
128
|
+
hostIndex: number;
|
|
129
|
+
/** Emitted-once guard: a flush entry already went out for this call (terminal or end-of-stream). */
|
|
130
|
+
flushed: boolean;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Map key of a chat tool call that announced an upstream id. */
|
|
134
|
+
export function chatCallIdKey(id: string): string {
|
|
135
|
+
return `id:${id}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Legacy map key of the id-less fragment bucket for one observed delta index. */
|
|
139
|
+
export function chatIndexKey(idx: number): string {
|
|
140
|
+
return `idx:${idx}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createSseStreamCloakState(audit?: ToolcallRepairSink): SseStreamCloakState {
|
|
144
|
+
const state: SseStreamCloakState = {
|
|
145
|
+
responsesDroppedByIndex: new Map(),
|
|
146
|
+
responsesDroppedByItemId: new Map(),
|
|
147
|
+
messagesDroppedByIndex: new Map(),
|
|
148
|
+
responsesNameByIndex: new Map(),
|
|
149
|
+
messagesNameByIndex: new Map(),
|
|
150
|
+
messagesArgsByIndex: new Map(),
|
|
151
|
+
chatCalls: new Map(),
|
|
152
|
+
chatIndexOwner: new Map(),
|
|
153
|
+
chatIndexClaims: new Map(),
|
|
154
|
+
chatNextIndex: 0,
|
|
155
|
+
};
|
|
156
|
+
if (audit !== undefined) state.audit = audit;
|
|
157
|
+
return state;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Emit every tool call still holding buffered arguments, then clear the
|
|
162
|
+
* buffers. The fragments were withheld so the packet could be judged whole
|
|
163
|
+
* (see {@link SseStreamCloakState}); the terminal event that normally releases
|
|
164
|
+
* them never arrived when upstream cut the stream without one, and without
|
|
165
|
+
* this the client would receive a tool call whose `arguments` were deleted and
|
|
166
|
+
* never re-emitted. Repaired exactly as the terminal path repairs.
|
|
167
|
+
*
|
|
168
|
+
* Shape follows the pathname: Chat re-emits one `tool_calls` delta, Anthropic
|
|
169
|
+
* one `input_json_delta` per open block. Responses carries whole arguments on
|
|
170
|
+
* its `.done` event and buffers nothing, so it has nothing to flush.
|
|
171
|
+
* Returns null when nothing is buffered.
|
|
172
|
+
*/
|
|
173
|
+
export function flushBufferedToolArgs(
|
|
174
|
+
state: SseStreamCloakState,
|
|
175
|
+
cloak: StreamCloakOptions,
|
|
176
|
+
): string | null {
|
|
177
|
+
if (state.messagesArgsByIndex.size > 0) {
|
|
178
|
+
const blocks: string[] = [];
|
|
179
|
+
for (const [index, args] of state.messagesArgsByIndex) {
|
|
180
|
+
const name = state.messagesNameByIndex.get(index) ?? "";
|
|
181
|
+
const repaired = repairToolArguments(name, args, cloak.argSchemas);
|
|
182
|
+
if (repaired !== args) state.audit?.repair("arguments-repaired", name);
|
|
183
|
+
blocks.push(
|
|
184
|
+
`event: content_block_delta\ndata: ${JSON.stringify({
|
|
185
|
+
type: "content_block_delta",
|
|
186
|
+
index,
|
|
187
|
+
delta: { type: "input_json_delta", partial_json: repaired },
|
|
188
|
+
})}\n\n`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
state.messagesArgsByIndex.clear();
|
|
192
|
+
state.messagesNameByIndex.clear();
|
|
193
|
+
return blocks.join("");
|
|
194
|
+
}
|
|
195
|
+
const calls: unknown[] = [];
|
|
196
|
+
for (const call of state.chatCalls.values()) {
|
|
197
|
+
// The flushed guard makes this one-shot even when a terminal flush
|
|
198
|
+
// already emitted the call whole earlier in the stream.
|
|
199
|
+
if (!call.buffered || call.flushed) continue;
|
|
200
|
+
const name = call.name;
|
|
201
|
+
if (name === undefined) continue;
|
|
202
|
+
const args = call.args;
|
|
203
|
+
call.flushed = true;
|
|
204
|
+
call.buffered = false;
|
|
205
|
+
call.args = "";
|
|
206
|
+
const repairedArgs = repairToolArguments(name, args, cloak.argSchemas);
|
|
207
|
+
if (repairedArgs !== args) state.audit?.repair("arguments-repaired", name);
|
|
208
|
+
calls.push({ index: call.hostIndex, type: "function", function: { name, arguments: repairedArgs } });
|
|
209
|
+
}
|
|
210
|
+
if (calls.length === 0) return null;
|
|
211
|
+
return `data: ${JSON.stringify({ object: "chat.completion.chunk", choices: [{ index: 0, delta: { tool_calls: calls } }] })}\n\n`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* True when the stream needs no rewrite and must flow byte-identical:
|
|
216
|
+
* nothing injected, no casing to restore, no find->glob rename, and the
|
|
217
|
+
* caller declared tools. Tool-less callers must still cloak on every path
|
|
218
|
+
* when the injected record exists (legacy direct calls keep everything).
|
|
219
|
+
*/
|
|
220
|
+
export function shouldBypassStreamCloak(cloak?: StreamCloakOptions): boolean {
|
|
221
|
+
if (!cloak) return true;
|
|
222
|
+
if (cloak.injected !== undefined && cloak.injected.length > 0) return false;
|
|
223
|
+
if (cloak.caseRestore && Object.keys(cloak.caseRestore).length > 0) return false;
|
|
224
|
+
if (cloak.findGlob?.renamedFindToGlob === true) return false;
|
|
225
|
+
if (cloak.findGlob?.renamed && Object.keys(cloak.findGlob.renamed).length > 0) return false;
|
|
226
|
+
// Tool-less callers must still see tool calls stripped when the
|
|
227
|
+
// injected record exists (legacy direct calls keep everything).
|
|
228
|
+
if (!cloak.callerHadTools && cloak.injected !== undefined) return false;
|
|
229
|
+
// A repairable schema means fragmented arguments must be buffered.
|
|
230
|
+
if (cloak.argSchemas !== undefined && cloak.argSchemas.size > 0) return false;
|
|
231
|
+
if (cloak.normalizeReasoning === true) return false;
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function isInjectedName(name: unknown, injected?: readonly string[]): boolean {
|
|
236
|
+
return typeof name === "string" && injected !== undefined && injected.includes(name.toLowerCase());
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** True when the request declared a schema for this tool, so its arguments can be repaired. */
|
|
240
|
+
export function isRepairableTool(
|
|
241
|
+
name: string,
|
|
242
|
+
schemas: ReadonlyMap<string, Record<string, unknown>> | undefined,
|
|
243
|
+
): boolean {
|
|
244
|
+
return schemas !== undefined && schemas.size > 0 && schemas.has(name.trim().toLowerCase());
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Caller casing first, then the Pi find->glob rename (upstream glob->find). */
|
|
248
|
+
export function restoreStreamName(name: string, cloak: StreamCloakOptions): string {
|
|
249
|
+
const cased = cloak.caseRestore?.[name.toLowerCase()] ?? name;
|
|
250
|
+
return cloak.findGlob ? restoreToolNameForCaller(cased, cloak.findGlob) : cased;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Split a raw SSE block into its event name and data payload (if any). */
|
|
254
|
+
export function splitSseBlock(block: string): { event?: string; data?: string; hasData: boolean } {
|
|
255
|
+
let eventName: string | undefined;
|
|
256
|
+
const dataLines: string[] = [];
|
|
257
|
+
let hasData = false;
|
|
258
|
+
for (const line of block.split(/\r?\n/)) {
|
|
259
|
+
if (line.startsWith("event:")) {
|
|
260
|
+
eventName = line.slice(6).trim();
|
|
261
|
+
} else if (line.startsWith("data:")) {
|
|
262
|
+
hasData = true;
|
|
263
|
+
dataLines.push(line.slice(5).trimStart());
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (!hasData) return { event: eventName, hasData };
|
|
267
|
+
return { event: eventName, data: dataLines.join("\n"), hasData };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Re-emit a rewritten event, preserving the original event/data framing. */
|
|
271
|
+
export function emitSseBlock(event: string | undefined, payload: string, hadEventPrefix: boolean): string {
|
|
272
|
+
if (hadEventPrefix) return `event: ${event ?? ""}\ndata: ${payload}`;
|
|
273
|
+
return `data: ${payload}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Rewrite one Responses SSE block: drop injected function_call items/deltas,
|
|
278
|
+
* restore surviving names, cloak completed/incomplete/failed response objects.
|
|
279
|
+
* Returns null to drop the block, otherwise the (possibly rewritten) block.
|
|
280
|
+
*/
|
|
281
|
+
export function rewriteResponsesBlock(
|
|
282
|
+
block: string,
|
|
283
|
+
state: SseStreamCloakState,
|
|
284
|
+
cloak: StreamCloakOptions,
|
|
285
|
+
): string | null {
|
|
286
|
+
const { event, data, hasData } = splitSseBlock(block);
|
|
287
|
+
if (!hasData || data === undefined || data === "[DONE]") return block;
|
|
288
|
+
let parsed: Record<string, unknown>;
|
|
289
|
+
try {
|
|
290
|
+
const p: unknown = JSON.parse(data);
|
|
291
|
+
if (!p || typeof p !== "object" || Array.isArray(p)) return block;
|
|
292
|
+
parsed = p as Record<string, unknown>;
|
|
293
|
+
} catch {
|
|
294
|
+
return block;
|
|
295
|
+
}
|
|
296
|
+
const type = parsed.type;
|
|
297
|
+
const hadEventPrefix = EVENT_PREFIX_RE.test(block);
|
|
298
|
+
|
|
299
|
+
// New function_call item announced: drop when injected; tool-less callers
|
|
300
|
+
// drop every function_call, never leaking calls downstream. Ids ride
|
|
301
|
+
// verbatim; `output_item.done` carries the whole argument string, so that is
|
|
302
|
+
// where schema repair applies (name-only deltas cannot be repaired yet).
|
|
303
|
+
if (type === "response.output_item.added" || type === "response.output_item.done") {
|
|
304
|
+
const item = parsed.item;
|
|
305
|
+
if (item && typeof item === "object" && !Array.isArray(item)) {
|
|
306
|
+
const rec = item as Record<string, unknown>;
|
|
307
|
+
if (rec.type === "function_call" && typeof rec.name === "string") {
|
|
308
|
+
if (isInjectedName(rec.name, cloak.injected) || !cloak.callerHadTools) {
|
|
309
|
+
const itemId = rec.id ?? rec.call_id;
|
|
310
|
+
if (typeof parsed.output_index === "number") state.responsesDroppedByIndex.set(parsed.output_index, true);
|
|
311
|
+
if (typeof itemId === "string") state.responsesDroppedByItemId.set(itemId, true);
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
const restored = restoreStreamName(rec.name, cloak);
|
|
315
|
+
if (typeof parsed.output_index === "number") state.responsesNameByIndex.set(parsed.output_index, restored);
|
|
316
|
+
if (type === "response.output_item.done") {
|
|
317
|
+
rec.name = restored;
|
|
318
|
+
const originalArgs = typeof rec.arguments === "string" ? rec.arguments : "";
|
|
319
|
+
const repairedArgs = repairToolArguments(restored, originalArgs, cloak.argSchemas);
|
|
320
|
+
if (repairedArgs !== originalArgs) state.audit?.repair("arguments-repaired", restored);
|
|
321
|
+
rec.arguments = repairedArgs;
|
|
322
|
+
return emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
|
|
323
|
+
}
|
|
324
|
+
if (restored !== rec.name) {
|
|
325
|
+
rec.name = restored;
|
|
326
|
+
return emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return block;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Argument deltas carry no name: earlier added/done decisions rule by index/id.
|
|
334
|
+
// Tool-less callers drop every function_call_arguments delta outright. The
|
|
335
|
+
// `.done` event carries the whole `arguments` string, so it is repairable.
|
|
336
|
+
if (type === "response.function_call_arguments.delta" || type === "response.function_call_arguments.done") {
|
|
337
|
+
if (!cloak.callerHadTools) return null;
|
|
338
|
+
const droppedByIndex = typeof parsed.output_index === "number" && state.responsesDroppedByIndex.get(parsed.output_index) === true;
|
|
339
|
+
const droppedById = typeof parsed.item_id === "string" && state.responsesDroppedByItemId.get(parsed.item_id) === true;
|
|
340
|
+
if (droppedByIndex || droppedById) return null;
|
|
341
|
+
if (type === "response.function_call_arguments.done" && typeof parsed.arguments === "string") {
|
|
342
|
+
const name = typeof parsed.output_index === "number" ? state.responsesNameByIndex.get(parsed.output_index) : undefined;
|
|
343
|
+
if (name !== undefined) {
|
|
344
|
+
const repairedArgs = repairToolArguments(name, parsed.arguments, cloak.argSchemas);
|
|
345
|
+
if (repairedArgs !== parsed.arguments) {
|
|
346
|
+
state.audit?.repair("arguments-repaired", name);
|
|
347
|
+
parsed.arguments = repairedArgs;
|
|
348
|
+
return emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return block;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Terminal/fallback objects may still embed injected calls: cloak in place.
|
|
356
|
+
if (
|
|
357
|
+
(type === "response.completed" || type === "response.incomplete" || type === "response.failed") &&
|
|
358
|
+
parsed.response &&
|
|
359
|
+
typeof parsed.response === "object" &&
|
|
360
|
+
!Array.isArray(parsed.response)
|
|
361
|
+
) {
|
|
362
|
+
const before = JSON.stringify((parsed.response as Record<string, unknown>).output);
|
|
363
|
+
cloakStreamResponsesObject(parsed.response, cloak);
|
|
364
|
+
const after = JSON.stringify((parsed.response as Record<string, unknown>).output);
|
|
365
|
+
if (before !== after) return emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
|
|
366
|
+
// Names may have been restored without length change: compare whole object.
|
|
367
|
+
const beforeAll = data;
|
|
368
|
+
const rewritten = JSON.stringify(parsed);
|
|
369
|
+
if (rewritten !== beforeAll) return emitSseBlock(event, rewritten, hadEventPrefix);
|
|
370
|
+
return block;
|
|
371
|
+
}
|
|
372
|
+
return block;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Aggregate-cloak mirror for a Responses object: drop injected (all calls when tool-less), restore kept. */
|
|
376
|
+
export function cloakStreamResponsesObject(resp: unknown, cloak: StreamCloakOptions): void {
|
|
377
|
+
if (!resp || typeof resp !== "object" || Array.isArray(resp)) return;
|
|
378
|
+
const output = (resp as Record<string, unknown>).output;
|
|
379
|
+
if (!Array.isArray(output)) return;
|
|
380
|
+
const kept: unknown[] = [];
|
|
381
|
+
for (const item of output) {
|
|
382
|
+
if (item && typeof item === "object" && !Array.isArray(item)) {
|
|
383
|
+
const rec = item as Record<string, unknown>;
|
|
384
|
+
if (rec.type === "function_call") {
|
|
385
|
+
if (isInjectedName(rec.name, cloak.injected)) continue;
|
|
386
|
+
if (!cloak.callerHadTools) continue;
|
|
387
|
+
if (typeof rec.name === "string") rec.name = restoreStreamName(rec.name, cloak);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
kept.push(item);
|
|
391
|
+
}
|
|
392
|
+
(resp as Record<string, unknown>).output = kept;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Resolve ONE chat tool_calls record to its tracked call, registering the
|
|
397
|
+
* call on first sight.
|
|
398
|
+
*
|
|
399
|
+
* Keying mirrors the aggregate cloak (sseToChatCompletionJson): a record with
|
|
400
|
+
* a non-empty id is keyed `id:<id>`; an id-less record joins whatever call
|
|
401
|
+
* currently OWNS its index (`chatIndexOwner`, which id-bearing deltas
|
|
402
|
+
* rebind), else the legacy `idx:<index>` bucket — so id-less parallel calls
|
|
403
|
+
* sharing `index: 0` still merge into one fragment stream, exactly as
|
|
404
|
+
* before. Host index = the call's first observed index unless that upstream
|
|
405
|
+
* index is ALREADY CLAIMED by another tracked call (`chatIndexClaims`), in
|
|
406
|
+
* which case the next fresh sequential index is handed out in arrival order.
|
|
407
|
+
* The claim deliberately counts dropped calls too: the ticket rule is
|
|
408
|
+
* "claimed by ANOTHER tracked call", and a dropped placeholder has already
|
|
409
|
+
* moved the claim bookkeeping even though it emits nothing.
|
|
410
|
+
*/
|
|
411
|
+
export function chatTrackedCall(
|
|
412
|
+
state: SseStreamCloakState,
|
|
413
|
+
rec: Record<string, unknown>,
|
|
414
|
+
): { call: ChatTrackedCall; key: string; idx: number } {
|
|
415
|
+
const idx = typeof rec.index === "number" ? rec.index : 0;
|
|
416
|
+
if (idx + 1 > state.chatNextIndex) state.chatNextIndex = idx + 1;
|
|
417
|
+
const id = typeof rec.id === "string" && rec.id !== "" ? rec.id : undefined;
|
|
418
|
+
let key: string;
|
|
419
|
+
if (id !== undefined) {
|
|
420
|
+
key = chatCallIdKey(id);
|
|
421
|
+
// Id-bearing deltas (re)bind the index owner; later id-less fragments
|
|
422
|
+
// follow, and a real call reusing an injected placeholder's index takes
|
|
423
|
+
// the connection over instead of inheriting its drop verdict.
|
|
424
|
+
state.chatIndexOwner.set(idx, key);
|
|
425
|
+
} else {
|
|
426
|
+
key = state.chatIndexOwner.get(idx) ?? chatIndexKey(idx);
|
|
427
|
+
}
|
|
428
|
+
const known = state.chatCalls.get(key);
|
|
429
|
+
if (known !== undefined) return { call: known, key, idx };
|
|
430
|
+
// First sight: assign the host-visible index. A claimed upstream index
|
|
431
|
+
// forces a fresh sequential index above every index seen or assigned so
|
|
432
|
+
// far; the fresh index is itself claimed so a later real call keeps clear.
|
|
433
|
+
const claim = state.chatIndexClaims.get(idx);
|
|
434
|
+
let call: ChatTrackedCall;
|
|
435
|
+
if (claim === undefined) {
|
|
436
|
+
state.chatIndexClaims.set(idx, key);
|
|
437
|
+
call = { dropped: false, name: undefined, buffered: false, args: "", hostIndex: idx, flushed: false };
|
|
438
|
+
} else {
|
|
439
|
+
call = { dropped: false, name: undefined, buffered: false, args: "", hostIndex: state.chatNextIndex++, flushed: false };
|
|
440
|
+
state.chatIndexClaims.set(call.hostIndex, key);
|
|
441
|
+
// Audit: two tracked calls claimed one upstream index; the host index was
|
|
442
|
+
// re-homed sequentially so the wire stays well-formed.
|
|
443
|
+
state.audit?.repair("parallel-index-collision", `idx:${idx}`);
|
|
444
|
+
}
|
|
445
|
+
state.chatCalls.set(key, call);
|
|
446
|
+
return { call, key, idx };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Point one EMITTED chat tool_call delta at its call's host index. The
|
|
451
|
+
* rewrite must only ever fire in index-collision cases, so byte-identity
|
|
452
|
+
* holds for every single-call stream: an unchanged index is left untouched,
|
|
453
|
+
* and a record with NO index field (host-defaulted 0) only gains one when
|
|
454
|
+
* the host index is not 0.
|
|
455
|
+
*/
|
|
456
|
+
export function chatRewriteDeltaIndex(rec: Record<string, unknown>, call: ChatTrackedCall): boolean {
|
|
457
|
+
if (typeof rec.index === "number") {
|
|
458
|
+
if (rec.index === call.hostIndex) return false;
|
|
459
|
+
rec.index = call.hostIndex;
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
if (call.hostIndex === 0) return false;
|
|
463
|
+
rec.index = call.hostIndex;
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Terminal chunk for chat choice: absorb any tool-call argument fragment
|
|
469
|
+
* riding the finish_reason chunk into its call's buffered prefix, then
|
|
470
|
+
* re-emit every buffered call's whole argument packet, repaired, at ITS host
|
|
471
|
+
* index. Returns true if delta was mutated.
|
|
472
|
+
*
|
|
473
|
+
* Flush-entry id policy: entries keep today's exact shape
|
|
474
|
+
* {index, type:"function", function:{name,arguments}} WITHOUT the call id.
|
|
475
|
+
* Hosts assemble streamed tool_calls by index, and every delta of the call —
|
|
476
|
+
* including the id-bearing name-delta — is emitted at the same rewritten
|
|
477
|
+
* host index, so the host has already bound the id; the stable shape also
|
|
478
|
+
* keeps the single-call flush byte-identical.
|
|
479
|
+
*/
|
|
480
|
+
export function flushChatTerminalToolCalls(
|
|
481
|
+
d: Record<string, unknown>,
|
|
482
|
+
state: SseStreamCloakState,
|
|
483
|
+
cloak: StreamCloakOptions,
|
|
484
|
+
flushedKeys: Set<string>,
|
|
485
|
+
flushedEntries: Set<Record<string, unknown>>,
|
|
486
|
+
): boolean {
|
|
487
|
+
let rewrote = false;
|
|
488
|
+
|
|
489
|
+
for (const tc of Array.isArray(d.tool_calls) ? d.tool_calls : []) {
|
|
490
|
+
if (!tc || typeof tc !== "object" || Array.isArray(tc)) continue;
|
|
491
|
+
const rec = tc as Record<string, unknown>;
|
|
492
|
+
const { call } = chatTrackedCall(state, rec);
|
|
493
|
+
const fn = rec.function;
|
|
494
|
+
const fnRec = fn && typeof fn === "object" && !Array.isArray(fn) ? (fn as Record<string, unknown>) : null;
|
|
495
|
+
if (call.buffered && !call.flushed && fnRec && typeof fnRec.arguments === "string") {
|
|
496
|
+
call.args += fnRec.arguments;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const flushed: unknown[] = [];
|
|
501
|
+
for (const [key, call] of state.chatCalls) {
|
|
502
|
+
if (!call.buffered || call.flushed) continue;
|
|
503
|
+
const name = call.name;
|
|
504
|
+
if (name === undefined) continue;
|
|
505
|
+
call.flushed = true;
|
|
506
|
+
call.buffered = false;
|
|
507
|
+
const args = call.args;
|
|
508
|
+
call.args = "";
|
|
509
|
+
flushedKeys.add(key);
|
|
510
|
+
const repairedArgs = repairToolArguments(name, args, cloak.argSchemas);
|
|
511
|
+
if (repairedArgs !== args) state.audit?.repair("arguments-repaired", name);
|
|
512
|
+
const entry = { index: call.hostIndex, type: "function", function: { name, arguments: repairedArgs } };
|
|
513
|
+
flushedEntries.add(entry);
|
|
514
|
+
flushed.push(entry);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (flushed.length === 0 && !rewrote) return false;
|
|
518
|
+
|
|
519
|
+
// Records riding the finish chunk whose call was just flushed are removed
|
|
520
|
+
// (their fragments were absorbed above); surviving records keep flowing but
|
|
521
|
+
// are first pointed at their call's host index.
|
|
522
|
+
const remaining = (Array.isArray(d.tool_calls) ? d.tool_calls : []).filter((tc) => {
|
|
523
|
+
if (!tc || typeof tc !== "object" || Array.isArray(tc)) return true;
|
|
524
|
+
const rec = tc as Record<string, unknown>;
|
|
525
|
+
const { call, key } = chatTrackedCall(state, rec);
|
|
526
|
+
if (flushedKeys.has(key)) return false;
|
|
527
|
+
if (chatRewriteDeltaIndex(rec, call)) rewrote = true;
|
|
528
|
+
return true;
|
|
529
|
+
});
|
|
530
|
+
d.tool_calls = [...remaining, ...flushed];
|
|
531
|
+
return true;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Rewrite one Chat Completions SSE block: drop injected delta.tool_calls
|
|
536
|
+
* (including nameless continuations via per-call tracking), restore surviving
|
|
537
|
+
* names, and keep host-visible indices true: collisions on one upstream
|
|
538
|
+
* index (free models label every parallel call index 0) are rewritten to
|
|
539
|
+
* distinct sequential indices while the call stays one call.
|
|
540
|
+
* Tool-less callers lose all tool_calls with arguments folded to content,
|
|
541
|
+
* mirroring the aggregate cloak. Null drops the block.
|
|
542
|
+
*/
|
|
543
|
+
export function rewriteChatBlock(
|
|
544
|
+
block: string,
|
|
545
|
+
state: SseStreamCloakState,
|
|
546
|
+
cloak: StreamCloakOptions,
|
|
547
|
+
): string | null {
|
|
548
|
+
const { data, hasData } = splitSseBlock(block);
|
|
549
|
+
if (!hasData || data === undefined || data === "[DONE]") return block;
|
|
550
|
+
let parsed: Record<string, unknown>;
|
|
551
|
+
try {
|
|
552
|
+
const p: unknown = JSON.parse(data);
|
|
553
|
+
if (!p || typeof p !== "object" || Array.isArray(p)) return block;
|
|
554
|
+
parsed = p as Record<string, unknown>;
|
|
555
|
+
} catch {
|
|
556
|
+
return block;
|
|
557
|
+
}
|
|
558
|
+
if (!Array.isArray(parsed.choices)) return block;
|
|
559
|
+
let changed = false;
|
|
560
|
+
let allDeltasEmpty = true;
|
|
561
|
+
for (const choice of parsed.choices) {
|
|
562
|
+
if (!choice || typeof choice !== "object" || Array.isArray(choice)) continue;
|
|
563
|
+
const c = choice as Record<string, unknown>;
|
|
564
|
+
// Pre-assembled message riding a stream: cloak like the aggregate path.
|
|
565
|
+
if (c.message && typeof c.message === "object" && !Array.isArray(c.message)) {
|
|
566
|
+
cloakStreamChatMessage(c.message, cloak);
|
|
567
|
+
changed = true;
|
|
568
|
+
allDeltasEmpty = false;
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
const delta = c.delta;
|
|
572
|
+
if (!delta || typeof delta !== "object" || Array.isArray(delta)) {
|
|
573
|
+
if (c.finish_reason || c.text || c.logprobs) allDeltasEmpty = false;
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
const d = delta as Record<string, unknown>;
|
|
577
|
+
if (cloak.normalizeReasoning && typeof d.reasoning === "string" && typeof d.reasoning_content !== "string") {
|
|
578
|
+
d.reasoning_content = d.reasoning;
|
|
579
|
+
changed = true;
|
|
580
|
+
allDeltasEmpty = false;
|
|
581
|
+
}
|
|
582
|
+
// Terminal chunk for this choice: re-emit every buffered tool call's whole
|
|
583
|
+
// argument packet, repaired, at its host index. The fragments never went
|
|
584
|
+
// out, so this is the first and only time the client sees them. Runs
|
|
585
|
+
// before the tool_calls branch because the terminal chunk usually carries
|
|
586
|
+
// an empty delta. Call keys flushed on this block: their arguments are
|
|
587
|
+
// final, so the per-delta loop below must not buffer them a second time.
|
|
588
|
+
const flushedKeys = new Set<string>();
|
|
589
|
+
const flushedEntries = new Set<Record<string, unknown>>();
|
|
590
|
+
if (c.finish_reason && flushChatTerminalToolCalls(d, state, cloak, flushedKeys, flushedEntries)) {
|
|
591
|
+
changed = true;
|
|
592
|
+
}
|
|
593
|
+
if (!Array.isArray(d.tool_calls)) {
|
|
594
|
+
if (Object.keys(d).length > 0) allDeltasEmpty = false;
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
const kept: unknown[] = [];
|
|
598
|
+
let folded = "";
|
|
599
|
+
for (const tc of d.tool_calls as unknown[]) {
|
|
600
|
+
if (!tc || typeof tc !== "object" || Array.isArray(tc)) continue;
|
|
601
|
+
// Flush entry rebuilt by the terminal flush above: pure passthrough.
|
|
602
|
+
// These carry no id, so key resolution would misbind them to whichever
|
|
603
|
+
// call currently owns the index — today's code skipped them the same way
|
|
604
|
+
// via the index-keyed flushedIndices set.
|
|
605
|
+
if (flushedEntries.has(tc as Record<string, unknown>)) {
|
|
606
|
+
kept.push(tc);
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
const rec = tc as Record<string, unknown>;
|
|
610
|
+
const { call, key } = chatTrackedCall(state, rec);
|
|
611
|
+
const fn = rec.function;
|
|
612
|
+
const fnRec = fn && typeof fn === "object" && !Array.isArray(fn) ? (fn as Record<string, unknown>) : null;
|
|
613
|
+
const name = fnRec && typeof fnRec.name === "string" && fnRec.name !== "" ? (fnRec.name as string) : "";
|
|
614
|
+
if (!cloak.callerHadTools) {
|
|
615
|
+
call.dropped = true;
|
|
616
|
+
if (fnRec) {
|
|
617
|
+
folded += (typeof fnRec.arguments === "string" && fnRec.arguments) || name;
|
|
618
|
+
}
|
|
619
|
+
changed = true;
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
if (name !== "") {
|
|
623
|
+
if (isInjectedName(name, cloak.injected)) {
|
|
624
|
+
// The verdict is the CALL's, not the index's: a real id-bearing call
|
|
625
|
+
// reusing this index rebinds the fragment owner (chatIndexOwner) and
|
|
626
|
+
// inherits nothing.
|
|
627
|
+
call.dropped = true;
|
|
628
|
+
changed = true;
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
const restored = restoreStreamName(name, cloak);
|
|
632
|
+
// Fold this delta's name into the call's accumulated name: a name-only
|
|
633
|
+
// delta is a fragment (append), a delta that ALSO carries arguments
|
|
634
|
+
// restates the whole name (replace) — same rule as the aggregate cloak,
|
|
635
|
+
// see accumulateToolCallName. When the restated name is the one the host
|
|
636
|
+
// already holds, it is dropped from the wire instead of re-sent: the host
|
|
637
|
+
// concatenates streamed name deltas, so a second copy would hand it
|
|
638
|
+
// `get_weatherget_weather`.
|
|
639
|
+
const previousName = call.name;
|
|
640
|
+
const hasArgs = fnRec !== null && typeof fnRec.arguments === "string" && fnRec.arguments !== "";
|
|
641
|
+
const toolName = accumulateToolCallName(previousName, restored, hasArgs);
|
|
642
|
+
const nameRepeated = hasArgs && previousName !== undefined && toolName === previousName;
|
|
643
|
+
call.name = toolName;
|
|
644
|
+
if (nameRepeated && fnRec) {
|
|
645
|
+
delete fnRec.name;
|
|
646
|
+
changed = true;
|
|
647
|
+
// Audit: the upstream restated a name the host already holds; the copy
|
|
648
|
+
// was kept off the wire so the host does not concatenate it.
|
|
649
|
+
state.audit?.repair("name-restated", toolName);
|
|
650
|
+
} else if (restored !== name && fnRec) {
|
|
651
|
+
fnRec.name = restored;
|
|
652
|
+
changed = true;
|
|
653
|
+
}
|
|
654
|
+
call.dropped = false;
|
|
655
|
+
if (isRepairableTool(toolName, cloak.argSchemas) && !call.flushed && !flushedKeys.has(key)) {
|
|
656
|
+
// Hold this call's arguments: the packet is only judgeable once whole,
|
|
657
|
+
// so it is re-emitted repaired on the terminal finish_reason chunk.
|
|
658
|
+
// Reset-then-absorb mirrors the old per-index buffer exactly.
|
|
659
|
+
if (!call.buffered) {
|
|
660
|
+
state.audit?.repair("arguments-buffered", toolName);
|
|
661
|
+
call.buffered = true;
|
|
662
|
+
call.args = "";
|
|
663
|
+
}
|
|
664
|
+
if (fnRec && typeof fnRec.arguments === "string" && fnRec.arguments !== "") {
|
|
665
|
+
call.args += fnRec.arguments;
|
|
666
|
+
delete fnRec.arguments;
|
|
667
|
+
changed = true;
|
|
668
|
+
}
|
|
669
|
+
if (nameRepeated && fnRec && Object.keys(fnRec).length === 0) {
|
|
670
|
+
// Repeat header whose name and arguments both moved elsewhere: the
|
|
671
|
+
// delta has nothing left to tell the host.
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
kept.push(tc);
|
|
675
|
+
if (chatRewriteDeltaIndex(rec, call)) changed = true;
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
kept.push(tc);
|
|
679
|
+
if (chatRewriteDeltaIndex(rec, call)) changed = true;
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
// Nameless continuation (argument chunk): follows the CALL's verdict.
|
|
683
|
+
if (call.dropped) {
|
|
684
|
+
changed = true;
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
if (call.buffered && fnRec && typeof fnRec.arguments === "string") {
|
|
688
|
+
call.args += fnRec.arguments;
|
|
689
|
+
delete fnRec.arguments;
|
|
690
|
+
changed = true;
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
kept.push(tc);
|
|
694
|
+
if (chatRewriteDeltaIndex(rec, call)) changed = true;
|
|
695
|
+
}
|
|
696
|
+
if (kept.length > 0) {
|
|
697
|
+
d.tool_calls = kept;
|
|
698
|
+
allDeltasEmpty = false;
|
|
699
|
+
} else {
|
|
700
|
+
delete d.tool_calls;
|
|
701
|
+
changed = true;
|
|
702
|
+
if (folded !== "") {
|
|
703
|
+
d.content = `${typeof d.content === "string" ? d.content : ""}${folded}`;
|
|
704
|
+
}
|
|
705
|
+
if (Object.keys(d).length > 0) allDeltasEmpty = false;
|
|
706
|
+
}
|
|
707
|
+
if (Object.keys(d).length === 0 && !c.finish_reason) {
|
|
708
|
+
// Fully emptied delta: contributes nothing downstream.
|
|
709
|
+
} else if (Object.keys(d).length > 0) {
|
|
710
|
+
allDeltasEmpty = false;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
if (allDeltasEmpty && changed) return null;
|
|
714
|
+
if (!changed) return block;
|
|
715
|
+
const hadEventPrefix = EVENT_PREFIX_RE.test(block);
|
|
716
|
+
const { event } = splitSseBlock(block);
|
|
717
|
+
return emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Aggregate-cloak mirror for a Chat message: drop injected, restore kept. */
|
|
721
|
+
export function cloakStreamChatMessage(msg: unknown, cloak: StreamCloakOptions): void {
|
|
722
|
+
if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
|
|
723
|
+
const m = msg as Record<string, unknown>;
|
|
724
|
+
if (!Array.isArray(m.tool_calls)) return;
|
|
725
|
+
const calls = m.tool_calls as unknown[];
|
|
726
|
+
if (!cloak.callerHadTools) {
|
|
727
|
+
if ((!m.content || m.content === "") && calls.length > 0) {
|
|
728
|
+
m.content = calls
|
|
729
|
+
.map((tc) => {
|
|
730
|
+
if (!tc || typeof tc !== "object" || Array.isArray(tc)) return "";
|
|
731
|
+
const fn = (tc as Record<string, unknown>).function;
|
|
732
|
+
if (!fn || typeof fn !== "object" || Array.isArray(fn)) return "";
|
|
733
|
+
const f = fn as Record<string, unknown>;
|
|
734
|
+
return (typeof f.arguments === "string" && f.arguments) || (typeof f.name === "string" && f.name) || "";
|
|
735
|
+
})
|
|
736
|
+
.join("\n");
|
|
737
|
+
}
|
|
738
|
+
delete m.tool_calls;
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
const kept: unknown[] = [];
|
|
742
|
+
for (const tc of calls) {
|
|
743
|
+
let callName = "";
|
|
744
|
+
if (tc && typeof tc === "object" && !Array.isArray(tc)) {
|
|
745
|
+
const fn = (tc as Record<string, unknown>).function;
|
|
746
|
+
if (fn && typeof fn === "object" && !Array.isArray(fn) && typeof (fn as Record<string, unknown>).name === "string") {
|
|
747
|
+
callName = (fn as Record<string, unknown>).name as string;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
if (callName && isInjectedName(callName, cloak.injected)) continue;
|
|
751
|
+
if (tc && typeof tc === "object" && !Array.isArray(tc)) {
|
|
752
|
+
const fn = (tc as Record<string, unknown>).function;
|
|
753
|
+
if (fn && typeof fn === "object" && !Array.isArray(fn) && typeof (fn as Record<string, unknown>).name === "string") {
|
|
754
|
+
(fn as Record<string, unknown>).name = restoreStreamName(
|
|
755
|
+
(fn as Record<string, unknown>).name as string,
|
|
756
|
+
cloak,
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
kept.push(tc);
|
|
761
|
+
}
|
|
762
|
+
if (kept.length > 0) m.tool_calls = kept;
|
|
763
|
+
else delete m.tool_calls;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Rewrite one Anthropic Messages SSE block: drop injected tool_use blocks
|
|
768
|
+
* (including input_json continuations via per-index tracking), restore
|
|
769
|
+
* surviving names. Tool-less callers drop every tool_use, never leaking
|
|
770
|
+
* calls downstream. Null drops the block.
|
|
771
|
+
*/
|
|
772
|
+
export function rewriteMessagesBlock(
|
|
773
|
+
block: string,
|
|
774
|
+
state: SseStreamCloakState,
|
|
775
|
+
cloak: StreamCloakOptions,
|
|
776
|
+
): string | null {
|
|
777
|
+
const { event, data, hasData } = splitSseBlock(block);
|
|
778
|
+
if (!hasData || data === undefined || data === "[DONE]") return block;
|
|
779
|
+
let parsed: Record<string, unknown>;
|
|
780
|
+
try {
|
|
781
|
+
const p: unknown = JSON.parse(data);
|
|
782
|
+
if (!p || typeof p !== "object" || Array.isArray(p)) return block;
|
|
783
|
+
parsed = p as Record<string, unknown>;
|
|
784
|
+
} catch {
|
|
785
|
+
return block;
|
|
786
|
+
}
|
|
787
|
+
const type = typeof parsed.type === "string" ? parsed.type : "";
|
|
788
|
+
const hadEventPrefix = EVENT_PREFIX_RE.test(block);
|
|
789
|
+
const index = typeof parsed.index === "number" ? parsed.index : 0;
|
|
790
|
+
// New content block announced: drop injected tool_use, else restore name.
|
|
791
|
+
if (type === "content_block_start") {
|
|
792
|
+
const cb = parsed.content_block;
|
|
793
|
+
if (cb && typeof cb === "object" && !Array.isArray(cb) && (cb as Record<string, unknown>).type === "tool_use") {
|
|
794
|
+
const rec = cb as Record<string, unknown>;
|
|
795
|
+
if (typeof rec.name === "string" && (isInjectedName(rec.name, cloak.injected) || !cloak.callerHadTools)) {
|
|
796
|
+
state.messagesDroppedByIndex.set(index, true);
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
if (typeof rec.name === "string") {
|
|
800
|
+
const restored = restoreStreamName(rec.name, cloak);
|
|
801
|
+
rec.name = restored;
|
|
802
|
+
state.messagesNameByIndex.set(index, restored);
|
|
803
|
+
if (isRepairableTool(restored, cloak.argSchemas)) {
|
|
804
|
+
if (!state.messagesArgsByIndex.has(index)) state.audit?.repair("arguments-buffered", restored);
|
|
805
|
+
state.messagesArgsByIndex.set(index, typeof rec.input === "string" ? rec.input : "");
|
|
806
|
+
}
|
|
807
|
+
return emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return block;
|
|
811
|
+
}
|
|
812
|
+
// Input deltas carry no name: the start verdict rules by index. Repairable
|
|
813
|
+
// tools buffer their fragments, re-emitted whole on content_block_stop.
|
|
814
|
+
if (type === "content_block_delta") {
|
|
815
|
+
if (state.messagesDroppedByIndex.get(index) === true) return null;
|
|
816
|
+
const delta = parsed.delta as Record<string, unknown> | undefined;
|
|
817
|
+
if (state.messagesArgsByIndex.has(index) && delta && typeof delta.partial_json === "string") {
|
|
818
|
+
state.messagesArgsByIndex.set(index, (state.messagesArgsByIndex.get(index) ?? "") + delta.partial_json);
|
|
819
|
+
return null;
|
|
820
|
+
}
|
|
821
|
+
return block;
|
|
822
|
+
}
|
|
823
|
+
// Block close for a dropped tool_use carries no name: drop it too.
|
|
824
|
+
if (type === "content_block_stop") {
|
|
825
|
+
if (state.messagesDroppedByIndex.get(index) === true) {
|
|
826
|
+
state.messagesDroppedByIndex.delete(index);
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
const buffered = state.messagesArgsByIndex.get(index);
|
|
830
|
+
if (buffered === undefined) return block;
|
|
831
|
+
const name = state.messagesNameByIndex.get(index) ?? "";
|
|
832
|
+
state.messagesArgsByIndex.delete(index);
|
|
833
|
+
state.messagesNameByIndex.delete(index);
|
|
834
|
+
const repaired = repairToolArguments(name, buffered, cloak.argSchemas);
|
|
835
|
+
if (repaired !== buffered) state.audit?.repair("arguments-repaired", name);
|
|
836
|
+
const flushed: Record<string, unknown> = {
|
|
837
|
+
type: "content_block_delta",
|
|
838
|
+
index,
|
|
839
|
+
delta: { type: "input_json_delta", partial_json: repaired },
|
|
840
|
+
};
|
|
841
|
+
const stopBlock = emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
|
|
842
|
+
return `${emitSseBlock("content_block_delta", JSON.stringify(flushed), true)}\n\n${stopBlock}`;
|
|
843
|
+
}
|
|
844
|
+
// A `message` frame carries a whole finished message mid-stream. It needs the
|
|
845
|
+
// same treatment the aggregate path gives it — injected tool_use dropped,
|
|
846
|
+
// caller names restored, inputs repaired — otherwise a placeholder call leaks
|
|
847
|
+
// downstream and a malformed one reaches the host unrepaired. Any block still
|
|
848
|
+
// buffering arguments is completed first so nothing is orphaned.
|
|
849
|
+
if (type === "message") {
|
|
850
|
+
const flushed = flushBufferedToolArgs(state, cloak);
|
|
851
|
+
cloakMessagesContent(parsed.message ?? parsed, cloak.callerHadTools, cloak.caseRestore, cloak.findGlob, cloak.injected, cloak.argSchemas);
|
|
852
|
+
if (flushed === null) return emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
|
|
853
|
+
return `${flushed}${emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix)}`;
|
|
854
|
+
}
|
|
855
|
+
// A tool_use block still holding buffered arguments is completed HERE, while
|
|
856
|
+
// its own event order is intact: `message_delta` / `message_stop` close the
|
|
857
|
+
// message, so the recovered `input_json_delta` has to precede them. (An
|
|
858
|
+
// end-of-stream flush would emit it after `message_stop`, where a strict
|
|
859
|
+
// Anthropic client has already closed the message.)
|
|
860
|
+
if (type === "message_delta" || type === "message_stop") {
|
|
861
|
+
const flushed = flushBufferedToolArgs(state, cloak);
|
|
862
|
+
if (flushed === null) return block;
|
|
863
|
+
return `${flushed}${emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix)}`;
|
|
864
|
+
}
|
|
865
|
+
return block;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Rewrite one raw SSE block for the streaming cloak. Returns null to drop
|
|
870
|
+
* the block, otherwise the block to forward (identical string when no change,
|
|
871
|
+
* so full-inventory streams stay byte-identical).
|
|
872
|
+
*/
|
|
873
|
+
export function rewriteSseBlock(
|
|
874
|
+
block: string,
|
|
875
|
+
state: SseStreamCloakState,
|
|
876
|
+
cloak: StreamCloakOptions,
|
|
877
|
+
): string | null {
|
|
878
|
+
if (block.trim() === "") return block;
|
|
879
|
+
if (cloak.pathname.endsWith("/responses")) return rewriteResponsesBlock(block, state, cloak);
|
|
880
|
+
if (cloak.pathname.endsWith("/messages")) return rewriteMessagesBlock(block, state, cloak);
|
|
881
|
+
return rewriteChatBlock(block, state, cloak);
|
|
882
|
+
}
|