pi-freeflow 1.44.1 → 1.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +11 -13
- package/package.json +1 -1
- package/src/catalog.ts +38 -14
- package/src/client-recovery.ts +4 -1
- package/src/client-staging.ts +53 -3
- package/src/client.ts +37 -19
- package/src/cline-accounts.ts +7 -4
- package/src/cline-device-auth.ts +5 -3
- package/src/cline-pool.ts +176 -126
- package/src/cline-roll.ts +71 -38
- package/src/commands-relay.ts +2 -2
- package/src/commands-startup.ts +4 -1
- package/src/commands.ts +28 -6
- package/src/daemon.ts +16 -1
- package/src/deploy.ts +3 -1
- package/src/fingerprint-tools.ts +1 -1
- package/src/index.ts +10 -6
- package/src/lane-translation.ts +22 -3
- package/src/models.ts +70 -112
- package/src/opencode-fingerprint.ts +5 -2
- package/src/opencode-identity.ts +10 -3
- package/src/proxy-lanes.ts +15 -3
- package/src/proxy-ports.ts +2 -2
- package/src/proxy.ts +29 -14
- package/src/relay-state.ts +9 -4
- package/src/relay.ts +78 -31
- package/src/sse-aggregator.ts +1 -0
- package/src/stream-cloak.ts +7 -4
- package/src/stream-pipe.ts +12 -2
- package/src/system-ca-fetch.ts +15 -4
- package/src/token.ts +7 -2
- package/src/tool-args.ts +10 -3
- package/src/toolcall-audit.ts +20 -7
- package/src/update-checker.ts +17 -1
- package/src/update-core.ts +5 -2
- package/src/updater.ts +8 -3
- package/src/upstream-health.ts +22 -2
- package/src/zen-sidecar.ts +1 -0
- package/src/zen-transport.ts +48 -18
package/src/daemon.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import path from "node:path";
|
|
13
|
+
import fs from "node:fs";
|
|
13
14
|
import { fileURLToPath } from "node:url";
|
|
14
15
|
import {
|
|
15
16
|
DAEMON_GC_MS,
|
|
@@ -34,12 +35,26 @@ import {
|
|
|
34
35
|
import { getActiveRequests, startClineKeepAlive, startProxy } from "./proxy.ts";
|
|
35
36
|
import { loadRelayState, setActiveRelayState } from "./relay-state.ts";
|
|
36
37
|
|
|
38
|
+
/** True when this module was run as the entry script (not imported by tests). */
|
|
39
|
+
function safeCanonicalPath(p: string): string {
|
|
40
|
+
try {
|
|
41
|
+
return fs.realpathSync.native(path.resolve(p));
|
|
42
|
+
} catch {
|
|
43
|
+
return path.resolve(p);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
/** True when this module was run as the entry script (not imported by tests). */
|
|
38
48
|
export function isDaemonMain(): boolean {
|
|
39
49
|
try {
|
|
40
50
|
const entry = process.argv[1];
|
|
41
51
|
if (!entry) return false;
|
|
42
|
-
|
|
52
|
+
const canonicalEntry = safeCanonicalPath(entry);
|
|
53
|
+
const canonicalSelf = safeCanonicalPath(fileURLToPath(import.meta.url));
|
|
54
|
+
if (process.platform === "win32") {
|
|
55
|
+
return canonicalEntry.toLowerCase() === canonicalSelf.toLowerCase();
|
|
56
|
+
}
|
|
57
|
+
return canonicalEntry === canonicalSelf;
|
|
43
58
|
} catch {
|
|
44
59
|
return false;
|
|
45
60
|
}
|
package/src/deploy.ts
CHANGED
|
@@ -80,6 +80,7 @@ const isPrivateHostname = function(h) {
|
|
|
80
80
|
return false
|
|
81
81
|
}
|
|
82
82
|
if (host.includes(":")) {
|
|
83
|
+
if (host.startsWith("64:ff9b:") || host.startsWith("2002:")) return true
|
|
83
84
|
if (host.startsWith("fc") || host.startsWith("fd")) return true
|
|
84
85
|
if (/^fe[89ab]/.test(host)) return true
|
|
85
86
|
return false
|
|
@@ -121,7 +122,7 @@ async function relayHandler(req) {
|
|
|
121
122
|
DENY_HEADERS.forEach((h) => headers.delete(h));
|
|
122
123
|
headers.delete("x-relay-auth");
|
|
123
124
|
try {
|
|
124
|
-
const init = { method: req.method, headers };
|
|
125
|
+
const init = { method: req.method, headers, redirect: "manual" };
|
|
125
126
|
if (req.method !== "GET" && req.method !== "HEAD") { init.body = req.body; init.duplex = "half"; }
|
|
126
127
|
const response = await fetch(resolved.url, init);
|
|
127
128
|
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
@@ -242,6 +243,7 @@ export async function deployVercelRelay(
|
|
|
242
243
|
|
|
243
244
|
const depJson = (await dep.json()) as { id?: string; uid?: string; projectId?: string };
|
|
244
245
|
const depId = depJson.id || depJson.uid;
|
|
246
|
+
if (!depId) throw new Error("Vercel did not return a deployment id");
|
|
245
247
|
const projectId = depJson.projectId || name;
|
|
246
248
|
|
|
247
249
|
// 2. Make the deployment public (disable SSO protection if enabled on team)
|
package/src/fingerprint-tools.ts
CHANGED
|
@@ -41,7 +41,7 @@ export function clampedEffortFor(model: string, effort: string): string | null {
|
|
|
41
41
|
// Alias of a supported level: rewrite, because upstream knows the level, not
|
|
42
42
|
// the alias.
|
|
43
43
|
if (isUltra && key in map && map[key] !== null && map[key] !== undefined) return map[key];
|
|
44
|
-
// Effort is supported (non-null) → no clamp
|
|
44
|
+
// Effort is supported (non-null) → no clamp needed
|
|
45
45
|
if (key in map && map[key] !== null && map[key] !== undefined) return null;
|
|
46
46
|
// Effort key is unknown and not "ultra" → let upstream decide
|
|
47
47
|
if (!(key in map) && !isUltra) return null;
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
refreshCatalog,
|
|
17
17
|
setAliveCatalog,
|
|
18
18
|
} from "./catalog.ts";
|
|
19
|
-
import { ensureDaemon as ensureClientDaemon, ensureProxyReady, getClientPort,
|
|
19
|
+
import { ensureDaemon as ensureClientDaemon, ensureProxyReady, getClientPort, stopHeartbeat } from "./client.ts";
|
|
20
20
|
import { createCommandSpec, stopLogsFollow, updateStatusBar } from "./commands.ts";
|
|
21
21
|
import { HOST, ONBOARDED_FLAG_FILE, PORT } from "./config.ts";
|
|
22
22
|
import { logInfo, logWarn } from "./logger.ts";
|
|
@@ -179,6 +179,9 @@ export function buildProviderConfig(
|
|
|
179
179
|
supportsDeveloperRole: false,
|
|
180
180
|
supportsReasoningEffort: true,
|
|
181
181
|
}),
|
|
182
|
+
...(m.thinkingLevelMap !== undefined
|
|
183
|
+
? { reasoningEffortMap: Object.fromEntries(Object.entries(m.thinkingLevelMap).filter(([, v]) => typeof v === "string")) }
|
|
184
|
+
: {}),
|
|
182
185
|
...(m.requiresReasoningContentOnAssistantMessages !== undefined
|
|
183
186
|
? { requiresReasoningContentOnAssistantMessages: m.requiresReasoningContentOnAssistantMessages }
|
|
184
187
|
: {}),
|
|
@@ -371,15 +374,16 @@ export default async function(pi: ExtensionAPI): Promise<void> {
|
|
|
371
374
|
ctx.ui?.setStatus?.("freeflow", undefined);
|
|
372
375
|
}
|
|
373
376
|
});
|
|
374
|
-
pi.on?.("session_shutdown", () => {
|
|
377
|
+
pi.on?.("session_shutdown", async () => {
|
|
375
378
|
stopLogsFollow();
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
+
await stopHeartbeat();
|
|
380
|
+
});
|
|
381
|
+
process.once("beforeExit", () => {
|
|
382
|
+
void stopHeartbeat();
|
|
379
383
|
});
|
|
380
384
|
process.once("exit", () => {
|
|
381
385
|
try {
|
|
382
|
-
stopHeartbeat();
|
|
386
|
+
void stopHeartbeat();
|
|
383
387
|
} catch { }
|
|
384
388
|
});
|
|
385
389
|
}
|
package/src/lane-translation.ts
CHANGED
|
@@ -49,10 +49,20 @@ export function responsesInputToChatMessages(input: unknown): Record<string, unk
|
|
|
49
49
|
if (typeof item !== "object" || item === null || Array.isArray(item)) continue;
|
|
50
50
|
const rec = item as Record<string, unknown>;
|
|
51
51
|
if (rec.type === "function_call") {
|
|
52
|
-
const args = typeof rec.arguments === "string" ? rec.arguments : JSON.stringify(rec.arguments
|
|
52
|
+
const args = typeof rec.arguments === "string" ? rec.arguments : (rec.arguments !== undefined && rec.arguments !== null ? JSON.stringify(rec.arguments) : "{}");
|
|
53
53
|
const name = typeof rec.name === "string" ? rec.name : "tool";
|
|
54
54
|
const id = typeof rec.call_id === "string" ? rec.call_id : typeof rec.id === "string" ? rec.id : "";
|
|
55
|
-
|
|
55
|
+
const call = { id, type: "function", function: { name, arguments: args } };
|
|
56
|
+
const lastMsg = messages[messages.length - 1];
|
|
57
|
+
if (lastMsg && lastMsg.role === "assistant") {
|
|
58
|
+
if (Array.isArray(lastMsg.tool_calls)) {
|
|
59
|
+
(lastMsg.tool_calls as unknown[]).push(call);
|
|
60
|
+
} else {
|
|
61
|
+
lastMsg.tool_calls = [call];
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
messages.push({ role: "assistant", content: null, tool_calls: [call] });
|
|
65
|
+
}
|
|
56
66
|
continue;
|
|
57
67
|
}
|
|
58
68
|
if (rec.type === "function_call_output") {
|
|
@@ -98,6 +108,15 @@ export function chatBodyFromResponsesBody(body: Record<string, unknown>): Record
|
|
|
98
108
|
for (const key of ["temperature", "top_p", "max_tokens", "max_completion_tokens", "max_output_tokens", "stop", "presence_penalty", "frequency_penalty", "seed", "user", "parallel_tool_calls", "response_format"] as const) {
|
|
99
109
|
if (body[key] !== undefined) out[key] = body[key];
|
|
100
110
|
}
|
|
111
|
+
if (body.max_output_tokens !== undefined && out.max_tokens === undefined) {
|
|
112
|
+
out.max_tokens = body.max_output_tokens;
|
|
113
|
+
}
|
|
114
|
+
if (out.tool_choice !== undefined && typeof out.tool_choice === "object" && out.tool_choice !== null && !Array.isArray(out.tool_choice)) {
|
|
115
|
+
const tc = out.tool_choice as Record<string, unknown>;
|
|
116
|
+
if (tc.type === "function" && typeof tc.name === "string" && tc.function === undefined) {
|
|
117
|
+
out.tool_choice = { type: "function", function: { name: tc.name } };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
101
120
|
return out;
|
|
102
121
|
}
|
|
103
122
|
|
|
@@ -141,7 +160,7 @@ export function chatResponsesJsonFromChatCompletion(chat: Record<string, unknown
|
|
|
141
160
|
if (typeof tc !== "object" || tc === null || Array.isArray(tc)) continue;
|
|
142
161
|
const rec = tc as Record<string, unknown>;
|
|
143
162
|
const fn = typeof rec.function === "object" && rec.function !== null && !Array.isArray(rec.function) ? (rec.function as Record<string, unknown>) : {};
|
|
144
|
-
const args = typeof fn.arguments === "string" ? fn.arguments : JSON.stringify(fn.arguments
|
|
163
|
+
const args = typeof fn.arguments === "string" ? fn.arguments : (fn.arguments !== undefined && fn.arguments !== null ? JSON.stringify(fn.arguments) : "{}");
|
|
145
164
|
const callId = typeof rec.id === "string" && rec.id.length > 0 ? rec.id : `call_cline_${output.length}`;
|
|
146
165
|
output.push({ type: "function_call", id: callId, call_id: callId, name: typeof fn.name === "string" ? fn.name : "tool", arguments: args });
|
|
147
166
|
}
|
package/src/models.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Static model definitions and upstream routing catalogs for pi-freeflow
|
|
3
3
|
*
|
|
4
|
-
* Defines the
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
4
|
+
* Defines the 30 verified free models (live-verified 2026-11-09):
|
|
5
|
+
* - 11 OpenCode Zen models (all Chat Completions / Responses)
|
|
6
|
+
* - 10 KiloCode Keyless Gateway models (OpenRouter format)
|
|
7
7
|
* - 1 Cline direct-only model (per-user pool; z-ai/glm-5.3-flash)
|
|
8
8
|
* - 8 OVHcloud AI Endpoints models (keyless, direct-only)
|
|
9
9
|
*
|
|
@@ -273,6 +273,64 @@ export const OPENCODE_MODELS: ModelDef[] = [
|
|
|
273
273
|
max: "max",
|
|
274
274
|
},
|
|
275
275
|
},
|
|
276
|
+
{
|
|
277
|
+
// Added 2026-11-03: Stealth reasoning model on OpenCode Zen (/zen/v1/models: crouton-alpha-free).
|
|
278
|
+
// 1,048,576 context, 270,000 max output. Modalities: text and image.
|
|
279
|
+
id: "crouton-alpha-free",
|
|
280
|
+
name: "Crouton Alpha [OpenCode]",
|
|
281
|
+
reasoning: true,
|
|
282
|
+
contextWindow: 1_048_576,
|
|
283
|
+
maxTokens: 270_000,
|
|
284
|
+
input: ["text", "image"],
|
|
285
|
+
thinkingLevelMap: {
|
|
286
|
+
off: null,
|
|
287
|
+
minimal: "minimal",
|
|
288
|
+
low: "low",
|
|
289
|
+
medium: "medium",
|
|
290
|
+
high: "high",
|
|
291
|
+
xhigh: "xhigh",
|
|
292
|
+
max: "max",
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
// Added 2026-11-06: Stealth model on OpenCode Zen (/zen/v1/models: iceman-free).
|
|
297
|
+
// 262,144 context, 131,072 max output. Modalities: text.
|
|
298
|
+
id: "iceman-free",
|
|
299
|
+
name: "Iceman Free [OpenCode]",
|
|
300
|
+
reasoning: true,
|
|
301
|
+
contextWindow: 262_144,
|
|
302
|
+
maxTokens: 131_072,
|
|
303
|
+
input: ["text"],
|
|
304
|
+
thinkingLevelMap: {
|
|
305
|
+
off: null,
|
|
306
|
+
minimal: null,
|
|
307
|
+
low: "low",
|
|
308
|
+
medium: null,
|
|
309
|
+
high: "high",
|
|
310
|
+
xhigh: null,
|
|
311
|
+
max: "max",
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
// Added 2026-11-05: Thinking Machines Inkling 2 Small on OpenCode Zen (/zen/v1/models: inkling-2-small-free).
|
|
316
|
+
// 1,048,576 context, 131,072 max output. Modalities: text and image.
|
|
317
|
+
id: "inkling-2-small-free",
|
|
318
|
+
name: "Inkling 2 Small [OpenCode]",
|
|
319
|
+
reasoning: true,
|
|
320
|
+
requiresReasoningContentOnAssistantMessages: true,
|
|
321
|
+
contextWindow: 1_048_576,
|
|
322
|
+
maxTokens: 131_072,
|
|
323
|
+
input: ["text", "image"],
|
|
324
|
+
thinkingLevelMap: {
|
|
325
|
+
off: null,
|
|
326
|
+
minimal: "minimal",
|
|
327
|
+
low: "low",
|
|
328
|
+
medium: "medium",
|
|
329
|
+
high: "high",
|
|
330
|
+
xhigh: "high",
|
|
331
|
+
max: "max",
|
|
332
|
+
},
|
|
333
|
+
},
|
|
276
334
|
];
|
|
277
335
|
|
|
278
336
|
/**
|
|
@@ -360,36 +418,11 @@ export const KILO_MODELS: ModelDef[] = [
|
|
|
360
418
|
thinkingLevelMap: KILO_REASONING_MAP,
|
|
361
419
|
},
|
|
362
420
|
{
|
|
363
|
-
id: "
|
|
364
|
-
name: "
|
|
365
|
-
reasoning: true,
|
|
366
|
-
// Built-in catalog spec: 1,048,576 context. The free gateway's runtime cap
|
|
367
|
-
// is 262,144; the catalog follows the model's built-in highest spec and
|
|
368
|
-
// surfaces the upstream error above that cap.
|
|
369
|
-
contextWindow: 1_048_576,
|
|
370
|
-
maxTokens: 131_072,
|
|
371
|
-
input: ["text"],
|
|
372
|
-
thinkingFormat: "openrouter",
|
|
373
|
-
thinkingLevelMap: KILO_REASONING_MAP,
|
|
374
|
-
},
|
|
375
|
-
{
|
|
376
|
-
id: "poolside/laguna-xs-2.1:free",
|
|
377
|
-
name: "Laguna XS 2.1 [Kilo]",
|
|
421
|
+
id: "liquid/lfm-3-1.7b:free",
|
|
422
|
+
name: "Liquid LFM3 1.7B [Kilo]",
|
|
378
423
|
reasoning: true,
|
|
379
424
|
contextWindow: 262_144,
|
|
380
|
-
maxTokens:
|
|
381
|
-
input: ["text"],
|
|
382
|
-
thinkingFormat: "openrouter",
|
|
383
|
-
thinkingLevelMap: KILO_REASONING_MAP,
|
|
384
|
-
},
|
|
385
|
-
{
|
|
386
|
-
id: "liquid/lfm-2.5-2.6b:free",
|
|
387
|
-
name: "Liquid LFM 2.5 [Kilo]",
|
|
388
|
-
reasoning: true,
|
|
389
|
-
// Raised 2026-10-20 to the 128,000 ctx the published twin advertises; the
|
|
390
|
-
// 32,768 output ceiling is unchanged.
|
|
391
|
-
contextWindow: 128_000,
|
|
392
|
-
maxTokens: 32_768,
|
|
425
|
+
maxTokens: 8_192,
|
|
393
426
|
input: ["text"],
|
|
394
427
|
thinkingFormat: "openrouter",
|
|
395
428
|
thinkingLevelMap: KILO_REASONING_MAP,
|
|
@@ -449,79 +482,6 @@ export const KILO_MODELS: ModelDef[] = [
|
|
|
449
482
|
thinkingFormat: "openrouter",
|
|
450
483
|
thinkingLevelMap: KILO_REASONING_MAP,
|
|
451
484
|
},
|
|
452
|
-
{
|
|
453
|
-
// Added 2026-10-22: this is the Kilo twin of the Zen `beam-free` entry
|
|
454
|
-
// (same Reflection AI weights). 1,048,576 / 131,072 agrees across the
|
|
455
|
-
// gateway, models.dev (kilo, vercel and opencode rows) and the vendor docs,
|
|
456
|
-
// so no reconciliation was needed. Tool call + agent loop verified on chat
|
|
457
|
-
// and /v1/responses.
|
|
458
|
-
// Always-on reasoning: the model thinks even with `none`/`off`, so the
|
|
459
|
-
// toggle cannot disable it and only the effort ladder is exposed. `minimal`
|
|
460
|
-
// is accepted but ignored upstream, hence null. `max` is a real level.
|
|
461
|
-
// Upstream echoes the bare model id `reflection/beam` without a provider
|
|
462
|
-
// field; the catalog keeps the `reflection/beam-free` listing id here so
|
|
463
|
-
// alongside `beam-kilo` routing stays unambiguous against the Zen `beam`.
|
|
464
|
-
id: "reflection/beam-free",
|
|
465
|
-
name: "Beam 1.0 [Kilo]",
|
|
466
|
-
reasoning: true,
|
|
467
|
-
contextWindow: 1_048_576,
|
|
468
|
-
maxTokens: 131_072,
|
|
469
|
-
input: ["text"],
|
|
470
|
-
thinkingFormat: "openrouter",
|
|
471
|
-
thinkingLevelMap: {
|
|
472
|
-
off: null,
|
|
473
|
-
minimal: null,
|
|
474
|
-
low: "low",
|
|
475
|
-
medium: "medium",
|
|
476
|
-
high: "high",
|
|
477
|
-
xhigh: "xhigh",
|
|
478
|
-
max: "max",
|
|
479
|
-
},
|
|
480
|
-
},
|
|
481
|
-
{
|
|
482
|
-
// Added 2026-10-22: exact gateway id, tool call + agent loop verified on
|
|
483
|
-
// both the chat and /v1/responses paths. Same weights as the Zen
|
|
484
|
-
// `flint-40b-free` entry, which publishes 65,536 output for them, so the
|
|
485
|
-
// declared output is the conservative published figure.
|
|
486
|
-
// Context: the vendor card says prompt and output SHARE the 262,144 window.
|
|
487
|
-
// The gateway reports 235,929 for this id, which is its 0.9x-window
|
|
488
|
-
// convention rather than a measured cap; the shared published window is the
|
|
489
|
-
// declared value.
|
|
490
|
-
// Thinking is mandatory: `none` answers 400, so `off` stays null. Reasoning
|
|
491
|
-
// is verbose — on a tight max_tokens budget the thinking can consume the
|
|
492
|
-
// whole allowance and end the turn with empty content, which is a budget
|
|
493
|
-
// symptom, not a dead model.
|
|
494
|
-
id: "recursive/flint-v1.0:free",
|
|
495
|
-
name: "Flint 40B [Kilo]",
|
|
496
|
-
reasoning: true,
|
|
497
|
-
contextWindow: 262_144,
|
|
498
|
-
maxTokens: 65_536,
|
|
499
|
-
input: ["text"],
|
|
500
|
-
thinkingFormat: "openrouter",
|
|
501
|
-
thinkingLevelMap: KILO_REASONING_MAP,
|
|
502
|
-
},
|
|
503
|
-
{
|
|
504
|
-
// Added 2026-10-22: exact gateway id, tool call + agent loop verified on
|
|
505
|
-
// both the chat and /v1/responses paths. 262,144 / 235,929 is the pair this
|
|
506
|
-
// lane publishes through the gateway `top_provider`, and no cross-lane cap
|
|
507
|
-
// exists for the id — there is no twin elsewhere to reconcile against.
|
|
508
|
-
// Both figures follow the same 0.9x-window convention: prompt and output
|
|
509
|
-
// share the 262,144 window, so 235,929 is the output budget that still
|
|
510
|
-
// leaves prompt headroom.
|
|
511
|
-
// `none` disables thinking and answers 200, but like `off` everywhere else
|
|
512
|
-
// it maps nowhere here, so the shared Kilo ladder is declared.
|
|
513
|
-
// Reliability: roughly 60 percent of attempts hit the shared Phala 429
|
|
514
|
-
// quota. A 429 means the model is alive and rate-limited — never prune on
|
|
515
|
-
// it; the same id answers 200 once the shared pool frees up.
|
|
516
|
-
id: "xingchen-agi/xing4.0-29b-a4b:free",
|
|
517
|
-
name: "Xing4.0 29B A4B [Kilo]",
|
|
518
|
-
reasoning: true,
|
|
519
|
-
contextWindow: 262_144,
|
|
520
|
-
maxTokens: 235_929,
|
|
521
|
-
input: ["text"],
|
|
522
|
-
thinkingFormat: "openrouter",
|
|
523
|
-
thinkingLevelMap: KILO_REASONING_MAP,
|
|
524
|
-
},
|
|
525
485
|
];
|
|
526
486
|
|
|
527
487
|
/**
|
|
@@ -730,7 +690,7 @@ export const MODEL_ALIASES: Record<string, string> = {
|
|
|
730
690
|
"nemotron-3-ultra-550b": "nvidia/nemotron-3-ultra-550b-a55b:free",
|
|
731
691
|
"nemotron-3.5-lightning": "nvidia/nemotron-3.5-lightning:free",
|
|
732
692
|
"north-mini-code": "cohere/north-mini-code:free",
|
|
733
|
-
"lfm-
|
|
693
|
+
"lfm-3": "liquid/lfm-3-1.7b:free",
|
|
734
694
|
"glm-5.3-flash": "glm-5.3-flash-free",
|
|
735
695
|
"glyph-cluster": "stealth/glyph-cluster",
|
|
736
696
|
"mimo-v2.6-flash": "mimo-v2.6-flash-free",
|
|
@@ -738,12 +698,10 @@ export const MODEL_ALIASES: Record<string, string> = {
|
|
|
738
698
|
"beam": "beam-free",
|
|
739
699
|
"deepseek-v4.1-flash": "deepseek-v4.1-flash-free",
|
|
740
700
|
"flint": "flint-40b-free",
|
|
741
|
-
"
|
|
742
|
-
"
|
|
743
|
-
"
|
|
744
|
-
|
|
745
|
-
"laguna-s-2.1:free": "poolside/laguna-s-2.1:free",
|
|
746
|
-
"laguna-xs-2.1:free": "poolside/laguna-xs-2.1:free",
|
|
701
|
+
"crouton": "crouton-alpha-free",
|
|
702
|
+
"iceman": "iceman-free",
|
|
703
|
+
"inkling-2-small": "inkling-2-small-free",
|
|
704
|
+
"nemotron-3.5-super-vl-kilo": "nvidia/nemotron-3.5-super-vl:free",
|
|
747
705
|
"kilo-auto": "kilo-auto/free",
|
|
748
706
|
"openrouter": "openrouter/free",
|
|
749
707
|
// OVHcloud AI Endpoints — slash-free spellings of the `ovh-free/` ids.
|
|
@@ -795,7 +753,7 @@ export const OVH_MODEL_IDS = new Set<string>([
|
|
|
795
753
|
.map(([alias]) => alias),
|
|
796
754
|
]);
|
|
797
755
|
/**
|
|
798
|
-
* Combined list of all
|
|
756
|
+
* Combined list of all 30 static free models (canonical)
|
|
799
757
|
*/
|
|
800
758
|
export const ALL_MODELS: ModelDef[] = [...OPENCODE_MODELS, ...KILO_MODELS, ...CLINE_MODELS, ...OVH_MODELS];
|
|
801
759
|
|
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
upstreamToolNameFor,
|
|
55
55
|
type FindGlobRestore,
|
|
56
56
|
} from "./tool-translation.ts";
|
|
57
|
+
import { OPENCODE_MODELS, resolveCanonicalModelId } from "./models.ts";
|
|
57
58
|
import { buildArgRepairSchemas } from "./tool-args.ts";
|
|
58
59
|
import {
|
|
59
60
|
type CaseRestoreMap,
|
|
@@ -120,8 +121,10 @@ export function enforceOpencodeFingerprint(
|
|
|
120
121
|
// Zen takes the bare model id: the live CLI sends `model: <bare>` (e.g.
|
|
121
122
|
// glm-5.3-flash-free), never the `opencode/`-prefixed catalog id the host
|
|
122
123
|
// may carry. Strip one prefix so a prefixed id still routes.
|
|
123
|
-
if (typeof body.model === "string"
|
|
124
|
-
body.model
|
|
124
|
+
if (typeof body.model === "string") {
|
|
125
|
+
const stripped = body.model.startsWith("opencode/") ? body.model.slice("opencode/".length) : body.model;
|
|
126
|
+
const canonical = resolveCanonicalModelId(stripped);
|
|
127
|
+
body.model = OPENCODE_MODELS.some((m) => m.id === canonical) ? canonical : stripped;
|
|
125
128
|
}
|
|
126
129
|
// Zen responses shape conformance first (Chat leftovers, tool_choice, spark
|
|
127
130
|
// effort clamp, store default): Kilo/Cline bodies never reach here.
|
package/src/opencode-identity.ts
CHANGED
|
@@ -141,11 +141,18 @@ export function probeInstalledOpenCodeVersion(opts: {
|
|
|
141
141
|
spawnImpl?: OpenCodeSpawnSync;
|
|
142
142
|
} = {}): string | null {
|
|
143
143
|
const execPath = opts.execPath ?? "opencode";
|
|
144
|
-
const timeoutMs = opts.timeoutMs ??
|
|
144
|
+
const timeoutMs = opts.timeoutMs ?? 1_500;
|
|
145
145
|
const now = Date.now();
|
|
146
146
|
if (binaryMemo && binaryMemo.key === execPath && now - binaryMemo.probedAt < OPENCODE_BINARY_VERSION_TTL_MS) {
|
|
147
147
|
return binaryMemo.version;
|
|
148
148
|
}
|
|
149
|
+
if (opts.spawnImpl === undefined && opts.execPath === undefined && process.env.NODE_TEST_CONTEXT) {
|
|
150
|
+
const firstPath = (process.env.PATH ?? "").split(path.delimiter)[0] ?? "";
|
|
151
|
+
if (!firstPath.includes("pff-binver-")) {
|
|
152
|
+
binaryMemo = { key: execPath, version: null, probedAt: now };
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
149
156
|
let version: string | null = null;
|
|
150
157
|
try {
|
|
151
158
|
const spawn = opts.spawnImpl ?? defaultOpenCodeSpawn;
|
|
@@ -227,11 +234,11 @@ export function _resetLiveOpenCodeVersionForTest(): void {
|
|
|
227
234
|
function currentOpenCodeVersion(): string {
|
|
228
235
|
const override = toOpenCodeUserAgent(process.env[OPENCODE_USER_AGENT_ENV]);
|
|
229
236
|
if (override) return override.slice("opencode/".length);
|
|
230
|
-
const installed = probeInstalledOpenCodeVersion();
|
|
231
|
-
if (installed && installed.split(".")[0] === "1") return installed;
|
|
232
237
|
if (liveOpenCodeVersion && isSupportedOpenCodeVersion(liveOpenCodeVersion)) return liveOpenCodeVersion;
|
|
233
238
|
const disk = memoizedDiskVersion();
|
|
234
239
|
if (disk) return disk;
|
|
240
|
+
const installed = probeInstalledOpenCodeVersion();
|
|
241
|
+
if (installed && installed.split(".")[0] === "1") return installed;
|
|
235
242
|
return OPENCODE_VERSION_FALLBACK;
|
|
236
243
|
}
|
|
237
244
|
|
package/src/proxy-lanes.ts
CHANGED
|
@@ -918,6 +918,7 @@ export async function handleKiloRequest(opts: HandleKiloRequestOptions): Promise
|
|
|
918
918
|
res.once("close", abortKiloOnClientGone);
|
|
919
919
|
req.once("error", abortKiloOnClientGone);
|
|
920
920
|
let response: Response;
|
|
921
|
+
let servedRelay: string | null = null;
|
|
921
922
|
const kiloUrl = pathname.endsWith("/responses") ? KILO_RESPONSES_URL : KILO_CHAT_URL;
|
|
922
923
|
try {
|
|
923
924
|
response = await relayFetch(
|
|
@@ -931,7 +932,7 @@ export async function handleKiloRequest(opts: HandleKiloRequestOptions): Promise
|
|
|
931
932
|
signal: kiloController.signal,
|
|
932
933
|
},
|
|
933
934
|
reqId,
|
|
934
|
-
|
|
935
|
+
{ onServed: (r) => { servedRelay = r; } },
|
|
935
936
|
(info) => audit?.attemptFailure(info),
|
|
936
937
|
);
|
|
937
938
|
if (response.status === 400 && !res.writableEnded) {
|
|
@@ -952,6 +953,7 @@ export async function handleKiloRequest(opts: HandleKiloRequestOptions): Promise
|
|
|
952
953
|
if (changed) {
|
|
953
954
|
log("warn", `kilo 400 for model ${String(parsedBody.model)} — retrying with clamped tokens`, { model: parsedBody.model }, reqId);
|
|
954
955
|
try {
|
|
956
|
+
try { await response.body?.cancel(); } catch { }
|
|
955
957
|
const retryRes = await relayFetch(
|
|
956
958
|
kiloUrl,
|
|
957
959
|
{
|
|
@@ -963,7 +965,7 @@ export async function handleKiloRequest(opts: HandleKiloRequestOptions): Promise
|
|
|
963
965
|
signal: kiloController.signal,
|
|
964
966
|
},
|
|
965
967
|
reqId,
|
|
966
|
-
|
|
968
|
+
{ onServed: (r) => { servedRelay = r; } },
|
|
967
969
|
(info) => audit?.attemptFailure(info),
|
|
968
970
|
);
|
|
969
971
|
if (retryRes.ok) {
|
|
@@ -973,11 +975,21 @@ export async function handleKiloRequest(opts: HandleKiloRequestOptions): Promise
|
|
|
973
975
|
} catch { }
|
|
974
976
|
}
|
|
975
977
|
}
|
|
978
|
+
} catch (e) {
|
|
979
|
+
log("error", "kilo upstream fetch failed", { error: String(e), model: String(parsedBody.model) }, reqId);
|
|
980
|
+
if (!res.headersSent) {
|
|
981
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
982
|
+
res.end(JSON.stringify({ error: "upstream error" }));
|
|
983
|
+
} else if (!res.writableEnded) {
|
|
984
|
+
res.end();
|
|
985
|
+
}
|
|
986
|
+
return;
|
|
976
987
|
} finally {
|
|
977
988
|
clearTimeout(kiloTimeoutId);
|
|
978
989
|
res.off("close", abortKiloOnClientGone);
|
|
979
990
|
req.off("error", abortKiloOnClientGone);
|
|
980
991
|
}
|
|
992
|
+
if (res.writableEnded) return;
|
|
981
993
|
if (response.ok) recordUpstreamSuccess("kilo", { sessionKey });
|
|
982
994
|
else audit?.attemptFailure({ attempt: 0, status: response.status, reason: "final-upstream-status" });
|
|
983
995
|
|
|
@@ -996,7 +1008,7 @@ export async function handleKiloRequest(opts: HandleKiloRequestOptions): Promise
|
|
|
996
1008
|
res,
|
|
997
1009
|
req,
|
|
998
1010
|
reqId,
|
|
999
|
-
|
|
1011
|
+
servedRelay ?? "direct",
|
|
1000
1012
|
kiloArgSchemas.size > 0
|
|
1001
1013
|
? {
|
|
1002
1014
|
callerHadTools: true,
|
package/src/proxy-ports.ts
CHANGED
|
@@ -275,8 +275,8 @@ export async function killPortHolder(port: number): Promise<boolean> {
|
|
|
275
275
|
windowsHide: true,
|
|
276
276
|
}) as string;
|
|
277
277
|
for (const line of out.split("\n")) {
|
|
278
|
-
if (!line.includes("LISTENING")) continue;
|
|
279
278
|
const parts = line.trim().split(/\s+/);
|
|
279
|
+
if (!line.includes("LISTENING") && !(parts[0]?.toUpperCase() === "TCP" && (parts[2] === "0.0.0.0:0" || parts[2] === "[::]:0"))) continue;
|
|
280
280
|
if (!parts[1] || !parts[1].endsWith(`:${port}`)) continue;
|
|
281
281
|
tryTaskkillPid(parts[parts.length - 1]);
|
|
282
282
|
}
|
|
@@ -298,7 +298,7 @@ export async function killPortHolder(port: number): Promise<boolean> {
|
|
|
298
298
|
return killed;
|
|
299
299
|
}
|
|
300
300
|
try {
|
|
301
|
-
const out = execSync(`lsof -
|
|
301
|
+
const out = execSync(`lsof -tiTCP:${port} -sTCP:LISTEN 2>/dev/null || fuser -n tcp ${port} 2>/dev/null`, {
|
|
302
302
|
encoding: "utf8",
|
|
303
303
|
timeout: 3000,
|
|
304
304
|
}) as string;
|
package/src/proxy.ts
CHANGED
|
@@ -305,7 +305,7 @@ export function handleControlRequest(
|
|
|
305
305
|
}
|
|
306
306
|
if (isShutdown) {
|
|
307
307
|
log("info", "control /_shutdown received — closing proxy");
|
|
308
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
308
|
+
res.writeHead(200, { "content-type": "application/json", connection: "close" });
|
|
309
309
|
res.end(JSON.stringify({ ok: true }));
|
|
310
310
|
setTimeout(() => {
|
|
311
311
|
try {
|
|
@@ -316,6 +316,7 @@ export function handleControlRequest(
|
|
|
316
316
|
}, 50);
|
|
317
317
|
return true;
|
|
318
318
|
}
|
|
319
|
+
req.on("error", () => {});
|
|
319
320
|
const chunks: Buffer[] = [];
|
|
320
321
|
let total = 0;
|
|
321
322
|
req.on("data", (c: Buffer) => {
|
|
@@ -496,7 +497,7 @@ export function startProxy(
|
|
|
496
497
|
// Write/read the installation token before the listener is bound so the host
|
|
497
498
|
// can never observe a port that answers without a token, and so the daemon
|
|
498
499
|
// owns the value its own clients will read from the file.
|
|
499
|
-
|
|
500
|
+
let serverToken = ensureToken();
|
|
500
501
|
if (!serverToken) {
|
|
501
502
|
log("error", "proxy token unavailable — refusing every request until the data directory is writable");
|
|
502
503
|
}
|
|
@@ -547,12 +548,16 @@ export function startProxy(
|
|
|
547
548
|
// client recognise its daemon before it has a token.
|
|
548
549
|
const isIdentityProbe = req.method === "GET" && reqPathname === "/health";
|
|
549
550
|
if (!isIdentityProbe && !isAuthorizedBearer(req.headers.authorization, serverToken)) {
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
551
|
+
const latestToken = ensureToken();
|
|
552
|
+
if (latestToken && latestToken !== serverToken) serverToken = latestToken;
|
|
553
|
+
if (!isAuthorizedBearer(req.headers.authorization, serverToken)) {
|
|
554
|
+
res.writeHead(401, { "content-type": "application/json" });
|
|
555
|
+
res.end(JSON.stringify({ error: "unauthorized" }));
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
553
558
|
}
|
|
554
559
|
|
|
555
|
-
if (handleControlRequest(req, res, reqPathname, () => server.close())) return;
|
|
560
|
+
if (handleControlRequest(req, res, reqPathname, () => { try { server.closeIdleConnections?.(); } catch {} server.close(); })) return;
|
|
556
561
|
// Loopback-only health endpoint — always accessible even when widget hidden
|
|
557
562
|
if (req.method === "GET" && reqPathname !== null && (reqPathname === "/_health" || reqPathname === "/health")) {
|
|
558
563
|
const addr = server.address();
|
|
@@ -625,10 +630,12 @@ export function startProxy(
|
|
|
625
630
|
req.on("data", (chunk: Buffer) => {
|
|
626
631
|
bufferedBytes += chunk.length;
|
|
627
632
|
if (bufferedBytes > MAX_BODY_BYTES) {
|
|
628
|
-
|
|
633
|
+
bodyChunks.length = 0;
|
|
629
634
|
if (!res.headersSent) {
|
|
630
|
-
res.writeHead(413, { "content-type": "application/json" });
|
|
631
|
-
res.end(JSON.stringify({ error: "payload too large" }));
|
|
635
|
+
res.writeHead(413, { "content-type": "application/json", connection: "close" });
|
|
636
|
+
res.end(JSON.stringify({ error: "payload too large" }), () => req.destroy());
|
|
637
|
+
} else {
|
|
638
|
+
req.destroy();
|
|
632
639
|
}
|
|
633
640
|
return;
|
|
634
641
|
}
|
|
@@ -642,17 +649,24 @@ export function startProxy(
|
|
|
642
649
|
let isOvh = false;
|
|
643
650
|
let parsedBody: Record<string, unknown> | null = null;
|
|
644
651
|
let clientRequestedStream = false;
|
|
652
|
+
let bodyModified = false;
|
|
645
653
|
|
|
646
654
|
try {
|
|
647
655
|
parsedBody = JSON.parse(bodyStr);
|
|
648
656
|
if (typeof parsedBody?.model === "string") {
|
|
649
657
|
const canonical = resolveCanonicalModelId(parsedBody.model);
|
|
658
|
+
if (canonical !== parsedBody.model) {
|
|
659
|
+
parsedBody.model = canonical;
|
|
660
|
+
bodyModified = true;
|
|
661
|
+
} else {
|
|
662
|
+
parsedBody.model = canonical;
|
|
663
|
+
}
|
|
650
664
|
if (isClineModel(canonical)) {
|
|
651
665
|
isCline = true;
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
666
|
+
} else if (isOvhModel(canonical)) {
|
|
667
|
+
isOvh = true;
|
|
668
|
+
} else if (KILO_MODEL_IDS.has(canonical)) {
|
|
669
|
+
isKilo = true;
|
|
656
670
|
}
|
|
657
671
|
}
|
|
658
672
|
if (parsedBody && typeof parsedBody === "object") {
|
|
@@ -695,6 +709,7 @@ export function startProxy(
|
|
|
695
709
|
if (zenFailover.action === "failover" && parsedBody) {
|
|
696
710
|
const from = String(parsedBody.model);
|
|
697
711
|
parsedBody.model = zenFailover.model;
|
|
712
|
+
bodyModified = true;
|
|
698
713
|
if (target.pathname.endsWith("/responses")) {
|
|
699
714
|
prepareResponsesFailoverBody(parsedBody);
|
|
700
715
|
} else {
|
|
@@ -721,7 +736,7 @@ export function startProxy(
|
|
|
721
736
|
// OpenCode Zen free-tier fingerprint: upstream mandates stream: true and the
|
|
722
737
|
// live 11-tool roster. Missing tools (such as on
|
|
723
738
|
// subagent or advisor watchdog turns) trigger 403 FreeTierError.
|
|
724
|
-
|
|
739
|
+
// bodyModified was initialized above before model alias / failover mutations.
|
|
725
740
|
let callerHadTools = true;
|
|
726
741
|
let callerCaseRestore: CaseRestoreMap | undefined;
|
|
727
742
|
let callerFindGlob: FindGlobRestore | undefined;
|