@opennodes/registry 0.1.1 → 0.1.3

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/mcp.js CHANGED
@@ -1,182 +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
- }
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
+ }
package/src/reimport.js CHANGED
@@ -1,55 +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
- }
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
+ }