@opennodes/registry 0.1.10 → 0.1.12
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/feed.js +42 -0
- package/src/importers.js +34 -4
- package/src/mcp.js +1 -1
- package/src/search.js +4 -0
- package/src/server.js +29 -3
- package/src/web/index.html +17 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opennodes/registry",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
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/feed.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Feed shaping: a node that keeps dropping offline and coming back would otherwise fill
|
|
2
|
+
// the public feed with suspended/reinstated pairs. Consecutive liveness transitions of
|
|
3
|
+
// one node collapse into a single availability event; everything else passes through.
|
|
4
|
+
|
|
5
|
+
const isLiveness = (e) => /^(liveness:|reinstated:)/.test(e.reason ?? '');
|
|
6
|
+
|
|
7
|
+
/** events: newest first. Returns newest first, with liveness runs (2+ events) collapsed. */
|
|
8
|
+
export function collapseFlaps(events) {
|
|
9
|
+
const out = [];
|
|
10
|
+
const open = new Map(); // node_id -> collapsed entry still accepting older liveness events
|
|
11
|
+
for (const e of events) {
|
|
12
|
+
if (!isLiveness(e)) {
|
|
13
|
+
open.delete(e.node_id);
|
|
14
|
+
out.push(e);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const run = open.get(e.node_id);
|
|
18
|
+
if (run) {
|
|
19
|
+
run.events.push(e);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const entry = { ...e, events: [e] };
|
|
23
|
+
open.set(e.node_id, entry);
|
|
24
|
+
out.push(entry);
|
|
25
|
+
}
|
|
26
|
+
return out.map((entry) => {
|
|
27
|
+
if (!entry.events) return entry;
|
|
28
|
+
const { events: run, ...newest } = entry;
|
|
29
|
+
if (run.length === 1) return newest;
|
|
30
|
+
const oldest = run[run.length - 1];
|
|
31
|
+
const outages = run.filter((e) => e.to === 'suspended').length;
|
|
32
|
+
const now = newest.to === 'suspended' ? 'suspended now' : `${newest.to} again`;
|
|
33
|
+
return {
|
|
34
|
+
...newest,
|
|
35
|
+
from: oldest.from,
|
|
36
|
+
kind: 'availability',
|
|
37
|
+
collapsed: run.length,
|
|
38
|
+
since: oldest.at,
|
|
39
|
+
reason: `availability: ${outages} outage${outages === 1 ? '' : 's'} since ${oldest.at.slice(0, 10)}, ${now}`,
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
}
|
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 ??
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -80,7 +80,7 @@ async function runTool(store, name, args = {}) {
|
|
|
80
80
|
const features = args.features ?? (args.task != null
|
|
81
81
|
? extractFeatures(String(args.task), { est_output_tokens: args.est_output_tokens ?? null }) : null);
|
|
82
82
|
if (!features) throw new Error('recommend needs "task" or "features"');
|
|
83
|
-
return recommend(await collectOfferings(store), features, args.policy ?? {});
|
|
83
|
+
return recommend((await collectOfferings(store)).filter((o) => o.invocable), features, args.policy ?? {});
|
|
84
84
|
}
|
|
85
85
|
case 'search_offerings': {
|
|
86
86
|
const query = new URLSearchParams();
|
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
|
@@ -19,6 +19,7 @@ import { searchOfferings, searchOfferingsPaged, collectOfferings, RANK_COMPONENT
|
|
|
19
19
|
import { mcpRoutes } from './mcp.js';
|
|
20
20
|
import { hostControlsNamespace, isPrivateHost, makeRateLimiter } from './namespace.js';
|
|
21
21
|
import { exportRoutes } from './export.js';
|
|
22
|
+
import { collapseFlaps } from './feed.js';
|
|
22
23
|
import {
|
|
23
24
|
importModelsDev, importHuggingFace, importOpenRouter,
|
|
24
25
|
importOllamaLibrary, parseOllamaLibrary,
|
|
@@ -321,13 +322,16 @@ export async function startRegistry({
|
|
|
321
322
|
|
|
322
323
|
// Freshness feed: recent listing events (registrations, tier changes, suspensions),
|
|
323
324
|
// newest first, each linked to its provenance reason.
|
|
325
|
+
// Liveness flaps (suspended/reinstated runs) collapse into one availability event;
|
|
326
|
+
// ?raw=1 returns every transition.
|
|
324
327
|
['GET', '/v0/feed', async (req, res, { query }) => {
|
|
325
328
|
const limit = Math.min(Number(query.get('limit') ?? 50), 200);
|
|
326
|
-
const
|
|
329
|
+
const raw = query.get('raw') === '1';
|
|
330
|
+
const events = (await store.recentTransitions(raw ? limit : Math.min(limit * 10, 2000))).map((t) => ({
|
|
327
331
|
at: t.at, node_id: t.node_id, from: t.from_state, to: t.to_state,
|
|
328
332
|
reason: t.reason, source: t.source ?? 'registration',
|
|
329
333
|
}));
|
|
330
|
-
sendJson(res, 200, { events });
|
|
334
|
+
sendJson(res, 200, { events: raw ? events : collapseFlaps(events).slice(0, limit) });
|
|
331
335
|
}],
|
|
332
336
|
|
|
333
337
|
['GET', '/v0/nodes/:id', async (req, res, { params }) => {
|
|
@@ -395,10 +399,32 @@ export async function startRegistry({
|
|
|
395
399
|
? extractFeatures(String(body.task), { attachments: body.attachments ?? [], est_output_tokens: body.est_output_tokens ?? null, messages: body.messages ?? [] })
|
|
396
400
|
: null);
|
|
397
401
|
if (!features) return problem(res, 400, 'missing-task', 'body must carry "task" (text) or "features" (from extractFeatures)');
|
|
398
|
-
|
|
402
|
+
// only what a client could actually call: listing-only imports are never recommended
|
|
403
|
+
const all = (await collectOfferings(store)).filter((o) => o.invocable);
|
|
399
404
|
sendJson(res, 200, recommend(all, features, body.policy ?? {}));
|
|
400
405
|
}],
|
|
401
406
|
|
|
407
|
+
// Delist (admin): operator opt-out of an imported listing, or retiring a node. Search,
|
|
408
|
+
// the directory and Stage C skip delisted nodes, and re-imports never revive them.
|
|
409
|
+
// Body {reason}. A registered node returns by registering again; relist re-opens imports.
|
|
410
|
+
['POST', '/v0/admin/nodes/:id/delist', async (req, res, { params }) => {
|
|
411
|
+
if (!adminOk(req, res)) return;
|
|
412
|
+
const node = await store.getNode(params.id);
|
|
413
|
+
if (!node) return problem(res, 404, 'unknown-node', params.id);
|
|
414
|
+
const { reason } = await readJson(req);
|
|
415
|
+
if (node.state !== 'delisted') await store.setState(node.id, 'delisted', `delisted: ${reason || 'by registry admin'}`);
|
|
416
|
+
sendJson(res, 200, { id: node.id, state: 'delisted' });
|
|
417
|
+
}],
|
|
418
|
+
['POST', '/v0/admin/nodes/:id/relist', async (req, res, { params }) => {
|
|
419
|
+
if (!adminOk(req, res)) return;
|
|
420
|
+
const node = await store.getNode(params.id);
|
|
421
|
+
if (!node) return problem(res, 404, 'unknown-node', params.id);
|
|
422
|
+
if (node.state !== 'delisted') return problem(res, 409, 'not-delisted', `${node.id} is ${node.state}`);
|
|
423
|
+
if (node.source !== 'import') return problem(res, 409, 'register-again', 'a registered node returns by registering again (POST /v0/nodes)');
|
|
424
|
+
await store.setState(node.id, 'indexed', 'relisted by registry admin');
|
|
425
|
+
sendJson(res, 200, { id: node.id, state: 'indexed' });
|
|
426
|
+
}],
|
|
427
|
+
|
|
402
428
|
// Seed importer (admin): body {catalog} in models.dev shape, or {url} to fetch it.
|
|
403
429
|
['POST', '/v0/import/models-dev', async (req, res) => {
|
|
404
430
|
if (!adminOk(req, res)) return;
|
package/src/web/index.html
CHANGED
|
@@ -317,7 +317,7 @@
|
|
|
317
317
|
<!-- ================= NEW (feed) ================= -->
|
|
318
318
|
<div id="view-feed" hidden>
|
|
319
319
|
<h1>New</h1>
|
|
320
|
-
<p class="sub">Dated listing events, newest first — registrations, tier changes, suspensions — each with its provenance. Also available as <a id="feed-json" href="/v0/feed">JSON</a> for agents.</p>
|
|
320
|
+
<p class="sub">Dated listing events, newest first — registrations, tier changes, suspensions — each with its provenance. A node that keeps dropping offline and coming back shows as one availability line. Also available as <a id="feed-json" href="/v0/feed">JSON</a> for agents (<a href="/v0/feed?raw=1">every transition</a>).</p>
|
|
321
321
|
<div class="panel pad" id="feed-list"></div>
|
|
322
322
|
</div>
|
|
323
323
|
|
|
@@ -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 =
|
|
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 =
|
|
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'];
|
|
@@ -1729,7 +1737,7 @@ async function loadFeed() {
|
|
|
1729
1737
|
$('#feed-list').innerHTML = events.length ? events.map((e) => `<div class="feeditem">
|
|
1730
1738
|
<span class="prov">${esc((e.at || '').replace('T', ' ').slice(0, 16))}</span>
|
|
1731
1739
|
<span><a href="#/node/${encodeURIComponent(e.node_id)}" class="mono" style="font-size:0.78rem">${esc(e.node_id)}</a>
|
|
1732
|
-
→ ${tierChip(e.to)} ${e.reason ? '<span style="color:var(--muted)">— ' + esc(e.reason) + '</span>' : ''}</span>
|
|
1740
|
+
→ ${tierChip(e.to)} ${e.reason ? '<span style="color:var(--muted)">— ' + esc(e.reason) + (e.collapsed ? ' (' + e.collapsed + ' events)' : '') + '</span>' : ''}</span>
|
|
1733
1741
|
</div>`).join('') : '<p class="sub">No events yet.</p>';
|
|
1734
1742
|
} catch (e) { $('#feed-list').innerHTML = '<p class="err">' + esc(e.message) + '</p>'; }
|
|
1735
1743
|
}
|
|
@@ -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
|
|
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>
|