pi-freeflow 1.22.2 → 1.22.4

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/src/catalog.ts CHANGED
@@ -8,39 +8,39 @@ import { randomUUID } from "node:crypto";
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
10
  import {
11
- CATALOG_CACHE_FILE,
12
- CATALOG_CACHE_TTL_MS,
13
- CATALOG_REFRESH_TIMEOUT_MS,
14
- KILO_CHAT_URL,
15
- OPENCODE_API_URL,
16
- opencodeHeaders,
11
+ CATALOG_CACHE_FILE,
12
+ CATALOG_CACHE_TTL_MS,
13
+ CATALOG_REFRESH_TIMEOUT_MS,
14
+ KILO_CHAT_URL,
15
+ OPENCODE_API_URL,
16
+ opencodeHeaders,
17
17
  } from "./config.ts";
18
18
  import { log, logDebug, logWarn } from "./logger.ts";
19
19
  import {
20
- ALL_MODELS,
21
- MODEL_MAP,
22
- getModelUpstream,
20
+ ALL_MODELS,
21
+ MODEL_MAP,
22
+ getModelUpstream,
23
23
  } from "./models.ts";
24
24
  import type {
25
- CatalogCacheData,
26
- RawModelItem,
27
- RegisteredModel,
28
- Upstream,
25
+ CatalogCacheData,
26
+ RawModelItem,
27
+ RegisteredModel,
28
+ Upstream,
29
29
  } from "./types.ts";
30
30
 
31
31
  /**
32
32
  * Pruned model IDs that must never re-enter the catalog via disk cache or upstream merge.
33
33
  */
34
34
  export const DEAD_MODEL_IDS = new Set<string>([
35
- "deepseek-v4-flash-free",
36
- "x-preview-f-free",
37
- "hy3-free",
38
- "tencent/hy3:free",
39
- "meituan/longcat-2.0-free",
40
- "laguna-s-2.1-free",
41
- "minimax/minimax-m2.7:free",
42
- "minimax/minimax-m3:free",
43
- "thinkingmachines/inkling:free",
35
+ "deepseek-v4-flash-free",
36
+ "x-preview-f-free",
37
+ "hy3-free",
38
+ "tencent/hy3:free",
39
+ "meituan/longcat-2.0-free",
40
+ "laguna-s-2.1-free",
41
+ "minimax/minimax-m2.7:free",
42
+ "minimax/minimax-m3:free",
43
+ "thinkingmachines/inkling:free",
44
44
  ]);
45
45
  /**
46
46
  * Free-tier allowlist for anything entering the picker via network or stale disk.
@@ -50,9 +50,9 @@ export const DEAD_MODEL_IDS = new Set<string>([
50
50
  * (e.g. big-pickle) stay allowed via MODEL_MAP.
51
51
  */
52
52
  export function isFreeCatalogId(id: string): boolean {
53
- if (typeof id !== "string" || id.length === 0) return false;
54
- if (DEAD_MODEL_IDS.has(id)) return false;
55
- return id.includes("-free") || id.includes(":free") || id.includes("/free") || MODEL_MAP.has(id);
53
+ if (typeof id !== "string" || id.length === 0) return false;
54
+ if (DEAD_MODEL_IDS.has(id)) return false;
55
+ return id.includes("-free") || id.includes(":free") || id.includes("/free") || MODEL_MAP.has(id);
56
56
  }
57
57
  /**
58
58
  * Purge paid/dead entries from a catalog list and repair known models against
@@ -63,38 +63,41 @@ export function isFreeCatalogId(id: string): boolean {
63
63
  * reinstall.
64
64
  */
