openvidia 2.2.0__py3-none-any.whl

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.
openvidia/web/main.js ADDED
@@ -0,0 +1,732 @@
1
+ let theme = localStorage.getItem('openvidia-theme') || 'dark'
2
+ let keys = []
3
+ let activeModel = ''
4
+ let allModels = []
5
+ let presets = []
6
+ let _logEntries = 0
7
+ let modelFilter = 'starred'
8
+ let keyFilter = 'active'
9
+ let _lastKeyStats = null
10
+
11
+ /* ── Popular models shortcut ─────────────────
12
+ NVIDIA free-tier chat models, checked against GET /v1/models on
13
+ 2026-10-24. The catalog changes monthly: ids the provider drops simply
14
+ stop matching, so a stale entry hides itself instead of breaking. */
15
+ const POPULAR_MODELS = new Set([
16
+ 'z-ai/glm-5.3',
17
+ 'z-ai/glm-5.3-flash',
18
+ 'deepseek-ai/deepseek-v4.1-flash',
19
+ 'nvidia/nemotron-3-ultra-550b-a55b',
20
+ 'nvidia/nemotron-3.5-lightning-30b-a3b',
21
+ 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning',
22
+ 'poolside/laguna-xs-2.1',
23
+ 'google/gemma-4-31b-it',
24
+ 'google/diffusiongemma-26b-a4b-it',
25
+ 'meta/muse-glimmer-30b',
26
+ 'openai/gpt-oss-20b',
27
+ 'meta/llama-3.2-90b-vision-instruct',
28
+ 'meta/llama-3.2-11b-vision-instruct',
29
+ ])
30
+
31
+ const $ = id => document.getElementById(id)
32
+
33
+ /* ── Control-plane token ─────────────────────────
34
+ The proxy hands it over in the URL on open. It goes into sessionStorage and
35
+ out of the address bar immediately: leaving it there would put a live
36
+ credential into the window title, the history, and any screenshot. */
37
+ const TOKEN_HEADER = 'X-OpenVidia-Token'
38
+
39
+ function readToken() {
40
+ const fromUrl = new URLSearchParams(location.search).get('token')
41
+ if (fromUrl) {
42
+ sessionStorage.setItem('openvidia-token', fromUrl)
43
+ history.replaceState(null, '', location.pathname)
44
+ return fromUrl
45
+ }
46
+ return sessionStorage.getItem('openvidia-token') || ''
47
+ }
48
+
49
+ let controlToken = readToken()
50
+
51
+ /* ── API helper ──────────────────────────────── */
52
+ async function api(method, path, body) {
53
+ const opts = { method, headers: {} }
54
+ if (controlToken) opts.headers[TOKEN_HEADER] = controlToken
55
+ if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body) }
56
+ const r = await fetch(path, opts)
57
+ if (r.status === 401) {
58
+ throw new Error('This dashboard has no control token. Reopen it from the OpenVidia app, or append ?token=<contents of ~/.config/openvidia/control_token>')
59
+ }
60
+ if (!r.ok) { let t; try { t = await r.text() } catch { t = r.statusText }; throw new Error(t) }
61
+ return r.json()
62
+ }
63
+
64
+ /* ── Toast ──────────────────────────────────── */
65
+ function toast(msg, level = 'info') {
66
+ const icons = { ok: '✓', error: '✕', warn: '⚠', info: 'ℹ' }
67
+ const el = document.createElement('div')
68
+ el.className = 'toast'
69
+ // msg carries server text verbatim — including upstream error bodies — so
70
+ // it is set as text, never parsed as markup.
71
+ const ic = document.createElement('span')
72
+ ic.className = `toast-icon ${level}`; ic.textContent = icons[level] || icons.info
73
+ const tx = document.createElement('span'); tx.textContent = msg
74
+ el.appendChild(ic); el.appendChild(tx)
75
+ $('toast-container').appendChild(el)
76
+ setTimeout(() => { el.classList.add('toast-out'); setTimeout(() => el.remove(), 260) }, 3500)
77
+ }
78
+
79
+ /* ── Theme ──────────────────────────────────── */
80
+ function applyTheme(t) {
81
+ theme = t
82
+ document.documentElement.setAttribute('data-theme', t)
83
+ $('themeToggle').textContent = t === 'dark' ? '🌙' : '☀️'
84
+ localStorage.setItem('openvidia-theme', t)
85
+ }
86
+ $('themeToggle').addEventListener('click', () => applyTheme(theme === 'dark' ? 'light' : 'dark'))
87
+ applyTheme(theme)
88
+
89
+ /* ── Bento Dropdowns ────────────────────────── */
90
+ document.querySelectorAll('[data-toggle]').forEach(hd => {
91
+ hd.addEventListener('click', () => hd.closest('[data-dropdown]').classList.toggle('open'))
92
+ })
93
+
94
+ /* ── Running State ──────────────────────────── */
95
+ function renderUpstream(up) {
96
+ const badge = $('hUpstream')
97
+ const dot = $('upDot')
98
+ const txt = $('upText')
99
+ if (!badge) return
100
+ const st = (up && up.status) || 'unknown'
101
+ const ms = up && up.latency_ms
102
+ badge.className = `header-stat upstream-badge up-${st}`
103
+ if (st === 'ok') {
104
+ txt.textContent = ms != null ? `${Math.round(ms)}ms` : 'ok'
105
+ badge.title = 'NVIDIA reachable'
106
+ } else if (st === 'slow') {
107
+ txt.textContent = ms != null ? `slow ${Math.round(ms)}ms` : 'slow'
108
+ badge.title = `NVIDIA is slow (${(up && up.detail) || 'high latency'}) — not your keys`
109
+ } else if (st === 'down') {
110
+ txt.textContent = 'down'
111
+ badge.title = `NVIDIA is DOWN (${(up && up.detail) || 'no response'}) — your proxy and keys are fine`
112
+ } else {
113
+ txt.textContent = '…'
114
+ badge.title = 'NVIDIA health not measured yet'
115
+ }
116
+ }
117
+
118
+ async function updateRunningState() {
119
+ try {
120
+ const st = await api('GET', '/api/status')
121
+ const running = st.running
122
+ $('statusText').textContent = running ? 'Running' : 'Stopped'
123
+ $('statusDot').querySelector('.pulse-dot').className = `pulse-dot ${running ? 'running' : ''}`
124
+ $('stopBtn').style.display = running ? '' : 'none'
125
+ $('startBtn').style.display = running ? 'none' : ''
126
+ $('hKeyCount').textContent = `${st.keys || 0} keys`
127
+ renderUpstream(st.upstream)
128
+ } catch (_) {
129
+ $('statusText').textContent = 'Offline'
130
+ $('statusDot').querySelector('.pulse-dot').className = 'pulse-dot'
131
+ }
132
+ }
133
+
134
+ $('stopBtn').addEventListener('click', async () => { try { await api('POST', '/api/stop'); toast('Proxy stopped', 'warn') } catch (_) {} await updateRunningState() })
135
+ $('startBtn').addEventListener('click', async () => { try { await api('POST', '/api/start'); toast('Proxy started', 'ok') } catch (_) {} await updateRunningState() })
136
+
137
+ $('restartBtn').addEventListener('click', async () => {
138
+ $('restartBtn').textContent = '⟳…'
139
+ try { await api('POST', '/api/restart'); toast('Restarting…', 'info') } catch (_) { toast('Restart failed', 'error') }
140
+ $('restartBtn').textContent = '⟳'
141
+ })
142
+
143
+ /* ── Stats polling ───────────────────────────── */
144
+ $('portDisplay').textContent = '1919'
145
+ setInterval(async () => {
146
+ try {
147
+ const s = await api('GET', '/api/stats')
148
+ $('statReqs').textContent = s.requests
149
+ $('statOk').textContent = s.success
150
+ $('statRots').textContent = s.rotations
151
+ const cd = s.cooldowns || 0
152
+ $('statCooldowns').textContent = cd
153
+ $('cdMini').classList.toggle('warn', cd > 0)
154
+ $('hRpm').textContent = `${s.total_rpm || 0} RPM`
155
+ await updateRunningState()
156
+ } catch (_) {}
157
+ }, 2000)
158
+
159
+ /* ── Starred models (= presets = quick-pick shortlist) ────── */
160
+ async function loadPresets() {
161
+ try { const r = await api('GET', '/api/presets'); presets = r.presets || [] } catch (_) { presets = [] }
162
+ renderFilters(); renderModelList()
163
+ }
164
+ async function savePresets() { try { await api('POST', '/api/presets', { presets }) } catch (_) {} renderFilters(); renderModelList() }
165
+
166
+ function shortLabel(id) {
167
+ const known = { 'z-ai/glm-5.3': 'GLM 5.3', 'z-ai/glm-5.3-flash': 'GLM 5.3 Flash', 'deepseek-ai/deepseek-v4.1-flash': 'DeepSeek V4.1 Flash' }
168
+ if (known[id]) return known[id]
169
+ return id.split('/').pop()
170
+ }
171
+
172
+ async function setModel(id) {
173
+ try {
174
+ await fetch('/api/model', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: id }) })
175
+ activeModel = id
176
+ $('activeModelDisplay').textContent = id || 'none'
177
+ renderModelList(); renderUsage()
178
+ toast(`Model: ${id || 'none'}`, 'ok')
179
+ } catch (_) {}
180
+ }
181
+
182
+ async function loadModel() {
183
+ try { const r = await api('GET', '/api/model'); activeModel = r.model || ''; $('activeModelDisplay').textContent = activeModel || 'none' } catch (_) {}
184
+ loadThinking()
185
+ loadEffort()
186
+ loadFallback()
187
+ }
188
+
189
+ document.querySelectorAll('.think-btn').forEach(b => {
190
+ b.addEventListener('click', () => setThinking(b.dataset.think))
191
+ })
192
+
193
+ /* ── Reasoning toggle ─────────────────────────
194
+ A hybrid reasoning model withholds every byte until it finishes thinking.
195
+ The switch is per active model and stored server-side, so all four CLIs
196
+ pick it up without touching their own configs. */
197
+ let thinkingMode = 'auto'
198
+
199
+ function renderThinking() {
200
+ document.querySelectorAll('.think-btn').forEach(b => {
201
+ b.classList.toggle('active', b.dataset.think === thinkingMode)
202
+ })
203
+ }
204
+
205
+ async function loadThinking() {
206
+ try {
207
+ const r = await api('GET', '/api/thinking')
208
+ thinkingMode = r.mode || 'auto'
209
+ renderThinking()
210
+ } catch (_) {}
211
+ }
212
+
213
+ async function setThinking(mode) {
214
+ try {
215
+ await fetch('/api/thinking', {
216
+ method: 'POST',
217
+ headers: { 'Content-Type': 'application/json' },
218
+ body: JSON.stringify({ mode })
219
+ })
220
+ thinkingMode = mode
221
+ renderThinking()
222
+ toast(`Thinking: ${mode}${activeModel ? ' — ' + activeModel : ''}`, 'ok')
223
+ } catch (_) {}
224
+ }
225
+
226
+ /* ── Reasoning effort ──────────────────────────
227
+ Finer granularity than thinking on/off:
228
+ low (fast), medium (balanced), high (deep). */
229
+ let reasoningEffort = 'auto'
230
+
231
+ document.querySelectorAll('.effort-btn').forEach(b => {
232
+ b.addEventListener('click', () => setEffort(b.dataset.effort))
233
+ })
234
+
235
+ function renderEffort() {
236
+ document.querySelectorAll('.effort-btn').forEach(b => {
237
+ b.classList.toggle('active', b.dataset.effort === reasoningEffort)
238
+ })
239
+ }
240
+
241
+ async function loadEffort() {
242
+ try {
243
+ const r = await api('GET', '/api/reasoning-effort')
244
+ reasoningEffort = r.effort || 'auto'
245
+ renderEffort()
246
+ } catch (_) {}
247
+ }
248
+
249
+ async function setEffort(effort) {
250
+ try {
251
+ await fetch('/api/reasoning-effort', {
252
+ method: 'POST',
253
+ headers: { 'Content-Type': 'application/json' },
254
+ body: JSON.stringify({ effort })
255
+ })
256
+ reasoningEffort = effort
257
+ renderEffort()
258
+ toast(`Effort: ${effort}${activeModel ? ' — ' + activeModel : ''}`, 'ok')
259
+ } catch (_) {}
260
+ }
261
+
262
+ /* ── Fallback toggle ───────────────────────────
263
+ When a model's circuit is open, the proxy can either failover to the
264
+ next healthy preset or return 503. off = never failover, on = always. */
265
+ let fallbackMode = 'off'
266
+
267
+ document.querySelectorAll('.fallback-btn').forEach(b => {
268
+ b.addEventListener('click', () => setFallback(b.dataset.fallback))
269
+ })
270
+
271
+ function renderFallback() {
272
+ document.querySelectorAll('.fallback-btn').forEach(b => {
273
+ b.classList.toggle('active', b.dataset.fallback === fallbackMode)
274
+ })
275
+ }
276
+
277
+ async function loadFallback() {
278
+ try {
279
+ const r = await api('GET', '/api/fallback')
280
+ fallbackMode = r.fallback || 'off'
281
+ renderFallback()
282
+ } catch (_) {}
283
+ }
284
+
285
+ async function setFallback(mode) {
286
+ try {
287
+ await api('POST', '/api/fallback', { fallback: mode })
288
+ fallbackMode = mode
289
+ renderFallback()
290
+ toast(`Fallback: ${mode}${activeModel ? ' — ' + activeModel : ''}`, 'ok')
291
+ } catch (_) {}
292
+ }
293
+
294
+ /* ── Models browser ──────────────────────────── */
295
+ const FILTERS = ['starred', 'all', 'popular']
296
+ function getFilteredModels() {
297
+ let list
298
+ if (modelFilter === 'starred') {
299
+ // in the order they were starred
300
+ list = presets.map(id => allModels.find(m => m.id === id) || { id, owned_by: '' })
301
+ } else if (modelFilter === 'popular') {
302
+ list = allModels.filter(m => POPULAR_MODELS.has(m.id))
303
+ } else {
304
+ list = allModels
305
+ }
306
+ const q = ($('modelSearch').value || '').toLowerCase()
307
+ if (q) list = list.filter(m => m.id.toLowerCase().includes(q))
308
+ // active model pinned to the top under any filter
309
+ if (activeModel) {
310
+ const i = list.findIndex(m => m.id === activeModel)
311
+ if (i > 0) { const [a] = list.splice(i, 1); list.unshift(a) }
312
+ }
313
+ return list
314
+ }
315
+
316
+ function renderFilters() {
317
+ const c = $('modelFilters'); c.innerHTML = ''
318
+ FILTERS.forEach(f => {
319
+ const b = document.createElement('button')
320
+ b.className = `browser-filter ${f === modelFilter ? 'active' : ''}`
321
+ if (f === 'starred') b.innerHTML = `★ Starred <span class="filter-count">${presets.length}</span>`
322
+ else if (f === 'popular') b.textContent = 'Popular'
323
+ else b.textContent = 'All'
324
+ b.onclick = () => { modelFilter = f; renderFilters(); renderModelList() }
325
+ c.appendChild(b)
326
+ })
327
+ }
328
+
329
+ async function fetchModels() {
330
+ try {
331
+ const r = await fetch('/v1/models'); const d = await r.json()
332
+ allModels = (d.data || d.models || []).map(m => ({ id: m.id, owned_by: m.owned_by || '', health: m.health || null }))
333
+ $('modelCount').textContent = allModels.length
334
+ renderFilters(); renderModelList(); renderUsage()
335
+ } catch (_) { $('modelList').innerHTML = '<div class="browser-empty">Failed to load</div>' }
336
+ }
337
+ // Refresh the catalog every 60s: NVIDIA removes/renames models without notice,
338
+ // and the list has to follow along without a page reload.
339
+ setInterval(fetchModels, 60000)
340
+
341
+ async function testModel(id, btn) {
342
+ btn.className = 'browser-test testing'; btn.textContent = '…'
343
+ const r = document.createElement('div'); r.className = 'test-result'
344
+ btn.parentElement.parentElement.after(r)
345
+ try {
346
+ const res = await api('POST', '/api/test-model', { model: id })
347
+ if (res.ok) { r.className = 'test-result ok'; r.textContent = `✓ ${(res.response || '').slice(0, 80)}`; btn.className = 'browser-test done'; btn.textContent = '✓' }
348
+ else { r.className = 'test-result fail'; r.textContent = `✗ ${res.detail || res.error || 'unknown'}`.slice(0, 120); btn.className = 'browser-test fail'; btn.textContent = '✗' }
349
+ } catch (e) { r.className = 'test-result fail'; r.textContent = `✗ ${e.message.slice(0, 80)}`; btn.className = 'browser-test fail'; btn.textContent = '✗' }
350
+ setTimeout(() => r.remove(), 8000)
351
+ setTimeout(() => { btn.className = 'browser-test'; btn.textContent = '▶' }, 5000)
352
+ }
353
+
354
+ function renderModelList() {
355
+ const f = getFilteredModels()
356
+ $('modelCount').textContent = `${f.length} / ${allModels.length}`
357
+ if (!f.length) {
358
+ const hint = modelFilter === 'starred'
359
+ ? 'No starred models — tap ☆ on any model in All'
360
+ : 'No models found'
361
+ $('modelList').innerHTML = `<div class="browser-empty">${hint}</div>`
362
+ return
363
+ }
364
+ $('modelList').innerHTML = ''
365
+ f.forEach(m => {
366
+ const item = document.createElement('div')
367
+ item.className = `browser-item ${m.id === activeModel ? 'active' : ''}`
368
+ // m.id / m.owned_by / m.health come straight from the upstream /v1/models
369
+ // response (enriched by the proxy with ModelHealth). Remote data never
370
+ // becomes markup.
371
+ const nm = document.createElement('span'); nm.className = 'browser-name'; nm.textContent = m.id
372
+ item.appendChild(nm)
373
+ // Health badge: open circuit (model down) or high failure rate
374
+ // (model degraded) → the problem is NVIDIA, not the keys.
375
+ if (m.health) {
376
+ const h = m.health
377
+ let cls = '', label = ''
378
+ if (h.circuit_open) { cls = 'health-bad'; label = 'down' }
379
+ else if (h.failure_rate >= 0.5 && h.requests >= 4) { cls = 'health-bad'; label = 'failing' }
380
+ else if (h.median_ttft > 5000 && h.requests >= 4) { cls = 'health-slow'; label = `slow ${Math.round(h.median_ttft / 1000)}s` }
381
+ if (cls) {
382
+ const hb = document.createElement('span')
383
+ hb.className = `health-badge ${cls}`; hb.textContent = label
384
+ hb.title = `${h.requests} req · ${Math.round(h.failure_rate * 100)}% fail · median ttft ${Math.round(h.median_ttft)}ms — upstream NVIDIA, not your keys`
385
+ item.appendChild(hb)
386
+ }
387
+ }
388
+ if (m.owned_by) {
389
+ const ow = document.createElement('span'); ow.className = 'browser-owner'; ow.textContent = m.owned_by
390
+ item.appendChild(ow)
391
+ }
392
+ const acts = document.createElement('div'); acts.className = 'browser-acts'
393
+ const test = document.createElement('button'); test.className = 'browser-test'; test.textContent = '▶'; test.title = 'Test'
394
+ test.onclick = e => { e.stopPropagation(); testModel(m.id, test) }
395
+ acts.appendChild(test)
396
+ const starred = presets.includes(m.id)
397
+ const star = document.createElement('button')
398
+ star.className = `browser-star ${starred ? 'on' : ''}`
399
+ star.textContent = starred ? '★' : '☆'
400
+ star.title = starred ? 'Unstar — remove from shortlist' : 'Star — add to shortlist'
401
+ star.onclick = e => {
402
+ e.stopPropagation()
403
+ if (starred) presets = presets.filter(x => x !== m.id)
404
+ else presets.push(m.id)
405
+ savePresets()
406
+ toast(`${starred ? 'Unstarred' : 'Starred'} ${shortLabel(m.id)}`, 'ok')
407
+ }
408
+ acts.appendChild(star)
409
+ item.appendChild(acts)
410
+ item.onclick = () => setModel(m.id)
411
+ $('modelList').appendChild(item)
412
+ })
413
+ }
414
+ $('modelSearch').addEventListener('input', renderModelList)
415
+
416
+ /* ── Key management: CRUD + filters ──────────── */
417
+ function copyToClipboard(t) { navigator.clipboard.writeText(t).then(() => toast('Key copied', 'ok')).catch(() => {}) }
418
+
419
+ /* Keys live server-side. The page only ever holds masked strings, so copying
420
+ asks for the one key it needs, when the user asks for it. */
421
+ async function copyKey(i) {
422
+ try {
423
+ const d = await api('POST', '/api/keys/reveal', { index: i })
424
+ if (d.ok) copyToClipboard(d.key); else toast(d.error || 'Reveal failed', 'error')
425
+ } catch (e) { toast(`Reveal failed: ${e.message}`, 'error') }
426
+ }
427
+
428
+ async function removeKey(i) {
429
+ try {
430
+ const d = await api('POST', '/api/keys/remove', { index: i })
431
+ if (!d.ok) return toast(d.error || 'Remove failed', 'error')
432
+ keys = d.keys || []
433
+ toast('Key removed', 'warn')
434
+ renderKeys(await api('GET', '/api/keys/stats').catch(() => null))
435
+ } catch (e) { toast(`Remove failed: ${e.message}`, 'error') }
436
+ }
437
+ function timeAgo(ts) { if (!ts) return ''; const s = Math.floor(Date.now() / 1000 - ts); if (s < 60) return `${s}s`; if (s < 3600) return `${Math.floor(s / 60)}m`; return `${Math.floor(s / 3600)}h` }
438
+
439
+ function keyStatus(k, i, stats) {
440
+ const s = stats ? stats[String(i)] : null
441
+ if (s && s.cooldown > 0) return 'cooldown'
442
+ if (s && s.success > 0) return 'fresh'
443
+ return 'idle'
444
+ }
445
+
446
+ function updateFilterCounts(stats) {
447
+ if (!stats) return
448
+ const sm = stats.key_stats || {}
449
+ let active = 0, cooldown = 0, fresh = 0, idle = 0
450
+ keys.forEach((k, i) => {
451
+ const st = keyStatus(k, i, sm)
452
+ if (st === 'cooldown') cooldown++
453
+ else if (st === 'fresh') fresh++
454
+ else idle++
455
+ if (i === stats.active_index) active++
456
+ })
457
+ $('fAll').textContent = keys.length
458
+ $('fActive').textContent = active
459
+ $('fCooldown').textContent = cooldown
460
+ $('fFresh').textContent = fresh
461
+ $('fIdle').textContent = idle
462
+ }
463
+
464
+ function renderKeys(data) {
465
+ _lastKeyStats = data
466
+ const list = $('keysList')
467
+ list.innerHTML = ''
468
+ $('keyCount').textContent = String(keys.length)
469
+ $('keysEmpty').classList.toggle('hidden', keys.length > 0)
470
+ const ai = data ? data.active_index : -1
471
+ const sm = data ? data.key_stats || {} : {}
472
+ updateFilterCounts(data)
473
+ keys.forEach((k, i) => {
474
+ const s = sm[String(i)]
475
+ const status = keyStatus(k, i, sm)
476
+ if (keyFilter !== 'all' && keyFilter !== status && !(keyFilter === 'active' && i === ai)) return
477
+ const isCd = s && s.cooldown > 0
478
+ const row = document.createElement('div')
479
+ row.className = `key-row ${i === ai ? 'active' : ''}`
480
+ const dot = document.createElement('span')
481
+ dot.className = `key-dot key-${status}`
482
+ row.appendChild(dot)
483
+ const val = document.createElement('span')
484
+ val.className = 'key-val'; val.textContent = k; val.title = 'Click to copy'
485
+ val.onclick = () => copyKey(i)
486
+ row.appendChild(val)
487
+ const acts = document.createElement('div'); acts.className = 'key-acts'
488
+ if (i === ai) { const b = document.createElement('span'); b.className = `key-badge ${isCd ? 'cd' : ''}`; b.textContent = isCd ? '⏳' : 'active'; acts.appendChild(b) }
489
+ const info = document.createElement('span'); info.className = 'key-info'
490
+ if (isCd) { info.textContent = `⏳ ${Math.ceil(s.cooldown)}s`; info.title = s.cooldown_reason || '' }
491
+ else if (s && s.requests > 0) { info.textContent = `${s.success}✓${s.failed > 0 ? ' ' + s.failed + '✗' : ''} ${timeAgo(s.last_used)}`; info.title = s.last_error || '' }
492
+ else { info.textContent = 'idle' }
493
+ acts.appendChild(info)
494
+ const cp = document.createElement('button'); cp.className = 'key-act'; cp.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'; cp.title = 'Copy'; cp.onclick = () => copyKey(i)
495
+ acts.appendChild(cp)
496
+ const del = document.createElement('button'); del.className = 'key-act danger'; del.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>'; del.title = 'Remove'; del.onclick = () => removeKey(i)
497
+ acts.appendChild(del)
498
+ row.appendChild(acts)
499
+ list.appendChild(row)
500
+ })
501
+ }
502
+
503
+ async function pollKeyStats() {
504
+ try {
505
+ const data = await api('GET', '/api/keys/stats')
506
+ renderKeys(data)
507
+ } catch (_) {}
508
+ }
509
+
510
+
511
+ /* ── Key filter buttons ──────────────────────── */
512
+ document.querySelectorAll('.key-filter').forEach(btn => {
513
+ btn.addEventListener('click', () => {
514
+ keyFilter = btn.dataset.filter
515
+ document.querySelectorAll('.key-filter').forEach(b => b.classList.toggle('active', b === btn))
516
+ renderKeys(_lastKeyStats)
517
+ })
518
+ })
519
+
520
+ /* ── Add key form ────────────────────────────── */
521
+ $('addKeyBtn').addEventListener('click', () => { $('keyAddForm').classList.remove('hidden'); $('newKeyInput').value = ''; $('newKeyInput').focus() })
522
+ $('cancelAddKeyBtn').addEventListener('click', () => $('keyAddForm').classList.add('hidden'))
523
+ $('confirmAddKeyBtn').addEventListener('click', async () => {
524
+ const v = $('newKeyInput').value.trim()
525
+ if (!v) return toast('Enter a key', 'warn')
526
+ try {
527
+ // The pool is server-side, so duplicate detection lives there too — the
528
+ // page cannot compare a cleartext key against its masked list.
529
+ const d = await api('POST', '/api/keys/add', { key: v })
530
+ if (!d.ok) return toast(d.error || 'Add failed', 'warn')
531
+ keys = d.keys || []
532
+ $('keyAddForm').classList.add('hidden')
533
+ toast('Key added', 'ok')
534
+ renderKeys(await api('GET', '/api/keys/stats').catch(() => null))
535
+ } catch (e) { toast(`Add failed: ${e.message}`, 'error') }
536
+ })
537
+ $('newKeyInput').addEventListener('keydown', e => { if (e.key === 'Enter') $('confirmAddKeyBtn').click() })
538
+
539
+ /* ── Log ────────────────────────────────────── */
540
+ $('clearLogBtn').addEventListener('click', () => { $('logArea').innerHTML = '<div class="log-empty">Cleared</div>'; _logEntries = 0; $('logCount').textContent = '0' })
541
+
542
+ function appendLog(level, msg) {
543
+ const t = new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
544
+ const el = document.createElement('div')
545
+ el.className = `log-entry ${level}`
546
+ const time = document.createElement('span'); time.className = 'log-time'; time.textContent = t
547
+ const text = document.createElement('span'); text.className = 'log-msg'; text.textContent = msg
548
+ el.appendChild(time); el.appendChild(text)
549
+ const e = $('logArea').querySelector('.log-empty')
550
+ if (e) e.remove()
551
+ $('logArea').appendChild(el)
552
+ $('logArea').scrollTop = $('logArea').scrollHeight
553
+ _logEntries++; $('logCount').textContent = _logEntries
554
+ $('logDot').className = 'log-dot active'
555
+ clearTimeout($('logDot')._timer)
556
+ $('logDot')._timer = setTimeout(() => { $('logDot').className = 'log-dot' }, 2000)
557
+ }
558
+
559
+ // EventSource cannot set request headers, which is the reason the middleware
560
+ // accepts ?token= as well as the header.
561
+ new EventSource(`/api/logs/stream?token=${encodeURIComponent(controlToken)}`).onmessage = e => {
562
+ try {
563
+ const { msg } = JSON.parse(e.data)
564
+ let level = 'info'
565
+ if (/cooldown|429|rate.limit|⏳/.test(msg)) level = 'cooldown'
566
+ else if (/error|Error|✗|failed/.test(msg)) level = 'error'
567
+ else if (/OK|✔|✓|revived/.test(msg)) level = 'ok'
568
+ appendLog(level, msg)
569
+ } catch (_) {}
570
+ }
571
+
572
+ /* ── CLI Setup tab (opencode / Codex / Claude Code / Grok / Jcode) ──────── */
573
+ let currentCli = 'opencode'
574
+ const CLI_TABS = [
575
+ { id: 'opencode', label: 'opencode', icon: '⚡' },
576
+ { id: 'codex', label: 'Codex', icon: '⬡' },
577
+ { id: 'claude', label: 'Claude', icon: '✦' },
578
+ { id: 'grok', label: 'Grok', icon: '✸' },
579
+ { id: 'jcode', label: 'Jcode', icon: '◆' },
580
+ ]
581
+
582
+ function renderCliTabs() {
583
+ const c = $('cliTabs')
584
+ if (!c) return
585
+ c.innerHTML = ''
586
+ CLI_TABS.forEach(t => {
587
+ const b = document.createElement('button')
588
+ b.className = `cli-tab ${t.id === currentCli ? 'active' : ''}`
589
+ b.innerHTML = `<span class="cli-tab-icon">${t.icon}</span>${t.label}`
590
+ b.onclick = () => { currentCli = t.id; renderCliTabs(); renderUsage() }
591
+ c.appendChild(b)
592
+ })
593
+ }
594
+
595
+ function renderUsage() {
596
+ const m = activeModel || 'openvidia'
597
+ const examples = {
598
+ opencode: {
599
+ title: 'opencode',
600
+ steps: [
601
+ { label: 'Auto-setup (opencode is the one CLI OpenVidia configures)', code: `openvidia setup` },
602
+ { label: 'Manual — ~/.config/opencode/opencode.json', code: `{
603
+ "provider": {
604
+ "openvidia": {
605
+ "npm": "@ai-sdk/openai-compatible",
606
+ "options": { "apiKey": "ignored", "baseURL": "http://localhost:1919/v1" },
607
+ "models": { "${m}": { "name": "OpenVidia", "tools": true } }
608
+ }
609
+ },
610
+ "model": "openvidia/${m}",
611
+ "compaction": { "auto": true, "prune": true, "reserved": 8000 }
612
+ }` },
613
+ { label: 'Run', code: `opencode` },
614
+ ],
615
+ },
616
+ codex: {
617
+ title: 'Codex CLI',
618
+ steps: [
619
+ { label: 'Run against the proxy (nothing is written)', code: `openvidia run codex` },
620
+ { label: 'If Codex needs its own provider block — ~/.codex/config.toml', code: `model = "${m}"
621
+ model_provider = "openvidia"
622
+
623
+ [model_providers.openvidia]
624
+ name = "OpenVidia"
625
+ base_url = "http://localhost:1919/v1"
626
+ env_key = "OPENVIDIA_API_KEY"
627
+ wire_api = "responses"
628
+
629
+ [model_providers.openvidia.models.${m}]
630
+ name = "OpenVidia (NVIDIA NIM via proxy)"
631
+ context_window = 128000
632
+ supports_tool_use = true
633
+ supports_parallel_tool_use = true
634
+ cost_input_tokens = 0
635
+ cost_output_tokens = 0` },
636
+ { label: 'Env', code: `export OPENVIDIA_API_KEY=ignored` },
637
+ { label: 'Run', code: `codex --model ${m}` },
638
+ ],
639
+ },
640
+ claude: {
641
+ title: 'Claude Code',
642
+ steps: [
643
+ { label: 'Run against the proxy (nothing is written)', code: `openvidia run claude` },
644
+ { label: 'With arguments', code: `openvidia run claude -p "explain this file"` },
645
+ { label: 'Equivalent by hand — one shell, no config edits', code: `ANTHROPIC_BASE_URL=http://localhost:1919 \\
646
+ ANTHROPIC_API_KEY=ignored \\
647
+ claude --model ${m}` },
648
+ ],
649
+ },
650
+ grok: {
651
+ title: 'Grok (xAI)',
652
+ steps: [
653
+ { label: 'Run against the proxy (nothing is written)', code: `openvidia run grok` },
654
+ { label: 'If Grok needs its own provider block — ~/.grok/config.toml', code: `[models]
655
+ default = "${m}"
656
+
657
+ [model.${m}]
658
+ api_key = "ignored"
659
+ base_url = "http://localhost:1919/v1"
660
+ api_backend = "chat_completions"
661
+ context_window = 128000` },
662
+ { label: 'Run', code: `grok -m ${m} "explain this codebase"` },
663
+ ],
664
+ },
665
+ jcode: {
666
+ title: 'Jcode',
667
+ steps: [
668
+ { label: 'Auto-setup (configures ~/.jcode/config.toml)', code: `openvidia setup` },
669
+ { label: 'Manual — ~/.jcode/config.toml', code: `[provider]
670
+ default_provider = "openvidia"
671
+ default_model = "${m}"
672
+
673
+ [providers.openvidia]
674
+ type = "openai-compatible"
675
+ base_url = "http://localhost:1919/v1"
676
+ auth = "none"
677
+ default_model = "${m}"
678
+ requires_api_key = false
679
+ model_catalog = true
680
+ stream_idle_timeout_secs = 300
681
+
682
+ [[providers.openvidia.models]]
683
+ id = "${m}"
684
+ context_window = 202752` },
685
+ { label: 'Run', code: `jcode` },
686
+ { label: 'Switch model inside Jcode', code: `/model openvidia` },
687
+ ],
688
+ },
689
+ }
690
+
691
+ const ex = examples[currentCli]
692
+ if (!ex) return
693
+ const container = $('usageCode') || $('cliSetup')
694
+ if (!container) return
695
+ // s.code interpolates activeModel, which is server-persisted state. Building
696
+ // this as an HTML string would make a stored model name executable markup,
697
+ // so the whole block is assembled from nodes and text.
698
+ container.innerHTML = ''
699
+ const wrap = document.createElement('div'); wrap.className = 'cli-setup-content'
700
+ const h = document.createElement('h4'); h.textContent = ex.title
701
+ wrap.appendChild(h)
702
+ ex.steps.forEach((s, i) => {
703
+ const step = document.createElement('div'); step.className = 'cli-step'
704
+ const lab = document.createElement('div'); lab.className = 'cli-step-label'
705
+ lab.textContent = `${i + 1}. ${s.label}`
706
+ const pre = document.createElement('pre'); pre.className = 'cli-step-code'
707
+ const code = document.createElement('code'); code.textContent = s.code
708
+ pre.appendChild(code)
709
+ step.appendChild(lab); step.appendChild(pre)
710
+ wrap.appendChild(step)
711
+ })
712
+ container.appendChild(wrap)
713
+ }
714
+
715
+ /* ── Init ───────────────────────────────────── */
716
+ ;(async () => {
717
+ try {
718
+ const d = await api('GET', '/api/keys'); keys = d.keys || []
719
+ await loadModel()
720
+ await loadPresets()
721
+ await fetchModels()
722
+ // Render with real stats: renderKeys(undefined) leaves active_index at -1,
723
+ // and the default 'active' filter then matches nothing — the list showed
724
+ // empty for the first two seconds while claiming N keys were loaded.
725
+ renderKeys(await api('GET', '/api/keys/stats').catch(() => null))
726
+ if (keys.length) toast(`${keys.length} keys loaded`, 'ok')
727
+ } catch (_) {}
728
+ await updateRunningState()
729
+ renderCliTabs()
730
+ renderUsage()
731
+ setInterval(pollKeyStats, 2000)
732
+ })()