@opennodes/registry 0.1.11 → 0.1.13

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.11",
3
+ "version": "0.1.13",
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",
@@ -43,7 +43,7 @@
43
43
  "access": "public"
44
44
  },
45
45
  "dependencies": {
46
- "@opennodes/core": "0.1.1",
46
+ "@opennodes/core": "0.1.2",
47
47
  "pg": "^8.13.1"
48
48
  }
49
49
  }
package/src/importers.js CHANGED
@@ -5,6 +5,12 @@ import { validateCard } from '@opennodes/core';
5
5
 
6
6
  const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[^a-z0-9]+/, '').slice(0, 100);
7
7
  const vendorSlug = (s) => slug(s).replace(/[._]/g, '-');
8
+ /** The value as a string if it is a concrete http(s) URL, else undefined. Templated
9
+ * per-account URLs (models.dev writes `https://${DATABRICKS_HOST}/...`) have no single base. */
10
+ const httpsUrl = (v) => {
11
+ if (typeof v !== 'string' || v.includes('${')) return undefined;
12
+ try { return ['https:', 'http:'].includes(new URL(v).protocol) ? v : undefined; } catch { return undefined; }
13
+ };
8
14
 
9
15
  /** Map input/output modality arrays (the shape both HF and OpenRouter use) to the ONP enum. */
