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/__init__.py +30 -0
- openvidia/__main__.py +827 -0
- openvidia/_upstream_utils.py +57 -0
- openvidia/anthropic_shim.py +714 -0
- openvidia/compaction.py +851 -0
- openvidia/config.py +513 -0
- openvidia/embedding_cache.py +69 -0
- openvidia/graph_engine.py +466 -0
- openvidia/provider_fallback.py +98 -0
- openvidia/proxy_app.py +863 -0
- openvidia/proxy_state.py +1010 -0
- openvidia/redis_sync.py +93 -0
- openvidia/responses_shim.py +1374 -0
- openvidia/safe_file.py +100 -0
- openvidia/server_manager.py +106 -0
- openvidia/upstream_router.py +50 -0
- openvidia/web/assets/32x32.ico +0 -0
- openvidia/web/assets/64x64.ico +0 -0
- openvidia/web/assets/dashboard.gif +0 -0
- openvidia/web/assets/favicon.ico +0 -0
- openvidia/web/assets/logo.png +0 -0
- openvidia/web/favicon.ico +0 -0
- openvidia/web/index.html +166 -0
- openvidia/web/logo.png +0 -0
- openvidia/web/main.js +732 -0
- openvidia/web/styles.css +425 -0
- openvidia/webui.py +532 -0
- openvidia-2.2.0.dist-info/METADATA +814 -0
- openvidia-2.2.0.dist-info/RECORD +33 -0
- openvidia-2.2.0.dist-info/WHEEL +5 -0
- openvidia-2.2.0.dist-info/entry_points.txt +2 -0
- openvidia-2.2.0.dist-info/licenses/LICENSE +21 -0
- openvidia-2.2.0.dist-info/top_level.txt +1 -0
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
|
+
})()
|