pi-freeflow 1.9.7 → 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 +13 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/catalog.ts +48 -18
- package/src/proxy.ts +17 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
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
|
+
|
|
5
18
|
## 1.9.7 - 2026-09-06
|
|
6
19
|
|
|
7
20
|
### Fixes
|
package/README.md
CHANGED
|
@@ -50,6 +50,7 @@ Optimized for deep reasoning, long-horizon coding & autonomous agentic workflows
|
|
|
50
50
|
| `nemotron-3-ultra-free` | NVIDIA | **1M** (1.000.000) | **128K** (128.000) | `minimal … xhigh` | ❌ |
|
|
51
51
|
| `big-pickle` | Big Pickle | **200K** (200.000) | **32K** (32.000) | `high / max` | ❌ |
|
|
52
52
|
| `ling-3.0-flash-fin-free` | Inclusion AI | **262K** (262.144) | **131K** (131.072) | `minimal … xhigh` | ❌ |
|
|
53
|
+
|
|
53
54
|
#### KiloCode Gateway (19 Models), OpenRouter Compatible
|
|
54
55
|
Keyless access with `Bearer kilo-free`. Clean slash-free and colon-free CLI aliases supported.
|
|
55
56
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.9.
|
|
4
|
+
"version": "1.9.8",
|
|
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
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
ALL_MODELS,
|
|
21
21
|
KILO_MODEL_IDS,
|
|
22
22
|
MODEL_MAP,
|
|
23
|
-
OPENCODE_MODELS,
|
|
24
23
|
} from "./models.ts";
|
|
25
24
|
import type {
|
|
26
25
|
CatalogCacheData,
|
|
@@ -40,6 +39,39 @@ export const DEAD_MODEL_IDS = new Set<string>([
|
|
|
40
39
|
"meituan/longcat-2.0-free",
|
|
41
40
|
"laguna-s-2.1-free",
|
|
42
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
|
+
}
|
|
43
75
|
/**
|
|
44
76
|
* In-memory cache of currently active/available free models.
|
|
45
77
|
* Initialized with all 26 verified models for 0ms instant availability.
|
|
@@ -73,11 +105,12 @@ export function mergeCatalog(
|
|
|
73
105
|
base: RegisteredModel[],
|
|
74
106
|
fresh: RegisteredModel[],
|
|
75
107
|
): RegisteredModel[] {
|
|
76
|
-
const filteredFresh = fresh.filter((m) =>
|
|
108
|
+
const filteredFresh = fresh.filter((m) => m && typeof m.id === "string" && isFreeCatalogId(m.id));
|
|
77
109
|
const byId = new Map(base.map((m) => [m.id, m]));
|
|
78
110
|
for (const m of filteredFresh) byId.set(m.id, m);
|
|
79
|
-
//
|
|
80
|
-
|
|
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()]);
|
|
81
114
|
}
|
|
82
115
|
|
|
83
116
|
/**
|
|
@@ -188,7 +221,7 @@ export function readCatalogCache(): CatalogCacheData | null {
|
|
|
188
221
|
if (!Array.isArray(data.models)) {
|
|
189
222
|
return null;
|
|
190
223
|
}
|
|
191
|
-
data.models = data.models
|
|
224
|
+
data.models = sanitizeCatalogModels(data.models);
|
|
192
225
|
if (Date.now() - data.timestamp < CATALOG_CACHE_TTL_MS) {
|
|
193
226
|
return data;
|
|
194
227
|
}
|
|
@@ -250,7 +283,7 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
250
283
|
if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
|
|
251
284
|
const age = Date.now() - (disk.timestamp ?? 0);
|
|
252
285
|
if (!force && age < CATALOG_CACHE_TTL_MS) {
|
|
253
|
-
aliveCatalog = disk.models
|
|
286
|
+
aliveCatalog = sanitizeCatalogModels(disk.models);
|
|
254
287
|
return aliveCatalog;
|
|
255
288
|
}
|
|
256
289
|
}
|
|
@@ -271,8 +304,12 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
271
304
|
}
|
|
272
305
|
}
|
|
273
306
|
|
|
274
|
-
// Attempt
|
|
275
|
-
|
|
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) {
|
|
276
313
|
try {
|
|
277
314
|
const headers: Record<string, string> = { ...opencodeHeaders() };
|
|
278
315
|
if (cachedEtag) {
|
|
@@ -306,14 +343,7 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
306
343
|
}
|
|
307
344
|
}
|
|
308
345
|
if (rawList.length > 0) {
|
|
309
|
-
const freeRawList = rawList.filter((r) =>
|
|
310
|
-
if (!r || typeof r.id !== "string") return false;
|
|
311
|
-
if (DEAD_MODEL_IDS.has(r.id)) return false;
|
|
312
|
-
return (
|
|
313
|
-
r.id.includes("-free") ||
|
|
314
|
-
OPENCODE_MODELS.some((m) => m.id === r.id)
|
|
315
|
-
);
|
|
316
|
-
});
|
|
346
|
+
const freeRawList = rawList.filter((r) => r && typeof r.id === "string" && isFreeCatalogId(r.id));
|
|
317
347
|
const fresh = freeRawList.map((r) => enrichModelDef(r, "opencode"));
|
|
318
348
|
const merged = mergeCatalog(aliveCatalog, fresh);
|
|
319
349
|
aliveCatalog = merged;
|
|
@@ -345,7 +375,7 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
345
375
|
|
|
346
376
|
// Stale cache still better than empty — return it without network (filtered)
|
|
347
377
|
if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
|
|
348
|
-
const filtered = disk.models
|
|
378
|
+
const filtered = sanitizeCatalogModels(disk.models);
|
|
349
379
|
if (filtered.length >= ALL_MODELS.length) {
|
|
350
380
|
aliveCatalog = filtered;
|
|
351
381
|
return aliveCatalog;
|
|
@@ -357,7 +387,7 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
357
387
|
const raw = fs.readFileSync(CATALOG_CACHE_FILE, "utf8");
|
|
358
388
|
const stale = JSON.parse(raw) as CatalogCacheData;
|
|
359
389
|
if (Array.isArray(stale.models) && stale.models.length > 0) {
|
|
360
|
-
const filtered = stale.models
|
|
390
|
+
const filtered = sanitizeCatalogModels(stale.models);
|
|
361
391
|
if (filtered.length >= ALL_MODELS.length) {
|
|
362
392
|
aliveCatalog = filtered;
|
|
363
393
|
return aliveCatalog;
|
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) {
|