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