pi-freeflow 1.18.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Every tool your agent can use now works on every free model: the proxy translates tool definitions between chat, responses, and messages formats automatically, so built-in tools, MCP server tools, and custom tools all pass through intact on all request types. Strict tool schemas are preserved, and requests sent in one format to another endpoint are converted instead of rejected.
8
+
3
9
  ## 1.18.0
4
10
 
5
11
  ### Minor Changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.18.0",
4
+ "version": "1.19.0",
5
5
  "description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
6
6
  "main": "extensions/index.ts",
7
7
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -10,11 +10,11 @@
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
12
12
  import {
13
- getAliveCatalog,
14
- mergeCatalog,
15
- readCatalogCache,
16
- refreshCatalog,
17
- setAliveCatalog,
13
+ getAliveCatalog,
14
+ mergeCatalog,
15
+ readCatalogCache,
16
+ refreshCatalog,
17
+ setAliveCatalog,
18
18
  } from "./catalog.ts";
19
19
  import { ensureDaemon as ensureClientDaemon, ensureProxyReady, getClientPort, hasFallbackServer, stopHeartbeat } from "./client.ts";
20
20
  import { createCommandSpec, stopLogsFollow, updateStatusBar } from "./commands.ts";
@@ -23,20 +23,20 @@ import { logInfo, logWarn } from "./logger.ts";
23
23
  import { ALL_MODELS, KILO_MODEL_IDS, MODEL_MAP, resolveCanonicalModelId } from "./models.ts";
24
24
  import { checkForUpdateInBackground } from "./update-checker.ts";
25
25
  import {
26
- ensureRelay,
27
- getActiveRelayState,
28
- resolveRelayState,
29
- setActiveRelayState,
30
- setStatusUi,
31
- setFreeFlowModelActive,
32
- shortRelayLabel,
26
+ ensureRelay,
27
+ getActiveRelayState,
28
+ resolveRelayState,
29
+ setActiveRelayState,
30
+ setStatusUi,
31
+ setFreeFlowModelActive,
32
+ shortRelayLabel,
33
33
  } from "./relay-state.ts";
34
34
  import type {
35
- ExtensionAPI,
36
- ExtensionContext,
37
- ExtensionUIContext,
38
- ProviderConfig,
39
- RegisteredModel,
35
+ ExtensionAPI,
36
+ ExtensionContext,
37
+ ExtensionUIContext,
38
+ ProviderConfig,
39
+ RegisteredModel,
40
40
  } from "./types.ts";
41
41
 
42
42
  // Re-export all sub-modules for clean library and programmatic usage
@@ -50,6 +50,7 @@ export * from "./relay.ts";
50
50
  export * from "./deploy.ts";
51
51
  export * from "./stream-pipe.ts";
52
52
  export * from "./proxy.ts";
53
+ export * from "./tool-translation.ts";
53
54
  export * from "./commands.ts";
54
55
  /**
55
56
  * Bind widget click to relay picker when host supports it.
@@ -57,48 +58,48 @@ export * from "./commands.ts";
57
58
  * Falls back to no-op; status hint already tells user to use `/freeflow use`.
58
59
  */
59
60
  export function bindWidgetClick(ui: ExtensionUIContext): void {
60
- const anyUi = ui as unknown as Record<string, unknown>;
61
- const candidates = ["onStatusClick", "onStatusBarClick", "onWidgetClick"] as const;
62
- let clickFn: ((h: () => void | Promise<void>) => unknown) | undefined;
63
- for (const name of candidates) {
64
- const v = anyUi[name];
65
- if (typeof v === "function") {
66
- clickFn = v as (h: () => void | Promise<void>) => unknown;
67
- break;
68
- }
69
- }
70
- if (!clickFn) return;
71
- try {
72
- clickFn.call(anyUi, async () => {
73
- try {
74
- const state = getActiveRelayState();
75
- if (!state.relays.length) {
76
- ui.notify("Use /freeflow use — no relays saved. Add one with /freeflow add <URL>", "info");
77
- return;
78
- }
79
- const fmt = (r: { url: string; label?: string }, idx: number) => {
80
- const isAct = r.url === state.url ? "★ " : " ";
81
- const lbl = r.label ? `[${r.label}] ` : `[${shortRelayLabel(r.url, state.relays)}] `;
82
- return `${isAct}[${idx + 1}] ${lbl}→ ${r.url}`;
83
- };
84
- const opts = state.relays.map(fmt);
85
- const choice = await ui.select("Switch active relay", opts);
86
- if (!choice) return;
87
- const idx = opts.indexOf(choice);
88
- const match = idx >= 0 ? state.relays[idx] : undefined;
89
- // Fallback find by formatting match
90
- const resolved = match ?? state.relays.find((r, i) => fmt(r, i) === choice);
91
- if (!resolved) return;
92
- state.enabled = true;
93
- state.url = resolved.url;
94
- ensureRelay(state, resolved.url, resolved.label);
95
- setActiveRelayState(state);
96
- setStatusUi(ui);
97
- updateStatusBar(ui);
98
- ui.notify(`Relay active: ${resolved.label || shortRelayLabel(resolved.url, state.relays)}`, "info");
99
- } catch {}
100
- });
101
- } catch {}
61
+ const anyUi = ui as unknown as Record<string, unknown>;
62
+ const candidates = ["onStatusClick", "onStatusBarClick", "onWidgetClick"] as const;
63
+ let clickFn: ((h: () => void | Promise<void>) => unknown) | undefined;
64
+ for (const name of candidates) {
65
+ const v = anyUi[name];
66
+ if (typeof v === "function") {
67
+ clickFn = v as (h: () => void | Promise<void>) => unknown;
68
+ break;
69
+ }
70
+ }
71
+ if (!clickFn) return;
72
+ try {
73
+ clickFn.call(anyUi, async () => {
74
+ try {
75
+ const state = getActiveRelayState();
76
+ if (!state.relays.length) {
77
+ ui.notify("Use /freeflow use — no relays saved. Add one with /freeflow add <URL>", "info");
78
+ return;
79
+ }
80
+ const fmt = (r: { url: string; label?: string }, idx: number) => {
81
+ const isAct = r.url === state.url ? "★ " : " ";
82
+ const lbl = r.label ? `[${r.label}] ` : `[${shortRelayLabel(r.url, state.relays)}] `;
83
+ return `${isAct}[${idx + 1}] ${lbl}→ ${r.url}`;
84
+ };
85
+ const opts = state.relays.map(fmt);
86
+ const choice = await ui.select("Switch active relay", opts);
87
+ if (!choice) return;
88
+ const idx = opts.indexOf(choice);
89
+ const match = idx >= 0 ? state.relays[idx] : undefined;
90
+ // Fallback find by formatting match
91
+ const resolved = match ?? state.relays.find((r, i) => fmt(r, i) === choice);
92
+ if (!resolved) return;
93
+ state.enabled = true;
94
+ state.url = resolved.url;
95
+ ensureRelay(state, resolved.url, resolved.label);
96
+ setActiveRelayState(state);
97
+ setStatusUi(ui);
98
+ updateStatusBar(ui);
99
+ ui.notify(`Relay active: ${resolved.label || shortRelayLabel(resolved.url, state.relays)}`, "info");
100
+ } catch { }
101
+ });
102
+ } catch { }
102
103
  }
