@opennodes/registry 0.1.2 → 0.1.4
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 +1 -1
- package/src/server.js +21 -1
- package/src/stageb.js +7 -2
- package/src/stagec.js +3 -0
- package/src/web/index.html +29 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opennodes/registry",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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",
|
package/src/server.js
CHANGED
|
@@ -100,6 +100,22 @@ export async function startRegistry({
|
|
|
100
100
|
return { card, sigOk };
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Re-index a registered node's card when its origin serves a newer revision (restart,
|
|
105
|
+
* reprice, config change). Probes pin the revision they hold; without this a restarted
|
|
106
|
+
* node answers 409 to every probe and would look like an identity mismatch.
|
|
107
|
+
*/
|
|
108
|
+
async function refreshCard(nodeId) {
|
|
109
|
+
try {
|
|
110
|
+
const node = await store.getNode(nodeId);
|
|
111
|
+
if (!node?.card_url || node.source === 'import') return;
|
|
112
|
+
const current = await store.latestCard(nodeId);
|
|
113
|
+
const fresh = await fetchCard(node.card_url);
|
|
114
|
+
if (current && fresh.revision === current.revision) return;
|
|
115
|
+
await indexCard(nodeId, node.card_url);
|
|
116
|
+
} catch { /* unreachable or invalid: probes will record the failure themselves */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
103
119
|
const app = createApp([
|
|
104
120
|
['POST', '/v0/nodes', async (req, res) => {
|
|
105
121
|
const { card_url } = await readJson(req);
|
|
@@ -172,8 +188,12 @@ export async function startRegistry({
|
|
|
172
188
|
if (node.state !== 'community' && node.state !== 'verified') {
|
|
173
189
|
return problem(res, 409, 'wrong-state', `Stage B requires community tier, node is ${node.state}`);
|
|
174
190
|
}
|
|
191
|
+
await refreshCard(params.id);
|
|
175
192
|
const card = await store.latestCard(params.id);
|
|
176
193
|
const stageB = await runStageB(store, params.id, card, { safeFetch, capacity: stageBCapacity });
|
|
194
|
+
if (stageB.stale) {
|
|
195
|
+
return problem(res, 409, 'stale-card', 'the node changed its card revision during the battery; retry');
|
|
196
|
+
}
|
|
177
197
|
if (stageB.identityFailed) {
|
|
178
198
|
await store.setState(params.id, 'disputed', 'Stage B identity fingerprint mismatch');
|
|
179
199
|
} else if (stageB.ok) {
|
|
@@ -372,7 +392,7 @@ export async function startRegistry({
|
|
|
372
392
|
...exportRoutes(store, registryKeys),
|
|
373
393
|
], { cors: true }); // public read API: third-party web clients may call it directly
|
|
374
394
|
|
|
375
|
-
const stageCHandle = stageC ? startStageC(store, { safeFetch, ...stageC }) : null;
|
|
395
|
+
const stageCHandle = stageC ? startStageC(store, { safeFetch, refreshCard, ...stageC }) : null;
|
|
376
396
|
const reimportHandle = reimport ? startReimport(store, { safeFetch, ...reimport }) : null;
|
|
377
397
|
const actualPort = await listen(app, port, host);
|
|
378
398
|
return {
|
package/src/stageb.js
CHANGED
|
@@ -52,13 +52,14 @@ export async function captureFingerprints(safeFetch, card, offering) {
|
|
|
52
52
|
|
|
53
53
|
/** DS-ADM-02: identity check against stored references. */
|
|
54
54
|
async function probeIdentity(safeFetch, card, offering, refs) {
|
|
55
|
-
let matches = 0;
|
|
55
|
+
let matches = 0, stale = false;
|
|
56
56
|
for (const ref of refs) {
|
|
57
57
|
const { status, text } = await chatOnce(safeFetch, card, offering, ref.prompt);
|
|
58
|
+
if (status === 409) stale = true; // pinned revision is no longer current: not evidence about identity
|
|
58
59
|
if (status === 200 && text !== null && sha256(text) === ref.expected_hash) matches++;
|
|
59
60
|
}
|
|
60
61
|
// Deterministic battery: require all prompts to match the reference distribution.
|
|
61
|
-
return { ok: matches === refs.length, matches, total: refs.length };
|
|
62
|
+
return { ok: matches === refs.length, matches, total: refs.length, ...(stale ? { stale: true } : {}) };
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
/** A needle counts as retrieved if the reply carries the full token or just its digits (small models drop the prefix). */
|
|
@@ -145,6 +146,10 @@ export async function runStageB(store, nodeId, card, { safeFetch, capacity = {}
|
|
|
145
146
|
const refs = await store.getFingerprints(offering.model.artifact);
|
|
146
147
|
if (refs.length) {
|
|
147
148
|
verdict.identity = await probeIdentity(safeFetch, card, offering, refs);
|
|
149
|
+
if (verdict.identity.stale) {
|
|
150
|
+
// The node rejected our pinned revision (409). Abort without judging: the caller re-indexes and retries.
|
|
151
|
+
return { ok: false, identityFailed: false, stale: true, verdicts: [...verdicts, verdict] };
|
|
152
|
+
}
|
|
148
153
|
await store.recordProbe(nodeId, offering.offering_id, 'B', 'identity', verdict.identity.ok,
|
|
149
154
|
`${verdict.identity.matches}/${verdict.identity.total} reference matches`);
|
|
150
155
|
} else {
|
package/src/stagec.js
CHANGED
|
@@ -9,6 +9,7 @@ const OBSERVED_STATES = ['community', 'verified', 'attested'];
|
|
|
9
9
|
|
|
10
10
|
export function startStageC(store, {
|
|
11
11
|
safeFetch,
|
|
12
|
+
refreshCard = null, // re-index a node's card when its revision moved (restart / reprice)
|
|
12
13
|
livenessIntervalMs = 300_000, // spec: <=5 min; tests shorten this
|
|
13
14
|
blindIntervalMs = 3_600_000,
|
|
14
15
|
failThreshold = 3,
|
|
@@ -47,6 +48,7 @@ export function startStageC(store, {
|
|
|
47
48
|
async function blindSweep() {
|
|
48
49
|
for (const node of await store.allNodes()) {
|
|
49
50
|
if (!OBSERVED_STATES.includes(node.state)) continue;
|
|
51
|
+
await refreshCard?.(node.id);
|
|
50
52
|
const card = await store.latestCard(node.id);
|
|
51
53
|
if (!card) continue;
|
|
52
54
|
const chatOfferings = card.offerings.filter((o) => o.binding.profile === 'onp.openai.chat/v1');
|
|
@@ -66,6 +68,7 @@ export function startStageC(store, {
|
|
|
66
68
|
max_tokens: 32,
|
|
67
69
|
}),
|
|
68
70
|
}, 30_000);
|
|
71
|
+
if (status === 409) { await refreshCard?.(node.id); continue; } // stale pin, not a failure of the node
|
|
69
72
|
const usage = body?.usage;
|
|
70
73
|
const ok = status === 200 && Number.isFinite(usage?.prompt_tokens) && Number.isFinite(usage?.completion_tokens);
|
|
71
74
|
await store.recordProbe(node.id, offering.offering_id, 'C', 'blind', ok,
|
package/src/web/index.html
CHANGED
|
@@ -983,11 +983,31 @@ async function loadChatTargets() {
|
|
|
983
983
|
$('#ch-modenote').textContent = MODE_NOTES[$('#ch-mode').value] ?? '';
|
|
984
984
|
if (!chat.offerings.length) {
|
|
985
985
|
try {
|
|
986
|
-
|
|
987
|
-
|
|
986
|
+
// Registered nodes answer straight from the browser with no key; imported catalog listings
|
|
987
|
+
// (OpenRouter, HF providers, models.dev) need the visitor's own key for that provider.
|
|
988
|
+
const [reg, all] = await Promise.all([
|
|
989
|
+
api('/v0/offerings?limit=60&modality=text&tier=community'),
|
|
990
|
+
api('/v0/offerings?limit=60&modality=text'),
|
|
991
|
+
]);
|
|
992
|
+
const key = (o) => o.node_id + '/' + o.offering_id;
|
|
993
|
+
const ready = reg.offerings.filter((o) => o.source !== 'import');
|
|
994
|
+
const seen = new Set(ready.map(key));
|
|
995
|
+
const rest = all.offerings.filter((o) => !seen.has(key(o)));
|
|
996
|
+
const local = rest.filter((o) => o.local); // Ollama library: runs on the visitor's own machine
|
|
997
|
+
const imports = rest.filter((o) => !o.local);
|
|
998
|
+
chat.offerings = [...ready, ...local, ...imports];
|
|
999
|
+
const opt = (o, i) => `<option value="${i}">${esc(o.model.name)} — ${esc(o.node_id)} — ${priceStr(o.pricing)}</option>`;
|
|
988
1000
|
$('#ch-add').innerHTML = '<option value="">+ Add a model…</option>'
|
|
989
|
-
+
|
|
1001
|
+
+ (ready.length ? `<optgroup label="Ready to use — no key needed (${ready.length})">${ready.map((o, i) => opt(o, i)).join('')}</optgroup>` : '')
|
|
1002
|
+
+ (local.length ? `<optgroup label="On your machine — needs a local Ollama with this model pulled">${local.map((o, i) => opt(o, ready.length + i)).join('')}</optgroup>` : '')
|
|
1003
|
+
+ (imports.length ? `<optgroup label="Imported catalogs — your own API key required">${imports.map((o, i) => opt(o, ready.length + local.length + i)).join('')}</optgroup>` : '')
|
|
990
1004
|
+ '<option value="custom">custom endpoint (local Ollama / any OpenAI server)</option>';
|
|
1005
|
+
if (!chat.roster.length && ready.length && !sessionStorage.getItem('onp-advisor-handoff')) {
|
|
1006
|
+
chat.roster.push({ offering: ready[0] }); // a newcomer can just type
|
|
1007
|
+
renderRoster();
|
|
1008
|
+
const log = $('#ch-log');
|
|
1009
|
+
if (log && !chat.history.length) log.innerHTML = `<p class="sub" style="margin:0">Ready: <b>${esc(ready[0].model.name)}</b> on ${esc(ready[0].node_id)} (${esc(ready[0].tier)}, no key needed) is preselected — say something, or add more models on the right to compare.</p>`;
|
|
1010
|
+
}
|
|
991
1011
|
} catch { $('#ch-add').innerHTML = '<option value="custom">custom endpoint</option>'; }
|
|
992
1012
|
}
|
|
993
1013
|
await applyAdvisorHandoff();
|
|
@@ -1099,6 +1119,7 @@ $('#ch-judge').addEventListener('change', refreshEstimate);
|
|
|
1099
1119
|
function importKeyHint(offering, base, key) {
|
|
1100
1120
|
if (!offering || offering.source !== 'import' || key) return null;
|
|
1101
1121
|
const host = new URL(base).host;
|
|
1122
|
+
if (offering.local || /^(localhost|127\.0\.0\.1|\[::1\])(:|$)/.test(host)) return null; // your own machine: no key
|
|
1102
1123
|
const where = host.includes('huggingface') ? 'a Hugging Face token (huggingface.co/settings/tokens)'
|
|
1103
1124
|
: host.includes('openrouter') ? 'an OpenRouter API key (openrouter.ai/keys)' : `an API key for ${host}`;
|
|
1104
1125
|
return `This is an imported catalog listing served by ${host}: your browser calls it directly, which needs ${where} — paste it in the API key field (stored only in this browser). Or run it locally with BYOK: npx @opennodes/cli gateway --key ${host}=<key>. Registered OpenNodes nodes need no key.`;
|
|
@@ -1189,7 +1210,8 @@ async function callTargetStream(t, messages, onDelta, maxTokens = 1024) {
|
|
|
1189
1210
|
}
|
|
1190
1211
|
|
|
1191
1212
|
const metaLine = (r) => [r.ttft != null ? `ttft ${r.ttft}ms · ` + r.ms + 'ms total' : r.ms + 'ms', r.usage ? `${r.usage.prompt_tokens}+${r.usage.completion_tokens} tok` : null, r.receipt ? 'receipt ✓' : null].filter(Boolean).join(' · ');
|
|
1192
|
-
const corsHint =
|
|
1213
|
+
const corsHint = (err) => /failed to fetch|networkerror|load failed/i.test(String(err?.message))
|
|
1214
|
+
? ' — network/CORS error: the node must allow browser origins (local Ollama: set OLLAMA_ORIGINS)' : '';
|
|
1193
1215
|
|
|
1194
1216
|
function logEl(html) {
|
|
1195
1217
|
if ($('#ch-log').querySelector('p.sub')) $('#ch-log').innerHTML = '';
|
|
@@ -1239,7 +1261,7 @@ async function runSingle(text) {
|
|
|
1239
1261
|
} catch (err) {
|
|
1240
1262
|
chat.history.pop();
|
|
1241
1263
|
el.firstChild.textContent = '';
|
|
1242
|
-
el.querySelector('.mm').innerHTML = '<span class="err">' + esc(err.message) + '</span>' + corsHint;
|
|
1264
|
+
el.querySelector('.mm').innerHTML = '<span class="err">' + esc(err.message) + '</span>' + corsHint(err);
|
|
1243
1265
|
}
|
|
1244
1266
|
}
|
|
1245
1267
|
|
|
@@ -1259,7 +1281,7 @@ async function fanoutRound(labelPrefix, buildMessages, { remember = false } = {}
|
|
|
1259
1281
|
}
|
|
1260
1282
|
return { label: String.fromCharCode(65 + i), text: r.text, ok: true };
|
|
1261
1283
|
} catch (err) {
|
|
1262
|
-
cards[i].querySelector('.ctext').innerHTML = '<span class="err">' + esc(err.message) + '</span><span class="prov">' + corsHint + '</span>';
|
|
1284
|
+
cards[i].querySelector('.ctext').innerHTML = '<span class="err">' + esc(err.message) + '</span><span class="prov">' + corsHint(err) + '</span>';
|
|
1263
1285
|
return { label: String.fromCharCode(65 + i), ok: false };
|
|
1264
1286
|
}
|
|
1265
1287
|
}));
|
|
@@ -1346,7 +1368,7 @@ async function runPipeline(text) {
|
|
|
1346
1368
|
el.querySelector('.ctext').textContent = r.text || '(empty)';
|
|
1347
1369
|
el.querySelector('.mm').textContent = metaLine(r);
|
|
1348
1370
|
} catch (err) {
|
|
1349
|
-
el.querySelector('.ctext').innerHTML = '<span class="err">' + esc(err.message) + '</span><span class="prov">' + corsHint + ' — pipeline stopped</span>';
|
|
1371
|
+
el.querySelector('.ctext').innerHTML = '<span class="err">' + esc(err.message) + '</span><span class="prov">' + corsHint(err) + ' — pipeline stopped</span>';
|
|
1350
1372
|
return;
|
|
1351
1373
|
}
|
|
1352
1374
|
}
|