@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 +1 -1
- package/src/importers.js +14 -2
- package/src/mcp.js +6 -4
- package/src/search.js +3 -0
- package/src/server.js +7 -4
- package/src/web/index.html +16 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opennodes/registry",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
108
|
-
|
|
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
|
package/src/web/index.html
CHANGED
|
@@ -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
|
|
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 ?
|
|
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 ??
|
|
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>
|