103
104
 
104
105
 
@@ -106,249 +107,249 @@ export function bindWidgetClick(ui: ExtensionUIContext): void {
106
107
  * Construct standard ProviderConfig for pi-ai / OMP registration.
107
108
  */
108
109
  export function buildProviderConfig(
109
- models: RegisteredModel[],
110
- port: number = PORT,
110
+ models: RegisteredModel[],
111
+ port: number = PORT,
111
112
  ): ProviderConfig {
112
- return {
113
- baseUrl: `http://${HOST}:${port}/v1`,
114
- apiKey: "public",
115
- api: "openai-completions",
116
- compat: { supportsDeveloperRole: false },
117
- models: models.map((m) => {
118
- const efforts = m.thinkingLevelMap
119
- ? (Object.keys(m.thinkingLevelMap) as (keyof typeof m.thinkingLevelMap)[]).filter(
120
- (k) => m.thinkingLevelMap![k] !== null && k !== "off",
121
- )
122
- : ["minimal", "low", "medium", "high", "xhigh"];
113
+ return {
114
+ baseUrl: `http://${HOST}:${port}/v1`,
115
+ apiKey: "public",
116
+ api: "openai-completions",
117
+ compat: { supportsDeveloperRole: false },
118
+ models: models.map((m) => {
119
+ const efforts = m.thinkingLevelMap
120
+ ? (Object.keys(m.thinkingLevelMap) as (keyof typeof m.thinkingLevelMap)[]).filter(
121
+ (k) => m.thinkingLevelMap![k] !== null && k !== "off",
122
+ )
123
+ : ["minimal", "low", "medium", "high", "xhigh"];
123
124
 
124
- return {
125
- id: m.id,
126
- name: m.name,
127
- api: m.api,
128
- reasoning: m.reasoning,
129
- thinking: m.reasoning
130
- ? {
131
- mode: "effort",
132
- efforts: efforts.length > 0 ? efforts : ["low", "high", "max"],
133
- }
134
- : undefined,
135
- thinkingLevelMap: m.thinkingLevelMap,
136
- input: m.input ?? ["text"],
137
- contextWindow: m.contextWindow,
138
- maxTokens: m.maxTokens,
139
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
140
- compat: m.thinkingFormat
141
- ? {
142
- supportsDeveloperRole: false,
143
- thinkingFormat: m.thinkingFormat,
144
- }
145
- : m.api === "openai-responses"
146
- ? { sessionAffinityFormat: "openai-nosession" }
147
- : m.source === "kilo"
148
- ? {
149
- supportsDeveloperRole: false,
150
- supportsReasoningEffort: !!m.thinkingLevelMap,
151
- }
152
- : {
153
- supportsDeveloperRole: false,
154
- supportsReasoningEffort: true,
155
- },
156
- };
157
- }),
158
- };
125
+ return {
126
+ id: m.id,
127
+ name: m.name,
128
+ api: m.api,
129
+ reasoning: m.reasoning,
130
+ thinking: m.reasoning
131
+ ? {
132
+ mode: "effort",
133
+ efforts: efforts.length > 0 ? efforts : ["low", "high", "max"],
134
+ }
135
+ : undefined,
136
+ thinkingLevelMap: m.thinkingLevelMap,
137
+ input: m.input ?? ["text"],
138
+ contextWindow: m.contextWindow,
139
+ maxTokens: m.maxTokens,
140
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
141
+ compat: m.thinkingFormat
142
+ ? {
143
+ supportsDeveloperRole: false,
144
+ thinkingFormat: m.thinkingFormat,
145
+ }
146
+ : m.api === "openai-responses"
147
+ ? { sessionAffinityFormat: "openai-nosession" }
148
+ : m.source === "kilo"
149
+ ? {
150
+ supportsDeveloperRole: false,
151
+ supportsReasoningEffort: !!m.thinkingLevelMap,
152
+ }
153
+ : {
154
+ supportsDeveloperRole: false,
155
+ supportsReasoningEffort: true,
156
+ },
157
+ };
158
+ }),
159
+ };
159
160
  }
160
161
  /**
161
162
  * Main extension entrypoint
162
163
  */
163
- export default async function (pi: ExtensionAPI): Promise<void> {
164
- logInfo("pi-freeflow extension initializing...");
164
+ export default async function(pi: ExtensionAPI): Promise<void> {
165
+ logInfo("pi-freeflow extension initializing...");
165
166
 
166
- let actualPort: number;
167
- try {
168
- actualPort = await ensureClientDaemon();
169
- } catch (e) {
170
- logWarn("daemon ensure failed — using configured port", { error: String(e) });
171
- actualPort = getClientPort();
172
- }
173
- // Register static models immediately on boot so Pi/OMP picker is populated with zero latency!
174
- const registeredCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
175
- ...m,
176
- source: KILO_MODEL_IDS.has(m.id) ? "kilo" : "opencode",
177
- }));
178
- setAliveCatalog(registeredCatalog);
179
- const registerCatalog = (models: RegisteredModel[]): void => {
167
+ let actualPort: number;
168
+ try {
169
+ actualPort = await ensureClientDaemon();
170
+ } catch (e) {
171
+ logWarn("daemon ensure failed — using configured port", { error: String(e) });
172
+ actualPort = getClientPort();
173
+ }
174
+ // Register static models immediately on boot so Pi/OMP picker is populated with zero latency!
175
+ const registeredCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
176
+ ...m,
177
+ source: KILO_MODEL_IDS.has(m.id) ? "kilo" : "opencode",
178
+ }));
179
+ setAliveCatalog(registeredCatalog);
180
+ const registerCatalog = (models: RegisteredModel[]): void => {
180
181
 
181
- pi.registerProvider(
182
- "freeflow",
183
- buildProviderConfig(models, actualPort),
184
- );
185
- };
186
- const ensureDaemon = async (ui?: ExtensionUIContext): Promise<void> => {
187
- const reattach = (port: number): void => {
188
- if (port !== actualPort) {
189
- actualPort = port;
190
- registerCatalog(getAliveCatalog());
191
- logInfo(`Re-attached to proxy daemon on http://${HOST}:${port}`);
192
- }
193
- };
194
- try {
195
- reattach(await ensureClientDaemon());
196
- // Health probe before requests: one bounded respawn retry, then an
197
- // explicit proxy-down notice instead of per-model connect failures.
198
- const ready = await ensureProxyReady(actualPort);
199
- if (!ready.ok) {
200
- logWarn(ready.reason);
201
- try {
202
- ui?.notify?.(ready.reason, "error");
203
- } catch {}
204
- } else {
205
- reattach(ready.port);
206
- }
207
- } catch (e) {
208
- logWarn("proxy daemon re-attach failed", { error: String(e) });
209
- }
210
- };
182
+ pi.registerProvider(
183
+ "freeflow",
184
+ buildProviderConfig(models, actualPort),
185
+ );
186
+ };
187
+ const ensureDaemon = async (ui?: ExtensionUIContext): Promise<void> => {
188
+ const reattach = (port: number): void => {
189
+ if (port !== actualPort) {
190
+ actualPort = port;
191
+ registerCatalog(getAliveCatalog());
192
+ logInfo(`Re-attached to proxy daemon on http://${HOST}:${port}`);
193
+ }
194
+ };
195
+ try {
196
+ reattach(await ensureClientDaemon());
197
+ // Health probe before requests: one bounded respawn retry, then an
198
+ // explicit proxy-down notice instead of per-model connect failures.
199
+ const ready = await ensureProxyReady(actualPort);
200
+ if (!ready.ok) {
201
+ logWarn(ready.reason);
202
+ try {
203
+ ui?.notify?.(ready.reason, "error");
204
+ } catch { }
205
+ } else {
206
+ reattach(ready.port);
207
+ }
208
+ } catch (e) {
209
+ logWarn("proxy daemon re-attach failed", { error: String(e) });
210
+ }
211
+ };
211
212
 
