llm-checker 3.5.15 → 3.7.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.
Files changed (39) hide show
  1. package/README.md +28 -8
  2. package/analyzer/compatibility.js +5 -0
  3. package/analyzer/performance.js +5 -4
  4. package/bin/cli.js +5 -39
  5. package/bin/enhanced_cli.js +449 -24
  6. package/bin/mcp-server.mjs +266 -101
  7. package/package.json +13 -8
  8. package/src/ai/multi-objective-selector.js +118 -11
  9. package/src/calibration/calibration-manager.js +4 -1
  10. package/src/data/model-database.js +489 -5
  11. package/src/data/registry-ingestors.js +751 -0
  12. package/src/data/registry-recommender.js +514 -0
  13. package/src/data/seed/README.md +11 -3
  14. package/src/data/seed/models.db +0 -0
  15. package/src/data/sync-manager.js +32 -18
  16. package/src/hardware/backends/apple-silicon.js +5 -1
  17. package/src/hardware/backends/cuda-detector.js +47 -19
  18. package/src/hardware/backends/intel-detector.js +6 -2
  19. package/src/hardware/backends/rocm-detector.js +6 -2
  20. package/src/hardware/detector.js +57 -30
  21. package/src/hardware/unified-detector.js +129 -25
  22. package/src/index.js +68 -4
  23. package/src/models/ai-check-selector.js +36 -5
  24. package/src/models/deterministic-selector.js +179 -18
  25. package/src/models/expanded_database.js +9 -5
  26. package/src/models/intelligent-selector.js +87 -1
  27. package/src/models/moe-assumptions.js +11 -0
  28. package/src/models/requirements.js +16 -11
  29. package/src/models/scoring-core.js +341 -0
  30. package/src/models/scoring-engine.js +9 -2
  31. package/src/ollama/capacity-planner.js +15 -2
  32. package/src/ollama/client.js +70 -30
  33. package/src/ollama/enhanced-client.js +20 -2
  34. package/src/ollama/manager.js +14 -2
  35. package/src/policy/cli-policy.js +8 -2
  36. package/src/policy/policy-engine.js +2 -1
  37. package/src/provenance/model-provenance.js +4 -1
  38. package/src/ui/cli-theme.js +47 -7
  39. package/src/ui/interactive-panel.js +162 -24
@@ -0,0 +1,751 @@
1
+ const crypto = require('crypto');
2
+ const fetch = require('../utils/fetch');
3
+
4
+ const SOURCE_DEFINITIONS = {
5
+ huggingface: {
6
+ id: 'huggingface',
7
+ name: 'Hugging Face Hub',
8
+ base_url: 'https://huggingface.co',
9
+ source_type: 'model_hub'
10
+ },
11
+ ollama: {
12
+ id: 'ollama',
13
+ name: 'Ollama Library',
14
+ base_url: 'https://ollama.com/library',
15
+ source_type: 'runtime_registry'
16
+ },
17
+ gpt4all: {
18
+ id: 'gpt4all',
19
+ name: 'GPT4All Catalog',
20
+ base_url: 'https://github.com/nomic-ai/gpt4all',
21
+ source_type: 'curated_catalog'
22
+ }
23
+ };
24
+
25
+ const HUGGING_FACE_MODEL_API = 'https://huggingface.co/api/models';
26
+ const GPT4ALL_MODELS_URL = 'https://gpt4all.io/models/models3.json';
27
+
28
+ function extractNextLink(linkHeader = '') {
29
+ const links = String(linkHeader || '').split(',');
30
+ for (const link of links) {
31
+ const match = link.match(/<([^>]+)>;\s*rel="next"/i);
32
+ if (match) return match[1];
33
+ }
34
+ return null;
35
+ }
36
+
37
+ function toArray(value) {
38
+ if (!value) return [];
39
+ return Array.isArray(value) ? value : [value];
40
+ }
41
+
42
+ function normalizeIdPart(value) {
43
+ return String(value || '')
44
+ .trim()
45
+ .replace(/^https?:\/\//, '')
46
+ .replace(/[^a-zA-Z0-9._:/@-]+/g, '-')
47
+ .replace(/^-+|-+$/g, '')
48
+ .toLowerCase();
49
+ }
50
+
51
+ function hashShort(value) {
52
+ return crypto.createHash('sha1').update(String(value || '')).digest('hex').slice(0, 12);
53
+ }
54
+
55
+ function makeScopedId(...parts) {
56
+ const normalized = parts.map(normalizeIdPart).filter(Boolean).join(':');
57
+ if (normalized.length <= 180) return normalized;
58
+ return `${normalized.slice(0, 140)}:${hashShort(normalized)}`;
59
+ }
60
+
61
+ function makeArtifactId(sourceId, repoId, artifactName) {
62
+ return makeScopedId(sourceId, repoId, artifactName, hashShort(artifactName));
63
+ }
64
+
65
+ function bytesToGB(bytes) {
66
+ const parsed = Number(bytes);
67
+ if (!Number.isFinite(parsed) || parsed <= 0) return null;
68
+ return Math.round((parsed / (1024 ** 3)) * 1000) / 1000;
69
+ }
70
+
71
+ function parseNumberWithUnit(rawValue) {
72
+ if (rawValue === null || rawValue === undefined) return null;
73
+ if (typeof rawValue === 'number' && Number.isFinite(rawValue)) return rawValue;
74
+
75
+ const text = String(rawValue).replace(/,/g, '').trim().toLowerCase();
76
+ if (!text) return null;
77
+
78
+ if (/^\d+(?:\.\d+)?$/.test(text)) {
79
+ return Number(text);
80
+ }
81
+
82
+ // Mixture-of-Experts "NxMB" naming (e.g. Mixtral 8x7B, 8x22B): the total
83
+ // parameter footprint that must reside in memory is experts * per-expert
84
+ // size. Without this, "8x7B" matches the bare "7b" below and is stored as 7B.
85
+ const moe = text.match(/(\d+)\s*x\s*(\d+(?:\.\d+)?)\s*b\b/i);
86
+ if (moe) {
87
+ const experts = Number(moe[1]);
88
+ const perExpert = Number(moe[2]);
89
+ if (experts > 0 && Number.isFinite(perExpert) && perExpert > 0) {
90
+ return experts * perExpert;
91
+ }
92
+ }
93
+
94
+ // Note: 'k'/'thousand' are intentionally NOT parameter units. Parameter
95
+ // counts are never expressed in thousands-of-billions, and tokens like
96
+ // "128k" (a context length) were being misread as ~0.0001B and rounded to 0.
97
+ const match = text.match(/(\d+(?:\.\d+)?)\s*(trillion|billion|million|[tmb])\b/i);
98
+ if (!match) return null;
99
+
100
+ const value = Number(match[1]);
101
+ if (!Number.isFinite(value)) return null;
102
+ const unit = (match[2] || '').toLowerCase();
103
+ if (unit === 't' || unit === 'trillion') return value * 1000;
104
+ if (unit === 'm' || unit === 'million') return value / 1000;
105
+ return value;
106
+ }
107
+
108
+ function sumSafetensorsParams(safetensors) {
109
+ if (!safetensors || typeof safetensors !== 'object') return null;
110
+ if (Number.isFinite(Number(safetensors.total))) {
111
+ return Number(safetensors.total) / 1e9;
112
+ }
113
+
114
+ const parameters = safetensors.parameters;
115
+ if (!parameters || typeof parameters !== 'object') return null;
116
+ const total = Object.values(parameters).reduce((sum, value) => {
117
+ const parsed = Number(value);
118
+ return sum + (Number.isFinite(parsed) ? parsed : 0);
119
+ }, 0);
120
+
121
+ return total > 0 ? total / 1e9 : null;
122
+ }
123
+
124
+ function parseParamsB(...values) {
125
+ for (const value of values) {
126
+ const parsed = parseNumberWithUnit(value);
127
+ if (parsed !== null && parsed > 0) {
128
+ const rounded = Math.round(parsed * 1000) / 1000;
129
+ // Never let a value that rounds to 0 escape the > 0 guard.
130
+ if (rounded > 0) return rounded;
131
+ }
132
+ }
133
+ return null;
134
+ }
135
+
136
+ function parseActiveParamsB(...values) {
137
+ const text = values.map((value) => String(value || '')).join(' ');
138
+ const active = text.match(/(?:^|[-_\s])a(\d+(?:\.\d+)?)([bm])(?:[-_\s]|$)/i);
139
+ if (!active) return null;
140
+ const value = Number(active[1]);
141
+ if (!Number.isFinite(value)) return null;
142
+ return active[2].toLowerCase() === 'm'
143
+ ? Math.round((value / 1000) * 1000) / 1000
144
+ : value;
145
+ }
146
+
147
+ function inferQuantization(...values) {
148
+ const text = values.map((value) => String(value || '')).join(' ');
149
+ const ggufQuant = text.match(/\b(IQ\d(?:_[A-Z0-9]+)?|Q\d(?:_[A-Z0-9]+){0,2}|F16|FP16|BF16|Q8_0)\b/i);
150
+ if (ggufQuant) return ggufQuant[1].toUpperCase().replace(/^F16$/, 'FP16');
151
+
152
+ const bitQuant = text.match(/\b([234568])\s*[-_ ]?bit\b/i);
153
+ if (bitQuant) return `${bitQuant[1]}bit`;
154
+
155
+ return '';
156
+ }
157
+
158
+ function inferPrecision(...values) {
159
+ const text = values.map((value) => String(value || '')).join(' ').toLowerCase();
160
+ if (/\bbf16\b/.test(text)) return 'BF16';
161
+ if (/\bfp16\b|\bf16\b/.test(text)) return 'FP16';
162
+ if (/\bfp32\b|\bf32\b/.test(text)) return 'FP32';
163
+ if (/\bint8\b|\b8bit\b/.test(text)) return 'INT8';
164
+ if (/\bint4\b|\b4bit\b/.test(text)) return 'INT4';
165
+ return '';
166
+ }
167
+
168
+ function inferFormat(filename = '', tags = []) {
169
+ const lower = String(filename || '').toLowerCase();
170
+ const tagText = toArray(tags).join(' ').toLowerCase();
171
+ if (lower.endsWith('.gguf')) return 'gguf';
172
+ if (lower.endsWith('.safetensors')) return tagText.includes('mlx') || lower.includes('mlx') ? 'mlx' : 'safetensors';
173
+ if (lower.endsWith('.bin')) return lower.includes('ggml') ? 'ggml' : 'pytorch_bin';
174
+ if (lower.endsWith('.pt') || lower.endsWith('.pth')) return 'pytorch';
175
+ if (tagText.includes('ollama')) return 'ollama';
176
+ return 'unknown';
177
+ }
178
+
179
+ function inferRuntimeSupport(format, tags = [], sourceId = '') {
180
+ const normalizedFormat = String(format || '').toLowerCase();
181
+ const tagText = `${toArray(tags).join(' ')} ${sourceId}`.toLowerCase();
182
+ const runtimes = new Set();
183
+
184
+ if (normalizedFormat === 'gguf' || normalizedFormat === 'ggml') {
185
+ runtimes.add('llama.cpp');
186
+ runtimes.add('ollama');
187
+ }
188
+ if (normalizedFormat === 'ollama') {
189
+ runtimes.add('ollama');
190
+ }
191
+ if (normalizedFormat === 'mlx' || tagText.includes('mlx')) {
192
+ runtimes.add('mlx');
193
+ }
194
+ if (normalizedFormat === 'safetensors' || normalizedFormat === 'pytorch' || normalizedFormat === 'pytorch_bin') {
195
+ runtimes.add('transformers');
196
+ runtimes.add('vllm');
197
+ }
198
+ if (tagText.includes('exl2') || tagText.includes('exllama')) {
199
+ runtimes.add('exllama');
200
+ }
201
+
202
+ return [...runtimes];
203
+ }
204
+
205
+ function inferTasks(model = {}) {
206
+ const tags = toArray(model.tags || model.capabilities || model.categories || model.use_cases);
207
+ const tasks = new Set();
208
+ const pipelineTag = model.pipeline_tag || model.primary_category || model.category;
209
+ if (pipelineTag) tasks.add(String(pipelineTag));
210
+
211
+ const text = [
212
+ model.id,
213
+ model.modelId,
214
+ model.model_identifier,
215
+ model.model_name,
216
+ model.description,
217
+ ...tags
218
+ ].filter(Boolean).join(' ').toLowerCase();
219
+
220
+ if (/code|coder|programming/.test(text)) tasks.add('coding');
221
+ if (/chat|instruct|assistant|conversation/.test(text)) tasks.add('chat');
222
+ if (/reason|math|logic|r1|qwq/.test(text)) tasks.add('reasoning');
223
+ if (/embed|retrieval|bge|e5|nomic/.test(text)) tasks.add('embeddings');
224
+ if (/vision|vl|image|multimodal|llava/.test(text)) tasks.add('multimodal');
225
+ if (/creative|writing|story|roleplay/.test(text)) tasks.add('creative');
226
+ if (tasks.size === 0) tasks.add('general');
227
+ return [...tasks];
228
+ }
229
+
230
+ function inferModalities(model = {}, filename = '') {
231
+ const text = [
232
+ model.id,
233
+ model.modelId,
234
+ model.model_identifier,
235
+ model.model_name,
236
+ model.description,
237
+ filename,
238
+ ...toArray(model.tags || model.capabilities || model.categories)
239
+ ].filter(Boolean).join(' ').toLowerCase();
240
+ const modalities = new Set(['text']);
241
+ if (/vision|image|vl|multimodal|llava/.test(text)) modalities.add('vision');
242
+ if (/audio|speech|whisper/.test(text)) modalities.add('audio');
243
+ return [...modalities];
244
+ }
245
+
246
+ function extractLicense(model = {}) {
247
+ const cardData = model.cardData || model.card_data || {};
248
+ if (cardData.license) return Array.isArray(cardData.license) ? cardData.license.join(',') : String(cardData.license);
249
+ const licenseTag = toArray(model.tags).find((tag) => String(tag).startsWith('license:'));
250
+ return licenseTag ? String(licenseTag).replace(/^license:/, '') : 'unknown';
251
+ }
252
+
253
+ function getSiblingName(sibling = {}) {
254
+ return sibling.rfilename || sibling.path || sibling.name || sibling.filename || '';
255
+ }
256
+
257
+ function getSiblingSizeBytes(sibling = {}) {
258
+ const candidates = [
259
+ sibling.size,
260
+ sibling.sizeBytes,
261
+ sibling.lfs?.size,
262
+ sibling.blobSize
263
+ ];
264
+ for (const value of candidates) {
265
+ const parsed = Number(value);
266
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
267
+ }
268
+ return null;
269
+ }
270
+
271
+ function isModelArtifactFile(filename) {
272
+ const lower = String(filename || '').toLowerCase();
273
+ if (!lower) return false;
274
+ if (lower.endsWith('.gguf')) return true;
275
+ if (lower.endsWith('.safetensors')) return true;
276
+ if (/pytorch_model.*\.bin$/.test(lower)) return true;
277
+ if (/model.*\.(bin|pt|pth)$/.test(lower)) return true;
278
+ if (/ggml.*\.bin$/.test(lower)) return true;
279
+ return false;
280
+ }
281
+
282
+ function buildHuggingFaceDownloadUrl(repoId, filename, revision = 'main') {
283
+ const encodedPath = String(filename || '')
284
+ .split('/')
285
+ .map((part) => encodeURIComponent(part))
286
+ .join('/');
287
+ return `https://huggingface.co/${repoId}/resolve/${revision || 'main'}/${encodedPath}`;
288
+ }
289
+
290
+ function normalizeHuggingFaceModel(model) {
291
+ const repoId = model.id || model.modelId || model.model_id;
292
+ if (!repoId) return null;
293
+
294
+ const namespace = repoId.includes('/') ? repoId.split('/')[0] : '';
295
+ const tags = toArray(model.tags);
296
+ const tasks = inferTasks(model);
297
+ const modalities = inferModalities(model);
298
+ const license = extractLicense(model);
299
+ const gated = Boolean(model.gated && model.gated !== 'false');
300
+ const repoKey = makeScopedId('huggingface', repoId);
301
+ const repo = {
302
+ id: repoKey,
303
+ source_id: 'huggingface',
304
+ repo_id: repoId,
305
+ namespace,
306
+ canonical_model_id: repoId,
307
+ display_name: model.modelId || repoId,
308
+ url: `https://huggingface.co/${repoId}`,
309
+ license,
310
+ gated,
311
+ requires_auth: gated,
312
+ downloads: Number(model.downloads) || 0,
313
+ likes: Number(model.likes) || 0,
314
+ tags,
315
+ tasks,
316
+ modalities,
317
+ last_modified: model.lastModified || model.last_modified || '',
318
+ sha: model.sha || '',
319
+ metadata: {
320
+ pipeline_tag: model.pipeline_tag || '',
321
+ library_name: model.library_name || '',
322
+ cardData: model.cardData || null
323
+ }
324
+ };
325
+
326
+ const nameTotalB = parseParamsB(repoId, tags.join(' '));
327
+ const metadataParamsB =
328
+ sumSafetensorsParams(model.safetensors) ||
329
+ parseParamsB(model.config?.num_parameters, model.cardData?.params);
330
+ // Prefer the larger of metadata vs the MoE-aware name total, so an MoE whose
331
+ // safetensors/config under-reports (or is absent) still stores the full total.
332
+ const repoParamsB = Math.max(metadataParamsB || 0, nameTotalB || 0) || null;
333
+ const activeParamsB = parseActiveParamsB(repoId, tags.join(' '));
334
+ const contextLength = Number(
335
+ model.config?.max_position_embeddings ||
336
+ model.config?.model_max_length ||
337
+ model.config?.max_sequence_length ||
338
+ model.cardData?.context_length ||
339
+ 0
340
+ ) || null;
341
+ const revision = model.sha || 'main';
342
+ const artifacts = [];
343
+
344
+ for (const sibling of toArray(model.siblings)) {
345
+ const filename = getSiblingName(sibling);
346
+ if (!isModelArtifactFile(filename)) continue;
347
+
348
+ const sizeBytes = getSiblingSizeBytes(sibling);
349
+ const format = inferFormat(filename, tags);
350
+ const quantization = inferQuantization(filename, tags.join(' '));
351
+ const precision = inferPrecision(filename, tags.join(' '), quantization);
352
+ const artifactName = filename;
353
+ artifacts.push({
354
+ id: makeArtifactId('huggingface', repoId, artifactName),
355
+ source_id: 'huggingface',
356
+ repo_key: repoKey,
357
+ repo_id: repoId,
358
+ canonical_model_id: repoId,
359
+ artifact_name: artifactName,
360
+ filename,
361
+ format,
362
+ quantization,
363
+ precision,
364
+ parameter_count_b: Math.max(parseParamsB(filename) || 0, repoParamsB || 0) || null,
365
+ active_parameter_count_b: activeParamsB,
366
+ size_bytes: sizeBytes,
367
+ size_gb: bytesToGB(sizeBytes),
368
+ context_length: contextLength,
369
+ runtime_support: inferRuntimeSupport(format, tags, 'huggingface'),
370
+ tasks,
371
+ modalities: inferModalities(model, filename),
372
+ download_url: buildHuggingFaceDownloadUrl(repoId, filename, revision),
373
+ install_command: `hf download ${repoId} ${filename}`,
374
+ sha256: sibling.lfs?.sha256 || '',
375
+ etag: sibling.lfs?.oid || sibling.blobId || '',
376
+ license,
377
+ gated,
378
+ requires_auth: gated,
379
+ downloads: repo.downloads,
380
+ likes: repo.likes,
381
+ updated_at: repo.last_modified,
382
+ metadata: {
383
+ repo_sha: model.sha || '',
384
+ sibling
385
+ }
386
+ });
387
+ }
388
+
389
+ return { source: SOURCE_DEFINITIONS.huggingface, repos: [repo], artifacts };
390
+ }
391
+
392
+ function normalizeGpt4AllEntry(entry) {
393
+ const filenameCandidate = entry.filename || '';
394
+ const url = entry.url || entry.downloadUrl || entry.download_url ||
395
+ (filenameCandidate ? `https://gpt4all.io/models/gguf/${encodeURIComponent(filenameCandidate)}` : '');
396
+ const name = entry.name || filenameCandidate || url.split('/').filter(Boolean).pop();
397
+ if (!name || !url) return null;
398
+
399
+ const repoMatch = url.match(/huggingface\.co\/([^/]+\/[^/]+)\/resolve\/([^/]+)\/(.+)$/);
400
+ const repoId = repoMatch ? repoMatch[1] : `gpt4all/${name}`;
401
+ const filename = repoMatch ? decodeURIComponent(repoMatch[3]) : (filenameCandidate || url.split('/').filter(Boolean).pop());
402
+ const repoKey = makeScopedId('gpt4all', repoId);
403
+ const tags = ['gpt4all', entry.type, entry.quant].filter(Boolean);
404
+ const paramsB = parseParamsB(entry.parameters, name, filename);
405
+ const sizeBytes = Number(entry.filesize || entry.fileSize || entry.size || 0) || null;
406
+ const format = inferFormat(filename, tags);
407
+
408
+ return {
409
+ source: SOURCE_DEFINITIONS.gpt4all,
410
+ repos: [{
411
+ id: repoKey,
412
+ source_id: 'gpt4all',
413
+ repo_id: repoId,
414
+ namespace: repoId.includes('/') ? repoId.split('/')[0] : 'gpt4all',
415
+ canonical_model_id: name,
416
+ display_name: name,
417
+ url: repoMatch ? `https://huggingface.co/${repoId}` : url,
418
+ license: entry.license || 'unknown',
419
+ gated: false,
420
+ requires_auth: false,
421
+ downloads: Number(entry.downloads) || 0,
422
+ likes: 0,
423
+ tags,
424
+ tasks: inferTasks({ model_name: name, tags }),
425
+ modalities: ['text'],
426
+ metadata: {
427
+ ramrequired: entry.ramrequired || null,
428
+ type: entry.type || null,
429
+ md5sum: entry.md5sum || null
430
+ }
431
+ }],
432
+ artifacts: [{
433
+ id: makeArtifactId('gpt4all', repoId, filename || name),
434
+ source_id: 'gpt4all',
435
+ repo_key: repoKey,
436
+ repo_id: repoId,
437
+ canonical_model_id: name,
438
+ artifact_name: filename || name,
439
+ filename: filename || '',
440
+ format,
441
+ quantization: inferQuantization(entry.quant, filename),
442
+ precision: inferPrecision(entry.quant, filename),
443
+ parameter_count_b: paramsB,
444
+ active_parameter_count_b: null,
445
+ size_bytes: sizeBytes,
446
+ size_gb: bytesToGB(sizeBytes),
447
+ runtime_support: inferRuntimeSupport(format, tags, 'gpt4all'),
448
+ tasks: inferTasks({ model_name: name, tags }),
449
+ modalities: ['text'],
450
+ download_url: url,
451
+ install_command: `curl -L ${url} -o ${filename || name}`,
452
+ sha256: entry.sha256 || '',
453
+ etag: entry.md5sum || '',
454
+ license: entry.license || 'unknown',
455
+ gated: false,
456
+ requires_auth: false,
457
+ metadata: {
458
+ ramrequired: entry.ramrequired || null,
459
+ description: entry.description || '',
460
+ promptTemplate: entry.promptTemplate || ''
461
+ }
462
+ }]
463
+ };
464
+ }
465
+
466
+ function normalizeOllamaRows(model, variant) {
467
+ const modelId = model.id || model.model_identifier;
468
+ const tag = variant.tag || modelId;
469
+ const repoKey = makeScopedId('ollama', modelId);
470
+ const capabilities = (() => {
471
+ try {
472
+ return JSON.parse(model.capabilities || '[]');
473
+ } catch {
474
+ return [];
475
+ }
476
+ })();
477
+ const tasks = inferTasks({
478
+ model_identifier: modelId,
479
+ model_name: model.name,
480
+ capabilities,
481
+ categories: capabilities
482
+ });
483
+ const modalities = inferModalities({ model_identifier: modelId, model_name: model.name, capabilities }, tag);
484
+
485
+ return {
486
+ source: SOURCE_DEFINITIONS.ollama,
487
+ repos: [{
488
+ id: repoKey,
489
+ source_id: 'ollama',
490
+ repo_id: modelId,
491
+ namespace: model.namespace || '',
492
+ canonical_model_id: modelId,
493
+ display_name: model.name || modelId,
494
+ url: model.url || `https://ollama.com/library/${modelId}`,
495
+ license: 'unknown',
496
+ gated: false,
497
+ requires_auth: false,
498
+ downloads: Number(model.pulls) || 0,
499
+ likes: 0,
500
+ tags: capabilities,
501
+ tasks,
502
+ modalities,
503
+ last_modified: model.last_updated || '',
504
+ metadata: {
505
+ tags_count: model.tags_count || 0,
506
+ source_updated_at: model.updated_at || ''
507
+ }
508
+ }],
509
+ artifacts: [{
510
+ id: makeArtifactId('ollama', modelId, tag),
511
+ source_id: 'ollama',
512
+ repo_key: repoKey,
513
+ repo_id: modelId,
514
+ canonical_model_id: modelId,
515
+ artifact_name: tag,
516
+ filename: '',
517
+ format: 'ollama',
518
+ quantization: variant.quant || inferQuantization(tag),
519
+ precision: inferPrecision(variant.quant, tag),
520
+ parameter_count_b: Math.max(Number(variant.params_b) || 0, parseParamsB(tag) || 0) || null,
521
+ active_parameter_count_b: null,
522
+ size_bytes: null,
523
+ size_gb: Number(variant.size_gb) || null,
524
+ context_length: Number(variant.context_length) || null,
525
+ runtime_support: ['ollama'],
526
+ tasks,
527
+ modalities,
528
+ download_url: `ollama://library/${tag}`,
529
+ install_command: `ollama pull ${tag}`,
530
+ license: 'unknown',
531
+ gated: false,
532
+ requires_auth: false,
533
+ downloads: Number(model.pulls) || 0,
534
+ updated_at: model.updated_at || model.last_updated || '',
535
+ metadata: {
536
+ input_types: variant.input_types || '["text"]',
537
+ is_moe: Boolean(variant.is_moe),
538
+ expert_count: variant.expert_count || null
539
+ }
540
+ }]
541
+ };
542
+ }
543
+
544
+ class RegistryIngestor {
545
+ constructor(options = {}) {
546
+ this.database = options.database;
547
+ this.fetchImpl = options.fetchImpl || fetch;
548
+ this.onProgress = options.onProgress || (() => {});
549
+ }
550
+
551
+ async ingest(options = {}) {
552
+ if (!this.database) {
553
+ throw new Error('RegistryIngestor requires a database instance');
554
+ }
555
+
556
+ const sources = String(options.sources || 'ollama,huggingface,gpt4all')
557
+ .split(',')
558
+ .map((source) => source.trim().toLowerCase())
559
+ .filter(Boolean);
560
+ const genericLimit = Number(options.limit) > 0 ? Number(options.limit) : null;
561
+ const limits = {
562
+ huggingface: Number(options.hfLimit || options.huggingfaceLimit) > 0
563
+ ? Number(options.hfLimit || options.huggingfaceLimit)
564
+ : (genericLimit || 3000),
565
+ gpt4all: Number(options.gpt4allLimit) > 0
566
+ ? Number(options.gpt4allLimit)
567
+ : (genericLimit || 1000),
568
+ ollama: Number(options.ollamaLimit) > 0
569
+ ? Number(options.ollamaLimit)
570
+ : (genericLimit || 10000)
571
+ };
572
+ const collections = [];
573
+
574
+ for (const source of sources) {
575
+ if (source === 'huggingface' || source === 'hf') {
576
+ collections.push(...await this.collectHuggingFace({
577
+ limit: limits.huggingface,
578
+ query: options.query,
579
+ task: options.task
580
+ }));
581
+ } else if (source === 'gpt4all') {
582
+ collections.push(...await this.collectGpt4All({ limit: limits.gpt4all }));
583
+ } else if (source === 'ollama') {
584
+ collections.push(...this.collectOllamaFromDatabase({ limit: limits.ollama }));
585
+ } else {
586
+ throw new Error(`Unsupported registry source: ${source}`);
587
+ }
588
+ }
589
+
590
+ if (!options.dryRun) {
591
+ this.storeCollections(collections);
592
+ }
593
+
594
+ return this.summarizeCollections(collections, { dryRun: Boolean(options.dryRun) });
595
+ }
596
+
597
+ async collectHuggingFace(options = {}) {
598
+ const requestedLimit = Number(options.limit) > 0 ? Number(options.limit) : 1000;
599
+ const pageLimit = Math.min(1000, requestedLimit);
600
+ const params = new URLSearchParams({
601
+ sort: 'downloads',
602
+ direction: '-1',
603
+ limit: String(pageLimit),
604
+ full: 'true',
605
+ config: 'true'
606
+ });
607
+ if (options.query) params.set('search', options.query);
608
+ if (options.task) params.set('filter', options.task);
609
+
610
+ const models = [];
611
+ let url = `${HUGGING_FACE_MODEL_API}?${params.toString()}`;
612
+ while (url && models.length < requestedLimit) {
613
+ this.onProgress({ source: 'huggingface', message: `Fetching ${url}` });
614
+ const response = await this.fetchImpl(url, {
615
+ headers: { 'Accept': 'application/json' }
616
+ });
617
+
618
+ if (!response.ok) {
619
+ throw new Error(`Hugging Face request failed: HTTP ${response.status}`);
620
+ }
621
+
622
+ const payload = await response.json();
623
+ const pageModels = toArray(payload);
624
+ models.push(...pageModels);
625
+ if (pageModels.length === 0) break;
626
+ url = models.length < requestedLimit ? extractNextLink(response.headers?.get?.('link')) : null;
627
+ }
628
+
629
+ return models
630
+ .slice(0, requestedLimit)
631
+ .map(normalizeHuggingFaceModel)
632
+ .filter(Boolean);
633
+ }
634
+
635
+ async collectGpt4All(options = {}) {
636
+ this.onProgress({ source: 'gpt4all', message: 'Fetching GPT4All metadata' });
637
+ const response = await this.fetchImpl(GPT4ALL_MODELS_URL, {
638
+ headers: { 'Accept': 'application/json' }
639
+ });
640
+
641
+ if (!response.ok) {
642
+ throw new Error(`GPT4All request failed: HTTP ${response.status}`);
643
+ }
644
+
645
+ const payload = await response.json();
646
+ const entries = Array.isArray(payload) ? payload : (payload.models || []);
647
+ return entries
648
+ .slice(0, options.limit || entries.length)
649
+ .map(normalizeGpt4AllEntry)
650
+ .filter(Boolean);
651
+ }
652
+
653
+ collectOllamaFromDatabase(options = {}) {
654
+ const limit = Number(options.limit) > 0 ? Number(options.limit) : 1000;
655
+ const rows = this.database.all(`
656
+ SELECT
657
+ m.*,
658
+ v.tag,
659
+ v.params_b,
660
+ v.quant,
661
+ v.size_gb,
662
+ v.context_length,
663
+ v.input_types,
664
+ v.is_moe,
665
+ v.expert_count
666
+ FROM models m
667
+ JOIN variants v ON v.model_id = m.id
668
+ ORDER BY m.pulls DESC, v.params_b DESC, v.size_gb ASC
669
+ LIMIT ?
670
+ `, [limit]);
671
+
672
+ return rows.map((row) => {
673
+ const model = {
674
+ id: row.id,
675
+ name: row.name,
676
+ capabilities: row.capabilities,
677
+ namespace: row.namespace,
678
+ url: row.url,
679
+ pulls: row.pulls,
680
+ tags_count: row.tags_count,
681
+ last_updated: row.last_updated,
682
+ updated_at: row.updated_at
683
+ };
684
+ const variant = {
685
+ tag: row.tag,
686
+ params_b: row.params_b,
687
+ quant: row.quant,
688
+ size_gb: row.size_gb,
689
+ context_length: row.context_length,
690
+ input_types: row.input_types,
691
+ is_moe: row.is_moe,
692
+ expert_count: row.expert_count
693
+ };
694
+ return normalizeOllamaRows(model, variant);
695
+ });
696
+ }
697
+
698
+ storeCollections(collections) {
699
+ this.database.beginBatch();
700
+ try {
701
+ for (const collection of collections) {
702
+ if (collection.source) {
703
+ this.database.upsertRegistrySource({
704
+ ...collection.source,
705
+ last_ingested_at: new Date().toISOString()
706
+ });
707
+ }
708
+ for (const repo of collection.repos || []) {
709
+ this.database.upsertRegistryRepo(repo);
710
+ }
711
+ for (const artifact of collection.artifacts || []) {
712
+ this.database.upsertModelArtifact(artifact);
713
+ }
714
+ }
715
+ } finally {
716
+ this.database.endBatch();
717
+ }
718
+ }
719
+
720
+ summarizeCollections(collections, options = {}) {
721
+ const sources = new Set();
722
+ const repoIds = new Set();
723
+ let artifacts = 0;
724
+ for (const collection of collections) {
725
+ if (collection.source?.id) sources.add(collection.source.id);
726
+ for (const repo of collection.repos || []) repoIds.add(repo.id);
727
+ artifacts += (collection.artifacts || []).length;
728
+ }
729
+
730
+ return {
731
+ dryRun: Boolean(options.dryRun),
732
+ sources: sources.size,
733
+ repos: repoIds.size,
734
+ artifacts,
735
+ collections: collections.length
736
+ };
737
+ }
738
+ }
739
+
740
+ module.exports = {
741
+ RegistryIngestor,
742
+ SOURCE_DEFINITIONS,
743
+ normalizeHuggingFaceModel,
744
+ normalizeGpt4AllEntry,
745
+ normalizeOllamaRows,
746
+ inferFormat,
747
+ inferQuantization,
748
+ inferRuntimeSupport,
749
+ parseParamsB,
750
+ buildHuggingFaceDownloadUrl
751
+ };