pi-freeflow 1.28.0 → 1.29.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.
@@ -1,743 +1,844 @@
1
- /**
2
- * OpenCode Zen free-tier client fingerprint enforcement & SSE stream aggregator.
3
- *
4
- * Upstream OpenCode Zen (/zen/v1/chat/completions, /zen/v1/responses and
5
- * /zen/v1/messages) validates requests against the official OpenCode agentic
6
- * client fingerprint. If any of the following 4 axes are missing, upstream
7
- * answers HTTP 403 FreeTierError:
8
- * 1. User-Agent (opencode/1.18.31, >= 1.17.0)
9
- * 2. x-opencode-session (canonical ses_[0-9a-f]{12}[0-9A-Za-z]{14} format)
10
- * 3. Tools: must declare the placeholder sextet {bash, glob, grep, read, edit, write}
11
- * 4. Streaming: must be stream: true
12
- *
13
- * This module ensures axes 3 and 4 are enforced on outgoing requests, and handles
14
- * bidirectional streaming conversion: if the client requested non-streaming
15
- * (stream: false), upstream is still sent stream: true to pass the gate, and the
16
- * resulting SSE stream is accumulated and converted back to a single JSON response.
17
- *
18
- * freeflow serves ONLY OMP and Pi hosts. Either host may send any of its tools
19
- * (see src/tool-translation.ts for the canonical inventories) in any wire shape,
20
- * so enforcement renames caller placeholders to lowercase (Bash -> bash,
21
- * retargeting tool_choice), translates caller tools to the target path's shape
22
- * (Pi find -> upstream glob via the translator), and only then injects the
23
- * missing placeholders. Caller tools (ls, powershell, find, web_search, ...)
24
- * are never dropped. Injected placeholder calls are cloaked from downstream
25
- * responses on all three paths; caller casing is restored via a bounded
26
- * per-request map (never module-global).
27
- */
28
- import {
29
- COMPAT_TOOL_DESCRIPTION,
30
- OPENCODE_FINGERPRINT_TOOLS,
31
- buildFindGlobRestore,
32
- restoreToolNameForCaller,
33
- retargetToolChoiceForUpstream,
34
- translateToolsForPath,
35
- upstreamToolNameFor,
36
- type FindGlobRestore,
37
- } from "./tool-translation.ts";
38
-
39
- /**
40
- * Safely extract the tool name whether formatted in Chat Completions style
41
- * ({ function: { name } }) or flat Responses style ({ name }).
42
- */
43
- export function toolNameOf(tool: unknown): string {
44
- if (!tool || typeof tool !== "object" || Array.isArray(tool)) return "";
45
- const t = tool as Record<string, unknown>;
46
- const fn =
47
- t.function && typeof t.function === "object" && !Array.isArray(t.function)
48
- ? (t.function as Record<string, unknown>)
49
- : null;
50
- const raw = typeof t.name === "string" ? t.name : typeof fn?.name === "string" ? fn.name : "";
51
- return raw.trim();
52
- }
53
- export { OPENCODE_FINGERPRINT_TOOLS };
54
- export type { FingerprintToolName, FindGlobRestore } from "./tool-translation.ts";
55
-
56
- /** Lowercase placeholder names the gate expects (canonical translator set). */
57
- const PLACEHOLDER_BY_LOWER_NAME: Record<string, true> = {};
58
- for (const n of OPENCODE_FINGERPRINT_TOOLS) PLACEHOLDER_BY_LOWER_NAME[n.toLowerCase()] = true;
59
-
60
- /** True for placeholder names (case-insensitive): injected compat tools only. */
61
- export function isPlaceholderToolName(name: unknown): boolean {
62
- return typeof name === "string" && PLACEHOLDER_BY_LOWER_NAME[name.toLowerCase()] === true;
63
- }
64
-
65
- /**
66
- * Bounded per-request restore map: lowercase placeholder name -> caller casing.
67
- * Built fresh by normalizePlaceholderCase for each enforced request and threaded
68
- * explicitly through the SSE converters; never module-global.
69
- */
70
- export type CaseRestoreMap = Record<string, string>;
71
-
72
- /**
73
- * Lowercase caller placeholder declarations (Bash -> bash) in place before
74
- * injection, so the gate sees canonical names and injection stays duplicate-free.
75
- * Non-placeholder tools (ls, powershell, find, web_search, ...) pass untouched.
76
- * Returns the bounded restore map for downstream response cloaking.
77
- */
78
- export function normalizePlaceholderCase(body: Record<string, unknown>): CaseRestoreMap {
79
- const restore: CaseRestoreMap = {};
80
- if (!Array.isArray(body.tools)) return restore;
81
- for (const tool of body.tools) {
82
- if (!tool || typeof tool !== "object" || Array.isArray(tool)) continue;
83
- const t = tool as Record<string, unknown>;
84
- const fn =
85
- t.function && typeof t.function === "object" && !Array.isArray(t.function)
86
- ? (t.function as Record<string, unknown>)
87
- : null;
88
- const holder = fn && typeof fn.name === "string" ? fn : typeof t.name === "string" ? t : null;
89
- if (!holder) continue;
90
- const original = holder.name as string;
91
- const lower = original.toLowerCase();
92
- if (PLACEHOLDER_BY_LOWER_NAME[lower] !== true || original === lower) continue;
93
- if (restore[lower] === undefined) restore[lower] = original;
94
- holder.name = lower;
95
- }
96
- return restore;
97
- }
98
-
99
- /**
100
- * Retarget a caller tool_choice that names a placeholder in original casing
101
- * ({function:{name}}, {name}) to the lowercased name. String choices
102
- * ("auto", ...) and non-placeholder names pass untouched. Request-side only.
103
- */
104
- export function retargetToolChoice(body: Record<string, unknown>): void {
105
- const choice = body.tool_choice;
106
- if (!choice || typeof choice !== "object" || Array.isArray(choice)) return;
107
- const c = choice as Record<string, unknown>;
108
- const fn =
109
- c.function && typeof c.function === "object" && !Array.isArray(c.function)
110
- ? (c.function as Record<string, unknown>)
111
- : null;
112
- if (fn && typeof fn.name === "string" && isPlaceholderToolName(fn.name)) {
113
- fn.name = (fn.name as string).toLowerCase();
114
- }
115
- if (typeof c.name === "string" && isPlaceholderToolName(c.name)) {
116
- c.name = (c.name as string).toLowerCase();
117
- }
118
- }
119
-
120
- /** Restore caller casing on one downstream tool name (bounded map, else verbatim). */
121
- function restoreName(name: string, restore?: CaseRestoreMap): string {
122
- if (restore) {
123
- const hit = restore[name.toLowerCase()];
124
- if (hit !== undefined) return hit;
125
- }
126
- return name;
127
- }
128
-
129
- /**
130
- * Full downstream restore: caller casing first, then the translator's
131
- * find->glob mapping (upstream glob back to caller find when renamed).
132
- */
133
- function restoreCallerName(name: string, caseRestore?: CaseRestoreMap, findGlob?: FindGlobRestore): string {
134
- const cased = restoreName(name, caseRestore);
135
- return findGlob ? restoreToolNameForCaller(cased, findGlob) : cased;
136
- }
137
-
138
- /**
139
- * Merge missing placeholder declarations (canonical translator set) into Chat
140
- * Completions bodies. Case-insensitive and idempotent: Bash counts as bash.
141
- * Preserves caller tools verbatim; missing placeholders appended as no-ops.
142
- */
143
- export function ensureChatFingerprintTools(body: Record<string, unknown>): void {
144
- if (!body || typeof body !== "object") return;
145
- const present = new Set<string>();
146
- if (Array.isArray(body.tools)) {
147
- for (const tool of body.tools) {
148
- const name = toolNameOf(tool);
149
- if (name) present.add(name.toLowerCase());
150
- }
151
- } else {
152
- body.tools = [];
153
- }
154
- for (const name of OPENCODE_FINGERPRINT_TOOLS) {
155
- if (present.has(name)) continue;
156
- (body.tools as unknown[]).push({
157
- type: "function",
158
- function: {
159
- name,
160
- description: COMPAT_TOOL_DESCRIPTION,
161
- parameters: { type: "object", properties: {} },
162
- },
163
- });
164
- present.add(name);
165
- }
166
- }
167
-
168
- /**
169
- * Merge missing placeholder declarations (canonical translator set) into
170
- * Responses API bodies. Case-insensitive and idempotent.
171
- * Uses the flat Responses tool shape ({ type: "function", name, description, parameters }).
172
- */
173
- export function ensureResponsesFingerprintTools(body: Record<string, unknown>): void {
174
- if (!body || typeof body !== "object") return;
175
- const present = new Set<string>();
176
- if (Array.isArray(body.tools)) {
177
- for (const tool of body.tools) {
178
- const name = toolNameOf(tool);
179
- if (name) present.add(name.toLowerCase());
180
- }
181
- } else {
182
- body.tools = [];
183
- }
184
- for (const name of OPENCODE_FINGERPRINT_TOOLS) {
185
- if (present.has(name)) continue;
186
- (body.tools as unknown[]).push({
187
- type: "function",
188
- name,
189
- description: COMPAT_TOOL_DESCRIPTION,
190
- parameters: { type: "object", properties: {} },
191
- });
192
- present.add(name);
193
- }
194
- }
195
-
196
- /**
197
- * Merge missing placeholder declarations (canonical translator set) into
198
- * Anthropic Messages bodies. Case-insensitive and idempotent.
199
- * Uses the Anthropic tool shape ({ name, description, input_schema }).
200
- */
201
- export function ensureMessagesFingerprintTools(body: Record<string, unknown>): void {
202
- if (!body || typeof body !== "object") return;
203
- const present = new Set<string>();
204
- if (Array.isArray(body.tools)) {
205
- for (const tool of body.tools) {
206
- const name = toolNameOf(tool);
207
- if (name) present.add(name.toLowerCase());
208
- }
209
- } else {
210
- body.tools = [];
211
- }
212
- for (const name of OPENCODE_FINGERPRINT_TOOLS) {
213
- if (present.has(name)) continue;
214
- (body.tools as unknown[]).push({
215
- name,
216
- description: COMPAT_TOOL_DESCRIPTION,
217
- input_schema: { type: "object", properties: {} },
218
- });
219
- present.add(name);
220
- }
221
- }
222
-
223
- /**
224
- * Enforce the full OpenCode free-tier client fingerprint on a parsed request body.
225
- * Caller placeholders are lowercased (Bash -> bash, tool_choice retargeted),
226
- * tools translated to the target path's wire shape (Pi find -> upstream glob via
227
- * src/tool-translation.ts), then the missing placeholders injected in that shape.
228
- * Returns the original stream flag, caller-tools flag, whether injection added
229
- * tools, and the bounded per-request restore records (caseRestore, findGlob,
230
- * injected) for downstream response cloaking.
231
- */
232
- export function enforceOpencodeFingerprint(
233
- body: Record<string, unknown>,
234
- pathname: string,
235
- ): {
236
- clientRequestedStream: boolean;
237
- callerHadTools: boolean;
238
- addedTools: boolean;
239
- caseRestore: CaseRestoreMap;
240
- findGlob: FindGlobRestore;
241
- injected: string[];
242
- } {
243
- const clientRequestedStream = body.stream === true;
244
- const callerHadTools = Array.isArray(body.tools) && body.tools.length > 0;
245
- // Upstream Zen free tier mandates stream: true for all free requests
246
- body.stream = true;
247
-
248
- const caseRestore = normalizePlaceholderCase(body);
249
- retargetToolChoice(body);
250
-
251
- const findGlob = buildFindGlobRestore(Array.isArray(body.tools) ? body.tools : []);
252
- // Caller tool_choice naming `find` must ride upstream as `glob` — the rename
253
- // above collapses the declaration, so an unretargeted choice dangles.
254
- if (body.tool_choice !== undefined) {
255
- body.tool_choice = retargetToolChoiceForUpstream(body.tool_choice, findGlob) as Record<string, unknown> | string;
256
- }
257
- const callerUpstream = new Set<string>();
258
- if (Array.isArray(body.tools)) {
259
- for (const tool of body.tools) {
260
- const n = toolNameOf(tool);
261
- if (n) callerUpstream.add(upstreamToolNameFor(n).toLowerCase());
262
- }
263
- }
264
- const injected = OPENCODE_FINGERPRINT_TOOLS.filter((n) => !callerUpstream.has(n.toLowerCase()));
265
-
266
- if (Array.isArray(body.tools)) {
267
- body.tools = translateToolsForPath(body.tools, pathname);
268
- }
269
-
270
- const before = Array.isArray(body.tools) ? body.tools.length : 0;
271
- if (pathname.endsWith("/responses")) {
272
- ensureResponsesFingerprintTools(body);
273
- if (body.store === undefined) {
274
- body.store = false;
275
- }
276
- } else if (pathname.endsWith("/messages")) {
277
- ensureMessagesFingerprintTools(body);
278
- } else {
279
- ensureChatFingerprintTools(body);
280
- }
281
- const after = Array.isArray(body.tools) ? body.tools.length : 0;
282
-
283
- // Note: Upstream OpenCode Zen explicitly rejects any tool_choice other than "auto"
284
- // with HTTP 400 (only "auto" is supported). We never impose tool_choice: "none".
285
- // The explicit placeholder description and output-aggregator guarantee clean text.
286
-
287
- return { clientRequestedStream, callerHadTools, addedTools: after > before, caseRestore, findGlob, injected };
288
- }
289
-
290
- /**
291
- * Parse raw SSE stream text into individual event blocks.
292
- */
293
- export function parseSseEvents(text: string): Array<{ event?: string; data: string }> {
294
- const result: Array<{ event?: string; data: string }> = [];
295
- const blocks = text.split(/\r?\n\r?\n/);
296
- for (const block of blocks) {
297
- const trimmed = block.trim();
298
- if (!trimmed) continue;
299
- let eventName: string | undefined;
300
- const dataLines: string[] = [];
301
- for (const line of trimmed.split(/\r?\n/)) {
302
- if (line.startsWith("event:")) {
303
- eventName = line.slice(6).trim();
304
- } else if (line.startsWith("data:")) {
305
- dataLines.push(line.slice(5).trimStart());
306
- }
307
- }
308
- if (dataLines.length > 0) {
309
- result.push({ event: eventName, data: dataLines.join("\n") });
310
- }
311
- }
312
- return result;
313
- }
314
-
315
- /**
316
- * Convert an SSE stream from an OpenAI-compatible Chat Completions endpoint
317
- * into a single standard non-streaming ChatCompletion JSON response.
318
- */
319
- /** Tool-call name from a Chat Completions tool_calls entry (else ""). */
320
- function chatCallName(tc: unknown): string {
321
- if (!tc || typeof tc !== "object" || Array.isArray(tc)) return "";
322
- const fn = (tc as Record<string, unknown>).function;
323
- if (!fn || typeof fn !== "object" || Array.isArray(fn)) return "";
324
- const n = (fn as Record<string, unknown>).name;
325
- return typeof n === "string" ? n : "";
326
- }
327
-
328
- /**
329
- * Strip injected placeholder calls from a Chat Completions message in place.
330
- * Tool-less callers never see tool_calls (args folded to text when content is
331
- * empty); callers with tools keep only their own calls, casing restored.
332
- */
333
- /**
334
- * Only names this request actually injected are ever cloaked downstream. The
335
- * caller's own bash is indistinguishable by name and must survive; legacy
336
- * direct calls (no record) keep everything.
337
- */
338
- function cloakChatMessage(
339
- msg: unknown,
340
- callerHadTools: boolean,
341
- caseRestore?: CaseRestoreMap,
342
- findGlob?: FindGlobRestore,
343
- injected?: readonly string[],
344
- ): void {
345
- if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
346
- const m = msg as Record<string, unknown>;
347
- if (!Array.isArray(m.tool_calls)) return;
348
- const calls = m.tool_calls as unknown[];
349
- if (!callerHadTools) {
350
- if ((!m.content || m.content === "") && calls.length > 0) {
351
- m.content = calls
352
- .map((tc) => {
353
- if (!tc || typeof tc !== "object" || Array.isArray(tc)) return "";
354
- const fn = (tc as Record<string, unknown>).function;
355
- if (!fn || typeof fn !== "object" || Array.isArray(fn)) return "";
356
- const f = fn as Record<string, unknown>;
357
- return (typeof f.arguments === "string" && f.arguments) || (typeof f.name === "string" && f.name) || "";
358
- })
359
- .join("\n");
360
- }
361
- delete m.tool_calls;
362
- return;
363
- }
364
- const kept: unknown[] = [];
365
- for (const tc of calls) {
366
- const callName = chatCallName(tc);
367
- if (callName && injected !== undefined && injected.includes(callName.toLowerCase())) continue;
368
- if (tc && typeof tc === "object" && !Array.isArray(tc)) {
369
- const fn = (tc as Record<string, unknown>).function;
370
- if (fn && typeof fn === "object" && !Array.isArray(fn) && typeof (fn as Record<string, unknown>).name === "string") {
371
- (fn as Record<string, unknown>).name = restoreCallerName(
372
- (fn as Record<string, unknown>).name as string,
373
- caseRestore,
374
- findGlob,
375
- );
376
- }
377
- }
378
- kept.push(tc);
379
- }
380
- if (kept.length > 0) m.tool_calls = kept;
381
- else delete m.tool_calls;
382
- }
383
-
384
- export function sseToChatCompletionJson(
385
- sseText: string,
386
- callerHadTools = true,
387
- caseRestore?: CaseRestoreMap,
388
- findGlob?: FindGlobRestore,
389
- injected?: readonly string[],
390
- ): Record<string, unknown> {
391
- const events = parseSseEvents(sseText);
392
- let id = "chatcmpl-freeflow";
393
- let model = "";
394
- let created = Math.floor(Date.now() / 1000);
395
- let finishReason: string | null = null;
396
- let content = "";
397
- let reasoningContent = "";
398
- let role = "assistant";
399
- let usage: unknown = undefined;
400
- const toolCallsMap = new Map<
401
- number,
402
- { id: string; type: string; function: { name: string; arguments: string } }
403
- >();
404
-
405
- for (const ev of events) {
406
- if (ev.data === "[DONE]") continue;
407
- try {
408
- const parsed = JSON.parse(ev.data);
409
- if (parsed.id) id = parsed.id;
410
- if (parsed.model) model = parsed.model;
411
- if (parsed.created) created = parsed.created;
412
- if (parsed.usage) usage = parsed.usage;
413
- if (Array.isArray(parsed.choices)) {
414
- for (const c of parsed.choices) {
415
- if (c.finish_reason) finishReason = c.finish_reason;
416
- const delta = c.delta;
417
- if (delta) {
418
- if (delta.role) role = delta.role;
419
- if (typeof delta.content === "string") content += delta.content;
420
- if (typeof delta.reasoning_content === "string") {
421
- reasoningContent += delta.reasoning_content;
422
- } else if (typeof delta.reasoning === "string") {
423
- reasoningContent += delta.reasoning;
424
- }
425
- if (Array.isArray(delta.tool_calls)) {
426
- for (const tc of delta.tool_calls) {
427
- const idx = tc.index ?? 0;
428
- const existing = toolCallsMap.get(idx) ?? {
429
- id: tc.id || "",
430
- type: tc.type || "function",
431
- function: { name: "", arguments: "" },
432
- };
433
- if (tc.id) existing.id = tc.id;
434
- if (tc.type) existing.type = tc.type;
435
- if (tc.function?.name) existing.function.name += tc.function.name;
436
- if (tc.function?.arguments) existing.function.arguments += tc.function.arguments;
437
- toolCallsMap.set(idx, existing);
438
- }
439
- }
440
- }
441
- // Choice contains a pre-assembled message: cloak before returning.
442
- if (c.message) {
443
- cloakChatMessage(c.message, callerHadTools, caseRestore, findGlob, injected);
444
- return parsed as Record<string, unknown>;
445
- }
446
- }
447
- }
448
- } catch {
449
- // ignore unparseable data chunks
450
- }
451
- }
452
-
453
- const toolCalls = Array.from(toolCallsMap.entries())
454
- .sort(([a], [b]) => a - b)
455
- .map(([, tc]) => tc);
456
-
457
- const message: Record<string, unknown> = {
458
- role,
459
- content: content || null,
460
- };
461
- if (reasoningContent) {
462
- message.reasoning_content = reasoningContent;
463
- }
464
- if (!callerHadTools) {
465
- if (!content && toolCalls.length > 0) {
466
- message.content = toolCalls.map((tc) => tc.function.arguments || tc.function.name).join("\n");
467
- }
468
- } else {
469
- const kept = injected === undefined
470
- ? toolCalls
471
- : toolCalls.filter((tc) => !injected.includes(tc.function.name.toLowerCase()));
472
- for (const tc of kept) tc.function.name = restoreCallerName(tc.function.name, caseRestore, findGlob);
473
- if (kept.length > 0) message.tool_calls = kept;
474
- }
475
-
476
- return {
477
- id,
478
- object: "chat.completion",
479
- created,
480
- model,
481
- choices: [
482
- {
483
- index: 0,
484
- message,
485
- finish_reason: finishReason ?? "stop",
486
- },
487
- ],
488
- ...(usage ? { usage } : {}),
489
- };
490
- }
491
-
492
- /**
493
- * Strip injected placeholder function_call items from a Responses object in
494
- * place. Only names this request injected are removed, whether or not the
495
- * caller declared tools; caller calls keep restored names.
496
- */
497
- function cloakResponsesObject(resp: unknown, caseRestore?: CaseRestoreMap, findGlob?: FindGlobRestore, injected?: readonly string[]): void {
498
- if (!resp || typeof resp !== "object" || Array.isArray(resp)) return;
499
- const output = (resp as Record<string, unknown>).output;
500
- if (!Array.isArray(output)) return;
501
- const kept: unknown[] = [];
502
- for (const item of output) {
503
- if (item && typeof item === "object" && !Array.isArray(item)) {
504
- const rec = item as Record<string, unknown>;
505
- if (rec.type === "function_call") {
506
- if (typeof rec.name === "string" && injected !== undefined && injected.includes(rec.name.toLowerCase())) continue;
507
- if (typeof rec.name === "string") rec.name = restoreCallerName(rec.name, caseRestore, findGlob);
508
- }
509
- }
510
- kept.push(item);
511
- }
512
- (resp as Record<string, unknown>).output = kept;
513
- }
514
-
515
- /**
516
- * Convert an SSE stream from an OpenAI Responses API endpoint into a single
517
- * standard non-streaming response object.
518
- */
519
- export function sseToResponsesJson(
520
- sseText: string,
521
- callerHadTools = true,
522
- caseRestore?: CaseRestoreMap,
523
- findGlob?: FindGlobRestore,
524
- injected?: readonly string[],
525
- ): Record<string, unknown> {
526
- const events = parseSseEvents(sseText);
527
- // 1. Highest fidelity: response.completed event carries the final full response object
528
- for (let i = events.length - 1; i >= 0; i--) {
529
- const ev = events[i];
530
- try {
531
- const parsed = JSON.parse(ev.data);
532
- if (parsed?.type === "response.completed" && parsed.response && typeof parsed.response === "object") {
533
- cloakResponsesObject(parsed.response, caseRestore, findGlob, injected);
534
- return parsed.response as Record<string, unknown>;
535
- }
536
- } catch { }
537
- }
538
- // 2. Secondary fallback: check any event with a response object
539
- for (let i = events.length - 1; i >= 0; i--) {
540
- const ev = events[i];
541
- try {
542
- const parsed = JSON.parse(ev.data);
543
- if (parsed?.response && typeof parsed.response === "object") {
544
- cloakResponsesObject(parsed.response, caseRestore, findGlob, injected);
545
- return parsed.response as Record<string, unknown>;
546
- }
547
- } catch { }
548
- }
549
- // 3. Fallback: parse entire string directly if upstream already returned JSON
550
- try {
551
- const parsed = JSON.parse(sseText);
552
- if (parsed && typeof parsed === "object") {
553
- cloakResponsesObject(parsed, caseRestore, findGlob, injected);
554
- return parsed;
555
- }
556
- } catch { }
557
-
558
- return {
559
- id: "resp_fallback",
560
- object: "response",
561
- status: "completed",
562
- output: [],
563
- };
564
- }
565
- /**
566
- * Drop placeholder tool_use blocks from a complete Anthropic message in place.
567
- * Tool-less callers never see tool_use; callers with tools keep only their own
568
- * blocks, names restored.
569
- */
570
- function cloakMessagesContent(
571
- msg: unknown,
572
- callerHadTools: boolean,
573
- caseRestore?: CaseRestoreMap,
574
- findGlob?: FindGlobRestore,
575
- injected?: readonly string[],
576
- ): void {
577
- if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
578
- const content = (msg as Record<string, unknown>).content;
579
- if (!Array.isArray(content)) return;
580
- const kept: unknown[] = [];
581
- for (const block of content) {
582
- if (
583
- block &&
584
- typeof block === "object" &&
585
- !Array.isArray(block) &&
586
- (block as Record<string, unknown>).type === "tool_use"
587
- ) {
588
- const rec = block as Record<string, unknown>;
589
- if (typeof rec.name === "string" && injected !== undefined && injected.includes(rec.name.toLowerCase())) continue;
590
- if (callerHadTools) {
591
- if (typeof rec.name === "string") rec.name = restoreCallerName(rec.name, caseRestore, findGlob);
592
- kept.push(block);
593
- }
594
- continue;
595
- }
596
- kept.push(block);
597
- }
598
- (msg as Record<string, unknown>).content = kept;
599
- }
600
-
601
- /**
602
- * Convert an SSE stream from an Anthropic Messages endpoint into a single
603
- * standard non-streaming message object. Aggregates content_block deltas
604
- * (text + tool_use input_json) into content blocks.
605
- */
606
- export function sseToMessagesJson(
607
- sseText: string,
608
- callerHadTools = true,
609
- caseRestore?: CaseRestoreMap,
610
- findGlob?: FindGlobRestore,
611
- injected?: readonly string[],
612
- ): Record<string, unknown> {
613
- const events = parseSseEvents(sseText);
614
- let id = "msg_freeflow";
615
- let model = "";
616
- let stopReason: string | null = null;
617
- let usage: unknown = undefined;
618
- const textByIndex = new Map<number, string>();
619
- const toolByIndex = new Map<number, { id: string; name: string; inputJson: string }>();
620
-
621
- for (const ev of events) {
622
- if (ev.data === "[DONE]") continue;
623
- let parsed: Record<string, unknown>;
624
- try {
625
- parsed = JSON.parse(ev.data) as Record<string, unknown>;
626
- } catch {
627
- continue;
628
- }
629
- const type = typeof parsed.type === "string" ? parsed.type : "";
630
- if (type === "message_start") {
631
- const msg = parsed.message as Record<string, unknown> | undefined;
632
- if (msg) {
633
- if (typeof msg.id === "string") id = msg.id;
634
- if (typeof msg.model === "string") model = msg.model;
635
- }
636
- continue;
637
- }
638
- if (type === "content_block_start") {
639
- const index = typeof parsed.index === "number" ? parsed.index : 0;
640
- const block = parsed.content_block as Record<string, unknown> | undefined;
641
- const blockType = block && typeof block.type === "string" ? block.type : "";
642
- if (blockType === "tool_use") {
643
- toolByIndex.set(index, {
644
- id: typeof block?.id === "string" ? (block.id as string) : "",
645
- name: typeof block?.name === "string" ? (block.name as string) : "",
646
- inputJson: "",
647
- });
648
- } else if (!toolByIndex.has(index)) {
649
- textByIndex.set(index, typeof block?.text === "string" ? (block.text as string) : "");
650
- }
651
- continue;
652
- }
653
- if (type === "content_block_delta") {
654
- const index = typeof parsed.index === "number" ? parsed.index : 0;
655
- const delta = parsed.delta as Record<string, unknown> | undefined;
656
- const deltaType = delta && typeof delta.type === "string" ? delta.type : "";
657
- if (deltaType === "text_delta" && typeof delta?.text === "string") {
658
- textByIndex.set(index, (textByIndex.get(index) ?? "") + (delta.text as string));
659
- } else if (deltaType === "input_json_delta" && typeof delta?.partial_json === "string") {
660
- const existing = toolByIndex.get(index) ?? { id: "", name: "", inputJson: "" };
661
- existing.inputJson += delta.partial_json as string;
662
- toolByIndex.set(index, existing);
663
- }
664
- continue;
665
- }
666
- if (type === "message_delta") {
667
- const delta = parsed.delta as Record<string, unknown> | undefined;
668
- if (delta && typeof delta.stop_reason === "string") stopReason = delta.stop_reason as string;
669
- if (parsed.usage !== undefined) usage = parsed.usage;
670
- continue;
671
- }
672
- if (type === "message" && parsed.role !== undefined) {
673
- cloakMessagesContent(parsed, callerHadTools, caseRestore, findGlob, injected);
674
- return parsed;
675
- }
676
- }
677
-
678
- const content: Record<string, unknown>[] = [];
679
- const order = Array.from(new Set([...textByIndex.keys(), ...toolByIndex.keys()])).sort((a, b) => a - b);
680
- for (const index of order) {
681
- const tool = toolByIndex.get(index);
682
- if (tool && (tool.id || tool.name)) {
683
- if (callerHadTools && (injected === undefined || !injected.includes(tool.name.toLowerCase()))) {
684
- let input: unknown = {};
685
- try {
686
- input = tool.inputJson ? JSON.parse(tool.inputJson) : {};
687
- } catch {
688
- input = {};
689
- }
690
- content.push({ type: "tool_use", id: tool.id, name: restoreCallerName(tool.name, caseRestore, findGlob), input });
691
- }
692
- continue;
693
- }
694
- const text = textByIndex.get(index) ?? "";
695
- if (text) content.push({ type: "text", text });
696
- }
697
- if (!callerHadTools && content.length === 0 && toolByIndex.size > 0) {
698
- const fallback = Array.from(toolByIndex.values())
699
- .map((t) => t.inputJson || t.name)
700
- .join("\n");
701
- if (fallback) content.push({ type: "text", text: fallback });
702
- }
703
-
704
- return {
705
- id,
706
- type: "message",
707
- role: "assistant",
708
- model,
709
- content,
710
- stop_reason: stopReason ?? "end_turn",
711
- ...(usage ? { usage } : {}),
712
- };
713
- }
714
-
715
- /**
716
- * Universal SSE-to-JSON aggregator. If input is not SSE, returns it untouched.
717
- */
718
- export function convertSseToJson(
719
- sseText: string,
720
- pathname: string,
721
- callerHadTools = true,
722
- caseRestore?: CaseRestoreMap,
723
- findGlob?: FindGlobRestore,
724
- injected?: readonly string[],
725
- ): string {
726
- if (!sseText || typeof sseText !== "string") return sseText;
727
- const trimmed = sseText.trim();
728
- if (!trimmed.includes("data:")) {
729
- return sseText;
730
- }
731
- try {
732
- if (pathname.endsWith("/responses")) {
733
- return JSON.stringify(sseToResponsesJson(trimmed, callerHadTools, caseRestore, findGlob, injected));
734
- }
735
- if (pathname.endsWith("/messages")) {
736
- return JSON.stringify(sseToMessagesJson(trimmed, callerHadTools, caseRestore, findGlob, injected));
737
- }
738
- return JSON.stringify(sseToChatCompletionJson(trimmed, callerHadTools, caseRestore, findGlob, injected));
739
- } catch {
740
- return sseText;
741
- }
742
- return sseText;
743
- }
1
+ /**
2
+ * OpenCode Zen free-tier client fingerprint enforcement & SSE stream aggregator.
3
+ *
4
+ * Upstream OpenCode Zen (/zen/v1/chat/completions, /zen/v1/responses and
5
+ * /zen/v1/messages) validates requests against the official OpenCode agentic
6
+ * client fingerprint. If any of the following 4 axes are missing, upstream
7
+ * answers HTTP 403 FreeTierError:
8
+ * 1. User-Agent (opencode/1.18.31, >= 1.17.0)
9
+ * 2. x-opencode-session (canonical ses_[0-9a-f]{12}[0-9A-Za-z]{14} format)
10
+ * 3. Tools: must declare the placeholder sextet {bash, glob, grep, read, edit, write}
11
+ * 4. Streaming: must be stream: true
12
+ *
13
+ * This module ensures axes 3 and 4 are enforced on outgoing requests, and handles
14
+ * bidirectional streaming conversion: if the client requested non-streaming
15
+ * (stream: false), upstream is still sent stream: true to pass the gate, and the
16
+ * resulting SSE stream is accumulated and converted back to a single JSON response.
17
+ *
18
+ * freeflow serves ONLY OMP and Pi hosts. Either host may send any of its tools
19
+ * (see src/tool-translation.ts for the canonical inventories) in any wire shape,
20
+ * so enforcement renames caller placeholders to lowercase (Bash -> bash,
21
+ * retargeting tool_choice), translates caller tools to the target path's shape
22
+ * (Pi find -> upstream glob via the translator), and only then injects the
23
+ * missing placeholders. Caller tools (ls, powershell, find, web_search, ...)
24
+ * are never dropped. Injected placeholder calls are cloaked from downstream
25
+ * responses on all three paths; caller casing is restored via a bounded
26
+ * per-request map (never module-global).
27
+ */
28
+ import {
29
+ COMPAT_TOOL_DESCRIPTION,
30
+ OMP_FINGERPRINT_DEFS,
31
+ OPENCODE_FINGERPRINT_TOOLS,
32
+ buildFindGlobRestore,
33
+ isOmpLikeCaller,
34
+ restoreToolNameForCaller,
35
+ retargetToolChoiceForUpstream,
36
+ translateToolsForPath,
37
+ upstreamToolNameFor,
38
+ type FindGlobRestore,
39
+ type FingerprintToolName,
40
+ } from "./tool-translation.ts";
41
+
42
+ /**
43
+ * Safely extract the tool name whether formatted in Chat Completions style
44
+ * ({ function: { name } }) or flat Responses style ({ name }).
45
+ */
46
+ export function toolNameOf(tool: unknown): string {
47
+ if (!tool || typeof tool !== "object" || Array.isArray(tool)) return "";
48
+ const t = tool as Record<string, unknown>;
49
+ const fn =
50
+ t.function && typeof t.function === "object" && !Array.isArray(t.function)
51
+ ? (t.function as Record<string, unknown>)
52
+ : null;
53
+ const raw = typeof t.name === "string" ? t.name : typeof fn?.name === "string" ? fn.name : "";
54
+ return raw.trim();
55
+ }
56
+ export { OPENCODE_FINGERPRINT_TOOLS };
57
+ export type { FingerprintToolName, FindGlobRestore } from "./tool-translation.ts";
58
+
59
+ /** Lowercase placeholder names the gate expects (canonical translator set). */
60
+ const PLACEHOLDER_BY_LOWER_NAME: Record<string, true> = {};
61
+ for (const n of OPENCODE_FINGERPRINT_TOOLS) PLACEHOLDER_BY_LOWER_NAME[n.toLowerCase()] = true;
62
+
63
+ /** True for placeholder names (case-insensitive): injected compat tools only. */
64
+ export function isPlaceholderToolName(name: unknown): boolean {
65
+ return typeof name === "string" && PLACEHOLDER_BY_LOWER_NAME[name.toLowerCase()] === true;
66
+ }
67
+ /**
68
+ * Fill for one missing fingerprint slot: the shared OMP real definition when
69
+ * the caller is OMP-like, otherwise the empty COMPAT placeholder Pi keeps.
70
+ * Single lockstep source for the three wire-shape ensure functions below.
71
+ */
72
+ function fingerprintFill(name: FingerprintToolName, omp: boolean): { description: string; schema: Record<string, unknown> } {
73
+ if (!omp) return { description: COMPAT_TOOL_DESCRIPTION, schema: { type: "object", properties: {} } };
74
+ const def = OMP_FINGERPRINT_DEFS[name];
75
+ return { description: def.description, schema: { ...(def.parameters as Record<string, unknown>) } };
76
+ }
77
+
78
+ /**
79
+ * Bounded per-request restore map: lowercase placeholder name -> caller casing.
80
+ * Built fresh by normalizePlaceholderCase for each enforced request and threaded
81
+ * explicitly through the SSE converters; never module-global.
82
+ */
83
+ export type CaseRestoreMap = Record<string, string>;
84
+
85
+ /**
86
+ * Lowercase caller placeholder declarations (Bash -> bash) in place before
87
+ * injection, so the gate sees canonical names and injection stays duplicate-free.
88
+ * Non-placeholder tools (ls, powershell, find, web_search, ...) pass untouched.
89
+ * Returns the bounded restore map for downstream response cloaking.
90
+ */
91
+ export function normalizePlaceholderCase(body: Record<string, unknown>): CaseRestoreMap {
92
+ const restore: CaseRestoreMap = {};
93
+ if (!Array.isArray(body.tools)) return restore;
94
+ for (const tool of body.tools) {
95
+ if (!tool || typeof tool !== "object" || Array.isArray(tool)) continue;
96
+ const t = tool as Record<string, unknown>;
97
+ const fn =
98
+ t.function && typeof t.function === "object" && !Array.isArray(t.function)
99
+ ? (t.function as Record<string, unknown>)
100
+ : null;
101
+ const holder = fn && typeof fn.name === "string" ? fn : typeof t.name === "string" ? t : null;
102
+ if (!holder) continue;
103
+ const original = holder.name as string;
104
+ const lower = original.toLowerCase();
105
+ if (PLACEHOLDER_BY_LOWER_NAME[lower] !== true || original === lower) continue;
106
+ if (restore[lower] === undefined) restore[lower] = original;
107
+ holder.name = lower;
108
+ }
109
+ return restore;
110
+ }
111
+
112
+ /**
113
+ * Retarget a caller tool_choice that names a placeholder in original casing
114
+ * ({function:{name}}, {name}) to the lowercased name. String choices
115
+ * ("auto", ...) and non-placeholder names pass untouched. Request-side only.
116
+ */
117
+ export function retargetToolChoice(body: Record<string, unknown>): void {
118
+ const choice = body.tool_choice;
119
+ if (!choice || typeof choice !== "object" || Array.isArray(choice)) return;
120
+ const c = choice as Record<string, unknown>;
121
+ const fn =
122
+ c.function && typeof c.function === "object" && !Array.isArray(c.function)
123
+ ? (c.function as Record<string, unknown>)
124
+ : null;
125
+ if (fn && typeof fn.name === "string" && isPlaceholderToolName(fn.name)) {
126
+ fn.name = (fn.name as string).toLowerCase();
127
+ }
128
+ if (typeof c.name === "string" && isPlaceholderToolName(c.name)) {
129
+ c.name = (c.name as string).toLowerCase();
130
+ }
131
+ }
132
+
133
+ /** Restore caller casing on one downstream tool name (bounded map, else verbatim). */
134
+ function restoreName(name: string, restore?: CaseRestoreMap): string {
135
+ if (restore) {
136
+ const hit = restore[name.toLowerCase()];
137
+ if (hit !== undefined) return hit;
138
+ }
139
+ return name;
140
+ }
141
+
142
+ /**
143
+ * Full downstream restore: caller casing first, then the translator's
144
+ * find->glob mapping (upstream glob back to caller find when renamed).
145
+ */
146
+ function restoreCallerName(name: string, caseRestore?: CaseRestoreMap, findGlob?: FindGlobRestore): string {
147
+ const cased = restoreName(name, caseRestore);
148
+ return findGlob ? restoreToolNameForCaller(cased, findGlob) : cased;
149
+ }
150
+
151
+ /**
152
+ * Merge missing fingerprint declarations (canonical translator set) into Chat
153
+ * Completions bodies. Case-insensitive and idempotent: Bash counts as bash.
154
+ * Preserves caller tools verbatim; missing slots become executable real
155
+ * definitions for OMP-like callers, empty no-ops otherwise. Explicit `ompLike`
156
+ * overrides auto-detection (pass pre-translation caller names when find was
157
+ * already renamed to glob).
158
+ */
159
+ export function ensureChatFingerprintTools(body: Record<string, unknown>, ompLike?: boolean): void {
160
+ if (!body || typeof body !== "object") return;
161
+ const present = new Set<string>();
162
+ if (Array.isArray(body.tools)) {
163
+ for (const tool of body.tools) {
164
+ const name = toolNameOf(tool);
165
+ if (name) present.add(name.toLowerCase());
166
+ }
167
+ } else {
168
+ body.tools = [];
169
+ }
170
+ const omp = ompLike ?? isOmpLikeCaller(present);
171
+ for (const name of OPENCODE_FINGERPRINT_TOOLS) {
172
+ if (present.has(name)) continue;
173
+ const fill = fingerprintFill(name, omp);
174
+ (body.tools as unknown[]).push({
175
+ type: "function",
176
+ function: {
177
+ name,
178
+ description: fill.description,
179
+ parameters: fill.schema,
180
+ },
181
+ });
182
+ present.add(name);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Merge missing fingerprint declarations (canonical translator set) into
188
+ * Responses API bodies. Case-insensitive and idempotent.
189
+ * Uses the flat Responses tool shape ({ type: "function", name, description, parameters }).
190
+ * Missing slots become executable real definitions for OMP-like callers,
191
+ * empty no-ops otherwise; see ensureChatFingerprintTools for the override.
192
+ */
193
+ export function ensureResponsesFingerprintTools(body: Record<string, unknown>, ompLike?: boolean): void {
194
+ if (!body || typeof body !== "object") return;
195
+ const present = new Set<string>();
196
+ if (Array.isArray(body.tools)) {
197
+ for (const tool of body.tools) {
198
+ const name = toolNameOf(tool);
199
+ if (name) present.add(name.toLowerCase());
200
+ }
201
+ } else {
202
+ body.tools = [];
203
+ }
204
+ const omp = ompLike ?? isOmpLikeCaller(present);
205
+ for (const name of OPENCODE_FINGERPRINT_TOOLS) {
206
+ if (present.has(name)) continue;
207
+ const fill = fingerprintFill(name, omp);
208
+ (body.tools as unknown[]).push({
209
+ type: "function",
210
+ name,
211
+ description: fill.description,
212
+ parameters: fill.schema,
213
+ });
214
+ present.add(name);
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Merge missing fingerprint declarations (canonical translator set) into
220
+ * Anthropic Messages bodies. Case-insensitive and idempotent.
221
+ * Uses the Anthropic tool shape ({ name, description, input_schema }).
222
+ * Missing slots become executable real definitions for OMP-like callers,
223
+ * empty no-ops otherwise; see ensureChatFingerprintTools for the override.
224
+ */
225
+ export function ensureMessagesFingerprintTools(body: Record<string, unknown>, ompLike?: boolean): void {
226
+ if (!body || typeof body !== "object") return;
227
+ const present = new Set<string>();
228
+ if (Array.isArray(body.tools)) {
229
+ for (const tool of body.tools) {
230
+ const name = toolNameOf(tool);
231
+ if (name) present.add(name.toLowerCase());
232
+ }
233
+ } else {
234
+ body.tools = [];
235
+ }
236
+ const omp = ompLike ?? isOmpLikeCaller(present);
237
+ for (const name of OPENCODE_FINGERPRINT_TOOLS) {
238
+ if (present.has(name)) continue;
239
+ const fill = fingerprintFill(name, omp);
240
+ (body.tools as unknown[]).push({
241
+ name,
242
+ description: fill.description,
243
+ input_schema: fill.schema,
244
+ });
245
+ present.add(name);
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Normalize a Zen Responses body to what upstream accepts. OMP/Pi hosts
251
+ * sometimes carry Chat Completions leftovers on the responses path and
252
+ * upstream rejects them with 400:
253
+ * - `messages` becomes `input` (copied when `input` is absent, dropped either
254
+ * way — the responses API has no `messages` field).
255
+ * - `max_tokens` / `max_completion_tokens` become `max_output_tokens` (copied
256
+ * when absent, dropped either way).
257
+ * - `temperature` / `top_p` are dropped (Responses rejects them).
258
+ * - `parallel_tool_calls: false` is dropped; absent-or-true rides verbatim.
259
+ * - `tool_choice` is auto-or-absent: `"none"` (string or `{ type: "none" }`)
260
+ * is dropped, everything else rides (named choices still retarget find->glob).
261
+ * - `store` defaults to false (never persisted server-side).
262
+ * - spark reasoning effort clamps `max` / `ultra` to `xhigh`: the spark
263
+ * thinkingLevelMap declares `max: null` (unoffered) and upstream serves at
264
+ * most `xhigh`, so a stale or hand-built `max`/`ultra` effort would 400.
265
+ * Only spark model ids are clamped; `off: null` stays untouched everywhere.
266
+ * Zen-responses only: chat/messages paths keep their own fields, and Kilo/Cline
267
+ * bodies never reach here (the proxy fingerprints Zen bodies exclusively).
268
+ */
269
+ export function normalizeResponsesBody(body: Record<string, unknown>): void {
270
+ if (!body || typeof body !== "object") return;
271
+ if (body.input === undefined && Array.isArray(body.messages)) {
272
+ body.input = body.messages;
273
+ }
274
+ delete body.messages;
275
+ if (body.max_output_tokens === undefined) {
276
+ if (typeof body.max_tokens === "number") body.max_output_tokens = body.max_tokens;
277
+ else if (typeof body.max_completion_tokens === "number") body.max_output_tokens = body.max_completion_tokens;
278
+ }
279
+ delete body.max_tokens;
280
+ delete body.max_completion_tokens;
281
+ delete body.temperature;
282
+ delete body.top_p;
283
+ if (body.parallel_tool_calls === false) delete body.parallel_tool_calls;
284
+ const choice = body.tool_choice;
285
+ if (typeof choice === "string" ? choice.toLowerCase() === "none" : (
286
+ choice !== null && typeof choice === "object" && !Array.isArray(choice)
287
+ && (choice as Record<string, unknown>).type === "none"
288
+ )) delete body.tool_choice;
289
+ if (typeof body.model === "string" && /muse-spark/i.test(body.model)) {
290
+ const reasoning = body.reasoning;
291
+ if (reasoning !== null && typeof reasoning === "object" && !Array.isArray(reasoning)) {
292
+ const effort = (reasoning as Record<string, unknown>).effort;
293
+ if (typeof effort === "string" && (effort.toLowerCase() === "max" || effort.toLowerCase() === "ultra")) {
294
+ (reasoning as Record<string, unknown>).effort = "xhigh";
295
+ }
296
+ }
297
+ for (const key of ["reasoning_effort", "reasoningEffort"] as const) {
298
+ const flat = body[key];
299
+ if (typeof flat === "string" && (flat.toLowerCase() === "max" || flat.toLowerCase() === "ultra")) body[key] = "xhigh";
300
+ }
301
+ }
302
+ if (body.store === undefined) body.store = false;
303
+ }
304
+
305
+ /**
306
+ * Enforce the full OpenCode free-tier client fingerprint on a parsed request body.
307
+ * Caller placeholders are lowercased (Bash -> bash, tool_choice retargeted),
308
+ * tools translated to the target path's wire shape (Pi find -> upstream glob via
309
+ * src/tool-translation.ts), then the missing slots injected in that shape: real
310
+ * executable definitions for OMP-like callers (ask/task/todo/hub/lsp present,
311
+ * or glob without find, detected on pre-translation caller names), empty
312
+ * no-ops for Pi. Returns the original stream flag, caller-tools flag, whether
313
+ * injection added tools, and the bounded per-request restore records
314
+ * (caseRestore, findGlob, injected, injectedReal) for downstream cloaking.
315
+ * Cloaking strips by `injected` (truly-empty placeholders) only: names in
316
+ * `injectedReal` were served with executable definitions, so model calls to
317
+ * them execute downstream instead of being cloaked.
318
+ */
319
+ export function enforceOpencodeFingerprint(
320
+ body: Record<string, unknown>,
321
+ pathname: string,
322
+ ): {
323
+ clientRequestedStream: boolean;
324
+ callerHadTools: boolean;
325
+ addedTools: boolean;
326
+ caseRestore: CaseRestoreMap;
327
+ findGlob: FindGlobRestore;
328
+ injected: string[];
329
+ injectedReal: string[];
330
+ } {
331
+ const clientRequestedStream = body.stream === true;
332
+ const callerHadTools = Array.isArray(body.tools) && body.tools.length > 0;
333
+ // Zen responses shape conformance first (Chat leftovers, tool_choice none,
334
+ // spark effort clamp, store default): Kilo/Cline bodies never reach here.
335
+ if (pathname.endsWith("/responses")) normalizeResponsesBody(body);
336
+ // Upstream Zen free tier mandates stream: true for all free requests
337
+ body.stream = true;
338
+
339
+ const caseRestore = normalizePlaceholderCase(body);
340
+ retargetToolChoice(body);
341
+
342
+ const findGlob = buildFindGlobRestore(Array.isArray(body.tools) ? body.tools : []);
343
+ // Caller tool_choice naming `find` must ride upstream as `glob` — the rename
344
+ // above collapses the declaration, so an unretargeted choice dangles.
345
+ if (body.tool_choice !== undefined) {
346
+ body.tool_choice = retargetToolChoiceForUpstream(body.tool_choice, findGlob) as Record<string, unknown> | string;
347
+ }
348
+ const callerUpstream = new Set<string>();
349
+ const callerNames = new Set<string>();
350
+ if (Array.isArray(body.tools)) {
351
+ for (const tool of body.tools) {
352
+ const n = toolNameOf(tool);
353
+ if (n) {
354
+ callerNames.add(n.toLowerCase());
355
+ callerUpstream.add(upstreamToolNameFor(n).toLowerCase());
356
+ }
357
+ }
358
+ }
359
+ // OMP detection runs on pre-translation names: find is still visible here,
360
+ // so Pi find-callers are never mistaken for OMP glob-callers.
361
+ const ompLike = isOmpLikeCaller(callerNames);
362
+ const missing = OPENCODE_FINGERPRINT_TOOLS.filter((n) => !callerUpstream.has(n.toLowerCase()));
363
+ // Cloak list carries only the truly-empty placeholders; real definitions
364
+ // survive downstream so injected OMP calls execute.
365
+ const injected = ompLike ? [] : [...missing];
366
+ const injectedReal = ompLike ? [...missing] : [];
367
+
368
+ if (Array.isArray(body.tools)) {
369
+ body.tools = translateToolsForPath(body.tools, pathname);
370
+ }
371
+
372
+ const before = Array.isArray(body.tools) ? body.tools.length : 0;
373
+ if (pathname.endsWith("/responses")) {
374
+ ensureResponsesFingerprintTools(body, ompLike);
375
+ } else if (pathname.endsWith("/messages")) {
376
+ ensureMessagesFingerprintTools(body, ompLike);
377
+ } else {
378
+ ensureChatFingerprintTools(body, ompLike);
379
+ }
380
+ const after = Array.isArray(body.tools) ? body.tools.length : 0;
381
+
382
+ // Note: Upstream OpenCode Zen explicitly rejects any tool_choice other than "auto"
383
+ // with HTTP 400 (only "auto" is supported). tool_choice is auto-or-absent here:
384
+ // normalizeResponsesBody drops "none" upstream, and we never impose a choice.
385
+ // Empty-placeholder callers stay silent via the COMPAT description and the
386
+ // output-aggregator; real OMP definitions execute normally.
387
+
388
+ return { clientRequestedStream, callerHadTools, addedTools: after > before, caseRestore, findGlob, injected, injectedReal };
389
+ }
390
+
391
+ /**
392
+ * Parse raw SSE stream text into individual event blocks.
393
+ */
394
+ export function parseSseEvents(text: string): Array<{ event?: string; data: string }> {
395
+ const result: Array<{ event?: string; data: string }> = [];
396
+ const blocks = text.split(/\r?\n\r?\n/);
397
+ for (const block of blocks) {
398
+ const trimmed = block.trim();
399
+ if (!trimmed) continue;
400
+ let eventName: string | undefined;
401
+ const dataLines: string[] = [];
402
+ for (const line of trimmed.split(/\r?\n/)) {
403
+ if (line.startsWith("event:")) {
404
+ eventName = line.slice(6).trim();
405
+ } else if (line.startsWith("data:")) {
406
+ dataLines.push(line.slice(5).trimStart());
407
+ }
408
+ }
409
+ if (dataLines.length > 0) {
410
+ result.push({ event: eventName, data: dataLines.join("\n") });
411
+ }
412
+ }
413
+ return result;
414
+ }
415
+
416
+ /**
417
+ * Convert an SSE stream from an OpenAI-compatible Chat Completions endpoint
418
+ * into a single standard non-streaming ChatCompletion JSON response.
419
+ */
420
+ /** Tool-call name from a Chat Completions tool_calls entry (else ""). */
421
+ function chatCallName(tc: unknown): string {
422
+ if (!tc || typeof tc !== "object" || Array.isArray(tc)) return "";
423
+ const fn = (tc as Record<string, unknown>).function;
424
+ if (!fn || typeof fn !== "object" || Array.isArray(fn)) return "";
425
+ const n = (fn as Record<string, unknown>).name;
426
+ return typeof n === "string" ? n : "";
427
+ }
428
+
429
+ /**
430
+ * Strip injected placeholder calls from a Chat Completions message in place.
431
+ * Tool-less callers never see tool_calls (args folded to text when content is
432
+ * empty); callers with tools keep only their own calls, casing restored.
433
+ */
434
+ /**
435
+ * Only names this request actually injected are ever cloaked downstream. The
436
+ * caller's own bash is indistinguishable by name and must survive; legacy
437
+ * direct calls (no record) keep everything.
438
+ */
439
+ function cloakChatMessage(
440
+ msg: unknown,
441
+ callerHadTools: boolean,
442
+ caseRestore?: CaseRestoreMap,
443
+ findGlob?: FindGlobRestore,
444
+ injected?: readonly string[],
445
+ ): void {
446
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
447
+ const m = msg as Record<string, unknown>;
448
+ if (!Array.isArray(m.tool_calls)) return;
449
+ const calls = m.tool_calls as unknown[];
450
+ if (!callerHadTools) {
451
+ if ((!m.content || m.content === "") && calls.length > 0) {
452
+ m.content = calls
453
+ .map((tc) => {
454
+ if (!tc || typeof tc !== "object" || Array.isArray(tc)) return "";
455
+ const fn = (tc as Record<string, unknown>).function;
456
+ if (!fn || typeof fn !== "object" || Array.isArray(fn)) return "";
457
+ const f = fn as Record<string, unknown>;
458
+ return (typeof f.arguments === "string" && f.arguments) || (typeof f.name === "string" && f.name) || "";
459
+ })
460
+ .join("\n");
461
+ }
462
+ delete m.tool_calls;
463
+ return;
464
+ }
465
+ const kept: unknown[] = [];
466
+ for (const tc of calls) {
467
+ const callName = chatCallName(tc);
468
+ if (callName && injected !== undefined && injected.includes(callName.toLowerCase())) continue;
469
+ if (tc && typeof tc === "object" && !Array.isArray(tc)) {
470
+ const fn = (tc as Record<string, unknown>).function;
471
+ if (fn && typeof fn === "object" && !Array.isArray(fn) && typeof (fn as Record<string, unknown>).name === "string") {
472
+ (fn as Record<string, unknown>).name = restoreCallerName(
473
+ (fn as Record<string, unknown>).name as string,
474
+ caseRestore,
475
+ findGlob,
476
+ );
477
+ }
478
+ }
479
+ kept.push(tc);
480
+ }
481
+ if (kept.length > 0) m.tool_calls = kept;
482
+ else delete m.tool_calls;
483
+ }
484
+
485
+ export function sseToChatCompletionJson(
486
+ sseText: string,
487
+ callerHadTools = true,
488
+ caseRestore?: CaseRestoreMap,
489
+ findGlob?: FindGlobRestore,
490
+ injected?: readonly string[],
491
+ ): Record<string, unknown> {
492
+ const events = parseSseEvents(sseText);
493
+ let id = "chatcmpl-freeflow";
494
+ let model = "";
495
+ let created = Math.floor(Date.now() / 1000);
496
+ let finishReason: string | null = null;
497
+ let content = "";
498
+ let reasoningContent = "";
499
+ let role = "assistant";
500
+ let usage: unknown = undefined;
501
+ const toolCallsMap = new Map<
502
+ number,
503
+ { id: string; type: string; function: { name: string; arguments: string } }
504
+ >();
505
+
506
+ for (const ev of events) {
507
+ if (ev.data === "[DONE]") continue;
508
+ try {
509
+ const parsed = JSON.parse(ev.data);
510
+ if (parsed.id) id = parsed.id;
511
+ if (parsed.model) model = parsed.model;
512
+ if (parsed.created) created = parsed.created;
513
+ if (parsed.usage) usage = parsed.usage;
514
+ if (Array.isArray(parsed.choices)) {
515
+ for (const c of parsed.choices) {
516
+ if (c.finish_reason) finishReason = c.finish_reason;
517
+ const delta = c.delta;
518
+ if (delta) {
519
+ if (delta.role) role = delta.role;
520
+ if (typeof delta.content === "string") content += delta.content;
521
+ if (typeof delta.reasoning_content === "string") {
522
+ reasoningContent += delta.reasoning_content;
523
+ } else if (typeof delta.reasoning === "string") {
524
+ reasoningContent += delta.reasoning;
525
+ }
526
+ if (Array.isArray(delta.tool_calls)) {
527
+ for (const tc of delta.tool_calls) {
528
+ const idx = tc.index ?? 0;
529
+ const existing = toolCallsMap.get(idx) ?? {
530
+ id: tc.id || "",
531
+ type: tc.type || "function",
532
+ function: { name: "", arguments: "" },
533
+ };
534
+ if (tc.id) existing.id = tc.id;
535
+ if (tc.type) existing.type = tc.type;
536
+ if (tc.function?.name) existing.function.name += tc.function.name;
537
+ if (tc.function?.arguments) existing.function.arguments += tc.function.arguments;
538
+ toolCallsMap.set(idx, existing);
539
+ }
540
+ }
541
+ }
542
+ // Choice contains a pre-assembled message: cloak before returning.
543
+ if (c.message) {
544
+ cloakChatMessage(c.message, callerHadTools, caseRestore, findGlob, injected);
545
+ return parsed as Record<string, unknown>;
546
+ }
547
+ }
548
+ }
549
+ } catch {
550
+ // ignore unparseable data chunks
551
+ }
552
+ }
553
+
554
+ const toolCalls = Array.from(toolCallsMap.entries())
555
+ .sort(([a], [b]) => a - b)
556
+ .map(([, tc]) => tc);
557
+
558
+ const message: Record<string, unknown> = {
559
+ role,
560
+ content: content || null,
561
+ };
562
+ if (reasoningContent) {
563
+ message.reasoning_content = reasoningContent;
564
+ }
565
+ if (!callerHadTools) {
566
+ if (!content && toolCalls.length > 0) {
567
+ message.content = toolCalls.map((tc) => tc.function.arguments || tc.function.name).join("\n");
568
+ }
569
+ } else {
570
+ const kept = injected === undefined
571
+ ? toolCalls
572
+ : toolCalls.filter((tc) => !injected.includes(tc.function.name.toLowerCase()));
573
+ for (const tc of kept) tc.function.name = restoreCallerName(tc.function.name, caseRestore, findGlob);
574
+ if (kept.length > 0) message.tool_calls = kept;
575
+ }
576
+
577
+ return {
578
+ id,
579
+ object: "chat.completion",
580
+ created,
581
+ model,
582
+ choices: [
583
+ {
584
+ index: 0,
585
+ message,
586
+ finish_reason: finishReason ?? "stop",
587
+ },
588
+ ],
589
+ ...(usage ? { usage } : {}),
590
+ };
591
+ }
592
+
593
+ /**
594
+ * Strip injected placeholder function_call items from a Responses object in
595
+ * place. Only names this request injected are removed, whether or not the
596
+ * caller declared tools; caller calls keep restored names.
597
+ */
598
+ function cloakResponsesObject(resp: unknown, caseRestore?: CaseRestoreMap, findGlob?: FindGlobRestore, injected?: readonly string[]): void {
599
+ if (!resp || typeof resp !== "object" || Array.isArray(resp)) return;
600
+ const output = (resp as Record<string, unknown>).output;
601
+ if (!Array.isArray(output)) return;
602
+ const kept: unknown[] = [];
603
+ for (const item of output) {
604
+ if (item && typeof item === "object" && !Array.isArray(item)) {
605
+ const rec = item as Record<string, unknown>;
606
+ if (rec.type === "function_call") {
607
+ if (typeof rec.name === "string" && injected !== undefined && injected.includes(rec.name.toLowerCase())) continue;
608
+ if (typeof rec.name === "string") rec.name = restoreCallerName(rec.name, caseRestore, findGlob);
609
+ }
610
+ }
611
+ kept.push(item);
612
+ }
613
+ (resp as Record<string, unknown>).output = kept;
614
+ }
615
+
616
+ /**
617
+ * Convert an SSE stream from an OpenAI Responses API endpoint into a single
618
+ * standard non-streaming response object.
619
+ */
620
+ export function sseToResponsesJson(
621
+ sseText: string,
622
+ callerHadTools = true,
623
+ caseRestore?: CaseRestoreMap,
624
+ findGlob?: FindGlobRestore,
625
+ injected?: readonly string[],
626
+ ): Record<string, unknown> {
627
+ const events = parseSseEvents(sseText);
628
+ // 1. Highest fidelity: response.completed event carries the final full response object
629
+ for (let i = events.length - 1; i >= 0; i--) {
630
+ const ev = events[i];
631
+ try {
632
+ const parsed = JSON.parse(ev.data);
633
+ if (parsed?.type === "response.completed" && parsed.response && typeof parsed.response === "object") {
634
+ cloakResponsesObject(parsed.response, caseRestore, findGlob, injected);
635
+ return parsed.response as Record<string, unknown>;
636
+ }
637
+ } catch { }
638
+ }
639
+ // 2. Secondary fallback: check any event with a response object
640
+ for (let i = events.length - 1; i >= 0; i--) {
641
+ const ev = events[i];
642
+ try {
643
+ const parsed = JSON.parse(ev.data);
644
+ if (parsed?.response && typeof parsed.response === "object") {
645
+ cloakResponsesObject(parsed.response, caseRestore, findGlob, injected);
646
+ return parsed.response as Record<string, unknown>;
647
+ }
648
+ } catch { }
649
+ }
650
+ // 3. Fallback: parse entire string directly if upstream already returned JSON
651
+ try {
652
+ const parsed = JSON.parse(sseText);
653
+ if (parsed && typeof parsed === "object") {
654
+ cloakResponsesObject(parsed, caseRestore, findGlob, injected);
655
+ return parsed;
656
+ }
657
+ } catch { }
658
+
659
+ return {
660
+ id: "resp_fallback",
661
+ object: "response",
662
+ status: "completed",
663
+ output: [],
664
+ };
665
+ }
666
+ /**
667
+ * Drop placeholder tool_use blocks from a complete Anthropic message in place.
668
+ * Tool-less callers never see tool_use; callers with tools keep only their own
669
+ * blocks, names restored.
670
+ */
671
+ function cloakMessagesContent(
672
+ msg: unknown,
673
+ callerHadTools: boolean,
674
+ caseRestore?: CaseRestoreMap,
675
+ findGlob?: FindGlobRestore,
676
+ injected?: readonly string[],
677
+ ): void {
678
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
679
+ const content = (msg as Record<string, unknown>).content;
680
+ if (!Array.isArray(content)) return;
681
+ const kept: unknown[] = [];
682
+ for (const block of content) {
683
+ if (
684
+ block &&
685
+ typeof block === "object" &&
686
+ !Array.isArray(block) &&
687
+ (block as Record<string, unknown>).type === "tool_use"
688
+ ) {
689
+ const rec = block as Record<string, unknown>;
690
+ if (typeof rec.name === "string" && injected !== undefined && injected.includes(rec.name.toLowerCase())) continue;
691
+ if (callerHadTools) {
692
+ if (typeof rec.name === "string") rec.name = restoreCallerName(rec.name, caseRestore, findGlob);
693
+ kept.push(block);
694
+ }
695
+ continue;
696
+ }
697
+ kept.push(block);
698
+ }
699
+ (msg as Record<string, unknown>).content = kept;
700
+ }
701
+
702
+ /**
703
+ * Convert an SSE stream from an Anthropic Messages endpoint into a single
704
+ * standard non-streaming message object. Aggregates content_block deltas
705
+ * (text + tool_use input_json) into content blocks.
706
+ */
707
+ export function sseToMessagesJson(
708
+ sseText: string,
709
+ callerHadTools = true,
710
+ caseRestore?: CaseRestoreMap,
711
+ findGlob?: FindGlobRestore,
712
+ injected?: readonly string[],
713
+ ): Record<string, unknown> {
714
+ const events = parseSseEvents(sseText);
715
+ let id = "msg_freeflow";
716
+ let model = "";
717
+ let stopReason: string | null = null;
718
+ let usage: unknown = undefined;
719
+ const textByIndex = new Map<number, string>();
720
+ const toolByIndex = new Map<number, { id: string; name: string; inputJson: string }>();
721
+
722
+ for (const ev of events) {
723
+ if (ev.data === "[DONE]") continue;
724
+ let parsed: Record<string, unknown>;
725
+ try {
726
+ parsed = JSON.parse(ev.data) as Record<string, unknown>;
727
+ } catch {
728
+ continue;
729
+ }
730
+ const type = typeof parsed.type === "string" ? parsed.type : "";
731
+ if (type === "message_start") {
732
+ const msg = parsed.message as Record<string, unknown> | undefined;
733
+ if (msg) {
734
+ if (typeof msg.id === "string") id = msg.id;
735
+ if (typeof msg.model === "string") model = msg.model;
736
+ }
737
+ continue;
738
+ }
739
+ if (type === "content_block_start") {
740
+ const index = typeof parsed.index === "number" ? parsed.index : 0;
741
+ const block = parsed.content_block as Record<string, unknown> | undefined;
742
+ const blockType = block && typeof block.type === "string" ? block.type : "";
743
+ if (blockType === "tool_use") {
744
+ toolByIndex.set(index, {
745
+ id: typeof block?.id === "string" ? (block.id as string) : "",
746
+ name: typeof block?.name === "string" ? (block.name as string) : "",
747
+ inputJson: "",
748
+ });
749
+ } else if (!toolByIndex.has(index)) {
750
+ textByIndex.set(index, typeof block?.text === "string" ? (block.text as string) : "");
751
+ }
752
+ continue;
753
+ }
754
+ if (type === "content_block_delta") {
755
+ const index = typeof parsed.index === "number" ? parsed.index : 0;
756
+ const delta = parsed.delta as Record<string, unknown> | undefined;
757
+ const deltaType = delta && typeof delta.type === "string" ? delta.type : "";
758
+ if (deltaType === "text_delta" && typeof delta?.text === "string") {
759
+ textByIndex.set(index, (textByIndex.get(index) ?? "") + (delta.text as string));
760
+ } else if (deltaType === "input_json_delta" && typeof delta?.partial_json === "string") {
761
+ const existing = toolByIndex.get(index) ?? { id: "", name: "", inputJson: "" };
762
+ existing.inputJson += delta.partial_json as string;
763
+ toolByIndex.set(index, existing);
764
+ }
765
+ continue;
766
+ }
767
+ if (type === "message_delta") {
768
+ const delta = parsed.delta as Record<string, unknown> | undefined;
769
+ if (delta && typeof delta.stop_reason === "string") stopReason = delta.stop_reason as string;
770
+ if (parsed.usage !== undefined) usage = parsed.usage;
771
+ continue;
772
+ }
773
+ if (type === "message" && parsed.role !== undefined) {
774
+ cloakMessagesContent(parsed, callerHadTools, caseRestore, findGlob, injected);
775
+ return parsed;
776
+ }
777
+ }
778
+
779
+ const content: Record<string, unknown>[] = [];
780
+ const order = Array.from(new Set([...textByIndex.keys(), ...toolByIndex.keys()])).sort((a, b) => a - b);
781
+ for (const index of order) {
782
+ const tool = toolByIndex.get(index);
783
+ if (tool && (tool.id || tool.name)) {
784
+ if (callerHadTools && (injected === undefined || !injected.includes(tool.name.toLowerCase()))) {
785
+ let input: unknown = {};
786
+ try {
787
+ input = tool.inputJson ? JSON.parse(tool.inputJson) : {};
788
+ } catch {
789
+ input = {};
790
+ }
791
+ content.push({ type: "tool_use", id: tool.id, name: restoreCallerName(tool.name, caseRestore, findGlob), input });
792
+ }
793
+ continue;
794
+ }
795
+ const text = textByIndex.get(index) ?? "";
796
+ if (text) content.push({ type: "text", text });
797
+ }
798
+ if (!callerHadTools && content.length === 0 && toolByIndex.size > 0) {
799
+ const fallback = Array.from(toolByIndex.values())
800
+ .map((t) => t.inputJson || t.name)
801
+ .join("\n");
802
+ if (fallback) content.push({ type: "text", text: fallback });
803
+ }
804
+
805
+ return {
806
+ id,
807
+ type: "message",
808
+ role: "assistant",
809
+ model,
810
+ content,
811
+ stop_reason: stopReason ?? "end_turn",
812
+ ...(usage ? { usage } : {}),
813
+ };
814
+ }
815
+
816
+ /**
817
+ * Universal SSE-to-JSON aggregator. If input is not SSE, returns it untouched.
818
+ */
819
+ export function convertSseToJson(
820
+ sseText: string,
821
+ pathname: string,
822
+ callerHadTools = true,
823
+ caseRestore?: CaseRestoreMap,
824
+ findGlob?: FindGlobRestore,
825
+ injected?: readonly string[],
826
+ ): string {
827
+ if (!sseText || typeof sseText !== "string") return sseText;
828
+ const trimmed = sseText.trim();
829
+ if (!trimmed.includes("data:")) {
830
+ return sseText;
831
+ }
832
+ try {
833
+ if (pathname.endsWith("/responses")) {
834
+ return JSON.stringify(sseToResponsesJson(trimmed, callerHadTools, caseRestore, findGlob, injected));
835
+ }
836
+ if (pathname.endsWith("/messages")) {
837
+ return JSON.stringify(sseToMessagesJson(trimmed, callerHadTools, caseRestore, findGlob, injected));
838
+ }
839
+ return JSON.stringify(sseToChatCompletionJson(trimmed, callerHadTools, caseRestore, findGlob, injected));
840
+ } catch {
841
+ return sseText;
842
+ }
843
+ return sseText;
844
+ }