@mmmbuto/nexuscrew 0.9.74 → 0.9.76
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 +17 -0
- package/frontend/dist/assets/{index-C_z6r985.css → index-B9GW2LHm.css} +1 -1
- package/frontend/dist/assets/index-BURtPwUi.js +169 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/fleet/builtin.js +211 -7
- package/lib/fleet/definitions.js +10 -1
- package/lib/fleet/managed.js +82 -8
- package/lib/fleet/model-discovery.js +1038 -0
- package/lib/fleet/routes.js +18 -1
- package/package.json +2 -2
- package/frontend/dist/assets/index-DRXfJssx.js +0 -169
|
@@ -0,0 +1,1038 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/fleet/model-discovery.js — per-engine dynamic model discovery (lots 1-2).
|
|
3
|
+
//
|
|
4
|
+
// One collector per endpoint kind (OpenAI-compatible, Anthropic, Ollama,
|
|
5
|
+
// llama-server, LM Studio), a per-engine disk cache with TTL under the
|
|
6
|
+
// NexusCrew home, and a field-by-field precedence merge. Lot 2 adds the public
|
|
7
|
+
// catalog (models.dev) as a third, key-less source: a snapshot with its own
|
|
8
|
+
// TTL and fallback, an explicit provider/model mapping, and the same record
|
|
9
|
+
// shape joining the merge below the provider API. The engine's endpoint
|
|
10
|
+
// and key are resolved by the CALLER (managed.js, through the same
|
|
11
|
+
// credentialSources/credential path the launcher uses) and handed to this
|
|
12
|
+
// module in memory only: the key never reaches a file, a log, a cache entry
|
|
13
|
+
// or an error string.
|
|
14
|
+
//
|
|
15
|
+
// Hard constraints encoded here:
|
|
16
|
+
// * llama-server: ONLY `GET /v1/models` plus a single `GET /props` for the
|
|
17
|
+
// resident model. A router with `--models-max 1` unloads the resident
|
|
18
|
+
// model on any per-model request, so this collector must never issue a
|
|
19
|
+
// per-model request (measured on the production router, 2026-10-18).
|
|
20
|
+
// * discovery PROPOSES, never fills: the merged list is capped at
|
|
21
|
+
// DISCOVERY_PROPOSAL_CAP. The number mirrors MAX_MODELS in
|
|
22
|
+
// definitions.js on purpose; it is not imported from there because
|
|
23
|
+
// definitions.js requires managed.js, which requires this module — the
|
|
24
|
+
// duplication is deliberate and pinned by tests.
|
|
25
|
+
// * a model absent from the API listing is never dropped: it keeps its
|
|
26
|
+
// user/backup record and is marked `inApi: false`; a model that no layer
|
|
27
|
+
// carries but the PREVIOUS cached listing knew stays too, as a bare id
|
|
28
|
+
// (the 'known' layer) — a model that was in the APIs never silently
|
|
29
|
+
// disappears from the proposal. When the API could
|
|
30
|
+
// not be reached at all (and no last-good value exists) `inApi` stays
|
|
31
|
+
// ABSENT: unknown is not false.
|
|
32
|
+
// * fallback chain on error/timeout: last good disk value first, then the
|
|
33
|
+
// backup catalog supplied by the caller.
|
|
34
|
+
|
|
35
|
+
const fs = require('node:fs');
|
|
36
|
+
const path = require('node:path');
|
|
37
|
+
const crypto = require('node:crypto');
|
|
38
|
+
|
|
39
|
+
const DISCOVERY_PROPOSAL_CAP = 128; // mirrors MAX_MODELS (definitions.js)
|
|
40
|
+
const DEFAULT_TIMEOUT_MS = 2500;
|
|
41
|
+
const HOSTED_TTL_MS = 12 * 60 * 60 * 1000;
|
|
42
|
+
const LOCAL_TTL_MS = 60 * 1000;
|
|
43
|
+
const CACHE_VERSION = 1;
|
|
44
|
+
const MAX_SHOW_CALLS = 32; // Ollama /api/show: only listed ids the caller asked about
|
|
45
|
+
const ANTHROPIC_MAX_PAGES = 5; // pagination safety bound
|
|
46
|
+
const ANTHROPIC_VERSION = '2023-06-01';
|
|
47
|
+
const MODEL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
|
48
|
+
const ENGINE_KEY_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
49
|
+
const MERGE_FIELDS = Object.freeze(['contextWindow', 'maxTokens', 'tools', 'vision', 'reasoning', 'deprecated']);
|
|
50
|
+
|
|
51
|
+
const COLLECTOR_KINDS = Object.freeze(['openai-compat', 'anthropic', 'ollama', 'llama-server', 'lmstudio']);
|
|
52
|
+
|
|
53
|
+
// Local endpoints change with every pull/start; hosted listings barely move
|
|
54
|
+
// in a day. The TTL follows the endpoint, not the caller.
|
|
55
|
+
function collectorDefaultTtlMs(collector) {
|
|
56
|
+
return collector === 'ollama' || collector === 'llama-server' || collector === 'lmstudio'
|
|
57
|
+
? LOCAL_TTL_MS : HOSTED_TTL_MS;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Sanitized failure: fixed reason strings only. The raw fetch error can embed
|
|
61
|
+
// the request URL; the key lives in headers — neither may surface here.
|
|
62
|
+
// The optional `attempt` object adds the FIXED facts of the request that
|
|
63
|
+
// failed (numeric HTTP status, error category, whether the credential header
|
|
64
|
+
// was applied). Internal diagnostics for the fallback chain: they ride
|
|
65
|
+
// DiscoveryError and the layer results, never a route response — the public
|
|
66
|
+
// face stays the sanitized `reason` above.
|
|
67
|
+
class DiscoveryError extends Error {
|
|
68
|
+
constructor(reason, attempt = null) {
|
|
69
|
+
super(`model discovery: ${reason}`);
|
|
70
|
+
this.name = 'DiscoveryError';
|
|
71
|
+
this.reason = reason;
|
|
72
|
+
if (attempt && typeof attempt === 'object') {
|
|
73
|
+
if (Number.isSafeInteger(attempt.status)) this.status = attempt.status;
|
|
74
|
+
if (typeof attempt.category === 'string') this.category = attempt.category;
|
|
75
|
+
if (typeof attempt.credentialSent === 'boolean') this.credentialSent = attempt.credentialSent;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function trimBase(baseUrl) {
|
|
81
|
+
const b = String(baseUrl || '').trim().replace(/\/+$/, '');
|
|
82
|
+
return /^https?:\/\//i.test(b) ? b : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function openAiStyleModelsUrl(base) {
|
|
86
|
+
const b = trimBase(base);
|
|
87
|
+
if (!b) return null;
|
|
88
|
+
return /\/v1$/.test(b) ? `${b}/models` : `${b}/v1/models`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function anthropicModelsUrl(base) {
|
|
92
|
+
const b = trimBase(base);
|
|
93
|
+
if (!b) return null;
|
|
94
|
+
return /\/v1$/.test(b) ? `${b}/models?limit=1000` : `${b}/v1/models?limit=1000`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function ollamaApiUrl(base, suffix) {
|
|
98
|
+
const b = trimBase(base);
|
|
99
|
+
if (!b) return null;
|
|
100
|
+
// ollama-cloud engines declare `https://ollama.com/v1`: the native API
|
|
101
|
+
// (`/api/tags`, `/api/show`) lives at the root, not under `/v1`.
|
|
102
|
+
return `${b.replace(/\/v1$/, '')}${suffix}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function llamaPropsUrl(base) {
|
|
106
|
+
const b = trimBase(base);
|
|
107
|
+
if (!b) return null;
|
|
108
|
+
return `${b.replace(/\/v1$/, '')}/props`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function lmstudioModelsUrl(base) {
|
|
112
|
+
const b = trimBase(base);
|
|
113
|
+
if (!b) return null;
|
|
114
|
+
return /\/api\/v\d+$/.test(b) ? `${b}/models` : `${b}/api/v0/models`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function httpJson(url, {
|
|
118
|
+
apiKey = '', authMode = 'bearer', method = 'GET', body, fetchImpl, timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
119
|
+
} = {}) {
|
|
120
|
+
// The deadline covers the WHOLE read: headers AND body. The timer stays
|
|
121
|
+
// armed until the response is parsed — a server that answers the headers
|
|
122
|
+
// and then stalls inside `json()` cannot hang the request with no expiry,
|
|
123
|
+
// and the abort while reading is reported as the same fixed timeout reason.
|
|
124
|
+
const controller = new AbortController();
|
|
125
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
126
|
+
const isAbort = (error) => !!(error && (error.name === 'AbortError' || error.code === 'ABORT_ERR'));
|
|
127
|
+
// Direct transport: the credential header is applied exactly when a key is
|
|
128
|
+
// valued (pinned by tests with a header-capturing fake fetch) — that is what
|
|
129
|
+
// `credentialSent` reports on every failure below.
|
|
130
|
+
const credentialSent = !!apiKey;
|
|
131
|
+
try {
|
|
132
|
+
const headers = { accept: 'application/json' };
|
|
133
|
+
if (body !== undefined) headers['content-type'] = 'application/json';
|
|
134
|
+
if (apiKey) {
|
|
135
|
+
if (authMode === 'x-api-key') {
|
|
136
|
+
headers['x-api-key'] = apiKey;
|
|
137
|
+
headers['anthropic-version'] = ANTHROPIC_VERSION;
|
|
138
|
+
} else {
|
|
139
|
+
headers.authorization = `Bearer ${apiKey}`;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
let response;
|
|
143
|
+
try {
|
|
144
|
+
response = await fetchImpl(url, {
|
|
145
|
+
method,
|
|
146
|
+
headers,
|
|
147
|
+
signal: controller.signal,
|
|
148
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
149
|
+
});
|
|
150
|
+
} catch (error) {
|
|
151
|
+
throw new DiscoveryError(isAbort(error) ? `timeout after ${timeoutMs}ms` : 'network error', {
|
|
152
|
+
category: isAbort(error) ? 'timeout' : 'network', credentialSent,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
// Minimal fakes (and some SDK shims) carry `ok` without `status`: honor
|
|
156
|
+
// `ok === true` as 200, mirroring the previous discoverOllamaModels check.
|
|
157
|
+
const okFlag = !!(response && typeof response === 'object' && response.ok === true);
|
|
158
|
+
const status = response && typeof response.status === 'number' ? response.status : (okFlag ? 200 : 0);
|
|
159
|
+
if (!(status >= 200 && status < 300)) throw new DiscoveryError(`HTTP ${status}`, { status, category: 'http', credentialSent });
|
|
160
|
+
let parsed;
|
|
161
|
+
try {
|
|
162
|
+
parsed = await response.json();
|
|
163
|
+
} catch (error) {
|
|
164
|
+
// The timer is still armed here: an abort while reading the body is the
|
|
165
|
+
// deadline expiring again, not a malformed payload.
|
|
166
|
+
throw new DiscoveryError(isAbort(error) ? `timeout after ${timeoutMs}ms` : 'invalid JSON response', {
|
|
167
|
+
category: isAbort(error) ? 'timeout' : 'invalid-response', credentialSent,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
171
|
+
throw new DiscoveryError('unexpected response shape', { category: 'invalid-response', credentialSent });
|
|
172
|
+
}
|
|
173
|
+
return parsed;
|
|
174
|
+
} finally {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// One normalized record per model. Fields the source does not provide stay
|
|
180
|
+
// ABSENT — no invented defaults, no guesses.
|
|
181
|
+
function normalizeModelRecord(raw) {
|
|
182
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
183
|
+
const id = typeof raw.id === 'string' ? raw.id : '';
|
|
184
|
+
if (!MODEL_ID_RE.test(id)) return null;
|
|
185
|
+
const out = { id };
|
|
186
|
+
for (const key of ['contextWindow', 'maxTokens']) {
|
|
187
|
+
if (raw[key] === undefined) continue;
|
|
188
|
+
if (Number.isSafeInteger(raw[key]) && raw[key] > 0) out[key] = raw[key];
|
|
189
|
+
}
|
|
190
|
+
for (const key of ['tools', 'vision', 'reasoning', 'deprecated']) {
|
|
191
|
+
if (raw[key] === undefined) continue;
|
|
192
|
+
if (typeof raw[key] === 'boolean') out[key] = raw[key];
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function collectIds(records, seen, out) {
|
|
198
|
+
for (const raw of records) {
|
|
199
|
+
const rec = normalizeModelRecord(raw);
|
|
200
|
+
if (!rec || seen.has(rec.id)) continue;
|
|
201
|
+
seen.add(rec.id);
|
|
202
|
+
out.push(rec);
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- Collectors -------------------------------------------------------------
|
|
208
|
+
// Each collector maps an endpoint kind to a list of normalized API records
|
|
209
|
+
// (`{ id, contextWindow?, maxTokens?, tools?, vision?, reasoning? }`).
|
|
210
|
+
|
|
211
|
+
const collectors = {
|
|
212
|
+
// OpenAI-compatible `GET /v1/models`: `{ data: [{ id, ... }] }`. The listing
|
|
213
|
+
// carries ids; context/pricing fields are provider-specific and usually
|
|
214
|
+
// absent — they stay absent.
|
|
215
|
+
'openai-compat': async ({ baseUrl, apiKey, fetchImpl, timeoutMs }) => {
|
|
216
|
+
const url = openAiStyleModelsUrl(baseUrl);
|
|
217
|
+
if (!url) throw new DiscoveryError('invalid base URL');
|
|
218
|
+
const body = await httpJson(url, { apiKey, fetchImpl, timeoutMs });
|
|
219
|
+
return collectIds(Array.isArray(body.data) ? body.data : [], new Set(), []);
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
// Anthropic `GET /v1/models` with x-api-key + anthropic-version and
|
|
223
|
+
// `after_id` pagination (limit <= 1000 per page, bounded pages).
|
|
224
|
+
'anthropic': async ({ baseUrl, apiKey, fetchImpl, timeoutMs }) => {
|
|
225
|
+
const base = anthropicModelsUrl(baseUrl);
|
|
226
|
+
if (!base) throw new DiscoveryError('invalid base URL');
|
|
227
|
+
const out = []; const seen = new Set();
|
|
228
|
+
let after = '';
|
|
229
|
+
for (let page = 0; page < ANTHROPIC_MAX_PAGES; page += 1) {
|
|
230
|
+
const url = after ? `${base}&after_id=${encodeURIComponent(after)}` : base;
|
|
231
|
+
const body = await httpJson(url, { apiKey, authMode: 'x-api-key', fetchImpl, timeoutMs });
|
|
232
|
+
collectIds(Array.isArray(body.data) ? body.data : [], seen, out);
|
|
233
|
+
if (body.has_more !== true || typeof body.last_id !== 'string' || !body.last_id) break;
|
|
234
|
+
after = body.last_id;
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
// Ollama `GET /api/tags` (+ Bearer key on cloud). `POST /api/show` runs ONLY
|
|
240
|
+
// for listed ids the caller explicitly asked details for (`detailIds`) and
|
|
241
|
+
// only to read what the listing cannot give (context length, capabilities).
|
|
242
|
+
'ollama': async ({ baseUrl, apiKey, fetchImpl, timeoutMs, detailIds }) => {
|
|
243
|
+
const tagsUrl = ollamaApiUrl(baseUrl, '/api/tags');
|
|
244
|
+
if (!tagsUrl) throw new DiscoveryError('invalid base URL');
|
|
245
|
+
const tags = await httpJson(tagsUrl, { apiKey, fetchImpl, timeoutMs });
|
|
246
|
+
const listed = collectIds(Array.isArray(tags.models) ? tags.models.map((m) => ({
|
|
247
|
+
id: typeof m?.name === 'string' && m.name ? m.name : m?.model,
|
|
248
|
+
})) : [], new Set(), []);
|
|
249
|
+
const wanted = new Set(Array.isArray(detailIds) ? detailIds.filter((id) => typeof id === 'string') : []);
|
|
250
|
+
if (!wanted.size) return listed;
|
|
251
|
+
const showUrl = ollamaApiUrl(baseUrl, '/api/show');
|
|
252
|
+
let calls = 0;
|
|
253
|
+
for (const rec of listed) {
|
|
254
|
+
if (calls >= MAX_SHOW_CALLS) break;
|
|
255
|
+
if (!wanted.has(rec.id)) continue;
|
|
256
|
+
calls += 1;
|
|
257
|
+
try {
|
|
258
|
+
const show = await httpJson(showUrl, {
|
|
259
|
+
method: 'POST', body: { name: rec.id }, apiKey, fetchImpl, timeoutMs,
|
|
260
|
+
});
|
|
261
|
+
for (const [key, value] of Object.entries(show.model_info || {})) {
|
|
262
|
+
if (/\.context_length$/.test(key) && Number.isSafeInteger(value) && value > 0) {
|
|
263
|
+
if (rec.contextWindow === undefined) rec.contextWindow = value;
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const caps = Array.isArray(show.capabilities) ? show.capabilities : [];
|
|
268
|
+
if (caps.includes('tools')) rec.tools = true;
|
|
269
|
+
if (caps.includes('vision')) rec.vision = true;
|
|
270
|
+
if (caps.includes('thinking') || show.thinking === true) rec.reasoning = true;
|
|
271
|
+
} catch (_) {
|
|
272
|
+
// A failed per-model detail is not a failed discovery: the id stays
|
|
273
|
+
// listed with the fields the listing provides.
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return listed;
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
// llama-server `GET /v1/models` + ONE `GET /props` (resident model only).
|
|
280
|
+
// NEVER a per-model request: on a `--models-max 1` router any per-model
|
|
281
|
+
// request unloads the resident model. `/props` describes whichever model is
|
|
282
|
+
// resident; it enriches at most the matching record and its failure is
|
|
283
|
+
// non-fatal (an idle router still lists its presets).
|
|
284
|
+
'llama-server': async ({ baseUrl, apiKey, fetchImpl, timeoutMs }) => {
|
|
285
|
+
const modelsUrl = openAiStyleModelsUrl(baseUrl);
|
|
286
|
+
if (!modelsUrl) throw new DiscoveryError('invalid base URL');
|
|
287
|
+
const body = await httpJson(modelsUrl, { apiKey, fetchImpl, timeoutMs });
|
|
288
|
+
const listed = collectIds(Array.isArray(body.data) ? body.data : [], new Set(), []);
|
|
289
|
+
try {
|
|
290
|
+
const props = await httpJson(llamaPropsUrl(baseUrl), { apiKey, fetchImpl, timeoutMs });
|
|
291
|
+
const alias = typeof props.model_alias === 'string' ? props.model_alias : '';
|
|
292
|
+
const target = alias ? listed.find((r) => r.id === alias) : null;
|
|
293
|
+
if (target) {
|
|
294
|
+
if (Number.isSafeInteger(props.n_ctx) && props.n_ctx > 0) target.contextWindow = props.n_ctx;
|
|
295
|
+
if (props.modalities && props.modalities.vision === true) target.vision = true;
|
|
296
|
+
const caps = props.chat_template_caps;
|
|
297
|
+
if (caps && typeof caps === 'object' && caps.tools === true) target.tools = true;
|
|
298
|
+
}
|
|
299
|
+
} catch (_) { /* no resident / props unavailable: the listing stands */ }
|
|
300
|
+
return listed;
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
// LM Studio `GET /api/v0/models`: `{ data: [{ id, max_context_length, ... }] }`.
|
|
304
|
+
'lmstudio': async ({ baseUrl, apiKey, fetchImpl, timeoutMs }) => {
|
|
305
|
+
const url = lmstudioModelsUrl(baseUrl);
|
|
306
|
+
if (!url) throw new DiscoveryError('invalid base URL');
|
|
307
|
+
const body = await httpJson(url, { apiKey, fetchImpl, timeoutMs });
|
|
308
|
+
const items = Array.isArray(body.data) ? body.data : [];
|
|
309
|
+
return collectIds(items.map((m) => ({
|
|
310
|
+
id: m?.id, contextWindow: m?.max_context_length,
|
|
311
|
+
})), new Set(), []);
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// --- Disk cache -------------------------------------------------------------
|
|
316
|
+
// <home>/.nexuscrew/model-discovery/<engine>.json, mode 600, atomic write.
|
|
317
|
+
// Only SUCCESSFUL API results are cached; failures never poison the file, and
|
|
318
|
+
// a stale-but-valid file is the "last good value" the fallback chain serves.
|
|
319
|
+
// The cache key is the DECLARED engine id: two engines sharing one profile
|
|
320
|
+
// (two custom engines of the same client) must never share a listing. The
|
|
321
|
+
// record also carries an endpoint FINGERPRINT — a truncated sha256 of the
|
|
322
|
+
// normalized base URL — so a listing fetched from another endpoint is never
|
|
323
|
+
// served (nor kept as last-good) after the engine's endpoint changed. The
|
|
324
|
+
// fingerprint covers the URL only: the API key, or anything derived from it,
|
|
325
|
+
// must never reach the file. Records without a fingerprint (older layout)
|
|
326
|
+
// are simply ignored: no error, no forced deletion.
|
|
327
|
+
|
|
328
|
+
function cacheDirFor(home) { return path.join(home, '.nexuscrew', 'model-discovery'); }
|
|
329
|
+
|
|
330
|
+
function safeEngineKey(engine) {
|
|
331
|
+
const e = String(engine || '');
|
|
332
|
+
return ENGINE_KEY_RE.test(e) ? e : crypto.createHash('sha256').update(e).digest('hex').slice(0, 16);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function cachePathFor(home, engine) { return path.join(cacheDirFor(home), `${safeEngineKey(engine)}.json`); }
|
|
336
|
+
|
|
337
|
+
function endpointFingerprint(baseUrl) {
|
|
338
|
+
const base = trimBase(baseUrl) || '';
|
|
339
|
+
return crypto.createHash('sha256').update(base).digest('hex').slice(0, 16);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function readCache(home, engine, endpoint) {
|
|
343
|
+
try {
|
|
344
|
+
const parsed = JSON.parse(fs.readFileSync(cachePathFor(home, engine), 'utf8'));
|
|
345
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
346
|
+
if (parsed.version !== CACHE_VERSION || parsed.engine !== String(engine)) return null;
|
|
347
|
+
if (!Array.isArray(parsed.models) || !Number.isSafeInteger(parsed.fetchedAt)) return null;
|
|
348
|
+
if (parsed.endpoint !== endpoint) return null;
|
|
349
|
+
// A successful EMPTY listing is a valid record: a provider that dropped
|
|
350
|
+
// every model must not fall back to the previous one forever.
|
|
351
|
+
const models = collectIds(parsed.models, new Set(), []);
|
|
352
|
+
return { fetchedAt: parsed.fetchedAt, collector: typeof parsed.collector === 'string' ? parsed.collector : '', models };
|
|
353
|
+
} catch (_) { return null; }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function writeCache(home, engine, collector, fetchedAt, models, endpoint) {
|
|
357
|
+
const dir = cacheDirFor(home);
|
|
358
|
+
try {
|
|
359
|
+
const st = fs.lstatSync(dir);
|
|
360
|
+
if (st.isSymbolicLink() || !st.isDirectory()) throw new Error('unsafe model-discovery directory');
|
|
361
|
+
} catch (e) {
|
|
362
|
+
if (e.code === 'ENOENT') fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
363
|
+
else throw e;
|
|
364
|
+
}
|
|
365
|
+
fs.chmodSync(dir, 0o700);
|
|
366
|
+
const target = cachePathFor(home, engine);
|
|
367
|
+
const tmp = path.join(dir, `.${safeEngineKey(engine)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
368
|
+
const payload = JSON.stringify({ version: CACHE_VERSION, engine: String(engine), collector, fetchedAt, models, endpoint });
|
|
369
|
+
try {
|
|
370
|
+
fs.writeFileSync(tmp, payload, { mode: 0o600 });
|
|
371
|
+
fs.chmodSync(tmp, 0o600);
|
|
372
|
+
fs.renameSync(tmp, target);
|
|
373
|
+
} catch (e) {
|
|
374
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
375
|
+
throw e;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// --- Public catalog snapshot (lot 2: models.dev) -----------------------------
|
|
380
|
+
// Third source: a public, key-less catalog. NEVER mandatory: on failure the
|
|
381
|
+
// last good snapshot serves, then nothing — an engine keeps working on its
|
|
382
|
+
// user/api/backup layers either way. The snapshot is large (~6 MB), so the TTL
|
|
383
|
+
// is one day, the default timeout is generous, and a fresh read is memoized
|
|
384
|
+
// in-process so per-engine lookups do not reparse the file every time.
|
|
385
|
+
|
|
386
|
+
const PUBLIC_CATALOG_URL = 'https://models.dev/api.json';
|
|
387
|
+
const PUBLIC_CATALOG_TTL_MS = 24 * 60 * 60 * 1000;
|
|
388
|
+
const PUBLIC_CATALOG_TIMEOUT_MS = 10000;
|
|
389
|
+
const PUBLIC_CATALOG_CACHE_FILE = 'public-catalog.json';
|
|
390
|
+
|
|
391
|
+
// Shape: `{ "<provider>": { id, name, models: { "<model>": { … } } } }`. Model
|
|
392
|
+
// entries are validated leniently at LOOKUP time (absent fields stay absent);
|
|
393
|
+
// here only the provider level must hold.
|
|
394
|
+
function validateCatalogSnapshot(parsed) {
|
|
395
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
|
|
396
|
+
let withModels = 0;
|
|
397
|
+
for (const provider of Object.values(parsed)) {
|
|
398
|
+
if (!provider || typeof provider !== 'object' || Array.isArray(provider)) return false;
|
|
399
|
+
if (provider.models && typeof provider.models === 'object' && !Array.isArray(provider.models)) withModels += 1;
|
|
400
|
+
}
|
|
401
|
+
return withModels > 0;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function readPublicCatalogCache(home) {
|
|
405
|
+
try {
|
|
406
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(cacheDirFor(home), PUBLIC_CATALOG_CACHE_FILE), 'utf8'));
|
|
407
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
408
|
+
if (parsed.version !== CACHE_VERSION || parsed.kind !== 'public-catalog') return null;
|
|
409
|
+
if (!Number.isSafeInteger(parsed.fetchedAt) || !validateCatalogSnapshot(parsed.providers)) return null;
|
|
410
|
+
return { fetchedAt: parsed.fetchedAt, catalog: parsed.providers };
|
|
411
|
+
} catch (_) { return null; }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function writePublicCatalogCache(home, fetchedAt, catalog) {
|
|
415
|
+
const dir = cacheDirFor(home);
|
|
416
|
+
try {
|
|
417
|
+
const st = fs.lstatSync(dir);
|
|
418
|
+
if (st.isSymbolicLink() || !st.isDirectory()) throw new Error('unsafe model-discovery directory');
|
|
419
|
+
} catch (e) {
|
|
420
|
+
if (e.code === 'ENOENT') fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
421
|
+
else throw e;
|
|
422
|
+
}
|
|
423
|
+
fs.chmodSync(dir, 0o700);
|
|
424
|
+
const target = path.join(dir, PUBLIC_CATALOG_CACHE_FILE);
|
|
425
|
+
const tmp = path.join(dir, `.public-catalog.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
426
|
+
const payload = JSON.stringify({ version: CACHE_VERSION, kind: 'public-catalog', fetchedAt, providers: catalog });
|
|
427
|
+
try {
|
|
428
|
+
fs.writeFileSync(tmp, payload, { mode: 0o600 });
|
|
429
|
+
fs.chmodSync(tmp, 0o600);
|
|
430
|
+
fs.renameSync(tmp, target);
|
|
431
|
+
} catch (e) {
|
|
432
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
433
|
+
throw e;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// --- Public catalog mapping --------------------------------------------------
|
|
438
|
+
// Engine provider -> catalog provider id, as an explicit, TESTED table: no
|
|
439
|
+
// inference. The mapping decides the FIRST lookup namespace (the most
|
|
440
|
+
// pertinent one for that endpoint); every provider — local runtimes, custom
|
|
441
|
+
// gateways on unknown hosts included — can still be enriched by the by-id
|
|
442
|
+
// fallback below, which never guesses a namespace: it matches ids only.
|
|
443
|
+
|
|
444
|
+
const PUBLIC_CATALOG_PROVIDER_IDS = Object.freeze({
|
|
445
|
+
zai: 'zai',
|
|
446
|
+
'zai-a': 'zai',
|
|
447
|
+
'zai-p': 'zai',
|
|
448
|
+
'ollama-cloud': 'ollama-cloud',
|
|
449
|
+
'opencode-go': 'opencode-go',
|
|
450
|
+
anthropic: 'anthropic',
|
|
451
|
+
openai: 'openai',
|
|
452
|
+
'openai-api': 'openai',
|
|
453
|
+
'openai-codex': 'openai',
|
|
454
|
+
deepseek: 'deepseek',
|
|
455
|
+
openrouter: 'openrouter',
|
|
456
|
+
nvidia: 'nvidia',
|
|
457
|
+
xai: 'xai',
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// Custom engines carry no provider type: an EXACT host match is the only
|
|
461
|
+
// fallback. No suffix or substring matching — a lookalike host maps to nothing.
|
|
462
|
+
const PUBLIC_CATALOG_HOSTS = Object.freeze({
|
|
463
|
+
'api.z.ai': 'zai',
|
|
464
|
+
'opencode.ai': 'opencode-go',
|
|
465
|
+
'ollama.com': 'ollama-cloud',
|
|
466
|
+
'api.anthropic.com': 'anthropic',
|
|
467
|
+
'api.openai.com': 'openai',
|
|
468
|
+
'openrouter.ai': 'openrouter',
|
|
469
|
+
'api.deepseek.com': 'deepseek',
|
|
470
|
+
'api.x.ai': 'xai',
|
|
471
|
+
'integrate.api.nvidia.com': 'nvidia',
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
function publicCatalogProviderId({ provider, baseUrl } = {}) {
|
|
475
|
+
const type = PUBLIC_CATALOG_PROVIDER_IDS[String(provider || '')];
|
|
476
|
+
if (type) return type;
|
|
477
|
+
const base = trimBase(baseUrl);
|
|
478
|
+
if (!base) return null;
|
|
479
|
+
let host;
|
|
480
|
+
try { host = new URL(base).hostname.toLowerCase(); } catch (_) { return null; }
|
|
481
|
+
return PUBLIC_CATALOG_HOSTS[host] || null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// --- Public catalog by-id fallback --------------------------------------------
|
|
485
|
+
// Resolution per model id, in tiers. A tier's candidates are ALL the entries
|
|
486
|
+
// of that tier — never the first in Object.keys order — and the first tier
|
|
487
|
+
// (strongest first) that has candidates wins: weaker tiers are never
|
|
488
|
+
// consulted afterwards, so a conflict among the winning tier's candidates
|
|
489
|
+
// cannot be dodged by descending, and the snapshot's provider/key order
|
|
490
|
+
// cannot change the outcome.
|
|
491
|
+
//
|
|
492
|
+
// 1. mapped: the namespace of the engine's EXPLICIT provider/host mapping.
|
|
493
|
+
// A field present here is never replaced by global consensus.
|
|
494
|
+
// 2. vendor: the namespace of a vendor prefix recognized by the explicit
|
|
495
|
+
// table below, with the prefix removed for this resolution only. Any
|
|
496
|
+
// other first segment is NOT a vendor and is never stripped.
|
|
497
|
+
// 3. global exact → 4. global case-folded → 5. global ":cloud"/"-cloud"
|
|
498
|
+
// suffix stripped. Tags, versions, quantizations and "[1m]" markers are
|
|
499
|
+
// never removed; the row keeps its original API id.
|
|
500
|
+
//
|
|
501
|
+
// Within the winning tier the agreement is per normalized field: a value
|
|
502
|
+
// enters when at least one valid value exists and all valid values are equal;
|
|
503
|
+
// absent/invalid values abstain (they are neither false nor zero), `false` is
|
|
504
|
+
// a value, and a disagreement leaves the field absent (counted, bounded).
|
|
505
|
+
|
|
506
|
+
// Vendor prefixes recognized for the namespace tier. Explicit and tested: a
|
|
507
|
+
// first segment not in this table is not a vendor, and is never stripped. The
|
|
508
|
+
// values are catalog provider ids, mirroring PUBLIC_CATALOG_PROVIDER_IDS.
|
|
509
|
+
const PUBLIC_CATALOG_VENDOR_IDS = Object.freeze({
|
|
510
|
+
zai: 'zai',
|
|
511
|
+
'z-ai': 'zai',
|
|
512
|
+
anthropic: 'anthropic',
|
|
513
|
+
openai: 'openai',
|
|
514
|
+
deepseek: 'deepseek',
|
|
515
|
+
openrouter: 'openrouter',
|
|
516
|
+
nvidia: 'nvidia',
|
|
517
|
+
xai: 'xai',
|
|
518
|
+
'ollama-cloud': 'ollama-cloud',
|
|
519
|
+
'opencode-go': 'opencode-go',
|
|
520
|
+
});
|
|
521
|
+
const PUBLIC_CATALOG_VENDOR_IDS_FOLDED = new Map(
|
|
522
|
+
Object.entries(PUBLIC_CATALOG_VENDOR_IDS).map(([k, v]) => [k.toLowerCase(), v])
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
function publicCatalogVendorProviderId(prefix) {
|
|
526
|
+
const p = String(prefix || '');
|
|
527
|
+
return PUBLIC_CATALOG_VENDOR_IDS[p] || PUBLIC_CATALOG_VENDOR_IDS_FOLDED.get(p.toLowerCase()) || null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// `z-ai/glm-5.3` -> { providerId: 'zai', rest: 'glm-5.3' }. Only the FIRST
|
|
531
|
+
// segment is considered, and only when the explicit table recognizes it.
|
|
532
|
+
function publicCatalogVendorSplit(apiId) {
|
|
533
|
+
const id = String(apiId || '');
|
|
534
|
+
const slash = id.indexOf('/');
|
|
535
|
+
if (slash <= 0 || slash >= id.length - 1) return null;
|
|
536
|
+
const providerId = publicCatalogVendorProviderId(id.slice(0, slash));
|
|
537
|
+
return providerId ? { providerId, rest: id.slice(slash + 1) } : null;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function stripCloudSuffix(id) { return id.replace(/(?::cloud|-cloud)$/, ''); }
|
|
541
|
+
|
|
542
|
+
// Index over one snapshot object, reused across rows and engines, never
|
|
543
|
+
// persisted: a plain WeakMap keyed by the catalog object itself, so each
|
|
544
|
+
// actual snapshot (including one read with noCache) has its own index and a
|
|
545
|
+
// dropped snapshot drops its index with it. Built once per snapshot, not per
|
|
546
|
+
// row.
|
|
547
|
+
const publicCatalogIndexes = new WeakMap();
|
|
548
|
+
|
|
549
|
+
function publicCatalogIndex(catalog) {
|
|
550
|
+
if (!catalog || typeof catalog !== 'object' || Array.isArray(catalog)) return null;
|
|
551
|
+
const cached = publicCatalogIndexes.get(catalog);
|
|
552
|
+
if (cached) return cached;
|
|
553
|
+
const providers = new Map(); // providerId -> { exact: Map(key->entry), folded: Map(lc->[entry]) }
|
|
554
|
+
const globalExact = new Map(); // key -> [entry]
|
|
555
|
+
const globalFolded = new Map(); // lc(key) -> [entry]
|
|
556
|
+
for (const [providerId, provider] of Object.entries(catalog)) {
|
|
557
|
+
if (!provider || typeof provider !== 'object' || Array.isArray(provider)) continue;
|
|
558
|
+
const models = provider.models && typeof provider.models === 'object' && !Array.isArray(provider.models)
|
|
559
|
+
? provider.models : null;
|
|
560
|
+
if (!models) continue;
|
|
561
|
+
const exact = new Map();
|
|
562
|
+
const folded = new Map();
|
|
563
|
+
for (const [key, model] of Object.entries(models)) {
|
|
564
|
+
if (!model || typeof model !== 'object' || Array.isArray(model)) continue;
|
|
565
|
+
const entry = { provider: providerId, key, fields: normalizePublicCatalogModel(key, model) };
|
|
566
|
+
exact.set(key, entry);
|
|
567
|
+
const lc = key.toLowerCase();
|
|
568
|
+
if (folded.has(lc)) folded.get(lc).push(entry); else folded.set(lc, [entry]);
|
|
569
|
+
if (globalExact.has(key)) globalExact.get(key).push(entry); else globalExact.set(key, [entry]);
|
|
570
|
+
if (globalFolded.has(lc)) globalFolded.get(lc).push(entry); else globalFolded.set(lc, [entry]);
|
|
571
|
+
}
|
|
572
|
+
providers.set(providerId, { exact, folded });
|
|
573
|
+
}
|
|
574
|
+
const index = { providers, globalExact, globalFolded };
|
|
575
|
+
publicCatalogIndexes.set(catalog, index);
|
|
576
|
+
return index;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// First non-empty tier (exact -> case-folded -> cloud-suffix stripped) within
|
|
580
|
+
// one provider namespace; ALL candidates of that tier, deduplicated.
|
|
581
|
+
function publicCatalogNamespaceCandidates(index, providerId, apiId) {
|
|
582
|
+
const ns = index.providers.get(providerId);
|
|
583
|
+
if (!ns) return [];
|
|
584
|
+
const id = String(apiId || '');
|
|
585
|
+
const out = [];
|
|
586
|
+
const seen = new Set();
|
|
587
|
+
const add = (entries) => {
|
|
588
|
+
for (const entry of entries || []) if (!seen.has(entry)) { seen.add(entry); out.push(entry); }
|
|
589
|
+
};
|
|
590
|
+
const exact = ns.exact.get(id);
|
|
591
|
+
if (exact) return [exact];
|
|
592
|
+
add(ns.folded.get(id.toLowerCase()));
|
|
593
|
+
if (out.length) return out;
|
|
594
|
+
const stripped = stripCloudSuffix(id);
|
|
595
|
+
if (stripped !== id) {
|
|
596
|
+
const sExact = ns.exact.get(stripped);
|
|
597
|
+
if (sExact) return [sExact];
|
|
598
|
+
add(ns.folded.get(stripped.toLowerCase()));
|
|
599
|
+
}
|
|
600
|
+
return out;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Same tier ladder across every provider of the snapshot.
|
|
604
|
+
function publicCatalogGlobalCandidates(index, apiId) {
|
|
605
|
+
const id = String(apiId || '');
|
|
606
|
+
const out = [];
|
|
607
|
+
const seen = new Set();
|
|
608
|
+
const add = (entries) => {
|
|
609
|
+
for (const entry of entries || []) if (!seen.has(entry)) { seen.add(entry); out.push(entry); }
|
|
610
|
+
};
|
|
611
|
+
const exact = index.globalExact.get(id);
|
|
612
|
+
if (exact) { add(exact); return out; }
|
|
613
|
+
add(index.globalFolded.get(id.toLowerCase()));
|
|
614
|
+
if (out.length) return out;
|
|
615
|
+
const stripped = stripCloudSuffix(id);
|
|
616
|
+
if (stripped !== id) {
|
|
617
|
+
const sExact = index.globalExact.get(stripped);
|
|
618
|
+
if (sExact) { add(sExact); return out; }
|
|
619
|
+
add(index.globalFolded.get(stripped.toLowerCase()));
|
|
620
|
+
}
|
|
621
|
+
return out;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Per-field agreement among one tier's candidates, restricted to `allowed`
|
|
625
|
+
// fields (null = all fields). Absent/invalid values abstain; a disagreement
|
|
626
|
+
// is a conflict and leaves the field absent.
|
|
627
|
+
function publicCatalogAgreement(entries, allowed) {
|
|
628
|
+
const values = {};
|
|
629
|
+
const conflictFields = [];
|
|
630
|
+
let noData = 0;
|
|
631
|
+
for (const field of MERGE_FIELDS) {
|
|
632
|
+
if (allowed && !allowed.has(field)) continue;
|
|
633
|
+
const vals = [];
|
|
634
|
+
for (const entry of entries) {
|
|
635
|
+
const v = entry.fields[field];
|
|
636
|
+
if (v !== undefined) vals.push(v);
|
|
637
|
+
}
|
|
638
|
+
if (!vals.length) { noData += 1; continue; }
|
|
639
|
+
if (vals.every((v) => v === vals[0])) values[field] = vals[0];
|
|
640
|
+
else conflictFields.push(field);
|
|
641
|
+
}
|
|
642
|
+
return { values, conflictFields, noData };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function resolvePublicCatalogRow(index, mappedProviderId, apiId) {
|
|
646
|
+
const fields = {};
|
|
647
|
+
const blocked = new Set();
|
|
648
|
+
// Tier 1: the mapped namespace of the engine's actual provider. Fields
|
|
649
|
+
// present here stay; a field this tier disagrees on is blocked — a weaker
|
|
650
|
+
// tier must not be used to dodge the conflict.
|
|
651
|
+
if (mappedProviderId) {
|
|
652
|
+
const entries = publicCatalogNamespaceCandidates(index, mappedProviderId, apiId);
|
|
653
|
+
if (entries.length) {
|
|
654
|
+
const agg = publicCatalogAgreement(entries, null);
|
|
655
|
+
Object.assign(fields, agg.values);
|
|
656
|
+
for (const f of agg.conflictFields) blocked.add(f);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
const allowed = new Set(MERGE_FIELDS.filter((f) => fields[f] === undefined && !blocked.has(f)));
|
|
660
|
+
// The mapped tier's conflicts are diagnostics too: a field blocked here is
|
|
661
|
+
// never consulted again, so counting it here and the fallback's own
|
|
662
|
+
// conflicts later cannot count the same field twice.
|
|
663
|
+
const stats = { matched: false, enriched: false, conflicts: blocked.size, noData: 0 };
|
|
664
|
+
if (!allowed.size) return { fields, stats };
|
|
665
|
+
// By-id ladder: a recognized vendor namespace first, then the global tiers.
|
|
666
|
+
// The first step with candidates wins; weaker steps are never consulted.
|
|
667
|
+
const split = publicCatalogVendorSplit(apiId);
|
|
668
|
+
let entries = split ? publicCatalogNamespaceCandidates(index, split.providerId, split.rest) : [];
|
|
669
|
+
if (!entries.length) entries = publicCatalogGlobalCandidates(index, apiId);
|
|
670
|
+
if (!entries.length) return { fields, stats };
|
|
671
|
+
stats.matched = true;
|
|
672
|
+
const agg = publicCatalogAgreement(entries, allowed);
|
|
673
|
+
Object.assign(fields, agg.values);
|
|
674
|
+
stats.conflicts = blocked.size + agg.conflictFields.length;
|
|
675
|
+
stats.noData = agg.noData;
|
|
676
|
+
stats.enriched = Object.keys(agg.values).length > 0;
|
|
677
|
+
return { fields, stats };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Records plus bounded by-id counters for a set of proposal ids. Records keep
|
|
681
|
+
// the API id unchanged, so they merge onto the engine's own listing; ids that
|
|
682
|
+
// resolve to nothing produce no record — the model keeps whatever the other
|
|
683
|
+
// layers say. The public layer never adds a model to the proposal.
|
|
684
|
+
function resolvePublicCatalogRecords(catalog, mappedProviderId, modelIds) {
|
|
685
|
+
const records = [];
|
|
686
|
+
const byId = { rows: 0, enriched: 0, conflicts: 0, noData: 0 };
|
|
687
|
+
const index = publicCatalogIndex(catalog);
|
|
688
|
+
if (!index) return { records, byId };
|
|
689
|
+
const seen = new Set();
|
|
690
|
+
for (const apiId of Array.isArray(modelIds) ? modelIds : []) {
|
|
691
|
+
if (typeof apiId !== 'string' || !apiId || seen.has(apiId)) continue;
|
|
692
|
+
seen.add(apiId);
|
|
693
|
+
const { fields, stats } = resolvePublicCatalogRow(index, mappedProviderId, apiId);
|
|
694
|
+
if (stats.matched) byId.rows += 1;
|
|
695
|
+
if (stats.enriched) byId.enriched += 1;
|
|
696
|
+
byId.conflicts += stats.conflicts;
|
|
697
|
+
byId.noData += stats.noData;
|
|
698
|
+
if (Object.keys(fields).length) records.push({ id: apiId, ...fields });
|
|
699
|
+
}
|
|
700
|
+
return { records, byId };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// One normalized record per MATCHING api id. The record keeps the API id, so
|
|
704
|
+
// it merges onto the engine's own listing; catalog fields map as:
|
|
705
|
+
// limit.context -> contextWindow, limit.output -> maxTokens, tool_call ->
|
|
706
|
+
// tools, modalities.input "image" -> vision, reasoning -> reasoning,
|
|
707
|
+
// status "deprecated" -> deprecated. Absent catalog fields stay absent.
|
|
708
|
+
function normalizePublicCatalogModel(id, m) {
|
|
709
|
+
const out = { id };
|
|
710
|
+
const limit = m.limit && typeof m.limit === 'object' && !Array.isArray(m.limit) ? m.limit : {};
|
|
711
|
+
if (Number.isSafeInteger(limit.context) && limit.context > 0) out.contextWindow = limit.context;
|
|
712
|
+
if (Number.isSafeInteger(limit.output) && limit.output > 0) out.maxTokens = limit.output;
|
|
713
|
+
if (typeof m.tool_call === 'boolean') out.tools = m.tool_call;
|
|
714
|
+
const input = m.modalities && Array.isArray(m.modalities.input) ? m.modalities.input : null;
|
|
715
|
+
if (input) out.vision = input.includes('image');
|
|
716
|
+
if (typeof m.reasoning === 'boolean') out.reasoning = m.reasoning;
|
|
717
|
+
if (m.status === 'deprecated') out.deprecated = true;
|
|
718
|
+
return out;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Backward-compatible shape: the resolved records alone.
|
|
722
|
+
function publicCatalogRecords(catalog, providerId, modelIds) {
|
|
723
|
+
return resolvePublicCatalogRecords(catalog, providerId, modelIds).records;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// --- Precedence merge -------------------------------------------------------
|
|
727
|
+
// Field-by-field, per the approved precedence order: user (fleet.json) > provider API >
|
|
728
|
+
// public catalog (models.dev snapshot, lot 2) > internal backup > known-gone.
|
|
729
|
+
// The user layer also fixes the ORDER: user-declared models come first, so the
|
|
730
|
+
// cap can never cut them out of the proposal. The last layer carries BARE IDS
|
|
731
|
+
// of models the API listed on a previous fetch and no longer lists: they carry
|
|
732
|
+
// no fields (nothing below the real layers may invent data), they only keep
|
|
733
|
+
// the row alive with `inApi: false` — a model that was there never silently
|
|
734
|
+
// disappears from the proposal.
|
|
735
|
+
|
|
736
|
+
function mergeModelRecords({
|
|
737
|
+
userModels = [], apiModels = [], publicCatalogModels = [], backupModels = [],
|
|
738
|
+
knownModels = [], apiKnown = true, fetchedAt = 0, cap = DISCOVERY_PROPOSAL_CAP,
|
|
739
|
+
} = {}) {
|
|
740
|
+
const layers = [
|
|
741
|
+
{ name: 'user', models: userModels },
|
|
742
|
+
{ name: 'api', models: apiModels },
|
|
743
|
+
{ name: 'public', models: publicCatalogModels },
|
|
744
|
+
{ name: 'backup', models: backupModels },
|
|
745
|
+
{ name: 'known', models: knownModels },
|
|
746
|
+
];
|
|
747
|
+
const merged = new Map(); // id -> { order, values, fieldSources, source }
|
|
748
|
+
const apiIds = new Set();
|
|
749
|
+
let order = 0;
|
|
750
|
+
for (const layer of layers) {
|
|
751
|
+
const records = collectIds(Array.isArray(layer.models) ? layer.models : [], new Set(), []);
|
|
752
|
+
if (layer.name === 'api') for (const r of records) apiIds.add(r.id);
|
|
753
|
+
for (const rec of records) {
|
|
754
|
+
let entry = merged.get(rec.id);
|
|
755
|
+
if (!entry) {
|
|
756
|
+
entry = { order: order++, values: {}, fieldSources: {}, source: layer.name };
|
|
757
|
+
merged.set(rec.id, entry);
|
|
758
|
+
}
|
|
759
|
+
for (const field of MERGE_FIELDS) {
|
|
760
|
+
if (rec[field] !== undefined && entry.values[field] === undefined) {
|
|
761
|
+
entry.values[field] = rec[field];
|
|
762
|
+
entry.fieldSources[field] = layer.name;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const effectiveCap = Number.isSafeInteger(cap) && cap > 0 ? cap : DISCOVERY_PROPOSAL_CAP;
|
|
768
|
+
return [...merged.entries()]
|
|
769
|
+
.sort((a, b) => a[1].order - b[1].order)
|
|
770
|
+
.slice(0, effectiveCap)
|
|
771
|
+
.map(([id, entry]) => ({
|
|
772
|
+
id,
|
|
773
|
+
...entry.values,
|
|
774
|
+
source: entry.source,
|
|
775
|
+
...(Object.keys(entry.fieldSources).length ? { fieldSources: entry.fieldSources } : {}),
|
|
776
|
+
...(apiKnown ? { inApi: apiIds.has(id) } : {}),
|
|
777
|
+
...(fetchedAt ? { fetchedAt } : {}),
|
|
778
|
+
}));
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// --- Discovery instance -----------------------------------------------------
|
|
782
|
+
|
|
783
|
+
function createModelDiscovery({ fetchImpl: defaultFetchImpl = globalThis.fetch, now = () => Date.now() } = {}) {
|
|
784
|
+
// Raw API layer with cache + fallback. `noCache: true` disattiva la cache in
|
|
785
|
+
// ENTRAMBE le direzioni: nessuna lettura, nessuna scrittura, e su errore la
|
|
786
|
+
// ricaduta e' il backup (vuoto), non l'ultimo valore buono.
|
|
787
|
+
// Returns:
|
|
788
|
+
// { status: 'api', fetchedAt, apiModels, stale: false, error: '' }
|
|
789
|
+
// { status: 'cache', fetchedAt, apiModels, stale: true|false, error } (last good value)
|
|
790
|
+
// { status: 'backup', fetchedAt: 0, apiModels: [], stale: false, error } (nothing known)
|
|
791
|
+
async function fetchEngineModels(opts = {}) {
|
|
792
|
+
const {
|
|
793
|
+
engine, collector, baseUrl, apiKey = '', home,
|
|
794
|
+
noCache = false, ttlMs, timeoutMs = DEFAULT_TIMEOUT_MS, detailIds,
|
|
795
|
+
fetchImpl = defaultFetchImpl,
|
|
796
|
+
} = opts;
|
|
797
|
+
if (!engine) throw new DiscoveryError('engine is required');
|
|
798
|
+
if (!COLLECTOR_KINDS.includes(collector)) throw new DiscoveryError(`unknown collector: ${collector}`);
|
|
799
|
+
if (typeof fetchImpl !== 'function') throw new DiscoveryError('no fetch implementation');
|
|
800
|
+
const ttl = ttlMs === undefined ? collectorDefaultTtlMs(collector) : ttlMs;
|
|
801
|
+
const at = now();
|
|
802
|
+
const endpoint = endpointFingerprint(baseUrl);
|
|
803
|
+
// `noCache` deve voler dire UNA cosa: nessuna cache. Non solo «salta il
|
|
804
|
+
// valore fresco», ma nemmeno LEGGE (per ricadere sull'ultimo buono) e
|
|
805
|
+
// nemmeno SCRIVE. Il caso che l'ha imposto: una fixture ha scritto la cache
|
|
806
|
+
// nella home vera dell'operatore; e con `noCache` attivo una lettura ferma
|
|
807
|
+
// non e' una risposta, e' un residuo di ieri.
|
|
808
|
+
// L'UNICA lettura che sopravvive a noCache e' il listing PRECEDENTE, e non
|
|
809
|
+
// come risposta: serve solo a calcolare quali id l'API ha tolto (il delta
|
|
810
|
+
// «prima c'era, ora no»). La risposta non esce mai dal file e il file non
|
|
811
|
+
// si scrive: e' storia, non cache attiva.
|
|
812
|
+
const previous = home ? readCache(home, engine, endpoint) : null;
|
|
813
|
+
const cached = !noCache ? previous : null;
|
|
814
|
+
if (!noCache && cached && at - cached.fetchedAt < ttl) {
|
|
815
|
+
return {
|
|
816
|
+
status: 'cache', fetchedAt: cached.fetchedAt, stale: false, error: '',
|
|
817
|
+
apiModels: cached.models.map((m) => ({ ...m })),
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
let apiModels;
|
|
821
|
+
try {
|
|
822
|
+
apiModels = await collectors[collector]({ baseUrl, apiKey, fetchImpl, timeoutMs, detailIds });
|
|
823
|
+
} catch (error) {
|
|
824
|
+
const reason = error instanceof DiscoveryError ? error.reason : 'discovery failed';
|
|
825
|
+
// Attempt metadata of the failed request rides the fallback so the
|
|
826
|
+
// caller can diagnose WHAT rejected the listing (fixed status/category,
|
|
827
|
+
// credential applied or not). Ephemeral only: nothing here is cached.
|
|
828
|
+
const failed = error instanceof DiscoveryError && typeof error.credentialSent === 'boolean'
|
|
829
|
+
? {
|
|
830
|
+
...(Number.isSafeInteger(error.status) ? { status: error.status } : {}),
|
|
831
|
+
...(typeof error.category === 'string' ? { category: error.category } : {}),
|
|
832
|
+
credentialSent: error.credentialSent,
|
|
833
|
+
}
|
|
834
|
+
: null;
|
|
835
|
+
if (cached) {
|
|
836
|
+
return {
|
|
837
|
+
status: 'cache', fetchedAt: cached.fetchedAt, stale: true, error: reason,
|
|
838
|
+
...(failed ? { attempt: failed } : {}),
|
|
839
|
+
apiModels: cached.models.map((m) => ({ ...m })),
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
return { status: 'backup', fetchedAt: 0, stale: false, error: reason, ...(failed ? { attempt: failed } : {}), apiModels: [] };
|
|
843
|
+
}
|
|
844
|
+
const fetchedAt = now();
|
|
845
|
+
// Il delta degli spariti si calcola SOLO su una lettura vera: la risposta
|
|
846
|
+
// fresca riuscita contro il listing precedente. Gli id che mancano restano
|
|
847
|
+
// fuori dalla cache appena scritta: il file conserva il listing come
|
|
848
|
+
// l'API l'ha dato, non la nostra storia.
|
|
849
|
+
const freshIds = new Set(apiModels.map((m) => m.id));
|
|
850
|
+
const disappeared = previous
|
|
851
|
+
? previous.models.filter((m) => !freshIds.has(m.id)).map((m) => m.id)
|
|
852
|
+
: [];
|
|
853
|
+
// The write follows the SUCCESS, not the size: an empty listing that the
|
|
854
|
+
// API answered with replaces the previous one. Failures never reach here.
|
|
855
|
+
if (!noCache && home) {
|
|
856
|
+
try { writeCache(home, engine, collector, fetchedAt, apiModels, endpoint); } catch (_) { /* cache is best-effort */ }
|
|
857
|
+
}
|
|
858
|
+
return {
|
|
859
|
+
status: 'api', fetchedAt, stale: false, error: '',
|
|
860
|
+
// The listing request went out: whether it carried the credential is
|
|
861
|
+
// what separates an anonymous success from an authenticated one.
|
|
862
|
+
attempt: { credentialSent: !!apiKey },
|
|
863
|
+
apiModels: apiModels.map((m) => ({ ...m })),
|
|
864
|
+
...(disappeared.length ? { disappeared } : {}),
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Public catalog snapshot with cache + fallback. Same contract shape as
|
|
869
|
+
// fetchEngineModels, minus any key — and the SAME noCache contract as the
|
|
870
|
+
// API layer: with `noCache` the snapshot is never served as the answer,
|
|
871
|
+
// never used as a ripiego on failure, and never written (disk or memo):
|
|
872
|
+
// { status: 'api', fetchedAt, stale: false, error: '', catalog }
|
|
873
|
+
// { status: 'cache', fetchedAt, stale: true|false, error, catalog } (last good)
|
|
874
|
+
// { status: 'none', fetchedAt: 0, stale: false, error, catalog: null }
|
|
875
|
+
let publicCatalogMemo = null; // { home, fetchedAt, catalog }: same-process reuse
|
|
876
|
+
async function fetchPublicCatalogSnapshot(opts = {}) {
|
|
877
|
+
const {
|
|
878
|
+
home, noCache = false, ttlMs = PUBLIC_CATALOG_TTL_MS,
|
|
879
|
+
timeoutMs = PUBLIC_CATALOG_TIMEOUT_MS, fetchImpl = defaultFetchImpl,
|
|
880
|
+
} = opts;
|
|
881
|
+
if (typeof fetchImpl !== 'function') throw new DiscoveryError('no fetch implementation');
|
|
882
|
+
const at = now();
|
|
883
|
+
const memoFresh = !noCache && publicCatalogMemo && publicCatalogMemo.home === home
|
|
884
|
+
&& at - publicCatalogMemo.fetchedAt < ttlMs;
|
|
885
|
+
if (memoFresh) {
|
|
886
|
+
return {
|
|
887
|
+
status: 'cache', fetchedAt: publicCatalogMemo.fetchedAt, stale: false, error: '',
|
|
888
|
+
catalog: publicCatalogMemo.catalog,
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
const cached = home ? readPublicCatalogCache(home) : null;
|
|
892
|
+
if (!noCache && cached && at - cached.fetchedAt < ttlMs) {
|
|
893
|
+
publicCatalogMemo = { home, fetchedAt: cached.fetchedAt, catalog: cached.catalog };
|
|
894
|
+
return {
|
|
895
|
+
status: 'cache', fetchedAt: cached.fetchedAt, stale: false, error: '',
|
|
896
|
+
catalog: cached.catalog,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
let catalog;
|
|
900
|
+
try {
|
|
901
|
+
// Public endpoint: no key, no auth header, fixed accept header only.
|
|
902
|
+
catalog = await httpJson(PUBLIC_CATALOG_URL, { fetchImpl, timeoutMs });
|
|
903
|
+
if (!validateCatalogSnapshot(catalog)) throw new DiscoveryError('unexpected response shape');
|
|
904
|
+
} catch (error) {
|
|
905
|
+
const reason = error instanceof DiscoveryError ? error.reason : 'catalog fetch failed';
|
|
906
|
+
if (cached && !noCache) {
|
|
907
|
+
publicCatalogMemo = { home, fetchedAt: cached.fetchedAt, catalog: cached.catalog };
|
|
908
|
+
return { status: 'cache', fetchedAt: cached.fetchedAt, stale: true, error: reason, catalog: cached.catalog };
|
|
909
|
+
}
|
|
910
|
+
return { status: 'none', fetchedAt: 0, stale: false, error: reason, catalog: null };
|
|
911
|
+
}
|
|
912
|
+
const fetchedAt = now();
|
|
913
|
+
// A forced read answers but leaves no trace: neither the memo nor the disk
|
|
914
|
+
// snapshot is written under noCache.
|
|
915
|
+
if (!noCache) publicCatalogMemo = { home, fetchedAt, catalog };
|
|
916
|
+
if (home && !noCache) {
|
|
917
|
+
try { writePublicCatalogCache(home, fetchedAt, catalog); } catch (_) { /* cache is best-effort */ }
|
|
918
|
+
}
|
|
919
|
+
return { status: 'api', fetchedAt, stale: false, error: '', catalog };
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Public layer for one engine: resolve the provider mapping, snapshot the
|
|
923
|
+
// catalog (its own TTL, cache and fallback), then enrich the ids the
|
|
924
|
+
// engine's proposal already carries — from the mapped namespace first, and
|
|
925
|
+
// for the fields still absent (or without a mapping at all) by id across
|
|
926
|
+
// the whole snapshot: custom gateways and local endpoints included. The
|
|
927
|
+
// public catalog COMPLETES fields; it never adds models to the proposal.
|
|
928
|
+
// Nothing about the engine's endpoint (model names, URLs, keys) is ever
|
|
929
|
+
// sent to the catalog: the fetch is the one fixed public snapshot URL,
|
|
930
|
+
// keyless. With no id to enrich the snapshot is not fetched at all.
|
|
931
|
+
async function resolvePublicCatalogLayer(opts, apiModels, userModels, backupModels, knownIds) {
|
|
932
|
+
const config = opts.publicCatalog && typeof opts.publicCatalog === 'object' ? opts.publicCatalog : {};
|
|
933
|
+
const providerId = publicCatalogProviderId(config);
|
|
934
|
+
const ids = [];
|
|
935
|
+
const seen = new Set();
|
|
936
|
+
const pushId = (id) => { if (id && !seen.has(id)) { seen.add(id); ids.push(id); } };
|
|
937
|
+
for (const list of [userModels, apiModels, backupModels]) {
|
|
938
|
+
for (const m of Array.isArray(list) ? list : []) {
|
|
939
|
+
pushId(m && typeof m.id === 'string' ? m.id : null);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
// Known-gone ids stay in the proposal as bare rows: they are enrichable
|
|
943
|
+
// like any other id already present.
|
|
944
|
+
for (const id of Array.isArray(knownIds) ? knownIds : []) {
|
|
945
|
+
pushId(typeof id === 'string' ? id : null);
|
|
946
|
+
}
|
|
947
|
+
if (!ids.length) {
|
|
948
|
+
return { providerId, records: [], summary: { providerId, status: 'skipped', records: 0 } };
|
|
949
|
+
}
|
|
950
|
+
// `noCache` is ONE contract on BOTH layers: a forced refresh that skips
|
|
951
|
+
// the engine cache skips the public snapshot too. The engine-level flag
|
|
952
|
+
// reaches the snapshot unless the publicCatalog config overrides it.
|
|
953
|
+
const noCache = config.noCache === true || opts.noCache === true;
|
|
954
|
+
const snapshot = await fetchPublicCatalogSnapshot({
|
|
955
|
+
home: opts.home,
|
|
956
|
+
...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
|
|
957
|
+
...(config.timeoutMs !== undefined ? { timeoutMs: config.timeoutMs } : {}),
|
|
958
|
+
...(config.ttlMs !== undefined ? { ttlMs: config.ttlMs } : {}),
|
|
959
|
+
...(noCache ? { noCache: true } : {}),
|
|
960
|
+
});
|
|
961
|
+
if (!snapshot.catalog) {
|
|
962
|
+
return {
|
|
963
|
+
providerId, records: [],
|
|
964
|
+
summary: { providerId, status: snapshot.status, error: snapshot.error, records: 0 },
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
const { records, byId } = resolvePublicCatalogRecords(snapshot.catalog, providerId, ids);
|
|
968
|
+
return {
|
|
969
|
+
providerId, records,
|
|
970
|
+
summary: {
|
|
971
|
+
providerId, status: snapshot.status, fetchedAt: snapshot.fetchedAt,
|
|
972
|
+
stale: snapshot.stale, error: snapshot.error, records: records.length,
|
|
973
|
+
// Additive, bounded by-id counters (consumed by the view layer):
|
|
974
|
+
// rows matched by id, rows that received at least one field, fields
|
|
975
|
+
// blocked by disagreement, fields with no data among the candidates.
|
|
976
|
+
byId: { rows: byId.rows, enriched: byId.enriched, conflicts: byId.conflicts, noData: byId.noData },
|
|
977
|
+
},
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Full engine view: API layer + user/backup layers merged per field, capped,
|
|
982
|
+
// with `inApi` marked only when the API (or its last good value) actually
|
|
983
|
+
// answered. The public catalog layer joins the merge when `publicCatalog`
|
|
984
|
+
// describes the engine's provider (or when `publicCatalogModels` is supplied
|
|
985
|
+
// pre-built, e.g. by tests and later UI lots).
|
|
986
|
+
async function discoverEngine(opts = {}) {
|
|
987
|
+
const {
|
|
988
|
+
userModels = [], backupModels = [], publicCatalogModels, publicCatalog,
|
|
989
|
+
cap = DISCOVERY_PROPOSAL_CAP,
|
|
990
|
+
} = opts;
|
|
991
|
+
const layer = await fetchEngineModels(opts);
|
|
992
|
+
let publicModels = Array.isArray(publicCatalogModels) ? publicCatalogModels : [];
|
|
993
|
+
let publicLayer = null;
|
|
994
|
+
if (!Array.isArray(publicCatalogModels) && publicCatalog) {
|
|
995
|
+
publicLayer = await resolvePublicCatalogLayer(
|
|
996
|
+
opts, layer.apiModels, userModels, backupModels, layer.disappeared || []
|
|
997
|
+
);
|
|
998
|
+
publicModels = publicLayer.records;
|
|
999
|
+
}
|
|
1000
|
+
const apiKnown = layer.status !== 'backup';
|
|
1001
|
+
const models = mergeModelRecords({
|
|
1002
|
+
userModels,
|
|
1003
|
+
apiModels: layer.apiModels,
|
|
1004
|
+
publicCatalogModels: publicModels,
|
|
1005
|
+
backupModels,
|
|
1006
|
+
knownModels: (layer.disappeared || []).map((id) => ({ id })),
|
|
1007
|
+
apiKnown,
|
|
1008
|
+
fetchedAt: layer.fetchedAt || now(),
|
|
1009
|
+
cap,
|
|
1010
|
+
});
|
|
1011
|
+
return {
|
|
1012
|
+
...layer,
|
|
1013
|
+
source: layer.status,
|
|
1014
|
+
models,
|
|
1015
|
+
...(publicLayer ? { publicCatalog: publicLayer.summary } : {}),
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
return { fetchEngineModels, discoverEngine, fetchPublicCatalogSnapshot, limits: { cap: DISCOVERY_PROPOSAL_CAP } };
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
module.exports = {
|
|
1023
|
+
COLLECTOR_KINDS,
|
|
1024
|
+
DiscoveryError,
|
|
1025
|
+
createModelDiscovery,
|
|
1026
|
+
mergeModelRecords,
|
|
1027
|
+
normalizeModelRecord,
|
|
1028
|
+
collectorDefaultTtlMs,
|
|
1029
|
+
PUBLIC_CATALOG_URL,
|
|
1030
|
+
PUBLIC_CATALOG_TTL_MS,
|
|
1031
|
+
publicCatalogProviderId,
|
|
1032
|
+
publicCatalogVendorProviderId,
|
|
1033
|
+
publicCatalogVendorSplit,
|
|
1034
|
+
resolvePublicCatalogRecords,
|
|
1035
|
+
publicCatalogRecords,
|
|
1036
|
+
DISCOVERY_PROPOSAL_CAP,
|
|
1037
|
+
DEFAULT_TIMEOUT_MS,
|
|
1038
|
+
};
|