@opennodes/registry 0.1.25 → 0.1.27

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennodes/registry",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "OpenNodes (ONP) reference registry: node registration with proof-of-resource qualification (Stage A/B/C), transparent ranking, enforceable estimates, catalog importers (OpenRouter, Hugging Face, models.dev, Ollama), MCP surface, and the web app (catalog, chat, advisor, Node Builder)",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/importers.js CHANGED
@@ -71,6 +71,14 @@ export const KNOWN_PROVIDER_COUNTRY = Object.freeze({
71
71
  mistral: 'FR', stackit: 'DE', hetzner: 'DE', infomaniak: 'CH',
72
72
  });
73
73
 
74
+ /** Providers whose models are included in a subscription plan rather than priced per token. */
75
+ export const SUBSCRIPTION_PROVIDERS = /token-plan|^gitlab$/;
76
+
77
+ function perTokenPriced(providerId, model) {
78
+ if (SUBSCRIPTION_PROVIDERS.test(providerId)) return false;
79
+ return model?.cost?.input != null || model?.cost?.output != null;
80
+ }
81
+
74
82
  /**
75
83
  * Import a models.dev-shaped catalog: { providerId: { id, name, api?, doc?, models: { key: model } } }.
76
84
  * Each provider becomes a synthetic node `import.modelsdev.{provider}`; each model an offering.
@@ -102,10 +110,14 @@ export async function importModelsDev(store, catalog) {
102
110
  ...(model?.tool_call ? { supports: ['tool_calls'] } : {}),
103
111
  },
104
112
  binding: { profile: 'onp.openai.chat/v1', model_id: model?.id ?? modelKey },
113
+ // A missing cost is "not published", never "free"; subscription plans (token plans,
114
+ // GitLab Duo) list 0 because usage is included in the plan, not because it is free.
105
115
  pricing: {
106
116
  currency: 'USD',
107
- input_per_mtok: Number(model?.cost?.input ?? 0),
108
- output_per_mtok: Number(model?.cost?.output ?? 0),
117
+ ...(perTokenPriced(providerId, model) ? {
118
+ input_per_mtok: Number(model.cost.input ?? 0),
119
+ output_per_mtok: Number(model.cost.output ?? 0),
120
+ } : {}),
109
121
  schemes: ['prepaid'],
110
122
  },
111
123
  });
package/src/mcp.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // MCP host gets ONP discovery (recommend, search_offerings, get_offering, get_node, estimate) for free.
4
4
  import { readFileSync } from 'node:fs';
5
5
  import { readJson, sendJson, problem, estimateBounds, round6, extractFeatures, recommend, MCP_TOOLS, MCP_INSTRUCTIONS, summarizeNode } from '@opennodes/core';
6
- import { searchOfferingsPaged, searchOfferings, collectOfferings } from './search.js';
6
+ import { searchOfferingsPaged, searchOfferings, collectOfferings, pricePublished } from './search.js';
7
7
 
8
8
  const PROTOCOL_VERSION = '2025-06-18';
9
9
  const { version: VERSION } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
@@ -61,7 +61,7 @@ async function runTool(store, name, args = {}) {
61
61
  case 'estimate': {
62
62
  const all = await collectOfferings(store);
63
63
  const steps = [];
64
- let min = 0, max = 0, currency = 'USD';
64
+ let min = 0, max = 0, currency = 'USD', incomplete = false;
65
65
  for (const step of args.steps ?? []) {
66
66
  let offering;
67
67
  if (step.offering) {
@@ -79,13 +79,15 @@ async function runTool(store, name, args = {}) {
79
79
  const bounds = estimateBounds(offering.pricing, step.est_input_tokens ?? 0, step.est_output_tokens ?? 0);
80
80
  currency = offering.pricing.currency;
81
81
  min += bounds.min; max += bounds.max;
82
+ const unpriced = !pricePublished(offering.pricing); // unknown cost, not zero
83
+ incomplete ||= unpriced;
82
84
  steps.push({
83
85
  offering: `${offering.node_id}/${offering.offering_id}`,
84
86
  card_revision: offering.card_revision,
85
- cost: { currency, ...bounds },
87
+ cost: { currency, ...bounds, ...(unpriced ? { price_published: false } : {}) },
86
88
  });
87
89
  }
88
- return { total: { currency, min: round6(min), max: round6(max) }, steps };
90
+ return { total: { currency, min: round6(min), max: round6(max), ...(incomplete ? { incomplete: true } : {}) }, steps };
89
91
  }
90
92
  default:
91
93
  throw new Error(`unknown tool: ${name}`);
package/src/search.js CHANGED
@@ -119,6 +119,9 @@ async function buildOfferings(store) {
119
119
  return out;
120
120
  }
121
121
 
122
+ /** Whether an offering publishes a per-token price (imports with no known price leave it out). */
123
+ export const pricePublished = (pricing) => pricing?.input_per_mtok != null || pricing?.output_per_mtok != null;
124
+
122
125
  /** Search returning the page plus the total number of matches (before `limit`). */