212
- // 4. Background Catalog Refresh
213
- // Asynchronously probe live upstreams and update the provider if alive model list changes
214
- refreshCatalog(false)
215
- .then((aliveModels) => {
216
- if (aliveModels.length > 0) {
217
- setAliveCatalog(aliveModels);
218
- registerCatalog(mergeCatalog(registeredCatalog, aliveModels));
219
- logInfo(
220
- `Catalog refreshed: ${aliveModels.length} models verified active`,
221
- );
222
- }
223
- })
224
- .catch((err) => {
225
- logWarn("Background catalog refresh failed; retaining static catalog", {
226
- error: String(err),
227
- });
228
- });
213
+ // 4. Background Catalog Refresh
214
+ // Asynchronously probe live upstreams and update the provider if alive model list changes
215
+ refreshCatalog(false)
216
+ .then((aliveModels) => {
217
+ if (aliveModels.length > 0) {
218
+ setAliveCatalog(aliveModels);
219
+ registerCatalog(mergeCatalog(registeredCatalog, aliveModels));
220
+ logInfo(
221
+ `Catalog refreshed: ${aliveModels.length} models verified active`,
222
+ );
223
+ }
224
+ })
225
+ .catch((err) => {
226
+ logWarn("Background catalog refresh failed; retaining static catalog", {
227
+ error: String(err),
228
+ });
229
+ });
229
230
 
230
- // 5. Register slash command
231
- const commandSpec = createCommandSpec(pi, (updatedModels) => {
232
- registerCatalog(updatedModels);
233
- });
234
- pi.registerCommand("freeflow", commandSpec);
231
+ // 5. Register slash command
232
+ const commandSpec = createCommandSpec(pi, (updatedModels) => {
233
+ registerCatalog(updatedModels);
234
+ });
235
+ pi.registerCommand("freeflow", commandSpec);
235
236
 
236
- // 6. Lifecycle Listeners
237
- function isFreeFlowModelMatch(provider?: string, modelId?: string): boolean {
238
- if (provider === "freeflow") return true;
239
- if (typeof modelId === "string" && modelId.trim()) {
240
- const clean = modelId.trim();
241
- const canonical = resolveCanonicalModelId(clean);
242
- return (
243
- MODEL_MAP.has(clean) ||
244
- MODEL_MAP.has(canonical) ||
245
- getAliveCatalog().some((m) => m.id === clean || m.id === canonical)
246
- );
247
- }
248
- // When provider and modelId are not yet populated on session_start,
249
- // default to true so the widget is present when starting with freeflow
250
- if (!provider && !modelId) {
251
- return true;
252
- }
253
- return false;
254
- }
237
+ // 6. Lifecycle Listeners
238
+ function isFreeFlowModelMatch(provider?: string, modelId?: string): boolean {
239
+ if (provider === "freeflow") return true;
240
+ if (typeof modelId === "string" && modelId.trim()) {
241
+ const clean = modelId.trim();
242
+ const canonical = resolveCanonicalModelId(clean);
243
+ return (
244
+ MODEL_MAP.has(clean) ||
245
+ MODEL_MAP.has(canonical) ||
246
+ getAliveCatalog().some((m) => m.id === clean || m.id === canonical)
247
+ );
248
+ }
249
+ // When provider and modelId are not yet populated on session_start,
250
+ // default to true so the widget is present when starting with freeflow
251
+ if (!provider && !modelId) {
252
+ return true;
253
+ }
254
+ return false;
255
+ }
255
256
 
