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,659 @@
1
+ /**
2
+ * SSE stream aggregator and response cloaking for OpenCode Zen free tier.
3
+ *
4
+ * Converts streaming responses (Chat Completions, Responses, Messages) into
5
+ * single non-streaming JSON responses, cloaking injected placeholder tools and
6
+ * restoring caller names and repairing schema arguments.
7
+ */
8
+
9
+ import { type CaseRestoreMap, restoreCallerName } from "./fingerprint-tools.ts";
10
+ import type { FindGlobRestore } from "./tool-translation.ts";
11
+ import { repairToolArguments, repairToolArgumentsValue } from "./tool-args.ts";
12
+
13
+ /**
14
+ * SSE field line at a line start (`data:`/`event:`), CRLF-tolerant. One source
15
+ * of truth for every "is this actually an event stream?" decision: the body
16
+ * gate in src/stream-pipe.ts, the proxy's aggregate gates, and
17
+ * convertSseToJson's own guard.
18
+ *
19
+ * A substring test is not enough. A plain-JSON 200 whose text merely CONTAINS
20
+ * `data:` — a bash command mentioning `metadata:`, a base64 `data:image/png`
21
+ * blob, `multipart/form-data:` — used to be fed to the SSE aggregator and
22
+ * replaced by an empty synthesized completion (content null, tool calls gone,
23
+ * HTTP 200). Line anchoring keeps such bodies byte-identical. Lives here
24
+ * rather than next to stream-pipe's former SSE_START_RE because that module
25
+ * already imports this one: exporting it from there would close an import
26
+ * cycle.
27
+ */
28
+ export const SSE_FIELD_LINE_RE = /^(data|event):/m;
29
+
30
+ const SSE_BLOCK_SPLIT_RE = /\r?\n\r?\n/;
31
+ const SSE_LINE_SPLIT_RE = /\r?\n/;
32
+
33
+ /** True when `text` carries a real SSE field line at a line start. */
34
+ export function looksLikeSse(text: string): boolean {
35
+ return text.length > 0 && SSE_FIELD_LINE_RE.test(text);
36
+ }
37
+
38
+ /**
39
+ * Parse raw SSE stream text into individual event blocks.
40
+ */
41
+ export function parseSseEvents(text: string): Array<{ event?: string; data: string }> {
42
+ const result: Array<{ event?: string; data: string }> = [];
43
+ const blocks = text.split(SSE_BLOCK_SPLIT_RE);
44
+ for (const block of blocks) {
45
+ const trimmed = block.trim();
46
+ if (!trimmed) continue;
47
+ let eventName: string | undefined;
48
+ const dataLines: string[] = [];
49
+ for (const line of trimmed.split(SSE_LINE_SPLIT_RE)) {
50
+ if (line.startsWith("event:")) {
51
+ eventName = line.slice(6).trim();
52
+ } else if (line.startsWith("data:")) {
53
+ dataLines.push(line.slice(5).trimStart());
54
+ }
55
+ }
56
+ if (dataLines.length > 0) {
57
+ result.push({ event: eventName, data: dataLines.join("\n") });
58
+ }
59
+ }
60
+ return result;
61
+ }
62
+
63
+ /** Tool-call name from a Chat Completions tool_calls entry (else ""). */
64
+ export function chatCallName(tc: unknown): string {
65
+ if (!tc || typeof tc !== "object" || Array.isArray(tc)) return "";
66
+ const fn = (tc as Record<string, unknown>).function;
67
+ if (!fn || typeof fn !== "object" || Array.isArray(fn)) return "";
68
+ const n = (fn as Record<string, unknown>).name;
69
+ return typeof n === "string" ? n : "";
70
+ }
71
+
72
+ /**
73
+ * Strip injected placeholder calls from a Chat Completions message in place.
74
+ * Tool-less callers never see tool_calls (args folded to text when content is
75
+ * empty); callers with tools keep only their own calls, casing restored.
76
+ *
77
+ * Only names this request actually injected are ever cloaked downstream. The
78
+ * caller's own bash is indistinguishable by name and must survive; legacy
79
+ * direct calls (no record) keep everything.
80
+ */
81
+ export function cloakChatMessage(
82
+ msg: unknown,
83
+ callerHadTools: boolean,
84
+ caseRestore?: CaseRestoreMap,
85
+ findGlob?: FindGlobRestore,
86
+ injected?: readonly string[],
87
+ argSchemas?: ReadonlyMap<string, Record<string, unknown>>,
88
+ ): void {
89
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
90
+ const m = msg as Record<string, unknown>;
91
+ if (!Array.isArray(m.tool_calls)) return;
92
+ const calls = m.tool_calls as unknown[];
93
+ if (!callerHadTools) {
94
+ if ((!m.content || m.content === "") && calls.length > 0) {
95
+ m.content = calls
96
+ .map((tc) => {
97
+ if (!tc || typeof tc !== "object" || Array.isArray(tc)) return "";
98
+ const fn = (tc as Record<string, unknown>).function;
99
+ if (!fn || typeof fn !== "object" || Array.isArray(fn)) return "";
100
+ const f = fn as Record<string, unknown>;
101
+ return (typeof f.arguments === "string" && f.arguments) || (typeof f.name === "string" && f.name) || "";
102
+ })
103
+ .join("\n");
104
+ }
105
+ delete m.tool_calls;
106
+ return;
107
+ }
108
+ const kept: unknown[] = [];
109
+ for (const tc of calls) {
110
+ const callName = chatCallName(tc);
111
+ if (callName && injected !== undefined && injected.includes(callName.toLowerCase())) continue;
112
+ if (tc && typeof tc === "object" && !Array.isArray(tc)) {
113
+ const fn = (tc as Record<string, unknown>).function;
114
+ if (fn && typeof fn === "object" && !Array.isArray(fn) && typeof (fn as Record<string, unknown>).name === "string") {
115
+ const f = fn as Record<string, unknown>;
116
+ f.name = restoreCallerName(f.name as string, caseRestore, findGlob);
117
+ f.arguments = repairToolArguments(f.name as string, typeof f.arguments === "string" ? f.arguments : "", argSchemas);
118
+ }
119
+ }
120
+ kept.push(tc);
121
+ }
122
+ if (kept.length > 0) m.tool_calls = kept;
123
+ else delete m.tool_calls;
124
+ }
125
+
126
+ /**
127
+ * Fold one streamed `function.name` delta into the accumulated tool name.
128
+ *
129
+ * Providers stream names in two legitimate shapes, and the difference is the
130
+ * presence of arguments on the same delta:
131
+ *
132
+ * - **Fragment** (`name`, no `arguments`): the ordinary piecewise stream — the
133
+ * delta carries the next slice of the name, so append it.
134
+ * - **Repeat header** (`name` AND `arguments`): OpenCode Zen restates the
135
+ * WHOLE name on the frame that opens the arguments (observed live
136
+ * 2026-10-22: `name:"get_weather"` alone, then `name:"get_weather",
137
+ * arguments:"{…}"`). The name there is authoritative, so replace instead of
138
+ * appending — appending produced `get_weatherget_weather`, a tool name no
139
+ * tool declaration matches. A full mid-stream restatement is the same shape.
140
+ *
141
+ * Shared by both aggregators (the non-streamed completion cloak and the
142
+ * streaming cloak) so streamed and aggregated output always agree.
143
+ */
144
+ export function accumulateToolCallName(
145
+ previous: string | undefined,
146
+ delta: string,
147
+ carriesArguments: boolean,
148
+ ): string {
149
+ return carriesArguments ? delta : `${previous ?? ""}${delta}`;
150
+ }
151
+
152
+ export function sseToChatCompletionJson(
153
+ sseText: string,
154
+ callerHadTools = true,
155
+ caseRestore?: CaseRestoreMap,
156
+ findGlob?: FindGlobRestore,
157
+ injected?: readonly string[],
158
+ argSchemas?: ReadonlyMap<string, Record<string, unknown>>,
159
+ ): Record<string, unknown> {
160
+ const events = parseSseEvents(sseText);
161
+ let id = "chatcmpl-freeflow";
162
+ let model = "";
163
+ let created = Math.floor(Date.now() / 1000);
164
+ let finishReason: string | null = null;
165
+ let content = "";
166
+ let reasoningContent = "";
167
+ let role = "assistant";
168
+ let usage: unknown = undefined;
169
+ // Keyed by tool-call id whenever the provider sends one. Index is NOT
170
+ // trustworthy: free models routinely label every parallel call `index: 0`,
171
+ // and merging their fragments produces one unparseable argument blob
172
+ // (observed live: `{"i":"a","op":"done"}{"i":"b","op":"done"}`). Index is
173
+ // only the fallback for providers that omit an id entirely.
174
+ const toolCallsMap = new Map<
175
+ string,
176
+ { id: string; type: string; function: { name: string; arguments: string } }
177
+ >();
178
+ const keyByIndex = new Map<number, string>();
179
+
180
+ for (const ev of events) {
181
+ if (ev.data === "[DONE]") continue;
182
+ try {
183
+ const parsed = JSON.parse(ev.data);
184
+ if (parsed.id) id = parsed.id;
185
+ if (parsed.model) model = parsed.model;
186
+ if (parsed.created) created = parsed.created;
187
+ if (parsed.usage) usage = parsed.usage;
188
+ if (Array.isArray(parsed.choices)) {
189
+ for (const c of parsed.choices) {
190
+ if (c.finish_reason) finishReason = c.finish_reason;
191
+ const delta = c.delta;
192
+ if (delta) {
193
+ if (delta.role) role = delta.role;
194
+ if (typeof delta.content === "string") content += delta.content;
195
+ if (typeof delta.reasoning_content === "string") {
196
+ reasoningContent += delta.reasoning_content;
197
+ } else if (typeof delta.reasoning === "string") {
198
+ reasoningContent += delta.reasoning;
199
+ }
200
+ if (Array.isArray(delta.tool_calls)) {
201
+ for (const tc of delta.tool_calls) {
202
+ // The first delta of a call carries its id; later deltas carry only
203
+ // `arguments`. A later id-less delta therefore belongs to whatever
204
+ // call this index already opened.
205
+ const idx = typeof tc.index === "number" ? tc.index : 0;
206
+ const hasId = typeof tc.id === "string" && tc.id !== "";
207
+ let key = hasId ? `id:${tc.id}` : keyByIndex.get(idx);
208
+ if (key === undefined) key = `idx:${idx}`;
209
+ if (hasId) keyByIndex.set(idx, key);
210
+ const existing = toolCallsMap.get(key) ?? {
211
+ id: tc.id || "",
212
+ type: tc.type || "function",
213
+ function: { name: "", arguments: "" },
214
+ };
215
+ if (tc.id) existing.id = tc.id;
216
+ if (tc.type) existing.type = tc.type;
217
+ if (tc.function?.name) {
218
+ // See accumulateToolCallName: a name on an arguments-carrying delta
219
+ // is the whole name restated, not another fragment.
220
+ existing.function.name = accumulateToolCallName(
221
+ existing.function.name,
222
+ tc.function.name,
223
+ typeof tc.function.arguments === "string" && tc.function.arguments !== "",
224
+ );
225
+ }
226
+ if (tc.function?.arguments) existing.function.arguments += tc.function.arguments;
227
+ toolCallsMap.set(key, existing);
228
+ }
229
+ }
230
+ }
231
+ // Choice contains a pre-assembled message: cloak before returning.
232
+ if (c.message) {
233
+ cloakChatMessage(c.message, callerHadTools, caseRestore, findGlob, injected, argSchemas);
234
+ return parsed as Record<string, unknown>;
235
+ }
236
+ }
237
+ }
238
+ } catch {
239
+ // ignore unparseable data chunks
240
+ }
241
+ }
242
+
243
+ // Map insertion order is emission order, which is what the host expects. The
244
+ // previous numeric sort assumed the keys were indices; they are now ids.
245
+ const toolCalls = Array.from(toolCallsMap.values());
246
+
247
+ const message: Record<string, unknown> = {
248
+ role,
249
+ content: content || null,
250
+ };
251
+ if (reasoningContent) {
252
+ message.reasoning_content = reasoningContent;
253
+ }
254
+ if (!callerHadTools) {
255
+ if (!content && toolCalls.length > 0) {
256
+ message.content = toolCalls.map((tc) => tc.function.arguments || tc.function.name).join("\n");
257
+ }
258
+ } else {
259
+ const kept = injected === undefined
260
+ ? toolCalls
261
+ : toolCalls.filter((tc) => !injected.includes(tc.function.name.toLowerCase()));
262
+ for (const tc of kept) {
263
+ tc.function.name = restoreCallerName(tc.function.name, caseRestore, findGlob);
264
+ tc.function.arguments = repairToolArguments(tc.function.name, tc.function.arguments, argSchemas);
265
+ }
266
+ if (kept.length > 0) message.tool_calls = kept;
267
+ }
268
+
269
+ return {
270
+ id,
271
+ object: "chat.completion",
272
+ created,
273
+ model,
274
+ choices: [
275
+ {
276
+ index: 0,
277
+ message,
278
+ finish_reason: finishReason ?? "stop",
279
+ },
280
+ ],
281
+ ...(usage ? { usage } : {}),
282
+ };
283
+ }
284
+
285
+ /**
286
+ * Strip injected placeholder function_call items from a Responses object in
287
+ * place. Only names this request injected are removed; caller calls keep
288
+ * restored names (caller casing, upstream glob back to caller find). Tool-less
289
+ * callers never see function_call items: every call is dropped, never leaked.
290
+ * Ids ride verbatim; arguments are repaired against the caller's declared
291
+ * schema when it says the emitted shape cannot be right.
292
+ */
293
+ export function cloakResponsesObject(
294
+ resp: unknown,
295
+ callerHadTools = true,
296
+ caseRestore?: CaseRestoreMap,
297
+ findGlob?: FindGlobRestore,
298
+ injected?: readonly string[],
299
+ argSchemas?: ReadonlyMap<string, Record<string, unknown>>,
300
+ ): void {
301
+ if (!resp || typeof resp !== "object" || Array.isArray(resp)) return;
302
+ const output = (resp as Record<string, unknown>).output;
303
+ if (!Array.isArray(output)) return;
304
+ const kept: unknown[] = [];
305
+ for (const item of output) {
306
+ if (item && typeof item === "object" && !Array.isArray(item)) {
307
+ const rec = item as Record<string, unknown>;
308
+ if (rec.type === "function_call") {
309
+ if (typeof rec.name === "string" && injected !== undefined && injected.includes(rec.name.toLowerCase())) continue;
310
+ if (!callerHadTools) continue;
311
+ if (typeof rec.name === "string") {
312
+ const name = restoreCallerName(rec.name, caseRestore, findGlob);
313
+ rec.name = name;
314
+ rec.arguments = repairToolArguments(name, typeof rec.arguments === "string" ? rec.arguments : "", argSchemas);
315
+ }
316
+ }
317
+ }
318
+ kept.push(item);
319
+ }
320
+ (resp as Record<string, unknown>).output = kept;
321
+ }
322
+
323
+ /**
324
+ * Convert an SSE stream from an OpenAI Responses API endpoint into a single
325
+ * standard non-streaming response object.
326
+ */
327
+ export function sseToResponsesJson(
328
+ sseText: string,
329
+ callerHadTools = true,
330
+ caseRestore?: CaseRestoreMap,
331
+ findGlob?: FindGlobRestore,
332
+ injected?: readonly string[],
333
+ argSchemas?: ReadonlyMap<string, Record<string, unknown>>,
334
+ ): Record<string, unknown> {
335
+ const events = parseSseEvents(sseText);
336
+ // 1. Highest fidelity: response.completed event carries the final full response object
337
+ for (let i = events.length - 1; i >= 0; i--) {
338
+ const ev = events[i];
339
+ try {
340
+ const parsed = JSON.parse(ev.data);
341
+ if (parsed?.type === "response.completed" && parsed.response && typeof parsed.response === "object") {
342
+ cloakResponsesObject(parsed.response, callerHadTools, caseRestore, findGlob, injected, argSchemas);
343
+ return parsed.response as Record<string, unknown>;
344
+ }
345
+ } catch { }
346
+ }
347
+ // 2. Secondary fallback: check any event with a response object
348
+ for (let i = events.length - 1; i >= 0; i--) {
349
+ const ev = events[i];
350
+ try {
351
+ const parsed = JSON.parse(ev.data);
352
+ if (parsed?.type === "response.created" || parsed?.type === "response.in_progress") continue;
353
+ if (parsed?.response && typeof parsed.response === "object") {
354
+ cloakResponsesObject(parsed.response, callerHadTools, caseRestore, findGlob, injected, argSchemas);
355
+ return parsed.response as Record<string, unknown>;
356
+ }
357
+ } catch { }
358
+ }
359
+ // 3. Fallback: parse entire string directly if upstream already returned JSON
360
+ try {
361
+ const parsed = JSON.parse(sseText);
362
+ if (parsed && typeof parsed === "object") {
363
+ cloakResponsesObject(parsed, callerHadTools, caseRestore, findGlob, injected, argSchemas);
364
+ return parsed;
365
+ }
366
+ } catch { }
367
+
368
+ // Nothing in the stream carried a response object: the upstream turn ended
369
+ // without one. Report that honestly — a `completed` answer with zero output
370
+ // reads to the host as "the model chose to say nothing", which is a
371
+ // different (and wrong) claim than "the stream was cut".
372
+ return {
373
+ id: "resp_fallback",
374
+ object: "response",
375
+ status: "incomplete",
376
+ output: [],
377
+ };
378
+ }
379
+
380
+ /**
381
+ * Drop placeholder tool_use blocks from a complete Anthropic message in place.
382
+ * Only names this request injected are removed; caller blocks keep restored
383
+ * names (caller casing, upstream glob back to caller find). Tool-less callers
384
+ * never see tool_use: stray placeholder input folds to text, matching the
385
+ * SSE aggregator fallback below. Ids ride verbatim; `input` is repaired
386
+ * against the caller's declared schema when the emitted shape cannot be right.
387
+ *
388
+ * Exported so the streaming cloak applies these identical rules to a `message`
389
+ * frame that arrives mid-stream, instead of forwarding it verbatim.
390
+ */
391
+ export function cloakMessagesContent(
392
+ msg: unknown,
393
+ callerHadTools: boolean,
394
+ caseRestore?: CaseRestoreMap,
395
+ findGlob?: FindGlobRestore,
396
+ injected?: readonly string[],
397
+ argSchemas?: ReadonlyMap<string, Record<string, unknown>>,
398
+ ): void {
399
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
400
+ const content = (msg as Record<string, unknown>).content;
401
+ if (!Array.isArray(content)) return;
402
+ const kept: unknown[] = [];
403
+ const folded: string[] = [];
404
+ for (const block of content) {
405
+ if (
406
+ block &&
407
+ typeof block === "object" &&
408
+ !Array.isArray(block) &&
409
+ (block as Record<string, unknown>).type === "tool_use"
410
+ ) {
411
+ const rec = block as Record<string, unknown>;
412
+ const isInjected = typeof rec.name === "string" && injected !== undefined && injected.includes(rec.name.toLowerCase());
413
+ if (callerHadTools && !isInjected) {
414
+ if (typeof rec.name === "string") {
415
+ const name = restoreCallerName(rec.name, caseRestore, findGlob);
416
+ rec.name = name;
417
+ rec.input = repairToolArgumentsValue(name, rec.input, argSchemas);
418
+ }
419
+ kept.push(block);
420
+ } else if (!callerHadTools) {
421
+ let text = "";
422
+ if (typeof rec.input === "string") text = rec.input;
423
+ else if (rec.input !== null && typeof rec.input === "object") {
424
+ try {
425
+ text = JSON.stringify(rec.input);
426
+ } catch {
427
+ text = "";
428
+ }
429
+ }
430
+ folded.push(text || (typeof rec.name === "string" ? rec.name : ""));
431
+ }
432
+ continue;
433
+ }
434
+ kept.push(block);
435
+ }
436
+ if (!callerHadTools && kept.every((b) => !(b && typeof b === "object" && !Array.isArray(b) && (b as Record<string, unknown>).type === "text"))) {
437
+ const fallback = folded.filter(Boolean).join("\n");
438
+ if (fallback) kept.push({ type: "text", text: fallback });
439
+ }
440
+ (msg as Record<string, unknown>).content = kept;
441
+ }
442
+
443
+ /**
444
+ * Convert an SSE stream from an Anthropic Messages endpoint into a single
445
+ * standard non-streaming message object. Aggregates content_block deltas
446
+ * (text + tool_use input_json) into content blocks.
447
+ */
448
+ export function sseToMessagesJson(
449
+ sseText: string,
450
+ callerHadTools = true,
451
+ caseRestore?: CaseRestoreMap,
452
+ findGlob?: FindGlobRestore,
453
+ injected?: readonly string[],
454
+ argSchemas?: ReadonlyMap<string, Record<string, unknown>>,
455
+ ): Record<string, unknown> {
456
+ const events = parseSseEvents(sseText);
457
+ let id = "msg_freeflow";
458
+ let model = "";
459
+ let stopReason: string | null = null;
460
+ let usage: unknown = undefined;
461
+ const textByIndex = new Map<number, string>();
462
+ const toolByIndex = new Map<number, { id: string; name: string; inputJson: string }>();
463
+
464
+ for (const ev of events) {
465
+ if (ev.data === "[DONE]") continue;
466
+ let parsed: Record<string, unknown>;
467
+ try {
468
+ parsed = JSON.parse(ev.data) as Record<string, unknown>;
469
+ } catch {
470
+ continue;
471
+ }
472
+ const type = typeof parsed.type === "string" ? parsed.type : "";
473
+ if (type === "message_start") {
474
+ const msg = parsed.message as Record<string, unknown> | undefined;
475
+ if (msg) {
476
+ if (typeof msg.id === "string") id = msg.id;
477
+ if (typeof msg.model === "string") model = msg.model;
478
+ }
479
+ continue;
480
+ }
481
+ if (type === "content_block_start") {
482
+ const index = typeof parsed.index === "number" ? parsed.index : 0;
483
+ const block = parsed.content_block as Record<string, unknown> | undefined;
484
+ const blockType = block && typeof block.type === "string" ? block.type : "";
485
+ if (blockType === "tool_use") {
486
+ toolByIndex.set(index, {
487
+ id: typeof block?.id === "string" ? (block.id as string) : "",
488
+ name: typeof block?.name === "string" ? (block.name as string) : "",
489
+ inputJson: "",
490
+ });
491
+ } else if (!toolByIndex.has(index)) {
492
+ textByIndex.set(index, typeof block?.text === "string" ? (block.text as string) : "");
493
+ }
494
+ continue;
495
+ }
496
+ if (type === "content_block_delta") {
497
+ const index = typeof parsed.index === "number" ? parsed.index : 0;
498
+ const delta = parsed.delta as Record<string, unknown> | undefined;
499
+ const deltaType = delta && typeof delta.type === "string" ? delta.type : "";
500
+ if (deltaType === "text_delta" && typeof delta?.text === "string") {
501
+ textByIndex.set(index, (textByIndex.get(index) ?? "") + (delta.text as string));
502
+ } else if (deltaType === "input_json_delta" && typeof delta?.partial_json === "string") {
503
+ const existing = toolByIndex.get(index) ?? { id: "", name: "", inputJson: "" };
504
+ existing.inputJson += delta.partial_json as string;
505
+ toolByIndex.set(index, existing);
506
+ }
507
+ continue;
508
+ }
509
+ if (type === "message_delta") {
510
+ const delta = parsed.delta as Record<string, unknown> | undefined;
511
+ if (delta && typeof delta.stop_reason === "string") stopReason = delta.stop_reason as string;
512
+ if (parsed.usage !== undefined) usage = parsed.usage;
513
+ continue;
514
+ }
515
+ if (type === "message" && parsed.role !== undefined) {
516
+ cloakMessagesContent(parsed, callerHadTools, caseRestore, findGlob, injected, argSchemas);
517
+ return parsed;
518
+ }
519
+ }
520
+
521
+ const content: Record<string, unknown>[] = [];
522
+ const order = Array.from(new Set([...textByIndex.keys(), ...toolByIndex.keys()])).sort((a, b) => a - b);
523
+ for (const index of order) {
524
+ const tool = toolByIndex.get(index);
525
+ if (tool && (tool.id || tool.name)) {
526
+ if (callerHadTools && (injected === undefined || !injected.includes(tool.name.toLowerCase()))) {
527
+ let input: unknown = {};
528
+ try {
529
+ input = tool.inputJson ? JSON.parse(tool.inputJson) : {};
530
+ } catch {
531
+ input = {};
532
+ }
533
+ const name = restoreCallerName(tool.name, caseRestore, findGlob);
534
+ content.push({ type: "tool_use", id: tool.id, name, input: repairToolArgumentsValue(name, input, argSchemas) });
535
+ }
536
+ continue;
537
+ }
538
+ const text = textByIndex.get(index) ?? "";
539
+ if (text) content.push({ type: "text", text });
540
+ }
541
+ if (!callerHadTools && content.length === 0 && toolByIndex.size > 0) {
542
+ const fallback = Array.from(toolByIndex.values())
543
+ .map((t) => t.inputJson || t.name)
544
+ .join("\n");
545
+ if (fallback) content.push({ type: "text", text: fallback });
546
+ }
547
+
548
+ return {
549
+ id,
550
+ type: "message",
551
+ role: "assistant",
552
+ model,
553
+ content,
554
+ stop_reason: stopReason ?? "end_turn",
555
+ ...(usage ? { usage } : {}),
556
+ };
557
+ }
558
+
559
+ /**
560
+ * Universal SSE-to-JSON aggregator. If input is not SSE, returns it untouched.
561
+ */
562
+ export function convertSseToJson(
563
+ sseText: string,
564
+ pathname: string,
565
+ callerHadTools = true,
566
+ caseRestore?: CaseRestoreMap,
567
+ findGlob?: FindGlobRestore,
568
+ injected?: readonly string[],
569
+ argSchemas?: ReadonlyMap<string, Record<string, unknown>>,
570
+ ): string {
571
+ if (!sseText || typeof sseText !== "string") return sseText;
572
+ const trimmed = sseText.trim();
573
+ if (!looksLikeSse(trimmed)) {
574
+ return sseText;
575
+ }
576
+ try {
577
+ if (pathname.endsWith("/responses")) {
578
+ return JSON.stringify(sseToResponsesJson(trimmed, callerHadTools, caseRestore, findGlob, injected, argSchemas));
579
+ }
580
+ if (pathname.endsWith("/messages")) {
581
+ return JSON.stringify(sseToMessagesJson(trimmed, callerHadTools, caseRestore, findGlob, injected, argSchemas));
582
+ }
583
+ return JSON.stringify(sseToChatCompletionJson(trimmed, callerHadTools, caseRestore, findGlob, injected, argSchemas));
584
+ } catch (error) {
585
+ // Every caller has already committed to `content-type: application/json`
586
+ // by the time this runs, so handing back the raw SSE bytes would deliver a
587
+ // parse error with no diagnosis. Say what happened, and keep the payload
588
+ // reachable under `raw` so nothing is silently destroyed.
589
+ return JSON.stringify({
590
+ error: {
591
+ message: "upstream stream could not be aggregated into a JSON answer",
592
+ detail: String(error).slice(0, 300),
593
+ raw: sseText.slice(0, 2000),
594
+ },
595
+ });
596
+ }
597
+ }
598
+
599
+ /**
600
+ * Repair the tool calls inside an already-JSON answer body.
601
+ *
602
+ * A lane that passes the caller's `stream` through untouched (Kilo) answers a
603
+ * non-streaming caller with a plain `chat.completion` / `response` object, not
604
+ * SSE. The SSE aggregators above never see that body, so the malformed
605
+ * arguments reached the host unrepaired on exactly the requests that asked for
606
+ * no stream. Repairs each call in place and re-serializes only when something
607
+ * actually changed, so a well-formed answer keeps its byte-identical body.
608
+ * Returns the input string untouched when it is not JSON or has no tool calls.
609
+ */
610
+ export function repairJsonToolArguments(
611
+ bodyText: string,
612
+ pathname: string,
613
+ argSchemas: ReadonlyMap<string, Record<string, unknown>> | undefined,
614
+ ): string {
615
+ if (argSchemas === undefined || argSchemas.size === 0 || !bodyText.trimStart().startsWith("{")) return bodyText;
616
+ let parsed: unknown;
617
+ try {
618
+ parsed = JSON.parse(bodyText);
619
+ } catch {
620
+ return bodyText;
621
+ }
622
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return bodyText;
623
+ const body = parsed as Record<string, unknown>;
624
+ let changed = false;
625
+ const repairCall = (name: string, args: string): string => {
626
+ const repaired = repairToolArguments(name, args, argSchemas);
627
+ if (repaired !== args) changed = true;
628
+ return repaired;
629
+ };
630
+ if (pathname.endsWith("/responses")) {
631
+ const output = body.output;
632
+ if (!Array.isArray(output)) return bodyText;
633
+ for (const item of output) {
634
+ if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
635
+ const rec = item as Record<string, unknown>;
636
+ if (rec.type !== "function_call" || typeof rec.name !== "string" || typeof rec.arguments !== "string") continue;
637
+ rec.arguments = repairCall(rec.name, rec.arguments);
638
+ }
639
+ } else {
640
+ const choices = body.choices;
641
+ if (!Array.isArray(choices)) return bodyText;
642
+ for (const choice of choices) {
643
+ if (choice === null || typeof choice !== "object" || Array.isArray(choice)) continue;
644
+ const message = (choice as Record<string, unknown>).message;
645
+ if (message === null || typeof message !== "object" || Array.isArray(message)) continue;
646
+ const calls = (message as Record<string, unknown>).tool_calls;
647
+ if (!Array.isArray(calls)) continue;
648
+ for (const call of calls) {
649
+ if (call === null || typeof call !== "object" || Array.isArray(call)) continue;
650
+ const fn = (call as Record<string, unknown>).function;
651
+ if (fn === null || typeof fn !== "object" || Array.isArray(fn)) continue;
652
+ const fnRec = fn as Record<string, unknown>;
653
+ if (typeof fnRec.name !== "string" || typeof fnRec.arguments !== "string") continue;
654
+ fnRec.arguments = repairCall(fnRec.name, fnRec.arguments);
655
+ }
656
+ }
657
+ }
658
+ return changed ? JSON.stringify(body) : bodyText;
659
+ }