10
16
  export function mapModalities(inputs = ['text'], outputs = ['text']) {
@@ -40,9 +46,26 @@ async function saveImportedCard(store, nodeId, origin, sourceRef, card) {
40
46
  return null;
41
47
  }
42
48
 
49
+ /**
50
+ * OpenAI-compatible API bases for major providers whose models.dev entry has no `api`
51
+ * (models.dev omits it when a dedicated AI SDK package knows the URL). Each checked
52
+ * against the provider's docs and live (401 on unauthenticated /models), 2026-10-19.
53
+ */
54
+ export const MODELS_DEV_API_BASES = {
55
+ groq: 'https://api.groq.com/openai/v1',
56
+ togetherai: 'https://api.together.xyz/v1', // docs now show api.together.ai; both serve, and the gateway keys TOGETHER_API_KEY to .xyz
57
+ deepinfra: 'https://api.deepinfra.com/v1/openai',
58
+ 'fireworks-ai': 'https://api.fireworks.ai/inference/v1',
59
+ mistral: 'https://api.mistral.ai/v1',
60
+ openai: 'https://api.openai.com/v1',
61
+ };
62
+
43
63
  /**
44
64
  * Import a models.dev-shaped catalog: { providerId: { id, name, api?, doc?, models: { key: model } } }.
45
65
  * Each provider becomes a synthetic node `import.modelsdev.{provider}`; each model an offering.
66
+ * The API base is the provider's `api`, else MODELS_DEV_API_BASES; `doc` is never used as one.
67
+ * A provider with neither is still listed, but without endpoints.openai/health (not invocable),
68
+ * keeping `doc` as the informational endpoints.docs link.
46
69
  * Returns { providers, offerings, skipped }.
47
70
  */
48
71
  export async function importModelsDev(store, catalog) {
@@ -79,7 +102,8 @@ export async function importModelsDev(store, catalog) {
79
102
  if (!offeringList.length) { skipped.push(providerId); continue; }
80
103
 
81
104
  const nodeId = `import.modelsdev.${slug(providerId).replace(/[._]/g, '-')}`;
82
- const base = provider?.api ?? provider?.doc ?? 'https://models.dev';
105
+ const base = httpsUrl(provider?.api ?? MODELS_DEV_API_BASES[providerId])?.replace(/\/+$/, '') ?? null;
106
+ const docs = httpsUrl(provider?.doc);
83
107
  const card = {
84
108
  onp: '0.1',
85
109
  revision: new Date().toISOString(),
@@ -89,14 +113,20 @@ export async function importModelsDev(store, catalog) {
89
113
  description: `Imported from models.dev; metadata only, unclaimed by the operator.`,
90
114
  operator: { name: provider?.name ?? providerId },
91
115
  },
92
- endpoints: { openai: base, health: `${base.replace(/\/$/, '')}/health` },
116
+ endpoints: {
117
+ ...(base ? { openai: base, health: `${base}/health` } : {}),
118
+ ...(docs ? { docs } : {}),
119
+ },
93
120
  offerings: offeringList,
94
121
  payment: { schemes: [{ scheme: 'prepaid' }] },
95
122
  };
96
- const errors = validateCard(card);
123
+ // A listing-only card deliberately lacks the schema's required invocation endpoints.
124
+ const errors = validateCard(card)
125
+ .filter((e) => base || !/^\/endpoints must have required property '(openai|health)'$/.test(e));
97
126
  if (errors.length) { skipped.push(`${providerId}: ${errors[0]}`); continue; }
98
127
 
99
- await store.upsertNode(nodeId, new URL(base).origin, `import:modelsdev:${providerId}`, 'indexed', 'import');
128
+ const origin = new URL(base ?? docs ?? 'https://models.dev').origin;
129
+ await store.upsertNode(nodeId, origin, `import:modelsdev:${providerId}`, 'indexed', 'import');
100
130
  await store.saveRevision(nodeId, card.revision, card, false);
101
131
  providers++;
102
132
  offerings += offeringList.length;
package/src/mcp.js CHANGED
@@ -1,78 +1,33 @@
1
1
  // DS-MCP-01: the registry as an MCP server (streamable HTTP, stateless).
2
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';
3
+ // MCP host gets ONP discovery (recommend, search_offerings, get_offering, get_node, estimate) for free.
4
+ import { readFileSync } from 'node:fs';
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
7
 
7
8
  const PROTOCOL_VERSION = '2025-06-18';
9
+ const { version: VERSION } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
8
10
 
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.',
11
+ // Same tools as the stdio server in @opennodes/cli (definitions live in core). Two differences
12
+ // on a remote endpoint: a `task` passed to recommend is sent here, so the schema says so and
13
+ // offers `features` (from a local extractFeatures) as the private alternative.
14
+ const TOOLS = MCP_TOOLS.map((tool) => {
15
+ if (tool.name !== 'recommend') return tool;
16
+ const { task, ...rest } = tool.inputSchema.properties;
17
+ return {
18
+ ...tool,
19
+ description: "Recommend the best AI node + model for a task, with an enforceable cost estimate and human-readable reasons. Use this first when you know the task but not the model. Pass `task` (sent to this registry, which extracts features and discards the text) or `features` already extracted on your side with extractFeatures from @opennodes/core, which keeps the prompt private. Returns ranked candidates with score components, a cost estimate pinned to the node's card revision, an optional cheaper two-step scenario, and MCP tool categories the task likely needs. To browse instead, use search_offerings; to price a specific chain, use estimate.",
13
20
  inputSchema: {
14
- type: 'object',
21
+ ...tool.inputSchema,
22
+ required: [],
15
23
  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' },
24
+ task: { ...task, description: 'The task description or the prompt itself. Sent to the registry for feature extraction; use `features` to keep it private. Give this or `features`.' },
25
+ features: { type: 'object', description: 'Features from extractFeatures(prompt) in @opennodes/core: task class, size, language and needs, no prompt text. Give this or `task`.' },
26
+ ...rest,
21
27
  },
22
28
  },
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
- ];
29
+ };
30
+ });
76
31
 
77
32
  async function runTool(store, name, args = {}) {
78
33
  switch (name) {
@@ -80,12 +35,12 @@ async function runTool(store, name, args = {}) {
80
35
  const features = args.features ?? (args.task != null
81
36
  ? extractFeatures(String(args.task), { est_output_tokens: args.est_output_tokens ?? null }) : null);
82
37
  if (!features) throw new Error('recommend needs "task" or "features"');
83
- return recommend(await collectOfferings(store), features, args.policy ?? {});
38
+ return recommend((await collectOfferings(store)).filter((o) => o.invocable), features, args.policy ?? {});
84
39
  }
85
40
  case 'search_offerings': {
86
41
  const query = new URLSearchParams();
87
42
  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) };
43
+ return searchOfferingsPaged(store, query);
89
44
  }
90
45
  case 'get_offering': {
91
46
  const found = (await collectOfferings(store)).find(
@@ -93,6 +48,16 @@ async function runTool(store, name, args = {}) {
93
48
  if (!found) throw new Error(`unknown offering: ${args.node_id}/${args.offering_id}`);
94
49
  return found;
95
50
  }
51
+ case 'get_node': {
52
+ const node = await store.getNode(args.node_id);
53
+ if (!node) throw new Error(`unknown node: ${args.node_id} (node ids come from search_offerings or recommend results)`);
54
+ return summarizeNode({
55
+ ...node,
56
+ card: await store.latestCard(node.id),
57
+ probes: (await store.probesFor(node.id)).slice(0, 20),
58
+ history: await store.transitionsFor(node.id),
59
+ }, args.offering_limit ?? 25);
60
+ }
96
61
  case 'estimate': {
97
62
  const all = await collectOfferings(store);
98
63
  const steps = [];
@@ -137,8 +102,8 @@ async function handleRpc(store, msg) {
137
102
  return reply({
138
103
  protocolVersion: params?.protocolVersion ?? PROTOCOL_VERSION,
139
104
  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).',
105
+ serverInfo: { name: 'opennodes-registry', title: 'OpenNodes registry', version: VERSION },
106
+ instructions: MCP_INSTRUCTIONS,
142
107
  });
143
108
  case 'ping':
144
109
  return reply({});
package/src/search.js CHANGED
@@ -52,6 +52,8 @@ export async function collectOfferings(store) {
52
52
  availability,
53
53
  },
54
54
  endpoints: card.endpoints,
55
+ // false for listing-only imports (no known API endpoint): shown, never called
56
+ invocable: Boolean(card.endpoints?.openai),
55
57
  });
56
58
  }
57
59
  }