256
- pi.on?.("session_start", async (_event, ctx: ExtensionContext) => {
257
- await ensureDaemon(ctx.ui);
258
- const freshRelayState = resolveRelayState();
259
- setStatusUi(ctx.ui);
260
- try { bindWidgetClick(ctx.ui); } catch {}
257
+ pi.on?.("session_start", async (_event, ctx: ExtensionContext) => {
258
+ await ensureDaemon(ctx.ui);
259
+ const freshRelayState = resolveRelayState();
260
+ setStatusUi(ctx.ui);
261
+ try { bindWidgetClick(ctx.ui); } catch { }
261
262
 
262
- // First-run onboarding: write the flag before notifying so a crash can
263
- // never re-fire the message; never block session start.
264
- try {
265
- if (!fs.existsSync(ONBOARDED_FLAG_FILE)) {
266
- fs.mkdirSync(path.dirname(ONBOARDED_FLAG_FILE), { recursive: true });
267
- fs.writeFileSync(ONBOARDED_FLAG_FILE, "1", "utf8");
268
- const hint =
269
- freshRelayState.relays.length > 0
270
- ? `Relay pool ready: ${freshRelayState.relays.length} relay(s). Run /freeflow for pool management.`
271
- : "Relay pool empty — direct mode. Run /freeflow deploy to add your own egress.";
272
- ctx.ui?.notify?.(
273
- `freeflow ready: ${ALL_MODELS.length} free models via local proxy 127.0.0.1:28180. ${hint}`,
274
- "info",
275
- );
276
- }
277
- } catch {}
263
+ // First-run onboarding: write the flag before notifying so a crash can
264
+ // never re-fire the message; never block session start.
265
+ try {
266
+ if (!fs.existsSync(ONBOARDED_FLAG_FILE)) {
267
+ fs.mkdirSync(path.dirname(ONBOARDED_FLAG_FILE), { recursive: true });
268
+ fs.writeFileSync(ONBOARDED_FLAG_FILE, "1", "utf8");
269
+ const hint =
270
+ freshRelayState.relays.length > 0
271
+ ? `Relay pool ready: ${freshRelayState.relays.length} relay(s). Run /freeflow for pool management.`
272
+ : "Relay pool empty — direct mode. Run /freeflow deploy to add your own egress.";
273
+ ctx.ui?.notify?.(
274
+ `freeflow ready: ${ALL_MODELS.length} free models via local proxy 127.0.0.1:28180. ${hint}`,
275
+ "info",
276
+ );
277
+ }
278
+ } catch { }
278
279
 
279
- let provider: string | undefined;
280
- let modelId: string | undefined;
281
- if (ctx && typeof ctx === "object" && "model" in ctx && ctx.model && typeof ctx.model === "object") {
282
- const m = ctx.model;
283
- if ("provider" in m && typeof m.provider === "string") {
284
- provider = m.provider;
285
- }
286
- if ("id" in m && typeof m.id === "string") {
287
- modelId = m.id;
288
- }
289
- }
290
- const isFreeFlow = isFreeFlowModelMatch(provider, modelId);
291
- setFreeFlowModelActive(isFreeFlow);
280
+ let provider: string | undefined;
281
+ let modelId: string | undefined;
282
+ if (ctx && typeof ctx === "object" && "model" in ctx && ctx.model && typeof ctx.model === "object") {
283
+ const m = ctx.model;
284
+ if ("provider" in m && typeof m.provider === "string") {
285
+ provider = m.provider;
286
+ }
287
+ if ("id" in m && typeof m.id === "string") {
288
+ modelId = m.id;
289
+ }
290
+ }
291
+ const isFreeFlow = isFreeFlowModelMatch(provider, modelId);
292
+ setFreeFlowModelActive(isFreeFlow);
292
293
 
293
- if (freshRelayState.mode !== "off") {
294
- freshRelayState.enabled = freshRelayState.relays.length > 0;
295
- setActiveRelayState(freshRelayState, false);
296
- }
294
+ if (freshRelayState.mode !== "off") {
295
+ freshRelayState.enabled = freshRelayState.relays.length > 0;
296
+ setActiveRelayState(freshRelayState, false);
297
+ }
297
298
 
298
- if (isFreeFlow) {
299
- updateStatusBar(ctx.ui);
300
- } else {
301
- ctx.ui?.setStatus?.("freeflow", undefined);
302
- }
303
- try { checkForUpdateInBackground(ctx.ui as unknown as ExtensionUIContext); } catch {}
304
- });
305
- pi.on?.("model_select", async (event, ctx: ExtensionContext) => {
306
- await ensureDaemon(ctx.ui);
307
- const freshRelayState = resolveRelayState();
308
- setStatusUi(ctx.ui);
309
- try { bindWidgetClick(ctx.ui); } catch {}
310
- let provider: string | undefined;
311
- let modelId: string | undefined;
312
- if (event && typeof event === "object" && "model" in event && event.model && typeof event.model === "object") {
313
- const m = event.model;
314
- if ("provider" in m && typeof m.provider === "string") {
315
- provider = m.provider;
316
- }
317
- if ("id" in m && typeof m.id === "string") {
318
- modelId = m.id;
319
- }
320
- } else if (ctx && typeof ctx === "object" && "model" in ctx && ctx.model && typeof ctx.model === "object") {
321
- const m = ctx.model;
322
- if ("provider" in m && typeof m.provider === "string") {
323
- provider = m.provider;
324
- }
325
- if ("id" in m && typeof m.id === "string") {
326
- modelId = m.id;
327
- }
328
- }
329
- const isFreeFlow = isFreeFlowModelMatch(provider, modelId);
330
- setFreeFlowModelActive(isFreeFlow);
299
+ if (isFreeFlow) {
300
+ updateStatusBar(ctx.ui);
301
+ } else {
302
+ ctx.ui?.setStatus?.("freeflow", undefined);
303
+ }
304
+ try { checkForUpdateInBackground(ctx.ui as unknown as ExtensionUIContext); } catch { }
305
+ });
306
+ pi.on?.("model_select", async (event, ctx: ExtensionContext) => {
307
+ await ensureDaemon(ctx.ui);
308
+ const freshRelayState = resolveRelayState();
309
+ setStatusUi(ctx.ui);
310
+ try { bindWidgetClick(ctx.ui); } catch { }
311
+ let provider: string | undefined;
312
+ let modelId: string | undefined;
313
+ if (event && typeof event === "object" && "model" in event && event.model && typeof event.model === "object") {
314
+ const m = event.model;
315
+ if ("provider" in m && typeof m.provider === "string") {
316
+ provider = m.provider;
317
+ }
318
+ if ("id" in m && typeof m.id === "string") {
319
+ modelId = m.id;
320
+ }
321
+ } else if (ctx && typeof ctx === "object" && "model" in ctx && ctx.model && typeof ctx.model === "object") {
322
+ const m = ctx.model;
323
+ if ("provider" in m && typeof m.provider === "string") {
324
+ provider = m.provider;
325
+ }
326
+ if ("id" in m && typeof m.id === "string") {
327
+ modelId = m.id;
328
+ }
329
+ }
330
+ const isFreeFlow = isFreeFlowModelMatch(provider, modelId);
331
+ setFreeFlowModelActive(isFreeFlow);
331
332
 
332
- if (freshRelayState.mode !== "off") {
333
- freshRelayState.enabled = freshRelayState.relays.length > 0;
334
- setActiveRelayState(freshRelayState, false);
335
- }
333
+ if (freshRelayState.mode !== "off") {
334
+ freshRelayState.enabled = freshRelayState.relays.length > 0;
335
+ setActiveRelayState(freshRelayState, false);
336
+ }
336
337
 
337
- if (isFreeFlow) {
338
- updateStatusBar(ctx.ui);
339
- } else {
340
- ctx.ui?.setStatus?.("freeflow", undefined);
341
- }
342
- });
343
- pi.on?.("session_shutdown", () => {
344
- stopLogsFollow();
345
- if (hasFallbackServer()) {
346
- stopHeartbeat();
347
- }
348
- });
349
- process.once("exit", () => {
350
- try {
351
- stopHeartbeat();
352
- } catch {}
353
- });
338
+ if (isFreeFlow) {
339
+ updateStatusBar(ctx.ui);
340
+ } else {
341
+ ctx.ui?.setStatus?.("freeflow", undefined);
342
+ }
343
+ });
344
+ pi.on?.("session_shutdown", () => {
345
+ stopLogsFollow();
346
+ if (hasFallbackServer()) {
347
+ stopHeartbeat();
348
+ }
349
+ });
350
+ process.once("exit", () => {
351
+ try {
352
+ stopHeartbeat();
353
+ } catch { }
354
+ });
354
355
  }
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * OpenCode Zen free-tier client fingerprint enforcement & SSE stream aggregator.
3
3
  *
