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/CHANGELOG.md +20 -0
- package/README.md +4 -4
- package/package.json +1 -1
- package/src/catalog.ts +417 -418
- package/src/cline-accounts.ts +891 -634
- package/src/index.ts +356 -356
- package/src/models.ts +555 -563
- package/src/proxy.ts +49 -11
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
|
-
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* api
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
*
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
clean = clean.replace(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if (lower === "
|
|
145
|
-
if (lower === "
|
|
146
|
-
if (lower === "
|
|
147
|
-
if (lower === "
|
|
148
|
-
if (lower === "
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
idLower.includes("
|
|
168
|
-
idLower.includes("
|
|
169
|
-
idLower.includes("
|
|
170
|
-
idLower.includes("
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
idLower.includes("
|
|
174
|
-
idLower.includes("
|
|
175
|
-
idLower.includes("
|
|
176
|
-
idLower.includes("
|
|
177
|
-
idLower.includes("
|
|
178
|
-
idLower.includes("
|
|
179
|
-
idLower.includes("
|
|
180
|
-
idLower.includes("
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
idLower.includes("
|
|
187
|
-
idLower.includes("
|
|
188
|
-
idLower.includes("
|
|
189
|
-
idLower.includes("
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
let
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
+
}
|