@opennodes/registry 0.1.26 → 0.1.28
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 +78 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opennodes/registry",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
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 = /-plan(-|$)|^gitlab$/; // token, coding, code and step plans
|
|
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
|
@@ -290,6 +290,7 @@
|
|
|
290
290
|
<input class="wide mono" id="pg-key" type="password" placeholder="optional" style="font-size:0.78rem">
|
|
291
291
|
</div>
|
|
292
292
|
</div>
|
|
293
|
+
<p class="sub" id="pg-note" hidden style="margin:0.6rem 0 0; font-size:0.8rem"></p>
|
|
293
294
|
<div id="pg-customrow" hidden style="display:grid; grid-template-columns: 2fr 1fr; gap:0.8rem">
|
|
294
295
|
<div><label class="f">Custom OpenAI-compatible endpoint <span style="font-weight:400">(e.g. your local Ollama: http://127.0.0.1:11434/v1)</span></label>
|
|
295
296
|
<input class="wide mono" id="pg-endpoint" placeholder="http://127.0.0.1:11434/v1" style="font-size:0.78rem"></div>
|
|
@@ -303,6 +304,7 @@
|
|
|
303
304
|
<span class="prov" id="pg-meta"></span>
|
|
304
305
|
</div>
|
|
305
306
|
<div id="pg-out" style="margin-top:0.8rem; font-size:0.88rem; white-space:pre-wrap; line-height:1.55"></div>
|
|
307
|
+
<div id="pg-hosted"></div>
|
|
306
308
|
</div>
|
|
307
309
|
<div id="md-perfsec"></div>
|
|
308
310
|
<div id="md-benchsec"></div>
|
|
@@ -534,7 +536,10 @@ const ago = (iso) => {
|
|
|
534
536
|
return Math.round(s / 86400) + 'd ago';
|
|
535
537
|
};
|
|
536
538
|
const tierChip = (t) => `<span class="chip t-${esc(t)}">${esc(String(t).toUpperCase())}</span>`;
|
|
537
|
-
const
|
|
539
|
+
const SUBSCRIPTION_NODE = /-plan(-|$)|\.gitlab$/;
|
|
540
|
+
const pricePublished = (p) => p && (p.input_per_mtok != null || p.output_per_mtok != null);
|
|
541
|
+
const priceStr = (p, nodeId = '') => {
|
|
542
|
+
if (!pricePublished(p)) return SUBSCRIPTION_NODE.test(nodeId) ? 'in a subscription plan' : 'price not published';
|
|
538
543
|
const i = p.input_per_mtok ?? 0, o = p.output_per_mtok ?? 0;
|
|
539
544
|
return (i === 0 && o === 0) ? 'free' : `$${fmt(i)} / $${fmt(o)} per MTok`;
|
|
540
545
|
};
|
|
@@ -608,6 +613,8 @@ function ctxBit(o) {
|
|
|
608
613
|
}
|
|
609
614
|
/** Listing-only imports carry no endpoints.openai: they are shown but never called. */
|
|
610
615
|
const invocable = (o) => o.invocable !== false && Boolean(o.endpoints?.openai);
|
|
616
|
+
/** Ollama library entries and local-app catalogs have no server: their endpoint is the visitor's own computer. */
|
|
617
|
+
const runsLocally = (o) => Boolean(o.local) || /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/i.test(o.endpoints?.openai ?? '');
|
|
611
618
|
/** endpoints.docs only when it is a plain web link (cards are third-party data). */
|
|
612
619
|
const docsUrl = (endpoints) => /^https?:\/\//i.test(endpoints?.docs ?? '') ? endpoints.docs : null;
|
|
613
620
|
function provLine(o) {
|
|
@@ -753,7 +760,7 @@ function renderItem(o) {
|
|
|
753
760
|
${o.local ? '<span class="chip loc">LOCAL</span>' : (o.source === 'import' ? '<span class="chip imp">IMP</span>' : '')}
|
|
754
761
|
</div>
|
|
755
762
|
<div class="b">
|
|
756
|
-
<span><b style="color:var(--ink)">${priceStr(o.pricing)}</b></span>
|
|
763
|
+
<span><b style="color:var(--ink)">${priceStr(o.pricing, o.node_id)}</b></span>
|
|
757
764
|
${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
765
|
${obs.ttft_ms?.p50 != null ? `<span>${obs.ttft_ms.p50}ms <span class="micro m-measured">●</span></span>` : ''}
|
|
759
766
|
<span class="mono">${fmt(o.rank)}</span>
|
|
@@ -814,7 +821,7 @@ window.toggleRow = async function (el, idx) {
|
|
|
814
821
|
? others.slice(0, 5).map((x) => `<div class="alsorow">
|
|
815
822
|
<a href="#/node/${encodeURIComponent(x.node_id)}" class="mono" style="font-size:0.72rem">${esc(x.node_id)}</a>
|
|
816
823
|
${tierChip(x.tier)}
|
|
817
|
-
<span class="num" style="color:var(--muted)">${priceStr(x.pricing)}</span>
|
|
824
|
+
<span class="num" style="color:var(--muted)">${priceStr(x.pricing, x.node_id)}</span>
|
|
818
825
|
${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
826
|
</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
827
|
: 'no other nodes serve this exact artifact';
|
|
@@ -838,7 +845,7 @@ function renderRow(o, idx) {
|
|
|
838
845
|
</span>
|
|
839
846
|
</div>
|
|
840
847
|
<div class="l2 num">
|
|
841
|
-
<span><b>${priceStr(o.pricing)}</b></span>
|
|
848
|
+
<span><b>${priceStr(o.pricing, o.node_id)}</b></span>
|
|
842
849
|
${ctxBit(o)}
|
|
843
850
|
${measuredBits(o).join('')}
|
|
844
851
|
${(o.serving?.supports || []).filter((s) => s !== 'streaming').map((s) => `<span class="chip">${esc(s)}</span>`).join('')}
|
|
@@ -857,7 +864,7 @@ function renderScore(artifact) {
|
|
|
857
864
|
const { weights, components } = o.rank_explanation;
|
|
858
865
|
const obs = o.observed || {};
|
|
859
866
|
const why = {
|
|
860
|
-
price: o.pricing?.input_per_mtok == null ?
|
|
867
|
+
price: o.pricing?.input_per_mtok == null ? `${priceStr(o.pricing, o.node_id)} — no price points`
|
|
861
868
|
: `${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
869
|
tier: {
|
|
863
870
|
verified: 'verified: passed identity, context and capacity checks',
|
|
@@ -884,12 +891,16 @@ async function loadModel(artifact) {
|
|
|
884
891
|
$('#md-title').textContent = '…';
|
|
885
892
|
$('#md-offerings').innerHTML = '';
|
|
886
893
|
$('#md-benchsec').innerHTML = '';
|
|
894
|
+
$('#pg-hosted').innerHTML = '';
|
|
895
|
+
$('#pg-note').hidden = true;
|
|
896
|
+
$('#pg-target').innerHTML = '';
|
|
897
|
+
pgOfferings = [];
|
|
887
898
|
try {
|
|
888
899
|
const { offerings } = await api('/v0/offerings?limit=100&q=' + encodeURIComponent(artifact));
|
|
889
900
|
const list = offerings.filter((o) => o.model.artifact === artifact);
|
|
890
901
|
if (!list.length) { $('#md-title').textContent = 'Not found'; return; }
|
|
891
902
|
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 ??
|
|
903
|
+
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
904
|
const first = list[0];
|
|
894
905
|
$('#md-title').textContent = first.model.name;
|
|
895
906
|
// the artifact chip links to its authoritative home: Hugging Face for hf: artifacts
|
|
@@ -917,14 +928,14 @@ async function loadModel(artifact) {
|
|
|
917
928
|
if (desc) $('#md-desc').textContent = desc;
|
|
918
929
|
|
|
919
930
|
// stats strip: aggregate honestly across serving nodes
|
|
920
|
-
const prices = list.map((o) => o.pricing.input_per_mtok ?? 0);
|
|
931
|
+
const prices = list.filter((o) => pricePublished(o.pricing)).map((o) => o.pricing.input_per_mtok ?? 0);
|
|
921
932
|
const minP = Math.min(...prices), maxP = Math.max(...prices);
|
|
922
933
|
const ctxMax = Math.max(...list.map((o) => o.serving?.context_window ?? 0), 0);
|
|
923
934
|
const verified = list.filter((o) => o.tier === 'verified').length;
|
|
924
935
|
const released = list.map((o) => o.model.released).find(Boolean);
|
|
925
936
|
const stat = (v, l) => `<span class="stat"><span class="sv">${v}</span><span class="sl">${l}</span></span>`;
|
|
926
937
|
$('#md-stats').innerHTML = [
|
|
927
|
-
stat(minP === 0 && maxP === 0 ? 'free' : (minP === maxP ? '$' + fmt(minP) : '$' + fmt(minP) + '–' + fmt(maxP)), '$/MTOK IN ACROSS NODES'),
|
|
938
|
+
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
939
|
ctxMax ? stat(fmtInt(ctxMax), 'MAX CONTEXT') : '',
|
|
929
940
|
stat(`${verified}/${list.length}`, 'VERIFIED NODES'),
|
|
930
941
|
released ? stat(esc(released), 'RELEASED') : '',
|
|
@@ -936,14 +947,17 @@ async function loadModel(artifact) {
|
|
|
936
947
|
// section nav (anchors appear only for sections that render)
|
|
937
948
|
const navBits = [['#sec-offerings', 'OFFERINGS'], ['#sec-play', 'PLAYGROUND']];
|
|
938
949
|
|
|
939
|
-
// playground targets:
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
950
|
+
// playground targets: hosted offerings first, then the ones that run on the visitor's own computer,
|
|
951
|
+
// then a custom endpoint
|
|
952
|
+
pgOfferings = [...list.filter((o) => invocable(o) && !runsLocally(o)), ...list.filter((o) => invocable(o) && runsLocally(o))];
|
|
953
|
+
$('#pg-target').innerHTML = pgOfferings.map((o, i) => `<option value="${i}">${pgLabel(o)}</option>`).join('')
|
|
943
954
|
+ '<option value="custom">custom endpoint (your local Ollama / any OpenAI-compatible server)</option>';
|
|
944
955
|
$('#pg-customrow').hidden = pgOfferings.length > 0; // listing-only offerings leave just the custom endpoint
|
|
945
956
|
$('#pg-out').textContent = '';
|
|
946
957
|
$('#pg-meta').textContent = '';
|
|
958
|
+
pgUpdateNote();
|
|
959
|
+
$('#pg-hosted').innerHTML = '';
|
|
960
|
+
if (pgOfferings.length && pgOfferings.every(runsLocally)) showHostedAlternatives(first, artifact);
|
|
947
961
|
$('#md-offerings').innerHTML = `<table class="grid num"><thead><tr>
|
|
948
962
|
<th>NODE</th><th>PRICE IN/OUT</th><th>HARDWARE</th><th>CONTEXT</th><th>TTFT</th><th>TOK/S</th><th>UP</th><th>CAPABILITIES</th><th>REVISION</th>
|
|
949
963
|
</tr></thead><tbody>` + list.map((o) => {
|
|
@@ -962,7 +976,7 @@ async function loadModel(artifact) {
|
|
|
962
976
|
<a href="#/node/${encodeURIComponent(o.node_id)}" style="font-weight:600">${esc(o.node_id)}</a>
|
|
963
977
|
<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
978
|
</div></td>
|
|
965
|
-
<td><b>${priceStr(o.pricing)}</b></td>
|
|
979
|
+
<td><b>${priceStr(o.pricing, o.node_id)}</b></td>
|
|
966
980
|
<td>${hwStr(o) || '<span style="color:var(--faint)" title="Vendor has not disclosed the hardware behind this offering">undisclosed</span>'}</td>
|
|
967
981
|
<td>${ctx}</td>
|
|
968
982
|
<td>${ttft}</td>
|
|
@@ -1003,7 +1017,7 @@ async function loadModel(artifact) {
|
|
|
1003
1017
|
$('#md-relsec').innerHTML = `<h2 id="sec-related">More from ${esc(first.model.family)}</h2><div class="relcards">` +
|
|
1004
1018
|
cards.map((r) => `<a class="relcard" href="#/model/${encodeURIComponent(r.model.artifact)}">
|
|
1005
1019
|
<span class="rn">${esc(r.model.name)}</span>
|
|
1006
|
-
<span class="rm">${esc(r.modality)} · ${priceStr(r.pricing)}</span></a>`).join('') + '</div>';
|
|
1020
|
+
<span class="rm">${esc(r.modality)} · ${priceStr(r.pricing, r.node_id)}</span></a>`).join('') + '</div>';
|
|
1007
1021
|
navBits.push(['#sec-related', 'RELATED']);
|
|
1008
1022
|
}
|
|
1009
1023
|
} catch { /* related is optional */ }
|
|
@@ -1034,8 +1048,55 @@ async function loadModel(artifact) {
|
|
|
1034
1048
|
|
|
1035
1049
|
/* ---------- playground ---------- */
|
|
1036
1050
|
let pgOfferings = [];
|
|
1051
|
+
const localHost = (o) => { try { return new URL(o.endpoints.openai).host; } catch { return 'localhost'; } };
|
|
1052
|
+
function pgLabel(o) {
|
|
1053
|
+
if (!runsLocally(o)) return `${esc(o.node_id)} — ${priceStr(o.pricing, o.node_id)} [${esc(o.tier)}]`;
|
|
1054
|
+
return `your computer — ${esc(o.local?.runtime === 'ollama' ? 'Ollama' : 'local app')} at ${esc(localHost(o))} (no hosted server for this listing)`;
|
|
1055
|
+
}
|
|
1056
|
+
/** Says plainly where Send goes when the target is the visitor's own machine. */
|
|
1057
|
+
function pgUpdateNote() {
|
|
1058
|
+
const note = $('#pg-note');
|
|
1059
|
+
const v = $('#pg-target').value;
|
|
1060
|
+
const o = v === 'custom' ? null : pgOfferings[Number(v)];
|
|
1061
|
+
note.hidden = !o || !runsLocally(o);
|
|
1062
|
+
if (note.hidden) return;
|
|
1063
|
+
const app = o.local?.runtime === 'ollama' ? 'Ollama' : 'the local app';
|
|
1064
|
+
note.innerHTML = `This listing has no server behind it: Send goes to ${app} on <b>your own computer</b> (${esc(localHost(o))}), `
|
|
1065
|
+
+ `and answers only if ${app} is running with this model installed${o.local?.run ? ` (<code class="mono">${esc(o.local.run)}</code>)` : ''}. `
|
|
1066
|
+
+ 'It tests your machine, not a node.';
|
|
1067
|
+
}
|
|
1068
|
+
/** On a local-only model page: hosted listings of the same model, so it can be tried without installing anything. */
|
|
1069
|
+
async function showHostedAlternatives(first, artifact) {
|
|
1070
|
+
const base = (first.binding?.model_id ?? first.model.name).split(':')[0].toLowerCase();
|
|
1071
|
+
const norm = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
1072
|
+
const needle = norm(base);
|
|
1073
|
+
if (needle.length < 3) return;
|
|
1074
|
+
const variants = [...new Set([base, base.replace(/([a-z])(\d)/, '$1-$2')])];
|
|
1075
|
+
try {
|
|
1076
|
+
const found = new Map();
|
|
1077
|
+
for (const v of variants) {
|
|
1078
|
+
const r = await api('/v0/offerings?limit=200&invocable=true&q=' + encodeURIComponent(v));
|
|
1079
|
+
for (const o of r.offerings) {
|
|
1080
|
+
if (runsLocally(o) || !invocable(o)) continue;
|
|
1081
|
+
if (!norm(`${o.model.name} ${o.model.artifact} ${o.binding?.model_id}`).includes(needle)) continue;
|
|
1082
|
+
found.set(o.node_id + '/' + o.offering_id, o);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
if ($('#model-card').dataset.artifact !== artifact) return; // the visitor moved on
|
|
1086
|
+
const tierOrder = { attested: 4, verified: 3, community: 2, unverified: 1 };
|
|
1087
|
+
const picks = [...found.values()].sort((a, b) =>
|
|
1088
|
+
(tierOrder[b.tier] ?? 0) - (tierOrder[a.tier] ?? 0)
|
|
1089
|
+
|| (a.pricing.input_per_mtok ?? Infinity) - (b.pricing.input_per_mtok ?? Infinity)).slice(0, 6);
|
|
1090
|
+
if (!picks.length) return;
|
|
1091
|
+
$('#pg-hosted').innerHTML = `<p class="sub" style="margin:1rem 0 0.4rem; font-size:0.8rem">To try it without installing anything, open a hosted listing with a matching name:</p>
|
|
1092
|
+
<div class="relcards">` + picks.map((o) => `<a class="relcard" href="#/model/${encodeURIComponent(o.model.artifact)}">
|
|
1093
|
+
<span class="rn">${esc(o.model.name)}</span>
|
|
1094
|
+
<span class="rm">${esc(o.node_id)} · ${priceStr(o.pricing, o.node_id)} · ${o.source === 'import' ? 'your key for that provider' : 'no key needed'}</span></a>`).join('') + '</div>';
|
|
1095
|
+
} catch { /* alternatives are optional */ }
|
|
1096
|
+
}
|
|
1037
1097
|
$('#pg-target').addEventListener('change', () => {
|
|
1038
1098
|
$('#pg-customrow').hidden = $('#pg-target').value !== 'custom';
|
|
1099
|
+
pgUpdateNote();
|
|
1039
1100
|
});
|
|
1040
1101
|
$('#pg-send').addEventListener('click', async () => {
|
|
1041
1102
|
const out = $('#pg-out'), meta = $('#pg-meta');
|
|
@@ -1043,6 +1104,7 @@ $('#pg-send').addEventListener('click', async () => {
|
|
|
1043
1104
|
if (!prompt) return;
|
|
1044
1105
|
const isCustom = $('#pg-target').value === 'custom';
|
|
1045
1106
|
const offering = isCustom ? null : pgOfferings[Number($('#pg-target').value)];
|
|
1107
|
+
if (!isCustom && !offering) return;
|
|
1046
1108
|
const base = isCustom ? $('#pg-endpoint').value.trim().replace(/\/$/, '') : offering.endpoints.openai.replace(/\/$/, '');
|
|
1047
1109
|
const model = isCustom ? $('#pg-model').value.trim() : offering.binding.model_id;
|
|
1048
1110
|
if (!base || !model) { out.innerHTML = '<span class="err">endpoint and model id are required</span>'; return; }
|
|
@@ -1105,7 +1167,7 @@ async function loadChatTargets() {
|
|
|
1105
1167
|
const local = rest.filter((o) => o.local); // Ollama library: runs on the visitor's own machine
|
|
1106
1168
|
const imports = rest.filter((o) => !o.local);
|
|
1107
1169
|
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>`;
|
|
1170
|
+
const opt = (o, i) => `<option value="${i}">${esc(o.model.name)} — ${esc(o.node_id)} — ${priceStr(o.pricing, o.node_id)}</option>`;
|
|
1109
1171
|
$('#ch-add').innerHTML = '<option value="">+ Add a model…</option>'
|
|
1110
1172
|
+ (ready.length ? `<optgroup label="Ready to use — no key needed (${ready.length})">${ready.map((o, i) => opt(o, i)).join('')}</optgroup>` : '')
|
|
1111
1173
|
+ (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 +1967,7 @@ async function loadNode(id) {
|
|
|
1905
1967
|
const offerRows = offers.slice(0, 50).map((o) => `<tr>
|
|
1906
1968
|
<td><a href="#/model/${encodeURIComponent(o.model?.artifact ?? '')}" style="font-weight:600">${esc(o.model?.name ?? o.offering_id)}</a></td>
|
|
1907
1969
|
<td><span class="chip">${esc(o.modality)}</span></td>
|
|
1908
|
-
<td class="num"><b>${priceStr(o.pricing || {})}</b></td>
|
|
1970
|
+
<td class="num"><b>${priceStr(o.pricing || {}, o.node_id)}</b></td>
|
|
1909
1971
|
<td>${hwStr({ hardware: o.hardware ?? card.hardware }) || '<span style="color:var(--faint)">undisclosed</span>'}</td>
|
|
1910
1972
|
<td class="num">${o.serving?.context_window ? fmtInt(o.serving.context_window) : '—'}</td>
|
|
1911
1973
|
<td>${(o.serving?.supports || []).filter((s) => s !== 'streaming').map((s) => `<span class="chip">${esc(s)}</span>`).join(' ') || '—'}</td>
|