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 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.7",
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) => !DEAD_MODEL_IDS.has(m.id));
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
- // Ensure no dead IDs survive even if base was stale
80
- return [...byId.values()].filter((m) => !DEAD_MODEL_IDS.has(m.id));
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.filter((m) => !DEAD_MODEL_IDS.has(m.id));
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.filter((m) => !DEAD_MODEL_IDS.has(m.id));
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 conditional fetch with If-None-Match when we have an etag
275
- if (cachedEtag || force) {
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.filter((m) => !DEAD_MODEL_IDS.has(m.id));
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.filter((m) => !DEAD_MODEL_IDS.has(m.id));
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) {