@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 +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 +57 -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
|
@@ -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
|
|
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 ??
|
|
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>
|