@opennodes/registry 0.1.12 → 0.1.14
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 +2 -2
- package/src/mcp.js +34 -69
- package/src/stageb.js +45 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opennodes/registry",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
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.
|
|
46
|
+
"@opennodes/core": "0.1.2",
|
|
47
47
|
"pg": "^8.13.1"
|
|
48
48
|
}
|
|
49
49
|
}
|
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 {
|
|
5
|
-
import {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
21
|
+
...tool.inputSchema,
|
|
22
|
+
required: [],
|
|
15
23
|
properties: {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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) {
|
|
@@ -85,7 +40,7 @@ async function runTool(store, name, args = {}) {
|
|
|
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
|
|
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',
|
|
141
|
-
instructions:
|
|
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/stageb.js
CHANGED
|
@@ -62,41 +62,63 @@ async function probeIdentity(safeFetch, card, offering, refs) {
|
|
|
62
62
|
return { ok: matches === refs.length, matches, total: refs.length, ...(stale ? { stale: true } : {}) };
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
/**
|
|
66
|
-
export function needleFound(text,
|
|
65
|
+
/** The needle counts as retrieved if its number appears in the reply as a whole number. */
|
|
66
|
+
export function needleFound(text, digits) {
|
|
67
67
|
if (typeof text !== 'string') return false;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return new RegExp(`(^|[^0-9])${digits}([^0-9]|$)`).test(text);
|
|
68
|
+
const number = String(digits).split(':').pop();
|
|
69
|
+
return new RegExp(`(^|[^0-9])${number}([^0-9]|$)`).test(text);
|
|
71
70
|
}
|
|
72
71
|
|
|
73
|
-
|
|
72
|
+
const FILLER = 'lorem ipsum context filler segment. ';
|
|
73
|
+
const fill = (chars) => FILLER.repeat(Math.ceil(Math.max(0, chars) / FILLER.length)).slice(0, Math.max(0, chars));
|
|
74
|
+
const QUESTION = '\n\nWhat is the magic number mentioned in the text above? Reply with the number only.';
|
|
75
|
+
|
|
76
|
+
/** Characters per token for this model's tokenizer, measured from a short prompt's reported usage. */
|
|
77
|
+
async function calibrate(safeFetch, card, offering) {
|
|
78
|
+
const chars = 4000;
|
|
79
|
+
try {
|
|
80
|
+
const { status, usage } = await chatOnce(safeFetch, card, offering, `${fill(chars)}\n\nReply with OK.`, 4);
|
|
81
|
+
const reported = usage?.prompt_tokens;
|
|
82
|
+
if (status === 200 && reported > 0) return { charsPerToken: chars / reported, measured: true };
|
|
83
|
+
} catch { /* fall through to the default */ }
|
|
84
|
+
return { charsPerToken: 4, measured: false };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* DS-ADM-03: retrieval at 25/50/90% of the claimed window; returns the verified cap.
|
|
89
|
+
* - Prompts are sized in the model's own tokens (calibrated from reported usage), not a chars/4 guess.
|
|
90
|
+
* - The fact sits near the start: engines that silently drop the front of an over-long prompt lose it,
|
|
91
|
+
* and a reported prompt_tokens well short of the target is failed as a cut prompt.
|
|
92
|
+
* - The question is plain ("the magic number"): small models refuse or mangle odd tokens like NEEDLE.
|
|
93
|
+
* - The cap is the largest size with every smaller size passing, so one lucky answer can't vouch for a window.
|
|
94
|
+
*/
|
|
74
95
|
async function probeContext(safeFetch, card, offering) {
|
|
75
96
|
const claimed = offering.serving?.context_window ?? 4096;
|
|
97
|
+
const { charsPerToken, measured } = await calibrate(safeFetch, card, offering);
|
|
76
98
|
const results = [];
|
|
77
|
-
let cap = 0;
|
|
99
|
+
let cap = 0, unbroken = true;
|
|
78
100
|
for (const fraction of [0.25, 0.5, 0.9]) {
|
|
79
101
|
const tokens = Math.floor(claimed * fraction);
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
const prompt =
|
|
85
|
-
|
|
86
|
-
+ fillerUnit.repeat(Math.ceil(afterNeedle / fillerUnit.length)).slice(0, afterNeedle)
|
|
87
|
-
+ ' Return the NEEDLE token from this text.';
|
|
88
|
-
let found = false, why = null;
|
|
102
|
+
const digits = String(Math.floor(tokens * 7919)); // deterministic per size
|
|
103
|
+
const fact = ` The magic number is ${digits}. `;
|
|
104
|
+
const body = Math.floor(tokens * charsPerToken) - fact.length - QUESTION.length - 40; // 40: chat template slack
|
|
105
|
+
const before = Math.floor(body * 0.1);
|
|
106
|
+
const prompt = fill(before) + fact + fill(body - before) + QUESTION;
|
|
107
|
+
let found = false, why = null, promptTokens = null;
|
|
89
108
|
try {
|
|
90
109
|
// Prefill on modest hardware is slow: allow ~30 ms per claimed token on top of the base timeout.
|
|
91
|
-
const { status, text } = await chatOnce(safeFetch, card, offering, prompt, 64, 60_000 + tokens * 30);
|
|
92
|
-
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
110
|
+
const { status, text, usage } = await chatOnce(safeFetch, card, offering, prompt, 64, 60_000 + tokens * 30);
|
|
111
|
+
promptTokens = usage?.prompt_tokens ?? null;
|
|
112
|
+
if (status !== 200) why = `status ${status}`;
|
|
113
|
+
else if (measured && promptTokens != null && promptTokens < tokens * 0.85) why = `prompt cut to ${promptTokens} tokens`;
|
|
114
|
+
else if (!needleFound(text, digits)) why = 'number not in reply';
|
|
115
|
+
else found = true;
|
|
116
|
+
} catch (err) { why = /abort|timeout/i.test(String(err?.message)) ? 'timeout' : String(err?.message ?? err).slice(0, 80); }
|
|
117
|
+
results.push({ fraction, tokens, found, ...(promptTokens != null ? { prompt_tokens: promptTokens } : {}), ...(why ? { why } : {}) });
|
|
118
|
+
if (found && unbroken) cap = tokens; else unbroken = false;
|
|
97
119
|
}
|
|
98
120
|
const allPassed = results.every((r) => r.found);
|
|
99
|
-
return { ok: allPassed, claimed, verified_cap: allPassed ? claimed : cap, results };
|
|
121
|
+
return { ok: allPassed, claimed, verified_cap: allPassed ? claimed : cap, chars_per_token: Math.round(charsPerToken * 100) / 100, results };
|
|
100
122
|
}
|
|
101
123
|
|
|
102
124
|
/** DS-ADM-04: stepped-concurrency ramp; measures TTFT p50/p95 and rough TPS. */
|