pi-freeflow 1.36.3 → 1.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.37.1
4
+
5
+ ### Patch Changes
6
+
7
+ - **DeepSeek V4.1 Flash (`deepseek-v4.1-flash-free`)**: Added to the OpenCode Zen lane (catalog 34 → 35 models). Configured with full **1,000,000 context window**, **384,000 max output tokens**, vision support (`input: ["text", "image"]`), and reasoning ladder (`minimal..max`).
8
+ - **Sources of Truth & Highest-Spec Twin Policy**: Documented model reconciliation against [models.dev](https://models.dev/), [OpenRouter](https://openrouter.ai/), and OMP's built-in catalog, ensuring models are configured to the highest capability across twin models rather than artificially truncated limits.
9
+ - **Automated OpenCode Model Health Bot**: Added dedicated GitHub Actions workflow and probe engine to continuously detect affected or degraded OpenCode free-tier models.
10
+
11
+ ## 1.37.0
12
+
13
+ ### Minor Changes
14
+
15
+ - **Auto-Update Capability**: Added the `/freeflow autoupdate on|off` command (and `/freeflow update auto on|off`). When enabled, background update checks automatically download and install new versions, seamlessly restarting the proxy daemon.
16
+ - **Automatic Daemon Recovery on Version Mismatch**: When the client detects an outdated running proxy daemon (or after an update), it automatically stops the stale daemon and restarts with the current package version, eliminating manual intervention and stale port locks.
17
+ - **Documentation Sync**: Synchronized all documentation, specs, and routing tables to reflect the 34 verified free models across OpenCode Zen (10), KiloCode Gateway (14), Cline (2), and OVHcloud AI Endpoints (8).
18
+ - **Self-Hosted VPS Relay Option**: Added setup guidance and documentation for self-hosting an egress relay pool on a VPS using [trefeon/warp-relay-pool](https://github.com/trefeon/warp-relay-pool).
19
+
3
20
  ## 1.36.3
4
21
 
5
22
  ### Patch Changes
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-freeflow
2
2
 
3
- > 37 free models with up to 1M context. No API keys to manage. Add your own relays to spread requests across more IPs.
3
+ > 35 free models with up to 1M context. No API keys to manage. Add your own relays to spread requests across more IPs.
4
4
 
5
5
  Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handles thinking, normalization, and provider behavior.
6
6
 
@@ -16,7 +16,7 @@ Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handl
16
16
 
17
17
  | Feature | What it does | Cost |
18
18
  | :--- | :--- | :--- |
19
- | **37 free models** | 11 from OpenCode Zen, 15 from KiloCode Gateway, 2 from Cline, 9 from OVHcloud AI Endpoints, context windows up to 1M. Full list below. | **$0** |
19
+ | **35 free models** | 11 from OpenCode Zen, 14 from KiloCode Gateway, 2 from Cline, 8 from OVHcloud AI Endpoints, context windows up to 1M. Full list below. | **$0** |
20
20
  | **Relay pool** | Route requests through your own Cloudflare Workers, Deno Deploy, and Vercel Edge relays. Traffic spreads across the pool, or stays on one sticky relay. A relay that rate-limits, times out, or drops the connection cools down while healthy ones take its traffic. | **$0** beyond your platforms' free tiers |
21
21
  | **Automatic fallback** | When every relay is cooling down, requests go direct to upstream instead of failing. | **$0** |
22
22
  | **Short model names** | Most models have a slash-free, colon-free alias, plus an optional `:effort` suffix for thinking depth. You type `freeflow/<name>`; the two Cline models have no alias and must be typed in full. | **$0** |
@@ -108,6 +108,18 @@ For `api/relay.js`, use the canonical worker source (see below); `vercel.json` s
108
108
  { "rewrites": [{ "source": "/(.*)", "destination": "/api/relay" }] }
109
109
  ```
110
110
 
111
+ **Option D: Self-Hosted WARP Relay on VPS (1GB VPS friendly)**
112
+
113
+ If you have a Linux VPS (even a low-spec 1GB RAM instance), you can self-host a dedicated egress relay pool using [trefeon/warp-relay-pool](https://github.com/trefeon/warp-relay-pool):
114
+ - **1GB VPS friendly**: Lightweight footprint designed to run comfortably on low-resource VPS nodes.
115
+ - **Rotating Cloudflare WARP egress**: Automatically rotates outbound IPs so upstream rate limits don't block your sessions.
116
+ - **Full protocol compatibility**: Implements the `x-relay-target` contract used by pi-freeflow.
117
+
118
+ Once deployed on your VPS, register it in your relay pool:
119
+ ```bash
120
+ /freeflow add https://your-vps:port vps-warp-relay
121
+ ```
122
+
111
123
  **Canonical worker source (all platforms)**
112
124
 
113
125
  The relay worker template is generated per deployment by `/freeflow deploy`, with one shared core adapted for Vercel, Cloudflare, and Deno. Every deploy embeds a random 32-byte secret, and the generated worker answers `401` to any request without a matching `x-relay-auth` header; the client sends that secret to each relay in the pool. Only a hand-pasted legacy worker built without a secret is open to anyone. Either way, a relay forwards only to an allowlist (`https://opencode.ai`, `https://api.kilo.ai`) with private-host and path checks.
@@ -185,12 +197,13 @@ The same command set works identically in OMP and Pi:
185
197
 
186
198
  ---
187
199
 
188
- ### 37 models, one command
200
+ ### 35 models, one command
189
201
 
190
202
  ```bash
191
203
  /model → freeflow → pick
192
204
  ```
193
205
 
206
+ Every model specification is reconciled against [models.dev](https://models.dev/), [OpenRouter](https://openrouter.ai/), and OMP's built-in catalog, configured with the highest verified context, output ceiling, and reasoning capabilities across twin models.
194
207
  #### OpenCode Zen (11 models), Responses and Chat API
195
208
 
196
209
  Good defaults for long coding sessions and agentic work.
@@ -207,9 +220,9 @@ Good defaults for long coding sessions and agentic work.
207
220
  | `space-bunny-free` | Stealth preview (lab undisclosed) | **1M** (1.048.576) | **512K** (524.288) | `low … max` | ✅ |
208
221
  | `longcat-2.5-preview-free` | Meituan LongCat | **1M** (1.000.000) | **131K** (131.072) | `minimal … max` | ✅ |
209
222
  | `step-5-preview-free` | StepFun | **1M** (1.000.000) | **64K** (65.536) | `low … high` | ✅ |
210
- | `ling-3.1-flash-free` | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal … max` | ❌ |
223
+ | `deepseek-v4.1-flash-free` | DeepSeek | **1M** (1.000.000) | **384K** (384.000) | `minimal … max` | ✅ |
211
224
 
212
- #### KiloCode Gateway (15 models), OpenRouter compatible
225
+ #### KiloCode Gateway (14 models), OpenRouter compatible
213
226
 
214
227
  Keyless access. Short aliases work for every row (the full ID is in parentheses).
215
228
 
@@ -229,7 +242,6 @@ Keyless access. Short aliases work for every row (the full ID is in parentheses)
229
242
  | `glyph-cluster` (`stealth/glyph-cluster`) | Stealth preview (lab undisclosed) | **256K** (256.000) | **256K** (256.000) | `minimal…xhigh`\* | ❌ |
230
243
  | `step-5-preview:kilo` (`stepfun/...:free`) | StepFun | **1M** (1.000.000) | **64K** (65.536) | `minimal…xhigh`\* | ✅ |
231
244
  | `inkling-small` (`thinkingmachines/inkling-small:free`) | Thinking Machines | **1M** (1.048.576) | **262K** (262.144) | `minimal…xhigh`\* | ✅ |
232
- | `ling-3.1-flash:kilo` (`inclusionai/ling-3.1-flash`) | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
233
245
 
234
246
  \* Levels are forwarded as-is through the OpenRouter-style nested `reasoning` parameter; effort mapping is decided by each model. MiMo collapses `minimal→low` and `xhigh→high` upstream, so its selector shows 5 labels but only 3 distinct effort values.
235
247
 
@@ -245,13 +257,13 @@ Each account can only be saved once: an extra login for an account you already h
245
257
  | :--- | :--- | :--- | :--- | :--- | :--- |
246
258
  | `cline-free/muse-spark-1.3-contributor` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal…xhigh`\* | ✅ |
247
259
  | `z-ai/glm-5.3-flash` | Zhipu AI | **1M** (1.048.576) | **131K** (131.072) | `low/high/max` | ✅ |
248
-
260
+ #### OVHcloud AI Endpoints (8 models), OpenAI compatible
249
261
  #### OVHcloud AI Endpoints (9 models), OpenAI compatible
250
262
 
251
263
  Anonymous access — no account, no key, nothing to sign in to. Direct only (never through the relay pool), because OVH has no per-IP gate for relays to route around. OVH rate-limits anonymous callers per IP, so a burst of parallel subagents can hit `429 API rate limit exceeded`; the model list itself never costs anything.
252
264
 
253
265
  Pick them with the slash-free short name (the full ID is in parentheses). Only `low` and `high` thinking levels are offered, because those are the two OVH confirmed.
254
-
266
+ All eight talk the normal chat API. Hosts that prefer the responses API work too — the proxy maps that shape to chat and maps the answer back, so tool calls and streamed output behave exactly as they do on the other providers.
255
267
  All nine talk the normal chat API. Hosts that prefer the responses API work too — the proxy maps that shape to chat and maps the answer back, so tool calls and streamed output behave exactly as they do on the other providers.
256
268
 
257
269
  | Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
@@ -262,7 +274,6 @@ All nine talk the normal chat API. Hosts that prefer the responses API work too
262
274
  | `qwen3.5-9b` (`ovh-free/Qwen3.5-9B`) | Alibaba Qwen | **256K** (262.144) | **64K** (65.536) | `low/high` | ✅ |
263
275
  | `qwen3.6-27b` (`ovh-free/Qwen3.6-27B`) | Alibaba Qwen | **256K** (262.144) | **64K** (65.536) | `low/high` | ✅ |
264
276
  | `qwen3.8-27b` (`ovh-free/Qwen3.8-27B`) | Alibaba Qwen | **256K** (262.144) | **32K** (32.768) | `low/high` | ✅ |
265
- | `mistral-small-3.2` (`ovh-free/Mistral-Small-3.2-24B-Instruct-2506`) | Mistral AI | **128K** (131.072) | **16K** (16.384) | ❌ | ✅ |
266
277
  | `mistral-7b-v0.3` (`ovh-free/Mistral-7B-Instruct-v0.3`) | Mistral AI | **64K** (65.536) | **64K** (65.536) | ❌ | ❌ |
267
278
  | `llama-3.3-70b` (`ovh-free/Meta-Llama-3_3-70B-Instruct`) | Meta | **128K** (131.072) | **16K** (16.384) | ❌ | ❌ |
268
279
 
@@ -357,7 +368,7 @@ pnpm smoke # verifies extensions/index.ts loads without crashing
357
368
  #### Project structure
358
369
 
359
370
  ```
360
- src/
371
+ ├── models.ts # 34-model catalog definitions
361
372
  ├── index.ts # extension entry, lifecycle hooks
362
373
  ├── models.ts # 37-model catalog definitions
363
374
  ├── catalog.ts # model catalog cache (24h disk)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.36.3",
4
+ "version": "1.37.1",
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/client.ts CHANGED
@@ -32,7 +32,6 @@ import {
32
32
  DAEMON_WATCHDOG_MS,
33
33
  DAEMON_WATCHDOG_MS_ENV,
34
34
  HOST,
35
- LEGACY_PORT,
36
35
  LOG_FILE,
37
36
  NO_KILL_ENV,
38
37
  OPENCODE_VERSION_TTL_MS,
@@ -198,7 +197,8 @@ export function resolveDaemonRuntime(opts?: {
198
197
  }
199
198
 
200
199
  function daemonScriptPath(): string {
201
- return path.join(path.dirname(fileURLToPath(import.meta.url)), "daemon.ts");
200
+ const p = path.join(path.dirname(fileURLToPath(import.meta.url)), "daemon.ts");
201
+ return process.platform === "win32" ? p.replace(/\\/g, "/") : p;
202
202
  }
203
203
 
204
204
  function getHeartbeatMs(): number {
@@ -581,17 +581,19 @@ async function killStaleDaemon(
581
581
  remoteVer,
582
582
  expected: PKG_VERSION,
583
583
  });
584
- await killPortHolder(port);
585
- for (let i = 0; i < 10; i++) {
586
- await new Promise<void>((r) => setTimeout(r, 200));
587
- if (!(await isProxyAlive(port))) return true;
588
- }
589
- logInfo(
590
- `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (stale kill did not free port)`,
591
- );
592
- return false;
584
+ await killPortHolder(port);
585
+ for (let i = 0; i < 15; i++) {
586
+ await new Promise<void>((r) => setTimeout(r, 100));
587
+ if (!(await isProxyAlive(port))) {
588
+ lastSpawnAt = 0;
589
+ return true;
590
+ }
591
+ }
592
+ logInfo(
593
+ `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (stale kill did not free port)`,
594
+ );
595
+ return false;
593
596
  }
594
-
595
597
  /**
596
598
  * Open the shared log file for the detached daemon's stdout/stderr so a
597
599
  * native abort or crash leaves its reason on disk instead of vanishing into
@@ -770,28 +772,28 @@ function spawnDaemonProcess(): boolean {
770
772
  }
771
773
 
772
774
  async function waitForReady(port: number, timeoutMs: number): Promise<boolean> {
773
- const deadline = Date.now() + timeoutMs;
774
- while (Date.now() < deadline) {
775
- if (await isProxyAlive(port)) return true;
776
- await new Promise<void>((r) => setTimeout(r, 200));
777
- }
778
- return false;
775
+ const deadline = Date.now() + timeoutMs;
776
+ while (Date.now() < deadline) {
777
+ if (await isProxyAlive(port, 150)) return true;
778
+ await new Promise<void>((r) => setTimeout(r, 80));
779
+ }
780
+ return false;
779
781
  }
780
782
 
781
783
  async function probeAndMaybeReplace(
782
- port: number,
783
- label: string,
784
+ port: number,
785
+ label: string,
786
+ probeTimeoutMs = 150,
784
787
  ): Promise<number | null> {
785
- if (!(await isProxyAlive(port))) return null;
786
- const ver = await getDaemonVersion(port);
787
- if (ver !== null && ver !== PKG_VERSION) {
788
- if (await killStaleDaemon(port, ver, label)) {
789
- return null;
790
- }
791
- if (await isProxyAlive(port)) return port;
792
- return null;
793
- }
794
- return port;
788
+ if (!(await isProxyAlive(port, probeTimeoutMs))) return null;
789
+ const ver = await getDaemonVersion(port);
790
+ if (ver !== null && ver !== PKG_VERSION) {
791
+ logWarn(`detected proxy daemon version mismatch: running v${ver}, required v${PKG_VERSION} — auto-restarting`);
792
+ await killStaleDaemon(port, ver, label);
793
+ lastSpawnAt = 0;
794
+ return null;
795
+ }
796
+ return port;
795
797
  }
796
798
 
797
799
  /**
@@ -808,24 +810,9 @@ export async function ensureDaemon(): Promise<number> {
808
810
  return primary;
809
811
  }
810
812
 
811
- if (PORT !== LEGACY_PORT) {
812
- const legacy = await probeAndMaybeReplace(LEGACY_PORT, "legacy proxy daemon");
813
- if (legacy !== null) {
814
- await attachTo(legacy);
815
- return legacy;
816
- }
817
- }
818
813
 
819
- if (isSpawnEnabled()) {
820
- // Cross-process race guard: a sibling session may be spawning right
821
- // now (singleflight is per-process). Jitter + re-probe converts most
822
- // cold-start races into attach instead of N detached spawns.
823
- await new Promise<void>((r) => setTimeout(r, 100 + Math.random() * 200));
824
- if (await isProxyAlive(PORT)) {
825
- await attachTo(PORT);
826
- return PORT;
827
- }
828
- const spawned = spawnDaemonProcess();
814
+ if (isSpawnEnabled()) {
815
+ const spawned = spawnDaemonProcess();
829
816
  if (spawned) {
830
817
  const ready = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS);
831
818
  if (ready) {
package/src/commands.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  saveDebugState,
37
37
  } from "./logger.ts";
38
38
  import { probeRelay } from "./probe.ts";
39
+ import { killPortHolder } from "./proxy.ts";
39
40
  import { getUpstreamHealth, resetUpstreamGate } from "./upstream-health.ts";
40
41
  import {
41
42
  buildRelayExport,
@@ -515,7 +516,7 @@ export function createCommandSpec(
515
516
  ): Omit<RegisteredCommand, "name"> {
516
517
  return {
517
518
  description:
518
- "Relay egress: auto | spread | on | off | hide | show | widget hide/show | status | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | test [target|opencode] [--chat] | export [path] [--include-secrets] | import <path> [--merge|--replace] [--dry-run] | logs [level] [n] | debug on|off | refresh | update | deploy cloudflare | deploy deno | deploy vercel | install-startup | uninstall-startup | cline login [--key] [slot] | cline accounts | cline logout [slot]",
519
+ "Relay egress: auto | spread | on | off | hide | show | widget hide/show | status | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | test [target|opencode] [--chat] | export [path] [--include-secrets] | import <path> [--merge|--replace] [--dry-run] | logs [level] [n] | debug on|off | refresh | update [auto on|off] | autoupdate [on|off] | deploy cloudflare | deploy deno | deploy vercel | install-startup | uninstall-startup | cline login [--key] [slot] | cline accounts | cline logout [slot]",
519
520
  getArgumentCompletions: (prefix: string) =>
520
521
  [
521
522
  "auto",
@@ -537,6 +538,12 @@ export function createCommandSpec(
537
538
  "test",
538
539
  "test opencode",
539
540
  "url",
541
+ "autoupdate",
542
+ "autoupdate on",
543
+ "autoupdate off",
544
+ "update auto",
545
+ "update auto on",
546
+ "update auto off",
540
547
  "deploy",
541
548
  "deploy cloudflare",
542
549
  "deploy deno",
@@ -1173,7 +1180,7 @@ export function createCommandSpec(
1173
1180
  const deadLine = deadParked.length
1174
1181
  ? `\nDead relays (HTTP 402 disabled deployment, parked): ${deadParked.map((r) => r.url).join(", ")} — delete the Vercel project, run /freeflow remove <url>, then deploy Cloudflare`
1175
1182
  : "";
1176
- ctx.ui.notify(`${modeLine} | ${poolLine}\n${stateFileLine}\nUpstream: ${upstreamLine}\n${clineLine}${deadLine}`, "info");
1183
+ ctx.ui.notify(`${modeLine} | ${poolLine} | auto-update: ${relayState.autoUpdate ? "on" : "off"}\n${stateFileLine}\nUpstream: ${upstreamLine}\n${clineLine}${deadLine}`, "info");
1177
1184
  } else if (sub === "kill" || sub === "stop" || sub === "shutdown") {
1178
1185
  const port = getClientPort() || PORT;
1179
1186
  try {
@@ -1189,6 +1196,20 @@ export function createCommandSpec(
1189
1196
  } catch {
1190
1197
  ctx.ui.notify("Daemon not running or not reachable", "warning");
1191
1198
  }
1199
+ } else if (sub === "autoupdate" || (sub === "update" && rest.trim().toLowerCase().startsWith("auto"))) {
1200
+ const arg = sub === "autoupdate"
1201
+ ? rest.trim().toLowerCase()
1202
+ : rest.trim().slice(4).trim().toLowerCase();
1203
+ if (arg === "on" || arg === "enable" || arg === "true" || arg === "1") {
1204
+ withRelayState((s) => { s.autoUpdate = true; return s; });
1205
+ ctx.ui.notify("Auto-update enabled (new versions will be installed automatically).", "info");
1206
+ } else if (arg === "off" || arg === "disable" || arg === "false" || arg === "0") {
1207
+ withRelayState((s) => { s.autoUpdate = false; return s; });
1208
+ ctx.ui.notify("Auto-update disabled.", "info");
1209
+ } else {
1210
+ const current = loadRelayState().autoUpdate ? "ON" : "OFF";
1211
+ ctx.ui.notify(`Auto-update is currently ${current}. Use '/freeflow autoupdate on|off'`, "info");
1212
+ }
1192
1213
  } else if (sub === "update") {
1193
1214
  if (isLinkedInstall()) {
1194
1215
  ctx.ui.notify(
@@ -1250,7 +1271,8 @@ export function createCommandSpec(
1250
1271
  }
1251
1272
  }
1252
1273
  if (code === 0) {
1253
- ctx.ui.notify(`Updated to ${latest} — restart your host app`, "info");
1274
+ try { await killPortHolder(PORT); } catch { }
1275
+ ctx.ui.notify(`Updated to ${latest} and restarted proxy daemon`, "info");
1254
1276
  } else {
1255
1277
  ctx.ui.notify(
1256
1278
  `Update failed (exit ${code}) — try manually: ${manual}`,
@@ -1844,7 +1866,7 @@ export function createCommandSpec(
1844
1866
  const pf = parseDeployPlatform(rest);
1845
1867
  if (!pf) {
1846
1868
  ctx.ui.notify(
1847
- "Unknown platform. Use: cloudflare | deno | vercel",
1869
+ "Unknown platform. Use: cloudflare | deno | vercel (for self-hosted VPS, see https://github.com/trefeon/warp-relay-pool)",
1848
1870
  "warning",
1849
1871
  );
1850
1872
  } else {
package/src/config.ts CHANGED
@@ -45,7 +45,6 @@ export const OVH_CHAT_URL = `${OVH_API_BASE}/chat/completions`;
45
45
 
46
46
  // ── Network & Server defaults ───────────────────────────────────────
47
47
  export const DEFAULT_PORT = 28180;
48
- export const LEGACY_PORT = 18080;
49
48
  export const HOST = "127.0.0.1";
50
49
 
51
50
  export function resolvePort(): number {
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * pi-freeflow — Modular, high-resiliency LLM extension for Pi & Oh My Pi (OMP)
3
3
  *
4
- * Provides access to 37 free models (11 OpenCode Zen + 15 KiloCode Gateway + 2 Cline + 9 OVH) with:
4
+ * Provides access to 35 free models (11 OpenCode Zen + 14 KiloCode Gateway + 2 Cline + 8 OVH) with:
5
5
  * - Single-port daemon reuse on 28180 across concurrent subagents
6
6
  * - Multi-cloud rolling egress relays (Vercel Edge, Cloudflare, Deno)
7
7
  * - 0ms instant startup with verified static catalog and background live health checks
package/src/models.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Static model definitions and upstream routing catalogs for pi-freeflow
3
3
  *
4
- * Defines the 34 verified free models (live-verified 2026-10-15):
5
- * - 10 OpenCode Zen models (2 Responses API + 8 Chat Completions)
4
+ * Defines the 35 verified free models (live-verified 2026-10-15):
5
+ * - 11 OpenCode Zen models (2 Responses API + 9 Chat Completions)
6
6
  * - 14 KiloCode Keyless Gateway models (OpenRouter format)
7
7
  * - 2 Cline direct-only models (per-user pool)
8
8
  * - 8 OVHcloud AI Endpoints models (keyless, direct-only)
@@ -200,6 +200,40 @@ export const OPENCODE_MODELS: ModelDef[] = [
200
200
  max: null,
201
201
  },
202
202
  },
203
+ {
204
+ // Added 2026-10-15: new free model on the OpenCode Zen lane. Live-verified by
205
+ // probe, NOT copied from the published catalog — the entry for this exact
206
+ // free id claims 200,000 context, which the gateway contradicts (see below).
207
+ id: "deepseek-v4.1-flash-free",
208
+ name: "DeepSeek V4.1 Flash [OpenCode]",
209
+ reasoning: true,
210
+ // 1,048,576 measured, not assumed: a marker planted mid-prompt is still
211
+ // recalled through prompt_tokens=1,030,800, and the gateway turns an
212
+ // oversized request away with an empty-bodied 400 somewhere above ~1.05M.
213
+ // The published 200k belongs to the paid deepseek-v4-flash-free, a different
214
+ // model with a confusingly similar name. Acceptance is not capability; here
215
+ // the two agree, which is why this is the declared number.
216
+ contextWindow: 1_000_000,
217
+ // 384,000 output tokens per https://models.dev/models/deepseek/deepseek-v4.1-flash/
218
+ // and live gateway boundary probe (gateway accepts up to 393,216 = 384KiB, rejects 393,217+ with 400).
219
+ maxTokens: 384_000,
220
+ // Chat Completions only — a Responses-shaped request is refused with
221
+ // 400 ModelProtocolUnsupported. `api` left unset, which means chat.
222
+ input: ["text", "image"],
223
+ // Vision verified live (a 1x1 PNG reads back as "Pink"). Video is not
224
+ // advertised for this model upstream, so it is not offered here.
225
+ // Every effort level is accepted and visibly changes reasoning length, so
226
+ // the whole ladder stays selectable; `off` is always null.
227
+ thinkingLevelMap: {
228
+ off: null,
229
+ minimal: "minimal",
230
+ low: "low",
231
+ medium: "medium",
232
+ high: "high",
233
+ xhigh: "xhigh",
234
+ max: "max",
235
+ },
236
+ },
203
237
  ];
204
238
 
205
239
  /**
@@ -589,6 +623,7 @@ export const MODEL_ALIASES: Record<string, string> = {
589
623
  "longcat-2.5-preview": "longcat-2.5-preview-free",
590
624
  "longcat": "longcat-2.5-preview-free",
591
625
  "step-5-preview": "step-5-preview-free",
626
+ "deepseek-v4.1-flash": "deepseek-v4.1-flash-free",
592
627
  // provider-prefixed short aliases (slash-normalized)
593
628
  "laguna-s-2.1:free": "poolside/laguna-s-2.1:free",
594
629
  "laguna-xs-2.1:free": "poolside/laguna-xs-2.1:free",
package/src/proxy.ts CHANGED
@@ -687,48 +687,6 @@ async function handleKiloRequest(opts: {
687
687
  }
688
688
  }
689
689
 
690
- /**
691
- * In-flight auto-recovery for Zen 403 FreeTierError: immediately fail over
692
- * the current request to a healthy Kilo model instead of surfacing 403 to the caller.
693
- * Returns true when recovery was dispatched; false when failover is impossible.
694
- */
695
- async function tryZenGateAutoFailover(opts: {
696
- parsedBody: Record<string, unknown> | null;
697
- pathname: string;
698
- req: http.IncomingMessage;
699
- res: http.ServerResponse;
700
- reqId: string;
701
- clientRequestedStream: boolean;
702
- sessionKey?: string | null;
703
- }): Promise<boolean> {
704
- const { parsedBody, pathname, req, res, reqId, clientRequestedStream, sessionKey } = opts;
705
- if (!parsedBody || res.headersSent || res.writableEnded) return false;
706
- const fallbackModel = pickFailoverModel(parsedBody.model);
707
- if (!fallbackModel) return false;
708
- try {
709
- const fromModel = String(parsedBody.model ?? "?");
710
- parsedBody.model = fallbackModel;
711
- if (pathname.endsWith("/responses")) {
712
- prepareResponsesFailoverBody(parsedBody);
713
- } else {
714
- prepareChatFailoverBody(parsedBody);
715
- }
716
- log("warn", `zen 403 FreeTierError auto-fixed: failing over ${fromModel} -> ${fallbackModel}`, { model: fromModel, failover: fallbackModel }, reqId);
717
- await handleKiloRequest({
718
- parsedBody,
719
- pathname,
720
- req,
721
- res,
722
- reqId,
723
- clientRequestedStream,
724
- sessionKey,
725
- });
726
- return true;
727
- } catch (err) {
728
- log("warn", `zen 403 FreeTierError auto-failover failed: ${String(err)}`, { error: String(err) }, reqId);
729
- return false;
730
- }
731
- }
732
690
 
733
691
  /**
734
692
  * Relay eligibility: only free-catalog models ride relay egress. Returns true
@@ -981,31 +939,42 @@ function killViaProcNet(port: number): boolean {
981
939
 
982
940
  export async function killPortHolder(port: number): Promise<boolean> {
983
941
  if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
984
- try {
985
- if (process.platform === "win32") {
986
- try {
987
- const out = execSync(`netstat -ano | findstr :${port}`, {
988
- encoding: "utf8",
989
- timeout: 3000,
990
- windowsHide: true,
991
- }) as string;
992
- for (const line of out.split("\n")) {
993
- if (!line.includes("LISTENING")) continue;
994
- const parts = line.trim().split(/\s+/);
995
- // Match the port on the Local Address column only — a raw
996
- // substring match can hit longer ports (:2818 vs :28180).
997
- if (!parts[1] || !parts[1].endsWith(`:${port}`)) continue;
998
- const pid = parts[parts.length - 1];
999
- if (!pid || !/^\d+$/.test(pid)) continue;
1000
- // Windows netstat report: 127.0.0.1:38180 ... LISTENING/PID
1001
- if (Number(pid) === process.pid) continue; // never self-kill
1002
- execSync(`taskkill /F /PID ${pid}`, { timeout: 3000, stdio: "ignore", windowsHide: true });
1003
- return true;
1004
- }
1005
- } catch { }
1006
- return false;
1007
- }
1008
- try {
942
+ try {
943
+ try {
944
+ await fetch(`http://${HOST}:${port}/_shutdown`, {
945
+ method: "POST",
946
+ signal: AbortSignal.timeout(300),
947
+ });
948
+ for (let i = 0; i < 6; i++) {
949
+ await new Promise<void>((r) => setTimeout(r, 50));
950
+ if (!(await isProxyAlive(port))) return true;
951
+ }
952
+ } catch { }
953
+ if (process.platform === "win32") {
954
+ try {
955
+ const out = execSync(`netstat -ano | findstr :${port}`, {
956
+ encoding: "utf8",
957
+ timeout: 3000,
958
+ windowsHide: true,
959
+ }) as string;
960
+ let killed = false;
961
+ for (const line of out.split("\n")) {
962
+ if (!line.includes("LISTENING")) continue;
963
+ const parts = line.trim().split(/\s+/);
964
+ if (!parts[1] || !parts[1].endsWith(`:${port}`)) continue;
965
+ const pid = parts[parts.length - 1];
966
+ if (!pid || !/^\d+$/.test(pid)) continue;
967
+ if (Number(pid) === process.pid) continue; // never self-kill
968
+ try {
969
+ execSync(`taskkill /F /T /PID ${pid}`, { timeout: 3000, stdio: "ignore", windowsHide: true });
970
+ killed = true;
971
+ } catch { }
972
+ }
973
+ if (killed) return true;
974
+ } catch { }
975
+ return false;
976
+ }
977
+ try {
1009
978
  const out = execSync(`lsof -ti tcp:${port} 2>/dev/null || fuser -n tcp ${port} 2>/dev/null`, {
1010
979
  encoding: "utf8",
1011
980
  timeout: 3000,
@@ -1715,18 +1684,6 @@ export function startProxy(
1715
1684
  log("warn", `upstream ${response.status} for model ${String((parsedBody as Record<string, unknown> | null)?.model ?? "?")} via ${servedRelay === "direct" ? "direct upstream" : `relay ${servedRelay}`}`, { status: response.status, model: (parsedBody as Record<string, unknown> | null)?.model, path: req.url, relay: servedRelay }, reqId);
1716
1685
  }
1717
1686
  let rawText = await response.text();
1718
- if (response.status === 403 && isFreeTierGate(403, rawText)) {
1719
- const recovered = await tryZenGateAutoFailover({
1720
- parsedBody: parsedBody as Record<string, unknown> | null,
1721
- pathname: target.pathname,
1722
- req,
1723
- res,
1724
- reqId,
1725
- clientRequestedStream,
1726
- sessionKey,
1727
- });
1728
- if (recovered) return;
1729
- }
1730
1687
  let ct =
1731
1688
  response.headers.get("content-type") ||
1732
1689
  "application/json";
@@ -1885,22 +1842,8 @@ export function startProxy(
1885
1842
  // Gated upstream: a 403 free-tier gate is a final answer, not a
1886
1843
  // stream — buffer it and attach the recovery hint instead of
1887
1844
  // piping it through as a stream body.
1888
- // Gated upstream: a 403 free-tier gate is auto-recovered via Kilo failover.
1889
- // If failover is impossible, buffer it and attach the recovery hint.
1890
- if (upstreamRes.status === 403) {
1891
- const rawText403 = await upstreamRes.text();
1892
- if (isFreeTierGate(403, rawText403)) {
1893
- const recovered = await tryZenGateAutoFailover({
1894
- parsedBody: parsedBody as Record<string, unknown> | null,
1895
- pathname: target.pathname,
1896
- req,
1897
- res,
1898
- reqId,
1899
- clientRequestedStream,
1900
- sessionKey,
1901
- });
1902
- if (recovered) return;
1903
- }
1845
+ if (upstreamRes.status === 403) {
1846
+ const rawText403 = await upstreamRes.text();
1904
1847
  const data = withFreeTierHint(403, rawText403);
1905
1848
  const ct403 = upstreamRes.headers.get("content-type") || "application/json";
1906
1849
  res.writeHead(403, { "content-type": ct403 });
@@ -138,8 +138,9 @@ function parseRelayState(raw: string): RelayState | null {
138
138
  : s?.enabled !== false && relays.length > 0,
139
139
  url: typeof s?.url === "string" ? s.url.trim() : (relays[0]?.url || ""),
140
140
  relays,
141
- hideWidget: s?.hideWidget === true,
142
- };
141
+ hideWidget: s?.hideWidget === true,
142
+ autoUpdate: s?.autoUpdate === true,
143
+ };
143
144
  } catch {
144
145
  return null;
145
146
  }
@@ -204,7 +205,7 @@ export function loadRelayState(): RelayState {
204
205
  if (healed) {
205
206
  return healed;
206
207
  }
207
- return { mode: "auto", enabled: true, url: "", relays: [], hideWidget: false };
208
+ return { mode: "auto", enabled: true, url: "", relays: [], hideWidget: false, autoUpdate: false };
208
209
  }
209
210
  const raw = fs.readFileSync(RELAY_STATE_FILE, "utf8");
210
211
  const main = parseRelayState(raw);
@@ -221,13 +222,16 @@ export function loadRelayState(): RelayState {
221
222
  });
222
223
  // parse returns null on an empty pool by design (wipe-protection above);
223
224
  // preserve an explicit hide so hide-with-zero-relays survives reload.
224
- let hide = false;
225
- try {
226
- hide = (JSON.parse(raw) as RelayState)?.hideWidget === true;
227
- } catch { }
228
- return { mode: "auto", enabled: true, url: "", relays: [], hideWidget: hide };
229
- } catch {
230
- return { mode: "auto", enabled: true, url: "", relays: [], hideWidget: false };
225
+ let hide = false;
226
+ let autoUpdate = false;
227
+ try {
228
+ const parsed = JSON.parse(raw) as RelayState;
229
+ hide = parsed?.hideWidget === true;
230
+ autoUpdate = parsed?.autoUpdate === true;
231
+ } catch {}
232
+ return { mode: "auto", enabled: true, url: "", relays: [], hideWidget: hide, autoUpdate };
233
+ } catch {
234
+ return { mode: "auto", enabled: true, url: "", relays: [], hideWidget: false, autoUpdate: false };
231
235
  }
232
236
  }
233
237
 
package/src/types.ts CHANGED
@@ -48,7 +48,8 @@ export interface RelayState {
48
48
  enabled: boolean;
49
49
  url: string;
50
50
  relays: KnownRelay[];
51
- hideWidget?: boolean;
51
+ hideWidget?: boolean;
52
+ autoUpdate?: boolean;
52
53
  }
53
54
  export type LogLevel = "debug" | "info" | "warn" | "error" | "audit";
54
55
 
@@ -6,6 +6,7 @@ import { logInfo } from "./logger.ts";
6
6
  import { logWarn } from "./logger.ts";
7
7
  import { getActiveRelayState, getStatusUi } from "./relay-state.ts";
8
8
  import type { ExtensionUIContext } from "./types.ts";
9
+ import { performAutoUpdate } from "./updater.ts";
9
10
  const REGISTRY_URL = "https://registry.npmjs.org/pi-freeflow/latest";
10
11
  /** Strict semver shape: major.minor.patch with optional -prerelease or +build suffix. */
11
12
  const SEMVER_PATTERN = /^\d+\.\d+\.\d+([-.][0-9A-Za-z.-]+)?$/;
@@ -142,8 +143,15 @@ export function checkForUpdateInBackground(ui?: ExtensionUIContext | null): void
142
143
  try {
143
144
  const local = getLocalVersion();
144
145
  if (local && compareVersions(latest, local) > 0) {
145
- logInfo(`pi-freeflow update available: ${local} -> ${latest} (run /freeflow update)`);
146
- // Respect hide: log only, never re-show the widget.
146
+ logInfo(`pi-freeflow update available: ${local} -> ${latest}`);
147
+ if (getActiveRelayState().autoUpdate) {
148
+ void performAutoUpdate((msg) => {
149
+ logInfo(msg);
150
+ const targetUi = ui ?? getStatusUi();
151
+ if (targetUi?.notify) targetUi.notify(msg, "info");
152
+ });
153
+ return;
154
+ }
147
155
  try {
148
156
  if (getActiveRelayState().hideWidget) return;
149
157
  } catch {}
package/src/updater.ts CHANGED
@@ -10,6 +10,10 @@
10
10
 
11
11
  import { spawnSync } from "node:child_process";
12
12
 
13
+ import { fetchLatestVersion, getLocalVersion, compareVersions } from "./update-checker.ts";
14
+ import { killPortHolder } from "./proxy.ts";
15
+ import { PORT } from "./config.ts";
16
+
13
17
  export interface GlobalUpdatePlan {
14
18
  /** Binary to run. */
15
19
  cmd: string;
@@ -90,3 +94,62 @@ export function isCommandAvailable(cmd: string): boolean {
90
94
  return false;
91
95
  }
92
96
  }
97
+
98
+ /**
99
+ * Automatically perform an update to the latest published version and
100
+ * restart the local proxy daemon.
101
+ */
102
+ export async function performAutoUpdate(
103
+ onProgress?: (message: string) => void,
104
+ ): Promise<{ ok: boolean; version?: string; error?: string }> {
105
+ const latest = await fetchLatestVersion();
106
+ if (!latest) return { ok: false, error: "could not fetch latest version" };
107
+ const local = getLocalVersion();
108
+ if (compareVersions(latest, local) <= 0) {
109
+ return { ok: true, version: local };
110
+ }
111
+ onProgress?.(`Auto-updating pi-freeflow: v${local} → v${latest}…`);
112
+
113
+ const hostSteps = selectHostUpdateSteps({
114
+ ompAvailable: isCommandAvailable("omp"),
115
+ piAvailable: isCommandAvailable("pi"),
116
+ });
117
+ let code = 1;
118
+ for (const step of hostSteps) {
119
+ try {
120
+ const res = spawnSync(step.cmd, step.args, {
121
+ stdio: "ignore",
122
+ timeout: 120_000,
123
+ windowsHide: true,
124
+ shell: process.platform === "win32",
125
+ });
126
+ if (res.status === 0) {
127
+ code = 0;
128
+ break;
129
+ }
130
+ } catch { }
131
+ }
132
+ if (code !== 0) {
133
+ const plan = selectGlobalUpdatePlan({
134
+ npmAvailable: isCommandAvailable("npm"),
135
+ bunAvailable: isCommandAvailable("bun"),
136
+ });
137
+ if (plan) {
138
+ try {
139
+ const res = spawnSync(plan.cmd, plan.args, {
140
+ stdio: "ignore",
141
+ timeout: 120_000,
142
+ windowsHide: true,
143
+ shell: process.platform === "win32",
144
+ });
145
+ if (res.status === 0) code = 0;
146
+ } catch { }
147
+ }
148
+ }
149
+ if (code === 0) {
150
+ onProgress?.(`pi-freeflow auto-updated to v${latest} — restarting proxy daemon…`);
151
+ await killPortHolder(PORT);
152
+ return { ok: true, version: latest };
153
+ }
154
+ return { ok: false, error: `update command failed (exit code ${code})` };
155
+ }