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,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
|
+
}
|