@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.
@@ -0,0 +1,1778 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>OpenNodes Registry</title>
7
+ <meta name="description" content="Discover, compare, and rank AI inference offerings across providers — verified, not just ranked.">
8
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🛰️</text></svg>">
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;1,8..60,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap">
11
+ <style>
12
+ :root {
13
+ --ground: #F7F9FA; --panel: #FFFFFF; --panel-2: #EEF2F4;
14
+ --ink: #1C2733; --muted: #5C6E7E; --faint: #8FA1B0; --line: #D6DEE3; --line-2: #EEF2F4;
15
+ --accent: #0F7A80; --accent-soft: #E0EFF0; --good: #2F6B3A;
16
+ --warn: #A16207; --bad: #A33333; --star: #B45309;
17
+ }
18
+ @media (prefers-color-scheme: dark) {
19
+ :root {
20
+ --ground: #101820; --panel: #16202A; --panel-2: #1C2833;
21
+ --ink: #E4EBF0; --muted: #8FA1B0; --faint: #64788A; --line: #2C3B48; --line-2: #223140;
22
+ --accent: #4FC0C6; --accent-soft: #12333A; --good: #7CC08A;
23
+ --warn: #D9A648; --bad: #E08585; --star: #D9A648;
24
+ }
25
+ }
26
+ * { box-sizing: border-box; }
27
+ body { margin: 0; background: var(--ground); color: var(--ink);
28
+ font-family: "Archivo", "Helvetica Neue", Arial, sans-serif; font-size: 0.9rem; line-height: 1.5; }
29
+ a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; }
30
+ .mono { font-family: "IBM Plex Mono", ui-monospace, Consolas, monospace; }
31
+ .serif { font-family: "Source Serif 4", Georgia, serif; }
32
+ .num { font-variant-numeric: tabular-nums; }
33
+ .wrap { max-width: 82rem; margin: 0 auto; padding: 0 1.4rem; }
34
+
35
+ /* models master-detail */
36
+ .toolbar { display: flex; flex-wrap: wrap; gap: 0.55rem; align-items: center; margin-bottom: 1rem; }
37
+ .toolbar input[type=text] { flex: 1; min-width: 12rem; }
38
+ .toolbar input, .toolbar select { font: inherit; color: var(--ink); background: var(--panel); border: 1px solid var(--line); border-radius: 5px; padding: 0.42rem 0.6rem; }
39
+ .toolbar input[type=number] { width: 8.5rem; }
40
+ .mgrid { display: grid; grid-template-columns: 23rem minmax(0, 1fr); gap: 1.2rem; align-items: start; }
41
+ @media (max-width: 980px) { .mgrid { grid-template-columns: 1fr; } }
42
+ .mlist { display: flex; flex-direction: column; gap: 0.4rem; max-height: 76vh; overflow-y: auto; padding-right: 0.25rem; }
43
+ .mitem { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 0.6rem 0.75rem; cursor: pointer; }
44
+ .mitem:hover { border-color: var(--accent); }
45
+ .mitem.sel { border-color: var(--accent); background: var(--accent-soft); }
46
+ .mitem .t { display: flex; align-items: center; gap: 0.5rem; }
47
+ .mitem .t .nm { font-weight: 600; font-size: 0.86rem; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
48
+ .mitem .b { display: flex; gap: 0.8rem; margin-top: 0.25rem; font-size: 0.7rem; color: var(--muted); font-variant-numeric: tabular-nums; flex-wrap: wrap; }
49
+ .mdetail { min-height: 30rem; }
50
+
51
+ /* chat */
52
+ .chatgrid { display: grid; grid-template-columns: minmax(0, 1fr) 19rem; gap: 1.2rem; align-items: start; }
53
+ @media (max-width: 980px) { .chatgrid { grid-template-columns: 1fr; } }
54
+ .chatlog { display: flex; flex-direction: column; gap: 0.7rem; min-height: 20rem; max-height: 60vh; overflow-y: auto; padding: 1rem; }
55
+ .msg { max-width: 46rem; font-size: 0.9rem; line-height: 1.55; white-space: pre-wrap; border-radius: 8px; padding: 0.6rem 0.9rem; }
56
+ .msg.user { align-self: flex-end; background: var(--accent-soft); border: 1px solid var(--accent); }
57
+ .msg.assistant { align-self: flex-start; background: var(--panel-2); border: 1px solid var(--line); }
58
+ .msg .mm { display: block; margin-top: 0.35rem; }
59
+ .chatbar { display: flex; gap: 0.6rem; padding: 0.8rem 1rem; border-top: 1px solid var(--line); }
60
+ .chatbar textarea { flex: 1; font: inherit; color: var(--ink); background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 0.55rem 0.7rem; resize: none; }
61
+
62
+ /* nav */
63
+ nav { background: var(--panel); border-bottom: 1px solid var(--line); }
64
+ nav .wrap { display: flex; align-items: stretch; gap: 1.5rem; height: 3.5rem; }
65
+ nav .brand { display: flex; align-items: baseline; gap: 0.45rem; align-self: center; color: var(--ink); font-weight: 700; font-size: 1.05rem; text-decoration: none; }
66
+ nav .brand .mono { color: var(--accent); font-size: 0.7rem; }
67
+ nav a.tab { display: flex; align-items: center; color: var(--muted); font-weight: 500; font-size: 0.88rem; border-bottom: 2px solid transparent; }
68
+ nav a.tab:hover { text-decoration: none; color: var(--ink); }
69
+ nav a.tab.active { color: var(--ink); border-bottom-color: var(--accent); }
70
+ nav .spacer { flex: 1; }
71
+ nav .cta { align-self: center; background: var(--accent); color: #fff; font-weight: 600; font-size: 0.82rem; border-radius: 6px; padding: 0.55rem 1rem; }
72
+ nav .cta:hover { text-decoration: none; opacity: 0.92; }
73
+ nav .ext { align-self: center; font-size: 0.78rem; color: var(--muted); }
74
+
75
+ main { padding: 1.4rem 0 4rem; }
76
+ h1 { font-size: 1.4rem; margin: 0.2rem 0 0.4rem; }
77
+ h2 { font-size: 1.05rem; margin: 1.4rem 0 0.6rem; }
78
+ .sub { color: var(--muted); margin: 0 0 1.1rem; max-width: 52rem; font-size: 0.86rem; }
79
+ .subserif { font-family: "Source Serif 4", Georgia, serif; font-style: italic; color: var(--muted); font-size: 0.88rem; }
80
+ .crumb { font-family: "IBM Plex Mono", monospace; font-size: 0.7rem; color: var(--muted); margin-bottom: 0.3rem; }
81
+ .counts { font-family: "IBM Plex Mono", monospace; font-size: 0.72rem; color: var(--muted); }
82
+
83
+ /* chips */
84
+ .chip { font-family: "IBM Plex Mono", monospace; font-size: 0.6rem; font-weight: 600; padding: 0.12rem 0.5rem;
85
+ border-radius: 999px; border: 1px solid var(--line); color: var(--muted); white-space: nowrap; }
86
+ .chip.t-verified { color: var(--good); border-color: var(--good); }
87
+ .chip.t-community { color: var(--accent); border-color: var(--accent); }
88
+ .chip.t-disputed, .chip.t-suspended { color: var(--bad); border-color: var(--bad); }
89
+ .chip.inst { color: var(--accent); border-color: var(--accent); }
90
+ .chip.imp { background: var(--panel-2); font-weight: 400; }
91
+ .chip.loc { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
92
+ .micro { font-family: "IBM Plex Mono", monospace; font-size: 0.58rem; font-weight: 600; letter-spacing: 0.04em; }
93
+ .m-measured { color: var(--good); } .m-claimed { color: var(--faint); font-weight: 400; } .m-warn { color: var(--warn); }
94
+ .prov { font-family: "IBM Plex Mono", monospace; font-size: 0.64rem; color: var(--faint); }
95
+
96
+ /* layout */
97
+ .cols { display: grid; grid-template-columns: 240px minmax(0, 1fr); gap: 1.4rem; align-items: start; }
98
+ @media (max-width: 900px) { .cols { grid-template-columns: 1fr; } }
99
+ .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; }
100
+ .pad { padding: 0.9rem 1rem; }
101
+
102
+ /* filter rail */
103
+ .rail { display: flex; flex-direction: column; gap: 1rem; font-size: 0.82rem; }
104
+ .rail .grp { display: flex; flex-direction: column; gap: 0.45rem; }
105
+ .rail .lab { font-family: "IBM Plex Mono", monospace; font-size: 0.62rem; font-weight: 600; letter-spacing: 0.12em; color: var(--muted); }
106
+ .rail select, .rail input[type=number], .rail input[type=text] {
107
+ font: inherit; color: var(--ink); background: var(--panel); border: 1px solid var(--line); border-radius: 5px; padding: 0.4rem 0.55rem; width: 100%; }
108
+ .toggle { font-size: 0.72rem; padding: 0.28rem 0.65rem; border-radius: 999px; border: 1px solid var(--line);
109
+ background: var(--panel); color: var(--muted); cursor: pointer; font-family: inherit; }
110
+ .toggle.on { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); font-weight: 600; }
111
+ .switchrow { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; user-select: none; }
112
+ .switch { width: 30px; height: 17px; border-radius: 999px; background: var(--line); position: relative; transition: background 0.15s; flex: none; }
113
+ .switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 13px; height: 13px; border-radius: 50%; background: var(--panel); transition: left 0.15s; }
114
+ .switchrow.on .switch { background: var(--accent); } .switchrow.on .switch::after { left: 15px; }
115
+
116
+ /* offering rows */
117
+ .rows { display: flex; flex-direction: column; gap: 0.55rem; }
118
+ .row { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 0.8rem 1rem 0.6rem; cursor: pointer; }
119
+ .row:hover { border-color: var(--accent); }
120
+ .row .l1 { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
121
+ .row .name { font-weight: 600; font-size: 0.95rem; }
122
+ .row .nodeid { font-size: 0.76rem; color: var(--muted); }
123
+ .row .l2 { display: flex; gap: 1.4rem; margin-top: 0.35rem; font-size: 0.78rem; color: var(--muted); flex-wrap: wrap; align-items: baseline; }
124
+ .row .l2 b { color: var(--ink); font-weight: 600; }
125
+ .rankbar { width: 58px; height: 6px; border-radius: 3px; background: var(--panel-2); overflow: hidden; display: inline-block; vertical-align: middle; }
126
+ .rankbar i { display: block; height: 100%; background: var(--accent); }
127
+ .rankwrap { display: inline-flex; align-items: center; gap: 0.4rem; margin-left: auto; }
128
+ .row .prov { display: block; margin-top: 0.4rem; }
129
+ .row .chev { transition: transform 0.15s; color: var(--faint); flex: none; }
130
+ .row.open .chev { transform: rotate(90deg); }
131
+ .rowx { display: none; margin-top: 0.7rem; border-top: 1px solid var(--line-2); padding-top: 0.7rem; cursor: default; }
132
+ .row.open .rowx { display: block; }
133
+ .rowx .xgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.2rem; }
134
+ @media (max-width: 760px) { .rowx .xgrid { grid-template-columns: 1fr; } }
135
+ .rowx dl { margin: 0; display: grid; grid-template-columns: 7.5rem 1fr; gap: 0.3rem 0.8rem; font-size: 0.78rem; }
136
+ .rowx dt { color: var(--muted); font-family: "IBM Plex Mono", monospace; font-size: 0.66rem; align-self: baseline; padding-top: 0.1rem; }
137
+ .rowx dd { margin: 0; }
138
+ .rowx .xlab { font-family: "IBM Plex Mono", monospace; font-size: 0.62rem; font-weight: 600; letter-spacing: 0.1em; color: var(--accent); display: block; margin-bottom: 0.4rem; }
139
+ .rowx .also { margin-top: 0.7rem; }
140
+ .rowx .alsorow { display: flex; gap: 0.7rem; align-items: baseline; font-size: 0.78rem; padding: 0.3rem 0; border-bottom: 1px dashed var(--line-2); }
141
+ .rowx .alsorow:last-child { border-bottom: none; }
142
+ .rowx .actions { display: flex; gap: 1rem; margin-top: 0.7rem; font-size: 0.8rem; font-weight: 600; }
143
+
144
+ /* tables */
145
+ table.grid { border-collapse: collapse; width: 100%; font-size: 0.8rem; }
146
+ table.grid th { font-family: "IBM Plex Mono", monospace; font-size: 0.6rem; font-weight: 600; letter-spacing: 0.08em;
147
+ color: var(--muted); text-align: left; padding: 0.55rem 0.8rem; background: var(--panel-2); border-bottom: 1px solid var(--line); white-space: nowrap; }
148
+ table.grid td { padding: 0.7rem 0.8rem; border-bottom: 1px solid var(--line-2); vertical-align: middle; }
149
+ table.grid tr:last-child td { border-bottom: none; }
150
+ .tablewrap { overflow-x: auto; }
151
+
152
+ .btn { font: inherit; font-weight: 600; font-size: 0.82rem; padding: 0.55rem 1rem; border-radius: 6px;
153
+ border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; }
154
+ .btn.sec { background: transparent; color: var(--accent); }
155
+ .btn:disabled { opacity: 0.5; cursor: default; }
156
+ input.wide { width: 100%; font: inherit; color: var(--ink); background: var(--panel); border: 1px solid var(--line); border-radius: 5px; padding: 0.55rem 0.7rem; }
157
+ label.f { display: block; font-size: 0.78rem; color: var(--muted); margin: 0.9rem 0 0.25rem; }
158
+ .ok { color: var(--good); } .fail { color: var(--bad); } .warncol { color: var(--warn); }
159
+ .err { color: var(--bad); font-size: 0.82rem; margin-top: 0.5rem; }
160
+ .stage { display: grid; grid-template-columns: 7rem 1fr; gap: 0.8rem; padding: 0.5rem 0; border-bottom: 1px dashed var(--line); font-size: 0.82rem; }
161
+ .stage:last-child { border-bottom: none; }
162
+ .stage .k { font-family: "IBM Plex Mono", monospace; font-size: 0.66rem; font-weight: 600; color: var(--accent); }
163
+ .tabbtns { display: flex; gap: 0.5rem; flex-wrap: wrap; }
164
+ .tabbtn { font: inherit; font-weight: 600; font-size: 0.8rem; padding: 0.5rem 1rem; border-radius: 6px; border: 1px solid var(--line); background: var(--panel); color: var(--muted); cursor: pointer; }
165
+ .tabbtn.on { background: var(--accent); border-color: var(--accent); color: #fff; }
166
+ /* model/node card header */
167
+ .statstrip { display: flex; flex-wrap: wrap; gap: 1.8rem; margin: 0.8rem 0 0.4rem; }
168
+ .stat { display: flex; flex-direction: column; gap: 0.1rem; }
169
+ .stat .sv { font-weight: 600; font-size: 0.95rem; font-variant-numeric: tabular-nums; }
170
+ .stat .sl { font-family: "IBM Plex Mono", monospace; font-size: 0.6rem; letter-spacing: 0.1em; color: var(--muted); }
171
+ .secnav { display: flex; gap: 1.2rem; border-bottom: 1px solid var(--line); margin: 0.9rem 0 1.1rem; font-family: "IBM Plex Mono", monospace; font-size: 0.7rem; }
172
+ .secnav a { color: var(--muted); padding-bottom: 0.5rem; }
173
+ .secnav a:hover { color: var(--ink); text-decoration: none; }
174
+ .desc { max-width: 52rem; font-size: 0.88rem; color: var(--muted); margin: 0.5rem 0 0; line-height: 1.55; }
175
+ details.faq { border-bottom: 1px dashed var(--line); padding: 0.55rem 0; font-size: 0.85rem; }
176
+ details.faq:last-child { border-bottom: none; }
177
+ details.faq summary { cursor: pointer; font-weight: 600; list-style: none; display: flex; justify-content: space-between; align-items: center; }
178
+ details.faq summary::after { content: "+"; color: var(--muted); font-weight: 400; }
179
+ details.faq[open] summary::after { content: "–"; }
180
+ details.faq p { color: var(--muted); margin: 0.4rem 0 0.2rem; max-width: 48rem; }
181
+ .relcards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.7rem; }
182
+ @media (max-width: 800px) { .relcards { grid-template-columns: 1fr; } }
183
+ .relcard { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 0.7rem 0.9rem; display: flex; flex-direction: column; gap: 0.2rem; }
184
+ .relcard:hover { border-color: var(--accent); text-decoration: none; }
185
+ .relcard .rn { font-weight: 600; color: var(--ink); font-size: 0.86rem; }
186
+ .relcard .rm { font-family: "IBM Plex Mono", monospace; font-size: 0.66rem; color: var(--muted); }
187
+
188
+ .feeditem { display: grid; grid-template-columns: 9.5rem 1fr; gap: 0.9rem; padding: 0.6rem 0; border-bottom: 1px dashed var(--line); font-size: 0.83rem; align-items: baseline; }
189
+ .feeditem:last-child { border-bottom: none; }
190
+ .cards3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.7rem; }
191
+ @media (max-width: 800px) { .cards3 { grid-template-columns: 1fr; } }
192
+ </style>
193
+ </head>
194
+ <body>
195
+
196
+ <nav><div class="wrap">
197
+ <a class="brand" href="#/">OpenNodes<span class="mono">registry</span></a>
198
+ <a class="tab" data-view="models" href="#/">Models</a>
199
+ <a class="tab" data-view="rankings" href="#/rankings">Rankings</a>
200
+ <a class="tab" data-view="nodes" href="#/nodes">Nodes</a>
201
+ <a class="tab" data-view="feed" href="#/new">New</a>
202
+ <a class="tab" data-view="build" href="#/build">Build</a>
203
+ <a class="tab" data-view="chat" href="#/chat">Chat</a>
204
+ <span class="spacer"></span>
205
+ <a class="ext" href="https://opennodes.io">opennodes.io</a>
206
+ <a class="ext" href="https://github.com/opennodes-io/opennodes">GitHub</a>
207
+ <a class="cta" href="#/register">Register a node</a>
208
+ </div></nav>
209
+
210
+ <main class="wrap">
211
+
212
+ <!-- ================= MODELS (catalog, master-detail) ================= -->
213
+ <div id="view-models">
214
+ <div style="display:flex; align-items:baseline; gap:0.9rem; flex-wrap:wrap">
215
+ <h1>Models</h1>
216
+ <span class="counts" id="m-counts"></span>
217
+ </div>
218
+ <p class="sub">Search across registered AI nodes and imported catalogs — pick a model on the left, its full card is on the right. Claimed values are never blended with measured ones.</p>
219
+ <div class="toolbar">
220
+ <input type="text" id="f-q" placeholder="Search models, families, nodes…">
221
+ <span class="switchrow" id="f-verified" title="identity-fingerprinted · context-verified · continuously probed"><span class="switch"></span><b style="font-size:0.78rem">Verified only</b></span>
222
+ <select id="f-modality">
223
+ <option value="">any modality</option>
224
+ <option>text</option><option>multimodal</option><option>image</option>
225
+ <option>audio</option><option>video</option><option>embedding</option>
226
+ </select>
227
+ <button class="toggle" data-sup="tool_calls">tools</button>
228
+ <button class="toggle" data-sup="vision">vision</button>
229
+ <button class="toggle" data-sup="json_mode">json</button>
230
+ <input type="number" id="f-price" placeholder="max $/MTok in" min="0" step="0.05">
231
+ <select id="f-sort"><option value="rank">sort: rank</option><option value="price">sort: price</option><option value="ttft">sort: fastest first token</option><option value="tps">sort: throughput</option></select>
232
+ </div>
233
+ <details class="panel pad" id="advisor" style="margin:0.2rem 0 0.8rem">
234
+ <summary style="cursor:pointer; font-weight:600; font-size:0.9rem">Ask the advisor — describe a task, get the best node + model, priced and explained</summary>
235
+ <textarea id="adv-task" rows="2" class="wide" style="margin-top:0.6rem; font-size:0.86rem" placeholder="e.g. Translate this 20-page contract into Polish and extract the payment terms as JSON"></textarea>
236
+ <div class="toolbar" style="margin-top:0.4rem">
237
+ <select id="adv-preset"><option value="">balanced</option><option value="cheap">cheapest</option><option value="fast">fastest</option><option value="quality">best quality</option><option value="private">privacy-first</option></select>
238
+ <span class="switchrow" id="adv-verified"><span class="switch"></span><b style="font-size:0.78rem">Verified only</b></span>
239
+ <button class="btn" id="adv-go">Recommend</button>
240
+ <span class="prov">Deterministic: same catalog + task + policy → same answer, with the score components shown. The <code>onp mcp</code> / gateway advisors extract features locally and never send your prompt; this box sends the text to this registry.</span>
241
+ </div>
242
+ <div id="adv-out"></div>
243
+ </details>
244
+ <div class="mgrid">
245
+ <div class="mlist" id="m-list"></div>
246
+ <div class="mdetail" id="m-detail"><p class="sub">Select a model from the list.</p></div>
247
+ </div>
248
+ </div>
249
+
250
+ <!-- ================= MODEL DETAIL (the card lives once; it is re-parented between
251
+ the standalone route and the catalog's right pane) ================= -->
252
+ <div id="view-model" hidden>
253
+ <div id="model-card">
254
+ <div class="crumb" id="md-crumb"></div>
255
+ <div style="display:flex; align-items:center; gap:0.8rem; flex-wrap:wrap">
256
+ <h1 id="md-title" style="margin:0"></h1>
257
+ <span class="chip mono" id="md-artifact" style="border-radius:4px; color:var(--accent)"></span>
258
+ <span id="md-chips" style="display:inline-flex; gap:0.4rem"></span>
259
+ </div>
260
+ <p class="desc" id="md-desc" hidden></p>
261
+ <div class="statstrip" id="md-stats"></div>
262
+ <div class="secnav" id="md-nav"></div>
263
+ <h2 id="sec-offerings">Offerings <span class="counts" id="md-count"></span></h2>
264
+ <div class="panel tablewrap" id="md-offerings"></div>
265
+ <h2 id="sec-play">Playground <span class="counts">runs in your browser — your prompt goes straight to the node, never through this registry</span></h2>
266
+ <div class="panel pad" style="max-width:56rem">
267
+ <div style="display:grid; grid-template-columns: 1fr 1fr; gap:0.8rem">
268
+ <div>
269
+ <label class="f" style="margin-top:0">Target</label>
270
+ <select id="pg-target" style="width:100%; font:inherit; color:var(--ink); background:var(--panel); border:1px solid var(--line); border-radius:5px; padding:0.45rem 0.6rem"></select>
271
+ </div>
272
+ <div>
273
+ <label class="f" style="margin-top:0">API key <span style="font-weight:400">(if the node needs one — kept in your browser only)</span></label>
274
+ <input class="wide mono" id="pg-key" type="password" placeholder="optional" style="font-size:0.78rem">
275
+ </div>
276
+ </div>
277
+ <div id="pg-customrow" hidden style="display:grid; grid-template-columns: 2fr 1fr; gap:0.8rem">
278
+ <div><label class="f">Custom OpenAI-compatible endpoint <span style="font-weight:400">(e.g. your local Ollama: http://127.0.0.1:11434/v1)</span></label>
279
+ <input class="wide mono" id="pg-endpoint" placeholder="http://127.0.0.1:11434/v1" style="font-size:0.78rem"></div>
280
+ <div><label class="f">Model id</label>
281
+ <input class="wide mono" id="pg-model" placeholder="gemma4:latest" style="font-size:0.78rem"></div>
282
+ </div>
283
+ <label class="f">Prompt</label>
284
+ <textarea id="pg-prompt" rows="3" class="wide" style="resize:vertical; font-family:inherit" placeholder="Try the model…"></textarea>
285
+ <div style="display:flex; align-items:center; gap:0.9rem; margin-top:0.7rem">
286
+ <button class="btn" id="pg-send">Send</button>
287
+ <span class="prov" id="pg-meta"></span>
288
+ </div>
289
+ <div id="pg-out" style="margin-top:0.8rem; font-size:0.88rem; white-space:pre-wrap; line-height:1.55"></div>
290
+ </div>
291
+ <div id="md-perfsec"></div>
292
+ <div id="md-benchsec"></div>
293
+ <div id="md-relsec"></div>
294
+ <div id="md-faqsec"></div>
295
+ </div>
296
+ </div>
297
+
298
+ <!-- ================= RANKINGS ================= -->
299
+ <div id="view-rankings" hidden>
300
+ <h1>Rankings</h1>
301
+ <div class="tabbtns" style="margin:0.6rem 0 0.7rem">
302
+ <button class="tabbtn on">Measured</button>
303
+ <button class="tabbtn" disabled title="requires receipted gateway usage — arrives with settlement">Adoption</button>
304
+ <button class="tabbtn" disabled title="requires reviewer accounts — arrives with reviews">Community</button>
305
+ </div>
306
+ <p class="subserif">Rolling registry measurements of live endpoints — these rank <b>speed and reliability</b>, not model intelligence. Nodes are blind-probed at unpredictable times; imported catalog entries are unprobed and excluded. Adoption and Community rankings arrive with settlement and reviews.</p>
307
+ <div class="panel tablewrap" id="r-table"></div>
308
+ </div>
309
+
310
+ <!-- ================= NODES ================= -->
311
+ <div id="view-nodes" hidden>
312
+ <div style="display:flex; align-items:baseline; gap:0.9rem"><h1>Nodes</h1><span class="counts" id="n-counts"></span></div>
313
+ <p class="sub">The supply side, first-class: every provider serving through OpenNodes, with the trust tier it has earned. <b>Verified</b> means identity-fingerprinted, context-verified, capacity-measured, and continuously probed — <a href="#/qualification">the qualification procedure</a> is automated and the same for everyone.</p>
314
+ <div class="panel tablewrap" id="n-table"></div>
315
+ </div>
316
+
317
+ <!-- ================= NEW (feed) ================= -->
318
+ <div id="view-feed" hidden>
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>
321
+ <div class="panel pad" id="feed-list"></div>
322
+ </div>
323
+
324
+ <!-- ================= CHAT ================= -->
325
+ <div id="view-chat" hidden>
326
+ <h1>Chat</h1>
327
+ <p class="sub">Straight from your browser to the nodes you pick — your messages never pass through this registry. Add several models and switch modes: compare them in parallel, let a jury judge their answers, or chain them into a pipeline.</p>
328
+ <div class="chatgrid">
329
+ <div style="display:flex; flex-direction:column; gap:0.9rem; min-width:0">
330
+ <div class="panel" style="display:flex; flex-direction:column">
331
+ <div class="chatlog" id="ch-log"><p class="sub" style="margin:0">Add a model on the right and say something.</p></div>
332
+ <div class="chatbar">
333
+ <textarea id="ch-input" rows="2" placeholder="Message…"></textarea>
334
+ <button class="btn" id="ch-send" style="align-self:flex-end">Send</button>
335
+ </div>
336
+ </div>
337
+ <details class="panel pad">
338
+ <summary style="cursor:pointer; font-weight:600; font-size:0.9rem">Model tests <span class="counts">smoke probes for the current roster — quick sanity checks, not benchmarks</span></summary>
339
+ <div id="ct-list" style="display:flex; flex-wrap:wrap; gap:0.8rem; margin:0.8rem 0"></div>
340
+ <div style="display:flex; gap:0.8rem; align-items:center">
341
+ <button class="btn sec" id="ct-run" style="padding:0.45rem 0.9rem; font-size:0.78rem">Run tests</button>
342
+ <span class="prov" id="ct-note">single run each, default temperature — a ✗ is a data point, not a verdict</span>
343
+ </div>
344
+ <div class="tablewrap" id="ct-out" style="margin-top:0.8rem"></div>
345
+ </details>
346
+ </div>
347
+ <div style="display:flex; flex-direction:column; gap:0.9rem">
348
+ <div class="panel pad">
349
+ <span class="lab" style="font-family:'IBM Plex Mono',monospace; font-size:0.62rem; font-weight:600; letter-spacing:0.12em; color:var(--accent)">ROSTER</span>
350
+ <div id="ch-chips" style="display:flex; flex-direction:column; gap:0.35rem; margin:0.6rem 0"></div>
351
+ <select id="ch-add" style="width:100%; font:inherit; color:var(--ink); background:var(--panel); border:1px solid var(--line); border-radius:5px; padding:0.45rem 0.6rem"></select>
352
+ <div id="ch-customrow" hidden>
353
+ <label class="f">Custom endpoint</label>
354
+ <input class="wide mono" id="ch-endpoint" placeholder="http://127.0.0.1:11434/v1" style="font-size:0.76rem">
355
+ <label class="f">Model id</label>
356
+ <input class="wide mono" id="ch-model" placeholder="gemma3" style="font-size:0.76rem">
357
+ <button class="btn sec" id="ch-addcustom" style="margin-top:0.5rem; padding:0.35rem 0.8rem; font-size:0.74rem">Add custom</button>
358
+ </div>
359
+ <label class="f">Mode</label>
360
+ <select id="ch-mode" style="width:100%; font:inherit; color:var(--ink); background:var(--panel); border:1px solid var(--line); border-radius:5px; padding:0.45rem 0.6rem">
361
+ <option value="single">Single — multi-turn with the first model</option>
362
+ <option value="compare">Compare — parallel, each model keeps its own memory</option>
363
+ <option value="jury">Jury — parallel answers, one model judges</option>
364
+ <option value="pipeline">Pipeline — sequential refine through the roster</option>
365
+ <option value="iterative">Iterative — rounds of mutual revision, then verdict</option>
366
+ </select>
367
+ <p class="prov" id="ch-modenote" style="margin:0.3rem 0 0"></p>
368
+ <div id="ch-juryrow" hidden>
369
+ <label class="f">Judge</label>
370
+ <select id="ch-judge" style="width:100%; font:inherit; color:var(--ink); background:var(--panel); border:1px solid var(--line); border-radius:5px; padding:0.45rem 0.6rem"></select>
371
+ </div>
372
+ <div id="ch-iterrow" hidden>
373
+ <label class="f">Rounds</label>
374
+ <select id="ch-rounds" style="width:100%; font:inherit; color:var(--ink); background:var(--panel); border:1px solid var(--line); border-radius:5px; padding:0.45rem 0.6rem">
375
+ <option value="2">2 — answer, then revise</option>
376
+ <option value="3">3 — answer, rebut, revise</option>
377
+ </select>
378
+ </div>
379
+ <details style="margin-top:0.7rem">
380
+ <summary class="prov" style="cursor:pointer">scenario instructions</summary>
381
+ <label class="f">Pipeline step instruction</label>
382
+ <textarea id="ch-seqprompt" rows="3" class="wide" style="font-size:0.76rem; resize:vertical">You receive a question and a previous answer. Improve and correct it. Reply with the improved answer only.</textarea>
383
+ <label class="f">Jury instruction</label>
384
+ <textarea id="ch-juryprompt" rows="3" class="wide" style="font-size:0.76rem; resize:vertical">You are the judge. Compare the candidate answers to the question. Name the best one (by letter), then justify your verdict in two sentences.</textarea>
385
+ <label class="f">Revision instruction (iterative rounds)</label>
386
+ <textarea id="ch-revprompt" rows="3" class="wide" style="font-size:0.76rem; resize:vertical">You previously answered this question. Review the other candidates' answers: note where you agree, where you disagree and why, then provide your revised answer. Reply with the revised answer only.</textarea>
387
+ </details>
388
+ <details style="margin-top:0.5rem">
389
+ <summary class="prov" style="cursor:pointer">scenarios — save &amp; load</summary>
390
+ <div style="display:flex; gap:0.4rem; margin-top:0.5rem">
391
+ <input class="wide" id="sc-name" placeholder="scenario name" style="font-size:0.76rem">
392
+ <button class="btn sec" id="sc-save" style="padding:0.35rem 0.7rem; font-size:0.74rem">Save</button>
393
+ </div>
394
+ <div style="display:flex; gap:0.4rem; margin-top:0.4rem">
395
+ <select id="sc-list" style="flex:1; font:inherit; color:var(--ink); background:var(--panel); border:1px solid var(--line); border-radius:5px; padding:0.35rem 0.5rem; font-size:0.76rem"></select>
396
+ <button class="btn sec" id="sc-load" style="padding:0.35rem 0.7rem; font-size:0.74rem">Load</button>
397
+ <button class="btn sec" id="sc-del" style="padding:0.35rem 0.7rem; font-size:0.74rem" title="delete">✕</button>
398
+ </div>
399
+ <button class="btn sec" id="sc-export" style="margin-top:0.4rem; padding:0.35rem 0.7rem; font-size:0.74rem">Export as ONP-6 Scenario JSON</button>
400
+ <p class="prov" id="sc-note" style="margin:0.35rem 0 0">saved in your browser; export produces the portable ONP-6 §5 format</p>
401
+ </details>
402
+ <label class="f">API key <span style="font-weight:400">(per host, kept in your browser)</span></label>
403
+ <input class="wide mono" id="ch-key" type="password" placeholder="optional" style="font-size:0.76rem">
404
+ <div style="display:flex; gap:0.6rem; margin-top:0.8rem; align-items:center; flex-wrap:wrap">
405
+ <button class="btn sec" id="ch-clear" style="padding:0.4rem 0.8rem; font-size:0.76rem">Clear chat</button>
406
+ <span class="prov" id="ch-est" style="margin:0"></span>
407
+ </div>
408
+ <div class="prov" id="ch-cost" style="margin-top:0.4rem"></div>
409
+ </div>
410
+ <div class="panel pad">
411
+ <span class="lab" style="font-family:'IBM Plex Mono',monospace; font-size:0.62rem; font-weight:600; letter-spacing:0.12em; color:var(--accent)">DESKTOP &amp; APPS</span>
412
+ <p style="font-size:0.8rem; color:var(--muted); margin:0.5rem 0 0.4rem"><b>Surge</b>, a desktop client with native OpenNodes support (models + MCP tools in one app), is in development — <a href="https://github.com/opennodes-io/surge">watch the repository</a> for its first release.</p>
413
+ <p style="font-size:0.8rem; color:var(--muted); margin:0">Available today: bring every model here into <b>Cursor, VS Code, or any OpenAI-compatible app</b> via the <a href="https://github.com/opennodes-io/opennodes/tree/main/impl">onp gateway and MCP server</a>.</p>
414
+ </div>
415
+ </div>
416
+ </div>
417
+ </div>
418
+
419
+ <!-- ================= NODE BUILDER ================= -->
420
+ <div id="view-build" hidden>
421
+ <h1>Build an AI node</h1>
422
+ <p class="sub">From "which model?" to a registered node: pick a model and quantization, choose a runtime and hardware, and get a ready Node Kit configuration. Estimates are rough sizing guidance, not measurements — the qualification battery measures reality after you register.</p>
423
+
424
+ <h2>1 · Model &amp; quantization</h2>
425
+ <div class="panel pad" style="max-width:56rem">
426
+ <div style="display:grid; grid-template-columns: 2fr 1fr 1fr; gap:0.8rem">
427
+ <div><label class="f" style="margin-top:0">Search the catalog (open-weight models with known size)</label>
428
+ <input class="wide" id="nb-search" placeholder="llama, qwen, gemma…"></div>
429
+ <div><label class="f" style="margin-top:0">Quantization</label>
430
+ <select id="nb-quant" style="width:100%; font:inherit; color:var(--ink); background:var(--panel); border:1px solid var(--line); border-radius:5px; padding:0.45rem 0.6rem">
431
+ <option value="2">fp16 / bf16</option><option value="1" selected>fp8</option><option value="0.55">int4 / q4</option>
432
+ </select></div>
433
+ <div><label class="f" style="margin-top:0">Target context</label>
434
+ <select id="nb-ctx" style="width:100%; font:inherit; color:var(--ink); background:var(--panel); border:1px solid var(--line); border-radius:5px; padding:0.45rem 0.6rem">
435
+ <option value="8">8K</option><option value="32" selected>32K</option><option value="128">128K</option><option value="256">256K</option>
436
+ </select></div>
437
+ </div>
438
+ <div id="nb-models" style="margin-top:0.7rem; display:flex; flex-direction:column; gap:0.3rem"></div>
439
+ <p class="prov" id="nb-vram" style="margin-top:0.7rem"></p>
440
+ </div>
441
+
442
+ <h2>2 · Runtime</h2>
443
+ <div class="cards3" style="max-width:56rem" id="nb-runtimes">
444
+ <div class="panel pad nb-rt" data-rt="ollama" style="cursor:pointer"><b>Ollama</b><p class="sub" style="margin:0.3rem 0 0; font-size:0.78rem">Simplest path — one install, GGUF quantization built in. Best for personal nodes and development; modest peak throughput.</p></div>
445
+ <div class="panel pad nb-rt" data-rt="vllm" style="cursor:pointer"><b>vLLM</b><p class="sub" style="margin:0.3rem 0 0; font-size:0.78rem">Production serving — continuous batching, paged attention, best multi-user throughput. The default for a serious node.</p></div>
446
+ <div class="panel pad nb-rt" data-rt="tensorrt" style="cursor:pointer"><b>TensorRT-LLM</b><p class="sub" style="margin:0.3rem 0 0; font-size:0.78rem">NVIDIA-optimized kernels — lowest latency on H100/H200 class hardware; heavier build and engine-compilation step.</p></div>
447
+ </div>
448
+
449
+ <h2>3 · Hardware</h2>
450
+ <div class="panel pad" style="max-width:56rem">
451
+ <div id="nb-gpus" class="prov" style="font-size:0.78rem">pick a model above to size hardware</div>
452
+ <p class="sub" style="margin:0.7rem 0 0; font-size:0.78rem">Where to run it: your own hardware, a university/institutional cluster, or GPU clouds (Google Cloud, AWS, Hetzner, OVHcloud, CoreWeave, and regional providers). Data-residency requirements narrow the list — that constraint is a first-class filter in this registry for a reason.</p>
453
+ </div>
454
+
455
+ <h2>4 · Your node</h2>
456
+ <div class="panel pad" style="max-width:56rem">
457
+ <div id="nb-comparables" class="prov" style="margin-bottom:0.5rem"></div>
458
+ <pre id="nb-config" style="background:var(--panel-2); border:1px solid var(--line); border-radius:6px; padding:0.8rem 1rem; overflow-x:auto; font-size:0.72rem; line-height:1.5; font-family:'IBM Plex Mono', monospace">select a model and runtime to generate your configuration</pre>
459
+ <p style="font-size:0.84rem">Then: <a href="https://github.com/opennodes-io/opennodes/tree/main/node-kit">install the Node Kit</a> → <code>onp-node init</code> with this config → <code>onp-node check</code> → <a href="#/register">register</a>. Trust tiers are earned through the <a href="#/qualification">automated qualification battery</a>.</p>
460
+ </div>
461
+ </div>
462
+
463
+ <!-- ================= NODE DETAIL ================= -->
464
+ <div id="view-node" hidden>
465
+ <div class="crumb"><a href="#/nodes">Nodes</a> /</div>
466
+ <h1 id="node-title" class="mono" style="font-size:1.05rem"></h1>
467
+ <div id="node-detail"></div>
468
+ </div>
469
+
470
+ <!-- ================= REGISTER ================= -->
471
+ <div id="view-register" hidden>
472
+ <h1>Register an AI node</h1>
473
+ <p class="sub">Anyone can register — a lab, a university cluster, a single Ollama box. Registration is open; <a href="#/qualification">trust is earned</a> through the automated qualification procedure. Serve a signed <a href="https://github.com/opennodes-io/opennodes/blob/main/spec/ONP-2-node-card.md">Node Card</a> at <span class="mono" style="font-size:0.8em">/.well-known/open-node.json</span> (the <a href="https://github.com/opennodes-io/opennodes/tree/main/node-kit">Node Kit</a> does this for any vLLM/Ollama/TGI deployment), then submit it here.</p>
474
+ <div class="panel pad" style="max-width:46rem">
475
+ <label class="f">Node Card URL</label>
476
+ <input class="wide mono" id="card-url" placeholder="https://ai.example.org/.well-known/open-node.json" style="font-size:0.8rem">
477
+ <div style="margin-top:0.9rem"><button class="btn" id="btn-register">Submit for registration</button></div>
478
+ <div id="reg-out"></div>
479
+ </div>
480
+ </div>
481
+
482
+ <!-- ================= QUALIFICATION ================= -->
483
+ <div id="view-qualification" hidden>
484
+ <h1>The qualification procedure</h1>
485
+ <p class="sub">Listing is open; tiers are earned. The registry runs an automated admission battery against your live endpoints — no paperwork, no gatekeepers, no exceptions. Every verdict is recorded and visible in your node's listing history.</p>
486
+ <div class="panel pad" style="max-width:56rem">
487
+ <div class="stage"><span class="k">STAGE A</span><span><b>Conformance</b> — gate to <span class="chip t-community">COMMUNITY</span>, minutes. Health endpoint live; card valid and signed; models list agrees with the card; one blind invocation returns proper usage accounting and a signed receipt. Re-run every 6&nbsp;hours and on every card revision.</span></div>
488
+ <div class="stage"><span class="k">STAGE B</span><span><b>Substance</b> — gate to <span class="chip t-verified">VERIFIED</span>, hours. <b>Identity fingerprinting:</b> a deterministic probe battery compared against reference outputs of the claimed model — serving a cheaper substitute under a premium name marks the node <i>disputed</i> and delists it. <b>Context verification:</b> needle-in-haystack probes at 25/50/90% of the claimed window; overclaims are capped to the verified size, visibly. <b>Capacity ramp:</b> stepped concurrent load; measured TTFT/throughput overrides claims in search results.</span></div>
489
+ <div class="stage"><span class="k">STAGE C</span><span><b>Continuous</b> — keeps the tier honest. Liveness sweeps feed a rolling availability score; random blind invocations at unpredictable times defeat "benchmark mode"; sustained failures auto-suspend the listing.</span></div>
490
+ </div>
491
+ <h2>Principles</h2>
492
+ <div class="panel pad" style="max-width:56rem; font-size:0.85rem">
493
+ <p><b>Claimed ≠ measured ≠ attested.</b> Self-reported values are labeled <span class="micro m-claimed">CLAIMED</span>; registry measurements override them and show as <span class="micro m-measured">● MEASURED</span>; third-party Verifiable Credentials earn <i>attested</i>. The three are never blended.</p>
494
+ <p><b>Transparent rank, by rule.</b> Every result carries its rank explanation — component weights and values. Undisclosed ranking inputs are non-conformant; sponsored placement, if ever present, must be labeled and excluded from rank.</p>
495
+ <p style="margin-bottom:0"><b>Probes cost you nothing you don't grant.</b> Your card declares a probe allowance; the registry stays within it or pays like any client.</p>
496
+ </div>
497
+ </div>
498
+
499
+ </main>
500
+
501
+ <script>
502
+ const $ = (s) => document.querySelector(s);
503
+ const api = (p, opts) => fetch(p, opts).then(async (r) => { const b = await r.json().catch(() => ({})); if (!r.ok) throw new Error(b.detail || r.status); return b; });
504
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
505
+ const fmt = (n) => n == null ? '—' : (typeof n === 'number' ? (Math.round(n * 1000) / 1000) : n);
506
+ const fmtInt = (n) => n == null ? '—' : Number(n).toLocaleString('en-US');
507
+ const ago = (iso) => {
508
+ if (!iso) return '—';
509
+ const s = (Date.now() - new Date(iso).getTime()) / 1000;
510
+ if (s < 90) return 'just now';
511
+ if (s < 5400) return Math.round(s / 60) + 'min ago';
512
+ if (s < 129600) return Math.round(s / 3600) + 'h ago';
513
+ return Math.round(s / 86400) + 'd ago';
514
+ };
515
+ const tierChip = (t) => `<span class="chip t-${esc(t)}">${esc(String(t).toUpperCase())}</span>`;
516
+ const priceStr = (p) => {
517
+ const i = p.input_per_mtok ?? 0, o = p.output_per_mtok ?? 0;
518
+ return (i === 0 && o === 0) ? 'free' : `$${fmt(i)} / $${fmt(o)} per MTok`;
519
+ };
520
+
521
+ /* ---------- routing ---------- */
522
+ const views = ['models', 'model', 'rankings', 'nodes', 'feed', 'build', 'chat', 'node', 'register', 'qualification'];
523
+ function route() {
524
+ const hash = location.hash || '#/';
525
+ let view = 'models', arg = null;
526
+ if (hash.startsWith('#/model/')) { view = 'model'; arg = decodeURIComponent(hash.slice(8)); }
527
+ else if (hash.startsWith('#/rankings')) view = 'rankings';
528
+ else if (hash.startsWith('#/nodes')) view = 'nodes';
529
+ else if (hash.startsWith('#/new')) view = 'feed';
530
+ else if (hash.startsWith('#/build')) view = 'build';
531
+ else if (hash.startsWith('#/chat')) view = 'chat';
532
+ else if (hash.startsWith('#/node/')) { view = 'node'; arg = decodeURIComponent(hash.slice(7)); }
533
+ else if (hash.startsWith('#/register')) view = 'register';
534
+ else if (hash.startsWith('#/qualification')) view = 'qualification';
535
+ for (const v of views) $('#view-' + v).hidden = (v !== view);
536
+ document.querySelectorAll('nav a.tab').forEach((a) => a.classList.toggle('active', a.dataset.view === view || (view === 'model' && a.dataset.view === 'models') || (view === 'node' && a.dataset.view === 'nodes')));
537
+ window.scrollTo(0, 0);
538
+ if (view === 'models') search();
539
+ if (view === 'model' && arg) { $('#view-model').appendChild($('#model-card')); loadModel(arg); }
540
+ if (view === 'chat') loadChatTargets();
541
+ if (view === 'rankings') loadRankings();
542
+ if (view === 'nodes') loadNodes();
543
+ if (view === 'feed') loadFeed();
544
+ if (view === 'node' && arg) loadNode(arg);
545
+ }
546
+ window.addEventListener('hashchange', route);
547
+
548
+ /* ---------- catalog ---------- */
549
+ const sup = new Set();
550
+ document.querySelectorAll('.toggle').forEach((b) => b.addEventListener('click', () => {
551
+ b.classList.toggle('on'); b.classList.contains('on') ? sup.add(b.dataset.sup) : sup.delete(b.dataset.sup); search();
552
+ }));
553
+ $('#f-verified').addEventListener('click', () => { $('#f-verified').classList.toggle('on'); search(); });
554
+ let debounce;
555
+ for (const id of ['f-q', 'f-price']) $('#' + id).addEventListener('input', () => { clearTimeout(debounce); debounce = setTimeout(search, 250); });
556
+ for (const id of ['f-modality', 'f-sort']) $('#' + id).addEventListener('change', search);
557
+
558
+ function measuredBits(o) {
559
+ const obs = o.observed || {};
560
+ const bits = [];
561
+ if (obs.ttft_ms?.p50 != null) bits.push(`<span>ttft <b class="num">${obs.ttft_ms.p50}ms</b> <span class="micro m-measured">● MEASURED</span></span>`);
562
+ else if (o.serving?.expected_ttft_ms?.p50 != null) bits.push(`<span>ttft <b class="num">${o.serving.expected_ttft_ms.p50}ms</b> <span class="micro m-claimed">CLAIMED</span></span>`);
563
+ if (obs.tps != null && obs.tps > 0) bits.push(`<span><b class="num">${obs.tps}</b> tok/s <span class="micro m-measured">● MEASURED</span></span>`);
564
+ else if (o.serving?.expected_tps?.p50 != null) bits.push(`<span><b class="num">${o.serving.expected_tps.p50}</b> tok/s <span class="micro m-claimed">CLAIMED</span></span>`);
565
+ if (obs.availability?.ratio != null) bits.push(`<span>up <b class="num">${Math.round(obs.availability.ratio * 100)}%</b></span>`);
566
+ return bits;
567
+ }
568
+ function hwStr(o, withBasis = true) {
569
+ const acc = o.hardware?.accelerators;
570
+ if (!acc?.length) return '';
571
+ const parts = acc.map((a) => `${a.count}× ${esc(a.type)}${a.memory_gb ? ` ${a.memory_gb}GB` : ''}`).join(' + ');
572
+ if (!withBasis) return parts;
573
+ const basis = o.hardware.basis === 'attested'
574
+ ? '<span class="micro m-measured">✓ ATTESTED</span>' : '<span class="micro m-claimed">CLAIMED</span>';
575
+ const mlperf = o.hardware.mlperf?.url
576
+ ? ` <a href="${esc(o.hardware.mlperf.url)}" target="_blank" rel="noopener" class="micro" style="color:var(--accent)">MLPERF ↗</a>` : '';
577
+ return `${parts} ${basis}${mlperf}`;
578
+ }
579
+ function ctxBit(o) {
580
+ if (!o.serving?.context_window) return '';
581
+ const cap = o.serving.context_capped_from
582
+ ? ` <span class="micro m-warn">▼ CAPPED FROM ${fmtInt(o.serving.context_capped_from)}</span>` : '';
583
+ const basis = (o.source === 'import' && !o.serving.context_capped_from) ? ' <span class="micro m-claimed">CLAIMED</span>' : '';
584
+ return `<span>ctx <b class="num">${fmtInt(o.serving.context_window)}</b>${cap}${basis}</span>`;
585
+ }
586
+ function provLine(o) {
587
+ const obs = o.observed || {};
588
+ const bits = [`revision ${esc((o.card_revision || '').slice(0, 10))}`];
589
+ if (obs.probes > 0) bits.push(`probes ${Math.round((obs.probe_success ?? 0) * obs.probes)}/${obs.probes} pass`);
590
+ if (o.source === 'import') bits.push('catalog import — operator can claim this listing to get verified');
591
+ return bits.join(' · ');
592
+ }
593
+
594
+ async function search() {
595
+ const p = new URLSearchParams({ limit: 60 });
596
+ if ($('#f-q').value) p.set('q', $('#f-q').value);
597
+ if ($('#f-modality').value) p.set('modality', $('#f-modality').value);
598
+ if ($('#f-verified').classList.contains('on')) p.set('tier', 'verified');
599
+ if ($('#f-price').value) p.set('max_input_price', $('#f-price').value);
600
+ if (sup.size) p.set('supports', [...sup].join(','));
601
+ p.set('sort', $('#f-sort').value);
602
+ try {
603
+ const { offerings } = await api('/v0/offerings?' + p);
604
+ lastResults = offerings;
605
+ $('#m-counts').textContent = offerings.length + (offerings.length === 60 ? '+' : '') + ' offerings';
606
+ $('#m-list').innerHTML = offerings.map(renderItem).join('')
607
+ || '<p class="sub">No offerings match. A fresh registry fills up via catalog imports and node registrations.</p>';
608
+ document.querySelectorAll('.mitem').forEach((el) => el.addEventListener('click', () => selectModel(el)));
609
+ // keep the current selection when still listed, else select the first result
610
+ const current = $('#model-card')?.dataset.artifact;
611
+ const keep = current && [...document.querySelectorAll('.mitem')].find((el) => el.dataset.artifact === current);
612
+ const target = keep ?? document.querySelector('.mitem');
613
+ if (target) selectModel(target);
614
+ else $('#m-detail').innerHTML = '<p class="sub">No offerings match.</p>';
615
+ } catch (e) { $('#m-list').innerHTML = '<p class="err">' + esc(e.message) + '</p>'; }
616
+ }
617
+
618
+ /* ---------- advisor (ONP-3 §5b) ---------- */
619
+ $('#adv-verified').addEventListener('click', () => $('#adv-verified').classList.toggle('on'));
620
+ $('#adv-go').addEventListener('click', runAdvisor);
621
+ $('#adv-task').addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) runAdvisor(); });
622
+ async function runAdvisor() {
623
+ const task = $('#adv-task').value.trim();
624
+ if (!task) return;
625
+ const policy = { limit: 3 };
626
+ if ($('#adv-preset').value) policy.preset = $('#adv-preset').value;
627
+ if ($('#adv-verified').classList.contains('on')) policy.min_tier = 'verified';
628
+ $('#adv-out').innerHTML = '<p class="sub">ranking…</p>';
629
+ try {
630
+ const r = await api('/v0/recommend', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ task, policy }) });
631
+ renderAdvice(r);
632
+ } catch (e) { $('#adv-out').innerHTML = '<p class="err">' + esc(e.message) + '</p>'; }
633
+ }
634
+ function renderAdvice(r) {
635
+ const f = r.features;
636
+ const featLine = [f.task_class, f.modality, f.language, `~${fmtInt(f.est_input_tokens)} in / ${fmtInt(f.est_output_tokens)} out`,
637
+ f.needs.tools ? 'needs tools' : null, f.needs.json ? 'JSON output' : null, f.needs.reasoning === 'high' ? 'hard reasoning' : null,
638
+ f.privacy === 'sensitive' ? 'sensitive → retention:none only' : null, `${r.eligible}/${r.considered} eligible`].filter(Boolean).join(' · ');
639
+ const cards = r.recommendations.map((x, i) => `<div class="panel pad" style="margin-top:0.5rem">
640
+ <div style="display:flex; gap:0.6rem; align-items:baseline; flex-wrap:wrap"><b>#${i + 1}</b>
641
+ <a href="#/model/${encodeURIComponent(x.artifact ?? '')}" style="font-weight:600">${esc(x.model ?? x.offering)}</a>
642
+ ${tierChip(x.tier)} <a href="#/node/${encodeURIComponent(x.node_id)}" class="mono" style="font-size:0.72rem">${esc(x.offering)}</a>
643
+ <span class="num" style="margin-left:auto">score <b>${fmt(x.score)}</b></span></div>
644
+ <div class="prov" style="margin-top:0.3rem">${x.reasons.map(esc).join(' · ')}</div>
645
+ <div class="num" style="font-size:0.78rem; margin-top:0.3rem; color:var(--muted)">est. $${fmt(x.estimate.min)}–$${fmt(x.estimate.max)} · quality ${x.components.quality} · price ${x.components.price} · trust ${x.components.trust} · perf ${x.components.perf}${x.measured.ttft_ms != null ? ` · ${x.measured.ttft_ms}ms <span class="micro m-measured">● MEASURED</span>` : ''}</div>
646
+ </div>`).join('');
647
+ const scen = r.scenario ? `<div class="panel pad" style="margin-top:0.5rem; font-size:0.84rem"><b>Suggested chain — ${esc(r.scenario.name)}:</b> ${esc(r.scenario.why)}${r.scenario.estimated_savings_usd_if_simple ? ` · saves up to $${fmt(r.scenario.estimated_savings_usd_if_simple)} per simple request` : ''}</div>` : '';
648
+ const tools = r.tools.length ? `<div class="panel pad" style="margin-top:0.5rem; font-size:0.84rem"><b>MCP tools likely needed:</b> ${r.tools.map((t) => `${esc(t.category)} (${t.servers.map((s) => `<code>${esc(s.name)}</code>`).join(', ')})`).join(' · ')} <span class="prov">seed list — a directory import replaces it</span></div>` : '';
649
+ const foot = r.recommendations.length
650
+ ? `<div style="margin-top:0.6rem; display:flex; gap:0.8rem; align-items:center; flex-wrap:wrap"><button class="btn" id="adv-chat">Open top ${r.recommendations.length} in chat (compare)</button><span class="prov">weights: ${Object.entries(r.weights).map(([k, v]) => `${k} ${v}`).join(' · ')}</span></div>`
651
+ : `<p class="sub">Nothing eligible. Rejected by filter: ${esc(Object.entries(r.rejected).map(([k, v]) => `${k} ${v}`).join(', ') || 'none')}.</p>`;
652
+ $('#adv-out').innerHTML = `<p class="prov" style="margin-top:0.5rem">${esc(featLine)}</p>${cards}${scen}${tools}${foot}`;
653
+ $('#adv-chat')?.addEventListener('click', () => {
654
+ sessionStorage.setItem('onp-advisor-handoff', JSON.stringify(r.recommendations.map((x) => x.offering)));
655
+ location.hash = '#/chat';
656
+ });
657
+ }
658
+
659
+ function selectModel(el) {
660
+ document.querySelectorAll('.mitem').forEach((x) => x.classList.toggle('sel', x === el));
661
+ const host = $('#m-detail');
662
+ if (!host.contains($('#model-card'))) { host.innerHTML = ''; host.appendChild($('#model-card')); }
663
+ loadModel(el.dataset.artifact);
664
+ }
665
+
666
+ let lastResults = [];
667
+ function renderItem(o) {
668
+ const obs = o.observed || {};
669
+ return `<div class="mitem" data-artifact="${esc(o.model.artifact)}">
670
+ <div class="t">
671
+ <span class="nm">${esc(o.model.name)}</span>
672
+ ${tierChip(o.tier)}
673
+ ${o.local ? '<span class="chip loc">LOCAL</span>' : (o.source === 'import' ? '<span class="chip imp">IMP</span>' : '')}
674
+ </div>
675
+ <div class="b">
676
+ <span><b style="color:var(--ink)">${priceStr(o.pricing)}</b></span>
677
+ ${o.serving?.context_window ? `<span>ctx ${fmtInt(o.serving.context_window)}${o.serving.context_capped_from ? ' <span class="micro m-warn">▼</span>' : ''}</span>` : ''}
678
+ ${obs.ttft_ms?.p50 != null ? `<span>${obs.ttft_ms.p50}ms <span class="micro m-measured">●</span></span>` : ''}
679
+ <span class="mono">${fmt(o.rank)}</span>
680
+ </div>
681
+ </div>`;
682
+ }
683
+
684
+ window.toggleRow = async function (el, idx) {
685
+ const o = lastResults[idx];
686
+ if (!o) return;
687
+ const willOpen = !el.classList.contains('open');
688
+ el.classList.toggle('open');
689
+ const pane = el.querySelector('.rowx');
690
+ if (!willOpen || pane.dataset.loaded) return;
691
+ pane.dataset.loaded = '1';
692
+ const obs = o.observed || {};
693
+ const expl = o.rank_explanation ? Object.entries(o.rank_explanation.components)
694
+ .map(([k, v]) => `${k}=${fmt(v)}×${o.rank_explanation.weights[k]}`).join(' ') : '—';
695
+ const artifactLink = o.model.artifact.startsWith('hf:')
696
+ ? `<a href="https://huggingface.co/${esc(o.model.artifact.slice(3))}" target="_blank" rel="noopener">${esc(o.model.artifact)}</a>`
697
+ : `<span class="mono" style="font-size:0.72rem">${esc(o.model.artifact)}</span>`;
698
+ pane.innerHTML = `<div class="xgrid">
699
+ <div>
700
+ <span class="xlab">MODEL</span>
701
+ <dl>
702
+ <dt>artifact</dt><dd>${artifactLink}</dd>
703
+ ${o.model.family ? `<dt>family</dt><dd>${esc(o.model.family)}</dd>` : ''}
704
+ ${o.model.params_b ? `<dt>params</dt><dd class="num">${o.model.params_b}B</dd>` : ''}
705
+ ${o.modalities ? `<dt>modalities</dt><dd>${esc((o.modalities.input || []).join('+'))} → ${esc((o.modalities.output || []).join('+'))}</dd>` : ''}
706
+ ${o.pricing.cached_input_per_mtok != null ? `<dt>cached input</dt><dd class="num">$${fmt(o.pricing.cached_input_per_mtok)}/MTok</dd>` : ''}
707
+ ${o.serving?.max_output_tokens ? `<dt>max output</dt><dd class="num">${fmtInt(o.serving.max_output_tokens)}</dd>` : ''}
708
+ ${hwStr(o) ? `<dt>hardware</dt><dd>${hwStr(o)}</dd>` : ''}
709
+ <dt>schemes</dt><dd>${esc((o.pricing.schemes || []).join(', '))}</dd>
710
+ ${o.local ? `<dt>run locally</dt><dd><code style="background:var(--panel-2); padding:0.1em 0.4em; border-radius:3px" class="mono">${esc(o.local.run)}</code>${o.local.pulls ? ` <span class="prov" style="display:inline">· ${esc(o.local.pulls)} pulls</span>` : ''}${o.local.sizes ? ` <span class="prov" style="display:inline">· sizes: ${esc(o.local.sizes.join(' '))}</span>` : ''}</dd>` : ''}
711
+ </dl>
712
+ </div>
713
+ <div>
714
+ <span class="xlab">MEASURED · REGISTRY OBSERVATIONS</span>
715
+ <dl class="num">
716
+ <dt>ttft</dt><dd>${obs.ttft_ms?.p50 != null ? `<b>${obs.ttft_ms.p50}ms</b> p50${obs.ttft_ms.p95 != null ? ` · ${obs.ttft_ms.p95}ms p95` : ''} <span class="micro m-measured">● MEASURED</span>` : '<span style="color:var(--faint)">not probed</span>'}</dd>
717
+ <dt>throughput</dt><dd>${obs.tps > 0 ? `<b>${obs.tps}</b> tok/s <span class="micro m-measured">● MEASURED</span>` : '<span style="color:var(--faint)">—</span>'}</dd>
718
+ <dt>availability</dt><dd>${obs.availability?.ratio != null ? `<b>${Math.round(obs.availability.ratio * 100)}%</b> · window ${obs.availability.window}` : '<span style="color:var(--faint)">—</span>'}</dd>
719
+ <dt>probes</dt><dd>${obs.probes > 0 ? `${Math.round((obs.probe_success ?? 0) * obs.probes)}/${obs.probes} pass` : '<span style="color:var(--faint)">none yet</span>'}</dd>
720
+ <dt>rank</dt><dd class="prov" style="font-size:0.66rem">${esc(expl)}</dd>
721
+ </dl>
722
+ </div>
723
+ </div>
724
+ <div class="also"><span class="xlab">ALSO SERVED BY</span><div class="alsolist prov">loading…</div></div>
725
+ <div class="actions">
726
+ <a href="#/model/${encodeURIComponent(o.model.artifact)}">Model page — all nodes side by side →</a>
727
+ <a href="#/node/${encodeURIComponent(o.node_id)}">Node ${esc(o.node_id)} →</a>
728
+ </div>`;
729
+ try {
730
+ const { offerings } = await api('/v0/offerings?limit=50&q=' + encodeURIComponent(o.model.artifact));
731
+ const others = offerings.filter((x) => x.model.artifact === o.model.artifact
732
+ && !(x.node_id === o.node_id && x.offering_id === o.offering_id));
733
+ pane.querySelector('.alsolist').innerHTML = others.length
734
+ ? others.slice(0, 5).map((x) => `<div class="alsorow">
735
+ <a href="#/node/${encodeURIComponent(x.node_id)}" class="mono" style="font-size:0.72rem">${esc(x.node_id)}</a>
736
+ ${tierChip(x.tier)}
737
+ <span class="num" style="color:var(--muted)">${priceStr(x.pricing)}</span>
738
+ ${x.observed?.ttft_ms?.p50 != null ? `<span class="num" style="color:var(--muted)">${x.observed.ttft_ms.p50}ms <span class="micro m-measured">●</span></span>` : ''}
739
+ </div>`).join('') + (others.length > 5 ? `<div class="alsorow"><a href="#/model/${encodeURIComponent(o.model.artifact)}">+${others.length - 5} more on the model page</a></div>` : '')
740
+ : 'no other nodes serve this exact artifact';
741
+ } catch { pane.querySelector('.alsolist').textContent = 'could not load'; }
742
+ };
743
+
744
+ function renderRow(o, idx) {
745
+ const rankMuted = o.tier === 'unverified';
746
+ return `<div class="row" onclick="if (!event.target.closest('a') && !event.target.closest('.rowx')) toggleRow(this, ${idx})">
747
+ <div class="l1">
748
+ <svg class="chev" width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M6 4l4 4-4 4"></path></svg>
749
+ <span class="name">${esc(o.model.name)}</span>
750
+ ${tierChip(o.tier)}
751
+ <span class="chip">${esc(o.modality)}</span>
752
+ ${o.local ? '<span class="chip loc">RUNS LOCALLY</span>' : (o.source === 'import' ? '<span class="chip imp">IMPORTED</span>' : '')}
753
+ ${o.institutional ? '<span class="chip inst">institutional</span>' : ''}
754
+ <a class="nodeid" href="#/node/${encodeURIComponent(o.node_id)}">${esc(o.node_id)}</a>
755
+ <span class="rankwrap" title="rank — expand for the formula">
756
+ <span class="rankbar"><i style="width:${Math.round((o.rank ?? 0) * 100)}%; ${rankMuted ? 'background: var(--faint)' : ''}"></i></span>
757
+ <span class="mono num" style="font-size:0.7rem; ${rankMuted ? 'color: var(--muted)' : ''}">${fmt(o.rank)}</span>
758
+ </span>
759
+ </div>
760
+ <div class="l2 num">
761
+ <span><b>${priceStr(o.pricing)}</b></span>
762
+ ${ctxBit(o)}
763
+ ${measuredBits(o).join('')}
764
+ ${(o.serving?.supports || []).filter((s) => s !== 'streaming').map((s) => `<span class="chip">${esc(s)}</span>`).join('')}
765
+ </div>
766
+ <span class="prov">${provLine(o)}</span>
767
+ <div class="rowx"></div>
768
+ </div>`;
769
+ }
770
+
771
+ /* ---------- model detail ---------- */
772
+ async function loadModel(artifact) {
773
+ $('#model-card').dataset.artifact = artifact;
774
+ $('#md-crumb').innerHTML = '<a href="#/">Models</a> / ' + esc(artifact);
775
+ $('#md-title').textContent = '…';
776
+ $('#md-offerings').innerHTML = '';
777
+ $('#md-benchsec').innerHTML = '';
778
+ try {
779
+ const { offerings } = await api('/v0/offerings?limit=100&q=' + encodeURIComponent(artifact));
780
+ const list = offerings.filter((o) => o.model.artifact === artifact);
781
+ if (!list.length) { $('#md-title').textContent = 'Not found'; return; }
782
+ const tierOrder = { verified: 3, community: 2, unverified: 1 };
783
+ list.sort((a, b) => (tierOrder[b.tier] ?? 0) - (tierOrder[a.tier] ?? 0) || (a.pricing.input_per_mtok ?? 0) - (b.pricing.input_per_mtok ?? 0));
784
+ const first = list[0];
785
+ $('#md-title').textContent = first.model.name;
786
+ // the artifact chip links to its authoritative home: Hugging Face for hf: artifacts
787
+ $('#md-artifact').innerHTML = artifact.startsWith('hf:')
788
+ ? `<a href="https://huggingface.co/${esc(artifact.slice(3))}" target="_blank" rel="noopener" style="color:inherit; text-decoration:none">${esc(artifact)}</a>`
789
+ : esc(artifact);
790
+ const extLinks = [];
791
+ if (artifact.startsWith('hf:')) {
792
+ extLinks.push(`<a class="chip" style="text-decoration:none; color:var(--accent); border-color:var(--accent)" href="https://huggingface.co/${esc(artifact.slice(3))}" target="_blank" rel="noopener">MODEL CARD ON HUGGING FACE ↗</a>`);
793
+ }
794
+ if (first.local?.runtime === 'ollama') {
795
+ extLinks.push(`<a class="chip" style="text-decoration:none; color:var(--accent); border-color:var(--accent)" href="https://ollama.com/library/${esc(first.binding.model_id.split(':')[0])}" target="_blank" rel="noopener">OLLAMA LIBRARY ↗</a>`);
796
+ }
797
+ $('#md-chips').innerHTML = [
798
+ first.model.params_b ? `<span class="chip">${first.model.params_b}B</span>` : '',
799
+ `<span class="chip">${esc(first.modality)}</span>`,
800
+ first.model.family ? `<span class="chip">${esc(first.model.family)}</span>` : '',
801
+ ...extLinks,
802
+ ].join('');
803
+ $('#md-count').textContent = list.length + ' node' + (list.length === 1 ? '' : 's');
804
+
805
+ // description (imported catalogs carry one; absent otherwise)
806
+ const desc = list.map((o) => o.model.description).find(Boolean);
807
+ $('#md-desc').hidden = !desc;
808
+ if (desc) $('#md-desc').textContent = desc;
809
+
810
+ // stats strip: aggregate honestly across serving nodes
811
+ const prices = list.map((o) => o.pricing.input_per_mtok ?? 0);
812
+ const minP = Math.min(...prices), maxP = Math.max(...prices);
813
+ const ctxMax = Math.max(...list.map((o) => o.serving?.context_window ?? 0), 0);
814
+ const verified = list.filter((o) => o.tier === 'verified').length;
815
+ const released = list.map((o) => o.model.released).find(Boolean);
816
+ const stat = (v, l) => `<span class="stat"><span class="sv">${v}</span><span class="sl">${l}</span></span>`;
817
+ $('#md-stats').innerHTML = [
818
+ stat(minP === 0 && maxP === 0 ? 'free' : (minP === maxP ? '$' + fmt(minP) : '$' + fmt(minP) + '–' + fmt(maxP)), '$/MTOK IN ACROSS NODES'),
819
+ ctxMax ? stat(fmtInt(ctxMax), 'MAX CONTEXT') : '',
820
+ stat(`${verified}/${list.length}`, 'VERIFIED NODES'),
821
+ released ? stat(esc(released), 'RELEASED') : '',
822
+ first.modalities ? stat(esc((first.modalities.input || []).join('+')) + ' → ' + esc((first.modalities.output || []).join('+')), 'MODALITIES') : '',
823
+ ].filter(Boolean).join('');
824
+
825
+ // section nav (anchors appear only for sections that render)
826
+ const navBits = [['#sec-offerings', 'OFFERINGS'], ['#sec-play', 'PLAYGROUND']];
827
+
828
+ // playground targets: this page's offerings + a custom endpoint (e.g. local Ollama)
829
+ pgOfferings = list;
830
+ $('#pg-target').innerHTML = list.map((o, i) =>
831
+ `<option value="${i}">${esc(o.node_id)} — ${priceStr(o.pricing)} [${esc(o.tier)}]</option>`).join('')
832
+ + '<option value="custom">custom endpoint (your local Ollama / any OpenAI-compatible server)</option>';
833
+ $('#pg-customrow').hidden = true;
834
+ $('#pg-out').textContent = '';
835
+ $('#pg-meta').textContent = '';
836
+ $('#md-offerings').innerHTML = `<table class="grid num"><thead><tr>
837
+ <th>NODE</th><th>PRICE IN/OUT</th><th>HARDWARE</th><th>CONTEXT</th><th>TTFT</th><th>TOK/S</th><th>UP</th><th>CAPABILITIES</th><th>REVISION</th>
838
+ </tr></thead><tbody>` + list.map((o) => {
839
+ const obs = o.observed || {};
840
+ const ctx = o.serving?.context_window
841
+ ? (o.serving.context_capped_from
842
+ ? `${fmtInt(o.serving.context_capped_from)} → <b class="warncol">${fmtInt(o.serving.context_window)}</b> <span class="micro m-warn">▼CAP</span>`
843
+ : `<b>${fmtInt(o.serving.context_window)}</b>${o.source === 'import' ? ' <span class="micro m-claimed">CLAIMED</span>' : ' <span class="micro m-measured">✓</span>'}`)
844
+ : '—';
845
+ const ttft = obs.ttft_ms?.p50 != null ? `<b>${obs.ttft_ms.p50}ms</b> <span class="micro m-measured">●</span>`
846
+ : (o.serving?.expected_ttft_ms?.p50 != null ? `${o.serving.expected_ttft_ms.p50}ms <span class="micro m-claimed">CLAIMED</span>` : '<span style="color:var(--faint)">not probed</span>');
847
+ return `<tr>
848
+ <td><div style="display:flex; flex-direction:column; gap:0.2rem">
849
+ <a href="#/node/${encodeURIComponent(o.node_id)}" style="font-weight:600">${esc(o.node_id)}</a>
850
+ <span style="display:flex; gap:0.3rem">${tierChip(o.tier)}${o.institutional ? '<span class="chip inst">institutional</span>' : ''}${o.source === 'import' ? '<span class="chip imp">IMPORTED</span>' : ''}</span>
851
+ </div></td>
852
+ <td><b>${priceStr(o.pricing)}</b></td>
853
+ <td>${hwStr(o) || '<span style="color:var(--faint)" title="Vendor has not disclosed the hardware behind this offering">undisclosed</span>'}</td>
854
+ <td>${ctx}</td>
855
+ <td>${ttft}</td>
856
+ <td>${obs.tps > 0 ? '<b>' + obs.tps + '</b>' : (o.serving?.expected_tps?.p50 != null ? o.serving.expected_tps.p50 + ' <span class="micro m-claimed">CL</span>' : '—')}</td>
857
+ <td>${obs.availability?.ratio != null ? '<b>' + Math.round(obs.availability.ratio * 100) + '%</b>' : '—'}</td>
858
+ <td>${(o.serving?.supports || []).filter((s) => s !== 'streaming').map((s) => `<span class="chip">${esc(s)}</span>`).join(' ') || '—'}</td>
859
+ <td><span class="prov">${esc((o.card_revision || '').slice(0, 10))}</span></td>
860
+ </tr>`;
861
+ }).join('') + '</tbody></table>';
862
+
863
+ // performance & uptime: only nodes with actual registry observations
864
+ const probed = list.filter((o) => o.observed?.ttft_ms?.p50 != null || o.observed?.tps > 0 || o.observed?.availability?.ratio != null);
865
+ $('#md-perfsec').innerHTML = probed.length ? `<h2 id="sec-perf">Performance &amp; uptime <span class="counts">registry-measured, blind-probed</span></h2>
866
+ <div class="panel pad">` + probed.map((o) => {
867
+ const obs = o.observed;
868
+ return `<div class="stage"><span class="k">${esc(o.node_id)}</span><span class="num">
869
+ ${obs.ttft_ms?.p50 != null ? `ttft <b>${obs.ttft_ms.p50}ms</b> p50${obs.ttft_ms.p95 != null ? ` / ${obs.ttft_ms.p95}ms p95` : ''} · ` : ''}
870
+ ${obs.tps > 0 ? `<b>${obs.tps}</b> tok/s · ` : ''}
871
+ ${obs.availability?.ratio != null ? `up <b>${Math.round(obs.availability.ratio * 100)}%</b> · ` : ''}
872
+ ${obs.probes ?? 0} probes <span class="micro m-measured">● MEASURED</span></span></div>`;
873
+ }).join('') + '</div>' : '';
874
+ if (probed.length) navBits.push(['#sec-perf', 'PERFORMANCE']);
875
+
876
+ const bench = list.flatMap((o) => o.benchmarks || []);
877
+ $('#md-benchsec').innerHTML = bench.length ? '<h2 id="sec-bench">Benchmarks</h2><div class="panel pad">' + bench.map((b) =>
878
+ `<div class="stage"><span class="k">${esc(b.suite)}</span><span class="num"><b>${fmt(b.score)}</b> <span class="micro ${b.basis === 'attested' ? 'm-measured' : 'm-claimed'}">${esc(String(b.basis || 'claimed').toUpperCase())}</span></span></div>`).join('') + '</div>' : '';
879
+ if (bench.length) navBits.push(['#sec-bench', 'BENCHMARKS']);
880
+
881
+ // related: same family, different artifact
882
+ $('#md-relsec').innerHTML = '';
883
+ if (first.model.family) {
884
+ try {
885
+ const rel = (await api('/v0/offerings?limit=40&family=' + encodeURIComponent(first.model.family))).offerings;
886
+ const seen = new Map();
887
+ for (const r of rel) if (r.model.artifact !== artifact && !seen.has(r.model.artifact)) seen.set(r.model.artifact, r);
888
+ const cards = [...seen.values()].slice(0, 6);
889
+ if (cards.length) {
890
+ $('#md-relsec').innerHTML = `<h2 id="sec-related">More from ${esc(first.model.family)}</h2><div class="relcards">` +
891
+ cards.map((r) => `<a class="relcard" href="#/model/${encodeURIComponent(r.model.artifact)}">
892
+ <span class="rn">${esc(r.model.name)}</span>
893
+ <span class="rm">${esc(r.modality)} · ${priceStr(r.pricing)}</span></a>`).join('') + '</div>';
894
+ navBits.push(['#sec-related', 'RELATED']);
895
+ }
896
+ } catch { /* related is optional */ }
897
+ }
898
+
899
+ // FAQ — generated strictly from the data on this page
900
+ const supports = [...new Set(list.flatMap((o) => o.serving?.supports || []))].filter((s) => s !== 'streaming');
901
+ const capped = list.filter((o) => o.serving?.context_capped_from);
902
+ const faqs = [
903
+ ['What is ' + first.model.name + '?', desc
904
+ ? desc
905
+ : `A ${first.modality} model${first.model.params_b ? ` (${first.model.params_b}B parameters)` : ''}${first.model.family ? ` in the ${first.model.family} family` : ''}, served by ${list.length} node${list.length === 1 ? '' : 's'} listed in this registry. Artifact: ${artifact}.`],
906
+ ['What does it cost through OpenNodes?', minP === 0 && maxP === 0
907
+ ? 'The listed offerings are currently free (rate limits apply per node).'
908
+ : `Input pricing ranges from $${fmt(minP)} to $${fmt(maxP)} per MTok across the ${list.length} serving node${list.length === 1 ? '' : 's'} — see the offerings table for per-node input/output prices and payment schemes.`],
909
+ ['Which offerings are verified?', verified > 0
910
+ ? `${verified} of ${list.length} serving node${list.length === 1 ? '' : 's'} hold the verified tier (identity-fingerprinted, context-verified, capacity-measured).${capped.length ? ` ${capped.length} offering${capped.length === 1 ? ' had its' : 's had their'} claimed context capped to the verified size.` : ''}`
911
+ : `None yet — current listings are ${list.every((o) => o.source === 'import') ? 'catalog imports (metadata only, unclaimed by their operators)' : 'awaiting Stage B admission'}. Operators can claim a listing and earn the verified tier through the automated qualification procedure.`],
912
+ supports.length ? ['What capabilities are declared?', `Across serving nodes: ${supports.join(', ')}${first.modalities ? `. Modalities: ${(first.modalities.input || []).join('+')} in, ${(first.modalities.output || []).join('+')} out.` : '.'}`] : null,
913
+ ].filter(Boolean);
914
+ $('#md-faqsec').innerHTML = '<h2 id="sec-faq">FAQ</h2><div class="panel pad">' +
915
+ faqs.map(([q, a]) => `<details class="faq"><summary>${esc(q)}</summary><p>${esc(a)}</p></details>`).join('') + '</div>';
916
+ navBits.push(['#sec-faq', 'FAQ']);
917
+
918
+ $('#md-nav').innerHTML = navBits.map(([h, l]) => `<a href="${h}" onclick="event.preventDefault(); document.querySelector('${h}').scrollIntoView({behavior:'smooth'})">${l}</a>`).join('');
919
+ } catch (e) { $('#md-offerings').innerHTML = '<p class="err">' + esc(e.message) + '</p>'; }
920
+ }
921
+
922
+ /* ---------- playground ---------- */
923
+ let pgOfferings = [];
924
+ $('#pg-target').addEventListener('change', () => {
925
+ $('#pg-customrow').hidden = $('#pg-target').value !== 'custom';
926
+ });
927
+ $('#pg-send').addEventListener('click', async () => {
928
+ const out = $('#pg-out'), meta = $('#pg-meta');
929
+ const prompt = $('#pg-prompt').value.trim();
930
+ if (!prompt) return;
931
+ const isCustom = $('#pg-target').value === 'custom';
932
+ const offering = isCustom ? null : pgOfferings[Number($('#pg-target').value)];
933
+ const base = isCustom ? $('#pg-endpoint').value.trim().replace(/\/$/, '') : offering.endpoints.openai.replace(/\/$/, '');
934
+ const model = isCustom ? $('#pg-model').value.trim() : offering.binding.model_id;
935
+ if (!base || !model) { out.innerHTML = '<span class="err">endpoint and model id are required</span>'; return; }
936
+ const keyStoreId = 'onp-key-' + new URL(base).host;
937
+ const key = $('#pg-key').value.trim();
938
+ if (key) localStorage.setItem(keyStoreId, key); else $('#pg-key').value = localStorage.getItem(keyStoreId) ?? '';
939
+ const useKey = key || localStorage.getItem(keyStoreId) || '';
940
+ $('#pg-send').disabled = true;
941
+ out.textContent = '…';
942
+ meta.textContent = '';
943
+ const t0 = performance.now();
944
+ try {
945
+ const res = await fetch(base + '/chat/completions', {
946
+ method: 'POST',
947
+ headers: {
948
+ 'content-type': 'application/json',
949
+ ...(useKey ? { authorization: 'Bearer ' + useKey } : {}),
950
+ ...(offering ? { 'onp-offering': offering.node_id + '/' + offering.offering_id, 'onp-card-revision': offering.card_revision } : {}),
951
+ },
952
+ body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }], max_tokens: 512 }),
953
+ });
954
+ const body = await res.json();
955
+ if (!res.ok) throw new Error(body?.detail ?? body?.error?.message ?? ('status ' + res.status));
956
+ out.textContent = body.choices?.[0]?.message?.content ?? JSON.stringify(body).slice(0, 800);
957
+ const u = body.usage;
958
+ const receipt = res.headers.get('onp-receipt');
959
+ meta.textContent = [
960
+ Math.round(performance.now() - t0) + 'ms',
961
+ u ? `${u.prompt_tokens} in / ${u.completion_tokens} out` : null,
962
+ receipt ? 'signed receipt received' : (offering ? 'no receipt' : null),
963
+ ].filter(Boolean).join(' · ');
964
+ } catch (err) {
965
+ out.innerHTML = '<span class="err">' + esc(err.message) + '</span><br><span class="prov">If this is a network/CORS error: the node must allow browser origins. ONP kit nodes do by default; for local Ollama set OLLAMA_ORIGINS to include this page&#39;s origin.</span>';
966
+ } finally { $('#pg-send').disabled = false; }
967
+ });
968
+
969
+ /* ---------- chat: roster, modes (single/compare/jury/pipeline), tests ----------
970
+ Jury verdict structure adapted from the author's LLM Jury project (synthesis +
971
+ per-candidate scores + best answer), with candidates anonymized as A/B/C. */
972
+ const chat = { roster: [], history: [], mem: {}, cost: 0, offerings: [] };
973
+ const memKey = (t) => targetKeyBase(t) + '|' + (t.offering ? t.offering.binding.model_id : t.custom.model);
974
+
975
+ async function loadChatTargets() {
976
+ renderTests();
977
+ scRefreshList();
978
+ $('#ch-modenote').textContent = MODE_NOTES[$('#ch-mode').value] ?? '';
979
+ if (!chat.offerings.length) {
980
+ try {
981
+ const { offerings } = await api('/v0/offerings?limit=60&modality=text');
982
+ chat.offerings = offerings;
983
+ $('#ch-add').innerHTML = '<option value="">+ Add a model…</option>'
984
+ + offerings.map((o, i) => `<option value="${i}">${esc(o.model.name)} — ${esc(o.node_id)} — ${priceStr(o.pricing)}</option>`).join('')
985
+ + '<option value="custom">custom endpoint (local Ollama / any OpenAI server)</option>';
986
+ } catch { $('#ch-add').innerHTML = '<option value="custom">custom endpoint</option>'; }
987
+ }
988
+ await applyAdvisorHandoff();
989
+ }
990
+
991
+ /** The catalog advisor hands its shortlist to the chat roster via sessionStorage. */
992
+ async function applyAdvisorHandoff() {
993
+ const raw = sessionStorage.getItem('onp-advisor-handoff');
994
+ if (!raw) return;
995
+ sessionStorage.removeItem('onp-advisor-handoff');
996
+ let keys = [];
997
+ try { keys = JSON.parse(raw); } catch { return; }
998
+ for (const key of keys) {
999
+ const [nodeId, offeringId] = key.split('/');
1000
+ let o = chat.offerings.find((x) => x.node_id === nodeId && x.offering_id === offeringId);
1001
+ if (!o) { try { o = await api(`/v0/offerings/${encodeURIComponent(nodeId)}/${encodeURIComponent(offeringId)}`); } catch { continue; } }
1002
+ if (!chat.roster.some((t) => t.offering && t.offering.node_id === nodeId && t.offering.offering_id === offeringId)) chat.roster.push({ offering: o });
1003
+ }
1004
+ if (chat.roster.length > 1) { $('#ch-mode').value = 'compare'; $('#ch-modenote').textContent = MODE_NOTES.compare; }
1005
+ renderRoster();
1006
+ }
1007
+
1008
+ const targetLabel = (t) => t.offering ? t.offering.model.name : `${t.custom.model} @ ${new URL(t.custom.base).host}`;
1009
+ const targetKeyBase = (t) => (t.offering ? t.offering.endpoints.openai : t.custom.base).replace(/\/$/, '');
1010
+
1011
+ $('#ch-add').addEventListener('change', () => {
1012
+ const v = $('#ch-add').value;
1013
+ $('#ch-customrow').hidden = v !== 'custom';
1014
+ if (v !== '' && v !== 'custom') {
1015
+ const o = chat.offerings[Number(v)];
1016
+ if (!chat.roster.some((t) => t.offering && t.offering.node_id === o.node_id && t.offering.offering_id === o.offering_id)) {
1017
+ chat.roster.push({ offering: o });
1018
+ renderRoster();
1019
+ }
1020
+ $('#ch-add').value = '';
1021
+ }
1022
+ });
1023
+ $('#ch-addcustom').addEventListener('click', () => {
1024
+ const base = $('#ch-endpoint').value.trim().replace(/\/$/, '');
1025
+ const model = $('#ch-model').value.trim();
1026
+ if (!base || !model) return;
1027
+ chat.roster.push({ custom: { base, model } });
1028
+ $('#ch-customrow').hidden = true; $('#ch-add').value = '';
1029
+ renderRoster();
1030
+ });
1031
+ const MODE_NOTES = {
1032
+ single: 'streams the reply; full conversation memory',
1033
+ compare: 'each model keeps its own conversation memory',
1034
+ jury: 'stateless per prompt; candidates anonymized for the judge',
1035
+ pipeline: 'stateless per prompt; edit the step instruction below',
1036
+ iterative: 'llm-jury style debate: models see each other’s answers and revise; the judge rules on the final round',
1037
+ };
1038
+ $('#ch-mode').addEventListener('change', () => {
1039
+ const m = $('#ch-mode').value;
1040
+ $('#ch-juryrow').hidden = m !== 'jury' && m !== 'iterative';
1041
+ $('#ch-iterrow').hidden = m !== 'iterative';
1042
+ $('#ch-modenote').textContent = MODE_NOTES[m] ?? '';
1043
+ refreshEstimate();
1044
+ });
1045
+ $('#ch-clear').addEventListener('click', () => {
1046
+ chat.history = []; chat.mem = {}; chat.cost = 0;
1047
+ $('#ch-log').innerHTML = '<p class="sub" style="margin:0">Cleared.</p>';
1048
+ $('#ch-cost').textContent = '';
1049
+ });
1050
+ $('#ch-input').addEventListener('keydown', (e) => {
1051
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); $('#ch-send').click(); }
1052
+ });
1053
+
1054
+ function renderRoster() {
1055
+ $('#ch-chips').innerHTML = chat.roster.map((t, i) => `
1056
+ <div style="display:flex; align-items:center; gap:0.5rem; border:1px solid var(--line); border-radius:6px; padding:0.35rem 0.6rem; font-size:0.78rem">
1057
+ <span class="mono" style="font-size:0.66rem; color:var(--accent)">${i + 1}</span>
1058
+ <span style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap"><b>${esc(targetLabel(t))}</b>${t.offering ? ` <span style="color:var(--muted)">${esc(t.offering.node_id)}</span>` : ' <span class="chip">custom</span>'}</span>
1059
+ <span style="cursor:pointer; color:var(--muted)" data-rm="${i}" title="remove">✕</span>
1060
+ </div>`).join('') || '<p class="prov" style="margin:0">no models yet</p>';
1061
+ document.querySelectorAll('#ch-chips [data-rm]').forEach((x) => x.addEventListener('click', () => {
1062
+ chat.roster.splice(Number(x.dataset.rm), 1); renderRoster();
1063
+ }));
1064
+ $('#ch-judge').innerHTML = chat.roster.map((t, i) => `<option value="${i}">${i + 1} · ${esc(targetLabel(t))}</option>`).join('');
1065
+ if (chat.roster.length) $('#ch-judge').value = String(chat.roster.length - 1);
1066
+ refreshEstimate();
1067
+ }
1068
+
1069
+ async function refreshEstimate() {
1070
+ const est = $('#ch-est');
1071
+ const offeringTargets = chat.roster.filter((t) => t.offering);
1072
+ if (!offeringTargets.length) { est.textContent = ''; return; }
1073
+ const mode = $('#ch-mode').value;
1074
+ const rounds = mode === 'iterative' ? Number($('#ch-rounds').value) : 1;
1075
+ const steps = [];
1076
+ for (let r = 0; r < rounds; r++) {
1077
+ for (const t of (mode === 'single' ? offeringTargets.slice(0, 1) : offeringTargets)) {
1078
+ steps.push({ offering: `${t.offering.node_id}/${t.offering.offering_id}`, est_input_tokens: r === 0 ? 1000 : 2500, est_output_tokens: 300 });
1079
+ }
1080
+ }
1081
+ if (mode === 'jury' || mode === 'iterative') {
1082
+ const j = chat.roster[Number($('#ch-judge').value)];
1083
+ if (j?.offering) steps.push({ offering: `${j.offering.node_id}/${j.offering.offering_id}`, est_input_tokens: 2500, est_output_tokens: 500 });
1084
+ }
1085
+ try {
1086
+ const r = await api('/v0/estimate', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ steps }) });
1087
+ est.textContent = `≈ $${r.total.min}–${r.total.max} per turn (enforceable estimate${chat.roster.some((t) => t.custom) ? '; custom endpoints excluded' : ''})`;
1088
+ } catch { est.textContent = ''; }
1089
+ }
1090
+ $('#ch-judge').addEventListener('change', refreshEstimate);
1091
+
1092
+ async function callTarget(t, messages, maxTokens = 1024) {
1093
+ const base = targetKeyBase(t);
1094
+ const model = t.offering ? t.offering.binding.model_id : t.custom.model;
1095
+ const keyId = 'onp-key-' + new URL(base).host;
1096
+ const typed = $('#ch-key').value.trim();
1097
+ if (typed) localStorage.setItem(keyId, typed);
1098
+ const key = typed || localStorage.getItem(keyId) || '';
1099
+ const t0 = performance.now();
1100
+ const res = await fetch(base + '/chat/completions', {
1101
+ method: 'POST',
1102
+ headers: {
1103
+ 'content-type': 'application/json',
1104
+ ...(key ? { authorization: 'Bearer ' + key } : {}),
1105
+ ...(t.offering ? { 'onp-offering': t.offering.node_id + '/' + t.offering.offering_id, 'onp-card-revision': t.offering.card_revision } : {}),
1106
+ },
1107
+ body: JSON.stringify({ model, messages, max_tokens: maxTokens }),
1108
+ });
1109
+ const body = await res.json().catch(() => ({}));
1110
+ if (!res.ok) throw new Error(body?.detail ?? body?.error?.message ?? ('status ' + res.status));
1111
+ const u = body.usage;
1112
+ const cost = (t.offering && u)
1113
+ ? ((t.offering.pricing.input_per_mtok ?? 0) * u.prompt_tokens + (t.offering.pricing.output_per_mtok ?? 0) * u.completion_tokens) / 1e6 : 0;
1114
+ chat.cost += cost;
1115
+ $('#ch-cost').textContent = chat.cost > 0 ? `session cost: $${Math.round(chat.cost * 1e6) / 1e6}` : '';
1116
+ return {
1117
+ text: body.choices?.[0]?.message?.content ?? '',
1118
+ usage: u, ms: Math.round(performance.now() - t0),
1119
+ receipt: Boolean(res.headers.get('onp-receipt')),
1120
+ };
1121
+ }
1122
+ /** Streaming variant for single mode: SSE deltas via onDelta, usage from the final chunk. */
1123
+ async function callTargetStream(t, messages, onDelta, maxTokens = 1024) {
1124
+ const base = targetKeyBase(t);
1125
+ const model = t.offering ? t.offering.binding.model_id : t.custom.model;
1126
+ const keyId = 'onp-key-' + new URL(base).host;
1127
+ const key = $('#ch-key').value.trim() || localStorage.getItem(keyId) || '';
1128
+ const t0 = performance.now();
1129
+ const res = await fetch(base + '/chat/completions', {
1130
+ method: 'POST',
1131
+ headers: {
1132
+ 'content-type': 'application/json',
1133
+ ...(key ? { authorization: 'Bearer ' + key } : {}),
1134
+ ...(t.offering ? { 'onp-offering': t.offering.node_id + '/' + t.offering.offering_id, 'onp-card-revision': t.offering.card_revision } : {}),
1135
+ },
1136
+ body: JSON.stringify({ model, messages, max_tokens: maxTokens, stream: true, stream_options: { include_usage: true } }),
1137
+ });
1138
+ if (!res.ok || !res.body) {
1139
+ const body = await res.json().catch(() => ({}));
1140
+ throw new Error(body?.detail ?? body?.error?.message ?? ('status ' + res.status));
1141
+ }
1142
+ const reader = res.body.getReader();
1143
+ const dec = new TextDecoder();
1144
+ let buf = '', text = '', usage = null, ttft = null;
1145
+ for (;;) {
1146
+ const { done, value } = await reader.read();
1147
+ if (done) break;
1148
+ buf += dec.decode(value, { stream: true });
1149
+ let idx;
1150
+ while ((idx = buf.indexOf('\n')) >= 0) {
1151
+ const line = buf.slice(0, idx).trim();
1152
+ buf = buf.slice(idx + 1);
1153
+ if (!line.startsWith('data:')) continue;
1154
+ const payload = line.slice(5).trim();
1155
+ if (payload === '[DONE]') continue;
1156
+ try {
1157
+ const evt = JSON.parse(payload);
1158
+ usage = evt.usage ?? usage;
1159
+ const delta = evt.choices?.[0]?.delta?.content;
1160
+ if (delta) {
1161
+ if (ttft === null) ttft = Math.round(performance.now() - t0);
1162
+ text += delta;
1163
+ onDelta(text);
1164
+ }
1165
+ } catch { /* partial line */ }
1166
+ }
1167
+ }
1168
+ const cost = (t.offering && usage)
1169
+ ? ((t.offering.pricing.input_per_mtok ?? 0) * usage.prompt_tokens + (t.offering.pricing.output_per_mtok ?? 0) * usage.completion_tokens) / 1e6 : 0;
1170
+ chat.cost += cost;
1171
+ $('#ch-cost').textContent = chat.cost > 0 ? `session cost: $${Math.round(chat.cost * 1e6) / 1e6}` : '';
1172
+ return { text, usage, ms: Math.round(performance.now() - t0), ttft, receipt: Boolean(res.headers.get('onp-receipt')) };
1173
+ }
1174
+
1175
+ 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(' · ');
1176
+ const corsHint = ' — if this is a CORS/network error, the node must allow browser origins (local Ollama: set OLLAMA_ORIGINS)';
1177
+
1178
+ function logEl(html) {
1179
+ if ($('#ch-log').querySelector('p.sub')) $('#ch-log').innerHTML = '';
1180
+ const div = document.createElement('div');
1181
+ div.innerHTML = html;
1182
+ const el = div.firstElementChild;
1183
+ $('#ch-log').appendChild(el);
1184
+ $('#ch-log').scrollTop = $('#ch-log').scrollHeight;
1185
+ return el;
1186
+ }
1187
+ const userBubble = (text) => logEl(`<div class="msg user"><span></span></div>`).firstChild.textContent = text;
1188
+ const candCard = (label) => `<div class="panel pad" style="min-width:0"><div style="display:flex; gap:0.5rem; align-items:baseline"><b style="font-size:0.8rem">${esc(label)}</b><span class="mm prov" style="margin:0"></span></div><div class="ctext" style="font-size:0.86rem; white-space:pre-wrap; line-height:1.5; margin-top:0.4rem">…</div></div>`;
1189
+
1190
+ $('#ch-send').addEventListener('click', async () => {
1191
+ const text = $('#ch-input').value.trim();
1192
+ if (!text || !chat.roster.length) {
1193
+ if (!chat.roster.length) logEl('<div class="msg assistant"><span>Add at least one model to the roster first.</span></div>');
1194
+ return;
1195
+ }
1196
+ $('#ch-input').value = '';
1197
+ $('#ch-send').disabled = true;
1198
+ const mode = $('#ch-mode').value;
1199
+ try {
1200
+ userBubble(text);
1201
+ if (mode === 'single') await runSingle(text);
1202
+ else if (mode === 'compare') await runCompare(text);
1203
+ else if (mode === 'jury') await runJury(text);
1204
+ else if (mode === 'iterative') await runIterative(text);
1205
+ else await runPipeline(text);
1206
+ } finally { $('#ch-send').disabled = false; $('#ch-input').focus(); }
1207
+ });
1208
+
1209
+ async function runSingle(text) {
1210
+ chat.history.push({ role: 'user', content: text });
1211
+ const el = logEl('<div class="msg assistant"><span>…</span><span class="mm prov"></span></div>');
1212
+ try {
1213
+ let r;
1214
+ try {
1215
+ r = await callTargetStream(chat.roster[0], chat.history, (partial) => { el.firstChild.textContent = partial; });
1216
+ } catch (streamErr) {
1217
+ // some endpoints reject streaming — fall back to a plain call
1218
+ r = await callTarget(chat.roster[0], chat.history);
1219
+ }
1220
+ chat.history.push({ role: 'assistant', content: r.text });
1221
+ el.firstChild.textContent = r.text || '(empty response)';
1222
+ el.querySelector('.mm').textContent = metaLine(r);
1223
+ } catch (err) {
1224
+ chat.history.pop();
1225
+ el.firstChild.textContent = '';
1226
+ el.querySelector('.mm').innerHTML = '<span class="err">' + esc(err.message) + '</span>' + corsHint;
1227
+ }
1228
+ }
1229
+
1230
+ /** One parallel round across the roster. buildMessages(t, i) supplies each model's messages. */
1231
+ async function fanoutRound(labelPrefix, buildMessages, { remember = false } = {}) {
1232
+ const block = logEl(`<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(16rem, 1fr)); gap:0.6rem; align-self:stretch">${chat.roster.map((t, i) => candCard(labelPrefix(i) + targetLabel(t))).join('')}</div>`);
1233
+ const cards = [...block.children];
1234
+ const answers = await Promise.all(chat.roster.map(async (t, i) => {
1235
+ const messages = buildMessages(t, i);
1236
+ try {
1237
+ const r = await callTarget(t, messages);
1238
+ cards[i].querySelector('.ctext').textContent = r.text || '(empty)';
1239
+ cards[i].querySelector('.mm').textContent = metaLine(r);
1240
+ if (remember) {
1241
+ const k = memKey(t);
1242
+ chat.mem[k] = [...messages, { role: 'assistant', content: r.text }];
1243
+ }
1244
+ return { label: String.fromCharCode(65 + i), text: r.text, ok: true };
1245
+ } catch (err) {
1246
+ cards[i].querySelector('.ctext').innerHTML = '<span class="err">' + esc(err.message) + '</span><span class="prov">' + corsHint + '</span>';
1247
+ return { label: String.fromCharCode(65 + i), ok: false };
1248
+ }
1249
+ }));
1250
+ return { answers, cards };
1251
+ }
1252
+
1253
+ async function runCompare(text) {
1254
+ // per-model memory: each model continues its own conversation
1255
+ await fanoutRound(() => '', (t) => [...(chat.mem[memKey(t)] ?? []), { role: 'user', content: text }], { remember: true });
1256
+ }
1257
+
1258
+ async function runVerdict(question, answers, cards) {
1259
+ const good = answers.filter((a) => a.ok);
1260
+ if (good.length < 2) { logEl('<div class="msg assistant"><span>The verdict needs at least two successful answers.</span></div>'); return; }
1261
+ const judge = chat.roster[Number($('#ch-judge').value)] ?? chat.roster[chat.roster.length - 1];
1262
+ const jEl = logEl(`<div class="panel pad" style="align-self:stretch; border-color:var(--accent)"><div style="display:flex; gap:0.5rem; align-items:baseline"><b style="font-size:0.8rem; color:var(--accent)">VERDICT · judge: ${esc(targetLabel(judge))}</b><span class="mm prov" style="margin:0"></span></div><div class="ctext" style="font-size:0.86rem; white-space:pre-wrap; line-height:1.5; margin-top:0.4rem">deliberating…</div></div>`);
1263
+ const prompt = `${$('#ch-juryprompt').value.trim()}
1264
+
1265
+ QUESTION: ${question}
1266
+
1267
+ CANDIDATE ANSWERS (models anonymized):
1268
+ ${good.map((a) => `--- CANDIDATE ${a.label} ---\n${a.text}`).join('\n\n')}
1269
+
1270
+ Format your verdict as:
1271
+ BEST: [candidate letter]
1272
+ SYNTHESIS: [one unified answer combining the best of all candidates]
1273
+ SCORES: ${good.map((a) => a.label + '=[0-100]').join(' ')}
1274
+ REASON: [two sentences]`;
1275
+ try {
1276
+ const r = await callTarget(judge, [{ role: 'user', content: prompt }], 1024);
1277
+ jEl.querySelector('.ctext').textContent = r.text;
1278
+ jEl.querySelector('.mm').textContent = metaLine(r);
1279
+ const best = r.text.match(/BEST:\s*\[?([A-Z])\]?/)?.[1];
1280
+ if (best) {
1281
+ const idx = best.charCodeAt(0) - 65;
1282
+ if (cards[idx]) { cards[idx].style.borderColor = 'var(--good)'; cards[idx].querySelector('b').innerHTML += ' <span class="micro m-measured">★ BEST</span>'; }
1283
+ }
1284
+ } catch (err) { jEl.querySelector('.ctext').innerHTML = '<span class="err">' + esc(err.message) + '</span>'; }
1285
+ }
1286
+
1287
+ async function runJury(text) {
1288
+ const { answers, cards } = await fanoutRound((i) => String.fromCharCode(65 + i) + ' · ', () => [{ role: 'user', content: text }]);
1289
+ await runVerdict(text, answers, cards);
1290
+ }
1291
+
1292
+ /** Iterative debate (pattern from the LLM Jury project): initial answers, then rounds
1293
+ * where each model sees the others' answers and revises; the judge rules on the final round. */
1294
+ async function runIterative(text) {
1295
+ const rounds = Number($('#ch-rounds').value);
1296
+ logEl('<div class="prov" style="align-self:stretch">ROUND 1 · initial answers</div>');
1297
+ let { answers, cards } = await fanoutRound((i) => String.fromCharCode(65 + i) + ' · ', () => [{ role: 'user', content: text }]);
1298
+ for (let round = 2; round <= rounds; round++) {
1299
+ const prev = answers;
1300
+ logEl(`<div class="prov" style="align-self:stretch">ROUND ${round} · ${round === rounds ? 'final revision' : 'rebuttal'}</div>`);
1301
+ ({ answers, cards } = await fanoutRound((i) => String.fromCharCode(65 + i) + ' · ', (t, i) => {
1302
+ const own = prev[i]?.ok ? prev[i].text : '(your previous attempt failed)';
1303
+ const others = prev.filter((a, j) => j !== i && a.ok)
1304
+ .map((a) => `--- CANDIDATE ${a.label} ---\n${a.text}`).join('\n\n') || '(no other answers succeeded)';
1305
+ return [{ role: 'user', content: `${$('#ch-revprompt').value.trim()}
1306
+
1307
+ QUESTION: ${text}
1308
+
1309
+ YOUR PREVIOUS ANSWER:
1310
+ ${own}
1311
+
1312
+ OTHER CANDIDATES' ANSWERS:
1313
+ ${others}` }];
1314
+ }));
1315
+ }
1316
+ await runVerdict(text, answers, cards);
1317
+ }
1318
+
1319
+ async function runPipeline(text) {
1320
+ let current = null;
1321
+ for (let i = 0; i < chat.roster.length; i++) {
1322
+ const t = chat.roster[i];
1323
+ const last = i === chat.roster.length - 1;
1324
+ const el = logEl(`<div class="panel pad" style="align-self:stretch${last ? '; border-color:var(--accent)' : ''}"><div style="display:flex; gap:0.5rem; align-items:baseline"><b style="font-size:0.8rem${last ? '; color:var(--accent)' : ''}">STEP ${i + 1}${last ? ' · FINAL' : ''} · ${esc(targetLabel(t))}</b><span class="mm prov" style="margin:0"></span></div><div class="ctext" style="font-size:0.86rem; white-space:pre-wrap; line-height:1.5; margin-top:0.4rem">…</div></div>`);
1325
+ const content = i === 0 ? text
1326
+ : `${$('#ch-seqprompt').value.trim()}\n\nQUESTION: ${text}\n\nPREVIOUS ANSWER:\n${current}`;
1327
+ try {
1328
+ const r = await callTarget(t, [{ role: 'user', content }]);
1329
+ current = r.text;
1330
+ el.querySelector('.ctext').textContent = r.text || '(empty)';
1331
+ el.querySelector('.mm').textContent = metaLine(r);
1332
+ } catch (err) {
1333
+ el.querySelector('.ctext').innerHTML = '<span class="err">' + esc(err.message) + '</span><span class="prov">' + corsHint + ' — pipeline stopped</span>';
1334
+ return;
1335
+ }
1336
+ }
1337
+ }
1338
+
1339
+ /* ---------- scenarios: save/load locally, export as ONP-6 §5 ---------- */
1340
+ $('#ch-rounds')?.addEventListener('change', refreshEstimate);
1341
+ const SC_STORE = 'onp-chat-scenarios';
1342
+ const scAll = () => { try { return JSON.parse(localStorage.getItem(SC_STORE)) ?? {}; } catch { return {}; } };
1343
+ function scRefreshList() {
1344
+ const names = Object.keys(scAll());
1345
+ $('#sc-list').innerHTML = names.length
1346
+ ? names.map((n) => `<option>${esc(n)}</option>`).join('')
1347
+ : '<option value="">(none saved)</option>';
1348
+ }
1349
+ function scSnapshot(name) {
1350
+ return {
1351
+ name, saved_at: new Date().toISOString(),
1352
+ mode: $('#ch-mode').value,
1353
+ rounds: Number($('#ch-rounds').value),
1354
+ judge: Number($('#ch-judge').value) || 0,
1355
+ roster: chat.roster.map((t) => t.offering
1356
+ ? { pin: `${t.offering.node_id}/${t.offering.offering_id}` }
1357
+ : { custom: t.custom }),
1358
+ instructions: { pipeline: $('#ch-seqprompt').value, jury: $('#ch-juryprompt').value, revise: $('#ch-revprompt').value },
1359
+ };
1360
+ }
1361
+ $('#sc-save').addEventListener('click', () => {
1362
+ const name = $('#sc-name').value.trim();
1363
+ if (!name || !chat.roster.length) { $('#sc-note').textContent = 'name the scenario and add at least one model'; return; }
1364
+ const all = scAll(); all[name] = scSnapshot(name);
1365
+ localStorage.setItem(SC_STORE, JSON.stringify(all));
1366
+ scRefreshList(); $('#sc-list').value = name;
1367
+ $('#sc-note').textContent = `saved "${name}"`;
1368
+ });
1369
+ $('#sc-load').addEventListener('click', async () => {
1370
+ const sc = scAll()[$('#sc-list').value];
1371
+ if (!sc) return;
1372
+ await loadChatTargets();
1373
+ chat.roster = [];
1374
+ const missing = [];
1375
+ for (const entry of sc.roster) {
1376
+ if (entry.custom) chat.roster.push({ custom: entry.custom });
1377
+ else {
1378
+ const [nodeId, offId] = entry.pin.split('/');
1379
+ const o = chat.offerings.find((x) => x.node_id === nodeId && x.offering_id === offId);
1380
+ if (o) chat.roster.push({ offering: o }); else missing.push(entry.pin);
1381
+ }
1382
+ }
1383
+ $('#ch-mode').value = sc.mode; $('#ch-mode').dispatchEvent(new Event('change'));
1384
+ $('#ch-rounds').value = String(sc.rounds || 2);
1385
+ $('#ch-seqprompt').value = sc.instructions?.pipeline ?? $('#ch-seqprompt').value;
1386
+ $('#ch-juryprompt').value = sc.instructions?.jury ?? $('#ch-juryprompt').value;
1387
+ $('#ch-revprompt').value = sc.instructions?.revise ?? $('#ch-revprompt').value;
1388
+ renderRoster();
1389
+ if (sc.judge < chat.roster.length) $('#ch-judge').value = String(sc.judge);
1390
+ $('#sc-note').textContent = missing.length
1391
+ ? `loaded; ${missing.length} pinned offering(s) no longer listed: ${missing.join(', ')}`
1392
+ : `loaded "${sc.name}"`;
1393
+ });
1394
+ $('#sc-del').addEventListener('click', () => {
1395
+ const all = scAll(); delete all[$('#sc-list').value];
1396
+ localStorage.setItem(SC_STORE, JSON.stringify(all)); scRefreshList();
1397
+ });
1398
+ $('#sc-export').addEventListener('click', () => {
1399
+ // Bridge to the portable ONP-6 §5 Scenario document
1400
+ const s = scSnapshot($('#sc-name').value.trim() || $('#sc-list').value || 'chat-scenario');
1401
+ const roles = {};
1402
+ s.roster.forEach((r, i) => { roles['m' + (i + 1)] = r.pin ? { pin: r.pin } : { custom: r.custom }; });
1403
+ let steps = [];
1404
+ const roleIds = Object.keys(roles);
1405
+ if (s.mode === 'pipeline') {
1406
+ steps = roleIds.map((rid, i) => ({ id: 'step' + (i + 1), role: rid, input: i === 0 ? '$user' : '$prev', ...(i > 0 ? { instruction: s.instructions.pipeline } : {}) }));
1407
+ } else if (s.mode === 'single') {
1408
+ steps = [{ id: 'answer', role: roleIds[0], input: '$user' }];
1409
+ } else {
1410
+ steps = [{ id: 'fanout', mode: 'parallel', roles: roleIds, input: '$user', ...(s.mode === 'iterative' ? { rounds: s.rounds, revise_instruction: s.instructions.revise } : {}) }];
1411
+ if (s.mode === 'jury' || s.mode === 'iterative') {
1412
+ steps.push({ id: 'verdict', role: roleIds[Math.min(s.judge, roleIds.length - 1)], merge: 'judge', instruction: s.instructions.jury });
1413
+ }
1414
+ }
1415
+ const doc = { onp_scenario: '0.1', name: s.name, roles, steps, policy: {} };
1416
+ const a = document.createElement('a');
1417
+ a.href = URL.createObjectURL(new Blob([JSON.stringify(doc, null, 2)], { type: 'application/json' }));
1418
+ a.download = s.name.replace(/[^a-z0-9._-]+/gi, '-') + '.onp-scenario.json';
1419
+ a.click();
1420
+ URL.revokeObjectURL(a.href);
1421
+ });
1422
+
1423
+ /* ---------- model tests (smoke probes) ---------- */
1424
+ const TESTS = [
1425
+ { id: 'count', label: 'letter count (r in strawberry)', prompt: 'How many times does the letter r appear in the word strawberry? Reply with only the number.', check: (t) => /(^|[^\d])3([^\d]|$)/.test(t) },
1426
+ { id: 'math', label: 'arithmetic 17×24', prompt: 'What is 17 multiplied by 24? Reply with only the number.', check: (t) => t.replace(/[,\s]/g, '').includes('408') },
1427
+ { id: 'follow', label: 'instruction: exactly PONG', prompt: 'Reply with exactly the word PONG in uppercase and nothing else.', check: (t) => t.trim() === 'PONG' },
1428
+ { id: 'json', label: 'strict JSON output', prompt: 'Return exactly this JSON object and nothing else: {"a":1,"b":2}', check: (t) => { try { const s = t.trim().replace(/^```(json)?|```$/g, '').trim(); const o = JSON.parse(s); return o.a === 1 && o.b === 2; } catch { return false; } } },
1429
+ { id: 'recall', label: 'context recall (buried secret)', prompt: 'Context: ' + 'The quick brown fox jumps over the lazy dog. '.repeat(50) + 'The secret word is kumquat-742. ' + 'Pack my box with five dozen liquor jugs. '.repeat(20) + 'Question: what is the secret word? Reply with only the secret word.', check: (t) => t.includes('kumquat-742') },
1430
+ { id: 'compare', label: 'is 3.11 > 3.9?', prompt: 'Is 3.11 greater than 3.9? Reply with only yes or no.', check: (t) => /\bno\b/i.test(t) && !/\byes\b/i.test(t) },
1431
+ ];
1432
+ function renderTests() {
1433
+ if ($('#ct-list').children.length) return;
1434
+ $('#ct-list').innerHTML = TESTS.map((t) =>
1435
+ `<label style="display:flex; gap:0.35rem; align-items:center; font-size:0.78rem; cursor:pointer"><input type="checkbox" checked data-test="${t.id}">${esc(t.label)}</label>`).join('');
1436
+ }
1437
+ $('#ct-run').addEventListener('click', async () => {
1438
+ if (!chat.roster.length) { $('#ct-out').innerHTML = '<p class="sub">Add models to the roster first.</p>'; return; }
1439
+ const picked = TESTS.filter((t) => $(`#ct-list input[data-test="${t.id}"]`)?.checked);
1440
+ if (!picked.length) return;
1441
+ $('#ct-run').disabled = true;
1442
+ $('#ct-out').innerHTML = `<table class="grid num"><thead><tr><th>TEST</th>${chat.roster.map((t) => `<th>${esc(targetLabel(t)).toUpperCase().slice(0, 24)}</th>`).join('')}</tr></thead><tbody>${picked.map((t) => `<tr data-row="${t.id}"><td>${esc(t.label)}</td>${chat.roster.map((_, i) => `<td data-cell="${t.id}-${i}">…</td>`).join('')}</tr>`).join('')}</tbody></table>`;
1443
+ await Promise.all(chat.roster.flatMap((target, i) => picked.map(async (test) => {
1444
+ const cell = document.querySelector(`[data-cell="${test.id}-${i}"]`);
1445
+ try {
1446
+ const r = await callTarget(target, [{ role: 'user', content: test.prompt }], 128);
1447
+ const pass = test.check(r.text ?? '');
1448
+ cell.innerHTML = pass
1449
+ ? `<span class="ok">✓</span> <span class="prov" style="display:inline">${r.ms}ms</span>`
1450
+ : `<span class="fail">✗</span> <span class="prov" style="display:inline" title="${esc((r.text ?? '').slice(0, 200))}">${r.ms}ms</span>`;
1451
+ } catch (err) { cell.innerHTML = `<span class="warncol" title="${esc(err.message)}">⚠</span>`; }
1452
+ })));
1453
+ $('#ct-run').disabled = false;
1454
+ });
1455
+
1456
+ /* ---------- node builder ---------- */
1457
+ const nb = { model: null, runtime: null };
1458
+ let nbDebounce;
1459
+ $('#nb-search').addEventListener('input', () => { clearTimeout(nbDebounce); nbDebounce = setTimeout(nbSearch, 250); });
1460
+ for (const id of ['nb-quant', 'nb-ctx']) $('#' + id).addEventListener('change', nbUpdate);
1461
+ document.querySelectorAll('.nb-rt').forEach((c) => c.addEventListener('click', () => {
1462
+ document.querySelectorAll('.nb-rt').forEach((x) => x.style.borderColor = 'var(--line)');
1463
+ c.style.borderColor = 'var(--accent)';
1464
+ nb.runtime = c.dataset.rt;
1465
+ nbUpdate();
1466
+ }));
1467
+
1468
+ async function nbSearch() {
1469
+ const q = $('#nb-search').value.trim();
1470
+ if (!q) { $('#nb-models').innerHTML = ''; return; }
1471
+ try {
1472
+ const { offerings } = await api('/v0/offerings?limit=200&q=' + encodeURIComponent(q));
1473
+ const seen = new Map();
1474
+ for (const o of offerings) {
1475
+ if (!o.model.params_b) continue; // sizing needs a known parameter count
1476
+ if (!seen.has(o.model.artifact)) seen.set(o.model.artifact, o);
1477
+ }
1478
+ const list = [...seen.values()].slice(0, 6);
1479
+ $('#nb-models').innerHTML = list.length ? list.map((o) => `
1480
+ <div class="panel pad nb-m" data-artifact="${esc(o.model.artifact)}" style="cursor:pointer; display:flex; gap:0.7rem; align-items:baseline; padding:0.55rem 0.8rem">
1481
+ <b style="font-size:0.85rem">${esc(o.model.name)}</b>
1482
+ <span class="chip">${o.model.params_b}B</span>
1483
+ <span class="chip">${esc(o.modality)}</span>
1484
+ ${o.local ? '<span class="chip loc">RUNS LOCALLY</span>' : ''}
1485
+ <span class="prov" style="margin:0">${esc(o.model.artifact)}</span>
1486
+ </div>`).join('') : '<p class="prov">no sized open-weight models match — try another term</p>';
1487
+ document.querySelectorAll('.nb-m').forEach((el) => el.addEventListener('click', () => {
1488
+ document.querySelectorAll('.nb-m').forEach((x) => x.style.borderColor = 'var(--line)');
1489
+ el.style.borderColor = 'var(--accent)';
1490
+ nb.model = list.find((o) => o.model.artifact === el.dataset.artifact);
1491
+ nbUpdate();
1492
+ }));
1493
+ } catch (e) { $('#nb-models').innerHTML = '<p class="err">' + esc(e.message) + '</p>'; }
1494
+ }
1495
+
1496
+ async function nbUpdate() {
1497
+ if (!nb.model) return;
1498
+ const bpp = Number($('#nb-quant').value);
1499
+ const ctxK = Number($('#nb-ctx').value);
1500
+ const p = nb.model.model.params_b;
1501
+ const weights = p * bpp;
1502
+ const kv = ctxK * 0.12; // ~GB per K tokens, rough
1503
+ const total = Math.ceil((weights + kv) * 1.15); // runtime overhead
1504
+ $('#nb-vram').textContent = `rough VRAM estimate: ${weights.toFixed(1)}GB weights + ~${kv.toFixed(1)}GB KV cache @ ${ctxK}K ctx + overhead ≈ ${total}GB — the capacity ramp measures the truth after registration`;
1505
+
1506
+ const gpus = [['RTX 4090', 24], ['L4', 24], ['L40S', 48], ['A100 40G', 40], ['A100 80G', 80], ['H100', 80], ['H200', 141]];
1507
+ const fits = gpus.filter(([, g]) => g >= total);
1508
+ $('#nb-gpus').innerHTML = (fits.length
1509
+ ? 'fits on a single: ' + fits.map(([n]) => `<b>${n}</b>`).join(' · ')
1510
+ : `needs ≈${total}GB — multi-GPU (tensor parallel): e.g. ${Math.ceil(total / 80)}× H100 or ${Math.ceil(total / 141)}× H200`)
1511
+ + ` <span style="color:var(--faint)">· for ${esc(nb.model.model.name)} at ${bpp === 2 ? 'fp16' : bpp === 1 ? 'fp8' : 'int4'}</span>`;
1512
+
1513
+ // price comparables from the catalog for the same artifact
1514
+ try {
1515
+ const { offerings } = await api('/v0/offerings?limit=50&q=' + encodeURIComponent(nb.model.model.artifact));
1516
+ const priced = offerings.filter((o) => o.model.artifact === nb.model.model.artifact && (o.pricing.input_per_mtok ?? 0) > 0);
1517
+ $('#nb-comparables').textContent = priced.length
1518
+ ? `catalog comparables for this artifact: $${fmt(Math.min(...priced.map((o) => o.pricing.input_per_mtok)))}–${fmt(Math.max(...priced.map((o) => o.pricing.input_per_mtok)))} per MTok in, across ${priced.length} listing${priced.length === 1 ? '' : 's'}`
1519
+ : 'no priced catalog comparables for this artifact yet — you set the market';
1520
+ } catch { $('#nb-comparables').textContent = ''; }
1521
+
1522
+ const rt = nb.runtime;
1523
+ if (!rt) { $('#nb-config').textContent = 'pick a runtime above to generate your configuration'; return; }
1524
+ const base = rt === 'ollama' ? 'http://127.0.0.1:11434/v1' : 'http://127.0.0.1:8000/v1';
1525
+ const install = rt === 'ollama'
1526
+ ? `# engine\nollama pull ${nb.model.binding.model_id}`
1527
+ : rt === 'vllm'
1528
+ ? `# engine\npip install vllm\nvllm serve ${nb.model.model.artifact.startsWith('hf:') ? nb.model.model.artifact.slice(3) : nb.model.binding.model_id} ${bpp === 1 ? '--quantization fp8 ' : ''}--max-model-len ${ctxK * 1024}`
1529
+ : `# engine — TensorRT-LLM: build an engine for your GPU, then serve the OpenAI endpoint\n# see nvidia.github.io/TensorRT-LLM (trtllm-serve)`;
1530
+ $('#nb-config').textContent = `${install}
1531
+
1532
+ # node kit
1533
+ pipx install onp-node # from the OpenNodes repo
1534
+ onp-node init --engine ${base} \\
1535
+ --id org.example.mynode \\
1536
+ --public-url https://ai.example.org
1537
+
1538
+ # then edit onp-node.toml:
1539
+ [pricing]
1540
+ currency = "USD"
1541
+ input_per_mtok = 0.0 # see catalog comparables above
1542
+ output_per_mtok = 0.0
1543
+ schemes = ["free"]
1544
+
1545
+ [serving]
1546
+ context_window = ${ctxK * 1024}
1547
+
1548
+ # verify locally, then register
1549
+ onp-node serve
1550
+ onp-node check
1551
+ onp-node register https://registry.opennodes.io`;
1552
+ }
1553
+
1554
+ /* ---------- rankings ---------- */
1555
+ async function loadRankings() {
1556
+ try {
1557
+ const { offerings } = await api('/v0/offerings?limit=200&tier=community');
1558
+ const measured = offerings.filter((o) => (o.observed?.ttft_ms?.p50 != null) || (o.observed?.tps > 0));
1559
+ measured.sort((a, b) => (a.observed?.ttft_ms?.p50 ?? 1e9) - (b.observed?.ttft_ms?.p50 ?? 1e9));
1560
+ $('#r-table').innerHTML = measured.length ? `<table class="grid num"><thead><tr>
1561
+ <th>RANK</th><th>OFFERING · NODE</th><th>TTFT P50</th><th>TOK/S</th><th>UP 7D</th><th>PROBES</th><th>$/MTOK IN</th>
1562
+ </tr></thead><tbody>` + measured.map((o, i) => `<tr>
1563
+ <td class="mono" style="font-weight:600">${i + 1}</td>
1564
+ <td><div style="display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap">
1565
+ <a href="#/model/${encodeURIComponent(o.model.artifact)}" style="font-weight:600">${esc(o.model.name)}</a>
1566
+ <span style="color:var(--muted); font-size:0.76rem">${esc(o.node_id)}</span>
1567
+ ${tierChip(o.tier)}${o.institutional ? '<span class="chip inst">institutional</span>' : ''}
1568
+ </div></td>
1569
+ <td><b>${o.observed?.ttft_ms?.p50 ?? '—'}ms</b></td>
1570
+ <td><b>${o.observed?.tps > 0 ? o.observed.tps : '—'}</b></td>
1571
+ <td>${o.observed?.availability?.ratio != null ? Math.round(o.observed.availability.ratio * 100) + '%' : '—'}</td>
1572
+ <td style="color:var(--muted)">${o.observed?.probes ?? 0}</td>
1573
+ <td>$${fmt(o.pricing.input_per_mtok)}</td>
1574
+ </tr>`).join('') + `</tbody></table>
1575
+ <div style="display:flex; padding:0.6rem 0.8rem; background:var(--panel-2); border-top:1px solid var(--line)">
1576
+ <span class="prov">only probed community+ offerings are ranked — imported claims never compete with measurements</span>
1577
+ </div>`
1578
+ : '<p class="sub" style="padding:1rem">No measured offerings yet — rankings appear once registered nodes pass Stage B and accumulate probe measurements.</p>';
1579
+ } catch (e) { $('#r-table').innerHTML = '<p class="err">' + esc(e.message) + '</p>'; }
1580
+ }
1581
+
1582
+ /* ---------- nodes directory ---------- */
1583
+ async function loadNodes() {
1584
+ try {
1585
+ const { nodes } = await api('/v0/nodes');
1586
+ $('#n-counts').textContent = nodes.length + ' nodes';
1587
+ $('#n-table').innerHTML = `<table class="grid num"><thead><tr>
1588
+ <th>NODE</th><th>TIER</th><th>OPERATOR</th><th>REGION</th><th>OFFERINGS</th><th>UP</th><th>UPDATED</th>
1589
+ </tr></thead><tbody>` + nodes.map((n) => `<tr>
1590
+ <td><a href="#/node/${encodeURIComponent(n.id)}" style="font-weight:600">${esc(n.id)}</a></td>
1591
+ <td><span style="display:inline-flex; gap:0.3rem">${tierChip(n.tier)}${n.institutional ? '<span class="chip inst">institutional</span>' : ''}${n.source === 'import' ? '<span class="chip imp">IMPORTED</span>' : ''}</span></td>
1592
+ <td>${esc(n.operator ?? '—')}${n.country ? ' <span style="color:var(--muted)">(' + esc(n.country) + ')</span>' : ''}</td>
1593
+ <td style="color:var(--muted)">${esc((n.regions || []).join(', ') || '—')}</td>
1594
+ <td>${n.offerings}</td>
1595
+ <td>${n.availability?.ratio != null ? Math.round(n.availability.ratio * 100) + '%' : '—'}</td>
1596
+ <td><span class="prov">${ago(n.updated_at)}</span></td>
1597
+ </tr>`).join('') + '</tbody></table>';
1598
+ } catch (e) { $('#n-table').innerHTML = '<p class="err">' + esc(e.message) + '</p>'; }
1599
+ }
1600
+
1601
+ /* ---------- feed ---------- */
1602
+ async function loadFeed() {
1603
+ try {
1604
+ const { events } = await api('/v0/feed?limit=80');
1605
+ $('#feed-list').innerHTML = events.length ? events.map((e) => `<div class="feeditem">
1606
+ <span class="prov">${esc((e.at || '').replace('T', ' ').slice(0, 16))}</span>
1607
+ <span><a href="#/node/${encodeURIComponent(e.node_id)}" class="mono" style="font-size:0.78rem">${esc(e.node_id)}</a>
1608
+ → ${tierChip(e.to)} ${e.reason ? '<span style="color:var(--muted)">— ' + esc(e.reason) + '</span>' : ''}</span>
1609
+ </div>`).join('') : '<p class="sub">No events yet.</p>';
1610
+ } catch (e) { $('#feed-list').innerHTML = '<p class="err">' + esc(e.message) + '</p>'; }
1611
+ }
1612
+
1613
+ /* ---------- node detail ---------- */
1614
+ async function loadNode(id) {
1615
+ $('#node-title').textContent = id;
1616
+ try {
1617
+ const n = await api('/v0/nodes/' + encodeURIComponent(id));
1618
+ const card = n.card || {};
1619
+ const probes = (n.probes || []).map((p) =>
1620
+ `<div class="stage"><span class="k">${esc(p.stage)}·${esc(p.kind)}</span><span class="${p.ok ? 'ok' : 'fail'}">${p.ok ? 'pass' : 'fail'}${p.detail ? ' <span style="color:var(--muted)">— ' + esc(p.detail) + '</span>' : ''}</span></div>`).join('');
1621
+ const history = (n.history || []).map((h) =>
1622
+ `<div class="stage"><span class="k">${esc((h.at || '').slice(0, 10))}</span><span>→ <b>${esc(h.to_state)}</b>${h.reason ? ' <span style="color:var(--muted)">— ' + esc(h.reason) + '</span>' : ''}</span></div>`).join('');
1623
+ const tier = n.state === 'indexed' ? 'unverified' : n.state;
1624
+ const stat = (v, l) => `<span class="stat"><span class="sv">${v}</span><span class="sl">${l}</span></span>`;
1625
+ const offers = (card.offerings || []);
1626
+ const offerRows = offers.slice(0, 50).map((o) => `<tr>
1627
+ <td><a href="#/model/${encodeURIComponent(o.model?.artifact ?? '')}" style="font-weight:600">${esc(o.model?.name ?? o.offering_id)}</a></td>
1628
+ <td><span class="chip">${esc(o.modality)}</span></td>
1629
+ <td class="num"><b>${priceStr(o.pricing || {})}</b></td>
1630
+ <td>${hwStr({ hardware: o.hardware ?? card.hardware }) || '<span style="color:var(--faint)">undisclosed</span>'}</td>
1631
+ <td class="num">${o.serving?.context_window ? fmtInt(o.serving.context_window) : '—'}</td>
1632
+ <td>${(o.serving?.supports || []).filter((s) => s !== 'streaming').map((s) => `<span class="chip">${esc(s)}</span>`).join(' ') || '—'}</td>
1633
+ </tr>`).join('');
1634
+ $('#node-detail').innerHTML = `
1635
+ ${card.node?.description ? `<p class="desc">${esc(card.node.description)}</p>` : ''}
1636
+ <div class="statstrip">
1637
+ ${stat(tierChip(tier), 'TRUST TIER')}
1638
+ ${stat(String(offers.length), 'OFFERINGS')}
1639
+ ${card.node?.operator?.name ? stat(esc(card.node.operator.name) + (card.node.operator.country ? ' (' + esc(card.node.operator.country) + ')' : ''), 'OPERATOR') : ''}
1640
+ ${(card.node?.regions || []).length ? stat(esc(card.node.regions.join(', ')), 'REGIONS') : ''}
1641
+ ${n.source === 'import' ? stat('<span class="chip imp">IMPORTED</span>', 'SOURCE — unclaimed by operator') : ''}
1642
+ </div>
1643
+ <div class="panel pad" style="max-width:56rem; margin-top:0.6rem"><table class="grid">
1644
+ <tr><td style="color:var(--muted); width:8rem" class="mono">endpoint</td><td class="mono" style="font-size:0.74rem">${esc(card.endpoints?.openai ?? '—')}</td></tr>
1645
+ ${card.hardware ? `<tr><td style="color:var(--muted)" class="mono">hardware</td><td style="font-size:0.78rem">${hwStr({ hardware: card.hardware })}</td></tr>` : ''}
1646
+ <tr><td style="color:var(--muted)" class="mono">revision</td><td class="mono" style="font-size:0.74rem">${esc(card.revision ?? '—')}</td></tr>
1647
+ </table></div>
1648
+ ${offers.length ? `<h2>Offerings</h2><div class="panel tablewrap" style="max-width:56rem"><table class="grid num"><thead><tr>
1649
+ <th>MODEL</th><th>MODALITY</th><th>PRICE IN/OUT</th><th>HARDWARE</th><th>CONTEXT</th><th>CAPABILITIES</th>
1650
+ </tr></thead><tbody>${offerRows}</tbody></table>${offers.length > 50 ? `<div style="padding:0.6rem 0.8rem; border-top:1px solid var(--line)"><span class="prov">showing 50 of ${offers.length} — use the catalog search to browse all</span></div>` : ''}</div>` : ''}
1651
+ ${probes ? '<h2>Recent probes</h2><div class="panel pad" style="max-width:56rem">' + probes + '</div>' : ''}
1652
+ ${history ? '<h2>Listing history</h2><div class="panel pad" style="max-width:56rem">' + history + '</div>' : ''}`;
1653
+ } catch (e) { $('#node-detail').innerHTML = '<p class="err">' + esc(e.message) + '</p>'; }
1654
+ }
1655
+
1656
+ /* ---------- register flow ---------- */
1657
+ let regState = null;
1658
+ $('#btn-register').addEventListener('click', async () => {
1659
+ const out = $('#reg-out');
1660
+ try {
1661
+ if (!regState) {
1662
+ out.innerHTML = '<p class="sub">Submitting…</p>';
1663
+ regState = await api('/v0/nodes', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ card_url: $('#card-url').value.trim() }) });
1664
+ out.innerHTML = `
1665
+ <h2>Prove your namespace</h2>
1666
+ <p style="font-size:0.84rem">Card accepted for <span class="mono" style="font-size:0.78rem">${esc(regState.node_id)}</span>. Complete <b>one</b> challenge, then verify:</p>
1667
+ <div class="stage"><span class="k">HTTP</span><span style="font-size:0.8rem">Serve <span class="mono" style="font-size:0.72rem">{"token":"${esc(regState.challenge.token)}"}</span> at<br><span class="mono" style="font-size:0.7rem">${esc(regState.challenge.methods.http)}</span><br><span style="color:var(--muted)">(the Node Kit stages this automatically)</span></span></div>
1668
+ <div class="stage"><span class="k">DNS TXT</span><span class="mono" style="font-size:0.7rem">${esc(regState.challenge.methods.dns)}</span></div>
1669
+ <div style="margin-top:0.9rem"><button class="btn" id="btn-verify">Verify &amp; run Stage A</button></div>`;
1670
+ $('#btn-verify').addEventListener('click', verify);
1671
+ }
1672
+ } catch (e) { regState = null; out.innerHTML = '<p class="err">' + esc(e.message) + '</p>'; }
1673
+ });
1674
+
1675
+ async function verify() {
1676
+ const out = $('#reg-out');
1677
+ try {
1678
+ const v = await api(regState.challenge.verify, { method: 'POST' });
1679
+ const rows = (v.stage_a?.results || []).map((r) =>
1680
+ `<div class="stage"><span class="k">${esc(r.kind)}</span><span class="${r.ok ? 'ok' : 'fail'}">${r.ok ? 'pass' : 'fail'}${r.detail ? ' <span style="color:var(--muted)">— ' + esc(r.detail) + '</span>' : ''}</span></div>`).join('');
1681
+ out.innerHTML = `
1682
+ <h2>Stage A ${v.stage_a?.ok ? '<span class="ok">passed</span>' : '<span class="fail">failed</span>'}</h2>
1683
+ <p style="font-size:0.84rem">State: ${tierChip(v.state)} · signature ${v.signature_verified ? '<span class="ok">verified</span>' : '<span class="warncol">not verified</span>'}</p>
1684
+ <div class="panel pad">${rows}</div>
1685
+ ${v.state === 'community' ? `<p style="font-size:0.84rem; margin-top:0.8rem">Your node is listed. <a href="#/node/${encodeURIComponent(v.node_id)}">View it</a> — then request <b>Stage B</b> admission for the verified tier: <button class="btn sec" id="btn-admit">Run Stage B</button></p>` : ''}`;
1686
+ const admitBtn = $('#btn-admit');
1687
+ if (admitBtn) admitBtn.addEventListener('click', async () => {
1688
+ admitBtn.disabled = true; admitBtn.textContent = 'Running (identity, context, capacity)…';
1689
+ try {
1690
+ const a = await api('/v0/nodes/' + encodeURIComponent(v.node_id) + '/admit', { method: 'POST' });
1691
+ admitBtn.outerHTML = tierChip(a.state) + ` <a href="#/node/${encodeURIComponent(v.node_id)}">details</a>`;
1692
+ } catch (e) { admitBtn.outerHTML = '<span class="err">' + esc(e.message) + '</span>'; }
1693
+ });
1694
+ } catch (e) { out.insertAdjacentHTML('beforeend', '<p class="err">' + esc(e.message) + '</p>'); }
1695
+ }
1696
+
1697
+ route();
1698
+
1699
+ /* ---------- WebMCP (ONP-6 §3): expose site functions as in-page tools ----------
1700
+ Progressive enhancement over the W3C WebML CG draft (navigator.modelContext,
1701
+ Chrome origin trial). Browser agents get structured tools instead of scraping
1702
+ the DOM; on browsers without the API this block is inert. Tool implementations
1703
+ are also exported on window.__onpWebMcpTools so they are testable everywhere. */
1704
+ (function () {
1705
+ const text = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
1706
+ const tools = [
1707
+ {
1708
+ name: 'search_offerings',
1709
+ description: 'Search AI inference offerings in this OpenNodes registry (registered nodes + imported catalogs). Filters: q, modality (text|multimodal|image|audio|video|embedding), tier (community|verified), supports (comma list: tool_calls,json_mode,vision), max_input_price (USD/MTok), sort (rank|price), limit.',
1710
+ inputSchema: { type: 'object', properties: {
1711
+ q: { type: 'string' }, modality: { type: 'string' }, tier: { type: 'string' },
1712
+ supports: { type: 'string' }, max_input_price: { type: 'number' },
1713
+ sort: { type: 'string' }, limit: { type: 'number' } } },
1714
+ async execute(args = {}) {
1715
+ const p = new URLSearchParams();
1716
+ for (const [k, v] of Object.entries(args)) if (v != null) p.set(k, String(v));
1717
+ return text(await api('/v0/offerings?' + p));
1718
+ },
1719
+ },
1720
+ {
1721
+ name: 'get_node',
1722
+ description: 'Full detail for one node: card, trust tier, recent probe verdicts, listing history.',
1723
+ inputSchema: { type: 'object', required: ['node_id'], properties: { node_id: { type: 'string' } } },
1724
+ async execute(args) { return text(await api('/v0/nodes/' + encodeURIComponent(args.node_id))); },
1725
+ },
1726
+ {
1727
+ name: 'estimate_chain',
1728
+ description: 'Pre-price an invocation chain BEFORE spending money. steps: [{offering: "node_id/offering_id"} or {select: {…search filters…}}, est_input_tokens, est_output_tokens]. Returns enforceable per-step cost bounds pinned to card revisions.',
1729
+ inputSchema: { type: 'object', required: ['steps'], properties: {
1730
+ steps: { type: 'array', items: { type: 'object', properties: {
1731
+ offering: { type: 'string' }, select: { type: 'object' },
1732
+ est_input_tokens: { type: 'number' }, est_output_tokens: { type: 'number' } } } } } },
1733
+ async execute(args) {
1734
+ return text(await api('/v0/estimate', { method: 'POST',
1735
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ steps: args.steps }) }));
1736
+ },
1737
+ },
1738
+ {
1739
+ name: 'list_nodes',
1740
+ description: 'The node directory: every listed provider with trust tier, operator, region, offering count, availability.',
1741
+ inputSchema: { type: 'object', properties: {} },
1742
+ async execute() { return text(await api('/v0/nodes')); },
1743
+ },
1744
+ {
1745
+ name: 'get_recent_changes',
1746
+ description: 'Dated listing events, newest first: registrations, tier promotions/demotions, suspensions — with provenance reasons.',
1747
+ inputSchema: { type: 'object', properties: { limit: { type: 'number' } } },
1748
+ async execute(args = {}) { return text(await api('/v0/feed?limit=' + (args.limit ?? 50))); },
1749
+ },
1750
+ {
1751
+ name: 'register_node',
1752
+ description: 'Begin registering an AI node: submits its Node Card URL and returns the namespace challenge (HTTP and DNS TXT methods) plus the verify endpoint. Registration is open; trust tiers are earned via automated probes.',
1753
+ inputSchema: { type: 'object', required: ['card_url'], properties: { card_url: { type: 'string' } } },
1754
+ async execute(args) {
1755
+ return text(await api('/v0/nodes', { method: 'POST',
1756
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ card_url: args.card_url }) }));
1757
+ },
1758
+ },
1759
+ {
1760
+ name: 'verify_node',
1761
+ description: 'Complete a pending registration: checks the namespace challenge (HTTP or DNS TXT), runs the Stage A conformance battery, and returns the resulting trust tier and probe verdicts.',
1762
+ inputSchema: { type: 'object', required: ['node_id'], properties: { node_id: { type: 'string' } } },
1763
+ async execute(args) {
1764
+ return text(await api('/v0/nodes/' + encodeURIComponent(args.node_id) + '/verify', { method: 'POST' }));
1765
+ },
1766
+ },
1767
+ ];
1768
+
1769
+ window.__onpWebMcpTools = tools; // testable surface, present regardless of browser support
1770
+ try {
1771
+ const mc = navigator.modelContext;
1772
+ if (mc?.provideContext) mc.provideContext({ tools });
1773
+ else if (mc?.registerTool) for (const t of tools) mc.registerTool(t);
1774
+ } catch { /* draft API — never let registration break the page */ }
1775
+ })();
1776
+ </script>
1777
+ </body>
1778
+ </html>