65
65
  export function sanitizeCatalogModels(models: RegisteredModel[]): RegisteredModel[] {
66
- const out: RegisteredModel[] = [];
67
- for (const m of models) {
68
- if (!m || typeof m.id !== "string" || !isFreeCatalogId(m.id)) continue;
69
- const known = MODEL_MAP.get(m.id);
70
- if (known) {
71
- out.push({ ...known, source: m.source ?? getModelUpstream(m.id) });
72
- } else {
73
- out.push(m);
74
- }
75
- }
76
- return out;
66
+ const out: RegisteredModel[] = [];
67
+ for (const m of models) {
68
+ if (!m || typeof m.id !== "string" || !isFreeCatalogId(m.id)) continue;
69
+ const known = MODEL_MAP.get(m.id);
70
+ if (known) {
71
+ // A known id's upstream is a static fact: a stale cache entry must never
72
+ // relabel it (a Cline model advertised as opencode would register with the
73
+ // wrong compat settings).
74
+ out.push({ ...known, source: getModelUpstream(m.id) });
75
+ } else {
76
+ out.push(m);
77
+ }
78
+ }
79
+ return out;
77
80
  }
78
81
  /**
79
82
  * In-memory cache of currently active/available free models.
80
83
  * Initialized with all 31 verified models for 0ms instant availability.
81
84
  */
82
85
  let aliveCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
83
- ...m,
84
- source: getModelUpstream(m.id),
86
+ ...m,
87
+ source: getModelUpstream(m.id),
85
88
  }));
86
89
  /**
87
90
  * Get current in-memory alive catalog
88
91
  */
89
92
  export function getAliveCatalog(): RegisteredModel[] {
90
- return aliveCatalog;
93
+ return aliveCatalog;
91
94
  }
92
95
 
93
96
  /**
94
97
  * Set in-memory alive catalog
95
98
  */
96
99
  export function setAliveCatalog(catalog: RegisteredModel[]): void {
97
- aliveCatalog = catalog;
100
+ aliveCatalog = catalog;
98
101
  }
99
102
 
100
103
  /**
@@ -105,113 +108,114 @@ export function setAliveCatalog(catalog: RegisteredModel[]): void {
105
108
  * Filters pruned dead IDs so they never re-enter via fresh upstream data.
106
109
  */
107
110
  export function mergeCatalog(
108
- base: RegisteredModel[],
109
- fresh: RegisteredModel[],
111
+ base: RegisteredModel[],
112
+ fresh: RegisteredModel[],
110
113
  ): RegisteredModel[] {
111
- const filteredFresh = fresh.filter((m) => m && typeof m.id === "string" && isFreeCatalogId(m.id));
112
- const byId = new Map(base.map((m) => [m.id, m]));
113
- for (const m of filteredFresh) byId.set(m.id, m);
114
- // Sanitize the merged result so stale paid entries in a pre-fix base and
115
- // stale api fields on known models never survive the merge.
116
- return sanitizeCatalogModels([...byId.values()]);
114
+ const filteredFresh = fresh.filter((m) => m && typeof m.id === "string" && isFreeCatalogId(m.id));
115
+ const byId = new Map(base.map((m) => [m.id, m]));
116
+ for (const m of filteredFresh) byId.set(m.id, m);
117
+ // Sanitize the merged result so stale paid entries in a pre-fix base and
118
+ // stale api fields on known models never survive the merge.
119
+ return sanitizeCatalogModels([...byId.values()]);
117
120
  }
118
121
 
119
122
  /**
120
123
  * Format a clean, human-readable display name for any upstream model ID.
121
124
  */