4
- * Upstream OpenCode Zen (/zen/v1/chat/completions and /zen/v1/responses) validates
5
- * requests against the official OpenCode agentic client fingerprint. If any of the
6
- * following 4 axes are missing, upstream answers HTTP 403 FreeTierError:
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:
7
8
  * 1. User-Agent (opencode/1.18.31, >= 1.17.0)
8
9
  * 2. x-opencode-session (canonical ses_[0-9a-f]{12}[0-9A-Za-z]{14} format)
9
10
  * 3. Tools: must declare the file-search tool quartet {bash, glob, grep, read}
@@ -13,11 +14,19 @@
13
14
  * bidirectional streaming conversion: if the client requested non-streaming
14
15
  * (stream: false), upstream is still sent stream: true to pass the gate, and the
15
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 first translates caller tools to the target path's shape and
21
+ * only then injects the missing quartet. Caller tools are never dropped.
16
22
  */
23
+ import {
24
+ OPENCODE_FINGERPRINT_TOOLS,
25
+ translateToolsForPath,
26
+ } from "./tool-translation.ts";
17
27
 
18
- export const OPENCODE_FINGERPRINT_TOOLS = ["bash", "glob", "grep", "read"] as const;
19
-
20
- export type FingerprintToolName = (typeof OPENCODE_FINGERPRINT_TOOLS)[number];
28
+ export { OPENCODE_FINGERPRINT_TOOLS };
29
+ export type { FingerprintToolName } from "./tool-translation.ts";
21
30
 
22
31
  /**
23
32
  * Safely extract the tool name whether formatted in Chat Completions style
@@ -90,8 +99,37 @@ export function ensureResponsesFingerprintTools(body: Record<string, unknown>):
90
99
  }
91
100
  }
92
101
 
102
+ /**
103
+ * Merge missing {bash, glob, grep, read} tool declarations into Anthropic
104
+ * Messages bodies. Uses the Anthropic tool shape ({ name, description,
105
+ * input_schema }).
106
+ */
107
+ export function ensureMessagesFingerprintTools(body: Record<string, unknown>): void {
108
+ if (!body || typeof body !== "object") return;
109
+ const present = new Set<string>();
110
+ if (Array.isArray(body.tools)) {
111
+ for (const tool of body.tools) {
112
+ const name = toolNameOf(tool);
113
+ if (name) present.add(name);
114
+ }
115
+ } else {
116
+ body.tools = [];
117
+ }
118
+ for (const name of OPENCODE_FINGERPRINT_TOOLS) {
119
+ if (present.has(name)) continue;
120
+ (body.tools as unknown[]).push({
121
+ name,
122
+ description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
123
+ input_schema: { type: "object", properties: {} },
124
+ });
125
+ present.add(name);
126
+ }
127
+ }
128
+
93
129
  /**
94
130
  * Enforce the full OpenCode free-tier client fingerprint on a parsed request body.
131
+ * Caller tools are first translated to the target path's wire shape (see
132
+ * src/tool-translation.ts), then the missing quartet is injected in that shape.
95
133
  * Returns whether the client originally requested streaming.
96
134
  */
