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/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/catalog.ts +294 -286
- package/src/cline-accounts.ts +112 -113
- package/src/cline-device-auth.ts +17 -8
- package/src/commands.ts +46 -18
- package/src/daemon.ts +86 -91
- package/src/index.ts +2 -7
- package/src/proxy.ts +55 -10
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
ALL_MODELS,
|
|
21
|
+
MODEL_MAP,
|
|
22
|
+
getModelUpstream,
|
|
23
23
|
} from "./models.ts";
|
|
24
24
|
import type {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
111
|
+
base: RegisteredModel[],
|
|
112
|
+
fresh: RegisteredModel[],
|
|
110
113
|
): RegisteredModel[] {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
159
|
+
const known = MODEL_MAP.get(raw.id);
|
|
160
|
+
if (known) {
|
|
161
|
+
return { ...known, source };
|
|
162
|
+
}
|
|
160
163
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|