@mmmbuto/nexuscrew 0.9.75 → 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.
@@ -59,11 +59,21 @@ function collectorDefaultTtlMs(collector) {
59
59
 
60
60
  // Sanitized failure: fixed reason strings only. The raw fetch error can embed
61
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.
62
67
  class DiscoveryError extends Error {
63
- constructor(reason) {
68
+ constructor(reason, attempt = null) {
64
69
  super(`model discovery: ${reason}`);
65
70
  this.name = 'DiscoveryError';
66
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
+ }
67
77
  }
68
78
  }
69
79
 
@@ -114,6 +124,10 @@ async function httpJson(url, {
114
124
  const controller = new AbortController();
115
125
  const timer = setTimeout(() => controller.abort(), timeoutMs);
116
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;
117
131
  try {
118
132
  const headers = { accept: 'application/json' };
119
133
  if (body !== undefined) headers['content-type'] = 'application/json';
@@ -134,23 +148,27 @@ async function httpJson(url, {
134
148
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
135
149
  });
136
150
  } catch (error) {
137
- throw new DiscoveryError(isAbort(error) ? `timeout after ${timeoutMs}ms` : 'network error');
151
+ throw new DiscoveryError(isAbort(error) ? `timeout after ${timeoutMs}ms` : 'network error', {
152
+ category: isAbort(error) ? 'timeout' : 'network', credentialSent,
153
+ });
138
154
  }
139
155
  // Minimal fakes (and some SDK shims) carry `ok` without `status`: honor
140
156
  // `ok === true` as 200, mirroring the previous discoverOllamaModels check.
141
157
  const okFlag = !!(response && typeof response === 'object' && response.ok === true);
142
158
  const status = response && typeof response.status === 'number' ? response.status : (okFlag ? 200 : 0);
143
- if (!(status >= 200 && status < 300)) throw new DiscoveryError(`HTTP ${status}`);
159
+ if (!(status >= 200 && status < 300)) throw new DiscoveryError(`HTTP ${status}`, { status, category: 'http', credentialSent });
144
160
  let parsed;
145
161
  try {
146
162
  parsed = await response.json();
147
163
  } catch (error) {
148
164
  // The timer is still armed here: an abort while reading the body is the
149
165
  // deadline expiring again, not a malformed payload.
150
- throw new DiscoveryError(isAbort(error) ? `timeout after ${timeoutMs}ms` : 'invalid JSON response');
166
+ throw new DiscoveryError(isAbort(error) ? `timeout after ${timeoutMs}ms` : 'invalid JSON response', {
167
+ category: isAbort(error) ? 'timeout' : 'invalid-response', credentialSent,
168
+ });
151
169
  }
152
170
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
153
- throw new DiscoveryError('unexpected response shape');
171
+ throw new DiscoveryError('unexpected response shape', { category: 'invalid-response', credentialSent });
154
172
  }
155
173
  return parsed;
156
174
  } finally {
@@ -418,9 +436,10 @@ function writePublicCatalogCache(home, fetchedAt, catalog) {
418
436
 
419
437
  // --- Public catalog mapping --------------------------------------------------
420
438
  // Engine provider -> catalog provider id, as an explicit, TESTED table: no
421
- // inference. Unlisted providers (local runtimes, login accounts, custom
422
- // gateways on unknown hosts) get NO public layer at all — a missing mapping
423
- // means no data, never a guess.
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.
424
443
 
425
444
  const PUBLIC_CATALOG_PROVIDER_IDS = Object.freeze({
426
445
  zai: 'zai',
@@ -462,28 +481,225 @@ function publicCatalogProviderId({ provider, baseUrl } = {}) {
462
481
  return PUBLIC_CATALOG_HOSTS[host] || null;
463
482
  }
464
483
 
465
- // Candidate catalog ids for one API model id, best first: exact, case-folded,
466
- // one "vendor/" prefix dropped, then each of those without a ":cloud"/"-cloud"
467
- // suffix. Context-variant markers like "[1m]" are deliberately NOT stripped:
468
- // inheriting the base model's limits would import wrong numbers.
469
- function publicCatalogIdCandidates(apiId) {
470
- const out = [];
471
- const push = (v) => { if (v && !out.includes(v)) out.push(v); };
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) {
472
533
  const id = String(apiId || '');
473
- push(id);
474
- push(id.toLowerCase());
475
534
  const slash = id.indexOf('/');
476
- if (slash > 0 && slash < id.length - 1) {
477
- push(id.slice(slash + 1));
478
- push(id.slice(slash + 1).toLowerCase());
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 });
479
573
  }
480
- for (const base of [...out]) {
481
- const stripped = base.replace(/(?::cloud|-cloud)$/, '');
482
- if (stripped !== base) { push(stripped); push(stripped.toLowerCase()); }
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()));
483
599
  }
484
600
  return out;
485
601
  }
486
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
+
487
703
  // One normalized record per MATCHING api id. The record keeps the API id, so
488
704
  // it merges onto the engine's own listing; catalog fields map as:
489
705
  // limit.context -> contextWindow, limit.output -> maxTokens, tool_call ->
@@ -502,33 +718,9 @@ function normalizePublicCatalogModel(id, m) {
502
718
  return out;
503
719
  }
504
720
 
721
+ // Backward-compatible shape: the resolved records alone.
505
722
  function publicCatalogRecords(catalog, providerId, modelIds) {
506
- const provider = catalog && typeof catalog === 'object' && !Array.isArray(catalog) ? catalog[providerId] : null;
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;
723
+ return resolvePublicCatalogRecords(catalog, providerId, modelIds).records;
532
724
  }
533
725
 
534
726
  // --- Precedence merge -------------------------------------------------------
@@ -630,13 +822,24 @@ function createModelDiscovery({ fetchImpl: defaultFetchImpl = globalThis.fetch,
630
822
  apiModels = await collectors[collector]({ baseUrl, apiKey, fetchImpl, timeoutMs, detailIds });
631
823
  } catch (error) {
632
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;
633
835
  if (cached) {
634
836
  return {
635
837
  status: 'cache', fetchedAt: cached.fetchedAt, stale: true, error: reason,
838
+ ...(failed ? { attempt: failed } : {}),
636
839
  apiModels: cached.models.map((m) => ({ ...m })),
637
840
  };
638
841
  }
639
- return { status: 'backup', fetchedAt: 0, stale: false, error: reason, apiModels: [] };
842
+ return { status: 'backup', fetchedAt: 0, stale: false, error: reason, ...(failed ? { attempt: failed } : {}), apiModels: [] };
640
843
  }
641
844
  const fetchedAt = now();
642
845
  // Il delta degli spariti si calcola SOLO su una lettura vera: la risposta
@@ -654,6 +857,9 @@ function createModelDiscovery({ fetchImpl: defaultFetchImpl = globalThis.fetch,
654
857
  }
655
858
  return {
656
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 },
657
863
  apiModels: apiModels.map((m) => ({ ...m })),
658
864
  ...(disappeared.length ? { disappeared } : {}),
659
865
  };
@@ -713,25 +919,34 @@ function createModelDiscovery({ fetchImpl: defaultFetchImpl = globalThis.fetch,
713
919
  return { status: 'api', fetchedAt, stale: false, error: '', catalog };
714
920
  }
715
921
 
716
- // Public layer for one engine: map the provider, snapshot the catalog (its
717
- // own TTL, cache and fallback), then normalize records for the ids the
718
- // engine already knows from the user/api/backup layers. The public catalog
719
- // COMPLETES fields; it never adds models to the proposal. An unmapped
720
- // provider skips the network entirely.
721
- async function resolvePublicCatalogLayer(opts, apiModels, userModels, backupModels) {
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) {
722
932
  const config = opts.publicCatalog && typeof opts.publicCatalog === 'object' ? opts.publicCatalog : {};
723
933
  const providerId = publicCatalogProviderId(config);
724
- if (!providerId) {
725
- return { providerId: null, records: [], summary: { providerId: null, status: 'skipped', records: 0 } };
726
- }
727
934
  const ids = [];
728
935
  const seen = new Set();
936
+ const pushId = (id) => { if (id && !seen.has(id)) { seen.add(id); ids.push(id); } };
729
937
  for (const list of [userModels, apiModels, backupModels]) {
730
938
  for (const m of Array.isArray(list) ? list : []) {
731
- const id = m && typeof m.id === 'string' ? m.id : null;
732
- if (id && !seen.has(id)) { seen.add(id); ids.push(id); }
939
+ pushId(m && typeof m.id === 'string' ? m.id : null);
733
940
  }
734
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
+ }
735
950
  // `noCache` is ONE contract on BOTH layers: a forced refresh that skips
736
951
  // the engine cache skips the public snapshot too. The engine-level flag
737
952
  // reaches the snapshot unless the publicCatalog config overrides it.
@@ -749,12 +964,16 @@ function createModelDiscovery({ fetchImpl: defaultFetchImpl = globalThis.fetch,
749
964
  summary: { providerId, status: snapshot.status, error: snapshot.error, records: 0 },
750
965
  };
751
966
  }
752
- const records = publicCatalogRecords(snapshot.catalog, providerId, ids);
967
+ const { records, byId } = resolvePublicCatalogRecords(snapshot.catalog, providerId, ids);
753
968
  return {
754
969
  providerId, records,
755
970
  summary: {
756
971
  providerId, status: snapshot.status, fetchedAt: snapshot.fetchedAt,
757
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 },
758
977
  },
759
978
  };
760
979
  }
@@ -773,7 +992,9 @@ function createModelDiscovery({ fetchImpl: defaultFetchImpl = globalThis.fetch,
773
992
  let publicModels = Array.isArray(publicCatalogModels) ? publicCatalogModels : [];
774
993
  let publicLayer = null;
775
994
  if (!Array.isArray(publicCatalogModels) && publicCatalog) {
776
- publicLayer = await resolvePublicCatalogLayer(opts, layer.apiModels, userModels, backupModels);
995
+ publicLayer = await resolvePublicCatalogLayer(
996
+ opts, layer.apiModels, userModels, backupModels, layer.disappeared || []
997
+ );
777
998
  publicModels = publicLayer.records;
778
999
  }
779
1000
  const apiKnown = layer.status !== 'backup';
@@ -808,7 +1029,9 @@ module.exports = {
808
1029
  PUBLIC_CATALOG_URL,
809
1030
  PUBLIC_CATALOG_TTL_MS,
810
1031
  publicCatalogProviderId,
811
- publicCatalogIdCandidates,
1032
+ publicCatalogVendorProviderId,
1033
+ publicCatalogVendorSplit,
1034
+ resolvePublicCatalogRecords,
812
1035
  publicCatalogRecords,
813
1036
  DISCOVERY_PROPOSAL_CAP,
814
1037
  DEFAULT_TIMEOUT_MS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.75",
3
+ "version": "0.9.76",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {