free-coding-models 0.5.0 → 0.5.1

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.
Files changed (51) hide show
  1. package/README.md +9 -1
  2. package/bin/free-coding-models.js +10 -0
  3. package/changelog/v0.5.1.md +24 -0
  4. package/package.json +7 -2
  5. package/src/core/router-daemon.js +166 -1
  6. package/src/core/utils.js +2 -0
  7. package/src/tui/cli-help.js +2 -0
  8. package/src/tui/render-table.js +1 -1
  9. package/web/README.md +8 -5
  10. package/web/dist/assets/index-ByGf4Kq-.js +14 -0
  11. package/web/dist/assets/index-Ds7wmHBv.css +1 -0
  12. package/web/dist/index.html +3 -6
  13. package/web/index.html +1 -4
  14. package/web/package.json +11 -0
  15. package/web/server.js +606 -211
  16. package/web/src/App.jsx +54 -12
  17. package/web/src/components/analytics/AnalyticsView.jsx +10 -4
  18. package/web/src/components/atoms/AILatencyCell.jsx +38 -0
  19. package/web/src/components/atoms/AILatencyCell.module.css +43 -0
  20. package/web/src/components/atoms/HealthCell.jsx +53 -0
  21. package/web/src/components/atoms/HealthCell.module.css +15 -0
  22. package/web/src/components/atoms/LastPingCell.jsx +35 -0
  23. package/web/src/components/atoms/LastPingCell.module.css +35 -0
  24. package/web/src/components/atoms/MoodCell.jsx +25 -0
  25. package/web/src/components/atoms/MoodCell.module.css +6 -0
  26. package/web/src/components/atoms/RankCell.jsx +9 -0
  27. package/web/src/components/atoms/RankCell.module.css +9 -0
  28. package/web/src/components/atoms/TPSCell.jsx +36 -0
  29. package/web/src/components/atoms/TPSCell.module.css +38 -0
  30. package/web/src/components/atoms/VerdictBadge.jsx +30 -7
  31. package/web/src/components/atoms/VerdictBadge.module.css +24 -15
  32. package/web/src/components/dashboard/ExportModal.jsx +9 -4
  33. package/web/src/components/dashboard/FilterBar.jsx +112 -10
  34. package/web/src/components/dashboard/FilterBar.module.css +86 -1
  35. package/web/src/components/dashboard/ModelTable.jsx +293 -52
  36. package/web/src/components/dashboard/ModelTable.module.css +131 -33
  37. package/web/src/components/dashboard/StatsBar.jsx +7 -5
  38. package/web/src/components/layout/Footer.jsx +1 -1
  39. package/web/src/components/layout/Header.jsx +43 -9
  40. package/web/src/components/layout/Header.module.css +38 -4
  41. package/web/src/components/layout/Sidebar.jsx +19 -11
  42. package/web/src/components/layout/Sidebar.module.css +15 -5
  43. package/web/src/components/settings/SettingsView.jsx +24 -6
  44. package/web/src/components/settings/SettingsView.module.css +0 -1
  45. package/web/src/global.css +70 -73
  46. package/web/src/hooks/useFilter.js +117 -25
  47. package/web/src/hooks/useSSE.js +33 -9
  48. package/web/src/hooks/useSocket.js +200 -0
  49. package/web/vite.config.js +41 -0
  50. package/web/dist/assets/index-CGN-0_A0.css +0 -1
  51. package/web/dist/assets/index-CvMUM9Jr.js +0 -11
package/web/server.js CHANGED
@@ -1,21 +1,24 @@
1
1
  /**
2
2
  * @file web/server.js
3
- * @description HTTP server for the free-coding-models Web Dashboard V2.
3
+ * @description HTTP + Socket.IO/SSE server for the free-coding-models realtime Web Dashboard.
4
4
  *
5
- * Reuses the existing ping engine, model sources, and utility functions
6
- * from the CLI tool. Serves the dashboard HTML/CSS/JS and provides
7
- * API endpoints + SSE for real-time ping data.
5
+ * @details
6
+ * 📖 This server intentionally mirrors the TUI health loop instead of exposing a
7
+ * slow request/response snapshot. The browser gets per-model ping state, frequent
8
+ * updates while probes complete, and the same startup speed burst → normal → idle
9
+ * slow cadence used by the terminal UI.
8
10
  *
9
- * Endpoints:
10
- * GET / → Dashboard HTML
11
- * GET /styles.css → Dashboard styles
12
- * GET /app.js → Dashboard client JS
13
- * GET /api/models → All model metadata (JSON)
14
- * GET /api/health → Lightweight dashboard health probe
15
- * GET /api/config Current config (sanitized masked keys)
16
- * GET /api/key/:prov Reveal a provider's full API key
17
- * GET /api/events SSE stream of live ping results
18
- * POST /api/settings → Update API keys / provider toggles
11
+ * Realtime transport strategy:
12
+ * - Socket.IO is the primary channel for the local web app.
13
+ * - `/api/events` keeps an SSE stream alive as a zero-dependency fallback.
14
+ * - `/api/models` remains a plain JSON endpoint for polling/fallback clients.
15
+ *
16
+ * @functions
17
+ * → startWebServer(port, options)Start the dashboard server and realtime loops
18
+ * inspectExistingWebServer(port) Detect if a port already hosts this dashboard
19
+ * → findAvailablePort(startPort, maxAttempts) Find a local fallback port
20
+ *
21
+ * @exports startWebServer, inspectExistingWebServer, findAvailablePort
19
22
  */
20
23
 
21
24
  import { createServer } from 'node:http'
@@ -23,23 +26,58 @@ import { readFileSync, existsSync } from 'node:fs'
23
26
  import { join, dirname, extname } from 'node:path'
24
27
  import { fileURLToPath } from 'node:url'
25
28
  import { exec } from 'node:child_process'
29
+ import { Server } from 'socket.io'
26
30
 
27
31
  import { sources, MODELS } from '../sources.js'
28
32
  import { loadConfig, getApiKey, saveConfig, isProviderEnabled } from '../src/core/config.js'
29
33
  import { ping } from '../src/core/ping.js'
30
34
  import {
31
35
  getAvg, getVerdict, getUptime, getP95, getJitter,
32
- getStabilityScore, TIER_ORDER
36
+ getStabilityScore,
33
37
  } from '../src/core/utils.js'
38
+ import { benchmarkModel, BENCHMARK_TIMEOUT_MS } from '../src/core/benchmark.js'
39
+ import {
40
+ PING_MODE_INTERVALS,
41
+ PING_MODE_CYCLE,
42
+ SPEED_MODE_DURATION_MS,
43
+ IDLE_SLOW_AFTER_MS,
44
+ } from '../src/tui/tui-state.js'
34
45
 
35
46
  const __dirname = dirname(fileURLToPath(import.meta.url))
36
47
  const SERVER_SIGNATURE = 'free-coding-models-web'
48
+ const BROADCAST_THROTTLE_MS = 80
49
+ const MAX_PING_HISTORY = 60
50
+ const GLOBAL_BENCHMARK_CONCURRENCY = 5
51
+ const DEFAULT_WEB_PORT = 3333
52
+ const BODY_LIMIT_BYTES = 1024 * 1024
37
53
 
38
- // ─── State ───────────────────────────────────────────────────────────────────
54
+ // ─── Mutable server state ───────────────────────────────────────────────────
39
55
 
40
56
  let config = loadConfig()
57
+ let io = null
58
+ let pingLoopTimer = null
59
+ let broadcastTimer = null
60
+ let heartbeatTimer = null
61
+ let startedServer = null
62
+
63
+ const sseClients = new Set()
64
+
65
+ const runtime = {
66
+ pingMode: 'speed',
67
+ pingModeSource: 'startup',
68
+ activePingInterval: PING_MODE_INTERVALS.speed,
69
+ speedModeUntil: Date.now() + SPEED_MODE_DURATION_MS,
70
+ lastUserActivityAt: Date.now(),
71
+ resumeSpeedOnActivity: false,
72
+ lastPingTime: Date.now(),
73
+ nextPingAt: Date.now(),
74
+ pendingPings: 0,
75
+ pingRound: 0,
76
+ globalBenchmarkRunning: false,
77
+ globalBenchmarkTotal: 0,
78
+ globalBenchmarkCompleted: 0,
79
+ }
41
80
 
