pi-freeflow 1.9.6 → 1.9.8
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 +30 -0
- package/README.md +5 -5
- package/package.json +61 -61
- package/src/catalog.ts +50 -10
- package/src/commands.ts +2 -1
- package/src/deploy.ts +13 -12
- package/src/index.ts +1 -1
- package/src/models.ts +13 -18
- package/src/proxy.ts +17 -1
- package/src/relay.ts +30 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-freeflow. Public, user-visible behavior only.
|
|
4
4
|
|
|
5
|
+
## 1.9.8 - 2026-09-06
|
|
6
|
+
|
|
7
|
+
### Fixes
|
|
8
|
+
- **Stale model list heals itself (follow-up to #6).** If your saved model list predates a newly added model, the background refresh now repairs the entry (correct endpoint and details) instead of sending requests to the wrong address — no manual `/freeflow refresh` or cache deletion needed.
|
|
9
|
+
- **Paid models stay out of the picker even from old saved lists.** Every read of the saved model list now drops non-free entries, so models requiring an API key cannot linger after an upgrade.
|
|
10
|
+
- **Old saved lists without a sync marker now re-sync once.** A saved list that could never trigger a network check now performs one plain revalidation (then syncs normally), so newly added free models appear without manual intervention. Missing or corrupt lists still fall back silently with no network call.
|
|
11
|
+
- **Upstream errors are now visible in the proxy log.** Failed upstream responses log their status code and model, and a model routed to the wrong endpoint logs the mismatch with the fix (restart Pi/OMP after upgrade).
|
|
12
|
+
|
|
13
|
+
### Validation
|
|
14
|
+
- TypeScript typecheck passed cleanly (`tsc --noEmit`).
|
|
15
|
+
- Full test suite passed on Windows (304 tests) and Ubuntu Linux (`acerblue`, 305/305 tests passed), including new regressions for the stale-cache shape from #6.
|
|
16
|
+
- Live sweep of all 26 models through a fresh install on `acerblue`: 23/26 answered on first try (both Muse Spark models via the Responses endpoint); the 3 misses are upstream per-model daily quotas (429), zero server errors.
|
|
17
|
+
|
|
18
|
+
## 1.9.7 - 2026-09-06
|
|
19
|
+
|
|
20
|
+
### Fixes
|
|
21
|
+
- **Model picker only shows verified free models (fixes #6).** Background catalog refresh now filters out non-free models from upstream endpoints so paid models requiring an API key no longer leak into your picker.
|
|
22
|
+
- **Relay pool automatically recovers from dead deployments (fixes #5).** If a relay URL in your pool returns an infrastructure 404 (such as a missing or deleted Vercel deployment), the proxy marks it as failed and immediately rolls over to your next healthy relay or direct mode instead of getting trapped in a 404 loop.
|
|
23
|
+
|
|
24
|
+
### Changes
|
|
25
|
+
- **Updated model catalog (26 verified free models: 7 OpenCode Zen + 19 KiloCode Gateway).**
|
|
26
|
+
- Added `inclusionai/ling-3.0-flash-sante:free` (Ling 3.0 Flash Sante, 262K context, 32K output, reasoning supported). Clean CLI alias `ling-3.0-flash-sante` supported.
|
|
27
|
+
- Removed `laguna-s-2.1-free` (OpenCode Zen) after upstream dropped free-tier access. `poolside/laguna-s-2.1:free` on Kilo remains active.
|
|
28
|
+
- **Public by default for new relay deployments.** Relays deployed via `/freeflow deploy` are now public by default with no mandatory authentication tokens. This enables seamless copy-paste migration across proxy tools (such as 9router) while maintaining safety guards for allowed AI upstreams.
|
|
29
|
+
|
|
30
|
+
### Validation
|
|
31
|
+
- TypeScript typecheck passed cleanly (`tsc --noEmit`).
|
|
32
|
+
- Full test suite passed across Windows (299 tests) and Ubuntu Linux (`acerblue-local`, 300/300 tests passed).
|
|
33
|
+
- End-to-end concurrency and failover stress testing verified (100 parallel requests, 20 subagent leases, burst failover).
|
|
34
|
+
|
|
5
35
|
## 1.9.6 - 2026-09-03
|
|
6
36
|
|
|
7
37
|
### Fixes
|
package/README.md
CHANGED
|
@@ -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
|
-
| **26 Curated Free Models** |
|
|
21
|
+
| **26 Curated Free Models** | 7 OpenCode Zen + 19 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** |
|
|
@@ -38,7 +38,7 @@ Philosophy: **Thin by design.** We only ship model list + relay proxy + log. Hos
|
|
|
38
38
|
/model → freeflow → pick
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
#### OpenCode Zen (
|
|
41
|
+
#### OpenCode Zen (7 Models), Responses & Chat API
|
|
42
42
|
Optimized for deep reasoning, long-horizon coding & autonomous agentic workflows.
|
|
43
43
|
|
|
44
44
|
| Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
|
|
@@ -46,13 +46,12 @@ Optimized for deep reasoning, long-horizon coding & autonomous agentic workflows
|
|
|
46
46
|
| `muse-spark-1.2-contributor-free` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
|
|
47
47
|
| `muse-spark-1.3-contributor-free` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
|
|
48
48
|
| `mimo-v2.5-free` | Xiaomi MiMo | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh`\* | ✅ |
|
|
49
|
-
| `laguna-s-2.1-free` | Poolside | **262K** (262.144) | **32K** (32.768) | `minimal … xhigh` | ❌ |
|
|
50
49
|
| `nemotron-3.5-lightning-free` | NVIDIA | **1M** (1.000.000) | **262K** (262.144) | `minimal … xhigh` | ❌ |
|
|
51
50
|
| `nemotron-3-ultra-free` | NVIDIA | **1M** (1.000.000) | **128K** (128.000) | `minimal … xhigh` | ❌ |
|
|
52
51
|
| `big-pickle` | Big Pickle | **200K** (200.000) | **32K** (32.000) | `high / max` | ❌ |
|
|
53
52
|
| `ling-3.0-flash-fin-free` | Inclusion AI | **262K** (262.144) | **131K** (131.072) | `minimal … xhigh` | ❌ |
|
|
54
53
|
|
|
55
|
-
#### KiloCode Gateway (
|
|
54
|
+
#### KiloCode Gateway (19 Models), OpenRouter Compatible
|
|
56
55
|
Keyless access with `Bearer kilo-free`. Clean slash-free and colon-free CLI aliases supported.
|
|
57
56
|
|
|
58
57
|
| Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
|
|
@@ -74,6 +73,7 @@ Keyless access with `Bearer kilo-free`. Clean slash-free and colon-free CLI alia
|
|
|
74
73
|
| `minimax-m3` (`minimax/minimax-m3:free`) | MiniMax | **1M** (1.048.576) | **512K** (524.288) | `minimal…xhigh`\* | ❌ |
|
|
75
74
|
| `ling-3.0-flash-fin` (`inclusionai/ling-3.0-flash-fin:free`) | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
76
75
|
| `inkling` (`thinkingmachines/inkling:free`) | Thinking Machines | **1M** (1.048.576) | **262K** (262.144) | `minimal…xhigh`\* | ✅ |
|
|
76
|
+
| `ling-3.0-flash-sante` (`inclusionai/ling-3.0-flash-sante:free`) | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
77
77
|
| `inkling-small` (`thinkingmachines/inkling-small:free`) | Thinking Machines | **1M** (1.048.576) | **262K** (262.144) | `minimal…xhigh`\* | ✅ |
|
|
78
78
|
|
|
79
79
|
\* 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.
|
|
@@ -215,7 +215,7 @@ For `api/relay.js`, use the canonical worker source (see below); `vercel.json` s
|
|
|
215
215
|
|
|
216
216
|
**Canonical worker source (all platforms)**
|
|
217
217
|
|
|
218
|
-
The relay worker template is generated per deployment by `/freeflow deploy` and lives in [`src/deploy.ts`](src/deploy.ts): one hardened core plus thin Vercel / Cloudflare / Deno wrappers.
|
|
218
|
+
The relay worker template is generated per deployment by `/freeflow deploy` and lives in [`src/deploy.ts`](src/deploy.ts): one hardened core plus thin Vercel / Cloudflare / Deno wrappers. Deployed relays are public by default for seamless migration across proxy tools (such as 9router), while enforcing the target allowlist (`https://opencode.ai`, `https://api.kilo.ai`), SSRF/private-host guard, relay-path validation, and a header denylist.
|
|
219
219
|
|
|
220
220
|
```js
|
|
221
221
|
// Minimal Cloudflare illustration. Prefer /freeflow deploy: the generated
|
package/package.json
CHANGED
|
@@ -1,61 +1,61 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pi-freeflow",
|
|
3
|
-
"type": "module",
|
|
4
|
-
"version": "1.9.
|
|
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
|
-
"CHANGELOG.md",
|
|
44
|
-
"LICENSE"
|
|
45
|
-
],
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-freeflow",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "1.9.8",
|
|
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
|
+
"CHANGELOG.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "node --experimental-strip-types --import ./test/setup.mjs --test --test-concurrency=1 test/**/*.test.ts",
|
|
48
|
+
"typecheck": "tsc --noEmit",
|
|
49
|
+
"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); })\"",
|
|
50
|
+
"changeset": "changeset",
|
|
51
|
+
"docs:dev": "vitepress dev docs",
|
|
52
|
+
"docs:build": "vitepress build docs"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@changesets/cli": "^2.27.0",
|
|
56
|
+
"@types/node": "^22.13.9",
|
|
57
|
+
"typescript": "^5.8.2",
|
|
58
|
+
"vitepress": "^1.6.4"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {}
|
|
61
|
+
}
|
package/src/catalog.ts
CHANGED
|
@@ -37,7 +37,41 @@ export const DEAD_MODEL_IDS = new Set<string>([
|
|
|
37
37
|
"hy3-free",
|
|
38
38
|
"tencent/hy3:free",
|
|
39
39
|
"meituan/longcat-2.0-free",
|
|
40
|
+
"laguna-s-2.1-free",
|
|
40
41
|
]);
|
|
42
|
+
/**
|
|
43
|
+
* Free-tier allowlist for anything entering the picker via network or stale disk.
|
|
44
|
+
* Upstream lists paid models alongside free ones (e.g. claude-fable-5-1,
|
|
45
|
+
* claude-opus-4-*, gemini-3-*) so a bare upstream merge leaks paid entries that
|
|
46
|
+
* fail with 401 Missing API key. Known static IDs without a free suffix
|
|
47
|
+
* (e.g. big-pickle) stay allowed via MODEL_MAP.
|
|
48
|
+
*/
|
|
49
|
+
export function isFreeCatalogId(id: string): boolean {
|
|
50
|
+
if (typeof id !== "string" || id.length === 0) return false;
|
|
51
|
+
if (DEAD_MODEL_IDS.has(id)) return false;
|
|
52
|
+
return id.includes("-free") || id.includes(":free") || id.includes("/free") || MODEL_MAP.has(id);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Purge paid/dead entries from a catalog list and repair known models against
|
|
56
|
+
* the static definitions. Stale disk caches predate the paid filter and the
|
|
57
|
+
* muse-spark-1.3 responses-api entry, so loading them verbatim replays a wrong
|
|
58
|
+
* api (chat/completions for a responses-only model -> upstream 500) until the
|
|
59
|
+
* 24h TTL expires. Repairing here self-heals on the next refresh without a
|
|
60
|
+
* reinstall.
|
|
61
|
+
*/
|
|
62
|
+
export function sanitizeCatalogModels(models: RegisteredModel[]): RegisteredModel[] {
|
|
63
|
+
const out: RegisteredModel[] = [];
|
|
64
|
+
for (const m of models) {
|
|
65
|
+
if (!m || typeof m.id !== "string" || !isFreeCatalogId(m.id)) continue;
|
|
66
|
+
const known = MODEL_MAP.get(m.id);
|
|
67
|
+
if (known) {
|
|
68
|
+
out.push({ ...known, source: m.source ?? (KILO_MODEL_IDS.has(m.id) ? "kilo" : "opencode") });
|
|
69
|
+
} else {
|
|
70
|
+
out.push(m);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
41
75
|
/**
|
|
42
76
|
* In-memory cache of currently active/available free models.
|
|
43
77
|
* Initialized with all 26 verified models for 0ms instant availability.
|
|
@@ -71,11 +105,12 @@ export function mergeCatalog(
|
|
|
71
105
|
base: RegisteredModel[],
|
|
72
106
|
fresh: RegisteredModel[],
|
|
73
107
|
): RegisteredModel[] {
|
|
74
|
-
const filteredFresh = fresh.filter((m) =>
|
|
108
|
+
const filteredFresh = fresh.filter((m) => m && typeof m.id === "string" && isFreeCatalogId(m.id));
|
|
75
109
|
const byId = new Map(base.map((m) => [m.id, m]));
|
|
76
110
|
for (const m of filteredFresh) byId.set(m.id, m);
|
|
77
|
-
//
|
|
78
|
-
|
|
111
|
+
// Sanitize the merged result so stale paid entries in a pre-fix base and
|
|
112
|
+
// stale api fields on known models never survive the merge.
|
|
113
|
+
return sanitizeCatalogModels([...byId.values()]);
|
|
79
114
|
}
|
|
80
115
|
|
|
81
116
|
/**
|
|
@@ -186,7 +221,7 @@ export function readCatalogCache(): CatalogCacheData | null {
|
|
|
186
221
|
if (!Array.isArray(data.models)) {
|
|
187
222
|
return null;
|
|
188
223
|
}
|
|
189
|
-
data.models = data.models
|
|
224
|
+
data.models = sanitizeCatalogModels(data.models);
|
|
190
225
|
if (Date.now() - data.timestamp < CATALOG_CACHE_TTL_MS) {
|
|
191
226
|
return data;
|
|
192
227
|
}
|
|
@@ -248,7 +283,7 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
248
283
|
if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
|
|
249
284
|
const age = Date.now() - (disk.timestamp ?? 0);
|
|
250
285
|
if (!force && age < CATALOG_CACHE_TTL_MS) {
|
|
251
|
-
aliveCatalog = disk.models
|
|
286
|
+
aliveCatalog = sanitizeCatalogModels(disk.models);
|
|
252
287
|
return aliveCatalog;
|
|
253
288
|
}
|
|
254
289
|
}
|
|
@@ -269,8 +304,12 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
269
304
|
}
|
|
270
305
|
}
|
|
271
306
|
|
|
272
|
-
// Attempt
|
|
273
|
-
|
|
307
|
+
// Attempt a fetch whenever there is cache material to revalidate: conditional
|
|
308
|
+
// with If-None-Match when we have an etag, plain otherwise. A pre-fix cache
|
|
309
|
+
// file with no etag must still go live (acquiring an etag and discovering
|
|
310
|
+
// new free models) instead of serving stale indefinitely. Corrupt/missing
|
|
311
|
+
// caches (staleForEtag null) skip the network and fall through to static.
|
|
312
|
+
if (cachedEtag || force || staleForEtag) {
|
|
274
313
|
try {
|
|
275
314
|
const headers: Record<string, string> = { ...opencodeHeaders() };
|
|
276
315
|
if (cachedEtag) {
|
|
@@ -304,7 +343,8 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
304
343
|
}
|
|
305
344
|
}
|
|
306
345
|
if (rawList.length > 0) {
|
|
307
|
-
const
|
|
346
|
+
const freeRawList = rawList.filter((r) => r && typeof r.id === "string" && isFreeCatalogId(r.id));
|
|
347
|
+
const fresh = freeRawList.map((r) => enrichModelDef(r, "opencode"));
|
|
308
348
|
const merged = mergeCatalog(aliveCatalog, fresh);
|
|
309
349
|
aliveCatalog = merged;
|
|
310
350
|
writeCatalogCache({
|
|
@@ -335,7 +375,7 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
335
375
|
|
|
336
376
|
// Stale cache still better than empty — return it without network (filtered)
|
|
337
377
|
if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
|
|
338
|
-
const filtered = disk.models
|
|
378
|
+
const filtered = sanitizeCatalogModels(disk.models);
|
|
339
379
|
if (filtered.length >= ALL_MODELS.length) {
|
|
340
380
|
aliveCatalog = filtered;
|
|
341
381
|
return aliveCatalog;
|
|
@@ -347,7 +387,7 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
347
387
|
const raw = fs.readFileSync(CATALOG_CACHE_FILE, "utf8");
|
|
348
388
|
const stale = JSON.parse(raw) as CatalogCacheData;
|
|
349
389
|
if (Array.isArray(stale.models) && stale.models.length > 0) {
|
|
350
|
-
const filtered = stale.models
|
|
390
|
+
const filtered = sanitizeCatalogModels(stale.models);
|
|
351
391
|
if (filtered.length >= ALL_MODELS.length) {
|
|
352
392
|
aliveCatalog = filtered;
|
|
353
393
|
return aliveCatalog;
|
package/src/commands.ts
CHANGED
|
@@ -370,7 +370,8 @@ export function createCommandSpec(
|
|
|
370
370
|
);
|
|
371
371
|
relayState = withRelayState((s) => {
|
|
372
372
|
const r = ensureRelay(s, url, `deployed ${name}`);
|
|
373
|
-
r.auth = auth;
|
|
373
|
+
if (auth) r.auth = auth;
|
|
374
|
+
else delete r.auth;
|
|
374
375
|
s.enabled = true;
|
|
375
376
|
s.url = url;
|
|
376
377
|
return s;
|
package/src/deploy.ts
CHANGED
|
@@ -160,6 +160,7 @@ export async function deployVercelRelay(
|
|
|
160
160
|
token: string,
|
|
161
161
|
name: string,
|
|
162
162
|
onProgress?: (msg: string) => void,
|
|
163
|
+
authSecret: string = "",
|
|
163
164
|
): Promise<{ url: string; auth: string }> {
|
|
164
165
|
name = baseRelayName(name) || "relay-worker";
|
|
165
166
|
const auth = {
|
|
@@ -169,9 +170,9 @@ export async function deployVercelRelay(
|
|
|
169
170
|
// 1. Create deployment (3 inline files, no git repository required)
|
|
170
171
|
onProgress?.("Uploading relay files to Vercel…");
|
|
171
172
|
log("info", `Starting Vercel deployment: ${name}`);
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
const relayAuth =
|
|
173
|
+
// Public by default for easy migration with 9router and other proxy tools.
|
|
174
|
+
// When authSecret is provided, embeds the shared secret for private auth.
|
|
175
|
+
const relayAuth = authSecret || "";
|
|
175
176
|
|
|
176
177
|
const dep = await fetch(`${VERCEL_API}/v13/deployments`, {
|
|
177
178
|
method: "POST",
|
|
@@ -339,6 +340,7 @@ export async function deployCloudflareWorker(
|
|
|
339
340
|
token: string,
|
|
340
341
|
name: string,
|
|
341
342
|
onProgress?: (msg: string) => void,
|
|
343
|
+
authSecret: string = "",
|
|
342
344
|
): Promise<{ url: string; auth: string }> {
|
|
343
345
|
const auth = { Authorization: `Bearer ${token}` };
|
|
344
346
|
const scriptName = cloudflareScriptName(name);
|
|
@@ -358,9 +360,9 @@ export async function deployCloudflareWorker(
|
|
|
358
360
|
|
|
359
361
|
// 2. Upload the module worker script (multipart: main module + metadata)
|
|
360
362
|
onProgress?.("Uploading relay worker to Cloudflare…");
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
const relayAuth =
|
|
363
|
+
// Public by default for easy migration with 9router and other proxy tools.
|
|
364
|
+
// When authSecret is provided, embeds the shared secret for private auth.
|
|
365
|
+
const relayAuth = authSecret || "";
|
|
364
366
|
const formData = new FormData();
|
|
365
367
|
formData.append(
|
|
366
368
|
"index.js",
|
|
@@ -452,12 +454,11 @@ export async function deployDenoRelay(
|
|
|
452
454
|
token: string,
|
|
453
455
|
name: string,
|
|
454
456
|
onProgress?: (msg: string) => void,
|
|
457
|
+
authSecret: string = "",
|
|
455
458
|
): Promise<{ url: string; auth: string }> {
|
|
459
|
+
const slug = denoProjectName(name);
|
|
456
460
|
const auth = { Authorization: `Bearer ${token}` };
|
|
457
461
|
const jsonHeaders = { ...auth, "Content-Type": "application/json" };
|
|
458
|
-
const slug = denoProjectName(name);
|
|
459
|
-
|
|
460
|
-
// 1. Create the app
|
|
461
462
|
onProgress?.("Creating Deno Deploy app…");
|
|
462
463
|
log("info", `Starting Deno Deploy deployment: ${slug}`);
|
|
463
464
|
const createRes = await fetch(`${DENO_API}/apps`, {
|
|
@@ -495,9 +496,9 @@ export async function deployDenoRelay(
|
|
|
495
496
|
|
|
496
497
|
// 2. Push the relay source as a single-file revision
|
|
497
498
|
onProgress?.("Uploading relay script to Deno Deploy…");
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
const relayAuth =
|
|
499
|
+
// Public by default for easy migration with 9router and other proxy tools.
|
|
500
|
+
// When authSecret is provided, embeds the shared secret for private auth.
|
|
501
|
+
const relayAuth = authSecret || "";
|
|
501
502
|
const deployRes = await fetch(`${DENO_API}/apps/${appId}/deploy`, {
|
|
502
503
|
method: "POST",
|
|
503
504
|
headers: jsonHeaders,
|
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 26 free models (
|
|
4
|
+
* Provides access to 26 free models (7 OpenCode Zen + 19 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
|
package/src/models.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Static model definitions and upstream routing catalogs for pi-freeflow
|
|
3
3
|
*
|
|
4
4
|
* Defines the 26 verified free models:
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
5
|
+
* - 7 OpenCode Zen models (2 Responses API + 5 Chat Completions)
|
|
6
|
+
* - 19 KiloCode Keyless Gateway models (18 OpenRouter format + 1 Standard format)
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { ModelDef, ThinkingLevelMap, Upstream } from "./types.ts";
|
|
@@ -111,22 +111,6 @@ export const OPENCODE_MODELS: ModelDef[] = [
|
|
|
111
111
|
xhigh: null,
|
|
112
112
|
max: "max",},
|
|
113
113
|
},
|
|
114
|
-
{
|
|
115
|
-
id: "laguna-s-2.1-free",
|
|
116
|
-
name: "Laguna S 2.1 (256K)",
|
|
117
|
-
reasoning: true,
|
|
118
|
-
contextWindow: 262_144,
|
|
119
|
-
maxTokens: 32_768,
|
|
120
|
-
input: ["text"],
|
|
121
|
-
thinkingLevelMap: {
|
|
122
|
-
off: null,
|
|
123
|
-
minimal: "minimal",
|
|
124
|
-
low: "low",
|
|
125
|
-
medium: "medium",
|
|
126
|
-
high: "high",
|
|
127
|
-
xhigh: "xhigh",
|
|
128
|
-
max: null,},
|
|
129
|
-
},
|
|
130
114
|
{
|
|
131
115
|
id: "ling-3.0-flash-fin-free",
|
|
132
116
|
name: "Ling 3.0 Flash Fin (256K)",
|
|
@@ -345,6 +329,16 @@ export const KILO_MODELS: ModelDef[] = [
|
|
|
345
329
|
thinkingFormat: "openrouter",
|
|
346
330
|
thinkingLevelMap: KILO_REASONING_MAP,
|
|
347
331
|
},
|
|
332
|
+
{
|
|
333
|
+
id: "inclusionai/ling-3.0-flash-sante:free",
|
|
334
|
+
name: "Ling 3.0 Flash Sante",
|
|
335
|
+
reasoning: true,
|
|
336
|
+
contextWindow: 262_144,
|
|
337
|
+
maxTokens: 32_768,
|
|
338
|
+
input: ["text"],
|
|
339
|
+
thinkingFormat: "openrouter",
|
|
340
|
+
thinkingLevelMap: KILO_REASONING_MAP,
|
|
341
|
+
},
|
|
348
342
|
];
|
|
349
343
|
|
|
350
344
|
/**
|
|
@@ -368,6 +362,7 @@ export const MODEL_ALIASES: Record<string, string> = {
|
|
|
368
362
|
"minimax-m2.7": "minimax/minimax-m2.7:free",
|
|
369
363
|
"minimax-m3": "minimax/minimax-m3:free",
|
|
370
364
|
"ling-3.0-flash-fin": "inclusionai/ling-3.0-flash-fin:free",
|
|
365
|
+
"ling-3.0-flash-sante": "inclusionai/ling-3.0-flash-sante:free",
|
|
371
366
|
"inkling": "thinkingmachines/inkling:free",
|
|
372
367
|
"inkling-small": "thinkingmachines/inkling-small:free",
|
|
373
368
|
// provider-prefixed short aliases (slash-normalized)
|
package/src/proxy.ts
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
} from "./config.ts";
|
|
31
31
|
|
|
32
32
|
import { isDebugEnabled, log } from "./logger.ts";
|
|
33
|
-
import { KILO_MODEL_IDS, resolveCanonicalModelId } from "./models.ts";
|
|
33
|
+
import { KILO_MODEL_IDS, MODEL_MAP, resolveCanonicalModelId } from "./models.ts";
|
|
34
34
|
// normalize removed — host pi-ai already normalizes thinking/reasoning before proxy
|
|
35
35
|
import { checkRateLimit } from "./rate-limiter.ts";
|
|
36
36
|
import { relayFetch } from "./relay.ts";
|
|
@@ -524,6 +524,16 @@ export function startProxy(
|
|
|
524
524
|
return;
|
|
525
525
|
}
|
|
526
526
|
|
|
527
|
+
// Stale-registration guard: responses-only models (muse-spark-*) must
|
|
528
|
+
// reach upstream via /v1/responses. A chat/completions request for one
|
|
529
|
+
// means the host still holds a pre-fix provider registration (stale
|
|
530
|
+
// disk cache or no restart after upgrade) and upstream answers 500.
|
|
531
|
+
if (!isKilo && typeof parsedBody?.model === "string" && target.pathname.endsWith("/chat/completions")) {
|
|
532
|
+
const knownDef = MODEL_MAP.get(String(parsedBody.model));
|
|
533
|
+
if (knownDef?.api === "openai-responses") {
|
|
534
|
+
log("warn", `model ${String(parsedBody.model)} expects openai-responses but got ${target.pathname} — stale provider registration (restart Pi/OMP after upgrade)`, { model: String(parsedBody.model), path: target.pathname }, reqId);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
527
537
|
try {
|
|
528
538
|
if (isKilo && parsedBody) {
|
|
529
539
|
// Header-wait timeout + client-disconnect abort: once headers
|
|
@@ -659,6 +669,9 @@ export function startProxy(
|
|
|
659
669
|
relayState.url,
|
|
660
670
|
);
|
|
661
671
|
} else {
|
|
672
|
+
if (!response.ok) {
|
|
673
|
+
log("warn", `upstream ${response.status} for model ${String((parsedBody as Record<string, unknown> | null)?.model ?? "?")} via relay`, { status: response.status, model: (parsedBody as Record<string, unknown> | null)?.model, path: req.url }, reqId);
|
|
674
|
+
}
|
|
662
675
|
const data = await response.text();
|
|
663
676
|
const ct =
|
|
664
677
|
response.headers.get("content-type") ||
|
|
@@ -716,6 +729,9 @@ export function startProxy(
|
|
|
716
729
|
clearTimeout(timeoutId);
|
|
717
730
|
res.off("close", onClientClose);
|
|
718
731
|
req.off("error", onReqError);
|
|
732
|
+
if (upstreamRes.status >= 400) {
|
|
733
|
+
log("warn", `direct upstream ${upstreamRes.status} for model ${String(parsedBody?.model ?? "?")} ${target.pathname}`, { status: upstreamRes.status, model: parsedBody?.model, path: target.pathname }, reqId);
|
|
734
|
+
}
|
|
719
735
|
|
|
720
736
|
const outHeaders: Record<string, string> = {};
|
|
721
737
|
for (const h of ["content-type", "cache-control", "x-request-id"] as const) {
|
package/src/relay.ts
CHANGED
|
@@ -156,6 +156,36 @@ export async function relayFetch(
|
|
|
156
156
|
break;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
// Relay host infrastructure 404 (e.g. Vercel DEPLOYMENT_NOT_FOUND or non-JSON 404):
|
|
160
|
+
// When a relay URL is deleted, misconfigured, or has no deployment, Vercel/Cloudflare
|
|
161
|
+
// returns edge 404. This is a relay failure, not an upstream API response.
|
|
162
|
+
const isRelayEdge404 =
|
|
163
|
+
res.status === 404 &&
|
|
164
|
+
(Boolean(res.headers.get("x-vercel-error")) ||
|
|
165
|
+
Boolean(res.headers.get("x-vercel-id")) ||
|
|
166
|
+
res.headers.get("server")?.toLowerCase().includes("vercel") ||
|
|
167
|
+
!res.headers.get("content-type")?.includes("json"));
|
|
168
|
+
|
|
169
|
+
if (isRelayEdge404) {
|
|
170
|
+
markRelayFailure(targetUrl, 404, "Deployment or route not found on relay host");
|
|
171
|
+
lastResponse?.body?.cancel().catch(() => {});
|
|
172
|
+
lastResponse = res;
|
|
173
|
+
log(
|
|
174
|
+
"warn",
|
|
175
|
+
`relay ${targetUrl} returned edge 404 (deployment missing or route not found) — rolling to next relay`,
|
|
176
|
+
{ upstream: url },
|
|
177
|
+
rid,
|
|
178
|
+
);
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
if (now - lastRollNotify > ROLL_NOTIFY_MS) {
|
|
181
|
+
lastRollNotify = now;
|
|
182
|
+
const ui = getStatusUi();
|
|
183
|
+
if (ui?.notify) {
|
|
184
|
+
ui.notify(`relay ${shortRelayLabel(targetUrl)} failed (HTTP 404) — rolled to next relay`, "warning");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
159
189
|
if (isRetriableStatus(res.status)) {
|
|
160
190
|
markRelayFailure(targetUrl, res.status);
|
|
161
191
|
lastResponse?.body?.cancel().catch(() => {});
|