123
126
  export async function searchOfferingsPaged(store, query) {
124
127
  let items = await collectOfferings(store);
package/src/server.js CHANGED
@@ -15,7 +15,7 @@ import { runStageB, captureFingerprints } from './stageb.js';
15
15
  import { startStageC } from './stagec.js';
16
16
  import { startReimport } from './reimport.js';
17
17
  import { makeSafeFetch } from './safefetch.js';
18
- import { searchOfferings, searchOfferingsPaged, collectOfferings, watchStoreWrites, RANK_COMPONENTS } from './search.js';
18
+ import { searchOfferings, searchOfferingsPaged, collectOfferings, watchStoreWrites, RANK_COMPONENTS, pricePublished } from './search.js';
19
19
  import { mcpRoutes } from './mcp.js';
20
20
  import { hostControlsNamespace, isPrivateHost, makeRateLimiter } from './namespace.js';
21
21
  import { exportRoutes } from './export.js';
@@ -366,7 +366,7 @@ export async function startRegistry({
366
366
  if (!Array.isArray(steps) || steps.length === 0) return problem(res, 400, 'missing-steps', 'body must be {"steps": [...]}');
367
367
  const all = await collectOfferings(store);
368
368
  const out = [];
369
- let min = 0, max = 0;
369
+ let min = 0, max = 0, incomplete = false;
370
370
  let currency = 'USD';
371
371
  for (const step of steps) {
372
372
  let offering;
@@ -385,13 +385,16 @@ export async function startRegistry({
385
385
  const bounds = estimateBounds(offering.pricing, step.est_input_tokens ?? 0, step.est_output_tokens ?? 0);
386
386
  currency = offering.pricing.currency;
387
387
  min += bounds.min; max += bounds.max;
388
+ // No published per-token price: the step's cost is unknown, not zero.
389
+ const unpriced = !pricePublished(offering.pricing);
390
+ incomplete ||= unpriced;
388
391
  out.push({
389
392
  offering: `${offering.node_id}/${offering.offering_id}`,
390
393
  card_revision: offering.card_revision,
391
- cost: { currency: offering.pricing.currency, ...bounds },
394
+ cost: { currency: offering.pricing.currency, ...bounds, ...(unpriced ? { price_published: false } : {}) },
392
395
  });
393
396
  }
394
- sendJson(res, 200, { total: { currency, min: round6(min), max: round6(max) }, steps: out });
397
+ sendJson(res, 200, { total: { currency, min: round6(min), max: round6(max), ...(incomplete ? { incomplete: true } : {}) }, steps: out });
395
398
  }],
396
399
 
397
400
  // ONP-3 §5b: task → ranked offerings with estimate + explanation. Accepts either a
@@ -47,6 +47,13 @@
47
47
  .mitem .t .nm { font-weight: 600; font-size: 0.86rem; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
48
48
  .mitem .b { display: flex; gap: 0.8rem; margin-top: 0.25rem; font-size: 0.7rem; color: var(--muted); font-variant-numeric: tabular-nums; flex-wrap: wrap; }
49
49
  .mdetail { min-height: 30rem; }
50
+ .scorebox { margin: 0.4rem 0 0.8rem; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); padding: 0.5rem 0.75rem; font-size: 0.8rem; }
51
+ .scorebox summary { cursor: pointer; }
52
+ .scorebox table { width: 100%; border-collapse: collapse; margin-top: 0.4rem; }
53
+ .scorebox td { padding: 0.2rem 0.5rem 0.2rem 0; border-top: 1px solid var(--line-2); vertical-align: top; }
54
+ .scorebox td.num { text-align: right; white-space: nowrap; }
55
+ .scorebox td.sub { font-size: 0.74rem; }
56
+ .scorebox tr.tot td { border-top: 1px solid var(--line); }
50
57
  .pager { display: flex; align-items: center; justify-content: center; gap: 0.3rem; margin-top: 0.5rem; font-size: 0.78rem; color: var(--muted); flex-wrap: wrap; }
51
58
  .pager:empty { display: none; }
52
59
  .pgbtn { font: inherit; min-width: 2rem; padding: 0.25rem 0.5rem; border-radius: 6px; border: 1px solid var(--line); background: var(--panel); color: var(--accent); cursor: pointer; }
@@ -267,6 +274,7 @@
267
274
  </div>
268
275
  <p class="desc" id="md-desc" hidden></p>
269
276
  <div class="statstrip" id="md-stats"></div>
277
+ <div id="md-score"></div>
270
278
  <div class="secnav" id="md-nav"></div>
271
279
  <h2 id="sec-offerings">Offerings <span class="counts" id="md-count"></span></h2>
272
280
  <div class="panel tablewrap" id="md-offerings"></div>
@@ -526,7 +534,10 @@ const ago = (iso) => {
526
534
  return Math.round(s / 86400) + 'd ago';
527
535
  };
528
536
  const tierChip = (t) => `<span class="chip t-${esc(t)}">${esc(String(t).toUpperCase())}</span>`;
529
- const priceStr = (p) => {
537
+ const SUBSCRIPTION_NODE = /token-plan|\.gitlab$/;
538
+ const pricePublished = (p) => p && (p.input_per_mtok != null || p.output_per_mtok != null);
539
+ const priceStr = (p, nodeId = '') => {
540
+ if (!pricePublished(p)) return SUBSCRIPTION_NODE.test(nodeId) ? 'in a subscription plan' : 'price not published';
530
541
  const i = p.input_per_mtok ?? 0, o = p.output_per_mtok ?? 0;
531
542
  return (i === 0 && o === 0) ? 'free' : `$${fmt(i)} / $${fmt(o)} per MTok`;
532
543
  };
@@ -726,8 +737,10 @@ function renderAdvice(r) {
726
737
  });
727
738
  }
728
739
 
740
+ let listedOffering = null; // the list entry the detail was opened from: its score is explained there
729
741
  function selectModel(el) {
730
742
  document.querySelectorAll('.mitem').forEach((x) => x.classList.toggle('sel', x === el));
743
+ listedOffering = lastResults.find((o) => `${o.node_id}/${o.offering_id}` === el.dataset.key) ?? null;
731
744
  const host = $('#m-detail');
732
745
  if (!host.contains($('#model-card'))) { host.innerHTML = ''; host.appendChild($('#model-card')); }
733
746
  loadModel(el.dataset.artifact);
@@ -736,14 +749,14 @@ function selectModel(el) {
736
749
  let lastResults = [];
737
750
  function renderItem(o) {
738
751
  const obs = o.observed || {};
739
- return `<div class="mitem" data-artifact="${esc(o.model.artifact)}">
752
+ return `<div class="mitem" data-artifact="${esc(o.model.artifact)}" data-key="${esc(o.node_id + '/' + o.offering_id)}">
740
753
  <div class="t">
741
754
  <span class="nm">${esc(o.model.name)}</span>
742
755
  ${tierChip(o.tier)}
743
756
  ${o.local ? '<span class="chip loc">LOCAL</span>' : (o.source === 'import' ? '<span class="chip imp">IMP</span>' : '')}
744
757
  </div>
745
758
  <div class="b">
746
- <span><b style="color:var(--ink)">${priceStr(o.pricing)}</b></span>
759
+ <span><b style="color:var(--ink)">${priceStr(o.pricing, o.node_id)}</b></span>
747
760
  ${o.serving?.context_window ? `<span>ctx ${fmtInt(o.serving.context_window)}${o.serving.context_capped_from ? ' <span class="micro m-warn">▼</span>' : ''}</span>` : (o.serving?.context_capped_from ? '<span>ctx <span class="micro m-warn">unverified</span></span>' : '')}
748
761
  ${obs.ttft_ms?.p50 != null ? `<span>${obs.ttft_ms.p50}ms <span class="micro m-measured">●</span></span>` : ''}
749
762
  <span class="mono">${fmt(o.rank)}</span>
@@ -804,7 +817,7 @@ window.toggleRow = async function (el, idx) {
804
817
  ? others.slice(0, 5).map((x) => `<div class="alsorow">
805
818
  <a href="#/node/${encodeURIComponent(x.node_id)}" class="mono" style="font-size:0.72rem">${esc(x.node_id)}</a>
806
819
  ${tierChip(x.tier)}
807
- <span class="num" style="color:var(--muted)">${priceStr(x.pricing)}</span>
820
+ <span class="num" style="color:var(--muted)">${priceStr(x.pricing, x.node_id)}</span>
808
821
  ${x.observed?.ttft_ms?.p50 != null ? `<span class="num" style="color:var(--muted)">${x.observed.ttft_ms.p50}ms <span class="micro m-measured">●</span></span>` : ''}
809
822
  </div>`).join('') + (others.length > 5 ? `<div class="alsorow"><a href="#/model/${encodeURIComponent(o.model.artifact)}">+${others.length - 5} more on the model page</a></div>` : '')
810
823
  : 'no other nodes serve this exact artifact';
@@ -828,7 +841,7 @@ function renderRow(o, idx) {
828
841
  </span>
829
842
  </div>
830
843
  <div class="l2 num">
831
- <span><b>${priceStr(o.pricing)}</b></span>
844
+ <span><b>${priceStr(o.pricing, o.node_id)}</b></span>
832
845
  ${ctxBit(o)}
833
846
  ${measuredBits(o).join('')}
834
847
  ${(o.serving?.supports || []).filter((s) => s !== 'streaming').map((s) => `<span class="chip">${esc(s)}</span>`).join('')}
@@ -839,6 +852,35 @@ function renderRow(o, idx) {
839
852
  }
840
853
 
841
854
  /* ---------- model detail ---------- */
855
+ // How the list's ranking score for the selected entry was computed: weight × component per part.
856
+ function renderScore(artifact) {
857
+ const host = $('#md-score');
858
+ const o = listedOffering;
859
+ if (!o?.rank_explanation || o.model.artifact !== artifact) { host.innerHTML = ''; return; }
860
+ const { weights, components } = o.rank_explanation;
861
+ const obs = o.observed || {};
862
+ const why = {
863
+ price: o.pricing?.input_per_mtok == null ? `${priceStr(o.pricing, o.node_id)} — no price points`
864
+ : `${o.pricing.input_per_mtok === 0 ? 'free' : '$' + fmt(o.pricing.input_per_mtok) + '/MTok in'} — position among the listed results (cheapest 1.00, dearest 0.00)`,
865
+ tier: {
866
+ verified: 'verified: passed identity, context and capacity checks',
867
+ community: 'community: passed the registration checks',
868
+ attested: 'attested by a third party',
869
+ }[o.tier] ?? (o.source === 'import' ? 'unverified: imported from a public catalog, not checked by this registry' : 'unverified: not checked yet'),
870
+ probe_success: obs.probes > 0 ? `${Math.round((obs.probe_success ?? 0) * obs.probes)} of the last ${obs.probes} checks passed` : 'no checks yet',
871
+ };
872
+ const label = { price: 'Price', tier: 'Trust tier', probe_success: 'Check pass rate' };
873
+ const rows = Object.keys(weights).map((k) => `<tr>
874
+ <td>${label[k] ?? esc(k)}</td>
875
+ <td class="num mono">${fmt(components[k] ?? 0)} × ${fmt(weights[k])}</td>
876
+ <td class="num mono"><b>${fmt((components[k] ?? 0) * weights[k])}</b></td>
877
+ <td class="sub">${esc(why[k] ?? '')}</td></tr>`).join('');
878
+ host.innerHTML = `<details class="scorebox" open>
879
+ <summary>Ranking score <b class="mono">${fmt(o.rank)}</b> <span class="sub">for ${esc(o.node_id)} in the current list</span></summary>
880
+ <table>${rows}<tr class="tot"><td>Total</td><td></td><td class="num mono"><b>${fmt(o.rank)}</b></td><td class="sub">weighted sum; measured values, never paid placement</td></tr></table>
881
+ </details>`;
882
+ }
883
+
842
884
  async function loadModel(artifact) {
843
885
  $('#model-card').dataset.artifact = artifact;
844
886
  $('#md-crumb').innerHTML = '<a href="#/">Models</a> / ' + esc(artifact);
@@ -850,7 +892,7 @@ async function loadModel(artifact) {
850
892
  const list = offerings.filter((o) => o.model.artifact === artifact);
851
893
  if (!list.length) { $('#md-title').textContent = 'Not found'; return; }
852
894
  const tierOrder = { verified: 3, community: 2, unverified: 1 };
853
- list.sort((a, b) => (tierOrder[b.tier] ?? 0) - (tierOrder[a.tier] ?? 0) || (a.pricing.input_per_mtok ?? 0) - (b.pricing.input_per_mtok ?? 0));
895
+ list.sort((a, b) => (tierOrder[b.tier] ?? 0) - (tierOrder[a.tier] ?? 0) || (a.pricing.input_per_mtok ?? Infinity) - (b.pricing.input_per_mtok ?? Infinity));
854
896
  const first = list[0];
855
897
  $('#md-title').textContent = first.model.name;
856
898
  // the artifact chip links to its authoritative home: Hugging Face for hf: artifacts
@@ -878,27 +920,29 @@ async function loadModel(artifact) {
878
920
  if (desc) $('#md-desc').textContent = desc;
879
921
 
880
922
  // stats strip: aggregate honestly across serving nodes
881
- const prices = list.map((o) => o.pricing.input_per_mtok ?? 0);
923
+ const prices = list.filter((o) => pricePublished(o.pricing)).map((o) => o.pricing.input_per_mtok ?? 0);
882
924
  const minP = Math.min(...prices), maxP = Math.max(...prices);
883
925
  const ctxMax = Math.max(...list.map((o) => o.serving?.context_window ?? 0), 0);
884
926
  const verified = list.filter((o) => o.tier === 'verified').length;
885
927
  const released = list.map((o) => o.model.released).find(Boolean);
886
928
  const stat = (v, l) => `<span class="stat"><span class="sv">${v}</span><span class="sl">${l}</span></span>`;
887
929
  $('#md-stats').innerHTML = [
888
- stat(minP === 0 && maxP === 0 ? 'free' : (minP === maxP ? '$' + fmt(minP) : '$' + fmt(minP) + '–' + fmt(maxP)), '$/MTOK IN ACROSS NODES'),
930
+ stat(!prices.length ? priceStr(first.pricing, first.node_id) : minP === 0 && maxP === 0 ? 'free' : (minP === maxP ? '$' + fmt(minP) : '$' + fmt(minP) + '–' + fmt(maxP)), '$/MTOK IN ACROSS NODES'),
889
931
  ctxMax ? stat(fmtInt(ctxMax), 'MAX CONTEXT') : '',
890
932
  stat(`${verified}/${list.length}`, 'VERIFIED NODES'),
891
933
  released ? stat(esc(released), 'RELEASED') : '',
892
934
  first.modalities ? stat(esc((first.modalities.input || []).join('+')) + ' → ' + esc((first.modalities.output || []).join('+')), 'MODALITIES') : '',
893
935
  ].filter(Boolean).join('');
894
936
 
937
+ renderScore(artifact);
938
+
895
939
  // section nav (anchors appear only for sections that render)
896
940
  const navBits = [['#sec-offerings', 'OFFERINGS'], ['#sec-play', 'PLAYGROUND']];
897
941
 
898
942
  // playground targets: this page's offerings + a custom endpoint (e.g. local Ollama)
899
943
  pgOfferings = list.filter(invocable);
900
944
  $('#pg-target').innerHTML = pgOfferings.map((o, i) =>
901
- `<option value="${i}">${esc(o.node_id)} — ${priceStr(o.pricing)} [${esc(o.tier)}]</option>`).join('')
945
+ `<option value="${i}">${esc(o.node_id)} — ${priceStr(o.pricing, o.node_id)} [${esc(o.tier)}]</option>`).join('')
902
946
  + '<option value="custom">custom endpoint (your local Ollama / any OpenAI-compatible server)</option>';
903
947
  $('#pg-customrow').hidden = pgOfferings.length > 0; // listing-only offerings leave just the custom endpoint
904
948
  $('#pg-out').textContent = '';
@@ -921,7 +965,7 @@ async function loadModel(artifact) {
921
965
  <a href="#/node/${encodeURIComponent(o.node_id)}" style="font-weight:600">${esc(o.node_id)}</a>
922
966
  <span style="display:flex; gap:0.3rem">${tierChip(o.tier)}${o.institutional ? '<span class="chip inst">institutional</span>' : ''}${o.source === 'import' ? '<span class="chip imp">IMPORTED</span>' : ''}</span>
923
967
  </div></td>
924
- <td><b>${priceStr(o.pricing)}</b></td>
968
+ <td><b>${priceStr(o.pricing, o.node_id)}</b></td>
925
969
  <td>${hwStr(o) || '<span style="color:var(--faint)" title="Vendor has not disclosed the hardware behind this offering">undisclosed</span>'}</td>
926
970
  <td>${ctx}</td>
927
971
  <td>${ttft}</td>
@@ -962,7 +1006,7 @@ async function loadModel(artifact) {
962
1006
  $('#md-relsec').innerHTML = `<h2 id="sec-related">More from ${esc(first.model.family)}</h2><div class="relcards">` +
963
1007
  cards.map((r) => `<a class="relcard" href="#/model/${encodeURIComponent(r.model.artifact)}">
964
1008
  <span class="rn">${esc(r.model.name)}</span>
965
- <span class="rm">${esc(r.modality)} · ${priceStr(r.pricing)}</span></a>`).join('') + '</div>';
1009
+ <span class="rm">${esc(r.modality)} · ${priceStr(r.pricing, r.node_id)}</span></a>`).join('') + '</div>';
966
1010
  navBits.push(['#sec-related', 'RELATED']);
967
1011
  }
968
1012
  } catch { /* related is optional */ }
@@ -1064,7 +1108,7 @@ async function loadChatTargets() {
1064
1108
  const local = rest.filter((o) => o.local); // Ollama library: runs on the visitor's own machine
1065
1109
  const imports = rest.filter((o) => !o.local);
1066
1110
  chat.offerings = [...ready, ...local, ...imports];
1067
- const opt = (o, i) => `<option value="${i}">${esc(o.model.name)} — ${esc(o.node_id)} — ${priceStr(o.pricing)}</option>`;
1111
+ const opt = (o, i) => `<option value="${i}">${esc(o.model.name)} — ${esc(o.node_id)} — ${priceStr(o.pricing, o.node_id)}</option>`;
1068
1112
  $('#ch-add').innerHTML = '<option value="">+ Add a model…</option>'
1069
1113
  + (ready.length ? `<optgroup label="Ready to use — no key needed (${ready.length})">${ready.map((o, i) => opt(o, i)).join('')}</optgroup>` : '')
1070
1114
  + (local.length ? `<optgroup label="On your machine — needs a local Ollama with this model pulled">${local.map((o, i) => opt(o, ready.length + i)).join('')}</optgroup>` : '')
@@ -1864,7 +1908,7 @@ async function loadNode(id) {
1864
1908
  const offerRows = offers.slice(0, 50).map((o) => `<tr>
1865
1909
  <td><a href="#/model/${encodeURIComponent(o.model?.artifact ?? '')}" style="font-weight:600">${esc(o.model?.name ?? o.offering_id)}</a></td>
1866
1910
  <td><span class="chip">${esc(o.modality)}</span></td>
1867
- <td class="num"><b>${priceStr(o.pricing || {})}</b></td>
1911
+ <td class="num"><b>${priceStr(o.pricing || {}, o.node_id)}</b></td>
1868
1912
  <td>${hwStr({ hardware: o.hardware ?? card.hardware }) || '<span style="color:var(--faint)">undisclosed</span>'}</td>
1869
1913
  <td class="num">${o.serving?.context_window ? fmtInt(o.serving.context_window) : '—'}</td>
1870
1914
  <td>${(o.serving?.supports || []).filter((s) => s !== 'streaming').map((s) => `<span class="chip">${esc(s)}</span>`).join(' ') || '—'}</td>