@mmmbuto/nexuscrew 0.9.75 → 0.9.77
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/frontend/dist/assets/index-Cgd3OQws.js +169 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/fleet/builtin.js +127 -37
- package/lib/fleet/definitions.js +10 -1
- package/lib/fleet/managed.js +54 -14
- package/lib/fleet/model-discovery.js +307 -69
- package/package.json +1 -1
- package/frontend/dist/assets/index-DMfpKI3n.js +0 -169
|
@@ -44,7 +44,9 @@ const CACHE_VERSION = 1;
|
|
|
44
44
|
const MAX_SHOW_CALLS = 32; // Ollama /api/show: only listed ids the caller asked about
|
|
45
45
|
const ANTHROPIC_MAX_PAGES = 5; // pagination safety bound
|
|
46
46
|
const ANTHROPIC_VERSION = '2023-06-01';
|
|
47
|
-
|
|
47
|
+
// Bracketed markers such as `[1m]` are part of the model id the fleet file
|
|
48
|
+
// accepts; they are kept intact and never stripped for a basename match.
|
|
49
|
+
const MODEL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:/\[\]-]{0,127}$/;
|
|
48
50
|
const ENGINE_KEY_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
49
51
|
const MERGE_FIELDS = Object.freeze(['contextWindow', 'maxTokens', 'tools', 'vision', 'reasoning', 'deprecated']);
|
|
50
52
|
|
|
@@ -59,11 +61,21 @@ function collectorDefaultTtlMs(collector) {
|
|
|
59
61
|
|
|
60
62
|
// Sanitized failure: fixed reason strings only. The raw fetch error can embed
|
|
61
63
|
// the request URL; the key lives in headers — neither may surface here.
|
|
64
|
+
// The optional `attempt` object adds the FIXED facts of the request that
|
|
65
|
+
// failed (numeric HTTP status, error category, whether the credential header
|
|
66
|
+
// was applied). Internal diagnostics for the fallback chain: they ride
|
|
67
|
+
// DiscoveryError and the layer results, never a route response — the public
|
|
68
|
+
// face stays the sanitized `reason` above.
|
|
62
69
|
class DiscoveryError extends Error {
|
|
63
|
-
constructor(reason) {
|
|
70
|
+
constructor(reason, attempt = null) {
|
|
64
71
|
super(`model discovery: ${reason}`);
|
|
65
72
|
this.name = 'DiscoveryError';
|
|
66
73
|
this.reason = reason;
|
|
74
|
+
if (attempt && typeof attempt === 'object') {
|
|
75
|
+
if (Number.isSafeInteger(attempt.status)) this.status = attempt.status;
|
|
76
|
+
if (typeof attempt.category === 'string') this.category = attempt.category;
|
|
77
|
+
if (typeof attempt.credentialSent === 'boolean') this.credentialSent = attempt.credentialSent;
|
|
78
|
+
}
|
|
67
79
|
}
|
|
68
80
|
}
|
|
69
81
|
|
|
@@ -114,6 +126,10 @@ async function httpJson(url, {
|
|
|
114
126
|
const controller = new AbortController();
|
|
115
127
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
116
128
|
const isAbort = (error) => !!(error && (error.name === 'AbortError' || error.code === 'ABORT_ERR'));
|
|
129
|
+
// Direct transport: the credential header is applied exactly when a key is
|
|
130
|
+
// valued (pinned by tests with a header-capturing fake fetch) — that is what
|
|
131
|
+
// `credentialSent` reports on every failure below.
|
|
132
|
+
const credentialSent = !!apiKey;
|
|
117
133
|
try {
|
|
118
134
|
const headers = { accept: 'application/json' };
|
|
119
135
|
if (body !== undefined) headers['content-type'] = 'application/json';
|
|
@@ -134,23 +150,27 @@ async function httpJson(url, {
|
|
|
134
150
|
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
135
151
|
});
|
|
136
152
|
} catch (error) {
|
|
137
|
-
throw new DiscoveryError(isAbort(error) ? `timeout after ${timeoutMs}ms` : 'network error'
|
|
153
|
+
throw new DiscoveryError(isAbort(error) ? `timeout after ${timeoutMs}ms` : 'network error', {
|
|
154
|
+
category: isAbort(error) ? 'timeout' : 'network', credentialSent,
|
|
155
|
+
});
|
|
138
156
|
}
|
|
139
157
|
// Minimal fakes (and some SDK shims) carry `ok` without `status`: honor
|
|
140
158
|
// `ok === true` as 200, mirroring the previous discoverOllamaModels check.
|
|
141
159
|
const okFlag = !!(response && typeof response === 'object' && response.ok === true);
|
|
142
160
|
const status = response && typeof response.status === 'number' ? response.status : (okFlag ? 200 : 0);
|
|
143
|
-
if (!(status >= 200 && status < 300)) throw new DiscoveryError(`HTTP ${status}
|
|
161
|
+
if (!(status >= 200 && status < 300)) throw new DiscoveryError(`HTTP ${status}`, { status, category: 'http', credentialSent });
|
|
144
162
|
let parsed;
|
|
145
163
|
try {
|
|
146
164
|
parsed = await response.json();
|
|
147
165
|
} catch (error) {
|
|
148
166
|
// The timer is still armed here: an abort while reading the body is the
|
|
149
167
|
// deadline expiring again, not a malformed payload.
|
|
150
|
-
throw new DiscoveryError(isAbort(error) ? `timeout after ${timeoutMs}ms` : 'invalid JSON response'
|
|
168
|
+
throw new DiscoveryError(isAbort(error) ? `timeout after ${timeoutMs}ms` : 'invalid JSON response', {
|
|
169
|
+
category: isAbort(error) ? 'timeout' : 'invalid-response', credentialSent,
|
|
170
|
+
});
|
|
151
171
|
}
|
|
152
172
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
153
|
-
throw new DiscoveryError('unexpected response shape');
|
|
173
|
+
throw new DiscoveryError('unexpected response shape', { category: 'invalid-response', credentialSent });
|
|
154
174
|
}
|
|
155
175
|
return parsed;
|
|
156
176
|
} finally {
|
|
@@ -418,9 +438,10 @@ function writePublicCatalogCache(home, fetchedAt, catalog) {
|
|
|
418
438
|
|
|
419
439
|
// --- Public catalog mapping --------------------------------------------------
|
|
420
440
|
// Engine provider -> catalog provider id, as an explicit, TESTED table: no
|
|
421
|
-
// inference.
|
|
422
|
-
//
|
|
423
|
-
//
|
|
441
|
+
// inference. The mapping decides the FIRST lookup namespace (the most
|
|
442
|
+
// pertinent one for that endpoint); every provider — local runtimes, custom
|
|
443
|
+
// gateways on unknown hosts included — can still be enriched by the by-id
|
|
444
|
+
// fallback below, which never guesses a namespace: it matches ids only.
|
|
424
445
|
|
|
425
446
|
const PUBLIC_CATALOG_PROVIDER_IDS = Object.freeze({
|
|
426
447
|
zai: 'zai',
|
|
@@ -462,28 +483,225 @@ function publicCatalogProviderId({ provider, baseUrl } = {}) {
|
|
|
462
483
|
return PUBLIC_CATALOG_HOSTS[host] || null;
|
|
463
484
|
}
|
|
464
485
|
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
486
|
+
// --- Public catalog by-id fallback --------------------------------------------
|
|
487
|
+
// Resolution per model id, in tiers. A tier's candidates are ALL the entries
|
|
488
|
+
// of that tier — never the first in Object.keys order — and the first tier
|
|
489
|
+
// (strongest first) that has candidates wins: weaker tiers are never
|
|
490
|
+
// consulted afterwards, so a conflict among the winning tier's candidates
|
|
491
|
+
// cannot be dodged by descending, and the snapshot's provider/key order
|
|
492
|
+
// cannot change the outcome.
|
|
493
|
+
//
|
|
494
|
+
// 1. mapped: the namespace of the engine's EXPLICIT provider/host mapping.
|
|
495
|
+
// A field present here is never replaced by global consensus.
|
|
496
|
+
// 2. vendor: the namespace of a vendor prefix recognized by the explicit
|
|
497
|
+
// table below, with the prefix removed for this resolution only. Any
|
|
498
|
+
// other first segment is NOT a vendor and is never stripped.
|
|
499
|
+
// 3. global exact → 4. global case-folded → 5. global ":cloud"/"-cloud"
|
|
500
|
+
// suffix stripped. Tags, versions, quantizations and "[1m]" markers are
|
|
501
|
+
// never removed; the row keeps its original API id.
|
|
502
|
+
//
|
|
503
|
+
// Within the winning tier the agreement is per normalized field: a value
|
|
504
|
+
// enters when at least one valid value exists and all valid values are equal;
|
|
505
|
+
// absent/invalid values abstain (they are neither false nor zero), `false` is
|
|
506
|
+
// a value, and a disagreement leaves the field absent (counted, bounded).
|
|
507
|
+
|
|
508
|
+
// Vendor prefixes recognized for the namespace tier. Explicit and tested: a
|
|
509
|
+
// first segment not in this table is not a vendor, and is never stripped. The
|
|
510
|
+
// values are catalog provider ids, mirroring PUBLIC_CATALOG_PROVIDER_IDS.
|
|
511
|
+
const PUBLIC_CATALOG_VENDOR_IDS = Object.freeze({
|
|
512
|
+
zai: 'zai',
|
|
513
|
+
'z-ai': 'zai',
|
|
514
|
+
anthropic: 'anthropic',
|
|
515
|
+
openai: 'openai',
|
|
516
|
+
deepseek: 'deepseek',
|
|
517
|
+
openrouter: 'openrouter',
|
|
518
|
+
nvidia: 'nvidia',
|
|
519
|
+
xai: 'xai',
|
|
520
|
+
'ollama-cloud': 'ollama-cloud',
|
|
521
|
+
'opencode-go': 'opencode-go',
|
|
522
|
+
});
|
|
523
|
+
const PUBLIC_CATALOG_VENDOR_IDS_FOLDED = new Map(
|
|
524
|
+
Object.entries(PUBLIC_CATALOG_VENDOR_IDS).map(([k, v]) => [k.toLowerCase(), v])
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
function publicCatalogVendorProviderId(prefix) {
|
|
528
|
+
const p = String(prefix || '');
|
|
529
|
+
return PUBLIC_CATALOG_VENDOR_IDS[p] || PUBLIC_CATALOG_VENDOR_IDS_FOLDED.get(p.toLowerCase()) || null;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// `z-ai/glm-5.3` -> { providerId: 'zai', rest: 'glm-5.3' }. Only the FIRST
|
|
533
|
+
// segment is considered, and only when the explicit table recognizes it.
|
|
534
|
+
function publicCatalogVendorSplit(apiId) {
|
|
472
535
|
const id = String(apiId || '');
|
|
473
|
-
push(id);
|
|
474
|
-
push(id.toLowerCase());
|
|
475
536
|
const slash = id.indexOf('/');
|
|
476
|
-
if (slash
|
|
477
|
-
|
|
478
|
-
|
|
537
|
+
if (slash <= 0 || slash >= id.length - 1) return null;
|
|
538
|
+
const providerId = publicCatalogVendorProviderId(id.slice(0, slash));
|
|
539
|
+
return providerId ? { providerId, rest: id.slice(slash + 1) } : null;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function stripCloudSuffix(id) { return id.replace(/(?::cloud|-cloud)$/, ''); }
|
|
543
|
+
|
|
544
|
+
// Index over one snapshot object, reused across rows and engines, never
|
|
545
|
+
// persisted: a plain WeakMap keyed by the catalog object itself, so each
|
|
546
|
+
// actual snapshot (including one read with noCache) has its own index and a
|
|
547
|
+
// dropped snapshot drops its index with it. Built once per snapshot, not per
|
|
548
|
+
// row.
|
|
549
|
+
const publicCatalogIndexes = new WeakMap();
|
|
550
|
+
|
|
551
|
+
function publicCatalogIndex(catalog) {
|
|
552
|
+
if (!catalog || typeof catalog !== 'object' || Array.isArray(catalog)) return null;
|
|
553
|
+
const cached = publicCatalogIndexes.get(catalog);
|
|
554
|
+
if (cached) return cached;
|
|
555
|
+
const providers = new Map(); // providerId -> { exact: Map(key->entry), folded: Map(lc->[entry]) }
|
|
556
|
+
const globalExact = new Map(); // key -> [entry]
|
|
557
|
+
const globalFolded = new Map(); // lc(key) -> [entry]
|
|
558
|
+
for (const [providerId, provider] of Object.entries(catalog)) {
|
|
559
|
+
if (!provider || typeof provider !== 'object' || Array.isArray(provider)) continue;
|
|
560
|
+
const models = provider.models && typeof provider.models === 'object' && !Array.isArray(provider.models)
|
|
561
|
+
? provider.models : null;
|
|
562
|
+
if (!models) continue;
|
|
563
|
+
const exact = new Map();
|
|
564
|
+
const folded = new Map();
|
|
565
|
+
for (const [key, model] of Object.entries(models)) {
|
|
566
|
+
if (!model || typeof model !== 'object' || Array.isArray(model)) continue;
|
|
567
|
+
const entry = { provider: providerId, key, fields: normalizePublicCatalogModel(key, model) };
|
|
568
|
+
exact.set(key, entry);
|
|
569
|
+
const lc = key.toLowerCase();
|
|
570
|
+
if (folded.has(lc)) folded.get(lc).push(entry); else folded.set(lc, [entry]);
|
|
571
|
+
if (globalExact.has(key)) globalExact.get(key).push(entry); else globalExact.set(key, [entry]);
|
|
572
|
+
if (globalFolded.has(lc)) globalFolded.get(lc).push(entry); else globalFolded.set(lc, [entry]);
|
|
573
|
+
}
|
|
574
|
+
providers.set(providerId, { exact, folded });
|
|
479
575
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
576
|
+
const index = { providers, globalExact, globalFolded };
|
|
577
|
+
publicCatalogIndexes.set(catalog, index);
|
|
578
|
+
return index;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// First non-empty tier (exact -> case-folded -> cloud-suffix stripped) within
|
|
582
|
+
// one provider namespace; ALL candidates of that tier, deduplicated.
|
|
583
|
+
function publicCatalogNamespaceCandidates(index, providerId, apiId) {
|
|
584
|
+
const ns = index.providers.get(providerId);
|
|
585
|
+
if (!ns) return [];
|
|
586
|
+
const id = String(apiId || '');
|
|
587
|
+
const out = [];
|
|
588
|
+
const seen = new Set();
|
|
589
|
+
const add = (entries) => {
|
|
590
|
+
for (const entry of entries || []) if (!seen.has(entry)) { seen.add(entry); out.push(entry); }
|
|
591
|
+
};
|
|
592
|
+
const exact = ns.exact.get(id);
|
|
593
|
+
if (exact) return [exact];
|
|
594
|
+
add(ns.folded.get(id.toLowerCase()));
|
|
595
|
+
if (out.length) return out;
|
|
596
|
+
const stripped = stripCloudSuffix(id);
|
|
597
|
+
if (stripped !== id) {
|
|
598
|
+
const sExact = ns.exact.get(stripped);
|
|
599
|
+
if (sExact) return [sExact];
|
|
600
|
+
add(ns.folded.get(stripped.toLowerCase()));
|
|
483
601
|
}
|
|
484
602
|
return out;
|
|
485
603
|
}
|
|
486
604
|
|
|
605
|
+
// Same tier ladder across every provider of the snapshot.
|
|
606
|
+
function publicCatalogGlobalCandidates(index, apiId) {
|
|
607
|
+
const id = String(apiId || '');
|
|
608
|
+
const out = [];
|
|
609
|
+
const seen = new Set();
|
|
610
|
+
const add = (entries) => {
|
|
611
|
+
for (const entry of entries || []) if (!seen.has(entry)) { seen.add(entry); out.push(entry); }
|
|
612
|
+
};
|
|
613
|
+
const exact = index.globalExact.get(id);
|
|
614
|
+
if (exact) { add(exact); return out; }
|
|
615
|
+
add(index.globalFolded.get(id.toLowerCase()));
|
|
616
|
+
if (out.length) return out;
|
|
617
|
+
const stripped = stripCloudSuffix(id);
|
|
618
|
+
if (stripped !== id) {
|
|
619
|
+
const sExact = index.globalExact.get(stripped);
|
|
620
|
+
if (sExact) { add(sExact); return out; }
|
|
621
|
+
add(index.globalFolded.get(stripped.toLowerCase()));
|
|
622
|
+
}
|
|
623
|
+
return out;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Per-field agreement among one tier's candidates, restricted to `allowed`
|
|
627
|
+
// fields (null = all fields). Absent/invalid values abstain; a disagreement
|
|
628
|
+
// is a conflict and leaves the field absent.
|
|
629
|
+
function publicCatalogAgreement(entries, allowed) {
|
|
630
|
+
const values = {};
|
|
631
|
+
const conflictFields = [];
|
|
632
|
+
let noData = 0;
|
|
633
|
+
for (const field of MERGE_FIELDS) {
|
|
634
|
+
if (allowed && !allowed.has(field)) continue;
|
|
635
|
+
const vals = [];
|
|
636
|
+
for (const entry of entries) {
|
|
637
|
+
const v = entry.fields[field];
|
|
638
|
+
if (v !== undefined) vals.push(v);
|
|
639
|
+
}
|
|
640
|
+
if (!vals.length) { noData += 1; continue; }
|
|
641
|
+
if (vals.every((v) => v === vals[0])) values[field] = vals[0];
|
|
642
|
+
else conflictFields.push(field);
|
|
643
|
+
}
|
|
644
|
+
return { values, conflictFields, noData };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function resolvePublicCatalogRow(index, mappedProviderId, apiId) {
|
|
648
|
+
const fields = {};
|
|
649
|
+
const blocked = new Set();
|
|
650
|
+
// Tier 1: the mapped namespace of the engine's actual provider. Fields
|
|
651
|
+
// present here stay; a field this tier disagrees on is blocked — a weaker
|
|
652
|
+
// tier must not be used to dodge the conflict.
|
|
653
|
+
if (mappedProviderId) {
|
|
654
|
+
const entries = publicCatalogNamespaceCandidates(index, mappedProviderId, apiId);
|
|
655
|
+
if (entries.length) {
|
|
656
|
+
const agg = publicCatalogAgreement(entries, null);
|
|
657
|
+
Object.assign(fields, agg.values);
|
|
658
|
+
for (const f of agg.conflictFields) blocked.add(f);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const allowed = new Set(MERGE_FIELDS.filter((f) => fields[f] === undefined && !blocked.has(f)));
|
|
662
|
+
// The mapped tier's conflicts are diagnostics too: a field blocked here is
|
|
663
|
+
// never consulted again, so counting it here and the fallback's own
|
|
664
|
+
// conflicts later cannot count the same field twice.
|
|
665
|
+
const stats = { matched: false, enriched: false, conflicts: blocked.size, noData: 0 };
|
|
666
|
+
if (!allowed.size) return { fields, stats };
|
|
667
|
+
// By-id ladder: a recognized vendor namespace first, then the global tiers.
|
|
668
|
+
// The first step with candidates wins; weaker steps are never consulted.
|
|
669
|
+
const split = publicCatalogVendorSplit(apiId);
|
|
670
|
+
let entries = split ? publicCatalogNamespaceCandidates(index, split.providerId, split.rest) : [];
|
|
671
|
+
if (!entries.length) entries = publicCatalogGlobalCandidates(index, apiId);
|
|
672
|
+
if (!entries.length) return { fields, stats };
|
|
673
|
+
stats.matched = true;
|
|
674
|
+
const agg = publicCatalogAgreement(entries, allowed);
|
|
675
|
+
Object.assign(fields, agg.values);
|
|
676
|
+
stats.conflicts = blocked.size + agg.conflictFields.length;
|
|
677
|
+
stats.noData = agg.noData;
|
|
678
|
+
stats.enriched = Object.keys(agg.values).length > 0;
|
|
679
|
+
return { fields, stats };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Records plus bounded by-id counters for a set of proposal ids. Records keep
|
|
683
|
+
// the API id unchanged, so they merge onto the engine's own listing; ids that
|
|
684
|
+
// resolve to nothing produce no record — the model keeps whatever the other
|
|
685
|
+
// layers say. The public layer never adds a model to the proposal.
|
|
686
|
+
function resolvePublicCatalogRecords(catalog, mappedProviderId, modelIds) {
|
|
687
|
+
const records = [];
|
|
688
|
+
const byId = { rows: 0, enriched: 0, conflicts: 0, noData: 0 };
|
|
689
|
+
const index = publicCatalogIndex(catalog);
|
|
690
|
+
if (!index) return { records, byId };
|
|
691
|
+
const seen = new Set();
|
|
692
|
+
for (const apiId of Array.isArray(modelIds) ? modelIds : []) {
|
|
693
|
+
if (typeof apiId !== 'string' || !apiId || seen.has(apiId)) continue;
|
|
694
|
+
seen.add(apiId);
|
|
695
|
+
const { fields, stats } = resolvePublicCatalogRow(index, mappedProviderId, apiId);
|
|
696
|
+
if (stats.matched) byId.rows += 1;
|
|
697
|
+
if (stats.enriched) byId.enriched += 1;
|
|
698
|
+
byId.conflicts += stats.conflicts;
|
|
699
|
+
byId.noData += stats.noData;
|
|
700
|
+
if (Object.keys(fields).length) records.push({ id: apiId, ...fields });
|
|
701
|
+
}
|
|
702
|
+
return { records, byId };
|
|
703
|
+
}
|
|
704
|
+
|
|
487
705
|
// One normalized record per MATCHING api id. The record keeps the API id, so
|
|
488
706
|
// it merges onto the engine's own listing; catalog fields map as:
|
|
489
707
|
// limit.context -> contextWindow, limit.output -> maxTokens, tool_call ->
|
|
@@ -502,33 +720,9 @@ function normalizePublicCatalogModel(id, m) {
|
|
|
502
720
|
return out;
|
|
503
721
|
}
|
|
504
722
|
|
|
723
|
+
// Backward-compatible shape: the resolved records alone.
|
|
505
724
|
function publicCatalogRecords(catalog, providerId, modelIds) {
|
|
506
|
-
|
|
507
|
-
const models = provider && typeof provider === 'object' && !Array.isArray(provider)
|
|
508
|
-
&& provider.models && typeof provider.models === 'object' && !Array.isArray(provider.models)
|
|
509
|
-
? provider.models : null;
|
|
510
|
-
if (!models) return [];
|
|
511
|
-
const lcIndex = new Map();
|
|
512
|
-
for (const key of Object.keys(models)) {
|
|
513
|
-
const lc = key.toLowerCase();
|
|
514
|
-
if (!lcIndex.has(lc)) lcIndex.set(lc, key);
|
|
515
|
-
}
|
|
516
|
-
const out = [];
|
|
517
|
-
const seen = new Set();
|
|
518
|
-
for (const apiId of Array.isArray(modelIds) ? modelIds : []) {
|
|
519
|
-
if (typeof apiId !== 'string' || !apiId || seen.has(apiId)) continue;
|
|
520
|
-
seen.add(apiId);
|
|
521
|
-
let entry = null;
|
|
522
|
-
for (const cand of publicCatalogIdCandidates(apiId)) {
|
|
523
|
-
const hit = Object.prototype.hasOwnProperty.call(models, cand)
|
|
524
|
-
? models[cand]
|
|
525
|
-
: (lcIndex.has(cand.toLowerCase()) ? models[lcIndex.get(cand.toLowerCase())] : null);
|
|
526
|
-
if (hit && typeof hit === 'object' && !Array.isArray(hit)) { entry = hit; break; }
|
|
527
|
-
}
|
|
528
|
-
// No match -> no record: the model keeps whatever the other layers say.
|
|
529
|
-
if (entry) out.push(normalizePublicCatalogModel(apiId, entry));
|
|
530
|
-
}
|
|
531
|
-
return out;
|
|
725
|
+
return resolvePublicCatalogRecords(catalog, providerId, modelIds).records;
|
|
532
726
|
}
|
|
533
727
|
|
|
534
728
|
// --- Precedence merge -------------------------------------------------------
|
|
@@ -630,13 +824,24 @@ function createModelDiscovery({ fetchImpl: defaultFetchImpl = globalThis.fetch,
|
|
|
630
824
|
apiModels = await collectors[collector]({ baseUrl, apiKey, fetchImpl, timeoutMs, detailIds });
|
|
631
825
|
} catch (error) {
|
|
632
826
|
const reason = error instanceof DiscoveryError ? error.reason : 'discovery failed';
|
|
827
|
+
// Attempt metadata of the failed request rides the fallback so the
|
|
828
|
+
// caller can diagnose WHAT rejected the listing (fixed status/category,
|
|
829
|
+
// credential applied or not). Ephemeral only: nothing here is cached.
|
|
830
|
+
const failed = error instanceof DiscoveryError && typeof error.credentialSent === 'boolean'
|
|
831
|
+
? {
|
|
832
|
+
...(Number.isSafeInteger(error.status) ? { status: error.status } : {}),
|
|
833
|
+
...(typeof error.category === 'string' ? { category: error.category } : {}),
|
|
834
|
+
credentialSent: error.credentialSent,
|
|
835
|
+
}
|
|
836
|
+
: null;
|
|
633
837
|
if (cached) {
|
|
634
838
|
return {
|
|
635
839
|
status: 'cache', fetchedAt: cached.fetchedAt, stale: true, error: reason,
|
|
840
|
+
...(failed ? { attempt: failed } : {}),
|
|
636
841
|
apiModels: cached.models.map((m) => ({ ...m })),
|
|
637
842
|
};
|
|
638
843
|
}
|
|
639
|
-
return { status: 'backup', fetchedAt: 0, stale: false, error: reason, apiModels: [] };
|
|
844
|
+
return { status: 'backup', fetchedAt: 0, stale: false, error: reason, ...(failed ? { attempt: failed } : {}), apiModels: [] };
|
|
640
845
|
}
|
|
641
846
|
const fetchedAt = now();
|
|
642
847
|
// Il delta degli spariti si calcola SOLO su una lettura vera: la risposta
|
|
@@ -654,6 +859,9 @@ function createModelDiscovery({ fetchImpl: defaultFetchImpl = globalThis.fetch,
|
|
|
654
859
|
}
|
|
655
860
|
return {
|
|
656
861
|
status: 'api', fetchedAt, stale: false, error: '',
|
|
862
|
+
// The listing request went out: whether it carried the credential is
|
|
863
|
+
// what separates an anonymous success from an authenticated one.
|
|
864
|
+
attempt: { credentialSent: !!apiKey },
|
|
657
865
|
apiModels: apiModels.map((m) => ({ ...m })),
|
|
658
866
|
...(disappeared.length ? { disappeared } : {}),
|
|
659
867
|
};
|
|
@@ -713,25 +921,34 @@ function createModelDiscovery({ fetchImpl: defaultFetchImpl = globalThis.fetch,
|
|
|
713
921
|
return { status: 'api', fetchedAt, stale: false, error: '', catalog };
|
|
714
922
|
}
|
|
715
923
|
|
|
716
|
-
// Public layer for one engine:
|
|
717
|
-
// own TTL, cache and fallback), then
|
|
718
|
-
// engine already
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
|
|
924
|
+
// Public layer for one engine: resolve the provider mapping, snapshot the
|
|
925
|
+
// catalog (its own TTL, cache and fallback), then enrich the ids the
|
|
926
|
+
// engine's proposal already carries — from the mapped namespace first, and
|
|
927
|
+
// for the fields still absent (or without a mapping at all) by id across
|
|
928
|
+
// the whole snapshot: custom gateways and local endpoints included. The
|
|
929
|
+
// public catalog COMPLETES fields; it never adds models to the proposal.
|
|
930
|
+
// Nothing about the engine's endpoint (model names, URLs, keys) is ever
|
|
931
|
+
// sent to the catalog: the fetch is the one fixed public snapshot URL,
|
|
932
|
+
// keyless. With no id to enrich the snapshot is not fetched at all.
|
|
933
|
+
async function resolvePublicCatalogLayer(opts, apiModels, userModels, backupModels, knownIds) {
|
|
722
934
|
const config = opts.publicCatalog && typeof opts.publicCatalog === 'object' ? opts.publicCatalog : {};
|
|
723
935
|
const providerId = publicCatalogProviderId(config);
|
|
724
|
-
if (!providerId) {
|
|
725
|
-
return { providerId: null, records: [], summary: { providerId: null, status: 'skipped', records: 0 } };
|
|
726
|
-
}
|
|
727
936
|
const ids = [];
|
|
728
937
|
const seen = new Set();
|
|
938
|
+
const pushId = (id) => { if (id && !seen.has(id)) { seen.add(id); ids.push(id); } };
|
|
729
939
|
for (const list of [userModels, apiModels, backupModels]) {
|
|
730
940
|
for (const m of Array.isArray(list) ? list : []) {
|
|
731
|
-
|
|
732
|
-
if (id && !seen.has(id)) { seen.add(id); ids.push(id); }
|
|
941
|
+
pushId(m && typeof m.id === 'string' ? m.id : null);
|
|
733
942
|
}
|
|
734
943
|
}
|
|
944
|
+
// Known-gone ids stay in the proposal as bare rows: they are enrichable
|
|
945
|
+
// like any other id already present.
|
|
946
|
+
for (const id of Array.isArray(knownIds) ? knownIds : []) {
|
|
947
|
+
pushId(typeof id === 'string' ? id : null);
|
|
948
|
+
}
|
|
949
|
+
if (!ids.length) {
|
|
950
|
+
return { providerId, records: [], summary: { providerId, status: 'skipped', records: 0 } };
|
|
951
|
+
}
|
|
735
952
|
// `noCache` is ONE contract on BOTH layers: a forced refresh that skips
|
|
736
953
|
// the engine cache skips the public snapshot too. The engine-level flag
|
|
737
954
|
// reaches the snapshot unless the publicCatalog config overrides it.
|
|
@@ -749,12 +966,16 @@ function createModelDiscovery({ fetchImpl: defaultFetchImpl = globalThis.fetch,
|
|
|
749
966
|
summary: { providerId, status: snapshot.status, error: snapshot.error, records: 0 },
|
|
750
967
|
};
|
|
751
968
|
}
|
|
752
|
-
const records =
|
|
969
|
+
const { records, byId } = resolvePublicCatalogRecords(snapshot.catalog, providerId, ids);
|
|
753
970
|
return {
|
|
754
971
|
providerId, records,
|
|
755
972
|
summary: {
|
|
756
973
|
providerId, status: snapshot.status, fetchedAt: snapshot.fetchedAt,
|
|
757
974
|
stale: snapshot.stale, error: snapshot.error, records: records.length,
|
|
975
|
+
// Additive, bounded by-id counters (consumed by the view layer):
|
|
976
|
+
// rows matched by id, rows that received at least one field, fields
|
|
977
|
+
// blocked by disagreement, fields with no data among the candidates.
|
|
978
|
+
byId: { rows: byId.rows, enriched: byId.enriched, conflicts: byId.conflicts, noData: byId.noData },
|
|
758
979
|
},
|
|
759
980
|
};
|
|
760
981
|
}
|
|
@@ -767,22 +988,37 @@ function createModelDiscovery({ fetchImpl: defaultFetchImpl = globalThis.fetch,
|
|
|
767
988
|
async function discoverEngine(opts = {}) {
|
|
768
989
|
const {
|
|
769
990
|
userModels = [], backupModels = [], publicCatalogModels, publicCatalog,
|
|
770
|
-
cap = DISCOVERY_PROPOSAL_CAP,
|
|
991
|
+
cap = DISCOVERY_PROPOSAL_CAP, apiAvailable = true, knownIds = [],
|
|
771
992
|
} = opts;
|
|
772
|
-
|
|
993
|
+
// An engine with no listing endpoint still has ids worth enriching: the
|
|
994
|
+
// declared models and the profile backup. There is no API layer at all —
|
|
995
|
+
// not a fake collector and not a fake fetch — so the merge runs with the
|
|
996
|
+
// API explicitly unavailable and never claims an API answer.
|
|
997
|
+
const layer = apiAvailable
|
|
998
|
+
? await fetchEngineModels(opts)
|
|
999
|
+
: { status: 'unsupported', fetchedAt: 0, apiModels: [], stale: false, error: '', disappeared: [] };
|
|
773
1000
|
let publicModels = Array.isArray(publicCatalogModels) ? publicCatalogModels : [];
|
|
774
1001
|
let publicLayer = null;
|
|
1002
|
+
// Ids that are known from configuration but are not a user declaration
|
|
1003
|
+
// (for example the engine's own active model). They join the public layer
|
|
1004
|
+
// as bare ids: they can be enriched, never invented with values, and they
|
|
1005
|
+
// stay after the declared ids in the proposal order.
|
|
1006
|
+
const configuredIds = (Array.isArray(knownIds) ? knownIds : [])
|
|
1007
|
+
.filter((id) => typeof id === 'string' && id);
|
|
1008
|
+
const knownLayerIds = [...(layer.disappeared || []), ...configuredIds];
|
|
775
1009
|
if (!Array.isArray(publicCatalogModels) && publicCatalog) {
|
|
776
|
-
publicLayer = await resolvePublicCatalogLayer(
|
|
1010
|
+
publicLayer = await resolvePublicCatalogLayer(
|
|
1011
|
+
opts, layer.apiModels, userModels, backupModels, knownLayerIds
|
|
1012
|
+
);
|
|
777
1013
|
publicModels = publicLayer.records;
|
|
778
1014
|
}
|
|
779
|
-
const apiKnown = layer.status !== 'backup';
|
|
1015
|
+
const apiKnown = apiAvailable && layer.status !== 'backup';
|
|
780
1016
|
const models = mergeModelRecords({
|
|
781
1017
|
userModels,
|
|
782
1018
|
apiModels: layer.apiModels,
|
|
783
1019
|
publicCatalogModels: publicModels,
|
|
784
1020
|
backupModels,
|
|
785
|
-
knownModels:
|
|
1021
|
+
knownModels: knownLayerIds.map((id) => ({ id })),
|
|
786
1022
|
apiKnown,
|
|
787
1023
|
fetchedAt: layer.fetchedAt || now(),
|
|
788
1024
|
cap,
|
|
@@ -808,7 +1044,9 @@ module.exports = {
|
|
|
808
1044
|
PUBLIC_CATALOG_URL,
|
|
809
1045
|
PUBLIC_CATALOG_TTL_MS,
|
|
810
1046
|
publicCatalogProviderId,
|
|
811
|
-
|
|
1047
|
+
publicCatalogVendorProviderId,
|
|
1048
|
+
publicCatalogVendorSplit,
|
|
1049
|
+
resolvePublicCatalogRecords,
|
|
812
1050
|
publicCatalogRecords,
|
|
813
1051
|
DISCOVERY_PROPOSAL_CAP,
|
|
814
1052
|
DEFAULT_TIMEOUT_MS,
|