@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/src/server.js ADDED
@@ -0,0 +1,391 @@
1
+ // OpenNodes Discovery Service — MVP registry server (REQ-001 subset).
2
+ import { randomUUID } from 'node:crypto';
3
+ import {
4
+ createApp, listen, closeApp, sendJson, problem, readJson,
5
+ validateCard, verifyCardSignature, keyFromJwk, estimateBounds,
6
+ extractFeatures, recommend,
7
+ } from '@opennodes/core';
8
+ import { resolveTxt } from 'node:dns/promises';
9
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { generateKeypair, keypairFromPem } from '@opennodes/core';
12
+ import { createStore } from './store.js';
13
+ import { runStageA } from './probes.js';
14
+ import { runStageB, captureFingerprints } from './stageb.js';
15
+ import { startStageC } from './stagec.js';
16
+ import { startReimport } from './reimport.js';
17
+ import { makeSafeFetch } from './safefetch.js';
18
+ import { searchOfferings, collectOfferings, RANK_COMPONENTS } from './search.js';
19
+ import { mcpRoutes } from './mcp.js';
20
+ import { exportRoutes } from './export.js';
21
+ import {
22
+ importModelsDev, importHuggingFace, importOpenRouter,
23
+ importOllamaLibrary, parseOllamaLibrary,
24
+ HF_ROUTER_URL, HF_HUB_URL, OPENROUTER_URL, OLLAMA_LIBRARY_URL,
25
+ } from './importers.js';
26
+
27
+ export async function startRegistry({
28
+ port = 0, host = '127.0.0.1', dbPath = ':memory:',
29
+ allowPrivateTargets = false, // SSRF guard off only for dev/e2e
30
+ dnsResolveTxt = resolveTxt, // injectable for tests
31
+ stageBCapacity = {}, // e.g. { durationMs, steps } — shortened in tests
32
+ stageC = null, // { livenessIntervalMs, blindIntervalMs, failThreshold } to enable
33
+ reimport = null, // { intervalMs, sources } to keep imported catalogs fresh
34
+ adminToken = null, // bearer for admin routes; without it, loopback-only
35
+ keyPath = null, // PEM file for a persistent signing identity; null = ephemeral
36
+ } = {}) {
37
+ const store = await createStore(dbPath); // sqlite path/:memory: or postgres:// URL
38
+ const safeFetch = makeSafeFetch({ allowPrivate: allowPrivateTargets });
39
+ const webApp = readFileSync(fileURLToPath(new URL('./web/index.html', import.meta.url)), 'utf8');
40
+ let registryKeys;
41
+ if (keyPath && existsSync(keyPath)) {
42
+ registryKeys = keypairFromPem(readFileSync(keyPath, 'utf8'));
43
+ } else {
44
+ registryKeys = generateKeypair();
45
+ if (keyPath) writeFileSync(keyPath, registryKeys.privatePem, { mode: 0o600 });
46
+ }
47
+
48
+ const adminOk = (req, res) => {
49
+ if (adminToken) {
50
+ if (req.headers.authorization === `Bearer ${adminToken}`) return true;
51
+ problem(res, 401, 'unauthorized', 'admin route requires bearer token');
52
+ return false;
53
+ }
54
+ const remote = req.socket.remoteAddress;
55
+ if (['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(remote)) return true;
56
+ problem(res, 403, 'forbidden', 'admin route is loopback-only without adminToken');
57
+ return false;
58
+ };
59
+
60
+ async function fetchCard(cardUrl) {
61
+ const res = await safeFetch(cardUrl, {}, 10_000);
62
+ if (!res.ok) throw new Error(`card fetch failed: ${res.status}`);
63
+ return res.json();
64
+ }
65
+
66
+ async function verifySignatureFromOrigin(card, origin) {
67
+ const res = await safeFetch(`${origin}/.well-known/jwks.json`, {}, 10_000);
68
+ if (!res.ok) throw new Error('jwks fetch failed');
69
+ const jwks = await res.json();
70
+ const kid = card.signatures?.[0]?.kid;
71
+ const jwk = jwks.keys.find((k) => !kid || k.kid === kid) ?? jwks.keys[0];
72
+ if (!jwk) throw new Error('no key in jwks');
73
+ verifyCardSignature(card, keyFromJwk(jwk));
74
+ }
75
+
76
+ /** node.id is reverse-DNS: org.opennodes.fixture -> fixture.opennodes.org */
77
+ const domainForNodeId = (nodeId) => nodeId.split('.').reverse().join('.');
78
+
79
+ async function challengeSatisfied(node, challenge) {
80
+ try { // HTTP method
81
+ const resp = await safeFetch(`${node.origin}/.well-known/onp-challenge/${challenge.token}`, {}, 10_000);
82
+ if (resp.ok) return { ok: true, method: 'http' };
83
+ } catch { /* fall through to DNS */ }
84
+ try { // DNS TXT method: _onp-challenge.{domain} TXT "{token}"
85
+ const records = await dnsResolveTxt(`_onp-challenge.${domainForNodeId(node.id)}`);
86
+ if (records.flat().includes(challenge.token)) return { ok: true, method: 'dns' };
87
+ } catch { /* neither */ }
88
+ return { ok: false };
89
+ }
90
+
91
+ async function indexCard(nodeId, cardUrl) {
92
+ const card = await fetchCard(cardUrl);
93
+ const errors = validateCard(card);
94
+ if (errors.length) throw new Error('card invalid: ' + errors.slice(0, 5).join('; '));
95
+ const origin = new URL(cardUrl).origin;
96
+ if (card.node.id !== nodeId) throw new Error(`card node.id ${card.node.id} != registered ${nodeId}`);
97
+ let sigOk = false;
98
+ try { await verifySignatureFromOrigin(card, origin); sigOk = true; } catch { /* accepted but labeled */ }
99
+ await store.saveRevision(nodeId, card.revision, card, sigOk);
100
+ return { card, sigOk };
101
+ }
102
+
103
+ const app = createApp([
104
+ ['POST', '/v0/nodes', async (req, res) => {
105
+ const { card_url } = await readJson(req);
106
+ if (!card_url) return problem(res, 400, 'missing-card-url', 'body must be {"card_url": "..."}');
107
+ let card;
108
+ try { card = await fetchCard(card_url); } catch (err) { return problem(res, 422, 'card-unfetchable', String(err.message)); }
109
+ const errors = validateCard(card);
110
+ if (errors.length) return problem(res, 422, 'card-invalid', errors.slice(0, 10).join('; '));
111
+
112
+ const nodeId = card.node.id;
113
+ const origin = new URL(card_url).origin;
114
+ await store.upsertNode(nodeId, origin, card_url, 'challenged');
115
+ await store.setState(nodeId, 'challenged', 'registration received');
116
+ const token = randomUUID();
117
+ await store.setChallenge(nodeId, token);
118
+ sendJson(res, 202, {
119
+ node_id: nodeId,
120
+ state: 'challenged',
121
+ challenge: {
122
+ methods: {
123
+ http: `${origin}/.well-known/onp-challenge/${token}`,
124
+ dns: `_onp-challenge.${nodeId.split('.').slice(0, 2).reverse().join('.')} TXT "${token}"`,
125
+ },
126
+ token,
127
+ verify: `/v0/nodes/${nodeId}/verify`,
128
+ },
129
+ });
130
+ }],
131
+
132
+ ['POST', '/v0/nodes/:id/verify', async (req, res, { params }) => {
133
+ const node = await store.getNode(params.id);
134
+ if (!node) return problem(res, 404, 'unknown-node', params.id);
135
+ const challenge = await store.getChallenge(params.id);
136
+ if (!challenge) return problem(res, 410, 'challenge-expired', 'register again');
137
+ const check = await challengeSatisfied(node, challenge);
138
+ if (!check.ok) return problem(res, 403, 'challenge-failed', 'neither HTTP nor DNS TXT challenge satisfied');
139
+ let indexed;
140
+ try { indexed = await indexCard(params.id, node.card_url); }
141
+ catch (err) { return problem(res, 422, 'card-invalid', String(err.message)); }
142
+
143
+ await store.setState(params.id, 'indexed', `namespace verified via ${check.method}`);
144
+ const stageA = await runStageA(store, params.id, indexed.card, { safeFetch });
145
+ await store.setState(params.id, stageA.ok ? 'community' : 'indexed',
146
+ stageA.ok ? 'Stage A passed' : 'Stage A failures: ' + stageA.results.filter((r) => !r.ok).map((r) => r.kind).join(','));
147
+ sendJson(res, 200, {
148
+ node_id: params.id,
149
+ state: (await store.getNode(params.id)).state,
150
+ signature_verified: indexed.sigOk,
151
+ stage_a: stageA,
152
+ });
153
+ }],
154
+
155
+ ['PUT', '/v0/nodes/:id/ping', async (req, res, { params }) => {
156
+ const node = await store.getNode(params.id);
157
+ if (!node) return problem(res, 404, 'unknown-node', params.id);
158
+ try {
159
+ const { card } = await indexCard(params.id, node.card_url);
160
+ const stageA = await runStageA(store, params.id, card, { safeFetch });
161
+ sendJson(res, 200, { node_id: params.id, revision: card.revision, stage_a_ok: stageA.ok });
162
+ } catch (err) {
163
+ problem(res, 422, 'refresh-failed', String(err.message));
164
+ }
165
+ }],
166
+
167
+ // Stage B admission: identity + context + capacity; promotes community -> verified,
168
+ // or marks the node disputed on an identity failure (DS-ADM-02..04).
169
+ ['POST', '/v0/nodes/:id/admit', async (req, res, { params }) => {
170
+ const node = await store.getNode(params.id);
171
+ if (!node) return problem(res, 404, 'unknown-node', params.id);
172
+ if (node.state !== 'community' && node.state !== 'verified') {
173
+ return problem(res, 409, 'wrong-state', `Stage B requires community tier, node is ${node.state}`);
174
+ }
175
+ const card = await store.latestCard(params.id);
176
+ const stageB = await runStageB(store, params.id, card, { safeFetch, capacity: stageBCapacity });
177
+ if (stageB.identityFailed) {
178
+ await store.setState(params.id, 'disputed', 'Stage B identity fingerprint mismatch');
179
+ } else if (stageB.ok) {
180
+ await store.setState(params.id, 'verified', 'Stage B passed');
181
+ }
182
+ sendJson(res, 200, { node_id: params.id, state: (await store.getNode(params.id)).state, stage_b: stageB });
183
+ }],
184
+
185
+ // Operator-trusted reference capture (admin surface, MVP: unauthenticated local only):
186
+ // runs the fingerprint battery against a KNOWN-GOOD deployment and stores references.
187
+ ['POST', '/v0/fingerprints/capture', async (req, res) => {
188
+ if (!adminOk(req, res)) return;
189
+ const { node_id, offering_id } = await readJson(req);
190
+ const card = await store.latestCard(node_id);
191
+ const offering = card?.offerings.find((o) => o.offering_id === offering_id);
192
+ if (!offering) return problem(res, 404, 'unknown-offering', `${node_id}/${offering_id}`);
193
+ const refs = await captureFingerprints(safeFetch, card, offering);
194
+ await store.addFingerprints(offering.model.artifact, refs);
195
+ sendJson(res, 200, { artifact: offering.model.artifact, references: refs.length });
196
+ }],
197
+
198
+ // Node directory: every listed node with tier, source, offering count, availability.
199
+ ['GET', '/v0/nodes', async (req, res) => {
200
+ const out = [];
201
+ for (const node of await store.allNodes()) {
202
+ if (['delisted'].includes(node.state)) continue;
203
+ const card = await store.latestCard(node.id);
204
+ const availability = (await store.getObservations(node.id, '_node')).availability ?? null;
205
+ out.push({
206
+ id: node.id,
207
+ state: node.state,
208
+ tier: node.state === 'indexed' ? 'unverified' : node.state,
209
+ source: node.source ?? 'registration',
210
+ institutional: Boolean(node.institutional),
211
+ updated_at: node.updated_at,
212
+ name: card?.node?.name ?? node.id,
213
+ operator: card?.node?.operator?.name ?? null,
214
+ country: card?.node?.operator?.country ?? null,
215
+ regions: card?.node?.regions ?? [],
216
+ offerings: card?.offerings?.length ?? 0,
217
+ availability,
218
+ });
219
+ }
220
+ out.sort((a, b) => (b.updated_at ?? '').localeCompare(a.updated_at ?? ''));
221
+ sendJson(res, 200, { nodes: out });
222
+ }],
223
+
224
+ // Freshness feed: recent listing events (registrations, tier changes, suspensions),
225
+ // newest first, each linked to its provenance reason.
226
+ ['GET', '/v0/feed', async (req, res, { query }) => {
227
+ const limit = Math.min(Number(query.get('limit') ?? 50), 200);
228
+ const events = (await store.recentTransitions(limit)).map((t) => ({
229
+ at: t.at, node_id: t.node_id, from: t.from_state, to: t.to_state,
230
+ reason: t.reason, source: t.source ?? 'registration',
231
+ }));
232
+ sendJson(res, 200, { events });
233
+ }],
234
+
235
+ ['GET', '/v0/nodes/:id', async (req, res, { params }) => {
236
+ const node = await store.getNode(params.id);
237
+ if (!node) return problem(res, 404, 'unknown-node', params.id);
238
+ sendJson(res, 200, {
239
+ ...node,
240
+ card: await store.latestCard(params.id),
241
+ probes: (await store.probesFor(params.id)).slice(0, 20),
242
+ history: await store.transitionsFor(params.id),
243
+ });
244
+ }],
245
+
246
+ ['GET', '/v0/offerings', async (req, res, { query }) => {
247
+ sendJson(res, 200, { offerings: await searchOfferings(store, query) });
248
+ }],
249
+
250
+ ['GET', '/v0/offerings/:nodeId/:offeringId', async (req, res, { params }) => {
251
+ const all = await collectOfferings(store);
252
+ const found = all.find((o) => o.node_id === params.nodeId && o.offering_id === params.offeringId);
253
+ if (!found) return problem(res, 404, 'unknown-offering', `${params.nodeId}/${params.offeringId}`);
254
+ sendJson(res, 200, found);
255
+ }],
256
+
257
+ ['POST', '/v0/estimate', async (req, res) => {
258
+ const { steps } = await readJson(req);
259
+ if (!Array.isArray(steps) || steps.length === 0) return problem(res, 400, 'missing-steps', 'body must be {"steps": [...]}');
260
+ const all = await collectOfferings(store);
261
+ const out = [];
262
+ let min = 0, max = 0;
263
+ let currency = 'USD';
264
+ for (const step of steps) {
265
+ let offering;
266
+ if (step.offering) {
267
+ const [nodeId, offeringId] = step.offering.split('/');
268
+ offering = all.find((o) => o.node_id === nodeId && o.offering_id === offeringId);
269
+ if (!offering) return problem(res, 404, 'unknown-offering', step.offering);
270
+ } else if (step.select) {
271
+ const query = new URLSearchParams(step.select);
272
+ const results = await searchOfferings(store, query);
273
+ if (!results.length) return problem(res, 404, 'no-offering-matches', JSON.stringify(step.select));
274
+ offering = results[0];
275
+ } else {
276
+ return problem(res, 400, 'bad-step', 'each step needs "offering" or "select"');
277
+ }
278
+ const bounds = estimateBounds(offering.pricing, step.est_input_tokens ?? 0, step.est_output_tokens ?? 0);
279
+ currency = offering.pricing.currency;
280
+ min += bounds.min; max += bounds.max;
281
+ out.push({
282
+ offering: `${offering.node_id}/${offering.offering_id}`,
283
+ card_revision: offering.card_revision,
284
+ cost: { currency: offering.pricing.currency, ...bounds },
285
+ });
286
+ }
287
+ sendJson(res, 200, { total: { currency, min: round6(min), max: round6(max) }, steps: out });
288
+ }],
289
+
290
+ // ONP-3 §5b: task → ranked offerings with estimate + explanation. Accepts either a
291
+ // task description (features extracted here) or pre-extracted `features` so a
292
+ // client never has to send its prompt to the registry.
293
+ ['POST', '/v0/recommend', async (req, res) => {
294
+ const body = await readJson(req, 1_000_000);
295
+ const features = body.features ?? (body.task != null
296
+ ? extractFeatures(String(body.task), { attachments: body.attachments ?? [], est_output_tokens: body.est_output_tokens ?? null, messages: body.messages ?? [] })
297
+ : null);
298
+ if (!features) return problem(res, 400, 'missing-task', 'body must carry "task" (text) or "features" (from extractFeatures)');
299
+ const all = await collectOfferings(store);
300
+ sendJson(res, 200, recommend(all, features, body.policy ?? {}));
301
+ }],
302
+
303
+ // Seed importer (admin): body {catalog} in models.dev shape, or {url} to fetch it.
304
+ ['POST', '/v0/import/models-dev', async (req, res) => {
305
+ if (!adminOk(req, res)) return;
306
+ const body = await readJson(req, 50_000_000);
307
+ let catalog = body.catalog;
308
+ if (!catalog && body.url) {
309
+ const resp = await safeFetch(body.url, {}, 30_000);
310
+ catalog = await resp.json();
311
+ }
312
+ if (!catalog) return problem(res, 400, 'missing-catalog', 'body must carry {catalog} or {url}');
313
+ sendJson(res, 200, await importModelsDev(store, catalog));
314
+ }],
315
+
316
+ // HF Inference Providers importer (admin): inline {router, hub}, or fetch the live APIs.
317
+ ['POST', '/v0/import/huggingface', async (req, res) => {
318
+ if (!adminOk(req, res)) return;
319
+ const body = await readJson(req, 100_000_000);
320
+ let { router, hub } = body;
321
+ if (!router) {
322
+ router = await (await safeFetch(HF_ROUTER_URL, {}, 60_000)).json();
323
+ try { hub = await (await safeFetch(HF_HUB_URL, {}, 60_000)).json(); }
324
+ catch { hub = []; } // hub enrichment is optional
325
+ }
326
+ sendJson(res, 200, await importHuggingFace(store, router, hub ?? []));
327
+ }],
328
+
329
+ // Ollama library importer (admin): inline {entries} or {html}, or fetch the live page.
330
+ ['POST', '/v0/import/ollama', async (req, res) => {
331
+ if (!adminOk(req, res)) return;
332
+ const body = await readJson(req, 50_000_000);
333
+ let entries = body.entries;
334
+ if (!entries) {
335
+ const html = body.html ?? await (await safeFetch(OLLAMA_LIBRARY_URL, {}, 60_000)).text();
336
+ entries = parseOllamaLibrary(html);
337
+ }
338
+ sendJson(res, 200, await importOllamaLibrary(store, entries));
339
+ }],
340
+
341
+ // OpenRouter catalog importer (admin): inline {catalog}, or fetch the live API.
342
+ ['POST', '/v0/import/openrouter', async (req, res) => {
343
+ if (!adminOk(req, res)) return;
344
+ const body = await readJson(req, 100_000_000);
345
+ const catalog = body.catalog ?? await (await safeFetch(OPENROUTER_URL, {}, 60_000)).json();
346
+ sendJson(res, 200, await importOpenRouter(store, catalog));
347
+ }],
348
+
349
+ ['GET', '/v0/health', (req, res) => {
350
+ sendJson(res, 200, {
351
+ service: 'opennodes-registry',
352
+ onp: ['0.1'],
353
+ ranking: { components: RANK_COMPONENTS, note: 'weighted sum; sponsored placement not implemented' },
354
+ peers: [],
355
+ fees: { listing: 'free', settlement: 'not-operated' },
356
+ mcp: '/mcp',
357
+ webmcp: true, // the web app at / registers in-page tools via navigator.modelContext where supported
358
+ export: '/v0/export',
359
+ signing_key: { ...registryKeys.publicJwk, kid: 'registry-1' },
360
+ stage_c: Boolean(stageC),
361
+ });
362
+ }],
363
+
364
+ // The registry web app: OpenRouter-style discovery + ranking + node registration UI.
365
+ ['GET', '/', (req, res) => {
366
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'max-age=300' });
367
+ res.end(webApp);
368
+ }],
369
+
370
+ ...mcpRoutes(store),
371
+ ...exportRoutes(store, registryKeys),
372
+ ], { cors: true }); // public read API: third-party web clients may call it directly
373
+
374
+ const stageCHandle = stageC ? startStageC(store, { safeFetch, ...stageC }) : null;
375
+ const reimportHandle = reimport ? startReimport(store, { safeFetch, ...reimport }) : null;
376
+ const actualPort = await listen(app, port, host);
377
+ return {
378
+ port: actualPort,
379
+ origin: `http://${host}:${actualPort}`,
380
+ store,
381
+ registryKeys,
382
+ close: async () => {
383
+ stageCHandle?.stop();
384
+ reimportHandle?.stop();
385
+ await closeApp(app);
386
+ await store.close();
387
+ },
388
+ };
389
+ }
390
+
391
+ const round6 = (n) => Math.round(n * 1e6) / 1e6;
package/src/stageb.js ADDED
@@ -0,0 +1,165 @@
1
+ // Stage B admission probes (DS-ADM-02..04): identity fingerprinting, context
2
+ // verification with capping, capacity ramp with measured-over-claimed metrics.
3
+ import { createHash } from 'node:crypto';
4
+ import { fetchJsonCapped } from './safefetch.js';
5
+
6
+ const sha256 = (s) => createHash('sha256').update(s).digest('hex');
7
+
8
+ /** The fixed identity battery: deterministic prompts, temperature 0. */
9
+ export const FINGERPRINT_PROMPTS = [
10
+ 'ONP identity probe 1: repeat exactly: correlation-4417',
11
+ 'ONP identity probe 2: repeat exactly: meridian-9022',
12
+ 'ONP identity probe 3: repeat exactly: basalt-7301',
13
+ ];
14
+
15
+ async function chatOnce(safeFetch, card, offering, content, maxTokens = 64) {
16
+ const t0 = performance.now();
17
+ const { status, body } = await fetchJsonCapped(safeFetch, `${card.endpoints.openai}/chat/completions`, {
18
+ method: 'POST',
19
+ headers: {
20
+ 'content-type': 'application/json',
21
+ 'onp-offering': `${card.node.id}/${offering.offering_id}`,
22
+ 'onp-card-revision': card.revision,
23
+ },
24
+ body: JSON.stringify({
25
+ model: offering.binding.model_id,
26
+ messages: [{ role: 'user', content }],
27
+ temperature: 0,
28
+ max_tokens: maxTokens,
29
+ }),
30
+ }, 60_000);
31
+ return {
32
+ status,
33
+ text: body?.choices?.[0]?.message?.content ?? null,
34
+ usage: body?.usage ?? null,
35
+ latencyMs: performance.now() - t0,
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Capture reference fingerprints for an artifact from a TRUSTED deployment
41
+ * (run by the registry operator against a known-good serving of the weights).
42
+ */
43
+ export async function captureFingerprints(safeFetch, card, offering) {
44
+ const refs = [];
45
+ for (const prompt of FINGERPRINT_PROMPTS) {
46
+ const { status, text } = await chatOnce(safeFetch, card, offering, prompt);
47
+ if (status !== 200 || text === null) throw new Error(`reference capture failed: status ${status}`);
48
+ refs.push({ prompt, expected_hash: sha256(text) });
49
+ }
50
+ return refs;
51
+ }
52
+
53
+ /** DS-ADM-02: identity check against stored references. */
54
+ async function probeIdentity(safeFetch, card, offering, refs) {
55
+ let matches = 0;
56
+ for (const ref of refs) {
57
+ const { status, text } = await chatOnce(safeFetch, card, offering, ref.prompt);
58
+ if (status === 200 && text !== null && sha256(text) === ref.expected_hash) matches++;
59
+ }
60
+ // Deterministic battery: require all prompts to match the reference distribution.
61
+ return { ok: matches === refs.length, matches, total: refs.length };
62
+ }
63
+
64
+ /** DS-ADM-03: needle retrieval at 25/50/90% of the claimed window; returns the verified cap. */
65
+ async function probeContext(safeFetch, card, offering) {
66
+ const claimed = offering.serving?.context_window ?? 4096;
67
+ const results = [];
68
+ let cap = 0;
69
+ for (const fraction of [0.25, 0.5, 0.9]) {
70
+ const tokens = Math.floor(claimed * fraction);
71
+ const needle = `NEEDLE:${Math.floor(tokens * 7919)}`; // deterministic per size
72
+ const fillerUnit = 'lorem ipsum context filler segment. ';
73
+ const beforeNeedle = Math.floor(tokens * 0.9) * 4; // needle at ~90% depth of this size
74
+ const afterNeedle = Math.max(0, tokens * 4 - beforeNeedle - needle.length - 60);
75
+ const prompt = fillerUnit.repeat(Math.ceil(beforeNeedle / fillerUnit.length)).slice(0, beforeNeedle)
76
+ + ` ${needle} `
77
+ + fillerUnit.repeat(Math.ceil(afterNeedle / fillerUnit.length)).slice(0, afterNeedle)
78
+ + ' Return the NEEDLE token from this text.';
79
+ let found = false;
80
+ try {
81
+ const { status, text } = await chatOnce(safeFetch, card, offering, prompt, 64);
82
+ found = status === 200 && typeof text === 'string' && text.includes(needle);
83
+ } catch { found = false; }
84
+ results.push({ fraction, tokens, found });
85
+ if (found) cap = Math.max(cap, tokens);
86
+ }
87
+ const allPassed = results.every((r) => r.found);
88
+ return { ok: allPassed, claimed, verified_cap: allPassed ? claimed : cap, results };
89
+ }
90
+
91
+ /** DS-ADM-04: stepped-concurrency ramp; measures TTFT p50/p95 and rough TPS. */
92
+ async function probeCapacity(safeFetch, card, offering, { steps = [1, 4, 8], durationMs = 3000 } = {}) {
93
+ const latencies = [];
94
+ let completed = 0, failed = 0, outputTokens = 0;
95
+ const deadline = Date.now() + durationMs;
96
+ for (const concurrency of steps) {
97
+ while (Date.now() < deadline) {
98
+ const batch = await Promise.all(Array.from({ length: concurrency }, async () => {
99
+ try {
100
+ const r = await chatOnce(safeFetch, card, offering, 'ONP capacity probe: reply with a short sentence.', 48);
101
+ return r.status === 200 ? r : null;
102
+ } catch { return null; }
103
+ }));
104
+ for (const r of batch) {
105
+ if (!r) { failed++; continue; }
106
+ completed++;
107
+ latencies.push(r.latencyMs);
108
+ outputTokens += r.usage?.completion_tokens ?? 0;
109
+ }
110
+ break; // one batch per step within the time budget
111
+ }
112
+ }
113
+ latencies.sort((a, b) => a - b);
114
+ const pct = (p) => latencies.length ? Math.round(latencies[Math.min(latencies.length - 1, Math.floor(latencies.length * p))]) : null;
115
+ const totalSeconds = Math.max(0.001, durationMs / 1000);
116
+ return {
117
+ ok: completed > 0 && failed / Math.max(1, completed + failed) < 0.1,
118
+ completed, failed,
119
+ ttft_ms: { p50: pct(0.5), p95: pct(0.95) },
120
+ tps: Math.round(outputTokens / totalSeconds),
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Run Stage B for every chat offering. Records probes + observations; returns per-offering verdicts.
126
+ * Identity refs come from await store.getFingerprints(artifact); absence of refs is recorded, not failed
127
+ * (proprietary artifacts are guarded by the vendor-namespace rule instead).
128
+ */
129
+ export async function runStageB(store, nodeId, card, { safeFetch, capacity = {} } = {}) {
130
+ const verdicts = [];
131
+ for (const offering of card.offerings) {
132
+ if (offering.binding.profile !== 'onp.openai.chat/v1') continue;
133
+ const verdict = { offering_id: offering.offering_id, identity: null, context: null, capacity: null };
134
+
135
+ const refs = await store.getFingerprints(offering.model.artifact);
136
+ if (refs.length) {
137
+ verdict.identity = await probeIdentity(safeFetch, card, offering, refs);
138
+ await store.recordProbe(nodeId, offering.offering_id, 'B', 'identity', verdict.identity.ok,
139
+ `${verdict.identity.matches}/${verdict.identity.total} reference matches`);
140
+ } else {
141
+ await store.recordProbe(nodeId, offering.offering_id, 'B', 'identity', true, 'no reference fingerprints for artifact; skipped');
142
+ }
143
+
144
+ verdict.context = await probeContext(safeFetch, card, offering);
145
+ await store.recordProbe(nodeId, offering.offering_id, 'B', 'context', verdict.context.ok,
146
+ `claimed ${verdict.context.claimed}, verified ${verdict.context.verified_cap}`,
147
+ verdict.context);
148
+ if (!verdict.context.ok && verdict.context.verified_cap > 0) {
149
+ await store.setObservation(nodeId, offering.offering_id, 'context_cap', verdict.context.verified_cap);
150
+ }
151
+
152
+ verdict.capacity = await probeCapacity(safeFetch, card, offering, capacity);
153
+ await store.recordProbe(nodeId, offering.offering_id, 'B', 'capacity', verdict.capacity.ok, null, verdict.capacity);
154
+ if (verdict.capacity.ttft_ms.p50 != null) {
155
+ await store.setObservation(nodeId, offering.offering_id, 'ttft_ms', verdict.capacity.ttft_ms);
156
+ await store.setObservation(nodeId, offering.offering_id, 'tps', verdict.capacity.tps);
157
+ }
158
+
159
+ verdicts.push(verdict);
160
+ }
161
+
162
+ const identityFailed = verdicts.some((v) => v.identity && !v.identity.ok);
163
+ const ok = !identityFailed && verdicts.every((v) => v.capacity?.ok);
164
+ return { ok, identityFailed, verdicts };
165
+ }
package/src/stagec.js ADDED
@@ -0,0 +1,92 @@
1
+ // Stage C (DS-ADM-06): continuous observation of community+ nodes.
2
+ // - liveness sweep on a fixed cadence -> rolling availability observation,
3
+ // auto-suspension after consecutive failures
4
+ // - random blind invocations at unpredictable times (anti-"benchmark mode":
5
+ // a node cannot serve the real model only when it detects a test window)
6
+ import { fetchJsonCapped } from './safefetch.js';
7
+
8
+ const OBSERVED_STATES = ['community', 'verified', 'attested'];
9
+
10
+ export function startStageC(store, {
11
+ safeFetch,
12
+ livenessIntervalMs = 300_000, // spec: <=5 min; tests shorten this
13
+ blindIntervalMs = 3_600_000,
14
+ failThreshold = 3,
15
+ } = {}) {
16
+ let stopped = false;
17
+
18
+ async function livenessSweep() {
19
+ for (const node of await store.allNodes()) {
20
+ if (!OBSERVED_STATES.includes(node.state)) continue;
21
+ const card = await store.latestCard(node.id);
22
+ if (!card) continue;
23
+ let ok = false;
24
+ try {
25
+ const { status, body } = await fetchJsonCapped(safeFetch, card.endpoints.health, {}, 5_000);
26
+ ok = status === 200 && body?.status === 'ok';
27
+ } catch { ok = false; }
28
+ await store.recordProbe(node.id, null, 'C', 'liveness', ok);
29
+
30
+ const recent = (await store.probesFor(node.id))
31
+ .filter((p) => p.stage === 'C' && p.kind === 'liveness')
32
+ .slice(0, 20);
33
+ const up = recent.filter((p) => p.ok).length;
34
+ await store.setObservation(node.id, '_node', 'availability', {
35
+ window: recent.length, up,
36
+ ratio: recent.length ? Math.round((up / recent.length) * 1000) / 1000 : null,
37
+ });
38
+
39
+ let consecutiveFails = 0;
40
+ for (const p of recent) { if (p.ok) break; consecutiveFails++; }
41
+ if (consecutiveFails >= failThreshold) {
42
+ await store.setState(node.id, 'suspended', `liveness: ${consecutiveFails} consecutive failures`);
43
+ }
44
+ }
45
+ }
46
+
47
+ async function blindSweep() {
48
+ for (const node of await store.allNodes()) {
49
+ if (!OBSERVED_STATES.includes(node.state)) continue;
50
+ const card = await store.latestCard(node.id);
51
+ if (!card) continue;
52
+ const chatOfferings = card.offerings.filter((o) => o.binding.profile === 'onp.openai.chat/v1');
53
+ if (!chatOfferings.length) continue;
54
+ const offering = chatOfferings[Math.floor(Math.random() * chatOfferings.length)];
55
+ try {
56
+ const { status, body } = await fetchJsonCapped(safeFetch, `${card.endpoints.openai}/chat/completions`, {
57
+ method: 'POST',
58
+ headers: {
59
+ 'content-type': 'application/json',
60
+ 'onp-offering': `${card.node.id}/${offering.offering_id}`,
61
+ 'onp-card-revision': card.revision,
62
+ },
63
+ body: JSON.stringify({
64
+ model: offering.binding.model_id,
65
+ messages: [{ role: 'user', content: `ONP continuous probe ${Math.floor(Math.random() * 1e9)}: reply briefly.` }],
66
+ max_tokens: 32,
67
+ }),
68
+ }, 30_000);
69
+ const usage = body?.usage;
70
+ const ok = status === 200 && Number.isFinite(usage?.prompt_tokens) && Number.isFinite(usage?.completion_tokens);
71
+ await store.recordProbe(node.id, offering.offering_id, 'C', 'blind', ok,
72
+ ok ? null : `status ${status} or missing usage`, usage ? { usage } : null);
73
+ } catch (err) {
74
+ await store.recordProbe(node.id, offering.offering_id, 'C', 'blind', false, String(err.message ?? err));
75
+ }
76
+ }
77
+ }
78
+
79
+ const jitter = (base) => base * (0.5 + Math.random());
80
+ const loop = (fn, base) => {
81
+ const tick = async () => {
82
+ if (stopped) return;
83
+ await fn().catch(() => {});
84
+ if (!stopped) setTimeout(tick, jitter(base)).unref?.();
85
+ };
86
+ setTimeout(tick, jitter(base)).unref?.();
87
+ };
88
+ loop(livenessSweep, livenessIntervalMs);
89
+ loop(blindSweep, blindIntervalMs);
90
+
91
+ return { stop: () => { stopped = true; } };
92
+ }