@@ -63,6 +65,8 @@ export async function searchOfferingsPaged(store, query) {
63
65
  let items = await collectOfferings(store);
64
66
 
65
67
  const q = (k) => query.get(k);
68
+ if (q('invocable') === 'true') items = items.filter((o) => o.invocable);
69
+ if (q('invocable') === 'false') items = items.filter((o) => !o.invocable);
66
70
  if (q('modality')) items = items.filter((o) => o.modality === q('modality'));
67
71
  if (q('family')) items = items.filter((o) => o.model.family === q('family') || o.model.artifact === q('family'));
68
72
  if (q('min_context')) items = items.filter((o) => (o.serving?.context_window ?? 0) >= Number(q('min_context')));
package/src/server.js CHANGED
@@ -399,7 +399,8 @@ export async function startRegistry({
399
399
  ? extractFeatures(String(body.task), { attachments: body.attachments ?? [], est_output_tokens: body.est_output_tokens ?? null, messages: body.messages ?? [] })
400
400
  : null);
401
401
  if (!features) return problem(res, 400, 'missing-task', 'body must carry "task" (text) or "features" (from extractFeatures)');
402
- const all = await collectOfferings(store);
402
+ // only what a client could actually call: listing-only imports are never recommended
403
+ const all = (await collectOfferings(store)).filter((o) => o.invocable);
403
404
  sendJson(res, 200, recommend(all, features, body.policy ?? {}));
404
405
  }],
405
406
 
@@ -589,11 +589,17 @@ function ctxBit(o) {
589
589
  const basis = (o.source === 'import' && !o.serving.context_capped_from) ? ' <span class="micro m-claimed">CLAIMED</span>' : '';
590
590
  return `<span>ctx <b class="num">${fmtInt(o.serving.context_window)}</b>${cap}${basis}</span>`;
591
591
  }
592
+ /** Listing-only imports carry no endpoints.openai: they are shown but never called. */
593
+ const invocable = (o) => o.invocable !== false && Boolean(o.endpoints?.openai);
594
+ /** endpoints.docs only when it is a plain web link (cards are third-party data). */
595
+ const docsUrl = (endpoints) => /^https?:\/\//i.test(endpoints?.docs ?? '') ? endpoints.docs : null;
592
596
  function provLine(o) {
593
597
  const obs = o.observed || {};
594
598
  const bits = [`revision ${esc((o.card_revision || '').slice(0, 10))}`];
595
599
  if (obs.probes > 0) bits.push(`probes ${Math.round((obs.probe_success ?? 0) * obs.probes)}/${obs.probes} pass`);
596
600
  if (o.source === 'import') bits.push('catalog import — operator can claim this listing to get verified');
601
+ if (!invocable(o)) bits.push('listing only — no known API endpoint, so it cannot be called from here'
602
+ + (docsUrl(o.endpoints) ? ` (<a href="${esc(docsUrl(o.endpoints))}" target="_blank" rel="noopener">provider docs ↗</a>)` : ''));
597
603
  return bits.join(' · ');
598
604
  }
599
605
 