42
- // Build results array from MODELS (same shape as the TUI)
43
81
  const results = MODELS.map(([modelId, label, tier, sweScore, ctx, providerKey], idx) => ({
44
82
  idx: idx + 1,
45
83
  modelId,
@@ -55,105 +93,203 @@ const results = MODELS.map(([modelId, label, tier, sweScore, ctx, providerKey],
55
93
  url: sources[providerKey]?.url || null,
56
94
  cliOnly: sources[providerKey]?.cliOnly || false,
57
95
  zenOnly: sources[providerKey]?.zenOnly || false,
96
+ isPinging: false,
58
97
  }))
59
98
 
60
- // SSE clients
61
- const sseClients = new Set()
99
+ const benchmarkRunning = new Set()
100
+ const benchmarkResults = new Map()
62
101
 
63
- // ─── Ping Loop ───────────────────────────────────────────────────────────────
64
- // Uses recursive setTimeout (not setInterval) to prevent overlapping rounds.
65
- // Each new round starts only after the previous one completes.
66
-
67
- let pingRound = 0
68
- let pingLoopRunning = false
69
-
70
- async function pingAllModels() {
71
- if (pingLoopRunning) return // guard against overlapping calls
72
- pingLoopRunning = true
73
- pingRound++
74
- const batchSize = 30
75
- // P2 fix: honor provider enabled flags — skip disabled providers
76
- const modelsToPing = results.filter(r =>
77
- !r.cliOnly && r.url && isProviderEnabled(config, r.providerKey)
78
- )
79
-
80
- for (let i = 0; i < modelsToPing.length; i += batchSize) {
81
- const batch = modelsToPing.slice(i, i + batchSize)
82
- const promises = batch.map(async (r) => {
83
- const apiKey = getApiKey(config, r.providerKey)
84
- try {
85
- const result = await ping(apiKey, r.modelId, r.providerKey, r.url)
86
- r.httpCode = result.code
87
- if (result.code === '200') {
88
- r.status = 'up'
89
- r.pings.push({ ms: result.ms, code: result.code })
90
- } else if (result.code === '401') {
91
- r.status = 'up'
92
- r.pings.push({ ms: result.ms, code: result.code })
93
- } else if (result.code === '429') {
94
- r.status = 'up'
95
- r.pings.push({ ms: result.ms, code: result.code })
96
- } else if (result.code === '000') {
97
- r.status = 'timeout'
98
- } else {
99
- r.status = 'down'
100
- r.pings.push({ ms: result.ms, code: result.code })
101
- }
102
- // Keep only last 60 pings
103
- if (r.pings.length > 60) r.pings = r.pings.slice(-60)
104
- } catch {
105
- r.status = 'timeout'
106
- }
107
- })
108
- await Promise.all(promises)
102
+ // ─── Shared state helpers ───────────────────────────────────────────────────
103
+
104
+ function benchmarkKey(providerKey, modelId) {
105
+ return `${providerKey}/${modelId}`
106
+ }
107
+
108
+ function getResultKey(result) {
109
+ return benchmarkKey(result.providerKey, result.modelId)
110
+ }
111
+
112
+ function getResult(providerKey, modelId) {
113
+ return results.find((r) => r.providerKey === providerKey && r.modelId === modelId) || null
114
+ }
115
+
116
+ function noteUserActivity() {
117
+ runtime.lastUserActivityAt = Date.now()
118
+ if (runtime.pingMode === 'forced') return
119
+ if (runtime.resumeSpeedOnActivity) setPingMode('speed', 'activity')
120
+ }
121
+
122
+ function setPingMode(nextMode, source = 'manual') {
123
+ const mode = PING_MODE_INTERVALS[nextMode] ? nextMode : 'normal'
124
+ runtime.pingMode = mode
125
+ runtime.pingModeSource = source
126
+ runtime.activePingInterval = PING_MODE_INTERVALS[mode]
127
+ runtime.speedModeUntil = mode === 'speed' ? Date.now() + SPEED_MODE_DURATION_MS : null
128
+ runtime.resumeSpeedOnActivity = source === 'idle'
129
+ scheduleNextPing()
130
+ broadcastUpdate({ immediate: true })
131
+ }
132
+
133
+ function cyclePingMode() {
134
+ const idx = PING_MODE_CYCLE.indexOf(runtime.pingMode)
135
+ setPingMode(PING_MODE_CYCLE[(idx + 1) % PING_MODE_CYCLE.length] || 'normal')
136
+ }
137
+
138
+ function refreshPingMode() {
139
+ const now = Date.now()
140
+ if (runtime.pingMode === 'forced') return
141
+
142
+ if (runtime.speedModeUntil && now >= runtime.speedModeUntil) {
143
+ setPingMode('normal', 'auto')
144
+ return
145
+ }
146
+
147
+ if (now - runtime.lastUserActivityAt >= IDLE_SLOW_AFTER_MS) {
148
+ if (runtime.pingMode !== 'slow' || runtime.pingModeSource !== 'idle') {
149
+ setPingMode('slow', 'idle')
150
+ } else {
151
+ runtime.resumeSpeedOnActivity = true
152
+ }
109
153
  }
154
+ }
155
+
156
+ function scheduleNextPing() {
157
+ if (!startedServer?.listening) return
158
+ clearTimeout(pingLoopTimer)
159
+ refreshPingMode()
160
+ const elapsed = Date.now() - runtime.lastPingTime
161
+ const delay = Math.max(0, runtime.activePingInterval - elapsed)
162
+ runtime.nextPingAt = Date.now() + delay
163
+ pingLoopTimer = setTimeout(startPingCycle, delay)
164
+ }
165
+
166
+ function trimPingHistory(result) {
167
+ if (result.pings.length > MAX_PING_HISTORY) result.pings = result.pings.slice(-MAX_PING_HISTORY)
168
+ }
169
+
170
+ function updateHealthFromPing(result, pingResult, hasApiKey) {
171
+ const code = String(pingResult.code || 'ERR')
172
+ result.httpCode = code
173
+
174
+ // 📖 Match the TUI: every probe contributes to availability history. Average,
175
+ // 📖 p95, and jitter still ignore non-measurable codes through src/core/utils.js.
176
+ result.pings.push({ ms: pingResult.ms, code })
177
+ trimPingHistory(result)
110
178
 
111
- // Broadcast update to all SSE clients
179
+ if (code === '200') result.status = 'up'
180
+ else if (code === '000') result.status = 'timeout'
181
+ else if (code === '401' || code === '403') result.status = hasApiKey ? 'auth_error' : 'noauth'
182
+ else result.status = 'down'
183
+ }
184
+
185
+ function updateHealthFromBenchmark(result, benchmarkResult) {
186
+ if (!result || !benchmarkResult) return
187
+ if (benchmarkResult.ok) {
188
+ result.status = 'up'
189
+ result.httpCode = '200'
190
+ return
191
+ }
192
+
193
+ const code = String(benchmarkResult.code || 'ERR')
194
+ if (code === 'TIMEOUT') result.status = 'timeout'
195
+ else if (code === '401' || code === '403') result.status = getApiKey(config, result.providerKey) ? 'auth_error' : 'noauth'
196
+ else if (code !== 'ERR' && code !== 'UNSUPPORTED') result.status = 'down'
197
+ result.httpCode = code
198
+ }
199
+
200
+ async function pingModel(result) {
201
+ if (!result || result.isPinging || result.cliOnly || !result.url || !isProviderEnabled(config, result.providerKey)) return
202
+
203
+ result.isPinging = true
204
+ runtime.pendingPings += 1
112
205
  broadcastUpdate()
113
- pingLoopRunning = false
206
+
207
+ const apiKey = getApiKey(config, result.providerKey) ?? null
208
+ try {
209
+ const pingResult = await ping(apiKey, result.modelId, result.providerKey, result.url)
210
+ updateHealthFromPing(result, pingResult, !!apiKey)
211
+ } catch (err) {
212
+ updateHealthFromPing(result, { code: '000', ms: null, error: err?.message || 'Ping failed' }, !!apiKey)
213
+ } finally {
214
+ result.isPinging = false
215
+ runtime.pendingPings = Math.max(0, runtime.pendingPings - 1)
216
+ broadcastUpdate()
217
+ }
114
218
  }
115
219
 
116
- function broadcastUpdate() {
117
- const data = JSON.stringify(getModelsPayload())
118
- for (const client of sseClients) {
119
- try {
120
- client.write(`data: ${data}\n\n`)
121
- } catch {
122
- sseClients.delete(client)
123
- }
220
+ function startPingCycle() {
221
+ if (!startedServer?.listening) return
222
+ refreshPingMode()
223
+
224
+ runtime.lastPingTime = Date.now()
225
+ runtime.pingRound += 1
226
+ runtime.nextPingAt = runtime.lastPingTime + runtime.activePingInterval
227
+
228
+ const modelsToPing = results.filter((r) => !r.cliOnly && r.url && isProviderEnabled(config, r.providerKey))
229
+ for (const result of modelsToPing) {
230
+ void pingModel(result)
231
+ }
232
+
233
+ broadcastUpdate({ immediate: true })
234
+ scheduleNextPing()
235
+ }
236
+
237
+ function serializeModel(result) {
238
+ const key = getResultKey(result)
239
+ const avg = getAvg(result)
240
+ const p95 = getP95(result)
241
+ const jitter = getJitter(result)
242
+ const stability = getStabilityScore(result)
243
+ const latest = result.pings.length > 0 ? result.pings[result.pings.length - 1] : null
244
+
245
+ return {
246
+ idx: result.idx,
247
+ modelId: result.modelId,
248
+ label: result.label,
249
+ tier: result.tier,
250
+ sweScore: result.sweScore,
251
+ ctx: result.ctx,
252
+ providerKey: result.providerKey,
253
+ origin: result.origin,
254
+ status: result.status,
255
+ httpCode: result.httpCode,
256
+ cliOnly: result.cliOnly,
257
+ zenOnly: result.zenOnly,
258
+ isPinging: result.isPinging,
259
+ avg: Number.isFinite(avg) ? avg : null,
260
+ verdict: getVerdict(result),
261
+ uptime: getUptime(result),
262
+ p95: Number.isFinite(p95) ? p95 : null,
263
+ jitter: Number.isFinite(jitter) ? jitter : null,
264
+ stability,
265
+ latestPing: latest?.ms ?? null,
266
+ latestCode: latest?.code ?? null,
267
+ pingHistory: result.pings.slice(-20).map((p) => ({ ms: p.ms, code: p.code })),
268
+ pingCount: result.pings.length,
269
+ hasApiKey: !!getApiKey(config, result.providerKey),
270
+ benchmarkKey: key,
271
+ isBenchmarking: benchmarkRunning.has(key),
272
+ benchmark: benchmarkResults.get(key) || null,
124
273
  }
125
274
  }
126
275
 
127
276
  function getModelsPayload() {
128
- return results.map(r => ({
129
- idx: r.idx,
130
- modelId: r.modelId,
131
- label: r.label,
132
- tier: r.tier,
133
- sweScore: r.sweScore,
134
- ctx: r.ctx,
135
- providerKey: r.providerKey,
136
- origin: r.origin,
137
- status: r.status,
138
- httpCode: r.httpCode,
139
- cliOnly: r.cliOnly,
140
- zenOnly: r.zenOnly,
141
- avg: getAvg(r),
142
- verdict: getVerdict(r),
143
- uptime: getUptime(r),
144
- p95: getP95(r),
145
- jitter: getJitter(r),
146
- stability: getStabilityScore(r),
147
- latestPing: r.pings.length > 0 ? r.pings[r.pings.length - 1].ms : null,
148
- latestCode: r.pings.length > 0 ? r.pings[r.pings.length - 1].code : null,
149
- pingHistory: r.pings.slice(-20).map(p => ({ ms: p.ms, code: p.code })),
150
- pingCount: r.pings.length,
151
- hasApiKey: !!getApiKey(config, r.providerKey),
152
- }))
277
+ return {
278
+ pingMode: runtime.pingMode,
279
+ pingModeSource: runtime.pingModeSource,
280
+ pingInterval: runtime.activePingInterval,
281
+ nextPingAt: runtime.nextPingAt,
282
+ pendingPings: runtime.pendingPings,
283
+ isPinging: runtime.pendingPings > 0,
284
+ pingRound: runtime.pingRound,
285
+ globalBenchmarkRunning: runtime.globalBenchmarkRunning,
286
+ globalBenchmarkTotal: runtime.globalBenchmarkTotal,
287
+ globalBenchmarkCompleted: runtime.globalBenchmarkCompleted,
288
+ models: results.map(serializeModel),
289
+ }
153
290
  }
154
291
 
155
292
  function getConfigPayload() {
156
- // Sanitize — show which providers have keys, but not the actual keys
157
293
  const providers = {}
158
294
  for (const [key, src] of Object.entries(sources)) {
159
295
  const rawKey = getApiKey(config, key)
@@ -175,7 +311,36 @@ function maskApiKey(key) {
175
311
  return '••••••••' + key.slice(-4)
176
312
  }
177
313
 
178
- // ─── HTTP Server ─────────────────────────────────────────────────────────────
314
+ function writeSsePayload(res, payload) {
315
+ try {
316
+ res.write(`data: ${JSON.stringify(payload)}\n\n`)
317
+ } catch {
318
+ sseClients.delete(res)
319
+ }
320
+ }
321
+
322
+ function broadcastNow() {
323
+ const payload = getModelsPayload()
324
+ if (io) io.emit('models:update', payload)
325
+ for (const res of [...sseClients]) writeSsePayload(res, payload)
326
+ }
327
+
328
+ function broadcastUpdate({ immediate = false } = {}) {
329
+ if (immediate) {
330
+ clearTimeout(broadcastTimer)
331
+ broadcastTimer = null
332
+ broadcastNow()
333
+ return
334
+ }
335
+
336
+ if (broadcastTimer) return
337
+ broadcastTimer = setTimeout(() => {
338
+ broadcastTimer = null
339
+ broadcastNow()
340
+ }, BROADCAST_THROTTLE_MS)
341
+ }
342
+
343
+ // ─── HTTP helpers ───────────────────────────────────────────────────────────
179
344
 
180
345
  const MIME_TYPES = {
181
346
  '.html': 'text/html; charset=utf-8',
@@ -208,7 +373,10 @@ function serveDistFile(res, pathname) {
208
373
  const ct = MIME_TYPES[ext] || 'application/octet-stream'
209
374
  try {
210
375
  const content = readFileSync(filePath)
211
- res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable' })
376
+ res.writeHead(200, {
377
+ 'Content-Type': ct,
378
+ 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable',
379
+ })
212
380
  res.end(content)
213
381
  } catch {
214
382
  res.writeHead(404)
@@ -216,10 +384,112 @@ function serveDistFile(res, pathname) {
216
384
  }
217
385
  }
218
386
 
219
- function handleRequest(req, res) {
220
- res.setHeader('X-FCM-Server', SERVER_SIGNATURE)
387
+ function sendJson(res, status, payload) {
388
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
389
+ res.end(JSON.stringify(payload))
390
+ }
221
391
 
222
- // CORS for local dev
392
+ function readJsonBody(req) {
393
+ return new Promise((resolve, reject) => {
394
+ let body = ''
395
+ req.on('data', (chunk) => {
396
+ body += chunk
397
+ if (body.length > BODY_LIMIT_BYTES) {
398
+ reject(new Error('Request body too large'))
399
+ req.destroy()
400
+ }
401
+ })
402
+ req.on('end', () => {
403
+ if (!body.trim()) {
404
+ resolve({})
405
+ return
406
+ }
407
+ try { resolve(JSON.parse(body)) }
408
+ catch (err) { reject(err) }
409
+ })
410
+ req.on('error', reject)
411
+ })
412
+ }
413
+
414
+ function parseVisibleBenchmarkModels(body) {
415
+ const rawModels = Array.isArray(body?.models) ? body.models : null
416
+ if (!rawModels) return results.filter((r) => !r.cliOnly && r.url)
417
+
418
+ const unique = new Map()
419
+ for (const item of rawModels) {
420
+ if (!item || typeof item !== 'object') continue
421
+ const providerKey = typeof item.providerKey === 'string' ? item.providerKey : ''
422
+ const modelId = typeof item.modelId === 'string' ? item.modelId : ''
423
+ const result = getResult(providerKey, modelId)
424
+ if (result && !result.cliOnly && result.url) unique.set(getResultKey(result), result)
425
+ }
426
+ return [...unique.values()]
427
+ }
428
+
429
+ async function runSingleBenchmark(result) {
430
+ const key = getResultKey(result)
431
+ if (benchmarkRunning.has(key)) return { skipped: true }
432
+
433
+ benchmarkRunning.add(key)
434
+ broadcastUpdate({ immediate: true })
435
+ try {
436
+ const benchmarkResult = await benchmarkModel({
437
+ apiKey: getApiKey(config, result.providerKey) ?? null,
438
+ modelId: result.modelId,
439
+ providerKey: result.providerKey,
440
+ url: result.url,
441
+ timeoutMs: BENCHMARK_TIMEOUT_MS,
442
+ })
443
+ benchmarkResults.set(key, benchmarkResult)
444
+ updateHealthFromBenchmark(result, benchmarkResult)
445
+ return benchmarkResult
446
+ } catch (err) {
447
+ const fallback = {
448
+ ok: false,
449
+ code: 'ERR',
450
+ totalMs: 0,
451
+ error: err?.message || 'Benchmark failed',
452
+ retries: 0,
453
+ }
454
+ benchmarkResults.set(key, fallback)
455
+ updateHealthFromBenchmark(result, fallback)
456
+ return fallback
457
+ } finally {
458
+ benchmarkRunning.delete(key)
459
+ broadcastUpdate({ immediate: true })
460
+ }
461
+ }
462
+
463
+ function runWithConcurrency(tasks, concurrency) {
464
+ return new Promise((resolve) => {
465
+ const resultsOut = new Array(tasks.length)
466
+ let nextIndex = 0
467
+ let active = 0
468
+ let completed = 0
469
+
470
+ function startNext() {
471
+ while (active < concurrency && nextIndex < tasks.length) {
472
+ const index = nextIndex++
473
+ active += 1
474
+ Promise.resolve(tasks[index]())
475
+ .then((value) => { resultsOut[index] = value })
476
+ .catch((err) => { resultsOut[index] = { error: err } })
477
+ .finally(() => {
478
+ active -= 1
479
+ completed += 1
480
+ if (completed >= tasks.length) resolve(resultsOut)
481
+ else startNext()
482
+ })
483
+ }
484
+ if (tasks.length === 0) resolve(resultsOut)
485
+ }
486
+
487
+ startNext()
488
+ })
489
+ }
490
+
491
+ async function handleRequest(req, res) {
492
+ res.setHeader('X-FCM-Server', SERVER_SIGNATURE)
223
493
  res.setHeader('Access-Control-Allow-Origin', '*')
224
494
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
225
495
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
@@ -230,111 +500,216 @@ function handleRequest(req, res) {
230
500
  return
231
501
  }
232
502
 
233
- const url = new URL(req.url, `http://${req.headers.host}`)
503
+ const url = new URL(req.url, `http://${req.headers.host || `localhost:${DEFAULT_WEB_PORT}`}`)
234
504
 
235
- // ─── API: Reveal full key for a provider ───
236
505
  const keyMatch = url.pathname.match(/^\/api\/key\/(.+)$/)
237
506
  if (keyMatch) {
238
507
  const providerKey = decodeURIComponent(keyMatch[1])
239
- const rawKey = getApiKey(config, providerKey)
240
- res.writeHead(200, { 'Content-Type': 'application/json' })
241
- res.end(JSON.stringify({ key: rawKey || null }))
508
+ if (!sources[providerKey]) {
509
+ sendJson(res, 404, { error: 'Unknown provider' })
510
+ return
511
+ }
512
+ sendJson(res, 200, { key: getApiKey(config, providerKey) || null })
242
513
  return
243
514
  }
244
515
 
245
- switch (url.pathname) {
246
- case '/':
247
- serveDistFile(res, '/')
248
- break
249
-
250
- case '/styles.css':
251
- case '/app.js':
252
- serveDistFile(res, url.pathname)
253
- break
516
+ try {
517
+ switch (url.pathname) {
518
+ case '/':
519
+ serveDistFile(res, '/')
520
+ return
254
521
 
255
- default:
256
- if (url.pathname.startsWith('/assets/') || url.pathname.endsWith('.js') || url.pathname.endsWith('.css')) {
522
+ case '/styles.css':
523
+ case '/app.js':
257
524
  serveDistFile(res, url.pathname)
258
- break
525
+ return
526
+
527
+ case '/api/activity':
528
+ if (req.method !== 'POST') {
529
+ res.writeHead(405)
530
+ res.end('Method Not Allowed')
531
+ return
532
+ }
533
+ noteUserActivity()
534
+ sendJson(res, 200, { ok: true, pingMode: runtime.pingMode })
535
+ return
536
+
537
+ case '/api/ping-mode': {
538
+ if (req.method !== 'POST' && req.method !== 'GET') {
539
+ res.writeHead(405)
540
+ res.end('Method Not Allowed')
541
+ return
542
+ }
543
+ noteUserActivity()
544
+ const action = url.searchParams.get('action')
545
+ if (action === 'cycle') cyclePingMode()
546
+ else if (PING_MODE_INTERVALS[action]) setPingMode(action, 'manual')
547
+ sendJson(res, 200, {
548
+ pingMode: runtime.pingMode,
549
+ pingModeSource: runtime.pingModeSource,
550
+ interval: runtime.activePingInterval,
551
+ nextPingAt: runtime.nextPingAt,
552
+ })
553
+ return
259
554
  }
260
555
 
261
- if (!url.pathname.startsWith('/api/')) {
262
- res.writeHead(404)
263
- res.end('Not Found')
264
- break
556
+ case '/api/ping-timer':
557
+ sendJson(res, 200, {
558
+ nextPingAt: runtime.nextPingAt,
559
+ isPinging: runtime.pendingPings > 0,
560
+ pendingPings: runtime.pendingPings,
561
+ })
562
+ return
563
+
564
+ case '/api/models':
565
+ // 📖 Legacy REST contract: keep returning the flat model array.
566
+ sendJson(res, 200, getModelsPayload().models)
567
+ return
568
+
569
+ case '/api/state':
570
+ sendJson(res, 200, getModelsPayload())
571
+ return
572
+
573
+ case '/api/health':
574
+ sendJson(res, 200, { ok: true, app: SERVER_SIGNATURE })
575
+ return
576
+
577
+ case '/api/config':
578
+ sendJson(res, 200, getConfigPayload())
579
+ return
580
+
581
+ case '/api/events':
582
+ res.writeHead(200, {
583
+ 'Content-Type': 'text/event-stream',
584
+ 'Cache-Control': 'no-cache',
585
+ Connection: 'keep-alive',
586
+ })
587
+ writeSsePayload(res, getModelsPayload())
588
+ sseClients.add(res)
589
+ req.on('close', () => sseClients.delete(res))
590
+ return
591
+
592
+ case '/api/settings': {
593
+ if (req.method !== 'POST') {
594
+ res.writeHead(405)
595
+ res.end('Method Not Allowed')
596
+ return
597
+ }
598
+ const settings = await readJsonBody(req)
599
+ noteUserActivity()
600
+ if (settings.apiKeys) {
601
+ if (!config.apiKeys) config.apiKeys = {}
602
+ for (const [key, value] of Object.entries(settings.apiKeys)) {
603
+ if (value) config.apiKeys[key] = value
604
+ else delete config.apiKeys[key]
605
+ }
606
+ }
607
+ if (settings.providers) {
608
+ if (!config.providers) config.providers = {}
609
+ for (const [key, value] of Object.entries(settings.providers)) {
610
+ if (!config.providers[key]) config.providers[key] = {}
611
+ config.providers[key].enabled = value?.enabled !== false
612
+ }
613
+ }
614
+ saveConfig(config)
615
+ broadcastUpdate({ immediate: true })
616
+ sendJson(res, 200, { success: true })
617
+ return
618
+ }
619
+
620
+ case '/api/benchmark': {
621
+ if (req.method !== 'POST') {
622
+ res.writeHead(405)
623
+ res.end('Method Not Allowed')
624
+ return
625
+ }
626
+ const body = await readJsonBody(req)
627
+ const result = getResult(body.providerKey, body.modelId)
628
+ if (!result) {
629
+ sendJson(res, 404, { error: 'Model not found' })
630
+ return
631
+ }
632
+ const key = getResultKey(result)
633
+ if (benchmarkRunning.has(key)) {
634
+ sendJson(res, 409, { error: 'Benchmark already in progress for this model' })
635
+ return
636
+ }
637
+ noteUserActivity()
638
+ const benchmarkResult = await runSingleBenchmark(result)
639
+ sendJson(res, 200, benchmarkResult)
640
+ return
265
641
  }
266
642
 
267
- case '/api/models':
268
- res.writeHead(200, { 'Content-Type': 'application/json' })
269
- res.end(JSON.stringify(getModelsPayload()))
270
- break
271
-
272
- case '/api/health':
273
- res.writeHead(200, { 'Content-Type': 'application/json' })
274
- res.end(JSON.stringify({ ok: true, app: SERVER_SIGNATURE }))
275
- break
276
-
277
- case '/api/config':
278
- res.writeHead(200, { 'Content-Type': 'application/json' })
279
- res.end(JSON.stringify(getConfigPayload()))
280
- break
281
-
282
- case '/api/events':
283
- // SSE endpoint
284
- res.writeHead(200, {
285
- 'Content-Type': 'text/event-stream',
286
- 'Cache-Control': 'no-cache',
287
- 'Connection': 'keep-alive',
288
- })
289
- res.write(`data: ${JSON.stringify(getModelsPayload())}\n\n`)
290
- sseClients.add(res)
291
- req.on('close', () => sseClients.delete(res))
292
- break
293
-
294
- case '/api/settings':
295
- if (req.method === 'POST') {
296
- let body = ''
297
- req.on('data', chunk => body += chunk)
298
- req.on('end', () => {
643
+ case '/api/global-benchmark': {
644
+ if (req.method === 'GET') {
645
+ sendJson(res, 200, {
646
+ running: runtime.globalBenchmarkRunning,
647
+ total: runtime.globalBenchmarkTotal,
648
+ completed: runtime.globalBenchmarkCompleted,
649
+ })
650
+ return
651
+ }
652
+ if (req.method !== 'POST') {
653
+ res.writeHead(405)
654
+ res.end('Method Not Allowed')
655
+ return
656
+ }
657
+ if (runtime.globalBenchmarkRunning) {
658
+ sendJson(res, 409, { error: 'Global benchmark already running' })
659
+ return
660
+ }
661
+
662
+ const body = await readJsonBody(req)
663
+ noteUserActivity()
664
+ const healthPriority = { up: 0, pending: 1, timeout: 2, noauth: 3, auth_error: 4, down: 5 }
665
+ const modelsToBenchmark = parseVisibleBenchmarkModels(body)
666
+ .sort((a, b) => {
667
+ const hpA = healthPriority[a.status] ?? 6
668
+ const hpB = healthPriority[b.status] ?? 6
669
+ if (hpA !== hpB) return hpA - hpB
670
+ const pingA = typeof a.pings?.[a.pings.length - 1]?.ms === 'number' ? a.pings[a.pings.length - 1].ms : 99999
671
+ const pingB = typeof b.pings?.[b.pings.length - 1]?.ms === 'number' ? b.pings[b.pings.length - 1].ms : 99999
672
+ return pingA - pingB
673
+ })
674
+
675
+ runtime.globalBenchmarkRunning = true
676
+ runtime.globalBenchmarkTotal = modelsToBenchmark.length
677
+ runtime.globalBenchmarkCompleted = 0
678
+ broadcastUpdate({ immediate: true })
679
+
680
+ const tasks = modelsToBenchmark.map((model) => async () => {
299
681
  try {
300
- const settings = JSON.parse(body)
301
- if (settings.apiKeys) {
302
- for (const [key, value] of Object.entries(settings.apiKeys)) {
303
- if (value) config.apiKeys[key] = value
304
- else delete config.apiKeys[key]
305
- }
306
- }
307
- if (settings.providers) {
308
- for (const [key, value] of Object.entries(settings.providers)) {
309
- if (!config.providers[key]) config.providers[key] = {}
310
- config.providers[key].enabled = value.enabled !== false
311
- }
312
- }
313
- // P2 fix: catch saveConfig failures and report to client
314
- try {
315
- saveConfig(config)
316
- } catch (saveErr) {
317
- res.writeHead(500, { 'Content-Type': 'application/json' })
318
- res.end(JSON.stringify({ success: false, error: 'Failed to save config: ' + saveErr.message }))
319
- return
320
- }
321
- res.writeHead(200, { 'Content-Type': 'application/json' })
322
- res.end(JSON.stringify({ success: true }))
323
- } catch (err) {
324
- res.writeHead(400, { 'Content-Type': 'application/json' })
325
- res.end(JSON.stringify({ error: err.message }))
682
+ return await runSingleBenchmark(model)
683
+ } finally {
684
+ runtime.globalBenchmarkCompleted += 1
685
+ broadcastUpdate({ immediate: true })
326
686
  }
327
687
  })
328
- } else {
329
- res.writeHead(405)
330
- res.end('Method Not Allowed')
688
+
689
+ void runWithConcurrency(tasks, GLOBAL_BENCHMARK_CONCURRENCY).finally(() => {
690
+ runtime.globalBenchmarkRunning = false
691
+ runtime.globalBenchmarkTotal = 0
692
+ runtime.globalBenchmarkCompleted = 0
693
+ broadcastUpdate({ immediate: true })
694
+ })
695
+
696
+ sendJson(res, 202, { started: true, total: modelsToBenchmark.length })
697
+ return
331
698
  }
332
- break
699
+
700
+ default:
701
+ if (url.pathname.startsWith('/assets/') || url.pathname.endsWith('.js') || url.pathname.endsWith('.css')) {
702
+ serveDistFile(res, url.pathname)
703
+ return
704
+ }
705
+ res.writeHead(404)
706
+ res.end('Not Found')
707
+ }
708
+ } catch (err) {
709
+ if (!res.writableEnded) sendJson(res, 500, { error: err?.message || 'Internal server error' })
333
710
  }
334
711
  }
335
712
 
336
- // ─── Helpers ─────────────────────────────────────────────────────────────────
337
-
338
713
  function checkPortInUse(port) {
339
714
  return new Promise((resolve) => {
340
715
  const s = createServer()
@@ -352,8 +727,6 @@ export async function inspectExistingWebServer(port) {
352
727
  const timeout = setTimeout(() => controller.abort(), 750)
353
728
 
354
729
  try {
355
- // 📖 Probe a tiny health route so we only reuse a port when the running
356
- // 📖 process is actually the free-coding-models dashboard, not any random app.
357
730
  const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
358
731
  signal: controller.signal,
359
732
  headers: { Accept: 'application/json' },
@@ -387,16 +760,13 @@ function openBrowser(url) {
387
760
  })
388
761
  }
389
762
 
390
- // ─── Exports ─────────────────────────────────────────────────────────────────
391
-
392
- export async function startWebServer(port = 3333, { open = true, startPingLoop = true } = {}) {
763
+ export async function startWebServer(port = DEFAULT_WEB_PORT, { open = true, startPingLoop = true } = {}) {
393
764
  const portStatus = await inspectExistingWebServer(port)
394
765
 
395
766
  if (portStatus.inUse && portStatus.isFcm) {
396
767
  const url = `http://localhost:${port}`
397
-
398
768
  console.log()
399
- console.log(` ⚡ free-coding-models Web Dashboard already running`)
769
+ console.log(' ⚡ free-coding-models Web Dashboard already running')
400
770
  console.log(` 🌐 ${url}`)
401
771
  console.log()
402
772
  if (open) openBrowser(url)
@@ -413,31 +783,56 @@ export async function startWebServer(port = 3333, { open = true, startPingLoop =
413
783
  }
414
784
 
415
785
  const url = `http://localhost:${resolvedPort}`
786
+ const server = createServer((req, res) => void handleRequest(req, res))
787
+ startedServer = server
416
788
 
417
- const server = createServer(handleRequest)
418
- let pingLoopTimer = null
789
+ io = new Server(server, {
790
+ cors: { origin: '*', methods: ['GET', 'POST'] },
791
+ transports: ['websocket', 'polling'],
792
+ })
793
+
794
+ io.on('connection', (socket) => {
795
+ noteUserActivity()
796
+ socket.emit('models:update', getModelsPayload())
797
+ socket.on('client:activity', () => noteUserActivity())
798
+ socket.on('models:refresh', () => socket.emit('models:update', getModelsPayload()))
799
+ })
419
800
 
420
801
  server.listen(resolvedPort, () => {
421
802
  console.log()
422
- console.log(` ⚡ free-coding-models Web Dashboard`)
803
+ console.log(' ⚡ free-coding-models Web Dashboard')
423
804
  console.log(` 🌐 ${url}`)
424
- console.log(` 📊 Monitoring ${results.filter(r => !r.cliOnly).length} models across ${Object.keys(sources).length} providers`)
805
+ console.log(` 📊 Monitoring ${results.filter((r) => !r.cliOnly).length} models across ${Object.keys(sources).length} providers`)
425
806
  console.log()
426
- console.log(` Press Ctrl+C to stop`)
807
+ console.log(' Press Ctrl+C to stop')
427
808
  console.log()
809
+ if (startPingLoop && !pingLoopTimer) {
810
+ runtime.lastPingTime = Date.now()
811
+ runtime.nextPingAt = runtime.lastPingTime + runtime.activePingInterval
812
+ startPingCycle()
813
+ }
428
814
  if (open) openBrowser(url)
429
815
  })
430
816
 
431
- async function schedulePingLoop() {
432
- if (!server.listening) return
433
- await pingAllModels()
434
- pingLoopTimer = setTimeout(schedulePingLoop, 10_000)
435
- }
436
-
437
- if (startPingLoop) schedulePingLoop()
438
817
  server.on('close', () => {
439
- if (pingLoopTimer) clearTimeout(pingLoopTimer)
818
+ clearTimeout(pingLoopTimer)
819
+ clearTimeout(broadcastTimer)
820
+ clearInterval(heartbeatTimer)
821
+ for (const res of [...sseClients]) {
822
+ try { res.end() } catch {}
823
+ }
824
+ sseClients.clear()
825
+ io?.close()
826
+ io = null
827
+ if (startedServer === server) startedServer = null
440
828
  })
441
829
 
830
+ heartbeatTimer = setInterval(() => {
831
+ refreshPingMode()
832
+ for (const res of [...sseClients]) {
833
+ try { res.write(': heartbeat\n\n') } catch { sseClients.delete(res) }
834
+ }
835
+ }, 15_000)
836
+
442
837
  return server
443
838
  }