@opennodes/registry 0.1.23 → 0.1.25

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.23",
3
+ "version": "0.1.25",
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/search.js CHANGED
@@ -51,8 +51,11 @@ async function buildOfferings(store) {
51
51
  const card = await store.latestCard(node.id);
52
52
  if (!card) continue;
53
53
  const probes = await store.probesFor(node.id);
54
- const stageA = probes.filter((p) => p.stage === 'A');
55
- const probeSuccess = stageA.length ? stageA.filter((p) => p.ok).length / stageA.length : 0;
54
+ // Pass rate over the recent window of conformance (A) and continuous (C) checks. Stage A
55
+ // alone runs every 6 h and is pushed out of the window by liveness checks within hours,
56
+ // which left healthy verified nodes showing 0 probes and a 0 pass rate.
57
+ const checks = probes.filter((p) => p.stage === 'A' || p.stage === 'C');
58
+ const probeSuccess = checks.length ? checks.filter((p) => p.ok).length / checks.length : 0;
56
59
  const tier = node.state === 'indexed' ? 'unverified' : node.state;
57
60
  const nodeObs = await store.getObservations(node.id, '_node');
58
61
  const availability = nodeObs.availability ?? null;
@@ -102,7 +105,7 @@ async function buildOfferings(store) {
102
105
  benchmarks: offering.benchmarks ?? null,
103
106
  observed: {
104
107
  probe_success: round2(probeSuccess),
105
- probes: stageA.length,
108
+ probes: checks.length,
106
109
  ttft_ms: obs.ttft_ms ?? null,
107
110
  tps: obs.tps ?? null,
108
111
  availability,
@@ -152,10 +155,14 @@ export async function searchOfferingsPaged(store, query) {
152
155
 
153
156
  // Rank depends on the filtered set: annotate copies, never the shared cached offerings.
154
157
  items = items.map((o) => ({ ...o }));
155
- const maxPrice = Math.max(...items.map((o) => o.pricing.input_per_mtok ?? 0), 0.000001);
158
+ // Price scores by position among the results (cheapest 1, dearest 0), not against the single
159
+ // most expensive offering: one outlier made almost everything "cheap" and every import tie.
160
+ const prices = items.map((o) => o.pricing.input_per_mtok).filter((p) => p != null).sort((a, b) => a - b);
161
+ const cheaperThan = (p) => { let lo = 0, hi = prices.length; while (lo < hi) { const m = (lo + hi) >> 1; if (prices[m] < p) lo = m + 1; else hi = m; } return lo; };
156
162
  for (const o of items) {
163
+ const p = o.pricing.input_per_mtok;
157
164
  const components = {
158
- price: 1 - (o.pricing.input_per_mtok ?? maxPrice) / maxPrice,
165
+ price: p == null ? 0 : prices.length > 1 ? 1 - cheaperThan(p) / (prices.length - 1) : 1,
159
166
  tier: TIER_SCORE[o.tier] ?? 0,
160
167
  probe_success: o.observed.probe_success,
161
168
  };
@@ -172,11 +179,17 @@ export async function searchOfferingsPaged(store, query) {
172
179
  if (sort === 'price') return (a.pricing.input_per_mtok ?? Infinity) - (b.pricing.input_per_mtok ?? Infinity);
173
180
  if (sort === 'ttft') return ttftOf(a) - ttftOf(b);
174
181
  if (sort === 'tps') return tpsOf(b) - tpsOf(a);
175
- return b.rank - a.rank;
182
+ // Equal rank: callable before listing-only, larger context first, then by name — a stable,
183
+ // meaningful order instead of whatever order the catalogs were imported in.
184
+ return b.rank - a.rank || Number(b.invocable) - Number(a.invocable)
185
+ || (b.serving?.context_window ?? 0) - (a.serving?.context_window ?? 0)
186
+ || String(a.model?.name ?? '').localeCompare(String(b.model?.name ?? ''))
187
+ || `${a.node_id}/${a.offering_id}`.localeCompare(`${b.node_id}/${b.offering_id}`);
176
188
  });
177
189
 
178
190
  const limit = Math.min(Number(q('limit') ?? 50), 200);
179
- return { offerings: items.slice(0, limit), total: items.length };
191
+ const offset = Math.max(0, Math.floor(Number(q('offset') ?? 0)) || 0);
192
+ return { offerings: items.slice(offset, offset + limit), total: items.length, offset };
180
193
  }
181
194
 
182
195
  export async function searchOfferings(store, query) {
package/src/server.js CHANGED
@@ -350,8 +350,8 @@ export async function startRegistry({
350
350
  }],
351
351
 
352
352
  ['GET', '/v0/offerings', async (req, res, { query }) => {
353
- const { offerings, total } = await searchOfferingsPaged(store, query);
354
- sendJson(res, 200, { offerings, total }); // total = matches before `limit`
353
+ const { offerings, total, offset } = await searchOfferingsPaged(store, query);
354
+ sendJson(res, 200, { offerings, total, offset }); // total = matches before `offset`/`limit`
355
355
  }],
356
356
 
357
357
  ['GET', '/v0/offerings/:nodeId/:offeringId', async (req, res, { params }) => {
@@ -47,6 +47,12 @@
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
+ .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
+ .pager:empty { display: none; }
52
+ .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; }
53
+ .pgbtn:disabled { color: var(--faint); cursor: default; }
54
+ .pgnum { padding: 0 0.3rem; }
55
+ .pgnum input { width: 4rem; font: inherit; padding: 0.2rem 0.3rem; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--ink); text-align: center; }
50
56
 
51
57
  /* chat */
52
58
  .chatgrid { display: grid; grid-template-columns: minmax(0, 1fr) 19rem; gap: 1.2rem; align-items: start; }
@@ -244,7 +250,7 @@
244
250
  <div id="adv-out"></div>
245
251
  </details>
246
252
  <div class="mgrid">
247
- <div class="mlist" id="m-list"></div>
253
+ <div class="mcol"><div class="mlist" id="m-list"></div><div class="pager" id="m-pager"></div></div>
248
254
  <div class="mdetail" id="m-detail"><p class="sub">Select a model from the list.</p></div>
249
255
  </div>
250
256
  </div>
@@ -606,8 +612,52 @@ function provLine(o) {
606
612
  return bits.join(' · ');
607
613
  }
608
614
 
615
+ const PAGE = 60;
616
+ let listQuery = '', listTotal = 0, listPage = 0;
617
+
618
+ // Count line and page controls under the list: first / previous / page N of M / next / last.
619
+ function updatePager() {
620
+ const pages = Math.max(1, Math.ceil(listTotal / PAGE));
621
+ const from = listTotal ? listPage * PAGE + 1 : 0, to = Math.min(listTotal, (listPage + 1) * PAGE);
622
+ $('#m-counts').textContent = fmtInt(listTotal) + ' offerings' + (pages > 1 ? ` · ${fmtInt(from)}–${fmtInt(to)}` : '');
623
+ const pager = $('#m-pager');
624
+ if (pages <= 1) { pager.innerHTML = ''; return; }
625
+ const btn = (label, page, title) => `<button class="pgbtn" data-page="${page}" title="${title}"${page < 0 || page >= pages || page === listPage ? ' disabled' : ''}>${label}</button>`;
626
+ pager.innerHTML = btn('«', 0, 'first page') + btn('‹', listPage - 1, 'previous page')
627
+ + `<span class="pgnum">Page <input id="m-page" type="number" min="1" max="${pages}" value="${listPage + 1}" aria-label="page number"> of ${fmtInt(pages)}</span>`
628
+ + btn('›', listPage + 1, 'next page') + btn('»', pages - 1, 'last page');
629
+ pager.querySelectorAll('.pgbtn').forEach((b) => b.addEventListener('click', () => goToPage(Number(b.dataset.page))));
630
+ $('#m-page').addEventListener('change', (e) => goToPage(Math.min(pages, Math.max(1, Math.round(Number(e.target.value) || 1))) - 1));
631
+ }
632
+
633
+ async function goToPage(page) {
634
+ const query = listQuery;
635
+ const p = new URLSearchParams(query);
636
+ p.set('offset', page * PAGE);
637
+ $('#m-pager').querySelectorAll('button, input').forEach((el) => { el.disabled = true; });
638
+ try {
639
+ const { offerings, total } = await api('/v0/offerings?' + p);
640
+ if (query !== listQuery) return; // the filters changed meanwhile
641
+ listPage = page;
642
+ listTotal = total ?? listTotal;
643
+ lastResults = offerings;
644
+ renderList(offerings);
645
+ $('#m-list').scrollTop = 0;
646
+ updatePager();
647
+ } catch (e) { updatePager(); $('#m-pager').insertAdjacentHTML('beforeend', '<span class="err">' + esc(e.message) + '</span>'); }
648
+ }
649
+
650
+ // Render one page of the list, keeping the highlighted model when it is on this page.
651
+ function renderList(offerings) {
652
+ $('#m-list').innerHTML = offerings.map(renderItem).join('')
653
+ || '<p class="sub">No offerings match. A fresh registry fills up via catalog imports and node registrations.</p>';
654
+ document.querySelectorAll('.mitem').forEach((el) => el.addEventListener('click', () => selectModel(el)));
655
+ const current = $('#model-card')?.dataset.artifact;
656
+ document.querySelectorAll('.mitem').forEach((el) => el.classList.toggle('sel', el.dataset.artifact === current));
657
+ }
658
+
609
659
  async function search() {
610
- const p = new URLSearchParams({ limit: 60 });
660
+ const p = new URLSearchParams({ limit: PAGE });
611
661
  if ($('#f-q').value) p.set('q', $('#f-q').value);
612
662
  if ($('#f-modality').value) p.set('modality', $('#f-modality').value);
613
663
  if ($('#f-verified').classList.contains('on')) p.set('tier', 'verified');
@@ -615,21 +665,22 @@ async function search() {
615
665
  if ($('#f-price').value) p.set('max_input_price', $('#f-price').value);
616
666
  if (sup.size) p.set('supports', [...sup].join(','));
617
667
  p.set('sort', $('#f-sort').value);
668
+ listQuery = p.toString();
618
669
  try {
619
670
  const { offerings, total } = await api('/v0/offerings?' + p);
671
+ if (listQuery !== p.toString()) return; // a newer search is under way
620
672
  lastResults = offerings;
621
- const n = total ?? offerings.length;
622
- $('#m-counts').textContent = fmtInt(n) + ' offerings' + (offerings.length < n ? ` · showing top ${offerings.length}` : '');
623
- $('#m-list').innerHTML = offerings.map(renderItem).join('')
624
- || '<p class="sub">No offerings match. A fresh registry fills up via catalog imports and node registrations.</p>';
625
- document.querySelectorAll('.mitem').forEach((el) => el.addEventListener('click', () => selectModel(el)));
673
+ listTotal = total ?? offerings.length;
674
+ listPage = 0;
675
+ renderList(offerings);
676
+ updatePager();
626
677
  // keep the current selection when still listed, else select the first result
627
678
  const current = $('#model-card')?.dataset.artifact;
628
679
  const keep = current && [...document.querySelectorAll('.mitem')].find((el) => el.dataset.artifact === current);
629
680
  const target = keep ?? document.querySelector('.mitem');
630
681
  if (target) selectModel(target);
631
682
  else $('#m-detail').innerHTML = '<p class="sub">No offerings match.</p>';
632
- } catch (e) { $('#m-list').innerHTML = '<p class="err">' + esc(e.message) + '</p>'; }
683
+ } catch (e) { $('#m-list').innerHTML = '<p class="err">' + esc(e.message) + '</p>'; $('#m-pager').innerHTML = ''; }
633
684
  }
634
685
 
635
686
  /* ---------- advisor (ONP-3 §5b) ---------- */