@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/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # @opennodes/registry
2
+
3
+ The OpenNodes Protocol (ONP) reference registry — an open, federatable discovery service for AI inference nodes.
4
+
5
+ ```bash
6
+ npx @opennodes/registry # web app + API on http://127.0.0.1:4300
7
+ ONP_DB=./registry.db npx @opennodes/registry # persistent SQLite + persistent signing key
8
+ ONP_DB=postgres://user:pass@host/db npx @opennodes/registry
9
+ ```
10
+
11
+ What it does:
12
+
13
+ - **Registration with proof of resource** — namespace challenge (HTTP or DNS TXT), Stage A conformance probes, Stage B identity fingerprinting / context verification / capacity ramp, Stage C continuous liveness and blind probes. Trust tiers: `unverified → community → verified` (+ `disputed`, `suspended`). Claimed ≠ measured ≠ attested — always labeled, never blended.
14
+ - **Search and transparent ranking** (`/v0/offerings`, `sort=rank|price|ttft|tps`) with per-result `rank_explanation`; **enforceable estimates** (`POST /v0/estimate`) pinned to card revisions; the **advisor** (`POST /v0/recommend`).
15
+ - **Catalog importers** — OpenRouter, Hugging Face Inference Providers, models.dev, the Ollama library — so search is useful on day one, with imported listings labeled and claimable by their operators.
16
+ - **MCP surface** at `/mcp` and a signed **export feed** for federation.
17
+ - **Web app** at `/`: OpenRouter-style catalog with model and node pages, hardware and measured performance, playground, multi-model chat (compare / jury / pipeline / iterative), the advisor, and the Node Builder.
18
+
19
+ Environment: `PORT`, `ONP_DB`, `ONP_REGISTRY_KEY`, `ONP_ADMIN_TOKEN`, `ONP_ALLOW_PRIVATE=1` (dev only: permit localhost nodes), `ONP_STAGE_C=0`, `ONP_REIMPORT=1`.
20
+
21
+ Part of [OpenNodes](https://opennodes.io) · [specification](https://github.com/opennodes-io/opennodes/tree/main/spec) · Apache-2.0.
package/bin.js ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import { startRegistry } from './src/server.js';
3
+
4
+ const dbPath = process.env.ONP_DB ?? ':memory:'; // sqlite path/:memory: or postgres:// URL
5
+ const persistent = dbPath !== ':memory:' && !dbPath.startsWith('postgres');
6
+ const registry = await startRegistry({
7
+ port: Number(process.env.PORT ?? 4300),
8
+ dbPath,
9
+ // Persistent signing identity: explicit path, or derived from a file db; pg deployments set ONP_REGISTRY_KEY.
10
+ keyPath: process.env.ONP_REGISTRY_KEY ?? (persistent ? `${dbPath}.key.pem` : (dbPath.startsWith('postgres') ? 'onp-registry-key.pem' : null)),
11
+ allowPrivateTargets: process.env.ONP_ALLOW_PRIVATE === '1', // dev only: permit localhost nodes
12
+ adminToken: process.env.ONP_ADMIN_TOKEN ?? null,
13
+ stageC: process.env.ONP_STAGE_C === '0' ? null : {
14
+ livenessIntervalMs: Number(process.env.ONP_LIVENESS_MS ?? 300_000),
15
+ blindIntervalMs: Number(process.env.ONP_BLIND_MS ?? 3_600_000),
16
+ },
17
+ reimport: process.env.ONP_REIMPORT === '1' ? {
18
+ intervalMs: Number(process.env.ONP_REIMPORT_MS ?? 6 * 3600 * 1000),
19
+ sources: (process.env.ONP_REIMPORT_SOURCES ?? 'openrouter,huggingface').split(','),
20
+ } : null,
21
+ });
22
+ if (process.env.ONP_ALLOW_PRIVATE === '1') console.log('WARNING: SSRF guard disabled (ONP_ALLOW_PRIVATE=1) — dev only');
23
+ console.log(`opennodes registry listening at ${registry.origin}`);
24
+ console.log(`health: ${registry.origin}/v0/health`);
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@opennodes/registry",
3
+ "version": "0.1.0",
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
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "src/server.js",
8
+ "exports": { ".": "./src/server.js", "./src/*": "./src/*" },
9
+ "bin": { "onp-registry": "bin.js" },
10
+ "files": ["bin.js", "src", "README.md"],
11
+ "keywords": ["opennodes", "onp", "registry", "ai", "inference", "discovery", "mcp", "catalog"],
12
+ "homepage": "https://opennodes.io",
13
+ "repository": { "type": "git", "url": "git+https://github.com/opennodes-io/opennodes.git", "directory": "impl/packages/registry" },
14
+ "bugs": { "url": "https://github.com/opennodes-io/opennodes/issues" },
15
+ "engines": { "node": ">=22.5" },
16
+ "publishConfig": { "access": "public" },
17
+ "dependencies": { "@opennodes/core": "0.1.0", "pg": "^8.13.1" }
18
+ }
package/src/export.js ADDED
@@ -0,0 +1,26 @@
1
+ // DS-EXP-01: signed NDJSON mirror feed so downstream registries/aggregators can
2
+ // replicate without re-crawling. Each line = { payload, jws } signed by this registry,
3
+ // so provenance survives mirroring.
4
+ import { signJson } from '@opennodes/core';
5
+
6
+ export function exportRoutes(store, registryKeys) {
7
+ return [
8
+ ['GET', '/v0/export', async (req, res, { query }) => {
9
+ const since = query.get('since') ?? '';
10
+ res.writeHead(200, { 'content-type': 'application/x-ndjson' });
11
+ for (const node of await store.allNodes()) {
12
+ if (since && node.updated_at <= since) continue;
13
+ const card = await store.latestCard(node.id);
14
+ const payload = {
15
+ type: 'node',
16
+ node: { id: node.id, origin: node.origin, state: node.state, source: node.source, updated_at: node.updated_at },
17
+ card,
18
+ observations: await store.observationsForNode(node.id),
19
+ };
20
+ const jws = signJson(payload, registryKeys.privateKey, 'registry-1');
21
+ res.write(JSON.stringify({ payload, jws }) + '\n');
22
+ }
23
+ res.end();
24
+ }],
25
+ ];
26
+ }
@@ -0,0 +1,346 @@
1
+ // DS-IMP-01: seed importers so search is useful before any real registration.
2
+ // Imported records enter as unverified, unsigned, source='import', and are
3
+ // excluded from verified tiers until claimed by their operator.
4
+ import { validateCard } from '@opennodes/core';
5
+
6
+ const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[^a-z0-9]+/, '').slice(0, 100);
7
+ const vendorSlug = (s) => slug(s).replace(/[._]/g, '-');
8
+
9
+ /** Map input/output modality arrays (the shape both HF and OpenRouter use) to the ONP enum. */
10
+ export function mapModalities(inputs = ['text'], outputs = ['text']) {
11
+ const out = new Set(outputs);
12
+ if (out.has('image')) return 'image';
13
+ if (out.has('video')) return 'video';
14
+ if (out.has('audio')) return 'audio';
15
+ if (out.has('embedding') || out.has('embeddings')) return 'embedding';
16
+ return inputs.some((i) => i !== 'text') ? 'multimodal' : 'text';
17
+ }
18
+
19
+ const PIPELINE_TAG_MODALITY = {
20
+ 'text-generation': 'text', 'text2text-generation': 'text', 'conversational': 'text',
21
+ 'image-text-to-text': 'multimodal', 'visual-question-answering': 'multimodal',
22
+ 'text-to-image': 'image', 'image-to-image': 'image',
23
+ 'text-to-video': 'video', 'image-to-video': 'video', 'image-text-to-video': 'video',
24
+ 'text-to-speech': 'audio', 'automatic-speech-recognition': 'audio', 'audio-to-audio': 'audio',
25
+ 'feature-extraction': 'embedding', 'sentence-similarity': 'embedding',
26
+ };
27
+
28
+ /** Per-token price strings/values -> USD per MTok. Values >= 0.01 are assumed per-MTok already. */
29
+ const perMtok = (v) => {
30
+ const n = Number(v);
31
+ if (!Number.isFinite(n) || n <= 0) return 0;
32
+ return Math.round((n < 0.01 ? n * 1_000_000 : n) * 1e6) / 1e6;
33
+ };
34
+
35
+ async function saveImportedCard(store, nodeId, origin, sourceRef, card) {
36
+ const errors = validateCard(card);
37
+ if (errors.length) return errors[0];
38
+ await store.upsertNode(nodeId, origin, sourceRef, 'indexed', 'import');
39
+ await store.saveRevision(nodeId, card.revision, card, false);
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Import a models.dev-shaped catalog: { providerId: { id, name, api?, doc?, models: { key: model } } }.
45
+ * Each provider becomes a synthetic node `import.modelsdev.{provider}`; each model an offering.
46
+ * Returns { providers, offerings, skipped }.
47
+ */
48
+ export async function importModelsDev(store, catalog) {
49
+ let providers = 0, offerings = 0;
50
+ const skipped = [];
51
+ for (const [providerId, provider] of Object.entries(catalog ?? {})) {
52
+ const models = provider?.models ?? {};
53
+ const offeringList = [];
54
+ for (const [modelKey, model] of Object.entries(models)) {
55
+ const outputs = model?.modalities?.output ?? ['text'];
56
+ const modality = outputs.includes('text') ? 'text' : outputs.includes('image') ? 'image' : 'text';
57
+ offeringList.push({
58
+ offering_id: slug(modelKey) || 'model',
59
+ modality,
60
+ model: {
61
+ name: model?.name ?? modelKey,
62
+ family: slug(providerId),
63
+ artifact: `urn:proprietary:${slug(providerId).replace(/[._]/g, '-')}:${slug(model?.id ?? modelKey).replace(/\//g, '-')}`,
64
+ },
65
+ serving: {
66
+ ...(model?.limit?.context ? { context_window: Math.floor(model.limit.context) } : {}),
67
+ ...(model?.limit?.output ? { max_output_tokens: Math.floor(model.limit.output) } : {}),
68
+ ...(model?.tool_call ? { supports: ['tool_calls'] } : {}),
69
+ },
70
+ binding: { profile: 'onp.openai.chat/v1', model_id: model?.id ?? modelKey },
71
+ pricing: {
72
+ currency: 'USD',
73
+ input_per_mtok: Number(model?.cost?.input ?? 0),
74
+ output_per_mtok: Number(model?.cost?.output ?? 0),
75
+ schemes: ['prepaid'],
76
+ },
77
+ });
78
+ }
79
+ if (!offeringList.length) { skipped.push(providerId); continue; }
80
+
81
+ const nodeId = `import.modelsdev.${slug(providerId).replace(/[._]/g, '-')}`;
82
+ const base = provider?.api ?? provider?.doc ?? 'https://models.dev';
83
+ const card = {
84
+ onp: '0.1',
85
+ revision: new Date().toISOString(),
86
+ node: {
87
+ id: nodeId,
88
+ name: provider?.name ?? providerId,
89
+ description: `Imported from models.dev; metadata only, unclaimed by the operator.`,
90
+ operator: { name: provider?.name ?? providerId },
91
+ },
92
+ endpoints: { openai: base, health: `${base.replace(/\/$/, '')}/health` },
93
+ offerings: offeringList,
94
+ payment: { schemes: [{ scheme: 'prepaid' }] },
95
+ };
96
+ const errors = validateCard(card);
97
+ if (errors.length) { skipped.push(`${providerId}: ${errors[0]}`); continue; }
98
+
99
+ await store.upsertNode(nodeId, new URL(base).origin, `import:modelsdev:${providerId}`, 'indexed', 'import');
100
+ await store.saveRevision(nodeId, card.revision, card, false);
101
+ providers++;
102
+ offerings += offeringList.length;
103
+ }
104
+ return { providers, offerings, skipped };
105
+ }
106
+
107
+ export const HF_ROUTER_URL = 'https://router.huggingface.co/v1/models';
108
+ export const HF_HUB_URL = 'https://huggingface.co/api/models?inference_provider=all&limit=1000&expand[]=inferenceProviderMapping&expand[]=pipeline_tag&expand[]=safetensors';
109
+
110
+ /**
111
+ * Import Hugging Face Inference Providers: `router` is the /v1/models response
112
+ * (live serving: per-provider status/capabilities/perf), `hub` the Hub API list
113
+ * (params_b from safetensors, pipeline_tag). One synthetic node per provider
114
+ * (`import.hf.{provider}`); artifact `hf:{repo}` is authoritative.
115
+ */
116
+ export async function importHuggingFace(store, router, hub = []) {
117
+ const hubById = new Map((hub ?? []).map((m) => [m.id, m]));
118
+ const byProvider = new Map(); // provider -> offerings[]
119
+
120
+ for (const model of router?.data ?? []) {
121
+ const hubEntry = hubById.get(model.id);
122
+ const paramsB = hubEntry?.safetensors?.total
123
+ ? Math.round(hubEntry.safetensors.total / 1e7) / 100 : undefined;
124
+ const inputs = model.architecture?.input_modalities ?? ['text'];
125
+ const outputs = model.architecture?.output_modalities ?? ['text'];
126
+ const modality = hubEntry?.pipeline_tag && PIPELINE_TAG_MODALITY[hubEntry.pipeline_tag]
127
+ ? PIPELINE_TAG_MODALITY[hubEntry.pipeline_tag]
128
+ : mapModalities(inputs, outputs);
129
+
130
+ for (const p of model.providers ?? []) {
131
+ if (p.status !== 'live') continue;
132
+ const supports = ['streaming'];
133
+ if (p.supports_tools) supports.push('tool_calls');
134
+ if (p.supports_structured_output) supports.push('json_mode');
135
+ if (inputs.includes('image') && outputs.includes('text')) supports.push('vision');
136
+ const offering = {
137
+ offering_id: slug(model.id),
138
+ modality,
139
+ modalities: { input: inputs, output: outputs }, // Draft 0.2 field, additive
140
+ model: {
141
+ name: model.id,
142
+ family: vendorSlug(model.owned_by ?? model.id.split('/')[0]),
143
+ artifact: `hf:${model.id}`,
144
+ ...(paramsB ? { params_b: paramsB } : {}),
145
+ },
146
+ serving: {
147
+ supports,
148
+ ...(p.context_length ? { context_window: Math.floor(p.context_length) } : {}),
149
+ ...(Number.isFinite(p.first_token_latency_ms)
150
+ ? { expected_ttft_ms: { p50: Math.round(p.first_token_latency_ms), basis: 'claimed' } } : {}),
151
+ ...(Number.isFinite(p.throughput)
152
+ ? { expected_tps: { p50: Math.round(p.throughput), basis: 'claimed' } } : {}),
153
+ },
154
+ binding: { profile: 'onp.openai.chat/v1', model_id: `${model.id}:${p.provider}` },
155
+ pricing: {
156
+ currency: 'USD',
157
+ input_per_mtok: perMtok(p.pricing?.input),
158
+ output_per_mtok: perMtok(p.pricing?.output),
159
+ schemes: p.is_free ? ['free'] : ['prepaid'],
160
+ },
161
+ };
162
+ if (!byProvider.has(p.provider)) byProvider.set(p.provider, []);
163
+ byProvider.get(p.provider).push(offering);
164
+ }
165
+ }
166
+
167
+ let providers = 0, offerings = 0;
168
+ const skipped = [];
169
+ for (const [provider, offeringList] of byProvider) {
170
+ const nodeId = `import.hf.${vendorSlug(provider)}`;
171
+ const card = {
172
+ onp: '0.1',
173
+ revision: new Date().toISOString(),
174
+ node: {
175
+ id: nodeId,
176
+ name: `${provider} (via Hugging Face Inference Providers)`,
177
+ description: 'Imported from the Hugging Face Inference Providers router; metadata only, unclaimed by the operator.',
178
+ operator: { name: provider },
179
+ },
180
+ endpoints: { openai: 'https://router.huggingface.co/v1', health: 'https://router.huggingface.co/health' },
181
+ offerings: offeringList,
182
+ payment: { schemes: [{ scheme: 'prepaid', signup: 'https://huggingface.co/settings/billing' }] },
183
+ };
184
+ const err = await saveImportedCard(store, nodeId, 'https://router.huggingface.co', `import:hf:${provider}`, card);
185
+ if (err) { skipped.push(`${provider}: ${err}`); continue; }
186
+ providers++;
187
+ offerings += offeringList.length;
188
+ }
189
+ return { providers, offerings, skipped };
190
+ }
191
+
192
+ export const OLLAMA_LIBRARY_URL = 'https://ollama.com/library';
193
+
194
+ /** Parse ollama.com/library HTML into entries. Resilient to styling churn:
195
+ * anchors on /library/{name}, spans classified by content (sizes look like "27b"/"270m"). */
196
+ export function parseOllamaLibrary(html) {
197
+ const entries = [];
198
+ const blocks = String(html).split('href="/library/').slice(1);
199
+ for (const block of blocks) {
200
+ const name = block.match(/^([a-z0-9._-]+)"/)?.[1];
201
+ if (!name) continue;
202
+ const chunk = block.slice(0, 6000);
203
+ const description = chunk.match(/<p[^>]*class="[^"]*max-w-lg[^"]*"[^>]*>\s*([^<]{1,400}?)\s*<\/p>/)?.[1]?.trim() ?? null;
204
+ const spans = [...chunk.matchAll(/<span[^>]*class="[^"]*inline-flex[^"]*"[^>]*>\s*([^<]{1,40}?)\s*<\/span>/g)].map((m) => m[1].trim());
205
+ const sizes = spans.filter((s) => /^\d+(\.\d+)?[mb]$/i.test(s));
206
+ const capabilities = spans.filter((s) => !/^\d+(\.\d+)?[mb]$/i.test(s));
207
+ const pulls = chunk.match(/<span[^>]*>\s*([\d.]+[KMB]?)\s*<\/span>\s*<span[^>]*>&nbsp;Pulls/)?.[1] ?? null;
208
+ entries.push({ name, description, capabilities, sizes, pulls });
209
+ }
210
+ return entries;
211
+ }
212
+
213
+ const sizeToParamsB = (s) => {
214
+ const m = String(s).match(/^(\d+(?:\.\d+)?)([mb])$/i);
215
+ if (!m) return null;
216
+ return m[2].toLowerCase() === 'b' ? Number(m[1]) : Math.round(Number(m[1]) / 10) / 100;
217
+ };
218
+
219
+ /**
220
+ * Import the Ollama library as locally-runnable offerings on a synthetic node whose
221
+ * endpoint is the user's OWN machine (http://127.0.0.1:11434/v1) — free, local, and
222
+ * directly usable from the model-page playground once `ollama run <name>` has pulled it.
223
+ */
224
+ export async function importOllamaLibrary(store, entries) {
225
+ const offeringList = [];
226
+ for (const e of entries ?? []) {
227
+ if (!e?.name) continue;
228
+ const caps = (e.capabilities ?? []).map((c) => String(c).toLowerCase());
229
+ const modality = caps.includes('embedding') ? 'embedding' : (caps.includes('vision') ? 'multimodal' : 'text');
230
+ const paramsCandidates = (e.sizes ?? []).map(sizeToParamsB).filter((n) => n != null);
231
+ const supports = ['streaming'];
232
+ if (caps.includes('tools')) supports.push('tool_calls');
233
+ if (caps.includes('vision')) supports.push('vision');
234
+ offeringList.push({
235
+ offering_id: slug(e.name),
236
+ modality,
237
+ model: {
238
+ name: e.name,
239
+ family: vendorSlug(e.name.split(/[.:0-9]/)[0] || e.name),
240
+ artifact: `urn:proprietary:ollama:${vendorSlug(e.name)}`,
241
+ ...(paramsCandidates.length ? { params_b: Math.max(...paramsCandidates) } : {}),
242
+ ...(e.description ? { description: String(e.description).slice(0, 500) } : {}),
243
+ },
244
+ serving: { supports },
245
+ binding: {
246
+ profile: modality === 'embedding' ? 'onp.openai.embeddings/v1' : 'onp.openai.chat/v1',
247
+ model_id: e.name,
248
+ },
249
+ pricing: { currency: 'USD', input_per_mtok: 0, output_per_mtok: 0, schemes: ['free'] },
250
+ local: {
251
+ runtime: 'ollama',
252
+ run: `ollama run ${e.name}`,
253
+ ...(e.pulls ? { pulls: e.pulls } : {}),
254
+ ...(e.sizes?.length ? { sizes: e.sizes } : {}),
255
+ },
256
+ });
257
+ }
258
+ if (!offeringList.length) return { providers: 0, offerings: 0, skipped: ['no entries'] };
259
+ const nodeId = 'import.ollama.library';
260
+ const card = {
261
+ onp: '0.1',
262
+ revision: new Date().toISOString(),
263
+ node: {
264
+ id: nodeId,
265
+ name: 'Ollama library (runs on your machine)',
266
+ description: 'Locally-runnable models from the Ollama library. The endpoint is your own machine: install Ollama, `ollama run <model>`, and these offerings serve at localhost — free, private, no network.',
267
+ operator: { name: 'Ollama (community library)' },
268
+ },
269
+ endpoints: { openai: 'http://127.0.0.1:11434/v1', health: 'http://127.0.0.1:11434' },
270
+ offerings: offeringList,
271
+ payment: { schemes: [{ scheme: 'free' }] },
272
+ };
273
+ const err = await saveImportedCard(store, nodeId, 'https://ollama.com', 'import:ollama-library', card);
274
+ if (err) return { providers: 0, offerings: 0, skipped: [err] };
275
+ return { providers: 1, offerings: offeringList.length, skipped: [] };
276
+ }
277
+
278
+ export const OPENROUTER_URL = 'https://openrouter.ai/api/v1/models';
279
+
280
+ /**
281
+ * Import the OpenRouter catalog as one synthetic node (`import.openrouter.catalog`):
282
+ * per-token price strings -> per-MTok; `hugging_face_id` gives the artifact join
283
+ * where present, else a proprietary URN under the model author's namespace.
284
+ */
285
+ export async function importOpenRouter(store, catalog) {
286
+ const offeringList = [];
287
+ for (const model of catalog?.data ?? []) {
288
+ const inputs = model.architecture?.input_modalities ?? ['text'];
289
+ const outputs = model.architecture?.output_modalities ?? ['text'];
290
+ const supports = ['streaming'];
291
+ const params = model.supported_parameters ?? [];
292
+ if (params.includes('tools')) supports.push('tool_calls');
293
+ if (params.includes('response_format') || params.includes('structured_outputs')) supports.push('json_mode');
294
+ if (inputs.includes('image') && outputs.includes('text')) supports.push('vision');
295
+ const author = vendorSlug(model.id.split('/')[0] || 'unknown');
296
+ offeringList.push({
297
+ offering_id: slug(model.id),
298
+ modality: mapModalities(inputs, outputs),
299
+ modalities: { input: inputs, output: outputs },
300
+ model: {
301
+ name: model.name ?? model.id,
302
+ family: author,
303
+ artifact: model.hugging_face_id
304
+ ? `hf:${model.hugging_face_id}`
305
+ : `urn:proprietary:${author}:${slug(model.id.split('/').slice(1).join('-') || model.id)}`,
306
+ ...(model.description ? { description: String(model.description).slice(0, 500) } : {}),
307
+ ...(Number.isFinite(model.created)
308
+ ? { released: new Date(model.created * 1000).toISOString().slice(0, 10) } : {}),
309
+ },
310
+ serving: {
311
+ supports,
312
+ ...(model.context_length ? { context_window: Math.floor(model.context_length) } : {}),
313
+ ...(model.top_provider?.max_completion_tokens
314
+ ? { max_output_tokens: Math.floor(model.top_provider.max_completion_tokens) } : {}),
315
+ },
316
+ binding: { profile: 'onp.openai.chat/v1', model_id: model.id },
317
+ pricing: {
318
+ currency: 'USD',
319
+ input_per_mtok: perMtok(model.pricing?.prompt),
320
+ output_per_mtok: perMtok(model.pricing?.completion),
321
+ ...(model.pricing?.input_cache_read
322
+ ? { cached_input_per_mtok: perMtok(model.pricing.input_cache_read) } : {}),
323
+ schemes: Number(model.pricing?.prompt) === 0 ? ['free'] : ['prepaid'],
324
+ },
325
+ });
326
+ }
327
+ if (!offeringList.length) return { providers: 0, offerings: 0, skipped: ['empty catalog'] };
328
+
329
+ const nodeId = 'import.openrouter.catalog';
330
+ const card = {
331
+ onp: '0.1',
332
+ revision: new Date().toISOString(),
333
+ node: {
334
+ id: nodeId,
335
+ name: 'OpenRouter catalog',
336
+ description: 'Imported from the public OpenRouter models API; metadata only, unclaimed by the operator.',
337
+ operator: { name: 'OpenRouter' },
338
+ },
339
+ endpoints: { openai: 'https://openrouter.ai/api/v1', health: 'https://openrouter.ai/api/v1/models' },
340
+ offerings: offeringList,
341
+ payment: { schemes: [{ scheme: 'prepaid', signup: 'https://openrouter.ai/keys' }] },
342
+ };
343
+ const err = await saveImportedCard(store, nodeId, 'https://openrouter.ai', 'import:openrouter', card);
344
+ if (err) return { providers: 0, offerings: 0, skipped: [err] };
345
+ return { providers: 1, offerings: offeringList.length, skipped: [] };
346
+ }
package/src/mcp.js ADDED
@@ -0,0 +1,182 @@
1
+ // DS-MCP-01: the registry as an MCP server (streamable HTTP, stateless).
2
+ // Minimal JSON-RPC handler covering initialize / tools/list / tools/call so any
3
+ // MCP host gets ONP discovery (search_offerings, get_offering, estimate) for free.
4
+ import { readJson, sendJson, problem, estimateBounds, round6, extractFeatures, recommend } from '@opennodes/core';
5
+ import { searchOfferings, collectOfferings } from './search.js';
6
+
7
+ const PROTOCOL_VERSION = '2025-06-18';
8
+
9
+ const TOOLS = [
10
+ {
11
+ name: 'search_offerings',
12
+ description: 'Search AI inference offerings in the OpenNodes registry. Filters: modality (text|image|audio|video|embedding|multimodal|agent), family, min_context, supports (comma list: tool_calls,json_mode,vision,streaming), max_input_price (USD per MTok), scheme (free|prepaid|x402), tier (community|verified|attested), lang ("en:strong"), q (free text), sort (rank|price). Returns offerings with pricing, trust tier, and rank explanation.',
13
+ inputSchema: {
14
+ type: 'object',
15
+ properties: {
16
+ modality: { type: 'string' }, family: { type: 'string' },
17
+ min_context: { type: 'number' }, supports: { type: 'string' },
18
+ max_input_price: { type: 'number' }, scheme: { type: 'string' },
19
+ tier: { type: 'string' }, lang: { type: 'string' },
20
+ q: { type: 'string' }, sort: { type: 'string' }, limit: { type: 'number' },
21
+ },
22
+ },
23
+ },
24
+ {
25
+ name: 'get_offering',
26
+ description: 'Get full detail for one offering by node_id and offering_id, including endpoints, pricing, serving capabilities, and observed probe results.',
27
+ inputSchema: {
28
+ type: 'object',
29
+ required: ['node_id', 'offering_id'],
30
+ properties: { node_id: { type: 'string' }, offering_id: { type: 'string' } },
31
+ },
32
+ },
33
+ {
34
+ name: 'estimate',
35
+ description: 'Estimate cost bounds for a planned invocation chain BEFORE spending money. Each step names a concrete offering ("node_id/offering_id") or a select query (same filters as search_offerings), plus est_input_tokens and est_output_tokens. Returns per-step cost, pinned card_revision (making the estimate enforceable at invocation), and totals.',
36
+ inputSchema: {
37
+ type: 'object',
38
+ required: ['steps'],
39
+ properties: {
40
+ steps: {
41
+ type: 'array',
42
+ items: {
43
+ type: 'object',
44
+ properties: {
45
+ offering: { type: 'string' },
46
+ select: { type: 'object' },
47
+ est_input_tokens: { type: 'number' },
48
+ est_output_tokens: { type: 'number' },
49
+ },
50
+ },
51
+ },
52
+ },
53
+ },
54
+ },
55
+ {
56
+ name: 'recommend',
57
+ description: 'Recommend the best AI node + model for a task. Give the task (a description or the prompt itself) and an optional policy; returns the top offerings with score components, human-readable reasons, an enforceable cost estimate with pinned card_revision, an optional two-step scenario (cheap triage/draft → strong expert/refine) when it would save money, and MCP tool categories the task likely needs. Prefer passing `features` (from a local extractFeatures) instead of `task` when the prompt is private.',
58
+ inputSchema: {
59
+ type: 'object',
60
+ properties: {
61
+ task: { type: 'string', description: 'task description or prompt' },
62
+ features: { type: 'object', description: 'pre-extracted features (privacy-preserving alternative to task)' },
63
+ est_output_tokens: { type: 'number' },
64
+ policy: {
65
+ type: 'object',
66
+ properties: {
67
+ min_tier: { type: 'string' }, max_input_per_mtok: { type: 'number' }, max_total_usd: { type: 'number' },
68
+ region: { type: 'string' }, prefer_local: { type: 'boolean' },
69
+ preset: { type: 'string', description: 'cheap | fast | quality | private' }, limit: { type: 'number' },
70
+ },
71
+ },
72
+ },
73
+ },
74
+ },
75
+ ];
76
+
77
+ async function runTool(store, name, args = {}) {
78
+ switch (name) {
79
+ case 'recommend': {
80
+ const features = args.features ?? (args.task != null
81
+ ? extractFeatures(String(args.task), { est_output_tokens: args.est_output_tokens ?? null }) : null);
82
+ if (!features) throw new Error('recommend needs "task" or "features"');
83
+ return recommend(await collectOfferings(store), features, args.policy ?? {});
84
+ }
85
+ case 'search_offerings': {
86
+ const query = new URLSearchParams();
87
+ for (const [k, v] of Object.entries(args)) if (v !== undefined && v !== null) query.set(k, String(v));
88
+ return { offerings: await searchOfferings(store, query) };
89
+ }
90
+ case 'get_offering': {
91
+ const found = (await collectOfferings(store)).find(
92
+ (o) => o.node_id === args.node_id && o.offering_id === args.offering_id);
93
+ if (!found) throw new Error(`unknown offering: ${args.node_id}/${args.offering_id}`);
94
+ return found;
95
+ }
96
+ case 'estimate': {
97
+ const all = await collectOfferings(store);
98
+ const steps = [];
99
+ let min = 0, max = 0, currency = 'USD';
100
+ for (const step of args.steps ?? []) {
101
+ let offering;
102
+ if (step.offering) {
103
+ const [nodeId, offeringId] = step.offering.split('/');
104
+ offering = all.find((o) => o.node_id === nodeId && o.offering_id === offeringId);
105
+ if (!offering) throw new Error(`unknown offering: ${step.offering}`);
106
+ } else if (step.select) {
107
+ const query = new URLSearchParams();
108
+ for (const [k, v] of Object.entries(step.select)) query.set(k, String(v));
109
+ offering = (await searchOfferings(store, query))[0];
110
+ if (!offering) throw new Error(`no offering matches select: ${JSON.stringify(step.select)}`);
111
+ } else {
112
+ throw new Error('each step needs "offering" or "select"');
113
+ }
114
+ const bounds = estimateBounds(offering.pricing, step.est_input_tokens ?? 0, step.est_output_tokens ?? 0);
115
+ currency = offering.pricing.currency;
116
+ min += bounds.min; max += bounds.max;
117
+ steps.push({
118
+ offering: `${offering.node_id}/${offering.offering_id}`,
119
+ card_revision: offering.card_revision,
120
+ cost: { currency, ...bounds },
121
+ });
122
+ }
123
+ return { total: { currency, min: round6(min), max: round6(max) }, steps };
124
+ }
125
+ default:
126
+ throw new Error(`unknown tool: ${name}`);
127
+ }
128
+ }
129
+
130
+ async function handleRpc(store, msg) {
131
+ const { id, method, params } = msg;
132
+ const reply = (result) => ({ jsonrpc: '2.0', id, result });
133
+ const rpcError = (code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
134
+
135
+ switch (method) {
136
+ case 'initialize':
137
+ return reply({
138
+ protocolVersion: params?.protocolVersion ?? PROTOCOL_VERSION,
139
+ capabilities: { tools: { listChanged: false } },
140
+ serverInfo: { name: 'opennodes-registry', version: '0.1.0' },
141
+ instructions: 'OpenNodes registry: search AI inference offerings, inspect them, and pre-price invocation chains. Typical flow: search_offerings -> estimate -> invoke via the offering endpoints (invocation itself is not exposed here).',
142
+ });
143
+ case 'ping':
144
+ return reply({});
145
+ case 'tools/list':
146
+ return reply({ tools: TOOLS });
147
+ case 'tools/call':
148
+ try {
149
+ const result = await runTool(store, params?.name, params?.arguments);
150
+ return reply({
151
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
152
+ structuredContent: result,
153
+ isError: false,
154
+ });
155
+ } catch (err) {
156
+ return reply({ content: [{ type: 'text', text: String(err.message) }], isError: true });
157
+ }
158
+ case 'resources/list':
159
+ return reply({ resources: [] });
160
+ case 'prompts/list':
161
+ return reply({ prompts: [] });
162
+ default:
163
+ if (method?.startsWith('notifications/')) return null; // notifications get no response
164
+ return rpcError(-32601, `method not found: ${method}`);
165
+ }
166
+ }
167
+
168
+ /** Routes to mount into the registry app. */
169
+ export function mcpRoutes(store) {
170
+ return [
171
+ ['POST', '/mcp', async (req, res) => {
172
+ const body = await readJson(req, 5_000_000);
173
+ const messages = Array.isArray(body) ? body : [body];
174
+ const replies = (await Promise.all(messages.map((m) => handleRpc(store, m)))).filter(Boolean);
175
+ if (replies.length === 0) { res.writeHead(202); res.end(); return; }
176
+ sendJson(res, 200, Array.isArray(body) ? replies : replies[0]);
177
+ }],
178
+ ['GET', '/mcp', (req, res) => {
179
+ problem(res, 405, 'method-not-allowed', 'stateless MCP server: POST JSON-RPC messages to this endpoint');
180
+ }],
181
+ ];
182
+ }