@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennodes/registry",
3
- "version": "0.1.26",
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
- 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
@@ -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 priceStr = (p) => {
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 ? 'no price published'
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 ?? 0) - (b.pricing.input_per_mtok ?? 0));
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: this page's offerings + a custom endpoint (e.g. local Ollama)
940
- pgOfferings = list.filter(invocable);
941
- $('#pg-target').innerHTML = pgOfferings.map((o, i) =>
942
- `<option value="${i}">${esc(o.node_id)} — ${priceStr(o.pricing)} [${esc(o.tier)}]</option>`).join('')
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>