122
125
  export function formatCleanDisplayName(id: string, customName?: string): string {
123
- if (customName && customName.trim()) {
124
- return customName.trim();
125
- }
126
- const known = MODEL_MAP.get(id);
127
- if (known && known.name) {
128
- return known.name;
129
- }
126
+ if (customName && customName.trim()) {
127
+ return customName.trim();
128
+ }
129
+ const known = MODEL_MAP.get(id);
130
+ if (known && known.name) {
131
+ return known.name;
132
+ }
130
133
 
131
- // Strip provider prefix ("nvidia/", "stepfun/", "dots-studio/", etc.)
132
- let clean = id.replace(/^[a-zA-Z0-9_.-]+\//, "");
133
- // Strip variant suffixes
134
- clean = clean.replace(/:(free|preview|exacto|default|batch)$/i, "");
135
- clean = clean.replace(/-(free|contributor|preview)$/i, "");
134
+ // Strip provider prefix ("nvidia/", "stepfun/", "dots-studio/", etc.)
135
+ let clean = id.replace(/^[a-zA-Z0-9_.-]+\//, "");
136
+ // Strip variant suffixes
137
+ clean = clean.replace(/:(free|preview|exacto|default|batch)$/i, "");
138
+ clean = clean.replace(/-(free|contributor|preview)$/i, "");
136
139
 
137
- // Capitalize words with acronym preservation
138
- const parts = clean.split(/[-_]/).map((w) => {
139
- const lower = w.toLowerCase();
140
- if (lower === "gpt") return "GPT";
141
- if (lower === "ai") return "AI";
142
- if (lower === "lfm") return "LFM";
143
- if (lower === "hy3") return "Hy3";
144
- if (lower === "mimo") return "MiMo";
145
- if (lower === "ocr") return "OCR";
146
- return w.charAt(0).toUpperCase() + w.slice(1);
147
- });
140
+ // Capitalize words with acronym preservation
141
+ const parts = clean.split(/[-_]/).map((w) => {
142
+ const lower = w.toLowerCase();
143
+ if (lower === "gpt") return "GPT";
144
+ if (lower === "ai") return "AI";
145
+ if (lower === "lfm") return "LFM";
146
+ if (lower === "hy3") return "Hy3";
147
+ if (lower === "mimo") return "MiMo";
148
+ if (lower === "ocr") return "OCR";
149
+ return w.charAt(0).toUpperCase() + w.slice(1);
150
+ });
148
151
 
149
- return parts.join(" ");
152
+ return parts.join(" ");
150
153
  }
151
154
 
152
155
  /**
153
156
  * Enrich a raw upstream model item into a fully typed RegisteredModel.
154
157
  */
155
158
  export function enrichModelDef(raw: RawModelItem, source: Upstream): RegisteredModel {
156
- const known = MODEL_MAP.get(raw.id);
157
- if (known) {
158
- return { ...known, source };
159
- }
159
+ const known = MODEL_MAP.get(raw.id);
160
+ if (known) {
161
+ return { ...known, source };
162
+ }
160
163
 
161
- const idLower = raw.id.toLowerCase();
162
- const hasVision =
163
- idLower.includes("vision") ||
164
- idLower.includes("vl") ||
165
- idLower.includes("omni") ||
166
- idLower.includes("note") ||
167
- idLower.includes("image");
168
- const hasReasoning =
169
- idLower.includes("reasoning") ||
170
- idLower.includes("r1") ||
171
- idLower.includes("o1") ||
172
- idLower.includes("think") ||
173
- idLower.includes("spark") ||
174
- idLower.includes("flash") ||
175
- idLower.includes("deepseek") ||
176
- idLower.includes("glm") ||
177
- idLower.includes("solar");
164
+ const idLower = raw.id.toLowerCase();
165
+ const hasVision =
166
+ idLower.includes("vision") ||
167
+ idLower.includes("vl") ||
168
+ idLower.includes("omni") ||
169
+ idLower.includes("note") ||
170
+ idLower.includes("image");
171
+ const hasReasoning =
172
+ idLower.includes("reasoning") ||
173
+ idLower.includes("r1") ||
174
+ idLower.includes("o1") ||
175
+ idLower.includes("think") ||
176
+ idLower.includes("spark") ||
177
+ idLower.includes("flash") ||
178
+ idLower.includes("deepseek") ||
179
+ idLower.includes("glm") ||
180
+ idLower.includes("solar");
178
181
 
179
- let contextWindow =
180
- typeof raw.context_length === "number" ? raw.context_length : 262_144;
181
- if (
182
- idLower.includes("1m") ||
183
- idLower.includes("ultra") ||
184
- idLower.includes("lightning") ||
185
- idLower.includes("mimo-v2.5") ||
186
- idLower.includes("muse-spark")
187
- ) {
188
- contextWindow = 1_048_576;
189
- }
182
+ let contextWindow =
183
+ typeof raw.context_length === "number" ? raw.context_length : 262_144;
184
+ if (
185
+ idLower.includes("1m") ||
186
+ idLower.includes("ultra") ||
187
+ idLower.includes("lightning") ||
188
+ idLower.includes("mimo-v2.5") ||
189
+ idLower.includes("muse-spark")
190
+ ) {
191
+ contextWindow = 1_048_576;
192
+ }
190
193
 
191
- let maxTokens =
192
- typeof raw.max_output_tokens === "number"
193
- ? raw.max_output_tokens
194
- : 65_536;
195
- if (idLower.includes("ultra") || idLower.includes("lightning")) {
196
- maxTokens = 131_072;
197
- }
194
+ let maxTokens =
195
+ typeof raw.max_output_tokens === "number"
196
+ ? raw.max_output_tokens
197
+ : 65_536;
198
+ if (idLower.includes("ultra") || idLower.includes("lightning")) {
199
+ maxTokens = 131_072;
200
+ }
198
201
 
199
- const isResponses =
200
- raw.id === "muse-spark-1.2-contributor-free" ||
201
- raw.id === "muse-spark-1.3-contributor-free" ||
202
- raw.id === "cline-free/muse-spark-1.3-contributor";
202
+ // Muse Spark is served on the responses API upstream (both static catalogue
203
+ // entries declare it). Every known id short-circuits above, so this only
204
+ // decides for ids Zen lists dynamically — matching the family keeps a future
205
+ // muse-spark id off the chat endpoint it would 500 on.
206
+ const isResponses = idLower.includes("muse-spark");
203
207
 
204
- return {
205
- id: raw.id,
206
- name: formatCleanDisplayName(raw.id),
207
- source,
208
- reasoning: hasReasoning,
209
- contextWindow,
210
- maxTokens,
211
- api: isResponses ? "openai-responses" : undefined,
212
- input: hasVision ? ["text", "image"] : ["text"],
213
- thinkingFormat: source === "kilo" && hasReasoning ? "openrouter" : undefined,
214
- };
208
+ return {
209
+ id: raw.id,
210
+ name: formatCleanDisplayName(raw.id),
211
+ source,
212
+ reasoning: hasReasoning,
213
+ contextWindow,
214
+ maxTokens,
215
+ api: isResponses ? "openai-responses" : undefined,
216
+ input: hasVision ? ["text", "image"] : ["text"],
217
+ thinkingFormat: source === "kilo" && hasReasoning ? "openrouter" : undefined,
218
+ };
215
219
  }
216
220
 
217
221
  /**
@@ -220,23 +224,23 @@ export function enrichModelDef(raw: RawModelItem, source: Upstream): RegisteredM
220
224
  * Preserves etag for conditional If-None-Match requests.
221
225
  */
222
226
  export function readCatalogCache(): CatalogCacheData | null {
223
- try {
224
- if (!fs.existsSync(CATALOG_CACHE_FILE)) {
225
- return null;
226
- }
227
- const raw = fs.readFileSync(CATALOG_CACHE_FILE, "utf8");
228
- const data = JSON.parse(raw) as CatalogCacheData;
229
- if (!Array.isArray(data.models)) {
230
- return null;
231
- }
232
- data.models = sanitizeCatalogModels(data.models);
233
- if (Date.now() - data.timestamp < CATALOG_CACHE_TTL_MS) {
234
- return data;
235
- }
236
- } catch (err) {
237
- logDebug("Failed reading catalog cache", { error: String(err) });
238
- }
239
- return null;
227
+ try {
228
+ if (!fs.existsSync(CATALOG_CACHE_FILE)) {
229
+ return null;
230
+ }
231
+ const raw = fs.readFileSync(CATALOG_CACHE_FILE, "utf8");
232
+ const data = JSON.parse(raw) as CatalogCacheData;
233
+ if (!Array.isArray(data.models)) {
234
+ return null;
235
+ }
236
+ data.models = sanitizeCatalogModels(data.models);
237
+ if (Date.now() - data.timestamp < CATALOG_CACHE_TTL_MS) {
238
+ return data;
239
+ }
240
+ } catch (err) {
241
+ logDebug("Failed reading catalog cache", { error: String(err) });
242
+ }
243
+ return null;
240
244
  }
241
245
 
242
246
  /**
@@ -245,37 +249,37 @@ export function readCatalogCache(): CatalogCacheData | null {
245
249
  * Note: fsync not needed — rename is atomic on same filesystem; crash leaves either old or new file intact.
246
250
  */
247
251
  function sleepSync(ms: number): void {
248
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
252
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
249
253
  }
250
254
 
251
255
  function renameWithRetry(from: string, to: string): void {
252
- const retryable: Record<string, true> = { EPERM: true, EACCES: true, EBUSY: true };
253
- for (let attempt = 1; ; attempt++) {
254
- try {
255
- fs.renameSync(from, to);
256
- return;
257
- } catch (e) {
258
- const code = (e as NodeJS.ErrnoException | null)?.code;
259
- if (!code || !retryable[code] || attempt >= 3) {
260
- throw e;
261
- }
262
- sleepSync(50);
263
- }
264
- }
256
+ const retryable: Record<string, true> = { EPERM: true, EACCES: true, EBUSY: true };
257
+ for (let attempt = 1; ; attempt++) {
258
+ try {
259
+ fs.renameSync(from, to);
260
+ return;
261
+ } catch (e) {
262
+ const code = (e as NodeJS.ErrnoException | null)?.code;
263
+ if (!code || !retryable[code] || attempt >= 3) {
264
+ throw e;
265
+ }
266
+ sleepSync(50);
267
+ }
268
+ }
265
269
  }
266
270
 
267
271
  export function writeCatalogCache(data: CatalogCacheData): void {
268
- try {
269
- const dir = path.dirname(CATALOG_CACHE_FILE);
270
- if (!fs.existsSync(dir)) {
271
- fs.mkdirSync(dir, { recursive: true });
272
- }
273
- const tmpPath = `${CATALOG_CACHE_FILE}.${randomUUID()}.tmp`;
274
- fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf8");
275
- renameWithRetry(tmpPath, CATALOG_CACHE_FILE);
276
- } catch (err) {
277
- logWarn("Could not persist catalog cache to disk", { error: String(err) });
278
- }
272
+ try {
273
+ const dir = path.dirname(CATALOG_CACHE_FILE);
274
+ if (!fs.existsSync(dir)) {
275
+ fs.mkdirSync(dir, { recursive: true });
276
+ }
277
+ const tmpPath = `${CATALOG_CACHE_FILE}.${randomUUID()}.tmp`;
278
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf8");
279
+ renameWithRetry(tmpPath, CATALOG_CACHE_FILE);
280
+ } catch (err) {
281
+ logWarn("Could not persist catalog cache to disk", { error: String(err) });
282
+ }
279
283
  }
280
284
 
281
285
  /**
@@ -286,125 +290,129 @@ export function writeCatalogCache(data: CatalogCacheData): void {
286
290
  * requests fail.
287
291
  */
288
292
  export async function refreshCatalog(force = false): Promise<RegisteredModel[]> {
289
- // Thin provider: serve fresh disk cache instantly when valid
290
- const disk = readCatalogCache();
291
- if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
292
- const age = Date.now() - (disk.timestamp ?? 0);
293
- if (!force && age < CATALOG_CACHE_TTL_MS) {
294
- aliveCatalog = sanitizeCatalogModels(disk.models);
295
- return aliveCatalog;
296
- }
297
- }
293
+ // Thin provider: serve fresh disk cache instantly when valid. The cache is
294
+ // overlayed on the static base rather than replacing it, so a cache written
295
+ // before a model was added to the catalog (or pruned from it) cannot drop
296
+ // models the host still registers.
297
+ const staticBase: RegisteredModel[] = ALL_MODELS.map((m) => ({ ...m, source: getModelUpstream(m.id) }));
298
+ const disk = readCatalogCache();
299
+ if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
300
+ const age = Date.now() - (disk.timestamp ?? 0);
301
+ if (!force && age < CATALOG_CACHE_TTL_MS) {
302
+ aliveCatalog = mergeCatalog(staticBase, sanitizeCatalogModels(disk.models));
303
+ return aliveCatalog;
304
+ }
305
+ }
298
306
 
299
- // Resolve etag from fresh or stale cache for conditional request
300
- let cachedEtag: string | undefined = disk?.etag;
301
- let staleForEtag: CatalogCacheData | null = disk;
302
- if (!cachedEtag) {
303
- try {
304
- if (fs.existsSync(CATALOG_CACHE_FILE)) {
305
- const raw = fs.readFileSync(CATALOG_CACHE_FILE, "utf8");
306
- const stale = JSON.parse(raw) as CatalogCacheData;
307
- cachedEtag = stale.etag;
308
- staleForEtag = stale;
309
- }
310
- } catch (err) {
311
- logDebug("Failed reading stale catalog cache for etag", { error: String(err) });
312
- }
313
- }
307
+ // Resolve etag from fresh or stale cache for conditional request
308
+ let cachedEtag: string | undefined = disk?.etag;
309
+ let staleForEtag: CatalogCacheData | null = disk;
310
+ if (!cachedEtag) {
311
+ try {
312
+ if (fs.existsSync(CATALOG_CACHE_FILE)) {
313
+ const raw = fs.readFileSync(CATALOG_CACHE_FILE, "utf8");
314
+ const stale = JSON.parse(raw) as CatalogCacheData;
315
+ cachedEtag = stale.etag;
316
+ staleForEtag = stale;
317
+ }
318
+ } catch (err) {
319
+ logDebug("Failed reading stale catalog cache for etag", { error: String(err) });
320
+ }
321
+ }
314
322
 
315
- // Attempt a fetch whenever there is cache material to revalidate: conditional
316
- // with If-None-Match when we have an etag, plain otherwise. A pre-fix cache
317
- // file with no etag must still go live (acquiring an etag and discovering
318
- // new free models) instead of serving stale indefinitely. Corrupt/missing
319
- // caches (staleForEtag null) skip the network and fall through to static.
320
- if (cachedEtag || force || staleForEtag) {
321
- try {
322
- const headers: Record<string, string> = { ...opencodeHeaders() };
323
- if (cachedEtag) {
324
- headers["If-None-Match"] = cachedEtag;
325
- }
326
- const res = await fetch(`${OPENCODE_API_URL}/models`, {
327
- headers,
328
- // A hung upstream must not freeze /freeflow refresh: abort after
329
- // CATALOG_REFRESH_TIMEOUT_MS and fall back to cache below.
330
- signal: AbortSignal.timeout(CATALOG_REFRESH_TIMEOUT_MS),
331
- });
332
- if (res.status === 304) {
333
- // Not modified — skip merge, extend timestamp to avoid tight loop
334
- if (staleForEtag && Array.isArray(staleForEtag.models)) {
335
- try {
336
- writeCatalogCache({ ...staleForEtag, timestamp: Date.now() });
337
- } catch {}
338
- }
339
- return aliveCatalog;
340
- }
341
- if (res.ok) {
342
- const newEtag = res.headers.get("etag") ?? res.headers.get("ETag") ?? cachedEtag;
343
- const body: unknown = await res.json();
344
- let rawList: RawModelItem[] = [];
345
- if (Array.isArray(body)) {
346
- rawList = body as RawModelItem[];
347
- } else if (body !== null && typeof body === "object" && "data" in body) {
348
- const dataVal = body.data as unknown;
349
- if (Array.isArray(dataVal)) {
350
- rawList = dataVal as RawModelItem[];
351
- }
352
- }
353
- if (rawList.length > 0) {
354
- const freeRawList = rawList.filter((r) => r && typeof r.id === "string" && isFreeCatalogId(r.id));
355
- const fresh = freeRawList.map((r) => enrichModelDef(r, "opencode"));
356
- const merged = mergeCatalog(aliveCatalog, fresh);
357
- aliveCatalog = merged;
358
- writeCatalogCache({
359
- timestamp: Date.now(),
360
- opencode: fresh.map((m) => m.id),
361
- kilo: staleForEtag?.kilo ?? [],
362
- models: merged,
363
- etag: newEtag ?? cachedEtag,
364
- });
365
- return aliveCatalog;
366
- }
367
- // Empty payload but 200 — treat as no-op, return current
368
- if (newEtag && newEtag !== cachedEtag && staleForEtag) {
369
- writeCatalogCache({ ...staleForEtag, timestamp: Date.now(), etag: newEtag });
370
- }
371
- return aliveCatalog;
372
- }
373
- } catch (err) {
374
- if ((err as Error)?.name === "AbortError") {
375
- logWarn("Catalog refresh timed out — using cached/static fallback", {
376
- timeoutMs: CATALOG_REFRESH_TIMEOUT_MS,
377
- });
378
- } else {
379
- logDebug("Conditional catalog fetch failed, falling back to cache", { error: String(err) });
380
- }
381
- }
382
- }
323
+ // Attempt a fetch whenever there is cache material to revalidate: conditional
324
+ // with If-None-Match when we have an etag, plain otherwise. A pre-fix cache
325
+ // file with no etag must still go live (acquiring an etag and discovering
326
+ // new free models) instead of serving stale indefinitely. Corrupt/missing
327
+ // caches (staleForEtag null) skip the network and fall through to static.
328
+ if (cachedEtag || force || staleForEtag) {
329
+ try {
330
+ const headers: Record<string, string> = { ...opencodeHeaders() };
331
+ if (cachedEtag) {
332
+ headers["If-None-Match"] = cachedEtag;
333
+ }
334
+ const res = await fetch(`${OPENCODE_API_URL}/models`, {
335
+ headers,
336
+ // A hung upstream must not freeze /freeflow refresh: abort after
337
+ // CATALOG_REFRESH_TIMEOUT_MS and fall back to cache below.
338
+ signal: AbortSignal.timeout(CATALOG_REFRESH_TIMEOUT_MS),
339
+ });
340
+ if (res.status === 304) {
341
+ // Not modified — skip merge, extend timestamp to avoid tight loop
342
+ if (staleForEtag && Array.isArray(staleForEtag.models)) {
343
+ try {
344
+ writeCatalogCache({ ...staleForEtag, timestamp: Date.now() });
345
+ } catch { }
346
+ }
347
+ return aliveCatalog;
348
+ }
349
+ if (res.ok) {
350
+ const newEtag = res.headers.get("etag") ?? res.headers.get("ETag") ?? cachedEtag;
351
+ const body: unknown = await res.json();
352
+ let rawList: RawModelItem[] = [];
353
+ if (Array.isArray(body)) {
354
+ rawList = body as RawModelItem[];
355
+ } else if (body !== null && typeof body === "object" && "data" in body) {
356
+ const dataVal = body.data as unknown;
357
+ if (Array.isArray(dataVal)) {
358
+ rawList = dataVal as RawModelItem[];
359
+ }
360
+ }
361
+ if (rawList.length > 0) {
362
+ const freeRawList = rawList.filter((r) => r && typeof r.id === "string" && isFreeCatalogId(r.id));
363
+ const fresh = freeRawList.map((r) => enrichModelDef(r, "opencode"));
364
+ const merged = mergeCatalog(aliveCatalog, fresh);
365
+ aliveCatalog = merged;
366
+ writeCatalogCache({
367
+ timestamp: Date.now(),
368
+ opencode: fresh.map((m) => m.id),
369
+ kilo: staleForEtag?.kilo ?? [],
370
+ models: merged,
371
+ etag: newEtag ?? cachedEtag,
372
+ });
373
+ return aliveCatalog;
374
+ }
375
+ // Empty payload but 200 — treat as no-op, return current
376
+ if (newEtag && newEtag !== cachedEtag && staleForEtag) {
377
+ writeCatalogCache({ ...staleForEtag, timestamp: Date.now(), etag: newEtag });
378
+ }
379
+ return aliveCatalog;
380
+ }
381
+ } catch (err) {
382
+ if ((err as Error)?.name === "AbortError") {
383
+ logWarn("Catalog refresh timed out — using cached/static fallback", {
384
+ timeoutMs: CATALOG_REFRESH_TIMEOUT_MS,
385
+ });
386
+ } else {
387
+ logDebug("Conditional catalog fetch failed, falling back to cache", { error: String(err) });
388
+ }
389
+ }
390
+ }
383
391
 
384
- // Stale cache still better than empty — return it without network (filtered)
385
- if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
386
- const filtered = sanitizeCatalogModels(disk.models);
387
- if (filtered.length >= ALL_MODELS.length) {
388
- aliveCatalog = filtered;
389
- return aliveCatalog;
390
- }
391
- }
392
- // No valid fresh cache — try stale disk cache directly (readCatalogCache returns null when expired)
393
- try {
394
- if (fs.existsSync(CATALOG_CACHE_FILE)) {
395
- const raw = fs.readFileSync(CATALOG_CACHE_FILE, "utf8");
396
- const stale = JSON.parse(raw) as CatalogCacheData;
397
- if (Array.isArray(stale.models) && stale.models.length > 0) {
398
- const filtered = sanitizeCatalogModels(stale.models);
399
- if (filtered.length >= ALL_MODELS.length) {
400
- aliveCatalog = filtered;
401
- return aliveCatalog;
402
- }
403
- }
404
- }
405
- } catch (err) {
406
- logDebug("Failed reading stale catalog cache", { error: String(err) });
407
- }
408
- // No valid cache — return in-memory static 27 (host will refresh if needed)
409
- return aliveCatalog;
392
+ // Stale cache still better than empty — return it without network (filtered)
393
+ if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
394
+ const filtered = sanitizeCatalogModels(disk.models);
395
+ if (filtered.length >= ALL_MODELS.length) {
396
+ aliveCatalog = filtered;
397
+ return aliveCatalog;
398
+ }
399
+ }
400
+ // No valid fresh cache — try stale disk cache directly (readCatalogCache returns null when expired)
401
+ try {
402
+ if (fs.existsSync(CATALOG_CACHE_FILE)) {
403
+ const raw = fs.readFileSync(CATALOG_CACHE_FILE, "utf8");
404
+ const stale = JSON.parse(raw) as CatalogCacheData;
405
+ if (Array.isArray(stale.models) && stale.models.length > 0) {
406
+ const filtered = sanitizeCatalogModels(stale.models);
407
+ if (filtered.length >= ALL_MODELS.length) {
408
+ aliveCatalog = filtered;
409
+ return aliveCatalog;
410
+ }
411
+ }
412
+ }
413
+ } catch (err) {
414
+ logDebug("Failed reading stale catalog cache", { error: String(err) });
415
+ }
416
+ // No valid cache — return in-memory static 27 (host will refresh if needed)
417
+ return aliveCatalog;
410
418
  }