@opennodes/registry 0.1.26 → 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.26",
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
@@ -534,7 +534,10 @@ const ago = (iso) => {
534
534
  return Math.round(s / 86400) + 'd ago';
535
535
  };
536
536
  const tierChip = (t) => `<span class="chip t-${esc(t)}">${esc(String(t).toUpperCase())}</span>`;
537
- 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';
538
541
  const i = p.input_per_mtok ?? 0, o = p.output_per_mtok ?? 0;
539
542
  return (i === 0 && o === 0) ? 'free' : `$${fmt(i)} / $${fmt(o)} per MTok`;
540
543
  };
@@ -753,7 +756,7 @@ function renderItem(o) {
753
756
  ${o.local ? '<span class="chip loc">LOCAL</span>' : (o.source === 'import' ? '<span class="chip imp">IMP</span>' : '')}
754
757
  </div>
755
758
  <div class="b">
756
- <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>
757
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>' : '')}
758
761
  ${obs.ttft_ms?.p50 != null ? `<span>${obs.ttft_ms.p50}ms <span class="micro m-measured">●</span></span>` : ''}
759
762
  <span class="mono">${fmt(o.rank)}</span>
@@ -814,7 +817,7 @@ window.toggleRow = async function (el, idx) {
814
817
  ? others.slice(0, 5).map((x) => `<div class="alsorow">
815
818
  <a href="#/node/${encodeURIComponent(x.node_id)}" class="mono" style="font-size:0.72rem">${esc(x.node_id)}</a>
816
819
  ${tierChip(x.tier)}
817
- <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>
818
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>` : ''}
819
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>` : '')
820
823
  : 'no other nodes serve this exact artifact';
@@ -838,7 +841,7 @@ function renderRow(o, idx) {
838
841
  </span>
839
842
  </div>
840
843
  <div class="l2 num">
841
- <span><b>${priceStr(o.pricing)}</b></span>
844
+ <span><b>${priceStr(o.pricing, o.node_id)}</b></span>
842
845
  ${ctxBit(o)}
843
846
  ${measuredBits(o).join('')}
844
847
  ${(o.serving?.supports || []).filter((s) => s !== 'streaming').map((s) => `<span class="chip">${esc(s)}</span>`).join('')}
@@ -857,7 +860,7 @@ function renderScore(artifact) {
857
860
  const { weights, components } = o.rank_explanation;
858
861
  const obs = o.observed || {};
859
862
  const why = {
860
- price: o.pricing?.input_per_mtok == null ? 'no price published'
863
+ price: o.pricing?.input_per_mtok == null ? `${priceStr(o.pricing, o.node_id)} — no price points`
861
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)`,
862
865
  tier: {
863
866
  verified: 'verified: passed identity, context and capacity checks',
@@ -889,7 +892,7 @@ async function loadModel(artifact) {
889
892
  const list = offerings.filter((o) => o.model.artifact === artifact);
890
893
  if (!list.length) { $('#md-title').textContent = 'Not found'; return; }
891
894
  const tierOrder = { verified: 3, community: 2, unverified: 1 };
892
- 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));
893
896
  const first = list[0];
894
897
  $('#md-title').textContent = first.model.name;
895
898
  // the artifact chip links to its authoritative home: Hugging Face for hf: artifacts
@@ -917,14 +920,14 @@ async function loadModel(artifact) {
917
920
  if (desc) $('#md-desc').textContent = desc;
918
921
 
919
922
  // stats strip: aggregate honestly across serving nodes
920
- 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);
921
924
  const minP = Math.min(...prices), maxP = Math.max(...prices);
922
925
  const ctxMax = Math.max(...list.map((o) => o.serving?.context_window ?? 0), 0);
923
926
  const verified = list.filter((o) => o.tier === 'verified').length;
924
927
  const released = list.map((o) => o.model.released).find(Boolean);
925
928
  const stat = (v, l) => `<span class="stat"><span class="sv">${v}</span><span class="sl">${l}</span></span>`;
926
929
  $('#md-stats').innerHTML = [
927
- 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'),
928
931
  ctxMax ? stat(fmtInt(ctxMax), 'MAX CONTEXT') : '',
929
932
  stat(`${verified}/${list.length}`, 'VERIFIED NODES'),
930
933
  released ? stat(esc(released), 'RELEASED') : '',
@@ -939,7 +942,7 @@ async function loadModel(artifact) {
939
942
  // playground targets: this page's offerings + a custom endpoint (e.g. local Ollama)
940
943
  pgOfferings = list.filter(invocable);
941
944
  $('#pg-target').innerHTML = pgOfferings.map((o, i) =>
942
- `<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('')
943
946
  + '<option value="custom">custom endpoint (your local Ollama / any OpenAI-compatible server)</option>';
944
947
  $('#pg-customrow').hidden = pgOfferings.length > 0; // listing-only offerings leave just the custom endpoint
945
948
  $('#pg-out').textContent = '';
@@ -962,7 +965,7 @@ async function loadModel(artifact) {
962
965
  <a href="#/node/${encodeURIComponent(o.node_id)}" style="font-weight:600">${esc(o.node_id)}</a>
963
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>
964
967
  </div></td>
965
- <td><b>${priceStr(o.pricing)}</b></td>
968
+ <td><b>${priceStr(o.pricing, o.node_id)}</b></td>
966
969
  <td>${hwStr(o) || '<span style="color:var(--faint)" title="Vendor has not disclosed the hardware behind this offering">undisclosed</span>'}</td>
967
970
  <td>${ctx}</td>
968
971
  <td>${ttft}</td>
@@ -1003,7 +1006,7 @@ async function loadModel(artifact) {
1003
1006
  $('#md-relsec').innerHTML = `<h2 id="sec-related">More from ${esc(first.model.family)}</h2><div class="relcards">` +
1004
1007
  cards.map((r) => `<a class="relcard" href="#/model/${encodeURIComponent(r.model.artifact)}">
1005
1008
  <span class="rn">${esc(r.model.name)}</span>
1006
- <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>';
1007
1010
  navBits.push(['#sec-related', 'RELATED']);
1008
1011
  }
1009
1012
  } catch { /* related is optional */ }
@@ -1105,7 +1108,7 @@ async function loadChatTargets() {
1105
1108
  const local = rest.filter((o) => o.local); // Ollama library: runs on the visitor's own machine
1106
1109
  const imports = rest.filter((o) => !o.local);
1107
1110
  chat.offerings = [...ready, ...local, ...imports];
1108
- 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>`;
1109
1112
  $('#ch-add').innerHTML = '<option value="">+ Add a model…</option>'
1110
1113
  + (ready.length ? `<optgroup label="Ready to use — no key needed (${ready.length})">${ready.map((o, i) => opt(o, i)).join('')}</optgroup>` : '')
1111
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>` : '')
@@ -1905,7 +1908,7 @@ async function loadNode(id) {
1905
1908
  const offerRows = offers.slice(0, 50).map((o) => `<tr>
1906
1909
  <td><a href="#/model/${encodeURIComponent(o.model?.artifact ?? '')}" style="font-weight:600">${esc(o.model?.name ?? o.offering_id)}</a></td>
1907
1910
  <td><span class="chip">${esc(o.modality)}</span></td>
1908
- <td class="num"><b>${priceStr(o.pricing || {})}</b></td>
1911
+ <td class="num"><b>${priceStr(o.pricing || {}, o.node_id)}</b></td>
1909
1912
  <td>${hwStr({ hardware: o.hardware ?? card.hardware }) || '<span style="color:var(--faint)">undisclosed</span>'}</td>
1910
1913
  <td class="num">${o.serving?.context_window ? fmtInt(o.serving.context_window) : '—'}</td>
1911
1914
  <td>${(o.serving?.supports || []).filter((s) => s !== 'streaming').map((s) => `<span class="chip">${esc(s)}</span>`).join(' ') || '—'}</td>