pi-freeflow 1.7.1 → 1.8.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/README.md +11 -5
- package/package.json +62 -62
- package/src/catalog.ts +2 -2
- package/src/config.ts +13 -0
- package/src/health.ts +11 -6
- package/src/index.ts +143 -29
- package/src/models.ts +42 -4
- package/src/proxy.ts +62 -5
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-freeflow 🌊
|
|
2
2
|
|
|
3
|
-
> **
|
|
3
|
+
> **28 free models. Up to 1M context. Zero API keys. Infinite scale via your own relay pool.**
|
|
4
4
|
|
|
5
5
|
Thin by design: model list + dumb relay + log. Host `pi-ai` owns thinking, normalization & provider magic. We just make it free, fast, and unbreakable.
|
|
6
6
|
|
|
@@ -18,7 +18,7 @@ Join devs bypassing rate limits with their own relay pools. BYO, add as many as
|
|
|
18
18
|
|
|
19
19
|
| Feature | Description | Value | Cost |
|
|
20
20
|
| :--- | :--- | :--- | :--- |
|
|
21
|
-
| **
|
|
21
|
+
| **28 Curated Free Models** | 8 OpenCode Zen + 20 KiloCode Gateway models, up to 1M context & 512K output | Ceiling Unlocked | **$0** |
|
|
22
22
|
| **BYO Relay Pool** | Round-robin load balancing across your Cloudflare Workers & Vercel Edges | Zero Rate Limits | **$0** (your free tiers) |
|
|
23
23
|
| **Adaptive Health & Error Detection** | Auto-cooldown on 429 rate limits, 504 timeouts, and socket drops | 0ms Wasted Latency | **$0** |
|
|
24
24
|
| **Stream Truncation Resilience** | Stateful SSE terminal tracking (`response.failed` / `response.incomplete` injection) | Zero Host Crashes | **$0** |
|
|
@@ -32,7 +32,7 @@ Philosophy: **Thin by design.** We only ship model list + relay proxy + log. Hos
|
|
|
32
32
|
|
|
33
33
|
---
|
|
34
34
|
|
|
35
|
-
###
|
|
35
|
+
### 28 Curated Models, One Command
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
38
|
/model → freeflow → pick
|
|
@@ -269,7 +269,7 @@ Current size: about 11.3k lines including tests. 230 tests pass, typecheck clean
|
|
|
269
269
|
### FAQ
|
|
270
270
|
|
|
271
271
|
**Do I need API keys?**
|
|
272
|
-
No. Kilo uses `Bearer kilo-free`, OpenCode
|
|
272
|
+
No. Kilo uses `Bearer kilo-free`, OpenCode free models are anonymous (no header needed).
|
|
273
273
|
|
|
274
274
|
**What if all relays are 429?**
|
|
275
275
|
Proxy tries direct. If that is also 429, Pi shows the rate limit. That number is the global upstream cap; without relays you would hit the same wall.
|
|
@@ -277,6 +277,12 @@ Proxy tries direct. If that is also 429, Pi shows the rate limit. That number is
|
|
|
277
277
|
**Can I use without relays?**
|
|
278
278
|
Yes. `/freeflow off` → direct. Add relays later to scale.
|
|
279
279
|
|
|
280
|
+
**What happens when I update to a new version?**
|
|
281
|
+
The local proxy daemon is shared across sessions on port 28180. On upgrade, the new extension
|
|
282
|
+
detects a stale daemon (mismatched internal version) and replaces it automatically — no manual
|
|
283
|
+
kill, no restart of other sessions required. If an old daemon cannot be replaced (e.g. port held
|
|
284
|
+
by an unrelated process), it falls back to reusing it with a warning.
|
|
285
|
+
|
|
280
286
|
**Where's the normalizer?**
|
|
281
287
|
Deleted in 1.3.0. If zai/qwen/deepseek thinking broke before, it's fixed now because host handles it.
|
|
282
288
|
|
|
@@ -324,7 +330,7 @@ pnpm smoke # verifies extensions/index.ts loads without crashing
|
|
|
324
330
|
```
|
|
325
331
|
src/
|
|
326
332
|
├── index.ts # extension entry, lifecycle hooks
|
|
327
|
-
├── models.ts #
|
|
333
|
+
├── models.ts # 28-model catalog definitions
|
|
328
334
|
├── catalog.ts # model catalog cache (24h disk)
|
|
329
335
|
├── proxy.ts # local proxy server (127.0.0.1:28180)
|
|
330
336
|
├── relay.ts # relay selection & round-robin
|
package/package.json
CHANGED
|
@@ -1,62 +1,62 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pi-freeflow",
|
|
3
|
-
"type": "module",
|
|
4
|
-
"version": "1.
|
|
5
|
-
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
|
-
"main": "extensions/index.ts",
|
|
7
|
-
"types": "src/index.ts",
|
|
8
|
-
"keywords": [
|
|
9
|
-
"pi-package",
|
|
10
|
-
"pi-extension",
|
|
11
|
-
"oh-my-pi",
|
|
12
|
-
"omp",
|
|
13
|
-
"free-models",
|
|
14
|
-
"opencode",
|
|
15
|
-
"kilocode",
|
|
16
|
-
"ai-models",
|
|
17
|
-
"relay"
|
|
18
|
-
],
|
|
19
|
-
"author": "trefeon",
|
|
20
|
-
"license": "MIT",
|
|
21
|
-
"repository": {
|
|
22
|
-
"type": "git",
|
|
23
|
-
"url": "git+https://github.com/trefeon/pi-freeflow.git"
|
|
24
|
-
},
|
|
25
|
-
"homepage": "https://github.com/trefeon/pi-freeflow#readme",
|
|
26
|
-
"engines": {
|
|
27
|
-
"node": ">=22.19.0"
|
|
28
|
-
},
|
|
29
|
-
"omp": {
|
|
30
|
-
"extensions": [
|
|
31
|
-
"./extensions"
|
|
32
|
-
]
|
|
33
|
-
},
|
|
34
|
-
"pi": {
|
|
35
|
-
"extensions": [
|
|
36
|
-
"./extensions"
|
|
37
|
-
]
|
|
38
|
-
},
|
|
39
|
-
"files": [
|
|
40
|
-
"extensions",
|
|
41
|
-
"src",
|
|
42
|
-
"README.md",
|
|
43
|
-
"LICENSE"
|
|
44
|
-
],
|
|
45
|
-
"scripts": {
|
|
46
|
-
"test": "node --experimental-strip-types --test --test-concurrency=1 test/**/*.test.ts",
|
|
47
|
-
"typecheck": "tsc --noEmit",
|
|
48
|
-
"smoke": "node --experimental-strip-types -e \"import('./extensions/index.ts').then(() => console.log('✓ Smoke test passed: extensions/index.ts loaded successfully')).catch(err => { console.error(err); process.exit(1); })\"",
|
|
49
|
-
"changeset": "changeset",
|
|
50
|
-
"docs:dev": "vitepress dev docs",
|
|
51
|
-
"docs:build": "vitepress build docs"
|
|
52
|
-
},
|
|
53
|
-
"devDependencies": {
|
|
54
|
-
"@changesets/cli": "^2.27.0",
|
|
55
|
-
"@types/node": "^22.13.9",
|
|
56
|
-
"typescript": "^5.8.2",
|
|
57
|
-
"vitepress": "^1.6.4"
|
|
58
|
-
},
|
|
59
|
-
"dependencies": {
|
|
60
|
-
"undici": "^8.10.0"
|
|
61
|
-
}
|
|
62
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-freeflow",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "1.8.1",
|
|
5
|
+
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
|
+
"main": "extensions/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"oh-my-pi",
|
|
12
|
+
"omp",
|
|
13
|
+
"free-models",
|
|
14
|
+
"opencode",
|
|
15
|
+
"kilocode",
|
|
16
|
+
"ai-models",
|
|
17
|
+
"relay"
|
|
18
|
+
],
|
|
19
|
+
"author": "trefeon",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/trefeon/pi-freeflow.git"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/trefeon/pi-freeflow#readme",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22.19.0"
|
|
28
|
+
},
|
|
29
|
+
"omp": {
|
|
30
|
+
"extensions": [
|
|
31
|
+
"./extensions"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"pi": {
|
|
35
|
+
"extensions": [
|
|
36
|
+
"./extensions"
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"extensions",
|
|
41
|
+
"src",
|
|
42
|
+
"README.md",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
],
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "node --experimental-strip-types --test --test-concurrency=1 test/**/*.test.ts",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"smoke": "node --experimental-strip-types -e \"import('./extensions/index.ts').then(() => console.log('✓ Smoke test passed: extensions/index.ts loaded successfully')).catch(err => { console.error(err); process.exit(1); })\"",
|
|
49
|
+
"changeset": "changeset",
|
|
50
|
+
"docs:dev": "vitepress dev docs",
|
|
51
|
+
"docs:build": "vitepress build docs"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@changesets/cli": "^2.27.0",
|
|
55
|
+
"@types/node": "^22.13.9",
|
|
56
|
+
"typescript": "^5.8.2",
|
|
57
|
+
"vitepress": "^1.6.4"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"undici": "^8.10.0"
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/catalog.ts
CHANGED
|
@@ -34,7 +34,7 @@ import type {
|
|
|
34
34
|
export const DEAD_MODEL_IDS = new Set<string>(["deepseek-v4-flash-free", "x-preview-f-free"]);
|
|
35
35
|
/**
|
|
36
36
|
* In-memory cache of currently active/available free models.
|
|
37
|
-
* Initialized with all
|
|
37
|
+
* Initialized with all 28 verified models for 0ms instant availability.
|
|
38
38
|
*/
|
|
39
39
|
let aliveCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
|
|
40
40
|
...m,
|
|
@@ -328,6 +328,6 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
328
328
|
} catch (err) {
|
|
329
329
|
logDebug("Failed reading stale catalog cache", { error: String(err) });
|
|
330
330
|
}
|
|
331
|
-
// No valid cache — return in-memory static
|
|
331
|
+
// No valid cache — return in-memory static 28 (host will refresh if needed)
|
|
332
332
|
return aliveCatalog;
|
|
333
333
|
}
|
package/src/config.ts
CHANGED
|
@@ -6,8 +6,21 @@ import { randomUUID } from "node:crypto";
|
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
9
10
|
import type { Upstream } from "./types.ts";
|
|
10
11
|
|
|
12
|
+
// Package version — stale-daemon detection in the shared-port reuse path.
|
|
13
|
+
let PKG_VERSION = "0.0.0";
|
|
14
|
+
try {
|
|
15
|
+
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "../package.json");
|
|
16
|
+
const raw: unknown = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
17
|
+
if (raw && typeof raw === "object" && "version" in raw) {
|
|
18
|
+
const v = raw.version;
|
|
19
|
+
if (typeof v === "string" && v) PKG_VERSION = v;
|
|
20
|
+
}
|
|
21
|
+
} catch {}
|
|
22
|
+
export { PKG_VERSION };
|
|
23
|
+
|
|
11
24
|
// ── Upstream endpoints ──────────────────────────────────────────────
|
|
12
25
|
export const UPSTREAM_OPENCODE = "https://opencode.ai/zen";
|
|
13
26
|
export const KILO_CHAT_URL = "https://api.kilo.ai/api/gateway/chat/completions";
|
package/src/health.ts
CHANGED
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
import type * as http from "node:http";
|
|
7
7
|
import { ALL_MODELS } from "./models.ts";
|
|
8
8
|
import { getActiveRelayState, getRelayHealth, isRelayHealthy } from "./relay-state.ts";
|
|
9
|
-
import { PORT } from "./config.ts";
|
|
10
|
-
import { getClientIP } from "./proxy.ts";
|
|
9
|
+
import { PKG_VERSION, PORT } from "./config.ts";
|
|
11
10
|
|
|
12
11
|
export interface HealthRelayInfo {
|
|
13
12
|
url: string;
|
|
@@ -24,8 +23,8 @@ export interface HealthData {
|
|
|
24
23
|
enabled: boolean;
|
|
25
24
|
relays: HealthRelayInfo[];
|
|
26
25
|
catalog: number;
|
|
26
|
+
version: string;
|
|
27
27
|
}
|
|
28
|
-
|
|
29
28
|
/**
|
|
30
29
|
* Collect current health snapshot.
|
|
31
30
|
* @param portOverride - actual listening port (defaults to config PORT)
|
|
@@ -50,6 +49,7 @@ export function getHealthData(portOverride?: number): HealthData {
|
|
|
50
49
|
enabled: Boolean(state.enabled),
|
|
51
50
|
relays,
|
|
52
51
|
catalog: ALL_MODELS.length,
|
|
52
|
+
version: PKG_VERSION,
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
|
|
@@ -76,13 +76,18 @@ export function handleHealthRequest(
|
|
|
76
76
|
return false;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
const isHealthPath =
|
|
80
|
-
pathname === "/_health" || pathname === "/health" || pathname.endsWith("/health");
|
|
79
|
+
const isHealthPath = pathname === "/_health" || pathname === "/health";
|
|
81
80
|
if (req.method !== "GET" || !isHealthPath) {
|
|
82
81
|
return false;
|
|
83
82
|
}
|
|
84
83
|
|
|
85
|
-
const
|
|
84
|
+
const sock: unknown = req.socket;
|
|
85
|
+
let rawIp = "";
|
|
86
|
+
if (sock && typeof sock === "object" && "remoteAddress" in sock) {
|
|
87
|
+
const v = sock.remoteAddress;
|
|
88
|
+
if (typeof v === "string") rawIp = v;
|
|
89
|
+
}
|
|
90
|
+
const clientIP = rawIp.startsWith("::ffff:") ? rawIp.slice(7) : rawIp;
|
|
86
91
|
if (!isLoopbackIP(clientIP)) {
|
|
87
92
|
const body = JSON.stringify({ error: "forbidden" });
|
|
88
93
|
res.writeHead(403, { "content-type": "application/json", "content-length": Buffer.byteLength(body) });
|
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 28 free models (8 OpenCode Zen + 20 KiloCode Gateway) 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
|
|
@@ -18,10 +18,10 @@ import {
|
|
|
18
18
|
setAliveCatalog,
|
|
19
19
|
} from "./catalog.ts";
|
|
20
20
|
import { createCommandSpec, updateStatusBar } from "./commands.ts";
|
|
21
|
-
import { HOST, LEGACY_PORT, ONBOARDED_FLAG_FILE, PORT } from "./config.ts";
|
|
21
|
+
import { HOST, LEGACY_PORT, ONBOARDED_FLAG_FILE, PKG_VERSION, PORT } from "./config.ts";
|
|
22
22
|
import { log, logInfo, logWarn } from "./logger.ts";
|
|
23
23
|
import { ALL_MODELS, KILO_MODEL_IDS, MODEL_MAP, resolveCanonicalModelId } from "./models.ts";
|
|
24
|
-
import { isProxyAlive, startProxy } from "./proxy.ts";
|
|
24
|
+
import { getDaemonVersion, isProxyAlive, killPortHolder, startProxy } from "./proxy.ts";
|
|
25
25
|
import { resetRateLimits } from "./rate-limiter.ts";
|
|
26
26
|
import { checkForUpdateInBackground } from "./update-checker.ts";
|
|
27
27
|
import {
|
|
@@ -114,7 +114,7 @@ export function buildProviderConfig(
|
|
|
114
114
|
): ProviderConfig {
|
|
115
115
|
return {
|
|
116
116
|
baseUrl: `http://${HOST}:${port}/v1`,
|
|
117
|
-
apiKey: "
|
|
117
|
+
apiKey: "public",
|
|
118
118
|
api: "openai-completions",
|
|
119
119
|
compat: { supportsDeveloperRole: false },
|
|
120
120
|
models: models.map((m) => {
|
|
@@ -174,18 +174,81 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
174
174
|
// 2. Single-Port Shared Pattern: Check if daemon is already running (e.g. parent session)
|
|
175
175
|
// Dual-probe: probe current PORT first; if missing, check LEGACY_PORT so existing v1.4.9
|
|
176
176
|
// sessions on 18080 are seamlessly reused without split-brain or duplicate daemons.
|
|
177
|
+
// Stale-daemon heal: if the alive daemon reports a different version (e.g. 1.7.1 vs 1.8.0
|
|
178
|
+
// after an upgrade), the auth fix never loads for the new session. Detect via /_health
|
|
179
|
+
// and best-effort replace the stale holder so old users are auto-healed.
|
|
177
180
|
let alreadyRunning = await isProxyAlive(PORT);
|
|
178
181
|
if (alreadyRunning) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
182
|
+
const remoteVer = await getDaemonVersion(PORT);
|
|
183
|
+
if (remoteVer !== null && remoteVer !== PKG_VERSION) {
|
|
184
|
+
logWarn(`stale proxy daemon v${remoteVer} on :${PORT} (need v${PKG_VERSION}) — replacing`, {
|
|
185
|
+
remoteVer,
|
|
186
|
+
expected: PKG_VERSION,
|
|
187
|
+
});
|
|
188
|
+
const killed = await killPortHolder(PORT);
|
|
189
|
+
if (killed) {
|
|
190
|
+
for (let i = 0; i < 10; i++) {
|
|
191
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
192
|
+
if (!(await isProxyAlive(PORT))) break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (!(await isProxyAlive(PORT))) {
|
|
196
|
+
try {
|
|
197
|
+
const r = await startProxy();
|
|
198
|
+
server = r.server;
|
|
199
|
+
actualPort = r.port;
|
|
200
|
+
alreadyRunning = false;
|
|
201
|
+
} catch (e) {
|
|
202
|
+
log("error", "stale daemon replaced but fresh bind failed — reusing stale as fallback", {
|
|
203
|
+
error: String(e),
|
|
204
|
+
});
|
|
205
|
+
actualPort = PORT;
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
logInfo(`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${PORT} (stale kill did not free port)`);
|
|
209
|
+
actualPort = PORT;
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
logInfo(`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${PORT}`);
|
|
213
|
+
actualPort = PORT;
|
|
214
|
+
}
|
|
183
215
|
} else if (PORT !== LEGACY_PORT && (await isProxyAlive(LEGACY_PORT))) {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
216
|
+
const remoteVer = await getDaemonVersion(LEGACY_PORT);
|
|
217
|
+
if (remoteVer !== null && remoteVer !== PKG_VERSION) {
|
|
218
|
+
logWarn(`stale legacy daemon v${remoteVer} on :${LEGACY_PORT} (need v${PKG_VERSION}) — replacing`, {
|
|
219
|
+
remoteVer,
|
|
220
|
+
expected: PKG_VERSION,
|
|
221
|
+
});
|
|
222
|
+
const killed = await killPortHolder(LEGACY_PORT);
|
|
223
|
+
if (killed) {
|
|
224
|
+
for (let i = 0; i < 10; i++) {
|
|
225
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
226
|
+
if (!(await isProxyAlive(LEGACY_PORT))) break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (!(await isProxyAlive(LEGACY_PORT)) && !(await isProxyAlive(PORT))) {
|
|
230
|
+
try {
|
|
231
|
+
const r = await startProxy();
|
|
232
|
+
server = r.server;
|
|
233
|
+
actualPort = r.port;
|
|
234
|
+
alreadyRunning = false;
|
|
235
|
+
} catch (e) {
|
|
236
|
+
log("error", "stale legacy daemon replaced but fresh bind failed — reusing as fallback", {
|
|
237
|
+
error: String(e),
|
|
238
|
+
});
|
|
239
|
+
alreadyRunning = true;
|
|
240
|
+
actualPort = LEGACY_PORT;
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
logInfo(`Reusing existing legacy pi-freeflow proxy daemon on http://${HOST}:${LEGACY_PORT}`);
|
|
244
|
+
alreadyRunning = true;
|
|
245
|
+
actualPort = LEGACY_PORT;
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
logInfo(`Reusing existing legacy pi-freeflow proxy daemon on http://${HOST}:${LEGACY_PORT}`);
|
|
249
|
+
alreadyRunning = true;
|
|
250
|
+
actualPort = LEGACY_PORT;
|
|
251
|
+
}
|
|
189
252
|
} else {
|
|
190
253
|
try {
|
|
191
254
|
const r = await startProxy();
|
|
@@ -200,8 +263,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
200
263
|
return;
|
|
201
264
|
}
|
|
202
265
|
}
|
|
203
|
-
|
|
204
|
-
// 3. Instant 0ms Static Catalog Registration
|
|
205
266
|
// Register static models immediately on boot so Pi/OMP picker is populated with zero latency!
|
|
206
267
|
const registeredCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
|
|
207
268
|
...m,
|
|
@@ -215,27 +276,80 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
215
276
|
buildProviderConfig(models, actualPort),
|
|
216
277
|
);
|
|
217
278
|
};
|
|
218
|
-
registerCatalog(registeredCatalog);
|
|
219
|
-
// Self-heal: if this session attached to an external daemon that later died,
|
|
220
|
-
// re-bind on the next lifecycle event so model calls recover without a restart.
|
|
221
279
|
let ensuringDaemon = false;
|
|
222
280
|
const ensureDaemon = async (): Promise<void> => {
|
|
223
281
|
if (ensuringDaemon) return;
|
|
224
282
|
ensuringDaemon = true;
|
|
225
283
|
try {
|
|
226
|
-
if (await isProxyAlive(actualPort))
|
|
227
|
-
|
|
284
|
+
if (await isProxyAlive(actualPort)) {
|
|
285
|
+
const v = await getDaemonVersion(actualPort);
|
|
286
|
+
if (v === null || v === PKG_VERSION) return;
|
|
287
|
+
logWarn(`proxy on :${actualPort} is stale v${v} (need v${PKG_VERSION}) — replacing`, {
|
|
288
|
+
remoteVer: v,
|
|
289
|
+
expected: PKG_VERSION,
|
|
290
|
+
});
|
|
291
|
+
const killed = await killPortHolder(actualPort);
|
|
292
|
+
if (killed) {
|
|
293
|
+
for (let i = 0; i < 10; i++) {
|
|
294
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
295
|
+
if (!(await isProxyAlive(actualPort))) break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (await isProxyAlive(actualPort)) return;
|
|
299
|
+
}
|
|
228
300
|
if (await isProxyAlive(PORT)) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
301
|
+
const v = await getDaemonVersion(PORT);
|
|
302
|
+
if (v !== null && v !== PKG_VERSION) {
|
|
303
|
+
logWarn(`proxy on :${PORT} is stale v${v} (need v${PKG_VERSION}) — replacing`, {
|
|
304
|
+
remoteVer: v,
|
|
305
|
+
expected: PKG_VERSION,
|
|
306
|
+
});
|
|
307
|
+
const killed = await killPortHolder(PORT);
|
|
308
|
+
if (killed) {
|
|
309
|
+
for (let i = 0; i < 10; i++) {
|
|
310
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
311
|
+
if (!(await isProxyAlive(PORT))) break;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (await isProxyAlive(PORT)) {
|
|
315
|
+
actualPort = PORT;
|
|
316
|
+
registerCatalog(getAliveCatalog());
|
|
317
|
+
logInfo(`Re-attached to proxy daemon on http://${HOST}:${PORT} (stale kill did not free port)`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
} else {
|
|
321
|
+
actualPort = PORT;
|
|
322
|
+
registerCatalog(getAliveCatalog());
|
|
323
|
+
logInfo(`Re-attached to proxy daemon on http://${HOST}:${PORT}`);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
233
326
|
}
|
|
234
327
|
if (PORT !== LEGACY_PORT && (await isProxyAlive(LEGACY_PORT))) {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
328
|
+
const v = await getDaemonVersion(LEGACY_PORT);
|
|
329
|
+
if (v !== null && v !== PKG_VERSION) {
|
|
330
|
+
logWarn(`legacy proxy on :${LEGACY_PORT} is stale v${v} (need v${PKG_VERSION}) — replacing`, {
|
|
331
|
+
remoteVer: v,
|
|
332
|
+
expected: PKG_VERSION,
|
|
333
|
+
});
|
|
334
|
+
const killed = await killPortHolder(LEGACY_PORT);
|
|
335
|
+
if (killed) {
|
|
336
|
+
for (let i = 0; i < 10; i++) {
|
|
337
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
338
|
+
if (!(await isProxyAlive(LEGACY_PORT))) break;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (await isProxyAlive(LEGACY_PORT)) {
|
|
342
|
+
actualPort = LEGACY_PORT;
|
|
343
|
+
registerCatalog(getAliveCatalog());
|
|
344
|
+
logInfo(`Re-attached to legacy proxy daemon on http://${HOST}:${LEGACY_PORT} (stale kill did not free port)`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
} else {
|
|
348
|
+
actualPort = LEGACY_PORT;
|
|
349
|
+
registerCatalog(getAliveCatalog());
|
|
350
|
+
logInfo(`Re-attached to legacy proxy daemon on http://${HOST}:${LEGACY_PORT}`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
239
353
|
}
|
|
240
354
|
const r = await startProxy();
|
|
241
355
|
if (r.server) server = r.server;
|
|
@@ -310,7 +424,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
310
424
|
? `Relay pool ready: ${freshRelayState.relays.length} relay(s). Run /freeflow for pool management.`
|
|
311
425
|
: "Relay pool empty — direct mode. Run /freeflow deploy to add your own egress.";
|
|
312
426
|
ctx.ui?.notify?.(
|
|
313
|
-
`freeflow ready:
|
|
427
|
+
`freeflow ready: 28 free models via local proxy 127.0.0.1:28180. ${hint}`,
|
|
314
428
|
"info",
|
|
315
429
|
);
|
|
316
430
|
}
|
package/src/models.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Static model definitions and upstream routing catalogs for pi-freeflow
|
|
3
3
|
*
|
|
4
|
-
* Defines the
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
4
|
+
* Defines the 28 verified free models:
|
|
5
|
+
* - 8 OpenCode Zen models (1 Responses API + 7 Chat Completions)
|
|
6
|
+
* - 20 KiloCode Keyless Gateway models (19 OpenRouter format + 1 Standard format)
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { ModelDef, ThinkingLevelMap, Upstream } from "./types.ts";
|
|
@@ -126,6 +126,22 @@ export const OPENCODE_MODELS: ModelDef[] = [
|
|
|
126
126
|
xhigh: "xhigh",
|
|
127
127
|
max: null,},
|
|
128
128
|
},
|
|
129
|
+
{
|
|
130
|
+
id: "ling-3.0-flash-fin-free",
|
|
131
|
+
name: "Ling 3.0 Flash Fin (256K)",
|
|
132
|
+
reasoning: true,
|
|
133
|
+
contextWindow: 262_144,
|
|
134
|
+
maxTokens: 131_072,
|
|
135
|
+
input: ["text"],
|
|
136
|
+
thinkingLevelMap: {
|
|
137
|
+
off: null,
|
|
138
|
+
minimal: "minimal",
|
|
139
|
+
low: "low",
|
|
140
|
+
medium: "medium",
|
|
141
|
+
high: "high",
|
|
142
|
+
xhigh: "xhigh",
|
|
143
|
+
max: null,},
|
|
144
|
+
},
|
|
129
145
|
];
|
|
130
146
|
|
|
131
147
|
/**
|
|
@@ -328,6 +344,26 @@ export const KILO_MODELS: ModelDef[] = [
|
|
|
328
344
|
thinkingFormat: "openrouter",
|
|
329
345
|
thinkingLevelMap: KILO_REASONING_MAP,
|
|
330
346
|
},
|
|
347
|
+
{
|
|
348
|
+
id: "thinkingmachines/inkling:free",
|
|
349
|
+
name: "Inkling (1M)",
|
|
350
|
+
reasoning: true,
|
|
351
|
+
contextWindow: 1_048_576,
|
|
352
|
+
maxTokens: 262_144,
|
|
353
|
+
input: ["text", "image"],
|
|
354
|
+
thinkingFormat: "openrouter",
|
|
355
|
+
thinkingLevelMap: KILO_REASONING_MAP,
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
id: "thinkingmachines/inkling-small:free",
|
|
359
|
+
name: "Inkling Small (1M)",
|
|
360
|
+
reasoning: true,
|
|
361
|
+
contextWindow: 1_048_576,
|
|
362
|
+
maxTokens: 262_144,
|
|
363
|
+
input: ["text", "image"],
|
|
364
|
+
thinkingFormat: "openrouter",
|
|
365
|
+
thinkingLevelMap: KILO_REASONING_MAP,
|
|
366
|
+
},
|
|
331
367
|
];
|
|
332
368
|
|
|
333
369
|
/**
|
|
@@ -352,6 +388,8 @@ export const MODEL_ALIASES: Record<string, string> = {
|
|
|
352
388
|
"minimax-m2.7": "minimax/minimax-m2.7:free",
|
|
353
389
|
"minimax-m3": "minimax/minimax-m3:free",
|
|
354
390
|
"ling-3.0-flash-fin": "inclusionai/ling-3.0-flash-fin:free",
|
|
391
|
+
"inkling": "thinkingmachines/inkling:free",
|
|
392
|
+
"inkling-small": "thinkingmachines/inkling-small:free",
|
|
355
393
|
// provider-prefixed short aliases (slash-normalized)
|
|
356
394
|
"hy3:free": "tencent/hy3:free",
|
|
357
395
|
"laguna-s-2.1:free": "poolside/laguna-s-2.1:free",
|
|
@@ -379,7 +417,7 @@ export const KILO_MODEL_IDS = new Set<string>([
|
|
|
379
417
|
]);
|
|
380
418
|
|
|
381
419
|
/**
|
|
382
|
-
* Combined list of all
|
|
420
|
+
* Combined list of all 28 static free models (canonical)
|
|
383
421
|
*/
|
|
384
422
|
export const ALL_MODELS: ModelDef[] = [...OPENCODE_MODELS, ...KILO_MODELS];
|
|
385
423
|
|
package/src/proxy.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { execSync } from "node:child_process";
|
|
9
10
|
import * as http from "node:http";
|
|
10
11
|
import * as https from "node:https";
|
|
11
12
|
import { handleHealthRequest } from "./health.ts";
|
|
@@ -85,13 +86,12 @@ export function sanitizeHeaders(
|
|
|
85
86
|
incoming: http.IncomingHttpHeaders,
|
|
86
87
|
targetHost: string,
|
|
87
88
|
): Record<string, string> {
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
89
|
+
// authorization is deliberately NOT forwarded: the host provider registers
|
|
90
|
+
// with a dummy key (placeholder), and zen free models are keyless — sending
|
|
91
|
+
// that fake key upstream gets 401 Invalid API key. Kilo injects its own key.
|
|
91
92
|
const allowed: Record<string, true> = {
|
|
92
93
|
"content-type": true,
|
|
93
94
|
accept: true,
|
|
94
|
-
authorization: true,
|
|
95
95
|
"x-request-id": true,
|
|
96
96
|
};
|
|
97
97
|
const sanitized: Record<string, string> = {};
|
|
@@ -113,6 +113,7 @@ export function sanitizeHeaders(
|
|
|
113
113
|
return sanitized;
|
|
114
114
|
}
|
|
115
115
|
export async function isProxyAlive(port: number): Promise<boolean> {
|
|
116
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
|
|
116
117
|
try {
|
|
117
118
|
const res = await fetch(`http://${HOST}:${port}/v1/models`, {
|
|
118
119
|
signal: AbortSignal.timeout(500),
|
|
@@ -124,6 +125,62 @@ export async function isProxyAlive(port: number): Promise<boolean> {
|
|
|
124
125
|
}
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
export async function getDaemonVersion(port: number): Promise<string | null> {
|
|
129
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
130
|
+
try {
|
|
131
|
+
const res = await fetch(`http://${HOST}:${port}/_health`, {
|
|
132
|
+
signal: AbortSignal.timeout(800),
|
|
133
|
+
});
|
|
134
|
+
if (!res.ok) return null;
|
|
135
|
+
const data: unknown = await res.json();
|
|
136
|
+
if (data && typeof data === "object" && "version" in data) {
|
|
137
|
+
const v = data.version;
|
|
138
|
+
if (typeof v === "string" && v) return v;
|
|
139
|
+
}
|
|
140
|
+
// Alive but no version field = pre-1.8.0 daemon (never embedded it) — stale.
|
|
141
|
+
return "";
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function killPortHolder(port: number): Promise<boolean> {
|
|
148
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
|
|
149
|
+
try {
|
|
150
|
+
if (process.platform === "win32") {
|
|
151
|
+
try {
|
|
152
|
+
const out = execSync(`netstat -ano | findstr :${port}`, { encoding: "utf8", timeout: 3000 }) as string;
|
|
153
|
+
for (const line of out.split("\n")) {
|
|
154
|
+
if (!line.includes("LISTENING")) continue;
|
|
155
|
+
if (!line.includes(`:${port}`)) continue;
|
|
156
|
+
const parts = line.trim().split(/\s+/);
|
|
157
|
+
const pid = parts[parts.length - 1];
|
|
158
|
+
if (!pid || !/^\d+$/.test(pid)) continue;
|
|
159
|
+
// Windows netstat report: 127.0.0.1:38180 ... LISTENING/PID
|
|
160
|
+
if (Number(pid) === process.pid) continue; // never self-kill
|
|
161
|
+
execSync(`taskkill /F /PID ${pid}`, { timeout: 3000, stdio: "ignore" });
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
} catch {}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const out = execSync(`lsof -ti tcp:${port} 2>/dev/null || fuser -n tcp ${port} 2>/dev/null`, {
|
|
169
|
+
encoding: "utf8",
|
|
170
|
+
timeout: 3000,
|
|
171
|
+
}) as string;
|
|
172
|
+
const pid = out.trim().split(/\s+/)[0];
|
|
173
|
+
if (!pid || !/^\d+$/.test(pid)) return false;
|
|
174
|
+
execSync(`kill -9 ${pid}`, { timeout: 3000, stdio: "ignore" });
|
|
175
|
+
return true;
|
|
176
|
+
} catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
127
184
|
/**
|
|
128
185
|
* Tagged abort reason for the proxy-internal header-wait timeout.
|
|
129
186
|
* relayFetch rethrows AbortErrors untouched, and stream-pipe recognizes
|
|
@@ -180,7 +237,7 @@ export function startProxy(
|
|
|
180
237
|
reqPathname = new URL(req.url ?? "/", `http://${HOST}`).pathname;
|
|
181
238
|
} catch {}
|
|
182
239
|
// Loopback-only health endpoint — always accessible even when widget hidden
|
|
183
|
-
if (req.method === "GET" && reqPathname !== null && (reqPathname === "/_health" || reqPathname === "/health"
|
|
240
|
+
if (req.method === "GET" && reqPathname !== null && (reqPathname === "/_health" || reqPathname === "/health")) {
|
|
184
241
|
const addr = server.address();
|
|
185
242
|
const realPort = addr && typeof addr === "object" ? (addr as { port: number }).port : basePort;
|
|
186
243
|
if (handleHealthRequest(req, res, realPort)) return;
|