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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OpenCode Zen free-tier client fingerprint enforcement &
|
|
2
|
+
* OpenCode Zen free-tier client fingerprint enforcement & coordinator.
|
|
3
3
|
*
|
|
4
4
|
* Upstream OpenCode Zen (/inference/openai/v1/chat/completions, /inference/v1
|
|
5
5
|
* chat/completions, /inference/v1/responses and /inference/v1/messages)
|
|
@@ -46,658 +46,39 @@
|
|
|
46
46
|
* responses on all three paths; caller casing is restored via a bounded
|
|
47
47
|
* per-request map (never module-global).
|
|
48
48
|
*/
|
|
49
|
+
|
|
49
50
|
import {
|
|
50
|
-
FULL11_TOOL_DEFS,
|
|
51
51
|
FULL11_TOOL_NAMES,
|
|
52
52
|
buildFindGlobRestore,
|
|
53
|
-
restoreToolNameForCaller,
|
|
54
|
-
sanitizeUpstreamToolName,
|
|
55
53
|
translateToolsForPath,
|
|
56
54
|
upstreamToolNameFor,
|
|
57
55
|
type FindGlobRestore,
|
|
58
|
-
type FingerprintToolName,
|
|
59
56
|
} from "./tool-translation.ts";
|
|
60
|
-
import {
|
|
61
|
-
import {
|
|
62
|
-
import
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// gateway answers 400 for every effort level its model does not enumerate.
|
|
84
|
-
const isUltra = effort.toLowerCase() === "ultra";
|
|
85
|
-
const key = (isUltra ? "max" : effort.toLowerCase()) as ThinkingLevel;
|
|
86
|
-
// Alias of a supported level: rewrite, because upstream knows the level, not
|
|
87
|
-
// the alias.
|
|
88
|
-
if (isUltra && key in map && map[key] !== null && map[key] !== undefined) return map[key];
|
|
89
|
-
// Effort is supported (non-null) → no clamp
|
|
90
|
-
if (key in map && map[key] !== null && map[key] !== undefined) return null;
|
|
91
|
-
// Effort key is unknown and not "ultra" → let upstream decide
|
|
92
|
-
if (!(key in map) && !isUltra) return null;
|
|
93
|
-
// Find highest supported effort
|
|
94
|
-
for (const level of EFFORT_DESCENDING) {
|
|
95
|
-
if (map[level] !== null && map[level] !== undefined) return level;
|
|
96
|
-
}
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Safely extract the tool name whether formatted in Chat Completions style
|
|
102
|
-
* ({ function: { name } }) or flat Responses style ({ name }).
|
|
103
|
-
*/
|
|
104
|
-
export function toolNameOf(tool: unknown): string {
|
|
105
|
-
if (!tool || typeof tool !== "object" || Array.isArray(tool)) return "";
|
|
106
|
-
const t = tool as Record<string, unknown>;
|
|
107
|
-
const fn =
|
|
108
|
-
t.function && typeof t.function === "object" && !Array.isArray(t.function)
|
|
109
|
-
? (t.function as Record<string, unknown>)
|
|
110
|
-
: null;
|
|
111
|
-
const raw = typeof t.name === "string" ? t.name : typeof fn?.name === "string" ? fn.name : "";
|
|
112
|
-
return raw.trim();
|
|
113
|
-
}
|
|
57
|
+
import { OPENCODE_MODELS, resolveCanonicalModelId } from "./models.ts";
|
|
58
|
+
import { buildArgRepairSchemas } from "./tool-args.ts";
|
|
59
|
+
import {
|
|
60
|
+
type CaseRestoreMap,
|
|
61
|
+
ZEN_EMPTY_TOOLS_ENV,
|
|
62
|
+
clampChatReasoningEffort,
|
|
63
|
+
enforceAutoToolChoice,
|
|
64
|
+
ensureChatFull11Tools,
|
|
65
|
+
ensureMessagesFull11Tools,
|
|
66
|
+
ensureResponsesFull11Tools,
|
|
67
|
+
normalizePlaceholderCase,
|
|
68
|
+
toolNameOf,
|
|
69
|
+
} from "./fingerprint-tools.ts";
|
|
70
|
+
import {
|
|
71
|
+
applyOpenAiCompatVariant,
|
|
72
|
+
ensureEnvBlock,
|
|
73
|
+
normalizePromptCacheKey,
|
|
74
|
+
normalizeResponsesBody,
|
|
75
|
+
} from "./fingerprint-env.ts";
|
|
76
|
+
|
|
77
|
+
export * from "./fingerprint-tools.ts";
|
|
78
|
+
export * from "./fingerprint-env.ts";
|
|
79
|
+
export * from "./sse-aggregator.ts";
|
|
114
80
|
export type { FindGlobRestore } from "./tool-translation.ts";
|
|
115
81
|
|
|
116
|
-
/** Lowercase roster names the gate expects (live-truth 11-tool roster). */
|
|
117
|
-
const PLACEHOLDER_BY_LOWER_NAME: Record<string, true> = {};
|
|
118
|
-
for (const n of FULL11_TOOL_NAMES) PLACEHOLDER_BY_LOWER_NAME[n.toLowerCase()] = true;
|
|
119
|
-
|
|
120
|
-
/** True for placeholder names (case-insensitive): injected compat tools only. */
|
|
121
|
-
export function isPlaceholderToolName(name: unknown): boolean {
|
|
122
|
-
return typeof name === "string" && PLACEHOLDER_BY_LOWER_NAME[name.toLowerCase()] === true;
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* Bounded per-request restore map: lowercase placeholder name -> caller casing.
|
|
126
|
-
* Built fresh by normalizePlaceholderCase for each enforced request and threaded
|
|
127
|
-
* explicitly through the SSE converters; never module-global.
|
|
128
|
-
*/
|
|
129
|
-
export type CaseRestoreMap = Record<string, string>;
|
|
130
|
-
/** Opt-in empty-tools: "1" skips the 11-injection on tool-less turns; unset → today's 11-injection. */
|
|
131
|
-
export const ZEN_EMPTY_TOOLS_ENV = "PI_FREEFLOW_ZEN_EMPTY_TOOLS";
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Lowercase caller placeholder declarations (Bash -> bash) in place before
|
|
135
|
-
* injection, so the gate sees canonical names and injection stays duplicate-free.
|
|
136
|
-
* Non-placeholder tools (ls, powershell, find, web_search, ...) pass untouched.
|
|
137
|
-
* Returns the bounded restore map for downstream response cloaking.
|
|
138
|
-
*/
|
|
139
|
-
export function normalizePlaceholderCase(body: Record<string, unknown>): CaseRestoreMap {
|
|
140
|
-
const restore: CaseRestoreMap = {};
|
|
141
|
-
if (!Array.isArray(body.tools)) return restore;
|
|
142
|
-
for (const tool of body.tools) {
|
|
143
|
-
if (!tool || typeof tool !== "object" || Array.isArray(tool)) continue;
|
|
144
|
-
const t = tool as Record<string, unknown>;
|
|
145
|
-
const fn =
|
|
146
|
-
t.function && typeof t.function === "object" && !Array.isArray(t.function)
|
|
147
|
-
? (t.function as Record<string, unknown>)
|
|
148
|
-
: null;
|
|
149
|
-
const holder = fn && typeof fn.name === "string" ? fn : typeof t.name === "string" ? t : null;
|
|
150
|
-
if (!holder) continue;
|
|
151
|
-
const original = holder.name as string;
|
|
152
|
-
const lower = original.toLowerCase();
|
|
153
|
-
if (PLACEHOLDER_BY_LOWER_NAME[lower] !== true || original === lower) continue;
|
|
154
|
-
if (restore[lower] === undefined) restore[lower] = original;
|
|
155
|
-
holder.name = lower;
|
|
156
|
-
}
|
|
157
|
-
return restore;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** Restore caller casing on one downstream tool name (bounded map, else verbatim). */
|
|
161
|
-
function restoreName(name: string, restore?: CaseRestoreMap): string {
|
|
162
|
-
if (restore) {
|
|
163
|
-
const hit = restore[name.toLowerCase()];
|
|
164
|
-
if (hit !== undefined) return hit;
|
|
165
|
-
}
|
|
166
|
-
return name;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Full downstream restore: caller casing first, then the translator's
|
|
171
|
-
* find->glob mapping (upstream glob back to caller find when renamed).
|
|
172
|
-
*/
|
|
173
|
-
function restoreCallerName(name: string, caseRestore?: CaseRestoreMap, findGlob?: FindGlobRestore): string {
|
|
174
|
-
const cased = restoreName(name, caseRestore);
|
|
175
|
-
return findGlob ? restoreToolNameForCaller(cased, findGlob) : cased;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Merge the missing live-truth 11-tool roster into a body in its wire shape.
|
|
180
|
-
* Idempotent and case-insensitive; caller tools pass through verbatim.
|
|
181
|
-
* Every slot carries the verbatim CLI definition (edit included — real
|
|
182
|
-
* schema, never a no-op placeholder), and every injected slot stays in the
|
|
183
|
-
* cloak list so model calls to non-caller tools never execute downstream.
|
|
184
|
-
*/
|
|
185
|
-
function full11Present(body: Record<string, unknown>): Set<string> {
|
|
186
|
-
const present = new Set<string>();
|
|
187
|
-
if (Array.isArray(body.tools)) {
|
|
188
|
-
for (const tool of body.tools) {
|
|
189
|
-
const name = toolNameOf(tool);
|
|
190
|
-
if (name) present.add(name.toLowerCase());
|
|
191
|
-
}
|
|
192
|
-
} else {
|
|
193
|
-
body.tools = [];
|
|
194
|
-
}
|
|
195
|
-
return present;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
export function ensureChatFull11Tools(body: Record<string, unknown>): void {
|
|
199
|
-
if (!body || typeof body !== "object") return;
|
|
200
|
-
const present = full11Present(body);
|
|
201
|
-
for (const name of FULL11_TOOL_NAMES) {
|
|
202
|
-
if (present.has(name)) continue;
|
|
203
|
-
const def = FULL11_TOOL_DEFS[name];
|
|
204
|
-
(body.tools as unknown[]).push({
|
|
205
|
-
type: "function",
|
|
206
|
-
function: {
|
|
207
|
-
name,
|
|
208
|
-
description: def.description,
|
|
209
|
-
parameters: { ...(def.parameters as Record<string, unknown>) },
|
|
210
|
-
},
|
|
211
|
-
});
|
|
212
|
-
present.add(name);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
export function ensureResponsesFull11Tools(body: Record<string, unknown>): void {
|
|
217
|
-
if (!body || typeof body !== "object") return;
|
|
218
|
-
const present = full11Present(body);
|
|
219
|
-
for (const name of FULL11_TOOL_NAMES) {
|
|
220
|
-
if (present.has(name)) continue;
|
|
221
|
-
const def = FULL11_TOOL_DEFS[name];
|
|
222
|
-
(body.tools as unknown[]).push({
|
|
223
|
-
type: "function",
|
|
224
|
-
name,
|
|
225
|
-
description: def.description,
|
|
226
|
-
parameters: { ...(def.parameters as Record<string, unknown>) },
|
|
227
|
-
});
|
|
228
|
-
present.add(name);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
export function ensureMessagesFull11Tools(body: Record<string, unknown>): void {
|
|
233
|
-
if (!body || typeof body !== "object") return;
|
|
234
|
-
const present = full11Present(body);
|
|
235
|
-
for (const name of FULL11_TOOL_NAMES) {
|
|
236
|
-
if (present.has(name)) continue;
|
|
237
|
-
const def = FULL11_TOOL_DEFS[name];
|
|
238
|
-
(body.tools as unknown[]).push({
|
|
239
|
-
name,
|
|
240
|
-
description: def.description,
|
|
241
|
-
input_schema: { ...(def.parameters as Record<string, unknown>) },
|
|
242
|
-
});
|
|
243
|
-
present.add(name);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Drop `phase: "commentary"` from assistant message items in the replayed
|
|
249
|
-
* history.
|
|
250
|
-
*
|
|
251
|
-
* Upstream tags the lead-in text of a tool-calling turn `phase: "commentary"`
|
|
252
|
-
* and the closing answer `phase: "final_answer"`. When the model narrates a
|
|
253
|
-
* tool call and emits none (the muse-spark ATEM leak, upstream
|
|
254
|
-
* anomalyco/opencode#44659), that commentary item ends its own turn — and
|
|
255
|
-
* upstream then rejects every replay of the history with `400
|
|
256
|
-
* invalid_request_error: The request contains invalid parameters`.
|
|
257
|
-
*
|
|
258
|
-
* Measured on `muse-spark-1.3-contributor-free`, one field at a time: the
|
|
259
|
-
* phase alone decides it. A terminal commentary item 400s; the same item
|
|
260
|
-
* followed by a function call, or by a `final_answer` item, returns 200; the
|
|
261
|
-
* id prefix and the text are irrelevant. Only the phase field is removed, and
|
|
262
|
-
* `phase` is output metadata the model never reads as instruction, so dropping
|
|
263
|
-
* it cannot change the answer.
|
|
264
|
-
*
|
|
265
|
-
* Every assistant message is swept, not just the turn's last item: upstream
|
|
266
|
-
* validates the whole replayed history, and a host may seat `system`,
|
|
267
|
-
* `developer` or additional-tool items between a commentary item and the next
|
|
268
|
-
* user message, so a tail-only sweep would miss one. One sweep also covers a
|
|
269
|
-
* commentary item left behind by an earlier turn once later turns append to
|
|
270
|
-
* the same history.
|
|
271
|
-
*/
|
|
272
|
-
function stripCommentaryPhase(body: Record<string, unknown>): void {
|
|
273
|
-
const input = body.input;
|
|
274
|
-
if (!Array.isArray(input)) return;
|
|
275
|
-
for (const item of input) {
|
|
276
|
-
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
277
|
-
const record = item as Record<string, unknown>;
|
|
278
|
-
if (record.role === "assistant" && record.phase === "commentary") delete record.phase;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Reduce `tool_choice` to what Zen accepts: `"auto"`, or absent.
|
|
284
|
-
*
|
|
285
|
-
* Upstream rejects every other value with `400 only "auto" is supported for
|
|
286
|
-
* tool_choice` — `"none"`, `"required"`, and named function choices alike
|
|
287
|
-
* (measured live 2026-10-12 on `muse-spark-1.3-contributor-free`, responses and
|
|
288
|
-
* chat). Absent means the same thing as `"auto"`, so an unsupported choice is
|
|
289
|
-
* dropped rather than translated. A choice is never imposed on a host that
|
|
290
|
-
* sent none.
|
|
291
|
-
*
|
|
292
|
-
* Dropping a choice can leave a host's native reasoning switched off: OMP
|
|
293
|
-
* forces its synthetic `think` tool with `reasoning: { effort: "none" }`
|
|
294
|
-
* (captured request 2026-10-12), so removing the forced call without
|
|
295
|
-
* restoring native effort would run the model with no reasoning at all.
|
|
296
|
-
*/
|
|
297
|
-
function enforceAutoToolChoice(body: Record<string, unknown>): void {
|
|
298
|
-
const choice = body.tool_choice;
|
|
299
|
-
if (choice === undefined) return;
|
|
300
|
-
if (typeof choice === "string" && choice.trim().toLowerCase() === "auto") return;
|
|
301
|
-
delete body.tool_choice;
|
|
302
|
-
restoreNativeEffortAfterChoiceDrop(body);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Put native reasoning back when the only thing enabling it was a dropped
|
|
307
|
-
* forced tool call.
|
|
308
|
-
*
|
|
309
|
-
* `"none"` is not a reasoning-off switch upstream, it is an unsupported value:
|
|
310
|
-
* `muse-spark-1.3-contributor-free` answers `400 reasoning_effort 'none' is
|
|
311
|
-
* not supported` (measured 2026-10-12). So the override is replaced with the
|
|
312
|
-
* model's own highest supported level rather than merely deleted, and a model
|
|
313
|
-
* whose declared map supports nothing but `off` gets the field dropped.
|
|
314
|
-
* Genuine reasoning-off (`"off"`) is the caller's real intent, never rewritten.
|
|
315
|
-
*/
|
|
316
|
-
function restoreNativeEffortAfterChoiceDrop(body: Record<string, unknown>): void {
|
|
317
|
-
const model = body.model;
|
|
318
|
-
if (typeof model !== "string") return;
|
|
319
|
-
const replacement = highestSupportedEffort(model);
|
|
320
|
-
if (replacement === null) return;
|
|
321
|
-
const nested = body.reasoning;
|
|
322
|
-
if (nested !== null && typeof nested === "object" && !Array.isArray(nested)) {
|
|
323
|
-
const rec = nested as Record<string, unknown>;
|
|
324
|
-
if (typeof rec.effort === "string" && rec.effort.trim().toLowerCase() === "none") {
|
|
325
|
-
rec.effort = replacement;
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
for (const key of ["reasoning_effort", "reasoningEffort"] as const) {
|
|
330
|
-
const flat = body[key];
|
|
331
|
-
if (typeof flat === "string" && flat.trim().toLowerCase() === "none") {
|
|
332
|
-
body[key] = replacement;
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Highest non-null level a model's declared map supports, or null when the
|
|
340
|
-
* model declares no map or only supports `off` (nothing to fall back to).
|
|
341
|
-
*/
|
|
342
|
-
/**
|
|
343
|
-
* Chat-lane mirror of the responses effort clamp (normalizeResponsesBody):
|
|
344
|
-
* a host targeting the chat path spells the effort flat — `reasoning_effort`
|
|
345
|
-
* or `reasoningEffort` — and the same clampedEffortFor algorithm applies.
|
|
346
|
-
* A level the model's map declares unsupported is clamped to the highest
|
|
347
|
-
* supported one; a supported level or a key the map does not know is left
|
|
348
|
-
* for upstream to rule on; genuine `off` is never rewritten. The Zen
|
|
349
|
-
* messages lane carries no flat effort spelling, so it is not touched.
|
|
350
|
-
*/
|
|
351
|
-
function clampChatReasoningEffort(body: Record<string, unknown>): void {
|
|
352
|
-
const model = body.model;
|
|
353
|
-
if (typeof model !== "string") return;
|
|
354
|
-
for (const key of ["reasoning_effort", "reasoningEffort"] as const) {
|
|
355
|
-
const flat = body[key];
|
|
356
|
-
if (typeof flat !== "string") continue;
|
|
357
|
-
const clamped = clampedEffortFor(model, flat);
|
|
358
|
-
if (clamped !== null) body[key] = clamped;
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
* Highest non-null level a model's declared map supports, or null when the
|
|
364
|
-
* model declares no map or only supports `off` (nothing to fall back to).
|
|
365
|
-
*/
|
|
366
|
-
function highestSupportedEffort(model: string): string | null {
|
|
367
|
-
const map = getModelDef(model)?.thinkingLevelMap;
|
|
368
|
-
if (!map) return null;
|
|
369
|
-
for (const level of EFFORT_DESCENDING) {
|
|
370
|
-
if (map[level] !== null && map[level] !== undefined) return level;
|
|
371
|
-
}
|
|
372
|
-
return null;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* Fold the name of a replayed tool-call item to a shape upstream accepts.
|
|
377
|
-
*
|
|
378
|
-
* A model can emit a malformed call and the host then replays it in `input[]`
|
|
379
|
-
* forever: upstream validates every replayed item, so one call named
|
|
380
|
-
* `id id=` (captured in a live session, emitted by `space-bunny-free`) 400s
|
|
381
|
-
* every subsequent turn of that conversation. Declarations were sanitized
|
|
382
|
-
* already; this covers the calls.
|
|
383
|
-
*/
|
|
384
|
-
function sanitizeReplayItemNames(body: Record<string, unknown>): void {
|
|
385
|
-
const input = body.input;
|
|
386
|
-
if (!Array.isArray(input)) return;
|
|
387
|
-
for (const item of input) {
|
|
388
|
-
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
389
|
-
const record = item as Record<string, unknown>;
|
|
390
|
-
if (record.type !== "function_call" && record.type !== "custom_tool_call") continue;
|
|
391
|
-
const name = record.name;
|
|
392
|
-
if (typeof name !== "string") continue;
|
|
393
|
-
const folded = sanitizeUpstreamToolName(name, new Set());
|
|
394
|
-
if (folded !== name) record.name = folded;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
/**
|
|
399
|
-
* Normalize a Zen Responses body to what upstream accepts. OMP/Pi hosts
|
|
400
|
-
* sometimes carry Chat Completions leftovers on the responses path and
|
|
401
|
-
* upstream rejects them with 400:
|
|
402
|
-
* - `messages` becomes `input` (copied when `input` is absent, dropped either
|
|
403
|
-
* way — the responses API has no `messages` field).
|
|
404
|
-
* - `max_tokens` / `max_completion_tokens` become `max_output_tokens` (copied
|
|
405
|
-
* when absent, dropped either way).
|
|
406
|
-
* - `temperature` / `top_p` are dropped (Responses rejects them).
|
|
407
|
-
* - `response_format` is dropped (Chat Completions field, Responses rejects it).
|
|
408
|
-
* - `parallel_tool_calls: false` is dropped; absent-or-true rides verbatim.
|
|
409
|
-
* - `tool_choice` is auto-or-absent: `"auto"` rides verbatim and everything
|
|
410
|
-
else is dropped, because Zen supports no other value (see
|
|
411
|
-
enforceAutoToolChoice).
|
|
412
|
-
* - `store` defaults to false (never persisted server-side).
|
|
413
|
-
* - Assistant message items lose `phase: "commentary"`; upstream 400s the whole
|
|
414
|
-
request otherwise (see stripCommentaryPhase).
|
|
415
|
-
* - Reasoning effort is clamped per model: when the model's thinkingLevelMap
|
|
416
|
-
* declares the requested effort as null (unsupported), the effort is clamped
|
|
417
|
-
* to the highest supported level. Covers all OpenCode models generically
|
|
418
|
-
* (muse-spark, mimo, nemotron, etc.), not just a single model family.
|
|
419
|
-
* Zen-responses only: chat/messages paths keep their own fields, and Kilo/Cline
|
|
420
|
-
* bodies never reach here (the proxy fingerprints Zen bodies exclusively).
|
|
421
|
-
*/
|
|
422
|
-
export function normalizeResponsesBody(body: Record<string, unknown>): void {
|
|
423
|
-
if (!body || typeof body !== "object") return;
|
|
424
|
-
if (body.input === undefined && Array.isArray(body.messages)) {
|
|
425
|
-
body.input = body.messages;
|
|
426
|
-
}
|
|
427
|
-
delete body.messages;
|
|
428
|
-
stripCommentaryPhase(body);
|
|
429
|
-
// After input[] exists: a replayed call name is only folded once the array is
|
|
430
|
-
// the one upstream will read.
|
|
431
|
-
sanitizeReplayItemNames(body);
|
|
432
|
-
if (body.max_output_tokens === undefined) {
|
|
433
|
-
if (typeof body.max_tokens === "number") body.max_output_tokens = body.max_tokens;
|
|
434
|
-
else if (typeof body.max_completion_tokens === "number") body.max_output_tokens = body.max_completion_tokens;
|
|
435
|
-
}
|
|
436
|
-
delete body.max_tokens;
|
|
437
|
-
delete body.max_completion_tokens;
|
|
438
|
-
delete body.temperature;
|
|
439
|
-
delete body.top_p;
|
|
440
|
-
delete body.response_format;
|
|
441
|
-
if (body.parallel_tool_calls === false) delete body.parallel_tool_calls;
|
|
442
|
-
if (typeof body.model === "string") {
|
|
443
|
-
const reasoning = body.reasoning;
|
|
444
|
-
if (reasoning !== null && typeof reasoning === "object" && !Array.isArray(reasoning)) {
|
|
445
|
-
const effort = (reasoning as Record<string, unknown>).effort;
|
|
446
|
-
if (typeof effort === "string") {
|
|
447
|
-
const clamped = clampedEffortFor(body.model as string, effort);
|
|
448
|
-
if (clamped !== null) (reasoning as Record<string, unknown>).effort = clamped;
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
for (const key of ["reasoning_effort", "reasoningEffort"] as const) {
|
|
452
|
-
const flat = body[key];
|
|
453
|
-
if (typeof flat === "string") {
|
|
454
|
-
const clamped = clampedEffortFor(body.model as string, flat);
|
|
455
|
-
if (clamped !== null) body[key] = clamped;
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
// After the effort clamp: dropping a forced tool call is what triggers the
|
|
460
|
-
// native-effort restore, so it runs once clamping is settled.
|
|
461
|
-
enforceAutoToolChoice(body);
|
|
462
|
-
if (body.store === undefined) body.store = false;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/** Complete `<env>` … `</env>` pair; non-greedy so two blocks stay two matches. */
|
|
466
|
-
const ENV_PAIR_RE = /<env>[\s\S]*?<\/env>/g;
|
|
467
|
-
/** A tag whose partner is missing or was stripped: it can shadow the real pair. */
|
|
468
|
-
const ENV_TAG_RE = /<\/?env>/g;
|
|
469
|
-
/** The gate's load-bearing key. Any value passes; a different key does not. */
|
|
470
|
-
const WORKING_DIRECTORY_RE = /Working directory:[ \t]*([^\n<]*)/;
|
|
471
|
-
/** The gate reads the key, not a usable path: this block is a fingerprint. */
|
|
472
|
-
const FALLBACK_WORKING_DIRECTORY = "(current project)";
|
|
473
|
-
|
|
474
|
-
/** One mutable piece of system-prompt text: the only place a block can live. */
|
|
475
|
-
type SystemText = { read(): string; write(text: string): void };
|
|
476
|
-
|
|
477
|
-
/** `container[key]` as a mutable string segment. */
|
|
478
|
-
function stringSegment(container: Record<string, unknown>, key: string): SystemText {
|
|
479
|
-
return {
|
|
480
|
-
read: () => (typeof container[key] === "string" ? (container[key] as string) : ""),
|
|
481
|
-
write: (text) => {
|
|
482
|
-
container[key] = text;
|
|
483
|
-
},
|
|
484
|
-
};
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/** The text blocks of a parts array (`content[]` / Anthropic `system[]`). */
|
|
488
|
-
function textBlockSegments(blocks: unknown[]): SystemText[] {
|
|
489
|
-
const segments: SystemText[] = [];
|
|
490
|
-
for (const block of blocks) {
|
|
491
|
-
if (!block || typeof block !== "object" || Array.isArray(block)) continue;
|
|
492
|
-
const rec = block as Record<string, unknown>;
|
|
493
|
-
if (typeof rec.text !== "string") continue;
|
|
494
|
-
segments.push({
|
|
495
|
-
read: () => rec.text as string,
|
|
496
|
-
write: (text) => {
|
|
497
|
-
rec.text = text;
|
|
498
|
-
},
|
|
499
|
-
});
|
|
500
|
-
}
|
|
501
|
-
return segments;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/**
|
|
505
|
-
* Text segments of a `content` field: a plain string, or the text blocks of a
|
|
506
|
-
* parts array (Chat Completions spells message content both ways). A parts
|
|
507
|
-
* array with no text block gains one, because there is nowhere else to append.
|
|
508
|
-
*/
|
|
509
|
-
function contentSegments(message: Record<string, unknown>): SystemText[] {
|
|
510
|
-
const content = message.content;
|
|
511
|
-
if (!Array.isArray(content)) {
|
|
512
|
-
if (typeof content !== "string") message.content = "";
|
|
513
|
-
return [stringSegment(message, "content")];
|
|
514
|
-
}
|
|
515
|
-
const segments = textBlockSegments(content);
|
|
516
|
-
if (segments.length > 0) return segments;
|
|
517
|
-
const block: Record<string, unknown> = { type: "text", text: "" };
|
|
518
|
-
content.push(block);
|
|
519
|
-
return [stringSegment(block, "text")];
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
/**
|
|
523
|
-
* Chat Completions system prompt: every `role: "system"` message in order.
|
|
524
|
-
* A body with none gains one at the top.
|
|
525
|
-
*/
|
|
526
|
-
function chatSystemSegments(body: Record<string, unknown>): SystemText[] {
|
|
527
|
-
const existing = body.messages;
|
|
528
|
-
// A non-array messages value carries no system prompt; coerce to an array
|
|
529
|
-
// so the env block still lands (a shape violation either way, but a 429
|
|
530
|
-
// without the block hides the real error).
|
|
531
|
-
if (existing !== undefined && !Array.isArray(existing)) body.messages = [];
|
|
532
|
-
const messages: unknown[] = Array.isArray(body.messages) ? body.messages : ((body.messages = []) as unknown[]);
|
|
533
|
-
const segments: SystemText[] = [];
|
|
534
|
-
for (const message of messages) {
|
|
535
|
-
if (!message || typeof message !== "object" || Array.isArray(message)) continue;
|
|
536
|
-
const rec = message as Record<string, unknown>;
|
|
537
|
-
if (rec.role !== "system") continue;
|
|
538
|
-
segments.push(...contentSegments(rec));
|
|
539
|
-
}
|
|
540
|
-
if (segments.length > 0) return segments;
|
|
541
|
-
const added: Record<string, unknown> = { role: "system", content: "" };
|
|
542
|
-
messages.unshift(added);
|
|
543
|
-
return [stringSegment(added, "content")];
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
/** Anthropic Messages system prompt: `system` is a string or a text-block array. */
|
|
547
|
-
function messagesSystemSegments(body: Record<string, unknown>): SystemText[] {
|
|
548
|
-
const system = body.system;
|
|
549
|
-
if (Array.isArray(system)) {
|
|
550
|
-
const segments = textBlockSegments(system);
|
|
551
|
-
if (segments.length > 0) return segments;
|
|
552
|
-
const block: Record<string, unknown> = { type: "text", text: "" };
|
|
553
|
-
system.push(block);
|
|
554
|
-
return [stringSegment(block, "text")];
|
|
555
|
-
}
|
|
556
|
-
if (typeof system !== "string") body.system = "";
|
|
557
|
-
return [stringSegment(body, "system")];
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
/**
|
|
561
|
-
* Responses system prompt: `instructions`, or the `system` / `developer` items
|
|
562
|
-
* a host seats in `input[]` instead. Real OpenCode clients send `instructions`,
|
|
563
|
-
* so a host that sends neither gets one.
|
|
564
|
-
*/
|
|
565
|
-
function responsesSystemSegments(body: Record<string, unknown>): SystemText[] {
|
|
566
|
-
if (typeof body.instructions === "string") return [stringSegment(body, "instructions")];
|
|
567
|
-
const input = body.input;
|
|
568
|
-
if (Array.isArray(input)) {
|
|
569
|
-
const segments: SystemText[] = [];
|
|
570
|
-
for (const item of input) {
|
|
571
|
-
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
572
|
-
const rec = item as Record<string, unknown>;
|
|
573
|
-
if (rec.role !== "system" && rec.role !== "developer") continue;
|
|
574
|
-
if (typeof rec.content === "string") {
|
|
575
|
-
segments.push(stringSegment(rec, "content"));
|
|
576
|
-
} else if (Array.isArray(rec.content)) {
|
|
577
|
-
// Array-content system/developer items (Responses text-block parts):
|
|
578
|
-
// edit the text blocks in place rather than ignoring the item.
|
|
579
|
-
segments.push(...textBlockSegments(rec.content));
|
|
580
|
-
} else {
|
|
581
|
-
continue;
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
if (segments.length > 0) return segments;
|
|
585
|
-
}
|
|
586
|
-
body.instructions = "";
|
|
587
|
-
return [stringSegment(body, "instructions")];
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
/**
|
|
591
|
-
* Every complete env block in the system text, in wire order. Upstream reads
|
|
592
|
-
* only the FIRST pair, which is why order decides whether a request passes.
|
|
593
|
-
*/
|
|
594
|
-
function envBlocks(segments: SystemText[]): Array<{ segment: number; start: number; end: number; body: string }> {
|
|
595
|
-
const blocks: Array<{ segment: number; start: number; end: number; body: string }> = [];
|
|
596
|
-
segments.forEach((segment, index) => {
|
|
597
|
-
for (const match of segment.read().matchAll(ENV_PAIR_RE)) {
|
|
598
|
-
if (match.index === undefined) continue;
|
|
599
|
-
blocks.push({ segment: index, start: match.index, end: match.index + match[0].length, body: match[0] });
|
|
600
|
-
}
|
|
601
|
-
});
|
|
602
|
-
return blocks;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
/** Drop every env block and stray tag from `text`, keeping one pair verbatim. */
|
|
606
|
-
function stripEnvBlocks(text: string, keep?: { start: number; end: number }): string {
|
|
607
|
-
const strip = (part: string) => part.replace(ENV_PAIR_RE, "").replace(ENV_TAG_RE, "");
|
|
608
|
-
const result =
|
|
609
|
-
keep === undefined
|
|
610
|
-
? strip(text)
|
|
611
|
-
: `${strip(text.slice(0, keep.start))}${text.slice(keep.start, keep.end)}${strip(text.slice(keep.end))}`;
|
|
612
|
-
return result.replace(/\n{3,}/g, "\n\n").trim();
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/** The `Working directory:` value the caller already stated, if any. */
|
|
616
|
-
function statedWorkingDirectory(segments: SystemText[]): string | null {
|
|
617
|
-
for (const segment of segments) {
|
|
618
|
-
const value = WORKING_DIRECTORY_RE.exec(segment.read())?.[1]?.trim();
|
|
619
|
-
if (value) return value;
|
|
620
|
-
}
|
|
621
|
-
return null;
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
/**
|
|
625
|
-
* Build the exact 5-line env block the live CLI sends (v1 1.19.4 MITM, SG):
|
|
626
|
-
* Working directory, Workspace root, Is git, Platform, Today's date.
|
|
627
|
-
* The directory values fall back to the fingerprint constant when the caller
|
|
628
|
-
* stated nothing — the gate reads the keys, not usable paths.
|
|
629
|
-
*/
|
|
630
|
-
export function buildLiveEnvBlock(workingDirectory: string): string {
|
|
631
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
632
|
-
return [
|
|
633
|
-
"<env>",
|
|
634
|
-
` Working directory: ${workingDirectory}`,
|
|
635
|
-
` Workspace root: ${workingDirectory}`,
|
|
636
|
-
" Is git: false",
|
|
637
|
-
` Platform: ${process.platform}`,
|
|
638
|
-
` Today's date: ${today}`,
|
|
639
|
-
"</env>",
|
|
640
|
-
].join("\n");
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
/**
|
|
644
|
-
* Guarantee the free-tier environment gate: exactly one `<env>` … `</env>` pair
|
|
645
|
-
* in the system prompt, with `Working directory:` inside it.
|
|
646
|
-
*
|
|
647
|
-
* Measured 2026-10-24 (issues #117/#118): a body with the block answers 200 and
|
|
648
|
-
* the same body without it answers 429 FreeUsageLimitError, on the same machine
|
|
649
|
-
* within the same minute, with the retry-after countdown still hours away from
|
|
650
|
-
* 00:00 UTC. The countdown is a generic error body, not a quota window, so a
|
|
651
|
-
* host that never emits the block fails on the direct route no matter what
|
|
652
|
-
* else the lane gets right.
|
|
653
|
-
*
|
|
654
|
-
* Two measured traps decide the shape of this function:
|
|
655
|
-
* - Upstream reads only the FIRST pair in the text. A stale or partial copy
|
|
656
|
-
* above the real block shadows it and still 429s, even though the block
|
|
657
|
-
* itself is complete and correct. So a fix must leave exactly one pair, not
|
|
658
|
-
* merely append one.
|
|
659
|
-
* - `Working directory:` is load-bearing. A plausible-looking block carrying
|
|
660
|
-
* `Platform:` or `Shell:` instead is refused; a block carrying only
|
|
661
|
-
* `Working directory:` is accepted. So an existing block without the key is
|
|
662
|
-
* replaced rather than kept and duplicated.
|
|
663
|
-
*
|
|
664
|
-
* A host block that already satisfies both rides through byte-for-byte: the
|
|
665
|
-
* host knows its environment, this module does not. Anything else is folded
|
|
666
|
-
* into the last system message, which upstream accepts on every wire shape.
|
|
667
|
-
*/
|
|
668
|
-
export function ensureEnvBlock(body: Record<string, unknown>, pathname: string): void {
|
|
669
|
-
if (!body || typeof body !== "object") return;
|
|
670
|
-
const segments = pathname.endsWith("/responses")
|
|
671
|
-
? responsesSystemSegments(body)
|
|
672
|
-
: pathname.endsWith("/messages")
|
|
673
|
-
? messagesSystemSegments(body)
|
|
674
|
-
: chatSystemSegments(body);
|
|
675
|
-
if (segments.length === 0) return;
|
|
676
|
-
const blocks = envBlocks(segments);
|
|
677
|
-
const carrying = blocks.find((block) => WORKING_DIRECTORY_RE.test(block.body));
|
|
678
|
-
// Already the single pair upstream reads, with the key inside: leave it be.
|
|
679
|
-
if (carrying !== undefined && blocks.length === 1) return;
|
|
680
|
-
segments.forEach((segment, index) => {
|
|
681
|
-
segment.write(stripEnvBlocks(segment.read(), carrying !== undefined && carrying.segment === index ? carrying : undefined));
|
|
682
|
-
});
|
|
683
|
-
if (carrying !== undefined) return;
|
|
684
|
-
const last = segments[segments.length - 1];
|
|
685
|
-
const text = last.read();
|
|
686
|
-
last.write(`${text ? `${text}\n\n` : ""}${buildLiveEnvBlock(statedWorkingDirectory(segments) ?? FALLBACK_WORKING_DIRECTORY)}`);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
/**
|
|
690
|
-
* Normalize the camelCase `promptCacheKey` alias onto `prompt_cache_key` so
|
|
691
|
-
* every session-key reader sees one spelling. A non-empty snake_case value
|
|
692
|
-
* wins; neither spelling is ever stripped on the Zen lane.
|
|
693
|
-
*/
|
|
694
|
-
export function normalizePromptCacheKey(body: Record<string, unknown>): void {
|
|
695
|
-
const camelKey = body["promptCacheKey"];
|
|
696
|
-
if (typeof camelKey === "string" && camelKey.length > 0 && (typeof body["prompt_cache_key"] !== "string" || (body["prompt_cache_key"] as string).length === 0)) {
|
|
697
|
-
body["prompt_cache_key"] = camelKey;
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
|
|
701
82
|
/**
|
|
702
83
|
* Enforce the full OpenCode free-tier client fingerprint on a parsed request body.
|
|
703
84
|
* Caller placeholders are lowercased (Bash -> bash),
|
|
@@ -740,8 +121,10 @@ export function enforceOpencodeFingerprint(
|
|
|
740
121
|
// Zen takes the bare model id: the live CLI sends `model: <bare>` (e.g.
|
|
741
122
|
// glm-5.3-flash-free), never the `opencode/`-prefixed catalog id the host
|
|
742
123
|
// may carry. Strip one prefix so a prefixed id still routes.
|
|
743
|
-
if (typeof body.model === "string"
|
|
744
|
-
body.model
|
|
124
|
+
if (typeof body.model === "string") {
|
|
125
|
+
const stripped = body.model.startsWith("opencode/") ? body.model.slice("opencode/".length) : body.model;
|
|
126
|
+
const canonical = resolveCanonicalModelId(stripped);
|
|
127
|
+
body.model = OPENCODE_MODELS.some((m) => m.id === canonical) ? canonical : stripped;
|
|
745
128
|
}
|
|
746
129
|
// Zen responses shape conformance first (Chat leftovers, tool_choice, spark
|
|
747
130
|
// effort clamp, store default): Kilo/Cline bodies never reach here.
|
|
@@ -839,667 +222,3 @@ export function enforceOpencodeFingerprint(
|
|
|
839
222
|
|
|
840
223
|
return { clientRequestedStream, callerHadTools, addedTools, caseRestore, findGlob, injected, argSchemas };
|
|
841
224
|
}
|
|
842
|
-
|
|
843
|
-
/**
|
|
844
|
-
* SSE field line at a line start (`data:`/`event:`), CRLF-tolerant. One source
|
|
845
|
-
* of truth for every "is this actually an event stream?" decision: the body
|
|
846
|
-
* gate in src/stream-pipe.ts, the proxy's aggregate gates, and
|
|
847
|
-
* convertSseToJson's own guard.
|
|
848
|
-
*
|
|
849
|
-
* A substring test is not enough. A plain-JSON 200 whose text merely CONTAINS
|
|
850
|
-
* `data:` — a bash command mentioning `metadata:`, a base64 `data:image/png`
|
|
851
|
-
* blob, `multipart/form-data:` — used to be fed to the SSE aggregator and
|
|
852
|
-
* replaced by an empty synthesized completion (content null, tool calls gone,
|
|
853
|
-
* HTTP 200). Line anchoring keeps such bodies byte-identical. Lives here
|
|
854
|
-
* rather than next to stream-pipe's former SSE_START_RE because that module
|
|
855
|
-
* already imports this one: exporting it from there would close an import
|
|
856
|
-
* cycle.
|
|
857
|
-
*/
|
|
858
|
-
const SSE_FIELD_LINE_RE = /^(data|event):/m;
|
|
859
|
-
|
|
860
|
-
/** True when `text` carries a real SSE field line at a line start. */
|
|
861
|
-
export function looksLikeSse(text: string): boolean {
|
|
862
|
-
return text.length > 0 && SSE_FIELD_LINE_RE.test(text);
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
/**
|
|
866
|
-
* Parse raw SSE stream text into individual event blocks.
|
|
867
|
-
*/
|
|
868
|
-
export function parseSseEvents(text: string): Array<{ event?: string; data: string }> {
|
|
869
|
-
const result: Array<{ event?: string; data: string }> = [];
|
|
870
|
-
const blocks = text.split(/\r?\n\r?\n/);
|
|
871
|
-
for (const block of blocks) {
|
|
872
|
-
const trimmed = block.trim();
|
|
873
|
-
if (!trimmed) continue;
|
|
874
|
-
let eventName: string | undefined;
|
|
875
|
-
const dataLines: string[] = [];
|
|
876
|
-
for (const line of trimmed.split(/\r?\n/)) {
|
|
877
|
-
if (line.startsWith("event:")) {
|
|
878
|
-
eventName = line.slice(6).trim();
|
|
879
|
-
} else if (line.startsWith("data:")) {
|
|
880
|
-
dataLines.push(line.slice(5).trimStart());
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
if (dataLines.length > 0) {
|
|
884
|
-
result.push({ event: eventName, data: dataLines.join("\n") });
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
return result;
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
/**
|
|
891
|
-
* Convert an SSE stream from an OpenAI-compatible Chat Completions endpoint
|
|
892
|
-
* into a single standard non-streaming ChatCompletion JSON response.
|
|
893
|
-
*/
|
|
894
|
-
/** Tool-call name from a Chat Completions tool_calls entry (else ""). */
|
|
895
|
-
function chatCallName(tc: unknown): string {
|
|
896
|
-
if (!tc || typeof tc !== "object" || Array.isArray(tc)) return "";
|
|
897
|
-
const fn = (tc as Record<string, unknown>).function;
|
|
898
|
-
if (!fn || typeof fn !== "object" || Array.isArray(fn)) return "";
|
|
899
|
-
const n = (fn as Record<string, unknown>).name;
|
|
900
|
-
return typeof n === "string" ? n : "";
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
/**
|
|
904
|
-
* Strip injected placeholder calls from a Chat Completions message in place.
|
|
905
|
-
* Tool-less callers never see tool_calls (args folded to text when content is
|
|
906
|
-
* empty); callers with tools keep only their own calls, casing restored.
|
|
907
|
-
*/
|
|
908
|
-
/**
|
|
909
|
-
* Only names this request actually injected are ever cloaked downstream. The
|
|
910
|
-
* caller's own bash is indistinguishable by name and must survive; legacy
|
|
911
|
-
* direct calls (no record) keep everything.
|
|
912
|
-
*/
|
|
913
|
-
function cloakChatMessage(
|
|
914
|
-
msg: unknown,
|
|
915
|
-
callerHadTools: boolean,
|
|
916
|
-
caseRestore?: CaseRestoreMap,
|
|
917
|
-
findGlob?: FindGlobRestore,
|
|
918
|
-
injected?: readonly string[],
|
|
919
|
-
argSchemas?: ReadonlyMap<string, Record<string, unknown>>,
|
|
920
|
-
): void {
|
|
921
|
-
if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
|
|
922
|
-
const m = msg as Record<string, unknown>;
|
|
923
|
-
if (!Array.isArray(m.tool_calls)) return;
|
|
924
|
-
const calls = m.tool_calls as unknown[];
|
|
925
|
-
if (!callerHadTools) {
|
|
926
|
-
if ((!m.content || m.content === "") && calls.length > 0) {
|
|
927
|
-
m.content = calls
|
|
928
|
-
.map((tc) => {
|
|
929
|
-
if (!tc || typeof tc !== "object" || Array.isArray(tc)) return "";
|
|
930
|
-
const fn = (tc as Record<string, unknown>).function;
|
|
931
|
-
if (!fn || typeof fn !== "object" || Array.isArray(fn)) return "";
|
|
932
|
-
const f = fn as Record<string, unknown>;
|
|
933
|
-
return (typeof f.arguments === "string" && f.arguments) || (typeof f.name === "string" && f.name) || "";
|
|
934
|
-
})
|
|
935
|
-
.join("\n");
|
|
936
|
-
}
|
|
937
|
-
delete m.tool_calls;
|
|
938
|
-
return;
|
|
939
|
-
}
|
|
940
|
-
const kept: unknown[] = [];
|
|
941
|
-
for (const tc of calls) {
|
|
942
|
-
const callName = chatCallName(tc);
|
|
943
|
-
if (callName && injected !== undefined && injected.includes(callName.toLowerCase())) continue;
|
|
944
|
-
if (tc && typeof tc === "object" && !Array.isArray(tc)) {
|
|
945
|
-
const fn = (tc as Record<string, unknown>).function;
|
|
946
|
-
if (fn && typeof fn === "object" && !Array.isArray(fn) && typeof (fn as Record<string, unknown>).name === "string") {
|
|
947
|
-
const f = fn as Record<string, unknown>;
|
|
948
|
-
f.name = restoreCallerName(f.name as string, caseRestore, findGlob);
|
|
949
|
-
f.arguments = repairToolArguments(f.name as string, typeof f.arguments === "string" ? f.arguments : "", argSchemas);
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
kept.push(tc);
|
|
953
|
-
}
|
|
954
|
-
if (kept.length > 0) m.tool_calls = kept;
|
|
955
|
-
else delete m.tool_calls;
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
/**
|
|
959
|
-
* Fold one streamed `function.name` delta into the accumulated tool name.
|
|
960
|
-
*
|
|
961
|
-
* Providers stream names in two legitimate shapes, and the difference is the
|
|
962
|
-
* presence of arguments on the same delta:
|
|
963
|
-
*
|
|
964
|
-
* - **Fragment** (`name`, no `arguments`): the ordinary piecewise stream — the
|
|
965
|
-
* delta carries the next slice of the name, so append it.
|
|
966
|
-
* - **Repeat header** (`name` AND `arguments`): OpenCode Zen restates the
|
|
967
|
-
* WHOLE name on the frame that opens the arguments (observed live
|
|
968
|
-
* 2026-10-22: `name:"get_weather"` alone, then `name:"get_weather",
|
|
969
|
-
* arguments:"{…}"`). The name there is authoritative, so replace instead of
|
|
970
|
-
* appending — appending produced `get_weatherget_weather`, a tool name no
|
|
971
|
-
* tool declaration matches. A full mid-stream restatement is the same shape.
|
|
972
|
-
*
|
|
973
|
-
* Shared by both aggregators (the non-streamed completion cloak and the
|
|
974
|
-
* streaming cloak) so streamed and aggregated output always agree.
|
|
975
|
-
*/
|
|
976
|
-
export function accumulateToolCallName(
|
|
977
|
-
previous: string | undefined,
|
|
978
|
-
delta: string,
|
|
979
|
-
carriesArguments: boolean,
|
|
980
|
-
): string {
|
|
981
|
-
return carriesArguments ? delta : `${previous ?? ""}${delta}`;
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
export function sseToChatCompletionJson(
|
|
985
|
-
sseText: string,
|
|
986
|
-
callerHadTools = true,
|
|
987
|
-
caseRestore?: CaseRestoreMap,
|
|
988
|
-
findGlob?: FindGlobRestore,
|
|
989
|
-
injected?: readonly string[],
|
|
990
|
-
argSchemas?: ReadonlyMap<string, Record<string, unknown>>,
|
|
991
|
-
): Record<string, unknown> {
|
|
992
|
-
const events = parseSseEvents(sseText);
|
|
993
|
-
let id = "chatcmpl-freeflow";
|
|
994
|
-
let model = "";
|
|
995
|
-
let created = Math.floor(Date.now() / 1000);
|
|
996
|
-
let finishReason: string | null = null;
|
|
997
|
-
let content = "";
|
|
998
|
-
let reasoningContent = "";
|
|
999
|
-
let role = "assistant";
|
|
1000
|
-
let usage: unknown = undefined;
|
|
1001
|
-
// Keyed by tool-call id whenever the provider sends one. Index is NOT
|
|
1002
|
-
// trustworthy: free models routinely label every parallel call `index: 0`,
|
|
1003
|
-
// and merging their fragments produces one unparseable argument blob
|
|
1004
|
-
// (observed live: `{"i":"a","op":"done"}{"i":"b","op":"done"}`). Index is
|
|
1005
|
-
// only the fallback for providers that omit an id entirely.
|
|
1006
|
-
const toolCallsMap = new Map<
|
|
1007
|
-
string,
|
|
1008
|
-
{ id: string; type: string; function: { name: string; arguments: string } }
|
|
1009
|
-
>();
|
|
1010
|
-
const keyByIndex = new Map<number, string>();
|
|
1011
|
-
|
|
1012
|
-
for (const ev of events) {
|
|
1013
|
-
if (ev.data === "[DONE]") continue;
|
|
1014
|
-
try {
|
|
1015
|
-
const parsed = JSON.parse(ev.data);
|
|
1016
|
-
if (parsed.id) id = parsed.id;
|
|
1017
|
-
if (parsed.model) model = parsed.model;
|
|
1018
|
-
if (parsed.created) created = parsed.created;
|
|
1019
|
-
if (parsed.usage) usage = parsed.usage;
|
|
1020
|
-
if (Array.isArray(parsed.choices)) {
|
|
1021
|
-
for (const c of parsed.choices) {
|
|
1022
|
-
if (c.finish_reason) finishReason = c.finish_reason;
|
|
1023
|
-
const delta = c.delta;
|
|
1024
|
-
if (delta) {
|
|
1025
|
-
if (delta.role) role = delta.role;
|
|
1026
|
-
if (typeof delta.content === "string") content += delta.content;
|
|
1027
|
-
if (typeof delta.reasoning_content === "string") {
|
|
1028
|
-
reasoningContent += delta.reasoning_content;
|
|
1029
|
-
} else if (typeof delta.reasoning === "string") {
|
|
1030
|
-
reasoningContent += delta.reasoning;
|
|
1031
|
-
}
|
|
1032
|
-
if (Array.isArray(delta.tool_calls)) {
|
|
1033
|
-
for (const tc of delta.tool_calls) {
|
|
1034
|
-
// The first delta of a call carries its id; later deltas carry only
|
|
1035
|
-
// `arguments`. A later id-less delta therefore belongs to whatever
|
|
1036
|
-
// call this index already opened.
|
|
1037
|
-
const idx = typeof tc.index === "number" ? tc.index : 0;
|
|
1038
|
-
const hasId = typeof tc.id === "string" && tc.id !== "";
|
|
1039
|
-
let key = hasId ? `id:${tc.id}` : keyByIndex.get(idx);
|
|
1040
|
-
if (key === undefined) key = `idx:${idx}`;
|
|
1041
|
-
if (hasId) keyByIndex.set(idx, key);
|
|
1042
|
-
const existing = toolCallsMap.get(key) ?? {
|
|
1043
|
-
id: tc.id || "",
|
|
1044
|
-
type: tc.type || "function",
|
|
1045
|
-
function: { name: "", arguments: "" },
|
|
1046
|
-
};
|
|
1047
|
-
if (tc.id) existing.id = tc.id;
|
|
1048
|
-
if (tc.type) existing.type = tc.type;
|
|
1049
|
-
if (tc.function?.name) {
|
|
1050
|
-
// See accumulateToolCallName: a name on an arguments-carrying delta
|
|
1051
|
-
// is the whole name restated, not another fragment.
|
|
1052
|
-
existing.function.name = accumulateToolCallName(
|
|
1053
|
-
existing.function.name,
|
|
1054
|
-
tc.function.name,
|
|
1055
|
-
typeof tc.function.arguments === "string" && tc.function.arguments !== "",
|
|
1056
|
-
);
|
|
1057
|
-
}
|
|
1058
|
-
if (tc.function?.arguments) existing.function.arguments += tc.function.arguments;
|
|
1059
|
-
toolCallsMap.set(key, existing);
|
|
1060
|
-
}
|
|
1061
|
-
}
|
|
1062
|
-
}
|
|
1063
|
-
// Choice contains a pre-assembled message: cloak before returning.
|
|
1064
|
-
if (c.message) {
|
|
1065
|
-
cloakChatMessage(c.message, callerHadTools, caseRestore, findGlob, injected, argSchemas);
|
|
1066
|
-
return parsed as Record<string, unknown>;
|
|
1067
|
-
}
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
} catch {
|
|
1071
|
-
// ignore unparseable data chunks
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
// Map insertion order is emission order, which is what the host expects. The
|
|
1076
|
-
// previous numeric sort assumed the keys were indices; they are now ids.
|
|
1077
|
-
const toolCalls = Array.from(toolCallsMap.values());
|
|
1078
|
-
|
|
1079
|
-
const message: Record<string, unknown> = {
|
|
1080
|
-
role,
|
|
1081
|
-
content: content || null,
|
|
1082
|
-
};
|
|
1083
|
-
if (reasoningContent) {
|
|
1084
|
-
message.reasoning_content = reasoningContent;
|
|
1085
|
-
}
|
|
1086
|
-
if (!callerHadTools) {
|
|
1087
|
-
if (!content && toolCalls.length > 0) {
|
|
1088
|
-
message.content = toolCalls.map((tc) => tc.function.arguments || tc.function.name).join("\n");
|
|
1089
|
-
}
|
|
1090
|
-
} else {
|
|
1091
|
-
const kept = injected === undefined
|
|
1092
|
-
? toolCalls
|
|
1093
|
-
: toolCalls.filter((tc) => !injected.includes(tc.function.name.toLowerCase()));
|
|
1094
|
-
for (const tc of kept) {
|
|
1095
|
-
tc.function.name = restoreCallerName(tc.function.name, caseRestore, findGlob);
|
|
1096
|
-
tc.function.arguments = repairToolArguments(tc.function.name, tc.function.arguments, argSchemas);
|
|
1097
|
-
}
|
|
1098
|
-
if (kept.length > 0) message.tool_calls = kept;
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
return {
|
|
1102
|
-
id,
|
|
1103
|
-
object: "chat.completion",
|
|
1104
|
-
created,
|
|
1105
|
-
model,
|
|
1106
|
-
choices: [
|
|
1107
|
-
{
|
|
1108
|
-
index: 0,
|
|
1109
|
-
message,
|
|
1110
|
-
finish_reason: finishReason ?? "stop",
|
|
1111
|
-
},
|
|
1112
|
-
],
|
|
1113
|
-
...(usage ? { usage } : {}),
|
|
1114
|
-
};
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
/**
|
|
1118
|
-
* Strip injected placeholder function_call items from a Responses object in
|
|
1119
|
-
* place. Only names this request injected are removed; caller calls keep
|
|
1120
|
-
* restored names (caller casing, upstream glob back to caller find). Tool-less
|
|
1121
|
-
* callers never see function_call items: every call is dropped, never leaked.
|
|
1122
|
-
* Ids ride verbatim; arguments are repaired against the caller's declared
|
|
1123
|
-
* schema when it says the emitted shape cannot be right.
|
|
1124
|
-
*/
|
|
1125
|
-
function cloakResponsesObject(
|
|
1126
|
-
resp: unknown,
|
|
1127
|
-
callerHadTools = true,
|
|
1128
|
-
caseRestore?: CaseRestoreMap,
|
|
1129
|
-
findGlob?: FindGlobRestore,
|
|
1130
|
-
injected?: readonly string[],
|
|
1131
|
-
argSchemas?: ReadonlyMap<string, Record<string, unknown>>,
|
|
1132
|
-
): void {
|
|
1133
|
-
if (!resp || typeof resp !== "object" || Array.isArray(resp)) return;
|
|
1134
|
-
const output = (resp as Record<string, unknown>).output;
|
|
1135
|
-
if (!Array.isArray(output)) return;
|
|
1136
|
-
const kept: unknown[] = [];
|
|
1137
|
-
for (const item of output) {
|
|
1138
|
-
if (item && typeof item === "object" && !Array.isArray(item)) {
|
|
1139
|
-
const rec = item as Record<string, unknown>;
|
|
1140
|
-
if (rec.type === "function_call") {
|
|
1141
|
-
if (typeof rec.name === "string" && injected !== undefined && injected.includes(rec.name.toLowerCase())) continue;
|
|
1142
|
-
if (!callerHadTools) continue;
|
|
1143
|
-
if (typeof rec.name === "string") {
|
|
1144
|
-
const name = restoreCallerName(rec.name, caseRestore, findGlob);
|
|
1145
|
-
rec.name = name;
|
|
1146
|
-
rec.arguments = repairToolArguments(name, typeof rec.arguments === "string" ? rec.arguments : "", argSchemas);
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
kept.push(item);
|
|
1151
|
-
}
|
|
1152
|
-
(resp as Record<string, unknown>).output = kept;
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
/**
|
|
1156
|
-
* Convert an SSE stream from an OpenAI Responses API endpoint into a single
|
|
1157
|
-
* standard non-streaming response object.
|
|
1158
|
-
*/
|
|
1159
|
-
export function sseToResponsesJson(
|
|
1160
|
-
sseText: string,
|
|
1161
|
-
callerHadTools = true,
|
|
1162
|
-
caseRestore?: CaseRestoreMap,
|
|
1163
|
-
findGlob?: FindGlobRestore,
|
|
1164
|
-
injected?: readonly string[],
|
|
1165
|
-
argSchemas?: ReadonlyMap<string, Record<string, unknown>>,
|
|
1166
|
-
): Record<string, unknown> {
|
|
1167
|
-
const events = parseSseEvents(sseText);
|
|
1168
|
-
// 1. Highest fidelity: response.completed event carries the final full response object
|
|
1169
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
1170
|
-
const ev = events[i];
|
|
1171
|
-
try {
|
|
1172
|
-
const parsed = JSON.parse(ev.data);
|
|
1173
|
-
if (parsed?.type === "response.completed" && parsed.response && typeof parsed.response === "object") {
|
|
1174
|
-
cloakResponsesObject(parsed.response, callerHadTools, caseRestore, findGlob, injected, argSchemas);
|
|
1175
|
-
return parsed.response as Record<string, unknown>;
|
|
1176
|
-
}
|
|
1177
|
-
} catch { }
|
|
1178
|
-
}
|
|
1179
|
-
// 2. Secondary fallback: check any event with a response object
|
|
1180
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
1181
|
-
const ev = events[i];
|
|
1182
|
-
try {
|
|
1183
|
-
const parsed = JSON.parse(ev.data);
|
|
1184
|
-
if (parsed?.response && typeof parsed.response === "object") {
|
|
1185
|
-
cloakResponsesObject(parsed.response, callerHadTools, caseRestore, findGlob, injected, argSchemas);
|
|
1186
|
-
return parsed.response as Record<string, unknown>;
|
|
1187
|
-
}
|
|
1188
|
-
} catch { }
|
|
1189
|
-
}
|
|
1190
|
-
// 3. Fallback: parse entire string directly if upstream already returned JSON
|
|
1191
|
-
try {
|
|
1192
|
-
const parsed = JSON.parse(sseText);
|
|
1193
|
-
if (parsed && typeof parsed === "object") {
|
|
1194
|
-
cloakResponsesObject(parsed, callerHadTools, caseRestore, findGlob, injected, argSchemas);
|
|
1195
|
-
return parsed;
|
|
1196
|
-
}
|
|
1197
|
-
} catch { }
|
|
1198
|
-
|
|
1199
|
-
// Nothing in the stream carried a response object: the upstream turn ended
|
|
1200
|
-
// without one. Report that honestly — a `completed` answer with zero output
|
|
1201
|
-
// reads to the host as "the model chose to say nothing", which is a
|
|
1202
|
-
// different (and wrong) claim than "the stream was cut".
|
|
1203
|
-
return {
|
|
1204
|
-
id: "resp_fallback",
|
|
1205
|
-
object: "response",
|
|
1206
|
-
status: "incomplete",
|
|
1207
|
-
output: [],
|
|
1208
|
-
};
|
|
1209
|
-
}
|
|
1210
|
-
/**
|
|
1211
|
-
* Drop placeholder tool_use blocks from a complete Anthropic message in place.
|
|
1212
|
-
* Only names this request injected are removed; caller blocks keep restored
|
|
1213
|
-
* names (caller casing, upstream glob back to caller find). Tool-less callers
|
|
1214
|
-
* never see tool_use: stray placeholder input folds to text, matching the
|
|
1215
|
-
* SSE aggregator fallback below. Ids ride verbatim; `input` is repaired
|
|
1216
|
-
* against the caller's declared schema when the emitted shape cannot be right.
|
|
1217
|
-
*
|
|
1218
|
-
* Exported so the streaming cloak applies these identical rules to a `message`
|
|
1219
|
-
* frame that arrives mid-stream, instead of forwarding it verbatim.
|
|
1220
|
-
*/
|
|
1221
|
-
export function cloakMessagesContent(
|
|
1222
|
-
msg: unknown,
|
|
1223
|
-
callerHadTools: boolean,
|
|
1224
|
-
caseRestore?: CaseRestoreMap,
|
|
1225
|
-
findGlob?: FindGlobRestore,
|
|
1226
|
-
injected?: readonly string[],
|
|
1227
|
-
argSchemas?: ReadonlyMap<string, Record<string, unknown>>,
|
|
1228
|
-
): void {
|
|
1229
|
-
if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
|
|
1230
|
-
const content = (msg as Record<string, unknown>).content;
|
|
1231
|
-
if (!Array.isArray(content)) return;
|
|
1232
|
-
const kept: unknown[] = [];
|
|
1233
|
-
const folded: string[] = [];
|
|
1234
|
-
for (const block of content) {
|
|
1235
|
-
if (
|
|
1236
|
-
block &&
|
|
1237
|
-
typeof block === "object" &&
|
|
1238
|
-
!Array.isArray(block) &&
|
|
1239
|
-
(block as Record<string, unknown>).type === "tool_use"
|
|
1240
|
-
) {
|
|
1241
|
-
const rec = block as Record<string, unknown>;
|
|
1242
|
-
const isInjected = typeof rec.name === "string" && injected !== undefined && injected.includes(rec.name.toLowerCase());
|
|
1243
|
-
if (callerHadTools && !isInjected) {
|
|
1244
|
-
if (typeof rec.name === "string") {
|
|
1245
|
-
const name = restoreCallerName(rec.name, caseRestore, findGlob);
|
|
1246
|
-
rec.name = name;
|
|
1247
|
-
rec.input = repairToolArgumentsValue(name, rec.input, argSchemas);
|
|
1248
|
-
}
|
|
1249
|
-
kept.push(block);
|
|
1250
|
-
} else if (!callerHadTools) {
|
|
1251
|
-
let text = "";
|
|
1252
|
-
if (typeof rec.input === "string") text = rec.input;
|
|
1253
|
-
else if (rec.input !== null && typeof rec.input === "object") {
|
|
1254
|
-
try {
|
|
1255
|
-
text = JSON.stringify(rec.input);
|
|
1256
|
-
} catch {
|
|
1257
|
-
text = "";
|
|
1258
|
-
}
|
|
1259
|
-
}
|
|
1260
|
-
folded.push(text || (typeof rec.name === "string" ? rec.name : ""));
|
|
1261
|
-
}
|
|
1262
|
-
continue;
|
|
1263
|
-
}
|
|
1264
|
-
kept.push(block);
|
|
1265
|
-
}
|
|
1266
|
-
if (!callerHadTools && kept.every((b) => !(b && typeof b === "object" && !Array.isArray(b) && (b as Record<string, unknown>).type === "text"))) {
|
|
1267
|
-
const fallback = folded.filter(Boolean).join("\n");
|
|
1268
|
-
if (fallback) kept.push({ type: "text", text: fallback });
|
|
1269
|
-
}
|
|
1270
|
-
(msg as Record<string, unknown>).content = kept;
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
/**
|
|
1274
|
-
* Convert an SSE stream from an Anthropic Messages endpoint into a single
|
|
1275
|
-
* standard non-streaming message object. Aggregates content_block deltas
|
|
1276
|
-
* (text + tool_use input_json) into content blocks.
|
|
1277
|
-
*/
|
|
1278
|
-
export function sseToMessagesJson(
|
|
1279
|
-
sseText: string,
|
|
1280
|
-
callerHadTools = true,
|
|
1281
|
-
caseRestore?: CaseRestoreMap,
|
|
1282
|
-
findGlob?: FindGlobRestore,
|
|
1283
|
-
injected?: readonly string[],
|
|
1284
|
-
argSchemas?: ReadonlyMap<string, Record<string, unknown>>,
|
|
1285
|
-
): Record<string, unknown> {
|
|
1286
|
-
const events = parseSseEvents(sseText);
|
|
1287
|
-
let id = "msg_freeflow";
|
|
1288
|
-
let model = "";
|
|
1289
|
-
let stopReason: string | null = null;
|
|
1290
|
-
let usage: unknown = undefined;
|
|
1291
|
-
const textByIndex = new Map<number, string>();
|
|
1292
|
-
const toolByIndex = new Map<number, { id: string; name: string; inputJson: string }>();
|
|
1293
|
-
|
|
1294
|
-
for (const ev of events) {
|
|
1295
|
-
if (ev.data === "[DONE]") continue;
|
|
1296
|
-
let parsed: Record<string, unknown>;
|
|
1297
|
-
try {
|
|
1298
|
-
parsed = JSON.parse(ev.data) as Record<string, unknown>;
|
|
1299
|
-
} catch {
|
|
1300
|
-
continue;
|
|
1301
|
-
}
|
|
1302
|
-
const type = typeof parsed.type === "string" ? parsed.type : "";
|
|
1303
|
-
if (type === "message_start") {
|
|
1304
|
-
const msg = parsed.message as Record<string, unknown> | undefined;
|
|
1305
|
-
if (msg) {
|
|
1306
|
-
if (typeof msg.id === "string") id = msg.id;
|
|
1307
|
-
if (typeof msg.model === "string") model = msg.model;
|
|
1308
|
-
}
|
|
1309
|
-
continue;
|
|
1310
|
-
}
|
|
1311
|
-
if (type === "content_block_start") {
|
|
1312
|
-
const index = typeof parsed.index === "number" ? parsed.index : 0;
|
|
1313
|
-
const block = parsed.content_block as Record<string, unknown> | undefined;
|
|
1314
|
-
const blockType = block && typeof block.type === "string" ? block.type : "";
|
|
1315
|
-
if (blockType === "tool_use") {
|
|
1316
|
-
toolByIndex.set(index, {
|
|
1317
|
-
id: typeof block?.id === "string" ? (block.id as string) : "",
|
|
1318
|
-
name: typeof block?.name === "string" ? (block.name as string) : "",
|
|
1319
|
-
inputJson: "",
|
|
1320
|
-
});
|
|
1321
|
-
} else if (!toolByIndex.has(index)) {
|
|
1322
|
-
textByIndex.set(index, typeof block?.text === "string" ? (block.text as string) : "");
|
|
1323
|
-
}
|
|
1324
|
-
continue;
|
|
1325
|
-
}
|
|
1326
|
-
if (type === "content_block_delta") {
|
|
1327
|
-
const index = typeof parsed.index === "number" ? parsed.index : 0;
|
|
1328
|
-
const delta = parsed.delta as Record<string, unknown> | undefined;
|
|
1329
|
-
const deltaType = delta && typeof delta.type === "string" ? delta.type : "";
|
|
1330
|
-
if (deltaType === "text_delta" && typeof delta?.text === "string") {
|
|
1331
|
-
textByIndex.set(index, (textByIndex.get(index) ?? "") + (delta.text as string));
|
|
1332
|
-
} else if (deltaType === "input_json_delta" && typeof delta?.partial_json === "string") {
|
|
1333
|
-
const existing = toolByIndex.get(index) ?? { id: "", name: "", inputJson: "" };
|
|
1334
|
-
existing.inputJson += delta.partial_json as string;
|
|
1335
|
-
toolByIndex.set(index, existing);
|
|
1336
|
-
}
|
|
1337
|
-
continue;
|
|
1338
|
-
}
|
|
1339
|
-
if (type === "message_delta") {
|
|
1340
|
-
const delta = parsed.delta as Record<string, unknown> | undefined;
|
|
1341
|
-
if (delta && typeof delta.stop_reason === "string") stopReason = delta.stop_reason as string;
|
|
1342
|
-
if (parsed.usage !== undefined) usage = parsed.usage;
|
|
1343
|
-
continue;
|
|
1344
|
-
}
|
|
1345
|
-
if (type === "message" && parsed.role !== undefined) {
|
|
1346
|
-
cloakMessagesContent(parsed, callerHadTools, caseRestore, findGlob, injected, argSchemas);
|
|
1347
|
-
return parsed;
|
|
1348
|
-
}
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
const content: Record<string, unknown>[] = [];
|
|
1352
|
-
const order = Array.from(new Set([...textByIndex.keys(), ...toolByIndex.keys()])).sort((a, b) => a - b);
|
|
1353
|
-
for (const index of order) {
|
|
1354
|
-
const tool = toolByIndex.get(index);
|
|
1355
|
-
if (tool && (tool.id || tool.name)) {
|
|
1356
|
-
if (callerHadTools && (injected === undefined || !injected.includes(tool.name.toLowerCase()))) {
|
|
1357
|
-
let input: unknown = {};
|
|
1358
|
-
try {
|
|
1359
|
-
input = tool.inputJson ? JSON.parse(tool.inputJson) : {};
|
|
1360
|
-
} catch {
|
|
1361
|
-
input = {};
|
|
1362
|
-
}
|
|
1363
|
-
const name = restoreCallerName(tool.name, caseRestore, findGlob);
|
|
1364
|
-
content.push({ type: "tool_use", id: tool.id, name, input: repairToolArgumentsValue(name, input, argSchemas) });
|
|
1365
|
-
}
|
|
1366
|
-
continue;
|
|
1367
|
-
}
|
|
1368
|
-
const text = textByIndex.get(index) ?? "";
|
|
1369
|
-
if (text) content.push({ type: "text", text });
|
|
1370
|
-
}
|
|
1371
|
-
if (!callerHadTools && content.length === 0 && toolByIndex.size > 0) {
|
|
1372
|
-
const fallback = Array.from(toolByIndex.values())
|
|
1373
|
-
.map((t) => t.inputJson || t.name)
|
|
1374
|
-
.join("\n");
|
|
1375
|
-
if (fallback) content.push({ type: "text", text: fallback });
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
return {
|
|
1379
|
-
id,
|
|
1380
|
-
type: "message",
|
|
1381
|
-
role: "assistant",
|
|
1382
|
-
model,
|
|
1383
|
-
content,
|
|
1384
|
-
stop_reason: stopReason ?? "end_turn",
|
|
1385
|
-
...(usage ? { usage } : {}),
|
|
1386
|
-
};
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
/**
|
|
1390
|
-
* Universal SSE-to-JSON aggregator. If input is not SSE, returns it untouched.
|
|
1391
|
-
*/
|
|
1392
|
-
export function convertSseToJson(
|
|
1393
|
-
sseText: string,
|
|
1394
|
-
pathname: string,
|
|
1395
|
-
callerHadTools = true,
|
|
1396
|
-
caseRestore?: CaseRestoreMap,
|
|
1397
|
-
findGlob?: FindGlobRestore,
|
|
1398
|
-
injected?: readonly string[],
|
|
1399
|
-
argSchemas?: ReadonlyMap<string, Record<string, unknown>>,
|
|
1400
|
-
): string {
|
|
1401
|
-
if (!sseText || typeof sseText !== "string") return sseText;
|
|
1402
|
-
const trimmed = sseText.trim();
|
|
1403
|
-
if (!looksLikeSse(trimmed)) {
|
|
1404
|
-
return sseText;
|
|
1405
|
-
}
|
|
1406
|
-
try {
|
|
1407
|
-
if (pathname.endsWith("/responses")) {
|
|
1408
|
-
return JSON.stringify(sseToResponsesJson(trimmed, callerHadTools, caseRestore, findGlob, injected, argSchemas));
|
|
1409
|
-
}
|
|
1410
|
-
if (pathname.endsWith("/messages")) {
|
|
1411
|
-
return JSON.stringify(sseToMessagesJson(trimmed, callerHadTools, caseRestore, findGlob, injected, argSchemas));
|
|
1412
|
-
}
|
|
1413
|
-
return JSON.stringify(sseToChatCompletionJson(trimmed, callerHadTools, caseRestore, findGlob, injected, argSchemas));
|
|
1414
|
-
} catch (error) {
|
|
1415
|
-
// Every caller has already committed to `content-type: application/json`
|
|
1416
|
-
// by the time this runs, so handing back the raw SSE bytes would deliver a
|
|
1417
|
-
// parse error with no diagnosis. Say what happened, and keep the payload
|
|
1418
|
-
// reachable under `raw` so nothing is silently destroyed.
|
|
1419
|
-
return JSON.stringify({
|
|
1420
|
-
error: {
|
|
1421
|
-
message: "upstream stream could not be aggregated into a JSON answer",
|
|
1422
|
-
detail: String(error).slice(0, 300),
|
|
1423
|
-
raw: sseText.slice(0, 2000),
|
|
1424
|
-
},
|
|
1425
|
-
});
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
/**
|
|
1430
|
-
* Repair the tool calls inside an already-JSON answer body.
|
|
1431
|
-
*
|
|
1432
|
-
* A lane that passes the caller's `stream` through untouched (Kilo) answers a
|
|
1433
|
-
* non-streaming caller with a plain `chat.completion` / `response` object, not
|
|
1434
|
-
* SSE. The SSE aggregators above never see that body, so the malformed
|
|
1435
|
-
* arguments reached the host unrepaired on exactly the requests that asked for
|
|
1436
|
-
* no stream. Repairs each call in place and re-serializes only when something
|
|
1437
|
-
* actually changed, so a well-formed answer keeps its byte-identical body.
|
|
1438
|
-
* Returns the input string untouched when it is not JSON or has no tool calls.
|
|
1439
|
-
*/
|
|
1440
|
-
export function repairJsonToolArguments(
|
|
1441
|
-
bodyText: string,
|
|
1442
|
-
pathname: string,
|
|
1443
|
-
argSchemas: ReadonlyMap<string, Record<string, unknown>> | undefined,
|
|
1444
|
-
): string {
|
|
1445
|
-
if (argSchemas === undefined || argSchemas.size === 0 || !bodyText.trimStart().startsWith("{")) return bodyText;
|
|
1446
|
-
let parsed: unknown;
|
|
1447
|
-
try {
|
|
1448
|
-
parsed = JSON.parse(bodyText);
|
|
1449
|
-
} catch {
|
|
1450
|
-
return bodyText;
|
|
1451
|
-
}
|
|
1452
|
-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return bodyText;
|
|
1453
|
-
const body = parsed as Record<string, unknown>;
|
|
1454
|
-
let changed = false;
|
|
1455
|
-
const repairCall = (name: string, args: string): string => {
|
|
1456
|
-
const repaired = repairToolArguments(name, args, argSchemas);
|
|
1457
|
-
if (repaired !== args) changed = true;
|
|
1458
|
-
return repaired;
|
|
1459
|
-
};
|
|
1460
|
-
if (pathname.endsWith("/responses")) {
|
|
1461
|
-
const output = body.output;
|
|
1462
|
-
if (!Array.isArray(output)) return bodyText;
|
|
1463
|
-
for (const item of output) {
|
|
1464
|
-
if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
|
|
1465
|
-
const rec = item as Record<string, unknown>;
|
|
1466
|
-
if (rec.type !== "function_call" || typeof rec.name !== "string" || typeof rec.arguments !== "string") continue;
|
|
1467
|
-
rec.arguments = repairCall(rec.name, rec.arguments);
|
|
1468
|
-
}
|
|
1469
|
-
} else {
|
|
1470
|
-
const choices = body.choices;
|
|
1471
|
-
if (!Array.isArray(choices)) return bodyText;
|
|
1472
|
-
for (const choice of choices) {
|
|
1473
|
-
if (choice === null || typeof choice !== "object" || Array.isArray(choice)) continue;
|
|
1474
|
-
const message = (choice as Record<string, unknown>).message;
|
|
1475
|
-
if (message === null || typeof message !== "object" || Array.isArray(message)) continue;
|
|
1476
|
-
const calls = (message as Record<string, unknown>).tool_calls;
|
|
1477
|
-
if (!Array.isArray(calls)) continue;
|
|
1478
|
-
for (const call of calls) {
|
|
1479
|
-
if (call === null || typeof call !== "object" || Array.isArray(call)) continue;
|
|
1480
|
-
const fn = (call as Record<string, unknown>).function;
|
|
1481
|
-
if (fn === null || typeof fn !== "object" || Array.isArray(fn)) continue;
|
|
1482
|
-
const fnRec = fn as Record<string, unknown>;
|
|
1483
|
-
if (typeof fnRec.name !== "string" || typeof fnRec.arguments !== "string") continue;
|
|
1484
|
-
fnRec.arguments = repairCall(fnRec.name, fnRec.arguments);
|
|
1485
|
-
}
|
|
1486
|
-
}
|
|
1487
|
-
}
|
|
1488
|
-
return changed ? JSON.stringify(body) : bodyText;
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
/**
|
|
1492
|
-
* OpenAI-compat body variant for the default lane: mark the body for usage
|
|
1493
|
-
* accounting the way the CLI's openai-compat request does. Merges into an
|
|
1494
|
-
* existing stream_options object rather than replacing it. Applied to every
|
|
1495
|
-
* chat-lane enforcement (see enforceOpencodeFingerprint).
|
|
1496
|
-
*/
|
|
1497
|
-
export function applyOpenAiCompatVariant(body: Record<string, unknown>): void {
|
|
1498
|
-
if (!body || typeof body !== "object") return;
|
|
1499
|
-
const existing = body.stream_options;
|
|
1500
|
-
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
|
|
1501
|
-
(existing as Record<string, unknown>).include_usage = true;
|
|
1502
|
-
return;
|
|
1503
|
-
}
|
|
1504
|
-
body.stream_options = { include_usage: true };
|
|
1505
|
-
}
|