pi-freeflow 1.44.0 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +11 -13
  3. package/package.json +1 -1
  4. package/src/catalog.ts +38 -14
  5. package/src/client-recovery.ts +238 -0
  6. package/src/client-staging.ts +316 -0
  7. package/src/client.ts +117 -472
  8. package/src/cline-accounts.ts +26 -1223
  9. package/src/cline-device-auth.ts +5 -3
  10. package/src/cline-pool.ts +691 -0
  11. package/src/cline-roll.ts +642 -0
  12. package/src/commands-cline.ts +283 -0
  13. package/src/commands-relay.ts +808 -0
  14. package/src/commands-startup.ts +264 -0
  15. package/src/commands.ts +119 -1202
  16. package/src/config-paths.ts +254 -0
  17. package/src/config.ts +6 -687
  18. package/src/daemon.ts +16 -1
  19. package/src/deploy.ts +3 -1
  20. package/src/fingerprint-env.ts +334 -0
  21. package/src/fingerprint-tools.ts +348 -0
  22. package/src/index.ts +10 -6
  23. package/src/lane-translation.ts +263 -0
  24. package/src/models.ts +70 -112
  25. package/src/opencode-fingerprint.ts +29 -1310
  26. package/src/opencode-identity.ts +464 -0
  27. package/src/proxy-lanes.ts +1125 -0
  28. package/src/proxy-ports.ts +317 -0
  29. package/src/proxy.ts +74 -1385
  30. package/src/relay-codec.ts +365 -0
  31. package/src/relay-state.ts +10 -367
  32. package/src/relay.ts +78 -31
  33. package/src/sse-aggregator.ts +659 -0
  34. package/src/stream-cloak.ts +882 -0
  35. package/src/stream-pipe.ts +22 -871
  36. package/src/system-ca-fetch.ts +15 -4
  37. package/src/token.ts +7 -2
  38. package/src/tool-args.ts +10 -3
  39. package/src/tool-translation.ts +1 -236
  40. package/src/toolcall-audit.ts +20 -7
  41. package/src/update-checker.ts +17 -1
  42. package/src/update-core.ts +5 -2
  43. package/src/updater.ts +8 -3
  44. package/src/upstream-health.ts +22 -2
  45. package/src/zen-sidecar.ts +1 -0
  46. package/src/zen-transport.ts +48 -18
@@ -1,5 +1,5 @@
1
1
  /**
2
- * OpenCode Zen free-tier client fingerprint enforcement & SSE stream aggregator.
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 { buildArgRepairSchemas, repairToolArguments, repairToolArgumentsValue } from "./tool-args.ts";
61
- import { getModelDef } from "./models.ts";
62
- import type { ThinkingLevel } from "./types.ts";
63
-
64
- /** Effort levels in descending priority (off excluded — clamping to off is never correct). */
65
- const EFFORT_DESCENDING: ThinkingLevel[] = ["max", "xhigh", "high", "medium", "low", "minimal"];
66
-
67
- /**
68
- * Find the highest supported effort for a model whose thinkingLevelMap
69
- * declares the requested effort as null. Returns null when no clamp is
70
- * needed (effort is supported or model has no map). The one supported effort
71
- * that is still rewritten is `ultra` (see below): it is an alias, never a
72
- * value the gateway enumerates.
73
- */
74
- function clampedEffortFor(model: string, effort: string): string | null {
75
- const def = getModelDef(model);
76
- if (!def?.thinkingLevelMap) return null;
77
- const map = def.thinkingLevelMap;
78
- // "off" means disable reasoning entirely — never clamp it even when map says null
79
- if (effort.toLowerCase() === "off") return null;
80
- // "ultra" is an unofficial alias for beyond-max: treated as "max" for the
81
- // support test, and rewritten to the model's own mapped value below. Using
82
- // the alias only for the clamp test left `ultra` on the wire, and the
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" && body.model.startsWith("opencode/")) {
744
- body.model = body.model.slice("opencode/".length);
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
- }