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 +17 -0
- package/README.md +21 -10
- package/package.json +1 -1
- package/src/client.ts +34 -47
- package/src/commands.ts +26 -4
- package/src/config.ts +0 -1
- package/src/index.ts +1 -1
- package/src/models.ts +37 -2
- package/src/proxy.ts +38 -95
- package/src/relay-state.ts +14 -10
- package/src/types.ts +2 -1
- package/src/update-checker.ts +10 -2
- package/src/updater.ts +63 -0
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
|
-
>
|
|
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
|
-
| **
|
|
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
|
-
###
|
|
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
|
-
| `
|
|
223
|
+
| `deepseek-v4.1-flash-free` | DeepSeek | **1M** (1.000.000) | **384K** (384.000) | `minimal … max` | ✅ |
|
|
211
224
|
|
|
212
|
-
#### KiloCode Gateway (
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
|
|
783
|
-
|
|
784
|
+
port: number,
|
|
785
|
+
label: string,
|
|
786
|
+
probeTimeoutMs = 150,
|
|
784
787
|
): Promise<number | null> {
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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
|
-
|
|
820
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
5
|
-
* -
|
|
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
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
1889
|
-
|
|
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 });
|
package/src/relay-state.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
package/src/update-checker.ts
CHANGED
|
@@ -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}
|
|
146
|
-
|
|
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
|
+
}
|