pi-freeflow 1.20.0 → 1.22.0

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
@@ -1,405 +1,410 @@
1
- /**
2
- * Dynamic catalog discovery, caching, and model enrichment for pi-freeflow
3
- *
4
- * Provides 1-hour atomic disk caching with safe offline fallback to verified static definitions.
5
- */
6
-
7
- import { randomUUID } from "node:crypto";
8
- import fs from "node:fs";
9
- import path from "node:path";
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,
17
- } from "./config.ts";
18
- import { log, logDebug, logWarn } from "./logger.ts";
19
- import {
20
- ALL_MODELS,
21
- KILO_MODEL_IDS,
22
- MODEL_MAP,
23
- } from "./models.ts";
24
- import type {
25
- CatalogCacheData,
26
- RawModelItem,
27
- RegisteredModel,
28
- Upstream,
29
- } from "./types.ts";
30
-
31
- /**
32
- * Pruned model IDs that must never re-enter the catalog via disk cache or upstream merge.
33
- */
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",
44
- ]);
45
- /**
46
- * Free-tier allowlist for anything entering the picker via network or stale disk.
47
- * Upstream lists paid models alongside free ones (e.g. claude-fable-5-1,
48
- * claude-opus-4-*, gemini-3-*) so a bare upstream merge leaks paid entries that
49
- * fail with 401 Missing API key. Known static IDs without a free suffix
50
- * (e.g. big-pickle) stay allowed via MODEL_MAP.
51
- */
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);
56
- }
57
- /**
58
- * Purge paid/dead entries from a catalog list and repair known models against
59
- * the static definitions. Stale disk caches predate the paid filter and the
60
- * muse-spark-1.3 responses-api entry, so loading them verbatim replays a wrong
61
- * api (chat/completions for a responses-only model -> upstream 500) until the
62
- * 24h TTL expires. Repairing here self-heals on the next refresh without a
63
- * reinstall.
64
- */
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 ?? (KILO_MODEL_IDS.has(m.id) ? "kilo" : "opencode") });
72
- } else {
73
- out.push(m);
74
- }
75
- }
76
- return out;
77
- }
78
- /**
79
- * In-memory cache of currently active/available free models.
80
- * Initialized with all 27 verified models for 0ms instant availability.
81
- */
82
- let aliveCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
83
- ...m,
84
- source: KILO_MODEL_IDS.has(m.id) ? ("kilo" as const) : ("opencode" as const),
85
- }));
86
- /**
87
- * Get current in-memory alive catalog
88
- */
89
- export function getAliveCatalog(): RegisteredModel[] {
90
- return aliveCatalog;
91
- }
92
-
93
- /**
94
- * Set in-memory alive catalog
95
- */
96
- export function setAliveCatalog(catalog: RegisteredModel[]): void {
97
- aliveCatalog = catalog;
98
- }
99
-
100
- /**
101
- * Overlay a refreshed model list onto a base list without dropping base entries.
102
- * Fresh entries win on id collision; unknown fresh ids are appended after the base.
103
- * Guards the background refresh against a partial upstream cache removing
104
- * verified static models from provider registration.
105
- * Filters pruned dead IDs so they never re-enter via fresh upstream data.
106
- */
107
- export function mergeCatalog(
108
- base: RegisteredModel[],
109
- fresh: RegisteredModel[],
110
- ): 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()]);
117
- }
118
-
119
- /**
120
- * Format a clean, human-readable display name for any upstream model ID.
121
- */
122
- 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
- }
130
-
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, "");
136
-
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
- });
148
-
149
- return parts.join(" ");
150
- }
151
-
152
- /**
153
- * Enrich a raw upstream model item into a fully typed RegisteredModel.
154
- */
155
- export function enrichModelDef(raw: RawModelItem, source: Upstream): RegisteredModel {
156
- const known = MODEL_MAP.get(raw.id);
157
- if (known) {
158
- return { ...known, source };
159
- }
160
-
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
-
175
- let contextWindow =
176
- typeof raw.context_length === "number" ? raw.context_length : 262_144;
177
- if (
178
- idLower.includes("1m") ||
179
- idLower.includes("ultra") ||
180
- idLower.includes("lightning") ||
181
- idLower.includes("mimo-v2.5") ||
182
- idLower.includes("muse-spark")
183
- ) {
184
- contextWindow = 1_048_576;
185
- }
186
-
187
- let maxTokens =
188
- typeof raw.max_output_tokens === "number"
189
- ? raw.max_output_tokens
190
- : 65_536;
191
- if (idLower.includes("ultra") || idLower.includes("lightning")) {
192
- maxTokens = 131_072;
193
- }
194
-
195
- const isResponses =
196
- raw.id === "muse-spark-1.2-contributor-free" ||
197
- raw.id === "muse-spark-1.3-contributor-free";
198
-
199
- return {
200
- id: raw.id,
201
- name: formatCleanDisplayName(raw.id),
202
- source,
203
- reasoning: hasReasoning,
204
- contextWindow,
205
- maxTokens,
206
- api: isResponses ? "openai-responses" : undefined,
207
- input: hasVision ? ["text", "image"] : ["text"],
208
- thinkingFormat: source === "kilo" && hasReasoning ? "openrouter" : undefined,
209
- };
210
- }
211
-
212
- /**
213
- * Read cached catalog data from disk if valid and unexpired.
214
- * Filters out pruned dead IDs so stale disk entries never repopulate the catalog.
215
- * Preserves etag for conditional If-None-Match requests.
216
- */
217
- export function readCatalogCache(): CatalogCacheData | null {
218
- try {
219
- if (!fs.existsSync(CATALOG_CACHE_FILE)) {
220
- return null;
221
- }
222
- const raw = fs.readFileSync(CATALOG_CACHE_FILE, "utf8");
223
- const data = JSON.parse(raw) as CatalogCacheData;
224
- if (!Array.isArray(data.models)) {
225
- return null;
226
- }
227
- data.models = sanitizeCatalogModels(data.models);
228
- if (Date.now() - data.timestamp < CATALOG_CACHE_TTL_MS) {
229
- return data;
230
- }
231
- } catch (err) {
232
- logDebug("Failed reading catalog cache", { error: String(err) });
233
- }
234
- return null;
235
- }
236
-
237
- /**
238
- * Atomically write catalog cache data to disk using temporary file + rename.
239
- * Persists etag alongside models for subsequent If-None-Match conditional requests.
240
- * Note: fsync not needed — rename is atomic on same filesystem; crash leaves either old or new file intact.
241
- */
242
- function sleepSync(ms: number): void {
243
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
244
- }
245
-
246
- function renameWithRetry(from: string, to: string): void {
247
- const retryable: Record<string, true> = { EPERM: true, EACCES: true, EBUSY: true };
248
- for (let attempt = 1; ; attempt++) {
249
- try {
250
- fs.renameSync(from, to);
251
- return;
252
- } catch (e) {
253
- const code = (e as NodeJS.ErrnoException | null)?.code;
254
- if (!code || !retryable[code] || attempt >= 3) {
255
- throw e;
256
- }
257
- sleepSync(50);
258
- }
259
- }
260
- }
261
-
262
- export function writeCatalogCache(data: CatalogCacheData): void {
263
- try {
264
- const dir = path.dirname(CATALOG_CACHE_FILE);
265
- if (!fs.existsSync(dir)) {
266
- fs.mkdirSync(dir, { recursive: true });
267
- }
268
- const tmpPath = `${CATALOG_CACHE_FILE}.${randomUUID()}.tmp`;
269
- fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf8");
270
- renameWithRetry(tmpPath, CATALOG_CACHE_FILE);
271
- } catch (err) {
272
- logWarn("Could not persist catalog cache to disk", { error: String(err) });
273
- }
274
- }
275
-
276
- /**
277
- * Refresh free model catalog from OpenCode Zen and KiloCode Gateway endpoints.
278
- * Uses ETag conditional requests (If-None-Match) to avoid re-merging unchanged
279
- * catalogs; a 304 Not Modified response skips merge and returns the in-memory
280
- * catalog unchanged. Falls back gracefully to cached or static models if network
281
- * requests fail.
282
- */
283
- export async function refreshCatalog(force = false): Promise<RegisteredModel[]> {
284
- // Thin provider: serve fresh disk cache instantly when valid
285
- const disk = readCatalogCache();
286
- if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
287
- const age = Date.now() - (disk.timestamp ?? 0);
288
- if (!force && age < CATALOG_CACHE_TTL_MS) {
289
- aliveCatalog = sanitizeCatalogModels(disk.models);
290
- return aliveCatalog;
291
- }
292
- }
293
-
294
- // Resolve etag from fresh or stale cache for conditional request
295
- let cachedEtag: string | undefined = disk?.etag;
296
- let staleForEtag: CatalogCacheData | null = disk;
297
- if (!cachedEtag) {
298
- try {
299
- if (fs.existsSync(CATALOG_CACHE_FILE)) {
300
- const raw = fs.readFileSync(CATALOG_CACHE_FILE, "utf8");
301
- const stale = JSON.parse(raw) as CatalogCacheData;
302
- cachedEtag = stale.etag;
303
- staleForEtag = stale;
304
- }
305
- } catch (err) {
306
- logDebug("Failed reading stale catalog cache for etag", { error: String(err) });
307
- }
308
- }
309
-
310
- // Attempt a fetch whenever there is cache material to revalidate: conditional
311
- // with If-None-Match when we have an etag, plain otherwise. A pre-fix cache
312
- // file with no etag must still go live (acquiring an etag and discovering
313
- // new free models) instead of serving stale indefinitely. Corrupt/missing
314
- // caches (staleForEtag null) skip the network and fall through to static.
315
- if (cachedEtag || force || staleForEtag) {
316
- try {
317
- const headers: Record<string, string> = { ...opencodeHeaders() };
318
- if (cachedEtag) {
319
- headers["If-None-Match"] = cachedEtag;
320
- }
321
- const res = await fetch(`${OPENCODE_API_URL}/models`, {
322
- headers,
323
- // A hung upstream must not freeze /freeflow refresh: abort after
324
- // CATALOG_REFRESH_TIMEOUT_MS and fall back to cache below.
325
- signal: AbortSignal.timeout(CATALOG_REFRESH_TIMEOUT_MS),
326
- });
327
- if (res.status === 304) {
328
- // Not modified — skip merge, extend timestamp to avoid tight loop
329
- if (staleForEtag && Array.isArray(staleForEtag.models)) {
330
- try {
331
- writeCatalogCache({ ...staleForEtag, timestamp: Date.now() });
332
- } catch {}
333
- }
334
- return aliveCatalog;
335
- }
336
- if (res.ok) {
337
- const newEtag = res.headers.get("etag") ?? res.headers.get("ETag") ?? cachedEtag;
338
- const body: unknown = await res.json();
339
- let rawList: RawModelItem[] = [];
340
- if (Array.isArray(body)) {
341
- rawList = body as RawModelItem[];
342
- } else if (body !== null && typeof body === "object" && "data" in body) {
343
- const dataVal = body.data as unknown;
344
- if (Array.isArray(dataVal)) {
345
- rawList = dataVal as RawModelItem[];
346
- }
347
- }
348
- if (rawList.length > 0) {
349
- const freeRawList = rawList.filter((r) => r && typeof r.id === "string" && isFreeCatalogId(r.id));
350
- const fresh = freeRawList.map((r) => enrichModelDef(r, "opencode"));
351
- const merged = mergeCatalog(aliveCatalog, fresh);
352
- aliveCatalog = merged;
353
- writeCatalogCache({
354
- timestamp: Date.now(),
355
- opencode: fresh.map((m) => m.id),
356
- kilo: staleForEtag?.kilo ?? [],
357
- models: merged,
358
- etag: newEtag ?? cachedEtag,
359
- });
360
- return aliveCatalog;
361
- }
362
- // Empty payload but 200 — treat as no-op, return current
363
- if (newEtag && newEtag !== cachedEtag && staleForEtag) {
364
- writeCatalogCache({ ...staleForEtag, timestamp: Date.now(), etag: newEtag });
365
- }
366
- return aliveCatalog;
367
- }
368
- } catch (err) {
369
- if ((err as Error)?.name === "AbortError") {
370
- logWarn("Catalog refresh timed out — using cached/static fallback", {
371
- timeoutMs: CATALOG_REFRESH_TIMEOUT_MS,
372
- });
373
- } else {
374
- logDebug("Conditional catalog fetch failed, falling back to cache", { error: String(err) });
375
- }
376
- }
377
- }
378
-
379
- // Stale cache still better than empty — return it without network (filtered)
380
- if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
381
- const filtered = sanitizeCatalogModels(disk.models);
382
- if (filtered.length >= ALL_MODELS.length) {
383
- aliveCatalog = filtered;
384
- return aliveCatalog;
385
- }
386
- }
387
- // No valid fresh cache — try stale disk cache directly (readCatalogCache returns null when expired)
388
- try {
389
- if (fs.existsSync(CATALOG_CACHE_FILE)) {
390
- const raw = fs.readFileSync(CATALOG_CACHE_FILE, "utf8");
391
- const stale = JSON.parse(raw) as CatalogCacheData;
392
- if (Array.isArray(stale.models) && stale.models.length > 0) {
393
- const filtered = sanitizeCatalogModels(stale.models);
394
- if (filtered.length >= ALL_MODELS.length) {
395
- aliveCatalog = filtered;
396
- return aliveCatalog;
397
- }
398
- }
399
- }
400
- } catch (err) {
401
- logDebug("Failed reading stale catalog cache", { error: String(err) });
402
- }
403
- // No valid cache — return in-memory static 27 (host will refresh if needed)
404
- return aliveCatalog;
405
- }
1
+ /**
2
+ * Dynamic catalog discovery, caching, and model enrichment for pi-freeflow
3
+ *
4
+ * Provides 1-hour atomic disk caching with safe offline fallback to verified static definitions.
5
+ */
6
+
7
+ import { randomUUID } from "node:crypto";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
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,
17
+ } from "./config.ts";
18
+ import { log, logDebug, logWarn } from "./logger.ts";
19
+ import {
20
+ ALL_MODELS,
21
+ MODEL_MAP,
22
+ getModelUpstream,
23
+ } from "./models.ts";
24
+ import type {
25
+ CatalogCacheData,
26
+ RawModelItem,
27
+ RegisteredModel,
28
+ Upstream,
29
+ } from "./types.ts";
30
+
31
+ /**
32
+ * Pruned model IDs that must never re-enter the catalog via disk cache or upstream merge.
33
+ */
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",
44
+ ]);
45
+ /**
46
+ * Free-tier allowlist for anything entering the picker via network or stale disk.
47
+ * Upstream lists paid models alongside free ones (e.g. claude-fable-5-1,
48
+ * claude-opus-4-*, gemini-3-*) so a bare upstream merge leaks paid entries that
49
+ * fail with 401 Missing API key. Known static IDs without a free suffix
50
+ * (e.g. big-pickle) stay allowed via MODEL_MAP.
51
+ */
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);
56
+ }
57
+ /**
58
+ * Purge paid/dead entries from a catalog list and repair known models against
59
+ * the static definitions. Stale disk caches predate the paid filter and the
60
+ * muse-spark-1.3 responses-api entry, so loading them verbatim replays a wrong
61
+ * api (chat/completions for a responses-only model -> upstream 500) until the
62
+ * 24h TTL expires. Repairing here self-heals on the next refresh without a
63
+ * reinstall.
64
+ */
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;
77
+ }
78
+ /**
79
+ * In-memory cache of currently active/available free models.
80
+ * Initialized with all 31 verified models for 0ms instant availability.
81
+ */
82
+ let aliveCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
83
+ ...m,
84
+ source: getModelUpstream(m.id),
85
+ }));
86
+ /**
87
+ * Get current in-memory alive catalog
88
+ */
89
+ export function getAliveCatalog(): RegisteredModel[] {
90
+ return aliveCatalog;
91
+ }
92
+
93
+ /**
94
+ * Set in-memory alive catalog
95
+ */
96
+ export function setAliveCatalog(catalog: RegisteredModel[]): void {
97
+ aliveCatalog = catalog;
98
+ }
99
+
100
+ /**
101
+ * Overlay a refreshed model list onto a base list without dropping base entries.
102
+ * Fresh entries win on id collision; unknown fresh ids are appended after the base.
103
+ * Guards the background refresh against a partial upstream cache removing
104
+ * verified static models from provider registration.
105
+ * Filters pruned dead IDs so they never re-enter via fresh upstream data.
106
+ */
107
+ export function mergeCatalog(
108
+ base: RegisteredModel[],
109
+ fresh: RegisteredModel[],
110
+ ): 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()]);
117
+ }
118
+
119
+ /**
120
+ * Format a clean, human-readable display name for any upstream model ID.
121
+ */
122
+ 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
+ }
130
+
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, "");
136
+
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
+ });
148
+
149
+ return parts.join(" ");
150
+ }
151
+
152
+ /**
153
+ * Enrich a raw upstream model item into a fully typed RegisteredModel.
154
+ */
155
+ export function enrichModelDef(raw: RawModelItem, source: Upstream): RegisteredModel {
156
+ const known = MODEL_MAP.get(raw.id);
157
+ if (known) {
158
+ return { ...known, source };
159
+ }
160
+
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");
178
+
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
+ }
190
+
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
+ }
198
+
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";
203
+
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
+ };
215
+ }
216
+
217
+ /**
218
+ * Read cached catalog data from disk if valid and unexpired.
219
+ * Filters out pruned dead IDs so stale disk entries never repopulate the catalog.
220
+ * Preserves etag for conditional If-None-Match requests.
221
+ */
222
+ 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;
240
+ }
241
+
242
+ /**
243
+ * Atomically write catalog cache data to disk using temporary file + rename.
244
+ * Persists etag alongside models for subsequent If-None-Match conditional requests.
245
+ * Note: fsync not needed — rename is atomic on same filesystem; crash leaves either old or new file intact.
246
+ */
247
+ function sleepSync(ms: number): void {
248
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
249
+ }
250
+
251
+ 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
+ }
265
+ }
266
+
267
+ 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
+ }
279
+ }
280
+
281
+ /**
282
+ * Refresh free model catalog from OpenCode Zen and KiloCode Gateway endpoints.
283
+ * Uses ETag conditional requests (If-None-Match) to avoid re-merging unchanged
284
+ * catalogs; a 304 Not Modified response skips merge and returns the in-memory
285
+ * catalog unchanged. Falls back gracefully to cached or static models if network
286
+ * requests fail.
287
+ */
288
+ 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
+ }
298
+
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
+ }
314
+
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
+ }
383
+
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;
410
+ }