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/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
- return path.resolve(entry) === fileURLToPath(import.meta.url);
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)
@@ -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, hasFallbackServer, stopHeartbeat } from "./client.ts";
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
- if (hasFallbackServer()) {
377
- stopHeartbeat();
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
  }
@@ -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
- messages.push({ role: "assistant", content: null, tool_calls: [{ id, type: "function", function: { name, arguments: args } }] });
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 32 verified free models (live-verified 2026-10-26):
5
- * - 8 OpenCode Zen models (all Chat Completions)
6
- * - 15 KiloCode Keyless Gateway models (OpenRouter format)
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: "poolside/laguna-s-2.1:free",
364
- name: "Laguna S 2.1 [Kilo]",
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: 32_768,
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-2.5": "liquid/lfm-2.5-2.6b:free",
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
- "flint-kilo": "recursive/flint-v1.0:free",
742
- "beam-kilo": "reflection/beam-free",
743
- "xing4.0": "xingchen-agi/xing4.0-29b-a4b:free",
744
- // provider-prefixed short aliases (slash-normalized)
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 32 static free models (canonical)
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" && body.model.startsWith("opencode/")) {
124
- body.model = body.model.slice("opencode/".length);
124
+ if (typeof body.model === "string") {
125
+ const stripped = body.model.startsWith("opencode/") ? body.model.slice("opencode/".length) : body.model;
126
+ const canonical = resolveCanonicalModelId(stripped);
127
+ body.model = OPENCODE_MODELS.some((m) => m.id === canonical) ? canonical : stripped;
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.
@@ -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 ?? 10_000;
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
 
@@ -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
- undefined,
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
- undefined,
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
- undefined,
1011
+ servedRelay ?? "direct",
1000
1012
  kiloArgSchemas.size > 0
1001
1013
  ? {
1002
1014
  callerHadTools: true,
@@ -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 -ti tcp:${port} 2>/dev/null || fuser -n tcp ${port} 2>/dev/null`, {
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
- const serverToken = ensureToken();
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
- res.writeHead(401, { "content-type": "application/json" });
551
- res.end(JSON.stringify({ error: "unauthorized" }));
552
- return;
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
- req.destroy();
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
- } else if (isOvhModel(canonical)) {
653
- isOvh = true;
654
- } else if (KILO_MODEL_IDS.has(canonical)) {
655
- isKilo = true;
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
- let bodyModified = false;
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;