@@ -833,11 +839,11 @@ async function loadModel(artifact) {
833
839
  const navBits = [['#sec-offerings', 'OFFERINGS'], ['#sec-play', 'PLAYGROUND']];
834
840
 
835
841
  // playground targets: this page's offerings + a custom endpoint (e.g. local Ollama)
836
- pgOfferings = list;
837
- $('#pg-target').innerHTML = list.map((o, i) =>
842
+ pgOfferings = list.filter(invocable);
843
+ $('#pg-target').innerHTML = pgOfferings.map((o, i) =>
838
844
  `<option value="${i}">${esc(o.node_id)} — ${priceStr(o.pricing)} [${esc(o.tier)}]</option>`).join('')
839
845
  + '<option value="custom">custom endpoint (your local Ollama / any OpenAI-compatible server)</option>';
840
- $('#pg-customrow').hidden = true;
846
+ $('#pg-customrow').hidden = pgOfferings.length > 0; // listing-only offerings leave just the custom endpoint
841
847
  $('#pg-out').textContent = '';
842
848
  $('#pg-meta').textContent = '';
843
849
  $('#md-offerings').innerHTML = `<table class="grid num"><thead><tr>
@@ -997,7 +1003,7 @@ async function loadChatTargets() {
997
1003
  const key = (o) => o.node_id + '/' + o.offering_id;
998
1004
  const ready = reg.offerings.filter((o) => o.source !== 'import');
999
1005
  const seen = new Set(ready.map(key));
1000
- const rest = all.offerings.filter((o) => !seen.has(key(o)));
1006
+ const rest = all.offerings.filter((o) => !seen.has(key(o)) && invocable(o));
1001
1007
  const local = rest.filter((o) => o.local); // Ollama library: runs on the visitor's own machine
1002
1008
  const imports = rest.filter((o) => !o.local);
1003
1009
  chat.offerings = [...ready, ...local, ...imports];
@@ -1030,6 +1036,7 @@ async function applyAdvisorHandoff() {
1030
1036
  const [nodeId, offeringId] = key.split('/');
1031
1037
  let o = chat.offerings.find((x) => x.node_id === nodeId && x.offering_id === offeringId);
1032
1038
  if (!o) { try { o = await api(`/v0/offerings/${encodeURIComponent(nodeId)}/${encodeURIComponent(offeringId)}`); } catch { continue; } }
1039
+ if (!invocable(o)) continue;
1033
1040
  if (!chat.roster.some((t) => t.offering && t.offering.node_id === nodeId && t.offering.offering_id === offeringId)) chat.roster.push({ offering: o });
1034
1041
  }
1035
1042
  if (chat.roster.length > 1) { $('#ch-mode').value = 'compare'; $('#ch-modenote').textContent = MODE_NOTES.compare; }
@@ -1422,8 +1429,9 @@ async function scApply(sc, verb = 'loaded') {
1422
1429
  o = chat.offerings.find((x) => x.node_id === nodeId && x.offering_id === offId);
1423
1430
  if (!o) { try { o = await api(`/v0/offerings/${encodeURIComponent(nodeId)}/${encodeURIComponent(offId)}`); } catch { o = null; } }
1424
1431
  } else if (entry.select) { // ONP-6 late binding: resolve the requirement against this registry now
1425
- try { o = (await api('/v0/offerings?limit=1&' + new URLSearchParams(entry.select))).offerings[0] ?? null; } catch { o = null; }
1432
+ try { o = (await api('/v0/offerings?limit=1&invocable=true&' + new URLSearchParams(entry.select))).offerings[0] ?? null; } catch { o = null; }
1426
1433
  }
1434
+ if (o && !invocable(o)) o = null; // a pinned listing-only import cannot be called
1427
1435
  if (o) chat.roster.push({ offering: o }); else missing.push(entry.pin ?? JSON.stringify(entry.select));
1428
1436
  }
1429
1437
  const MODES = ['single', 'compare', 'jury', 'pipeline', 'iterative'];
@@ -1765,7 +1773,8 @@ async function loadNode(id) {
1765
1773
  ${n.source === 'import' ? stat('<span class="chip imp">IMPORTED</span>', 'SOURCE — unclaimed by operator') : ''}
1766
1774
  </div>
1767
1775
  <div class="panel pad" style="max-width:56rem; margin-top:0.6rem"><table class="grid">
1768
- <tr><td style="color:var(--muted); width:8rem" class="mono">endpoint</td><td class="mono" style="font-size:0.74rem">${esc(card.endpoints?.openai ?? '—')}</td></tr>
1776
+ <tr><td style="color:var(--muted); width:8rem" class="mono">endpoint</td><td class="mono" style="font-size:0.74rem">${card.endpoints?.openai ? esc(card.endpoints.openai) : '— <span style="font-family:inherit; color:var(--muted)">listing only: no known API endpoint, not callable from OpenNodes</span>'}</td></tr>
1777
+ ${docsUrl(card.endpoints) ? `<tr><td style="color:var(--muted)" class="mono">docs</td><td class="mono" style="font-size:0.74rem"><a href="${esc(docsUrl(card.endpoints))}" target="_blank" rel="noopener">${esc(docsUrl(card.endpoints))}</a></td></tr>` : ''}
1769
1778
  ${card.hardware ? `<tr><td style="color:var(--muted)" class="mono">hardware</td><td style="font-size:0.78rem">${hwStr({ hardware: card.hardware })}</td></tr>` : ''}
1770
1779
  <tr><td style="color:var(--muted)" class="mono">revision</td><td class="mono" style="font-size:0.74rem">${esc(card.revision ?? '—')}</td></tr>
1771
1780
  </table></div>