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.
Files changed (46) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +11 -13
  3. package/package.json +1 -1
  4. package/src/catalog.ts +38 -14
  5. package/src/client-recovery.ts +238 -0
  6. package/src/client-staging.ts +316 -0
  7. package/src/client.ts +117 -472
  8. package/src/cline-accounts.ts +26 -1223
  9. package/src/cline-device-auth.ts +5 -3
  10. package/src/cline-pool.ts +691 -0
  11. package/src/cline-roll.ts +642 -0
  12. package/src/commands-cline.ts +283 -0
  13. package/src/commands-relay.ts +808 -0
  14. package/src/commands-startup.ts +264 -0
  15. package/src/commands.ts +119 -1202
  16. package/src/config-paths.ts +254 -0
  17. package/src/config.ts +6 -687
  18. package/src/daemon.ts +16 -1
  19. package/src/deploy.ts +3 -1
  20. package/src/fingerprint-env.ts +334 -0
  21. package/src/fingerprint-tools.ts +348 -0
  22. package/src/index.ts +10 -6
  23. package/src/lane-translation.ts +263 -0
  24. package/src/models.ts +70 -112
  25. package/src/opencode-fingerprint.ts +29 -1310
  26. package/src/opencode-identity.ts +464 -0
  27. package/src/proxy-lanes.ts +1125 -0
  28. package/src/proxy-ports.ts +317 -0
  29. package/src/proxy.ts +74 -1385
  30. package/src/relay-codec.ts +365 -0
  31. package/src/relay-state.ts +10 -367
  32. package/src/relay.ts +78 -31
  33. package/src/sse-aggregator.ts +659 -0
  34. package/src/stream-cloak.ts +882 -0
  35. package/src/stream-pipe.ts +22 -871
  36. package/src/system-ca-fetch.ts +15 -4
  37. package/src/token.ts +7 -2
  38. package/src/tool-args.ts +10 -3
  39. package/src/tool-translation.ts +1 -236
  40. package/src/toolcall-audit.ts +20 -7
  41. package/src/update-checker.ts +17 -1
  42. package/src/update-core.ts +5 -2
  43. package/src/updater.ts +8 -3
  44. package/src/upstream-health.ts +22 -2
  45. package/src/zen-sidecar.ts +1 -0
  46. 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
+ }