97
135
  export function enforceOpencodeFingerprint(
@@ -103,11 +141,17 @@ export function enforceOpencodeFingerprint(
103
141
  // Upstream Zen free tier mandates stream: true for all free requests
104
142
  body.stream = true;
105
143
 
144
+ if (Array.isArray(body.tools)) {
145
+ body.tools = translateToolsForPath(body.tools, pathname);
146
+ }
147
+
106
148
  if (pathname.endsWith("/responses")) {
107
149
  ensureResponsesFingerprintTools(body);
108
150
  if (body.store === undefined) {
109
151
  body.store = false;
110
152
  }
153
+ } else if (pathname.endsWith("/messages")) {
154
+ ensureMessagesFingerprintTools(body);
111
155
  } else {
112
156
  ensureChatFingerprintTools(body);
113
157
  }
@@ -289,6 +333,116 @@ export function sseToResponsesJson(
289
333
  };
290
334
  }
291
335
 
336
+ /**
337
+ * Convert an SSE stream from an Anthropic Messages endpoint into a single
338
+ * standard non-streaming message object. Aggregates content_block deltas
339
+ * (text + tool_use input_json) into content blocks.
340
+ */
341
+ export function sseToMessagesJson(
342
+ sseText: string,
343
+ callerHadTools = true,
344
+ ): Record<string, unknown> {
345
+ const events = parseSseEvents(sseText);
346
+ let id = "msg_freeflow";
347
+ let model = "";
348
+ let stopReason: string | null = null;
349
+ let usage: unknown = undefined;
350
+ const textByIndex = new Map<number, string>();
351
+ const toolByIndex = new Map<number, { id: string; name: string; inputJson: string }>();
352
+
353
+ for (const ev of events) {
354
+ if (ev.data === "[DONE]") continue;
355
+ let parsed: Record<string, unknown>;
356
+ try {
357
+ parsed = JSON.parse(ev.data) as Record<string, unknown>;
358
+ } catch {
359
+ continue;
360
+ }
361
+ const type = typeof parsed.type === "string" ? parsed.type : "";
362
+ if (type === "message_start") {
363
+ const msg = parsed.message as Record<string, unknown> | undefined;
364
+ if (msg) {
365
+ if (typeof msg.id === "string") id = msg.id;
366
+ if (typeof msg.model === "string") model = msg.model;
367
+ }
368
+ continue;
369
+ }
370
+ if (type === "content_block_start") {
371
+ const index = typeof parsed.index === "number" ? parsed.index : 0;
372
+ const block = parsed.content_block as Record<string, unknown> | undefined;
373
+ const blockType = block && typeof block.type === "string" ? block.type : "";
374
+ if (blockType === "tool_use") {
375
+ toolByIndex.set(index, {
376
+ id: typeof block?.id === "string" ? (block.id as string) : "",
377
+ name: typeof block?.name === "string" ? (block.name as string) : "",
378
+ inputJson: "",
379
+ });
380
+ } else if (!toolByIndex.has(index)) {
381
+ textByIndex.set(index, typeof block?.text === "string" ? (block.text as string) : "");
382
+ }
383
+ continue;
384
+ }
385
+ if (type === "content_block_delta") {
386
+ const index = typeof parsed.index === "number" ? parsed.index : 0;
387
+ const delta = parsed.delta as Record<string, unknown> | undefined;
388
+ const deltaType = delta && typeof delta.type === "string" ? delta.type : "";
389
+ if (deltaType === "text_delta" && typeof delta?.text === "string") {
390
+ textByIndex.set(index, (textByIndex.get(index) ?? "") + (delta.text as string));
391
+ } else if (deltaType === "input_json_delta" && typeof delta?.partial_json === "string") {
392
+ const existing = toolByIndex.get(index) ?? { id: "", name: "", inputJson: "" };
393
+ existing.inputJson += delta.partial_json as string;
394
+ toolByIndex.set(index, existing);
395
+ }
396
+ continue;
397
+ }
398
+ if (type === "message_delta") {
399
+ const delta = parsed.delta as Record<string, unknown> | undefined;
400
+ if (delta && typeof delta.stop_reason === "string") stopReason = delta.stop_reason as string;
401
+ if (parsed.usage !== undefined) usage = parsed.usage;
402
+ continue;
403
+ }
404
+ if (type === "message" && parsed.role !== undefined) {
405
+ return parsed;
406
+ }
407
+ }
408
+
409
+ const content: Record<string, unknown>[] = [];
410
+ const order = Array.from(new Set([...textByIndex.keys(), ...toolByIndex.keys()])).sort((a, b) => a - b);
411
+ for (const index of order) {
412
+ const tool = toolByIndex.get(index);
413
+ if (tool && (tool.id || tool.name)) {
414
+ if (callerHadTools) {
415
+ let input: unknown = {};
416
+ try {
417
+ input = tool.inputJson ? JSON.parse(tool.inputJson) : {};
418
+ } catch {
419
+ input = {};
420
+ }
421
+ content.push({ type: "tool_use", id: tool.id, name: tool.name, input });
422
+ }
423
+ continue;
424
+ }
425
+ const text = textByIndex.get(index) ?? "";
426
+ if (text) content.push({ type: "text", text });
427
+ }
428
+ if (!callerHadTools && content.length === 0 && toolByIndex.size > 0) {
429
+ const fallback = Array.from(toolByIndex.values())
430
+ .map((t) => t.inputJson || t.name)
431
+ .join("\n");
432
+ if (fallback) content.push({ type: "text", text: fallback });
433
+ }
434
+
435
+ return {
436
+ id,
437
+ type: "message",
438
+ role: "assistant",
439
+ model,
440
+ content,
441
+ stop_reason: stopReason ?? "end_turn",
442
+ ...(usage ? { usage } : {}),
443
+ };
444
+ }
445
+
292
446
  /**
293
447
  * Universal SSE-to-JSON aggregator. If input is not SSE, returns it untouched.
294
448
  */
@@ -306,6 +460,9 @@ export function convertSseToJson(
306
460
  if (pathname.endsWith("/responses")) {
307
461
  return JSON.stringify(sseToResponsesJson(trimmed, callerHadTools));
308
462
  }
463
+ if (pathname.endsWith("/messages")) {
464
+ return JSON.stringify(sseToMessagesJson(trimmed, callerHadTools));
465
+ }
309
466
  return JSON.stringify(sseToChatCompletionJson(trimmed, callerHadTools));
310
467
  } catch {
311
468
  return sseText;
package/src/proxy.ts CHANGED
@@ -726,13 +726,18 @@ export function startProxy(
726
726
  const isStream = clientRequestedStream;
727
727
 
728
728
  // Stale-registration guard: responses-only models (muse-spark-*) must
729
- // reach upstream via /v1/responses. A chat/completions request for one
730
- // means the host still holds a pre-fix provider registration (stale
731
- // disk cache or no restart after upgrade) and upstream answers 500.
732
- if (!isKilo && typeof parsedBody?.model === "string" && target.pathname.endsWith("/chat/completions")) {
729
+ // reach upstream via /v1/responses, and messages-only models (union-alpha)
730
+ // via /v1/messages. A request on the wrong path means the host still holds
731
+ // a pre-fix provider registration (stale disk cache or no restart after
732
+ // upgrade) and upstream answers 500.
733
+ if (!isKilo && typeof parsedBody?.model === "string") {
733
734
  const knownDef = MODEL_MAP.get(String(parsedBody.model));
734
- if (knownDef?.api === "openai-responses") {
735
- log("warn", `model ${String(parsedBody.model)} expects openai-responses but got ${target.pathname} — stale provider registration (restart Pi/OMP after upgrade)`, { model: String(parsedBody.model), path: target.pathname }, reqId);
735
+ if (target.pathname.endsWith("/chat/completions") && knownDef?.api && knownDef.api !== "openai-completions") {
736
+ log("warn", `model ${String(parsedBody.model)} expects ${knownDef.api} but got ${target.pathname} — stale provider registration (restart Pi/OMP after upgrade)`, { model: String(parsedBody.model), path: target.pathname }, reqId);
737
+ } else if (target.pathname.endsWith("/messages") && knownDef?.api && knownDef.api !== "anthropic-messages") {
738
+ log("warn", `model ${String(parsedBody.model)} expects ${knownDef.api} but got ${target.pathname} — stale provider registration (restart Pi/OMP after upgrade)`, { model: String(parsedBody.model), path: target.pathname }, reqId);
739
+ } else if (target.pathname.endsWith("/responses") && knownDef?.api && knownDef.api !== "openai-responses") {
740
+ log("warn", `model ${String(parsedBody.model)} expects ${knownDef.api} but got ${target.pathname} — stale provider registration (restart Pi/OMP after upgrade)`, { model: String(parsedBody.model), path: target.pathname }, reqId);
736
741
  }
737
742
  }
738
743
  try {
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Tool translation across the three wire APIs pi-freeflow serves.
3
+ *
4
+ * freeflow is used ONLY by OMP and Pi hosts (never generic OpenAI clients),
5
+ * so the proxy must accept every tool either host can send and reshape it for
6
+ * whichever upstream API the request targets:
7
+ * - Chat Completions (`/v1/chat/completions`): { type: "function", function: { name, description, parameters } }
8
+ * - Responses (`/v1/responses`): { type: "function", name, description, parameters }
9
+ * - Anthropic Messages (`/v1/messages`): { name, description, input_schema }
10
+ *
11
+ * Canonical host inventories (grounded in the reference checkouts):
12
+ * - Pi: `ToolName` union in reference/pi/packages/coding-agent/src/core/tools/index.ts
13
+ * - OMP: `BUILTIN_TOOL_NAMES` in reference/oh-my-pi/packages/coding-agent/src/tools/builtin-names.ts
14
+ *
15
+ * Translation rules:
16
+ * - Function tools are normalized to { name, description, parameters } and
17
+ * re-emitted in the target shape. Caller tools are NEVER dropped or renamed.
18
+ * - Non-function tools (e.g. Responses built-ins like { type: "web_search" })
19
+ * pass through verbatim.
20
+ * - `parameters` and Anthropic `input_schema` are treated as the same schema.
21
+ * - First-seen wins on duplicate names.
22
+ */
23
+
24
+ /** File-search tool quartet the upstream OpenCode Zen free tier mandates. */
25
+ export const OPENCODE_FINGERPRINT_TOOLS = ["bash", "glob", "grep", "read"] as const;
26
+
27
+ export type FingerprintToolName = (typeof OPENCODE_FINGERPRINT_TOOLS)[number];
28
+
29
+ /** Pi host tools: ToolName union in reference/pi packages/coding-agent/src/core/tools/index.ts */
30
+ export const PI_TOOL_NAMES = [
31
+ "read",
32
+ "bash",
33
+ "powershell",
34
+ "edit",
35
+ "write",
36
+ "grep",
37
+ "find",
38
+ "ls",
39
+ ] as const;
40
+
41
+ /** OMP host built-ins: BUILTIN_TOOL_NAMES in reference/oh-my-pi packages/coding-agent/src/tools/builtin-names.ts */
42
+ export const OMP_TOOL_NAMES = [
43
+ "read",
44
+ "bash",
45
+ "edit",
46
+ "ast_grep",
47
+ "ast_edit",
48
+ "ask",
49
+ "debug",
50
+ "eval",
51
+ "github",
52
+ "glob",
53
+ "grep",
54
+ "lsp",
55
+ "checkpoint",
56
+ "rewind",
57
+ "security_scan",
58
+ "task",
59
+ "hub",
60
+ "todo",
61
+ "web_search",
62
+ "write",
63
+ "memory_edit",
64
+ "retain",
65
+ "recall",
66
+ "reflect",
67
+ "learn",
68
+ "manage_skill",
69
+ ] as const;
70
+
71
+ /** OMP hidden tools: HIDDEN_TOOL_NAMES in the same OMP module. */
72
+ export const OMP_HIDDEN_TOOL_NAMES = ["yield", "goal", "think"] as const;
73
+
74
+ /** Every tool name either host can send (MCP `mcp__*` and xd:// device names pass through as-is). */
75
+ export const ALL_HOST_TOOL_NAMES: ReadonlySet<string> = new Set([
76
+ ...PI_TOOL_NAMES,
77
+ ...OMP_TOOL_NAMES,
78
+ ...OMP_HIDDEN_TOOL_NAMES,
79
+ ]);
80
+
81
+ /** Placeholder description for injected compatibility tools: must never be invoked. */
82
+ export const COMPAT_TOOL_DESCRIPTION =
83
+ "Do not call this tool. It exists only for API compatibility and must never be invoked.";
84
+
85
+ export interface CanonicalTool {
86
+ name: string;
87
+ description: string;
88
+ parameters: Record<string, unknown>;
89
+ }
90
+
91
+ function nestedFn(t: Record<string, unknown>): Record<string, unknown> | null {
92
+ const fn = t.function;
93
+ return typeof fn === "object" && fn !== null && !Array.isArray(fn)
94
+ ? (fn as Record<string, unknown>)
95
+ : null;
96
+ }
97
+
98
+ function schemaOf(t: Record<string, unknown>, fn: Record<string, unknown> | null): Record<string, unknown> {
99
+ for (const key of ["parameters", "input_schema"]) {
100
+ const direct = t[key];
101
+ if (typeof direct === "object" && direct !== null && !Array.isArray(direct)) {
102
+ return direct as Record<string, unknown>;
103
+ }
104
+ if (fn) {
105
+ const nested = fn[key];
106
+ if (typeof nested === "object" && nested !== null && !Array.isArray(nested)) {
107
+ return nested as Record<string, unknown>;
108
+ }
109
+ }
110
+ }
111
+ return { type: "object", properties: {} };
112
+ }
113
+
114
+ /**
115
+ * Normalize one wire tool to canonical form. Returns null for entries that
116
+ * are not function tools (they must pass through verbatim, not be dropped).
117
+ */
118
+ export function canonicalizeTool(tool: unknown): CanonicalTool | null {
119
+ if (typeof tool !== "object" || tool === null || Array.isArray(tool)) return null;
120
+ const t = tool as Record<string, unknown>;
121
+ const fn = nestedFn(t);
122
+ const rawName =
123
+ typeof t.name === "string" && t.name.trim()
124
+ ? t.name.trim()
125
+ : fn && typeof fn.name === "string"
126
+ ? fn.name.trim()
127
+ : "";
128
+ if (!rawName) return null;
129
+ const type = typeof t.type === "string" ? t.type : "";
130
+ // Function tools across all three APIs. Anything else (web_search,
131
+ // code_interpreter, custom MCP wrappers) is not ours to reshape.
132
+ if (type !== "" && type !== "function") return null;
133
+ return {
134
+ name: rawName,
135
+ description:
136
+ typeof t.description === "string"
137
+ ? t.description
138
+ : fn && typeof fn.description === "string"
139
+ ? fn.description
140
+ : "",
141
+ parameters: schemaOf(t, fn),
142
+ };
143
+ }
144
+
145
+ /** Canonical tool -> Chat Completions shape. */
146
+ export function toChatTool(t: CanonicalTool): Record<string, unknown> {
147
+ return {
148
+ type: "function",
149
+ function: {
150
+ name: t.name,
151
+ description: t.description,
152
+ parameters: t.parameters,
153
+ },
154
+ };
155
+ }
156
+
157
+ /** Canonical tool -> Responses shape. */
158
+ export function toResponsesTool(t: CanonicalTool): Record<string, unknown> {
159
+ return {
160
+ type: "function",
161
+ name: t.name,
162
+ description: t.description,
163
+ parameters: t.parameters,
164
+ };
165
+ }
166
+
167
+ /** Canonical tool -> Anthropic Messages shape. */
168
+ export function toAnthropicTool(t: CanonicalTool): Record<string, unknown> {
169
+ return {
170
+ name: t.name,
171
+ description: t.description,
172
+ input_schema: t.parameters,
173
+ };
174
+ }
175
+
176
+ /** Which wire API does this proxy path target? */
177
+ export function apiForPathname(pathname: string): "responses" | "messages" | "chat" {
178
+ if (pathname.endsWith("/responses")) return "responses";
179
+ if (pathname.endsWith("/messages")) return "messages";
180
+ return "chat";
181
+ }
182
+
183
+ function isChatShape(tool: Record<string, unknown>): boolean {
184
+ const fn = tool.function;
185
+ return typeof fn === "object" && fn !== null && !Array.isArray(fn) &&
186
+ typeof (fn as Record<string, unknown>).name === "string";
187
+ }
188
+
189
+ function isResponsesShape(tool: Record<string, unknown>): boolean {
190
+ return tool.type === "function" && typeof tool.name === "string";
191
+ }
192
+
193
+ function isAnthropicShape(tool: Record<string, unknown>): boolean {
194
+ // Anthropic tools carry input_schema and no type/function wrapper.
195
+ // A flat { name, parameters } tool is NOT anthropic shape: it still needs
196
+ // conversion (responses tools share the flat name field).
197
+ if (tool.type === "function" || isChatShape(tool)) return false;
198
+ if (typeof tool.name !== "string") return false;
199
+ const schema = tool.input_schema;
200
+ return typeof schema === "object" && schema !== null && !Array.isArray(schema);
201
+ }
202
+
203
+ function alreadyTargetShape(tool: Record<string, unknown>, api: "responses" | "messages" | "chat"): boolean {
204
+ return api === "responses" ? isResponsesShape(tool) : api === "messages" ? isAnthropicShape(tool) : isChatShape(tool);
205
+ }
206
+
207
+ /**
208
+ * Reshape a caller tool array for the target path. Tools already in the
209
+ * target shape pass through verbatim (every extra field intact); function
210
+ * tools in another shape are converted, carrying `strict` when the caller
211
+ * set it (all three APIs accept it). Every other entry passes through
212
+ * verbatim. Duplicates collapse first-seen-wins. Never returns null entries.
213
+ */
214
+ export function translateToolsForPath(tools: unknown[], pathname: string): Record<string, unknown>[] {
215
+ const api = apiForPathname(pathname);
216
+ const seen = new Set<string>();
217
+ const out: Record<string, unknown>[] = [];
218
+ for (const tool of tools) {
219
+ if (typeof tool !== "object" || tool === null || Array.isArray(tool)) continue;
220
+ const rec = tool as Record<string, unknown>;
221
+ const canon = canonicalizeTool(tool);
222
+ if (!canon) {
223
+ // Non-function tool (or unparsable): keep verbatim so built-ins survive.
224
+ out.push(rec);
225
+ continue;
226
+ }
227
+ if (seen.has(canon.name)) continue;
228
+ seen.add(canon.name);
229
+ if (alreadyTargetShape(rec, api)) {
230
+ out.push(rec);
231
+ continue;
232
+ }
233
+ const converted =
234
+ api === "responses"
235
+ ? toResponsesTool(canon)
236
+ : api === "messages"
237
+ ? toAnthropicTool(canon)
238
+ : toChatTool(canon);
239
+ if (typeof rec.strict === "boolean") converted.strict = rec.strict;
240
+ out.push(converted);
241
+ }
242
+ return out;
243
+ }
244
+
245
+ /**
246
+ * Inject the missing fingerprint quartet into an already-translated tool
247
+ * array, using the target path's shape. Idempotent.
248
+ */
249
+ export function injectFingerprintTools(
250
+ tools: Record<string, unknown>[],
251
+ pathname: string,
252
+ ): Record<string, unknown>[] {
253
+ const present = new Set<string>();
254
+ for (const tool of tools) {
255
+ const canon = canonicalizeTool(tool);
256
+ if (canon) present.add(canon.name);
257
+ }
258
+ const api = apiForPathname(pathname);
259
+ for (const name of OPENCODE_FINGERPRINT_TOOLS) {
260
+ if (present.has(name)) continue;
261
+ const canon: CanonicalTool = {
262
+ name,
263
+ description: COMPAT_TOOL_DESCRIPTION,
264
+ parameters: { type: "object", properties: {} },
265
+ };
266
+ tools.push(
267
+ api === "responses"
268
+ ? toResponsesTool(canon)
269
+ : api === "messages"
270
+ ? toAnthropicTool(canon)
271
+ : toChatTool(canon),
272
+ );
273
+ present.add(name);
274
+ }
275
+ return tools;
276
+ }