pi-freeflow 1.36.1 → 1.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +20 -11
- package/package.json +1 -1
- package/src/catalog.ts +6 -0
- package/src/client.ts +34 -47
- package/src/commands.ts +26 -4
- package/src/config.ts +9 -5
- package/src/index.ts +1 -0
- package/src/models.ts +5 -44
- package/src/opencode-fingerprint.ts +2 -1
- package/src/probe.ts +7 -7
- package/src/proxy.ts +175 -157
- package/src/relay-state.ts +14 -10
- package/src/tool-translation.ts +79 -3
- 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,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.37.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
- **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.
|
|
9
|
+
- **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).
|
|
10
|
+
- **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).
|
|
11
|
+
|
|
12
|
+
## 1.36.3
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- Restore OpenCode free models following upstream endpoint and gate migration
|
|
17
|
+
|
|
18
|
+
OpenCode free models were failing with HTTP 403 FreeTierError after upstream deprecated the older endpoint and moved free inference traffic to the v1 inference gateway. Upstream also requires public authorization and non-empty schema declarations on compatibility tools.
|
|
19
|
+
|
|
20
|
+
Requests now route to the active endpoint with the required public credentials and schema declarations, restoring free model access across both direct connections and deployed relays.
|
|
21
|
+
|
|
22
|
+
## 1.36.2
|
|
23
|
+
|
|
24
|
+
### Patch Changes
|
|
25
|
+
|
|
26
|
+
- Auto-recover OpenCode Zen 403 FreeTierError: when upstream OpenCode Zen refuses a session with `403 FreeTierError` ("OpenCode's free tier can only be used from within OpenCode"), the local proxy now catches the gate error in-flight and transparently fails over to a healthy KiloCode fallback model on the same wire API, rather than bubbling the 403 back to the coding agent.
|
|
27
|
+
- Prune decommissioned `ling-3.1-flash-free` from the active catalog and add it to the exclusion list, locking the verified catalog to 36 models.
|
|
28
|
+
- Update OpenCode version fallback to 1.19.0.
|
|
29
|
+
|
|
3
30
|
## 1.36.1
|
|
4
31
|
|
|
5
32
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-freeflow
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> 34 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
|
+
| **34 free models** | 10 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,13 +197,13 @@ The same command set works identically in OMP and Pi:
|
|
|
185
197
|
|
|
186
198
|
---
|
|
187
199
|
|
|
188
|
-
###
|
|
200
|
+
### 34 models, one command
|
|
189
201
|
|
|
190
202
|
```bash
|
|
191
203
|
/model → freeflow → pick
|
|
192
204
|
```
|
|
193
205
|
|
|
194
|
-
#### OpenCode Zen (
|
|
206
|
+
#### OpenCode Zen (10 models), Responses and Chat API
|
|
195
207
|
|
|
196
208
|
Good defaults for long coding sessions and agentic work.
|
|
197
209
|
|
|
@@ -207,9 +219,8 @@ Good defaults for long coding sessions and agentic work.
|
|
|
207
219
|
| `space-bunny-free` | Stealth preview (lab undisclosed) | **1M** (1.048.576) | **512K** (524.288) | `low … max` | ✅ |
|
|
208
220
|
| `longcat-2.5-preview-free` | Meituan LongCat | **1M** (1.000.000) | **131K** (131.072) | `minimal … max` | ✅ |
|
|
209
221
|
| `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` | ❌ |
|
|
211
222
|
|
|
212
|
-
#### KiloCode Gateway (
|
|
223
|
+
#### KiloCode Gateway (14 models), OpenRouter compatible
|
|
213
224
|
|
|
214
225
|
Keyless access. Short aliases work for every row (the full ID is in parentheses).
|
|
215
226
|
|
|
@@ -229,7 +240,6 @@ Keyless access. Short aliases work for every row (the full ID is in parentheses)
|
|
|
229
240
|
| `glyph-cluster` (`stealth/glyph-cluster`) | Stealth preview (lab undisclosed) | **256K** (256.000) | **256K** (256.000) | `minimal…xhigh`\* | ❌ |
|
|
230
241
|
| `step-5-preview:kilo` (`stepfun/...:free`) | StepFun | **1M** (1.000.000) | **64K** (65.536) | `minimal…xhigh`\* | ✅ |
|
|
231
242
|
| `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
243
|
|
|
234
244
|
\* 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
245
|
|
|
@@ -245,13 +255,13 @@ Each account can only be saved once: an extra login for an account you already h
|
|
|
245
255
|
| :--- | :--- | :--- | :--- | :--- | :--- |
|
|
246
256
|
| `cline-free/muse-spark-1.3-contributor` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal…xhigh`\* | ✅ |
|
|
247
257
|
| `z-ai/glm-5.3-flash` | Zhipu AI | **1M** (1.048.576) | **131K** (131.072) | `low/high/max` | ✅ |
|
|
248
|
-
|
|
258
|
+
#### OVHcloud AI Endpoints (8 models), OpenAI compatible
|
|
249
259
|
#### OVHcloud AI Endpoints (9 models), OpenAI compatible
|
|
250
260
|
|
|
251
261
|
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
262
|
|
|
253
263
|
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
|
-
|
|
264
|
+
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
265
|
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
266
|
|
|
257
267
|
| Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
|
|
@@ -262,7 +272,6 @@ All nine talk the normal chat API. Hosts that prefer the responses API work too
|
|
|
262
272
|
| `qwen3.5-9b` (`ovh-free/Qwen3.5-9B`) | Alibaba Qwen | **256K** (262.144) | **64K** (65.536) | `low/high` | ✅ |
|
|
263
273
|
| `qwen3.6-27b` (`ovh-free/Qwen3.6-27B`) | Alibaba Qwen | **256K** (262.144) | **64K** (65.536) | `low/high` | ✅ |
|
|
264
274
|
| `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
275
|
| `mistral-7b-v0.3` (`ovh-free/Mistral-7B-Instruct-v0.3`) | Mistral AI | **64K** (65.536) | **64K** (65.536) | ❌ | ❌ |
|
|
267
276
|
| `llama-3.3-70b` (`ovh-free/Meta-Llama-3_3-70B-Instruct`) | Meta | **128K** (131.072) | **16K** (16.384) | ❌ | ❌ |
|
|
268
277
|
|
|
@@ -357,7 +366,7 @@ pnpm smoke # verifies extensions/index.ts loads without crashing
|
|
|
357
366
|
#### Project structure
|
|
358
367
|
|
|
359
368
|
```
|
|
360
|
-
|
|
369
|
+
├── models.ts # 34-model catalog definitions
|
|
361
370
|
├── index.ts # extension entry, lifecycle hooks
|
|
362
371
|
├── models.ts # 37-model catalog definitions
|
|
363
372
|
├── 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.0",
|
|
5
5
|
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
6
|
"main": "extensions/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
package/src/catalog.ts
CHANGED
|
@@ -105,6 +105,12 @@ export const DEAD_MODEL_IDS = new Set<string>([
|
|
|
105
105
|
"cline-free/deepseek-v4.1-flash",
|
|
106
106
|
"cline-free/kimi-k3",
|
|
107
107
|
"cline-free/solar-pro4",
|
|
108
|
+
// ling-3.1-flash-free: removed from upstream free docs (2026-10-15).
|
|
109
|
+
"ling-3.1-flash-free",
|
|
110
|
+
// inclusionai/ling-3.1-flash: demoted to paid on Kilo (2026-10-15) — answers 401 PAID_MODEL_AUTH_REQUIRED.
|
|
111
|
+
"inclusionai/ling-3.1-flash",
|
|
112
|
+
// ovh-free/Mistral-7B-Instruct-v0.3: removed upstream by OVH (2026-10-15) — answers 404 no route matched.
|
|
113
|
+
"ovh-free/Mistral-7B-Instruct-v0.3",
|
|
108
114
|
]);
|
|
109
115
|
/**
|
|
110
116
|
* Free-tier allowlist for anything entering the picker via network or stale disk.
|
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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Configuration and path resolution for pi-freeflow
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { createHash, randomBytes
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
@@ -21,7 +21,7 @@ try {
|
|
|
21
21
|
export { PKG_VERSION };
|
|
22
22
|
|
|
23
23
|
// ── Upstream endpoints ──────────────────────────────────────────────
|
|
24
|
-
export const UPSTREAM_OPENCODE = "https://opencode.ai/
|
|
24
|
+
export const UPSTREAM_OPENCODE = "https://opencode.ai/inference";
|
|
25
25
|
export const KILO_CHAT_URL = "https://api.kilo.ai/api/gateway/chat/completions";
|
|
26
26
|
export const KILO_RESPONSES_URL = "https://api.kilo.ai/api/gateway/responses";
|
|
27
27
|
export const OPENCODE_API_URL = `${UPSTREAM_OPENCODE}/v1`;
|
|
@@ -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 {
|
|
@@ -74,7 +73,7 @@ export const PORT = resolvePort();
|
|
|
74
73
|
// OPENCODE_VERSION_FLOOR pins the minimum; OPENCODE_VERSION_FALLBACK is the
|
|
75
74
|
// last-known-good npm version used when the live lookup is stale/offline.
|
|
76
75
|
export const OPENCODE_VERSION_FLOOR = "1.17.0";
|
|
77
|
-
export const OPENCODE_VERSION_FALLBACK = "1.
|
|
76
|
+
export const OPENCODE_VERSION_FALLBACK = "1.19.0";
|
|
78
77
|
/** Live npm lookup TTL: 6h — within one window the cached UA is reused. */
|
|
79
78
|
export const OPENCODE_VERSION_TTL_MS = 6 * 60 * 60 * 1_000;
|
|
80
79
|
/** Env override: exact `opencode/<version>` UA (or bare version) for tests/pins. */
|
|
@@ -250,14 +249,19 @@ export const OPENCODE_SESSION = createOpenCodeSessionId();
|
|
|
250
249
|
|
|
251
250
|
export function opencodeHeaders(): Record<string, string> {
|
|
252
251
|
const sessionId = OPENCODE_SESSION;
|
|
252
|
+
// Real OpenCode CLI (captured 2.0.28) does NOT send x-opencode-request on
|
|
253
|
+
// /inference/v1. Emitting msg_* there is treated as a non-OpenCode client and
|
|
254
|
+
// returns 403 FreeTierError — keep the helper for ID-shape tests, but omit
|
|
255
|
+
// the header on the wire.
|
|
253
256
|
return {
|
|
257
|
+
Authorization: "Bearer public",
|
|
254
258
|
"User-Agent": getOpenCodeUserAgent(),
|
|
255
259
|
"x-opencode-client": OPENCODE_CLIENT,
|
|
256
260
|
"x-opencode-project": OPENCODE_PROJECT,
|
|
257
261
|
"x-opencode-session": sessionId,
|
|
258
262
|
"x-opencode-session-id": sessionId,
|
|
259
263
|
"x-session-affinity": sessionId,
|
|
260
|
-
"x-
|
|
264
|
+
"x-session-id": sessionId,
|
|
261
265
|
};
|
|
262
266
|
}
|
|
263
267
|
|
package/src/index.ts
CHANGED
|
@@ -185,6 +185,7 @@ export default async function(pi: ExtensionAPI): Promise<void> {
|
|
|
185
185
|
buildProviderConfig(models, actualPort),
|
|
186
186
|
);
|
|
187
187
|
};
|
|
188
|
+
registerCatalog(registeredCatalog);
|
|
188
189
|
const ensureDaemon = async (ui?: ExtensionUIContext): Promise<void> => {
|
|
189
190
|
const reattach = (port: number): void => {
|
|
190
191
|
if (port !== actualPort) {
|
package/src/models.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Static model definitions and upstream routing catalogs for pi-freeflow
|
|
3
3
|
*
|
|
4
|
-
* Defines the
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
4
|
+
* Defines the 34 verified free models (live-verified 2026-10-15):
|
|
5
|
+
* - 10 OpenCode Zen models (2 Responses API + 8 Chat Completions)
|
|
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)
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { ModelDef, ThinkingLevelMap, Upstream } from "./types.ts";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* OpenCode Zen free models verified against the live catalog and inference APIs.
|
|
15
|
-
* Endpoint: https://opencode.ai/
|
|
15
|
+
* Endpoint: https://opencode.ai/inference/v1
|
|
16
16
|
*/
|
|
17
17
|
export const OPENCODE_MODELS: ModelDef[] = [
|
|
18
18
|
{
|
|
@@ -200,23 +200,6 @@ export const OPENCODE_MODELS: ModelDef[] = [
|
|
|
200
200
|
max: null,
|
|
201
201
|
},
|
|
202
202
|
},
|
|
203
|
-
{
|
|
204
|
-
id: "ling-3.1-flash-free",
|
|
205
|
-
name: "Ling 3.1 Flash Free [OpenCode]",
|
|
206
|
-
reasoning: true,
|
|
207
|
-
contextWindow: 262_144,
|
|
208
|
-
maxTokens: 32_768,
|
|
209
|
-
input: ["text"],
|
|
210
|
-
thinkingLevelMap: {
|
|
211
|
-
off: null,
|
|
212
|
-
minimal: "minimal",
|
|
213
|
-
low: "low",
|
|
214
|
-
medium: "medium",
|
|
215
|
-
high: "high",
|
|
216
|
-
xhigh: "xhigh",
|
|
217
|
-
max: "max",
|
|
218
|
-
},
|
|
219
|
-
},
|
|
220
203
|
];
|
|
221
204
|
|
|
222
205
|
/**
|
|
@@ -386,17 +369,6 @@ export const KILO_MODELS: ModelDef[] = [
|
|
|
386
369
|
thinkingFormat: "openrouter",
|
|
387
370
|
thinkingLevelMap: KILO_REASONING_MAP,
|
|
388
371
|
},
|
|
389
|
-
{
|
|
390
|
-
// Added 2026-10-07: live on Kilo free list (isFree:true, 0/0 pricing, 262K ctx)
|
|
391
|
-
id: "inclusionai/ling-3.1-flash",
|
|
392
|
-
name: "Ling 3.1 Flash [Kilo]",
|
|
393
|
-
reasoning: true,
|
|
394
|
-
contextWindow: 262_144,
|
|
395
|
-
maxTokens: 32_768,
|
|
396
|
-
input: ["text"],
|
|
397
|
-
thinkingFormat: "openrouter",
|
|
398
|
-
thinkingLevelMap: KILO_REASONING_MAP,
|
|
399
|
-
},
|
|
400
372
|
];
|
|
401
373
|
|
|
402
374
|
/**
|
|
@@ -569,14 +541,6 @@ export const OVH_MODELS: ModelDef[] = [
|
|
|
569
541
|
maxTokens: 16_384,
|
|
570
542
|
input: ["text", "image"],
|
|
571
543
|
},
|
|
572
|
-
{
|
|
573
|
-
id: "ovh-free/Mistral-7B-Instruct-v0.3",
|
|
574
|
-
name: "Mistral 7B Instruct v0.3 [OVH]",
|
|
575
|
-
reasoning: false,
|
|
576
|
-
contextWindow: 65_536,
|
|
577
|
-
maxTokens: 65_536,
|
|
578
|
-
input: ["text"],
|
|
579
|
-
},
|
|
580
544
|
{
|
|
581
545
|
id: "ovh-free/Meta-Llama-3_3-70B-Instruct",
|
|
582
546
|
name: "Meta Llama 3.3 70B Instruct [OVH]",
|
|
@@ -625,8 +589,6 @@ export const MODEL_ALIASES: Record<string, string> = {
|
|
|
625
589
|
"longcat-2.5-preview": "longcat-2.5-preview-free",
|
|
626
590
|
"longcat": "longcat-2.5-preview-free",
|
|
627
591
|
"step-5-preview": "step-5-preview-free",
|
|
628
|
-
"ling-3.1-flash": "ling-3.1-flash-free",
|
|
629
|
-
"ling-3.1-flash:kilo": "inclusionai/ling-3.1-flash",
|
|
630
592
|
// provider-prefixed short aliases (slash-normalized)
|
|
631
593
|
"laguna-s-2.1:free": "poolside/laguna-s-2.1:free",
|
|
632
594
|
"laguna-xs-2.1:free": "poolside/laguna-xs-2.1:free",
|
|
@@ -640,7 +602,6 @@ export const MODEL_ALIASES: Record<string, string> = {
|
|
|
640
602
|
"qwen3.6-27b": "ovh-free/Qwen3.6-27B",
|
|
641
603
|
"qwen3.8-27b": "ovh-free/Qwen3.8-27B",
|
|
642
604
|
"mistral-small-3.2": "ovh-free/Mistral-Small-3.2-24B-Instruct-2506",
|
|
643
|
-
"mistral-7b-v0.3": "ovh-free/Mistral-7B-Instruct-v0.3",
|
|
644
605
|
"llama-3.3-70b": "ovh-free/Meta-Llama-3_3-70B-Instruct",
|
|
645
606
|
};
|
|
646
607
|
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* per-request map (never module-global).
|
|
27
27
|
*/
|
|
28
28
|
import {
|
|
29
|
+
COMPAT_FINGERPRINT_SCHEMAS,
|
|
29
30
|
COMPAT_TOOL_DESCRIPTION,
|
|
30
31
|
OMP_FINGERPRINT_DEFS,
|
|
31
32
|
OPENCODE_FINGERPRINT_TOOLS,
|
|
@@ -105,7 +106,7 @@ export function isPlaceholderToolName(name: unknown): boolean {
|
|
|
105
106
|
* functions below.
|
|
106
107
|
*/
|
|
107
108
|
function fingerprintFill(name: FingerprintToolName, omp: boolean): { description: string; schema: Record<string, unknown> } {
|
|
108
|
-
if (!omp || name === "edit") return { description: COMPAT_TOOL_DESCRIPTION, schema:
|
|
109
|
+
if (!omp || name === "edit") return { description: COMPAT_TOOL_DESCRIPTION, schema: COMPAT_FINGERPRINT_SCHEMAS[name] };
|
|
109
110
|
const def = OMP_FINGERPRINT_DEFS[name];
|
|
110
111
|
return { description: def.description, schema: { ...(def.parameters as Record<string, unknown>) } };
|
|
111
112
|
}
|
package/src/probe.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { opencodeHeaders } from "./config.ts";
|
|
1
|
+
import { OPENCODE_API_URL, opencodeHeaders } from "./config.ts";
|
|
2
2
|
import { ensureChatFingerprintTools } from "./opencode-fingerprint.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -17,7 +17,7 @@ export interface RelayProbeOptions {
|
|
|
17
17
|
/**
|
|
18
18
|
* Probe mode:
|
|
19
19
|
* - "models" (default): GET /v1/models (reachability probe)
|
|
20
|
-
|
|
20
|
+
* - "chat": POST /inference/v1/chat/completions (verifies free tier inference & spoofing)
|
|
21
21
|
*/
|
|
22
22
|
endpoint?: "models" | "chat";
|
|
23
23
|
/** Model to use for chat probe (defaults to "nemotron-3.5-lightning-free") */
|
|
@@ -76,7 +76,7 @@ async function probeRelayOnce(
|
|
|
76
76
|
|
|
77
77
|
if (isDirectOpenCode) {
|
|
78
78
|
if (isChat) {
|
|
79
|
-
fetchUrl =
|
|
79
|
+
fetchUrl = `${OPENCODE_API_URL}/chat/completions`;
|
|
80
80
|
method = "POST";
|
|
81
81
|
headers["content-type"] = "application/json";
|
|
82
82
|
const probePayload: Record<string, unknown> = {
|
|
@@ -88,15 +88,15 @@ async function probeRelayOnce(
|
|
|
88
88
|
ensureChatFingerprintTools(probePayload);
|
|
89
89
|
body = JSON.stringify(probePayload);
|
|
90
90
|
} else {
|
|
91
|
-
fetchUrl =
|
|
91
|
+
fetchUrl = `${OPENCODE_API_URL}/models`;
|
|
92
92
|
}
|
|
93
93
|
} else {
|
|
94
94
|
if (isChat) {
|
|
95
|
-
fetchUrl = `${cleanUrl}/
|
|
95
|
+
fetchUrl = `${cleanUrl}/inference/v1/chat/completions`;
|
|
96
96
|
method = "POST";
|
|
97
97
|
headers["content-type"] = "application/json";
|
|
98
98
|
headers["x-relay-target"] = "https://opencode.ai";
|
|
99
|
-
headers["x-relay-path"] = "/
|
|
99
|
+
headers["x-relay-path"] = "/inference/v1/chat/completions";
|
|
100
100
|
const probePayload: Record<string, unknown> = {
|
|
101
101
|
model: opts?.model || "nemotron-3.5-lightning-free",
|
|
102
102
|
max_tokens: 1,
|
|
@@ -108,7 +108,7 @@ async function probeRelayOnce(
|
|
|
108
108
|
} else {
|
|
109
109
|
fetchUrl = `${cleanUrl}/v1/models`;
|
|
110
110
|
headers["x-relay-target"] = "https://opencode.ai";
|
|
111
|
-
headers["x-relay-path"] = "/
|
|
111
|
+
headers["x-relay-path"] = "/inference/v1/models";
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
|
package/src/proxy.ts
CHANGED
|
@@ -80,6 +80,7 @@ import {
|
|
|
80
80
|
import { pipeUpstreamStream, type StreamCloakOptions } from "./stream-pipe.ts";
|
|
81
81
|
import {
|
|
82
82
|
decideZenRoute,
|
|
83
|
+
isFreeTierGate,
|
|
83
84
|
isUnprovenSession,
|
|
84
85
|
isUpstreamGated,
|
|
85
86
|
pickFailoverModel,
|
|
@@ -563,6 +564,130 @@ async function handleOvhRequest(opts: {
|
|
|
563
564
|
}
|
|
564
565
|
}
|
|
565
566
|
|
|
567
|
+
/**
|
|
568
|
+
* Keyless gateway handling for Kilo models (both direct Kilo models and
|
|
569
|
+
* Zen sessions failed over to Kilo). Serves chat completions and responses
|
|
570
|
+
* natively, repairs tool arguments, and recovers from 400 token overflows.
|
|
571
|
+
*/
|
|
572
|
+
async function handleKiloRequest(opts: {
|
|
573
|
+
parsedBody: Record<string, unknown>;
|
|
574
|
+
pathname: string;
|
|
575
|
+
req: http.IncomingMessage;
|
|
576
|
+
res: http.ServerResponse;
|
|
577
|
+
reqId: string;
|
|
578
|
+
clientRequestedStream: boolean;
|
|
579
|
+
sessionKey?: string | null;
|
|
580
|
+
}): Promise<void> {
|
|
581
|
+
const { parsedBody, pathname, req, res, reqId, clientRequestedStream, sessionKey } = opts;
|
|
582
|
+
const isStream = clientRequestedStream;
|
|
583
|
+
const kiloController = new AbortController();
|
|
584
|
+
const kiloArgSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
|
|
585
|
+
const kiloTimeoutId = setTimeout(
|
|
586
|
+
() => kiloController.abort(upstreamTimeoutError()),
|
|
587
|
+
UPSTREAM_HEADER_TIMEOUT_MS,
|
|
588
|
+
);
|
|
589
|
+
const abortKiloOnClientGone = () => {
|
|
590
|
+
if (!res.writableEnded) kiloController.abort();
|
|
591
|
+
};
|
|
592
|
+
res.once("close", abortKiloOnClientGone);
|
|
593
|
+
req.once("error", abortKiloOnClientGone);
|
|
594
|
+
let response: Response;
|
|
595
|
+
const kiloUrl = pathname.endsWith("/responses") ? KILO_RESPONSES_URL : KILO_CHAT_URL;
|
|
596
|
+
try {
|
|
597
|
+
response = await relayFetch(
|
|
598
|
+
kiloUrl,
|
|
599
|
+
{
|
|
600
|
+
method: "POST",
|
|
601
|
+
headers: {
|
|
602
|
+
"Content-Type": "application/json",
|
|
603
|
+
},
|
|
604
|
+
body: JSON.stringify(parsedBody),
|
|
605
|
+
signal: kiloController.signal,
|
|
606
|
+
},
|
|
607
|
+
reqId,
|
|
608
|
+
);
|
|
609
|
+
if (response.status === 400 && !res.writableEnded) {
|
|
610
|
+
const retryBody = { ...parsedBody };
|
|
611
|
+
let changed = false;
|
|
612
|
+
if (typeof retryBody.max_output_tokens === "number" && retryBody.max_output_tokens > 4096) {
|
|
613
|
+
retryBody.max_output_tokens = 4096;
|
|
614
|
+
changed = true;
|
|
615
|
+
}
|
|
616
|
+
if (typeof retryBody.max_tokens === "number" && retryBody.max_tokens > 4096) {
|
|
617
|
+
retryBody.max_tokens = 4096;
|
|
618
|
+
changed = true;
|
|
619
|
+
}
|
|
620
|
+
if (retryBody.model === "dots-studio/dots-3-note-preview:free") {
|
|
621
|
+
retryBody.model = "nvidia/nemotron-3.5-lightning:free";
|
|
622
|
+
changed = true;
|
|
623
|
+
}
|
|
624
|
+
if (changed) {
|
|
625
|
+
log("warn", `kilo 400 for model ${String(parsedBody.model)} — retrying with clamped tokens`, { model: parsedBody.model }, reqId);
|
|
626
|
+
try {
|
|
627
|
+
const retryRes = await relayFetch(
|
|
628
|
+
kiloUrl,
|
|
629
|
+
{
|
|
630
|
+
method: "POST",
|
|
631
|
+
headers: {
|
|
632
|
+
"Content-Type": "application/json",
|
|
633
|
+
},
|
|
634
|
+
body: JSON.stringify(retryBody),
|
|
635
|
+
signal: kiloController.signal,
|
|
636
|
+
},
|
|
637
|
+
reqId,
|
|
638
|
+
);
|
|
639
|
+
if (retryRes.ok) {
|
|
640
|
+
log("info", `kilo 400 recovery succeeded with model ${String(retryBody.model)}`, { model: retryBody.model }, reqId);
|
|
641
|
+
response = retryRes;
|
|
642
|
+
}
|
|
643
|
+
} catch { }
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
} finally {
|
|
647
|
+
clearTimeout(kiloTimeoutId);
|
|
648
|
+
res.off("close", abortKiloOnClientGone);
|
|
649
|
+
req.off("error", abortKiloOnClientGone);
|
|
650
|
+
}
|
|
651
|
+
if (response.ok) recordUpstreamSuccess("kilo", { sessionKey });
|
|
652
|
+
|
|
653
|
+
if (isStream && response.ok && response.body) {
|
|
654
|
+
const ct = response.headers.get("content-type") || "text/event-stream";
|
|
655
|
+
res.writeHead(response.status, {
|
|
656
|
+
"content-type": ct,
|
|
657
|
+
"cache-control": "no-cache, no-transform",
|
|
658
|
+
connection: "keep-alive",
|
|
659
|
+
"x-accel-buffering": "no",
|
|
660
|
+
});
|
|
661
|
+
pipeUpstreamStream(
|
|
662
|
+
Readable.fromWeb(
|
|
663
|
+
response.body as unknown as WebReadableStream,
|
|
664
|
+
),
|
|
665
|
+
res,
|
|
666
|
+
req,
|
|
667
|
+
reqId,
|
|
668
|
+
undefined,
|
|
669
|
+
kiloArgSchemas.size > 0
|
|
670
|
+
? {
|
|
671
|
+
callerHadTools: true,
|
|
672
|
+
argSchemas: kiloArgSchemas,
|
|
673
|
+
pathname: pathname.endsWith("/responses") ? "/v1/responses" : "/v1/chat/completions",
|
|
674
|
+
}
|
|
675
|
+
: undefined,
|
|
676
|
+
);
|
|
677
|
+
} else {
|
|
678
|
+
let rawText = await response.text();
|
|
679
|
+
if (response.ok && kiloArgSchemas.size > 0) {
|
|
680
|
+
rawText = convertSseToJson(rawText, pathname, true, undefined, undefined, undefined, kiloArgSchemas);
|
|
681
|
+
rawText = repairJsonToolArguments(rawText, pathname, kiloArgSchemas);
|
|
682
|
+
}
|
|
683
|
+
const data = withRateLimitHint(response.status, rawText);
|
|
684
|
+
const ct = response.headers.get("content-type") || "application/json";
|
|
685
|
+
res.writeHead(response.status, { "content-type": ct });
|
|
686
|
+
res.end(data);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
|
|
566
691
|
/**
|
|
567
692
|
* Relay eligibility: only free-catalog models ride relay egress. Returns true
|
|
568
693
|
* when the model is a non-empty string present in MODEL_MAP or KILO_MODEL_IDS
|
|
@@ -690,11 +815,11 @@ export function sanitizeHeaders(
|
|
|
690
815
|
sanitized.connection = "keep-alive";
|
|
691
816
|
return sanitized;
|
|
692
817
|
}
|
|
693
|
-
export async function isProxyAlive(port: number): Promise<boolean> {
|
|
818
|
+
export async function isProxyAlive(port: number, timeoutMs = 600): Promise<boolean> {
|
|
694
819
|
if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
|
|
695
820
|
try {
|
|
696
821
|
const res = await fetch(`http://${HOST}:${port}/v1/models`, {
|
|
697
|
-
signal: AbortSignal.timeout(
|
|
822
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
698
823
|
});
|
|
699
824
|
const ct = res.headers.get("content-type") || "";
|
|
700
825
|
return res.ok && ct.includes("application/json");
|
|
@@ -814,31 +939,42 @@ function killViaProcNet(port: number): boolean {
|
|
|
814
939
|
|
|
815
940
|
export async function killPortHolder(port: number): Promise<boolean> {
|
|
816
941
|
if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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 {
|
|
842
978
|
const out = execSync(`lsof -ti tcp:${port} 2>/dev/null || fuser -n tcp ${port} 2>/dev/null`, {
|
|
843
979
|
encoding: "utf8",
|
|
844
980
|
timeout: 3000,
|
|
@@ -1277,133 +1413,15 @@ export function startProxy(
|
|
|
1277
1413
|
// token: the proxy only passes the body and maps the answer.
|
|
1278
1414
|
await handleClineRequest({ parsedBody, pathname: target.pathname, req, res, reqId, clientRequestedStream });
|
|
1279
1415
|
} else if (isKilo && parsedBody) {
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
() => kiloController.abort(upstreamTimeoutError()),
|
|
1290
|
-
UPSTREAM_HEADER_TIMEOUT_MS,
|
|
1291
|
-
);
|
|
1292
|
-
const abortKiloOnClientGone = () => {
|
|
1293
|
-
if (!res.writableEnded) kiloController.abort();
|
|
1294
|
-
};
|
|
1295
|
-
res.once("close", abortKiloOnClientGone);
|
|
1296
|
-
req.once("error", abortKiloOnClientGone);
|
|
1297
|
-
let response: Response;
|
|
1298
|
-
// Kilo serves both wire APIs natively: responses-shaped bodies go to
|
|
1299
|
-
// its responses endpoint, chat bodies to chat completions. Sending a
|
|
1300
|
-
// responses body to the chat URL (or vice versa) would 400.
|
|
1301
|
-
const kiloUrl = target.pathname.endsWith("/responses") ? KILO_RESPONSES_URL : KILO_CHAT_URL;
|
|
1302
|
-
try {
|
|
1303
|
-
response = await relayFetch(
|
|
1304
|
-
kiloUrl,
|
|
1305
|
-
{
|
|
1306
|
-
method: "POST",
|
|
1307
|
-
headers: {
|
|
1308
|
-
// Keyless, like Zen: Kilo's gateway rejects a placeholder bearer
|
|
1309
|
-
// (live 2026-09-22 — "Bearer kilo-free" answers 401 INVALID_TOKEN,
|
|
1310
|
-
// omitting Authorization answers 200 for every free catalog model).
|
|
1311
|
-
"Content-Type": "application/json",
|
|
1312
|
-
},
|
|
1313
|
-
body: JSON.stringify(parsedBody),
|
|
1314
|
-
signal: kiloController.signal,
|
|
1315
|
-
},
|
|
1316
|
-
reqId,
|
|
1317
|
-
);
|
|
1318
|
-
// Kilo 400 recovery: if upstream Kilo rejected the body with 400 (e.g.
|
|
1319
|
-
// prompt + max_output_tokens exceeded model's operational context limit),
|
|
1320
|
-
// retry once with clamped max tokens and fallback to a 1M model.
|
|
1321
|
-
if (response.status === 400 && !res.writableEnded) {
|
|
1322
|
-
const retryBody = { ...parsedBody };
|
|
1323
|
-
let changed = false;
|
|
1324
|
-
if (typeof retryBody.max_output_tokens === "number" && retryBody.max_output_tokens > 4096) {
|
|
1325
|
-
retryBody.max_output_tokens = 4096;
|
|
1326
|
-
changed = true;
|
|
1327
|
-
}
|
|
1328
|
-
if (typeof retryBody.max_tokens === "number" && retryBody.max_tokens > 4096) {
|
|
1329
|
-
retryBody.max_tokens = 4096;
|
|
1330
|
-
changed = true;
|
|
1331
|
-
}
|
|
1332
|
-
if (retryBody.model === "dots-studio/dots-3-note-preview:free") {
|
|
1333
|
-
retryBody.model = "nvidia/nemotron-3.5-lightning:free";
|
|
1334
|
-
changed = true;
|
|
1335
|
-
}
|
|
1336
|
-
if (changed) {
|
|
1337
|
-
log("warn", `kilo 400 for model ${String(parsedBody.model)} — retrying with clamped tokens`, { model: parsedBody.model }, reqId);
|
|
1338
|
-
try {
|
|
1339
|
-
const retryRes = await relayFetch(
|
|
1340
|
-
kiloUrl,
|
|
1341
|
-
{
|
|
1342
|
-
method: "POST",
|
|
1343
|
-
headers: {
|
|
1344
|
-
"Content-Type": "application/json",
|
|
1345
|
-
},
|
|
1346
|
-
body: JSON.stringify(retryBody),
|
|
1347
|
-
signal: kiloController.signal,
|
|
1348
|
-
},
|
|
1349
|
-
reqId,
|
|
1350
|
-
);
|
|
1351
|
-
if (retryRes.ok) {
|
|
1352
|
-
log("info", `kilo 400 recovery succeeded with model ${String(retryBody.model)}`, { model: retryBody.model }, reqId);
|
|
1353
|
-
response = retryRes;
|
|
1354
|
-
}
|
|
1355
|
-
} catch { }
|
|
1356
|
-
}
|
|
1357
|
-
}
|
|
1358
|
-
} finally {
|
|
1359
|
-
clearTimeout(kiloTimeoutId);
|
|
1360
|
-
res.off("close", abortKiloOnClientGone);
|
|
1361
|
-
req.off("error", abortKiloOnClientGone);
|
|
1362
|
-
}
|
|
1363
|
-
if (response.ok) recordUpstreamSuccess("kilo", { sessionKey });
|
|
1364
|
-
|
|
1365
|
-
if (isStream && response.ok && response.body) {
|
|
1366
|
-
const ct =
|
|
1367
|
-
response.headers.get("content-type") || "text/event-stream";
|
|
1368
|
-
res.writeHead(response.status, {
|
|
1369
|
-
"content-type": ct,
|
|
1370
|
-
"cache-control": "no-cache, no-transform",
|
|
1371
|
-
connection: "keep-alive",
|
|
1372
|
-
"x-accel-buffering": "no",
|
|
1373
|
-
});
|
|
1374
|
-
// Kilo is fetched directly (not via the relay pool), so pass undefined as
|
|
1375
|
-
// the issuer: attributing kilo-side stream failures to an unrelated
|
|
1376
|
-
// opencode relay would mark a healthy relay as failed.
|
|
1377
|
-
pipeUpstreamStream(
|
|
1378
|
-
Readable.fromWeb(
|
|
1379
|
-
response.body as unknown as WebReadableStream,
|
|
1380
|
-
),
|
|
1381
|
-
res,
|
|
1382
|
-
req,
|
|
1383
|
-
reqId,
|
|
1384
|
-
undefined,
|
|
1385
|
-
kiloArgSchemas.size > 0
|
|
1386
|
-
? {
|
|
1387
|
-
callerHadTools: true,
|
|
1388
|
-
argSchemas: kiloArgSchemas,
|
|
1389
|
-
pathname: target.pathname.endsWith("/responses") ? "/v1/responses" : "/v1/chat/completions",
|
|
1390
|
-
}
|
|
1391
|
-
: undefined,
|
|
1392
|
-
);
|
|
1393
|
-
} else {
|
|
1394
|
-
let rawText = await response.text();
|
|
1395
|
-
if (response.ok && kiloArgSchemas.size > 0) {
|
|
1396
|
-
// Kilo passes `stream` through verbatim, so a non-streaming caller gets
|
|
1397
|
-
// a plain JSON body: the SSE aggregator is a no-op on it, and the JSON
|
|
1398
|
-
// repair is what actually applies here.
|
|
1399
|
-
rawText = convertSseToJson(rawText, target.pathname, true, undefined, undefined, undefined, kiloArgSchemas);
|
|
1400
|
-
rawText = repairJsonToolArguments(rawText, target.pathname, kiloArgSchemas);
|
|
1401
|
-
}
|
|
1402
|
-
const data = withRateLimitHint(response.status, rawText);
|
|
1403
|
-
const ct = response.headers.get("content-type") || "application/json";
|
|
1404
|
-
res.writeHead(response.status, { "content-type": ct });
|
|
1405
|
-
res.end(data);
|
|
1406
|
-
}
|
|
1416
|
+
await handleKiloRequest({
|
|
1417
|
+
parsedBody,
|
|
1418
|
+
pathname: target.pathname,
|
|
1419
|
+
req,
|
|
1420
|
+
res,
|
|
1421
|
+
reqId,
|
|
1422
|
+
clientRequestedStream,
|
|
1423
|
+
sessionKey,
|
|
1424
|
+
});
|
|
1407
1425
|
} else if (isOvh && parsedBody) {
|
|
1408
1426
|
// OVH serves chat upstream only (see handleOvhRequest): direct, keyless,
|
|
1409
1427
|
// no fingerprint, and a responses-path body translated to chat and back.
|
|
@@ -1824,14 +1842,14 @@ export function startProxy(
|
|
|
1824
1842
|
// Gated upstream: a 403 free-tier gate is a final answer, not a
|
|
1825
1843
|
// stream — buffer it and attach the recovery hint instead of
|
|
1826
1844
|
// piping it through as a stream body.
|
|
1827
|
-
|
|
1828
|
-
|
|
1845
|
+
if (upstreamRes.status === 403) {
|
|
1846
|
+
const rawText403 = await upstreamRes.text();
|
|
1847
|
+
const data = withFreeTierHint(403, rawText403);
|
|
1829
1848
|
const ct403 = upstreamRes.headers.get("content-type") || "application/json";
|
|
1830
1849
|
res.writeHead(403, { "content-type": ct403 });
|
|
1831
1850
|
res.end(data);
|
|
1832
1851
|
return;
|
|
1833
1852
|
}
|
|
1834
|
-
|
|
1835
1853
|
const outHeaders: Record<string, string> = {};
|
|
1836
1854
|
for (const h of ["content-type", "cache-control", "x-request-id"] as const) {
|
|
1837
1855
|
const v = upstreamRes.headers.get(h);
|
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/tool-translation.ts
CHANGED
|
@@ -190,6 +190,39 @@ export const OMP_FINGERPRINT_DEFS: Record<Exclude<FingerprintToolName, "edit">,
|
|
|
190
190
|
},
|
|
191
191
|
};
|
|
192
192
|
|
|
193
|
+
/** Minimal valid schemas for injected compat tools to satisfy upstream inference gate. */
|
|
194
|
+
export const COMPAT_FINGERPRINT_SCHEMAS: Record<FingerprintToolName, Record<string, unknown>> = {
|
|
195
|
+
bash: {
|
|
196
|
+
type: "object",
|
|
197
|
+
properties: { command: { type: "string", description: "Shell command to run." } },
|
|
198
|
+
required: ["command"],
|
|
199
|
+
},
|
|
200
|
+
read: {
|
|
201
|
+
type: "object",
|
|
202
|
+
properties: { path: { type: "string", description: "File path to read." } },
|
|
203
|
+
required: ["path"],
|
|
204
|
+
},
|
|
205
|
+
glob: {
|
|
206
|
+
type: "object",
|
|
207
|
+
properties: { path: { type: "string", description: "Search path." } },
|
|
208
|
+
},
|
|
209
|
+
grep: {
|
|
210
|
+
type: "object",
|
|
211
|
+
properties: { pattern: { type: "string", description: "Search pattern." } },
|
|
212
|
+
required: ["pattern"],
|
|
213
|
+
},
|
|
214
|
+
edit: {
|
|
215
|
+
type: "object",
|
|
216
|
+
properties: { path: { type: "string", description: "File path to edit." } },
|
|
217
|
+
required: ["path"],
|
|
218
|
+
},
|
|
219
|
+
write: {
|
|
220
|
+
type: "object",
|
|
221
|
+
properties: { path: { type: "string", description: "File path to write." } },
|
|
222
|
+
required: ["path"],
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
|
|
193
226
|
/** Caller tool names that only an OMP host sends (never Pi). */
|
|
194
227
|
export const OMP_CALLER_MARKERS = ["ask", "task", "todo", "hub", "lsp"] as const;
|
|
195
228
|
|
|
@@ -757,8 +790,51 @@ export function chatResponsesJsonFromChatCompletion(chat: Record<string, unknown
|
|
|
757
790
|
*/
|
|
758
791
|
export function chatResponsesSseFromChatCompletion(resp: Record<string, unknown>): string {
|
|
759
792
|
const head = { type: "response.created", response: { id: resp.id, object: "response", model: resp.model } };
|
|
793
|
+
let out = `event: response.created\ndata: ${JSON.stringify(head)}\n\n`;
|
|
794
|
+
const output = resp.output;
|
|
795
|
+
if (Array.isArray(output)) {
|
|
796
|
+
for (let idx = 0; idx < output.length; idx++) {
|
|
797
|
+
const item = output[idx] as Record<string, unknown>;
|
|
798
|
+
if (item && item.type === "message" && Array.isArray(item.content)) {
|
|
799
|
+
for (const part of item.content) {
|
|
800
|
+
const p = part as Record<string, unknown>;
|
|
801
|
+
if (p && p.type === "output_text" && typeof p.text === "string" && p.text.length > 0) {
|
|
802
|
+
out += `event: response.output_text.delta\ndata: ${JSON.stringify({
|
|
803
|
+
type: "response.output_text.delta",
|
|
804
|
+
output_index: idx,
|
|
805
|
+
delta: p.text,
|
|
806
|
+
})}\n\n`;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
} else if (item && item.type === "function_call") {
|
|
810
|
+
out += `event: response.output_item.added\ndata: ${JSON.stringify({
|
|
811
|
+
type: "response.output_item.added",
|
|
812
|
+
output_index: idx,
|
|
813
|
+
item: {
|
|
814
|
+
type: "function_call",
|
|
815
|
+
id: item.id,
|
|
816
|
+
call_id: item.call_id,
|
|
817
|
+
name: item.name,
|
|
818
|
+
arguments: "",
|
|
819
|
+
},
|
|
820
|
+
})}\n\n`;
|
|
821
|
+
if (typeof item.arguments === "string" && item.arguments.length > 0) {
|
|
822
|
+
out += `event: response.function_call_arguments.delta\ndata: ${JSON.stringify({
|
|
823
|
+
type: "response.function_call_arguments.delta",
|
|
824
|
+
output_index: idx,
|
|
825
|
+
delta: item.arguments,
|
|
826
|
+
})}\n\n`;
|
|
827
|
+
}
|
|
828
|
+
out += `event: response.output_item.done\ndata: ${JSON.stringify({
|
|
829
|
+
type: "response.output_item.done",
|
|
830
|
+
output_index: idx,
|
|
831
|
+
item,
|
|
832
|
+
})}\n\n`;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
760
836
|
const done = { type: "response.completed", response: resp };
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
837
|
+
out += `event: response.completed\ndata: ${JSON.stringify(done)}\n\n`;
|
|
838
|
+
out += `data: [DONE]\n\n`;
|
|
839
|
+
return out;
|
|
764
840
|
}
|
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
|
+
}
|