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 +6 -0
- package/package.json +1 -1
- package/src/index.ts +287 -286
- package/src/opencode-fingerprint.ts +163 -6
- package/src/proxy.ts +11 -6
- package/src/tool-translation.ts +276 -0
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.
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
110
|
-
|
|
110
|
+
models: RegisteredModel[],
|
|
111
|
+
port: number = PORT,
|
|
111
112
|
): ProviderConfig {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
164
|
-
|
|
164
|
+
export default async function(pi: ExtensionAPI): Promise<void> {
|
|
165
|
+
logInfo("pi-freeflow extension initializing...");
|
|
165
166
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
231
|
+
// 5. Register slash command
|
|
232
|
+
const commandSpec = createCommandSpec(pi, (updatedModels) => {
|
|
233
|
+
registerCatalog(updatedModels);
|
|
234
|
+
});
|
|
235
|
+
pi.registerCommand("freeflow", commandSpec);
|
|
235
236
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
294
|
+
if (freshRelayState.mode !== "off") {
|
|
295
|
+
freshRelayState.enabled = freshRelayState.relays.length > 0;
|
|
296
|
+
setActiveRelayState(freshRelayState, false);
|
|
297
|
+
}
|
|
297
298
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
333
|
+
if (freshRelayState.mode !== "off") {
|
|
334
|
+
freshRelayState.enabled = freshRelayState.relays.length > 0;
|
|
335
|
+
setActiveRelayState(freshRelayState, false);
|
|
336
|
+
}
|
|
336
337
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
|
5
|
-
* requests against the official OpenCode agentic
|
|
6
|
-
* following 4 axes are missing, upstream
|
|
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
|
|
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
|
|
730
|
-
// means the host still holds
|
|
731
|
-
// disk cache or no restart after
|
|
732
|
-
|
|
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
|
|
735
|
-
log("warn", `model ${String(parsedBody.model)} expects
|
|
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
|
+
}
|