@opennodes/registry 0.1.0

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/src/probes.js ADDED
@@ -0,0 +1,71 @@
1
+ // Stage A admission probes (DS-ADM-01): health, blind invocation, /models agreement.
2
+ // All outbound traffic goes through the SSRF-guarded fetcher (safefetch.js).
3
+ import { fetchJsonCapped } from './safefetch.js';
4
+
5
+ /** Run Stage A against a card. Records results via store; returns { ok, results }. */
6
+ export async function runStageA(store, nodeId, card, { safeFetch } = {}) {
7
+ if (!safeFetch) safeFetch = (url, opts = {}, t = 10_000) => fetch(url, { ...opts, signal: AbortSignal.timeout(t) });
8
+ const fetchJson = (url, opts, timeoutMs = 10_000) => fetchJsonCapped(safeFetch, url, opts, timeoutMs);
9
+ const results = [];
10
+ const record = async (offeringId, kind, ok, detail, measured) => {
11
+ await store.recordProbe(nodeId, offeringId, 'A', kind, ok, detail, measured);
12
+ results.push({ offeringId, kind, ok, detail });
13
+ };
14
+
15
+ // 1. Health endpoint
16
+ try {
17
+ const t0 = performance.now();
18
+ const { status, body } = await fetchJson(card.endpoints.health);
19
+ const ok = status === 200 && body?.status === 'ok';
20
+ await record(null, 'health', ok, ok ? null : `status ${status}`, { latency_ms: Math.round(performance.now() - t0) });
21
+ } catch (err) {
22
+ await record(null, 'health', false, String(err.message ?? err));
23
+ }
24
+
25
+ // 2. /models agreement
26
+ let modelIds = new Set();
27
+ try {
28
+ const { status, body } = await fetchJson(`${card.endpoints.openai}/models`);
29
+ modelIds = new Set((body?.data ?? []).map((m) => m.id));
30
+ const cardIds = card.offerings.filter((o) => o.binding.profile.startsWith('onp.openai.')).map((o) => o.binding.model_id);
31
+ const missing = cardIds.filter((id) => !modelIds.has(id));
32
+ await record(null, 'models-agreement', status === 200 && missing.length === 0,
33
+ missing.length ? `card offerings absent from /models: ${missing.join(', ')}` : null);
34
+ } catch (err) {
35
+ await record(null, 'models-agreement', false, String(err.message ?? err));
36
+ }
37
+
38
+ // 3. Blind invocation per chat offering
39
+ for (const offering of card.offerings) {
40
+ if (offering.binding.profile !== 'onp.openai.chat/v1') continue;
41
+ try {
42
+ const t0 = performance.now();
43
+ // Generous timeout: CPU-backed nodes legitimately take tens of seconds per completion.
44
+ const { status, headers, body } = await fetchJson(`${card.endpoints.openai}/chat/completions`, {
45
+ method: 'POST',
46
+ headers: {
47
+ 'content-type': 'application/json',
48
+ 'onp-offering': `${card.node.id}/${offering.offering_id}`,
49
+ 'onp-card-revision': card.revision,
50
+ },
51
+ body: JSON.stringify({
52
+ model: offering.binding.model_id,
53
+ messages: [{ role: 'user', content: 'ONP Stage A conformance probe. Reply briefly.' }],
54
+ max_tokens: 32,
55
+ }),
56
+ }, 90_000);
57
+ const usage = body?.usage;
58
+ const ok = status === 200
59
+ && typeof body?.choices?.[0]?.message?.content === 'string'
60
+ && Number.isFinite(usage?.prompt_tokens) && Number.isFinite(usage?.completion_tokens);
61
+ const hasReceipt = Boolean(headers.get('onp-receipt'));
62
+ await record(offering.offering_id, 'blind-invocation', ok,
63
+ ok ? (hasReceipt ? null : 'no ONP-Receipt header') : `status ${status} or missing usage`,
64
+ { latency_ms: Math.round(performance.now() - t0), usage: usage ?? null, receipt: hasReceipt });
65
+ } catch (err) {
66
+ await record(offering.offering_id, 'blind-invocation', false, String(err.message ?? err));
67
+ }
68
+ }
69
+
70
+ return { ok: results.every((r) => r.ok), results };
71
+ }
@@ -0,0 +1,55 @@
1
+ // Periodic catalog re-import: keeps imported prices/offerings fresh (imports are
2
+ // upserts keyed by node id + revision, so each cycle replaces the previous snapshot).
3
+ import {
4
+ importModelsDev, importHuggingFace, importOpenRouter,
5
+ importOllamaLibrary, parseOllamaLibrary,
6
+ HF_ROUTER_URL, HF_HUB_URL, OPENROUTER_URL, OLLAMA_LIBRARY_URL,
7
+ } from './importers.js';
8
+
9
+ export const MODELS_DEV_URL = 'https://models.dev/api.json';
10
+
11
+ export function startReimport(store, {
12
+ safeFetch,
13
+ intervalMs = 6 * 3600 * 1000, // the HF re-test cadence
14
+ sources = ['openrouter', 'huggingface'],
15
+ onCycle = null,
16
+ } = {}) {
17
+ let stopped = false;
18
+ const json = async (url, t = 120_000) => (await safeFetch(url, {}, t)).json();
19
+
20
+ async function cycle() {
21
+ const results = {};
22
+ for (const source of sources) {
23
+ try {
24
+ if (source === 'openrouter') {
25
+ results[source] = await importOpenRouter(store, await json(OPENROUTER_URL));
26
+ } else if (source === 'huggingface') {
27
+ const router = await json(HF_ROUTER_URL);
28
+ let hub = [];
29
+ try { hub = await json(HF_HUB_URL); } catch { /* enrichment optional */ }
30
+ results[source] = await importHuggingFace(store, router, hub);
31
+ } else if (source === 'models-dev') {
32
+ results[source] = await importModelsDev(store, await json(MODELS_DEV_URL));
33
+ } else if (source === 'ollama') {
34
+ const html = await (await safeFetch(OLLAMA_LIBRARY_URL, {}, 120_000)).text();
35
+ results[source] = await importOllamaLibrary(store, parseOllamaLibrary(html));
36
+ } else {
37
+ results[source] = { error: `unknown source: ${source}` };
38
+ }
39
+ } catch (err) {
40
+ results[source] = { error: String(err.message ?? err) };
41
+ }
42
+ }
43
+ onCycle?.(results);
44
+ return results;
45
+ }
46
+
47
+ const loop = async () => {
48
+ if (stopped) return;
49
+ await cycle().catch(() => {});
50
+ if (!stopped) setTimeout(loop, intervalMs).unref?.();
51
+ };
52
+ setTimeout(loop, 2000).unref?.(); // first cycle shortly after boot
53
+
54
+ return { stop: () => { stopped = true; }, cycle };
55
+ }
@@ -0,0 +1,47 @@
1
+ // SSRF-guarded fetch for probe/card traffic (DS-NFR-03): the registry fetches
2
+ // operator-supplied URLs, so private/reserved targets are denied unless explicitly
3
+ // allowed (dev/e2e). Also caps response size and time.
4
+ import { lookup } from 'node:dns/promises';
5
+ import { isIP } from 'node:net';
6
+
7
+ const MAX_BODY = 5_000_000;
8
+
9
+ function ipIsPrivate(ip) {
10
+ if (ip.includes(':')) { // IPv6
11
+ const low = ip.toLowerCase();
12
+ return low === '::1' || low === '::' || low.startsWith('fc') || low.startsWith('fd')
13
+ || low.startsWith('fe80') || low.startsWith('::ffff:127.') || low.startsWith('::ffff:10.')
14
+ || low.startsWith('::ffff:192.168.');
15
+ }
16
+ const [a, b] = ip.split('.').map(Number);
17
+ return a === 127 || a === 10 || a === 0
18
+ || (a === 172 && b >= 16 && b <= 31)
19
+ || (a === 192 && b === 168)
20
+ || (a === 169 && b === 254);
21
+ }
22
+
23
+ export function makeSafeFetch({ allowPrivate = false } = {}) {
24
+ return async function safeFetch(url, opts = {}, timeoutMs = 15_000) {
25
+ const parsed = new URL(url);
26
+ if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error(`ssrf-blocked: scheme ${parsed.protocol}`);
27
+ if (!allowPrivate) {
28
+ const host = parsed.hostname;
29
+ const ips = isIP(host) ? [host] : (await lookup(host, { all: true })).map((r) => r.address);
30
+ const blocked = ips.find(ipIsPrivate);
31
+ if (blocked || host === 'localhost') throw new Error(`ssrf-blocked: ${host} resolves to private range`);
32
+ }
33
+ const res = await fetch(url, { ...opts, signal: AbortSignal.timeout(timeoutMs), redirect: 'error' });
34
+ const len = Number(res.headers.get('content-length') ?? 0);
35
+ if (len > MAX_BODY) throw new Error('response too large');
36
+ return res;
37
+ };
38
+ }
39
+
40
+ export async function fetchJsonCapped(safeFetch, url, opts = {}, timeoutMs) {
41
+ const res = await safeFetch(url, opts, timeoutMs);
42
+ const text = await res.text();
43
+ if (text.length > MAX_BODY) throw new Error('response too large');
44
+ let body = null;
45
+ try { body = JSON.parse(text); } catch { /* leave null */ }
46
+ return { status: res.status, headers: res.headers, body };
47
+ }
package/src/search.js ADDED
@@ -0,0 +1,118 @@
1
+ // Offering search + transparent ranking (ONP-3 §5–6).
2
+
3
+ export const RANK_COMPONENTS = { price: 0.35, tier: 0.35, probe_success: 0.30 };
4
+ const TIER_SCORE = { unverified: 0, community: 0.4, verified: 0.8, attested: 1.0 };
5
+
6
+ /** Flatten indexed nodes into searchable offering summaries. */
7
+ export async function collectOfferings(store) {
8
+ const out = [];
9
+ for (const node of await store.allNodes()) {
10
+ if (['delisted', 'suspended', 'submitted', 'challenged', 'disputed'].includes(node.state)) continue;
11
+ const card = await store.latestCard(node.id);
12
+ if (!card) continue;
13
+ const probes = await store.probesFor(node.id);
14
+ const stageA = probes.filter((p) => p.stage === 'A');
15
+ const probeSuccess = stageA.length ? stageA.filter((p) => p.ok).length / stageA.length : 0;
16
+ const tier = node.state === 'indexed' ? 'unverified' : node.state;
17
+ const availability = (await store.getObservations(node.id, '_node')).availability ?? null;
18
+ for (const offering of card.offerings) {
19
+ const obs = await store.getObservations(node.id, offering.offering_id);
20
+ // Measured values override claims (DS-ADM-03/04): cap the advertised context to
21
+ // the verified size, and surface registry-measured latency/throughput.
22
+ const serving = { ...offering.serving };
23
+ if (obs.context_cap && obs.context_cap < (serving.context_window ?? Infinity)) {
24
+ serving.context_window = obs.context_cap;
25
+ serving.context_capped_from = offering.serving.context_window;
26
+ }
27
+ out.push({
28
+ node_id: node.id,
29
+ offering_id: offering.offering_id,
30
+ tier,
31
+ source: node.source ?? 'registration',
32
+ institutional: Boolean(node.institutional),
33
+ card_revision: card.revision,
34
+ modality: offering.modality,
35
+ modalities: offering.modalities ?? null,
36
+ local: offering.local ?? null,
37
+ model: offering.model,
38
+ serving,
39
+ binding: offering.binding,
40
+ pricing: offering.pricing,
41
+ data_policy: offering.data_policy ?? null,
42
+ // Allocated hardware for THIS offering (offering-level override wins over the
43
+ // node-level description) — same artifact on different vendors runs on different
44
+ // silicon, and clients deserve to see which. Always basis-labeled, never measured.
45
+ hardware: offering.hardware ?? card.hardware ?? null,
46
+ benchmarks: offering.benchmarks ?? null,
47
+ observed: {
48
+ probe_success: round2(probeSuccess),
49
+ probes: stageA.length,
50
+ ttft_ms: obs.ttft_ms ?? null,
51
+ tps: obs.tps ?? null,
52
+ availability,
53
+ },
54
+ endpoints: card.endpoints,
55
+ });
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ export async function searchOfferings(store, query) {
62
+ let items = await collectOfferings(store);
63
+
64
+ const q = (k) => query.get(k);
65
+ if (q('modality')) items = items.filter((o) => o.modality === q('modality'));
66
+ if (q('family')) items = items.filter((o) => o.model.family === q('family') || o.model.artifact === q('family'));
67
+ if (q('min_context')) items = items.filter((o) => (o.serving?.context_window ?? 0) >= Number(q('min_context')));
68
+ if (q('supports')) {
69
+ const wanted = q('supports').split(',');
70
+ items = items.filter((o) => wanted.every((s) => o.serving?.supports?.includes(s)));
71
+ }
72
+ if (q('max_input_price')) items = items.filter((o) => (o.pricing.input_per_mtok ?? Infinity) <= Number(q('max_input_price')));
73
+ if (q('scheme')) items = items.filter((o) => o.pricing.schemes.includes(q('scheme')));
74
+ if (q('tier')) {
75
+ const order = ['unverified', 'community', 'verified', 'attested'];
76
+ const min = order.indexOf(q('tier'));
77
+ items = items.filter((o) => order.indexOf(o.tier) >= min);
78
+ }
79
+ if (q('lang')) {
80
+ const [lang, minGrade = 'basic'] = q('lang').split(':');
81
+ const grades = ['basic', 'strong', 'native'];
82
+ items = items.filter((o) => (o.serving?.languages ?? []).some(
83
+ (l) => l.lang === lang && grades.indexOf(l.grade) >= grades.indexOf(minGrade)));
84
+ }
85
+ if (q('q')) {
86
+ const needle = q('q').toLowerCase();
87
+ items = items.filter((o) => JSON.stringify(o.model).toLowerCase().includes(needle)
88
+ || o.offering_id.includes(needle) || o.node_id.includes(needle));
89
+ }
90
+
91
+ const maxPrice = Math.max(...items.map((o) => o.pricing.input_per_mtok ?? 0), 0.000001);
92
+ for (const o of items) {
93
+ const components = {
94
+ price: 1 - (o.pricing.input_per_mtok ?? maxPrice) / maxPrice,
95
+ tier: TIER_SCORE[o.tier] ?? 0,
96
+ probe_success: o.observed.probe_success,
97
+ };
98
+ o.rank = round2(Object.entries(RANK_COMPONENTS).reduce((sum, [k, w]) => sum + w * components[k], 0));
99
+ o.rank_explanation = { weights: RANK_COMPONENTS, components };
100
+ }
101
+
102
+ // Performance sorts prefer registry-measured values; claimed expected_* is the
103
+ // fallback, and offerings with neither sink to the bottom (ONP-3 §4).
104
+ const ttftOf = (o) => o.observed.ttft_ms?.p50 ?? o.serving?.expected_ttft_ms?.p50 ?? Infinity;
105
+ const tpsOf = (o) => (o.observed.tps > 0 ? o.observed.tps : null) ?? o.serving?.expected_tps?.p50 ?? -Infinity;
106
+ const sort = q('sort') ?? 'rank';
107
+ items.sort((a, b) => {
108
+ if (sort === 'price') return (a.pricing.input_per_mtok ?? Infinity) - (b.pricing.input_per_mtok ?? Infinity);
109
+ if (sort === 'ttft') return ttftOf(a) - ttftOf(b);
110
+ if (sort === 'tps') return tpsOf(b) - tpsOf(a);
111
+ return b.rank - a.rank;
112
+ });
113
+
114
+ const limit = Math.min(Number(q('limit') ?? 50), 200);
115
+ return items.slice(0, limit);
116
+ }
117
+
118
+ const round2 = (n) => Math.round(n * 100) / 100;