free-coding-models 0.5.0 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -6
- package/bin/free-coding-models.js +10 -0
- package/changelog/v0.5.1.md +24 -0
- package/changelog/v0.5.4.md +24 -0
- package/package.json +11 -3
- package/sources.js +2 -0
- package/src/core/router-daemon.js +166 -1
- package/src/core/utils.js +2 -0
- package/src/tui/cli-help.js +2 -0
- package/src/tui/key-handler.js +1 -1
- package/src/tui/render-table.js +1 -1
- package/web/README.md +8 -5
- package/web/assets/providers/aistudio/aistudio-text.svg +1 -0
- package/web/assets/providers/aistudio/aistudio.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-brand-color.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-brand.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-color.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-text.svg +1 -0
- package/web/assets/providers/cerebras/cerebras.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare-color.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare-text.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare.svg +1 -0
- package/web/assets/providers/gemini/gemini-color.svg +1 -0
- package/web/assets/providers/gemini/gemini-text.svg +1 -0
- package/web/assets/providers/gemini/gemini.svg +1 -0
- package/web/assets/providers/github/github-text.svg +1 -0
- package/web/assets/providers/github/github.svg +1 -0
- package/web/assets/providers/groq/groq-text.svg +1 -0
- package/web/assets/providers/groq/groq.svg +1 -0
- package/web/assets/providers/mistral/mistral-color.svg +1 -0
- package/web/assets/providers/mistral/mistral-text.svg +1 -0
- package/web/assets/providers/mistral/mistral.svg +1 -0
- package/web/assets/providers/nvidia/nvidia-color.svg +1 -0
- package/web/assets/providers/nvidia/nvidia-text.svg +1 -0
- package/web/assets/providers/nvidia/nvidia.svg +1 -0
- package/web/assets/providers/opencode/opencode-text.svg +1 -0
- package/web/assets/providers/opencode/opencode.svg +1 -0
- package/web/assets/providers/openrouter/openrouter (1).svg +1 -0
- package/web/assets/providers/openrouter/openrouter-text.svg +1 -0
- package/web/assets/providers/openrouter/openrouter.svg +1 -0
- package/web/assets/providers/ovhcloud/ovhcloud.svg +12 -0
- package/web/assets/providers/qwen/qwen-color.svg +1 -0
- package/web/assets/providers/qwen/qwen-text.svg +1 -0
- package/web/assets/providers/qwen/qwen.svg +1 -0
- package/web/assets/providers/sambanova/sambanova-color.svg +1 -0
- package/web/assets/providers/sambanova/sambanova-text.svg +1 -0
- package/web/assets/providers/sambanova/sambanova.svg +1 -0
- package/web/assets/providers/scalewaylogo/ScalewayLogo.svg +13 -0
- package/web/assets/providers/zai/zai-text.svg +1 -0
- package/web/assets/providers/zai/zai.svg +1 -0
- package/web/dist/assets/index-BoWmUveV.js +39 -0
- package/web/dist/assets/index-BrpHevg4.css +1 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/favicons/apple-touch-icon.png +0 -0
- package/web/dist/favicons/browserconfig.xml +12 -0
- package/web/dist/favicons/favicon-16x16.png +0 -0
- package/web/dist/favicons/favicon-192x192.png +0 -0
- package/web/dist/favicons/favicon-32x32.png +0 -0
- package/web/dist/favicons/favicon-48x48.png +0 -0
- package/web/dist/favicons/favicon-512x512.png +0 -0
- package/web/dist/favicons/favicon-96x96.png +0 -0
- package/web/dist/favicons/favicon.ico +0 -0
- package/web/dist/favicons/mstile-150x150.png +0 -0
- package/web/dist/favicons/mstile-310x150.png +0 -0
- package/web/dist/favicons/mstile-310x310.png +0 -0
- package/web/dist/favicons/mstile-512x512.png +0 -0
- package/web/dist/favicons/mstile-70x70.png +0 -0
- package/web/dist/favicons/site.webmanifest +25 -0
- package/web/dist/index.html +42 -6
- package/web/index.html +40 -4
- package/web/package.json +11 -0
- package/web/public/favicon.ico +0 -0
- package/web/public/favicons/apple-touch-icon.png +0 -0
- package/web/public/favicons/browserconfig.xml +12 -0
- package/web/public/favicons/favicon-16x16.png +0 -0
- package/web/public/favicons/favicon-192x192.png +0 -0
- package/web/public/favicons/favicon-32x32.png +0 -0
- package/web/public/favicons/favicon-48x48.png +0 -0
- package/web/public/favicons/favicon-512x512.png +0 -0
- package/web/public/favicons/favicon-96x96.png +0 -0
- package/web/public/favicons/favicon.ico +0 -0
- package/web/public/favicons/mstile-150x150.png +0 -0
- package/web/public/favicons/mstile-310x150.png +0 -0
- package/web/public/favicons/mstile-310x310.png +0 -0
- package/web/public/favicons/mstile-512x512.png +0 -0
- package/web/public/favicons/mstile-70x70.png +0 -0
- package/web/public/favicons/site.webmanifest +25 -0
- package/web/server.js +676 -211
- package/web/src/App.jsx +233 -71
- package/web/src/components/analytics/AnalyticsView.jsx +40 -6
- package/web/src/components/analytics/AnalyticsView.module.css +4 -0
- package/web/src/components/atoms/AILatencyCell.jsx +38 -0
- package/web/src/components/atoms/AILatencyCell.module.css +51 -0
- package/web/src/components/atoms/HealthCell.jsx +138 -0
- package/web/src/components/atoms/HealthCell.module.css +56 -0
- package/web/src/components/atoms/LastPingCell.jsx +35 -0
- package/web/src/components/atoms/LastPingCell.module.css +45 -0
- package/web/src/components/atoms/MoodCell.jsx +25 -0
- package/web/src/components/atoms/MoodCell.module.css +6 -0
- package/web/src/components/atoms/NoKeyIcon.jsx +65 -0
- package/web/src/components/atoms/NoKeyIcon.module.css +30 -0
- package/web/src/components/atoms/ProviderLogo.jsx +188 -0
- package/web/src/components/atoms/ProviderLogo.module.css +103 -0
- package/web/src/components/atoms/RankCell.jsx +9 -0
- package/web/src/components/atoms/RankCell.module.css +9 -0
- package/web/src/components/atoms/Sparkline.jsx +21 -2
- package/web/src/components/atoms/StabilityCell.module.css +5 -0
- package/web/src/components/atoms/StatusDot.module.css +7 -0
- package/web/src/components/atoms/TPSCell.jsx +36 -0
- package/web/src/components/atoms/TPSCell.module.css +44 -0
- package/web/src/components/atoms/TierBadge.module.css +11 -0
- package/web/src/components/atoms/Toast.module.css +6 -0
- package/web/src/components/atoms/VerdictBadge.jsx +30 -7
- package/web/src/components/atoms/VerdictBadge.module.css +36 -15
- package/web/src/components/dashboard/DetailPanel.jsx +63 -2
- package/web/src/components/dashboard/DetailPanel.module.css +130 -0
- package/web/src/components/dashboard/ExportModal.jsx +9 -4
- package/web/src/components/dashboard/FilterBar.jsx +228 -33
- package/web/src/components/dashboard/FilterBar.module.css +187 -22
- package/web/src/components/dashboard/ModelTable.jsx +500 -54
- package/web/src/components/dashboard/ModelTable.module.css +318 -31
- package/web/src/components/layout/Footer.jsx +1 -1
- package/web/src/components/layout/Header.jsx +171 -9
- package/web/src/components/layout/Header.module.css +223 -25
- package/web/src/components/palette/CommandPalette.jsx +146 -0
- package/web/src/components/palette/CommandPalette.module.css +121 -0
- package/web/src/components/settings/SettingsView.jsx +24 -6
- package/web/src/components/settings/SettingsView.module.css +0 -1
- package/web/src/global.css +118 -84
- package/web/src/hooks/useColumnSizing.js +211 -0
- package/web/src/hooks/useFavorites.js +150 -0
- package/web/src/hooks/useFilter.js +262 -37
- package/web/src/hooks/useSSE.js +33 -9
- package/web/src/hooks/useSocket.js +200 -0
- package/web/src/hooks/useTheme.js +72 -16
- package/web/src/hooks/useUrlState.js +93 -0
- package/web/vite.config.js +41 -0
- package/web/dist/assets/index-CGN-0_A0.css +0 -1
- package/web/dist/assets/index-CvMUM9Jr.js +0 -11
- package/web/src/components/dashboard/StatsBar.jsx +0 -40
- package/web/src/components/dashboard/StatsBar.module.css +0 -28
- package/web/src/components/layout/Sidebar.jsx +0 -42
- package/web/src/components/layout/Sidebar.module.css +0 -76
- package/web/src/components/map/MapView.jsx +0 -17
- package/web/src/components/map/MapView.module.css +0 -25
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
|
|
3
|
+
* @description HTTP + Socket.IO/SSE server for the free-coding-models realtime Web Dashboard.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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,59 @@ 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'
|
|
33
|
+
import { ensureFavoritesConfig } from '../src/core/favorites.js'
|
|
29
34
|
import { ping } from '../src/core/ping.js'
|
|
30
35
|
import {
|
|
31
36
|
getAvg, getVerdict, getUptime, getP95, getJitter,
|
|
32
|
-
getStabilityScore,
|
|
37
|
+
getStabilityScore,
|
|
33
38
|
} from '../src/core/utils.js'
|
|
39
|
+
import { benchmarkModel, BENCHMARK_TIMEOUT_MS } from '../src/core/benchmark.js'
|
|
40
|
+
import {
|
|
41
|
+
PING_MODE_INTERVALS,
|
|
42
|
+
PING_MODE_CYCLE,
|
|
43
|
+
SPEED_MODE_DURATION_MS,
|
|
44
|
+
IDLE_SLOW_AFTER_MS,
|
|
45
|
+
} from '../src/tui/tui-state.js'
|
|
34
46
|
|
|
35
47
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
36
48
|
const SERVER_SIGNATURE = 'free-coding-models-web'
|
|
49
|
+
const BROADCAST_THROTTLE_MS = 80
|
|
50
|
+
const MAX_PING_HISTORY = 60
|
|
51
|
+
const GLOBAL_BENCHMARK_CONCURRENCY = 5
|
|
52
|
+
const DEFAULT_WEB_PORT = 3333
|
|
53
|
+
const BODY_LIMIT_BYTES = 1024 * 1024
|
|
37
54
|
|
|
38
|
-
// ───
|
|
55
|
+
// ─── Mutable server state ───────────────────────────────────────────────────
|
|
39
56
|
|
|
40
57
|
let config = loadConfig()
|
|
58
|
+
let io = null
|
|
59
|
+
let pingLoopTimer = null
|
|
60
|
+
let broadcastTimer = null
|
|
61
|
+
let heartbeatTimer = null
|
|
62
|
+
let startedServer = null
|
|
63
|
+
|
|
64
|
+
const sseClients = new Set()
|
|
65
|
+
|
|
66
|
+
const runtime = {
|
|
67
|
+
pingMode: 'speed',
|
|
68
|
+
pingModeSource: 'startup',
|
|
69
|
+
activePingInterval: PING_MODE_INTERVALS.speed,
|
|
70
|
+
speedModeUntil: Date.now() + SPEED_MODE_DURATION_MS,
|
|
71
|
+
lastUserActivityAt: Date.now(),
|
|
72
|
+
resumeSpeedOnActivity: false,
|
|
73
|
+
lastPingTime: Date.now(),
|
|
74
|
+
nextPingAt: Date.now(),
|
|
75
|
+
pendingPings: 0,
|
|
76
|
+
pingRound: 0,
|
|
77
|
+
globalBenchmarkRunning: false,
|
|
78
|
+
globalBenchmarkTotal: 0,
|
|
79
|
+
globalBenchmarkCompleted: 0,
|
|
80
|
+
}
|
|
41
81
|
|
|
42
|
-
// Build results array from MODELS (same shape as the TUI)
|
|
43
82
|
const results = MODELS.map(([modelId, label, tier, sweScore, ctx, providerKey], idx) => ({
|
|
44
83
|
idx: idx + 1,
|
|
45
84
|
modelId,
|
|
@@ -55,105 +94,203 @@ const results = MODELS.map(([modelId, label, tier, sweScore, ctx, providerKey],
|
|
|
55
94
|
url: sources[providerKey]?.url || null,
|
|
56
95
|
cliOnly: sources[providerKey]?.cliOnly || false,
|
|
57
96
|
zenOnly: sources[providerKey]?.zenOnly || false,
|
|
97
|
+
isPinging: false,
|
|
58
98
|
}))
|
|
59
99
|
|
|
60
|
-
|
|
61
|
-
const
|
|
100
|
+
const benchmarkRunning = new Set()
|
|
101
|
+
const benchmarkResults = new Map()
|
|
62
102
|
|
|
63
|
-
// ───
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
103
|
+
// ─── Shared state helpers ───────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
function benchmarkKey(providerKey, modelId) {
|
|
106
|
+
return `${providerKey}/${modelId}`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getResultKey(result) {
|
|
110
|
+
return benchmarkKey(result.providerKey, result.modelId)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getResult(providerKey, modelId) {
|
|
114
|
+
return results.find((r) => r.providerKey === providerKey && r.modelId === modelId) || null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function noteUserActivity() {
|
|
118
|
+
runtime.lastUserActivityAt = Date.now()
|
|
119
|
+
if (runtime.pingMode === 'forced') return
|
|
120
|
+
if (runtime.resumeSpeedOnActivity) setPingMode('speed', 'activity')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function setPingMode(nextMode, source = 'manual') {
|
|
124
|
+
const mode = PING_MODE_INTERVALS[nextMode] ? nextMode : 'normal'
|
|
125
|
+
runtime.pingMode = mode
|
|
126
|
+
runtime.pingModeSource = source
|
|
127
|
+
runtime.activePingInterval = PING_MODE_INTERVALS[mode]
|
|
128
|
+
runtime.speedModeUntil = mode === 'speed' ? Date.now() + SPEED_MODE_DURATION_MS : null
|
|
129
|
+
runtime.resumeSpeedOnActivity = source === 'idle'
|
|
130
|
+
scheduleNextPing()
|
|
131
|
+
broadcastUpdate({ immediate: true })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function cyclePingMode() {
|
|
135
|
+
const idx = PING_MODE_CYCLE.indexOf(runtime.pingMode)
|
|
136
|
+
setPingMode(PING_MODE_CYCLE[(idx + 1) % PING_MODE_CYCLE.length] || 'normal')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function refreshPingMode() {
|
|
140
|
+
const now = Date.now()
|
|
141
|
+
if (runtime.pingMode === 'forced') return
|
|
142
|
+
|
|
143
|
+
if (runtime.speedModeUntil && now >= runtime.speedModeUntil) {
|
|
144
|
+
setPingMode('normal', 'auto')
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (now - runtime.lastUserActivityAt >= IDLE_SLOW_AFTER_MS) {
|
|
149
|
+
if (runtime.pingMode !== 'slow' || runtime.pingModeSource !== 'idle') {
|
|
150
|
+
setPingMode('slow', 'idle')
|
|
151
|
+
} else {
|
|
152
|
+
runtime.resumeSpeedOnActivity = true
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function scheduleNextPing() {
|
|
158
|
+
if (!startedServer?.listening) return
|
|
159
|
+
clearTimeout(pingLoopTimer)
|
|
160
|
+
refreshPingMode()
|
|
161
|
+
const elapsed = Date.now() - runtime.lastPingTime
|
|
162
|
+
const delay = Math.max(0, runtime.activePingInterval - elapsed)
|
|
163
|
+
runtime.nextPingAt = Date.now() + delay
|
|
164
|
+
pingLoopTimer = setTimeout(startPingCycle, delay)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function trimPingHistory(result) {
|
|
168
|
+
if (result.pings.length > MAX_PING_HISTORY) result.pings = result.pings.slice(-MAX_PING_HISTORY)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function updateHealthFromPing(result, pingResult, hasApiKey) {
|
|
172
|
+
const code = String(pingResult.code || 'ERR')
|
|
173
|
+
result.httpCode = code
|
|
174
|
+
|
|
175
|
+
// 📖 Match the TUI: every probe contributes to availability history. Average,
|
|
176
|
+
// 📖 p95, and jitter still ignore non-measurable codes through src/core/utils.js.
|
|
177
|
+
result.pings.push({ ms: pingResult.ms, code })
|
|
178
|
+
trimPingHistory(result)
|
|
179
|
+
|
|
180
|
+
if (code === '200') result.status = 'up'
|
|
181
|
+
else if (code === '000') result.status = 'timeout'
|
|
182
|
+
else if (code === '401' || code === '403') result.status = hasApiKey ? 'auth_error' : 'noauth'
|
|
183
|
+
else result.status = 'down'
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function updateHealthFromBenchmark(result, benchmarkResult) {
|
|
187
|
+
if (!result || !benchmarkResult) return
|
|
188
|
+
if (benchmarkResult.ok) {
|
|
189
|
+
result.status = 'up'
|
|
190
|
+
result.httpCode = '200'
|
|
191
|
+
return
|
|
109
192
|
}
|
|
110
193
|
|
|
111
|
-
|
|
194
|
+
const code = String(benchmarkResult.code || 'ERR')
|
|
195
|
+
if (code === 'TIMEOUT') result.status = 'timeout'
|
|
196
|
+
else if (code === '401' || code === '403') result.status = getApiKey(config, result.providerKey) ? 'auth_error' : 'noauth'
|
|
197
|
+
else if (code !== 'ERR' && code !== 'UNSUPPORTED') result.status = 'down'
|
|
198
|
+
result.httpCode = code
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function pingModel(result) {
|
|
202
|
+
if (!result || result.isPinging || result.cliOnly || !result.url || !isProviderEnabled(config, result.providerKey)) return
|
|
203
|
+
|
|
204
|
+
result.isPinging = true
|
|
205
|
+
runtime.pendingPings += 1
|
|
112
206
|
broadcastUpdate()
|
|
113
|
-
|
|
207
|
+
|
|
208
|
+
const apiKey = getApiKey(config, result.providerKey) ?? null
|
|
209
|
+
try {
|
|
210
|
+
const pingResult = await ping(apiKey, result.modelId, result.providerKey, result.url)
|
|
211
|
+
updateHealthFromPing(result, pingResult, !!apiKey)
|
|
212
|
+
} catch (err) {
|
|
213
|
+
updateHealthFromPing(result, { code: '000', ms: null, error: err?.message || 'Ping failed' }, !!apiKey)
|
|
214
|
+
} finally {
|
|
215
|
+
result.isPinging = false
|
|
216
|
+
runtime.pendingPings = Math.max(0, runtime.pendingPings - 1)
|
|
217
|
+
broadcastUpdate()
|
|
218
|
+
}
|
|
114
219
|
}
|
|
115
220
|
|
|
116
|
-
function
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
221
|
+
function startPingCycle() {
|
|
222
|
+
if (!startedServer?.listening) return
|
|
223
|
+
refreshPingMode()
|
|
224
|
+
|
|
225
|
+
runtime.lastPingTime = Date.now()
|
|
226
|
+
runtime.pingRound += 1
|
|
227
|
+
runtime.nextPingAt = runtime.lastPingTime + runtime.activePingInterval
|
|
228
|
+
|
|
229
|
+
const modelsToPing = results.filter((r) => !r.cliOnly && r.url && isProviderEnabled(config, r.providerKey))
|
|
230
|
+
for (const result of modelsToPing) {
|
|
231
|
+
void pingModel(result)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
broadcastUpdate({ immediate: true })
|
|
235
|
+
scheduleNextPing()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function serializeModel(result) {
|
|
239
|
+
const key = getResultKey(result)
|
|
240
|
+
const avg = getAvg(result)
|
|
241
|
+
const p95 = getP95(result)
|
|
242
|
+
const jitter = getJitter(result)
|
|
243
|
+
const stability = getStabilityScore(result)
|
|
244
|
+
const latest = result.pings.length > 0 ? result.pings[result.pings.length - 1] : null
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
idx: result.idx,
|
|
248
|
+
modelId: result.modelId,
|
|
249
|
+
label: result.label,
|
|
250
|
+
tier: result.tier,
|
|
251
|
+
sweScore: result.sweScore,
|
|
252
|
+
ctx: result.ctx,
|
|
253
|
+
providerKey: result.providerKey,
|
|
254
|
+
origin: result.origin,
|
|
255
|
+
status: result.status,
|
|
256
|
+
httpCode: result.httpCode,
|
|
257
|
+
cliOnly: result.cliOnly,
|
|
258
|
+
zenOnly: result.zenOnly,
|
|
259
|
+
isPinging: result.isPinging,
|
|
260
|
+
avg: Number.isFinite(avg) ? avg : null,
|
|
261
|
+
verdict: getVerdict(result),
|
|
262
|
+
uptime: getUptime(result),
|
|
263
|
+
p95: Number.isFinite(p95) ? p95 : null,
|
|
264
|
+
jitter: Number.isFinite(jitter) ? jitter : null,
|
|
265
|
+
stability,
|
|
266
|
+
latestPing: latest?.ms ?? null,
|
|
267
|
+
latestCode: latest?.code ?? null,
|
|
268
|
+
pingHistory: result.pings.slice(-20).map((p) => ({ ms: p.ms, code: p.code })),
|
|
269
|
+
pingCount: result.pings.length,
|
|
270
|
+
hasApiKey: !!getApiKey(config, result.providerKey),
|
|
271
|
+
benchmarkKey: key,
|
|
272
|
+
isBenchmarking: benchmarkRunning.has(key),
|
|
273
|
+
benchmark: benchmarkResults.get(key) || null,
|
|
124
274
|
}
|
|
125
275
|
}
|
|
126
276
|
|
|
127
277
|
function getModelsPayload() {
|
|
128
|
-
return
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
}))
|
|
278
|
+
return {
|
|
279
|
+
pingMode: runtime.pingMode,
|
|
280
|
+
pingModeSource: runtime.pingModeSource,
|
|
281
|
+
pingInterval: runtime.activePingInterval,
|
|
282
|
+
nextPingAt: runtime.nextPingAt,
|
|
283
|
+
pendingPings: runtime.pendingPings,
|
|
284
|
+
isPinging: runtime.pendingPings > 0,
|
|
285
|
+
pingRound: runtime.pingRound,
|
|
286
|
+
globalBenchmarkRunning: runtime.globalBenchmarkRunning,
|
|
287
|
+
globalBenchmarkTotal: runtime.globalBenchmarkTotal,
|
|
288
|
+
globalBenchmarkCompleted: runtime.globalBenchmarkCompleted,
|
|
289
|
+
models: results.map(serializeModel),
|
|
290
|
+
}
|
|
153
291
|
}
|
|
154
292
|
|
|
155
293
|
function getConfigPayload() {
|
|
156
|
-
// Sanitize — show which providers have keys, but not the actual keys
|
|
157
294
|
const providers = {}
|
|
158
295
|
for (const [key, src] of Object.entries(sources)) {
|
|
159
296
|
const rawKey = getApiKey(config, key)
|
|
@@ -175,7 +312,36 @@ function maskApiKey(key) {
|
|
|
175
312
|
return '••••••••' + key.slice(-4)
|
|
176
313
|
}
|
|
177
314
|
|
|
178
|
-
|
|
315
|
+
function writeSsePayload(res, payload) {
|
|
316
|
+
try {
|
|
317
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`)
|
|
318
|
+
} catch {
|
|
319
|
+
sseClients.delete(res)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function broadcastNow() {
|
|
324
|
+
const payload = getModelsPayload()
|
|
325
|
+
if (io) io.emit('models:update', payload)
|
|
326
|
+
for (const res of [...sseClients]) writeSsePayload(res, payload)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function broadcastUpdate({ immediate = false } = {}) {
|
|
330
|
+
if (immediate) {
|
|
331
|
+
clearTimeout(broadcastTimer)
|
|
332
|
+
broadcastTimer = null
|
|
333
|
+
broadcastNow()
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (broadcastTimer) return
|
|
338
|
+
broadcastTimer = setTimeout(() => {
|
|
339
|
+
broadcastTimer = null
|
|
340
|
+
broadcastNow()
|
|
341
|
+
}, BROADCAST_THROTTLE_MS)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ─── HTTP helpers ───────────────────────────────────────────────────────────
|
|
179
345
|
|
|
180
346
|
const MIME_TYPES = {
|
|
181
347
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -185,6 +351,8 @@ const MIME_TYPES = {
|
|
|
185
351
|
'.svg': 'image/svg+xml',
|
|
186
352
|
'.png': 'image/png',
|
|
187
353
|
'.ico': 'image/x-icon',
|
|
354
|
+
'.webmanifest': 'application/manifest+json',
|
|
355
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
188
356
|
}
|
|
189
357
|
|
|
190
358
|
function serveFile(res, filename, contentType) {
|
|
@@ -201,6 +369,15 @@ function serveFile(res, filename, contentType) {
|
|
|
201
369
|
function serveDistFile(res, pathname) {
|
|
202
370
|
const filePath = join(__dirname, 'dist', pathname === '/' ? 'index.html' : pathname)
|
|
203
371
|
if (!existsSync(filePath)) {
|
|
372
|
+
// 📖 SPA fallback: GETs to non-asset paths return index.html so the React
|
|
373
|
+
// 📖 router can take over. Static assets (favicons, /assets/*, anything
|
|
374
|
+
// 📖 with a known extension) must 404 — never serve HTML for a missing PNG.
|
|
375
|
+
const hasExt = extname(pathname) !== ''
|
|
376
|
+
if (hasExt || pathname.startsWith('/assets/') || pathname.startsWith('/favicons/')) {
|
|
377
|
+
res.writeHead(404)
|
|
378
|
+
res.end('Not Found')
|
|
379
|
+
return
|
|
380
|
+
}
|
|
204
381
|
serveFile(res, 'dist/index.html', 'text/html; charset=utf-8')
|
|
205
382
|
return
|
|
206
383
|
}
|
|
@@ -208,7 +385,10 @@ function serveDistFile(res, pathname) {
|
|
|
208
385
|
const ct = MIME_TYPES[ext] || 'application/octet-stream'
|
|
209
386
|
try {
|
|
210
387
|
const content = readFileSync(filePath)
|
|
211
|
-
res.writeHead(200, {
|
|
388
|
+
res.writeHead(200, {
|
|
389
|
+
'Content-Type': ct,
|
|
390
|
+
'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable',
|
|
391
|
+
})
|
|
212
392
|
res.end(content)
|
|
213
393
|
} catch {
|
|
214
394
|
res.writeHead(404)
|
|
@@ -216,10 +396,112 @@ function serveDistFile(res, pathname) {
|
|
|
216
396
|
}
|
|
217
397
|
}
|
|
218
398
|
|
|
219
|
-
function
|
|
220
|
-
res.
|
|
399
|
+
function sendJson(res, status, payload) {
|
|
400
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
|
|
401
|
+
res.end(JSON.stringify(payload))
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function readJsonBody(req) {
|
|
405
|
+
return new Promise((resolve, reject) => {
|
|
406
|
+
let body = ''
|
|
407
|
+
req.on('data', (chunk) => {
|
|
408
|
+
body += chunk
|
|
409
|
+
if (body.length > BODY_LIMIT_BYTES) {
|
|
410
|
+
reject(new Error('Request body too large'))
|
|
411
|
+
req.destroy()
|
|
412
|
+
}
|
|
413
|
+
})
|
|
414
|
+
req.on('end', () => {
|
|
415
|
+
if (!body.trim()) {
|
|
416
|
+
resolve({})
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
try { resolve(JSON.parse(body)) }
|
|
420
|
+
catch (err) { reject(err) }
|
|
421
|
+
})
|
|
422
|
+
req.on('error', reject)
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function parseVisibleBenchmarkModels(body) {
|
|
427
|
+
const rawModels = Array.isArray(body?.models) ? body.models : null
|
|
428
|
+
if (!rawModels) return results.filter((r) => !r.cliOnly && r.url)
|
|
429
|
+
|
|
430
|
+
const unique = new Map()
|
|
431
|
+
for (const item of rawModels) {
|
|
432
|
+
if (!item || typeof item !== 'object') continue
|
|
433
|
+
const providerKey = typeof item.providerKey === 'string' ? item.providerKey : ''
|
|
434
|
+
const modelId = typeof item.modelId === 'string' ? item.modelId : ''
|
|
435
|
+
const result = getResult(providerKey, modelId)
|
|
436
|
+
if (result && !result.cliOnly && result.url) unique.set(getResultKey(result), result)
|
|
437
|
+
}
|
|
438
|
+
return [...unique.values()]
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function runSingleBenchmark(result) {
|
|
442
|
+
const key = getResultKey(result)
|
|
443
|
+
if (benchmarkRunning.has(key)) return { skipped: true }
|
|
444
|
+
|
|
445
|
+
benchmarkRunning.add(key)
|
|
446
|
+
broadcastUpdate({ immediate: true })
|
|
447
|
+
try {
|
|
448
|
+
const benchmarkResult = await benchmarkModel({
|
|
449
|
+
apiKey: getApiKey(config, result.providerKey) ?? null,
|
|
450
|
+
modelId: result.modelId,
|
|
451
|
+
providerKey: result.providerKey,
|
|
452
|
+
url: result.url,
|
|
453
|
+
timeoutMs: BENCHMARK_TIMEOUT_MS,
|
|
454
|
+
})
|
|
455
|
+
benchmarkResults.set(key, benchmarkResult)
|
|
456
|
+
updateHealthFromBenchmark(result, benchmarkResult)
|
|
457
|
+
return benchmarkResult
|
|
458
|
+
} catch (err) {
|
|
459
|
+
const fallback = {
|
|
460
|
+
ok: false,
|
|
461
|
+
code: 'ERR',
|
|
462
|
+
totalMs: 0,
|
|
463
|
+
error: err?.message || 'Benchmark failed',
|
|
464
|
+
retries: 0,
|
|
465
|
+
}
|
|
466
|
+
benchmarkResults.set(key, fallback)
|
|
467
|
+
updateHealthFromBenchmark(result, fallback)
|
|
468
|
+
return fallback
|
|
469
|
+
} finally {
|
|
470
|
+
benchmarkRunning.delete(key)
|
|
471
|
+
broadcastUpdate({ immediate: true })
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function runWithConcurrency(tasks, concurrency) {
|
|
476
|
+
return new Promise((resolve) => {
|
|
477
|
+
const resultsOut = new Array(tasks.length)
|
|
478
|
+
let nextIndex = 0
|
|
479
|
+
let active = 0
|
|
480
|
+
let completed = 0
|
|
481
|
+
|
|
482
|
+
function startNext() {
|
|
483
|
+
while (active < concurrency && nextIndex < tasks.length) {
|
|
484
|
+
const index = nextIndex++
|
|
485
|
+
active += 1
|
|
486
|
+
Promise.resolve(tasks[index]())
|
|
487
|
+
.then((value) => { resultsOut[index] = value })
|
|
488
|
+
.catch((err) => { resultsOut[index] = { error: err } })
|
|
489
|
+
.finally(() => {
|
|
490
|
+
active -= 1
|
|
491
|
+
completed += 1
|
|
492
|
+
if (completed >= tasks.length) resolve(resultsOut)
|
|
493
|
+
else startNext()
|
|
494
|
+
})
|
|
495
|
+
}
|
|
496
|
+
if (tasks.length === 0) resolve(resultsOut)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
startNext()
|
|
500
|
+
})
|
|
501
|
+
}
|
|
221
502
|
|
|
222
|
-
|
|
503
|
+
async function handleRequest(req, res) {
|
|
504
|
+
res.setHeader('X-FCM-Server', SERVER_SIGNATURE)
|
|
223
505
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
224
506
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
225
507
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
|
@@ -230,111 +512,274 @@ function handleRequest(req, res) {
|
|
|
230
512
|
return
|
|
231
513
|
}
|
|
232
514
|
|
|
233
|
-
const url = new URL(req.url, `http://${req.headers.host}`)
|
|
515
|
+
const url = new URL(req.url, `http://${req.headers.host || `localhost:${DEFAULT_WEB_PORT}`}`)
|
|
234
516
|
|
|
235
|
-
// ─── API: Reveal full key for a provider ───
|
|
236
517
|
const keyMatch = url.pathname.match(/^\/api\/key\/(.+)$/)
|
|
237
518
|
if (keyMatch) {
|
|
238
519
|
const providerKey = decodeURIComponent(keyMatch[1])
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
520
|
+
if (!sources[providerKey]) {
|
|
521
|
+
sendJson(res, 404, { error: 'Unknown provider' })
|
|
522
|
+
return
|
|
523
|
+
}
|
|
524
|
+
sendJson(res, 200, { key: getApiKey(config, providerKey) || null })
|
|
242
525
|
return
|
|
243
526
|
}
|
|
244
527
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
case '/styles.css':
|
|
251
|
-
case '/app.js':
|
|
252
|
-
serveDistFile(res, url.pathname)
|
|
253
|
-
break
|
|
528
|
+
try {
|
|
529
|
+
switch (url.pathname) {
|
|
530
|
+
case '/':
|
|
531
|
+
serveDistFile(res, '/')
|
|
532
|
+
return
|
|
254
533
|
|
|
255
|
-
|
|
256
|
-
|
|
534
|
+
case '/styles.css':
|
|
535
|
+
case '/app.js':
|
|
257
536
|
serveDistFile(res, url.pathname)
|
|
258
|
-
|
|
537
|
+
return
|
|
538
|
+
|
|
539
|
+
case '/api/activity':
|
|
540
|
+
if (req.method !== 'POST') {
|
|
541
|
+
res.writeHead(405)
|
|
542
|
+
res.end('Method Not Allowed')
|
|
543
|
+
return
|
|
544
|
+
}
|
|
545
|
+
noteUserActivity()
|
|
546
|
+
sendJson(res, 200, { ok: true, pingMode: runtime.pingMode })
|
|
547
|
+
return
|
|
548
|
+
|
|
549
|
+
case '/api/ping-mode': {
|
|
550
|
+
if (req.method !== 'POST' && req.method !== 'GET') {
|
|
551
|
+
res.writeHead(405)
|
|
552
|
+
res.end('Method Not Allowed')
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
noteUserActivity()
|
|
556
|
+
const action = url.searchParams.get('action')
|
|
557
|
+
if (action === 'cycle') cyclePingMode()
|
|
558
|
+
else if (PING_MODE_INTERVALS[action]) setPingMode(action, 'manual')
|
|
559
|
+
sendJson(res, 200, {
|
|
560
|
+
pingMode: runtime.pingMode,
|
|
561
|
+
pingModeSource: runtime.pingModeSource,
|
|
562
|
+
interval: runtime.activePingInterval,
|
|
563
|
+
nextPingAt: runtime.nextPingAt,
|
|
564
|
+
})
|
|
565
|
+
return
|
|
259
566
|
}
|
|
260
567
|
|
|
261
|
-
|
|
262
|
-
res
|
|
263
|
-
|
|
264
|
-
|
|
568
|
+
case '/api/ping-timer':
|
|
569
|
+
sendJson(res, 200, {
|
|
570
|
+
nextPingAt: runtime.nextPingAt,
|
|
571
|
+
isPinging: runtime.pendingPings > 0,
|
|
572
|
+
pendingPings: runtime.pendingPings,
|
|
573
|
+
})
|
|
574
|
+
return
|
|
575
|
+
|
|
576
|
+
case '/api/models':
|
|
577
|
+
// 📖 Legacy REST contract: keep returning the flat model array.
|
|
578
|
+
sendJson(res, 200, getModelsPayload().models)
|
|
579
|
+
return
|
|
580
|
+
|
|
581
|
+
case '/api/state':
|
|
582
|
+
sendJson(res, 200, getModelsPayload())
|
|
583
|
+
return
|
|
584
|
+
|
|
585
|
+
case '/api/health':
|
|
586
|
+
sendJson(res, 200, { ok: true, app: SERVER_SIGNATURE })
|
|
587
|
+
return
|
|
588
|
+
|
|
589
|
+
case '/api/config':
|
|
590
|
+
sendJson(res, 200, getConfigPayload())
|
|
591
|
+
return
|
|
592
|
+
|
|
593
|
+
case '/api/events':
|
|
594
|
+
res.writeHead(200, {
|
|
595
|
+
'Content-Type': 'text/event-stream',
|
|
596
|
+
'Cache-Control': 'no-cache',
|
|
597
|
+
Connection: 'keep-alive',
|
|
598
|
+
})
|
|
599
|
+
writeSsePayload(res, getModelsPayload())
|
|
600
|
+
sseClients.add(res)
|
|
601
|
+
req.on('close', () => sseClients.delete(res))
|
|
602
|
+
return
|
|
603
|
+
|
|
604
|
+
case '/api/settings': {
|
|
605
|
+
if (req.method !== 'POST') {
|
|
606
|
+
res.writeHead(405)
|
|
607
|
+
res.end('Method Not Allowed')
|
|
608
|
+
return
|
|
609
|
+
}
|
|
610
|
+
const settings = await readJsonBody(req)
|
|
611
|
+
noteUserActivity()
|
|
612
|
+
if (settings.apiKeys) {
|
|
613
|
+
if (!config.apiKeys) config.apiKeys = {}
|
|
614
|
+
for (const [key, value] of Object.entries(settings.apiKeys)) {
|
|
615
|
+
if (value) config.apiKeys[key] = value
|
|
616
|
+
else delete config.apiKeys[key]
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (settings.providers) {
|
|
620
|
+
if (!config.providers) config.providers = {}
|
|
621
|
+
for (const [key, value] of Object.entries(settings.providers)) {
|
|
622
|
+
if (!config.providers[key]) config.providers[key] = {}
|
|
623
|
+
config.providers[key].enabled = value?.enabled !== false
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
saveConfig(config)
|
|
627
|
+
broadcastUpdate({ immediate: true })
|
|
628
|
+
sendJson(res, 200, { success: true })
|
|
629
|
+
return
|
|
265
630
|
}
|
|
266
631
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
632
|
+
// ── M1: /api/favorites — single source of truth for favorites, shared
|
|
633
|
+
// ── with the TUI through ~/.free-coding-models.json. Read on load,
|
|
634
|
+
// ── write on toggle/reorder/pinnedAndSticky changes.
|
|
635
|
+
case '/api/favorites': {
|
|
636
|
+
ensureFavoritesConfig(config)
|
|
637
|
+
if (req.method === 'GET') {
|
|
638
|
+
sendJson(res, 200, {
|
|
639
|
+
favorites: config.favorites,
|
|
640
|
+
pinnedAndSticky: Boolean(config.settings?.favoritesPinnedAndSticky),
|
|
641
|
+
})
|
|
642
|
+
return
|
|
643
|
+
}
|
|
644
|
+
if (req.method !== 'POST') {
|
|
645
|
+
res.writeHead(405)
|
|
646
|
+
res.end('Method Not Allowed')
|
|
647
|
+
return
|
|
648
|
+
}
|
|
649
|
+
const body = await readJsonBody(req)
|
|
650
|
+
noteUserActivity()
|
|
651
|
+
|
|
652
|
+
if (Array.isArray(body.favorites)) {
|
|
653
|
+
// 📖 Validate each entry is a non-empty string. Anything else is dropped
|
|
654
|
+
// 📖 silently so a partial / malformed payload never breaks the config.
|
|
655
|
+
const cleaned = body.favorites.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
|
|
656
|
+
config.favorites = Array.from(new Set(cleaned))
|
|
657
|
+
}
|
|
658
|
+
if (typeof body.pinnedAndSticky === 'boolean') {
|
|
659
|
+
if (!config.settings || typeof config.settings !== 'object') config.settings = {}
|
|
660
|
+
config.settings.favoritesPinnedAndSticky = body.pinnedAndSticky
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const saveResult = saveConfig(config, { replaceFavorites: true })
|
|
664
|
+
if (!saveResult.success) {
|
|
665
|
+
sendJson(res, 500, { success: false, error: saveResult.error || 'Failed to persist favorites' })
|
|
666
|
+
return
|
|
667
|
+
}
|
|
668
|
+
sendJson(res, 200, {
|
|
669
|
+
success: true,
|
|
670
|
+
favorites: config.favorites,
|
|
671
|
+
pinnedAndSticky: Boolean(config.settings?.favoritesPinnedAndSticky),
|
|
672
|
+
})
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
case '/api/benchmark': {
|
|
677
|
+
if (req.method !== 'POST') {
|
|
678
|
+
res.writeHead(405)
|
|
679
|
+
res.end('Method Not Allowed')
|
|
680
|
+
return
|
|
681
|
+
}
|
|
682
|
+
const body = await readJsonBody(req)
|
|
683
|
+
const result = getResult(body.providerKey, body.modelId)
|
|
684
|
+
if (!result) {
|
|
685
|
+
sendJson(res, 404, { error: 'Model not found' })
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
const key = getResultKey(result)
|
|
689
|
+
if (benchmarkRunning.has(key)) {
|
|
690
|
+
sendJson(res, 409, { error: 'Benchmark already in progress for this model' })
|
|
691
|
+
return
|
|
692
|
+
}
|
|
693
|
+
noteUserActivity()
|
|
694
|
+
const benchmarkResult = await runSingleBenchmark(result)
|
|
695
|
+
sendJson(res, 200, benchmarkResult)
|
|
696
|
+
return
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
case '/api/global-benchmark': {
|
|
700
|
+
if (req.method === 'GET') {
|
|
701
|
+
sendJson(res, 200, {
|
|
702
|
+
running: runtime.globalBenchmarkRunning,
|
|
703
|
+
total: runtime.globalBenchmarkTotal,
|
|
704
|
+
completed: runtime.globalBenchmarkCompleted,
|
|
705
|
+
})
|
|
706
|
+
return
|
|
707
|
+
}
|
|
708
|
+
if (req.method !== 'POST') {
|
|
709
|
+
res.writeHead(405)
|
|
710
|
+
res.end('Method Not Allowed')
|
|
711
|
+
return
|
|
712
|
+
}
|
|
713
|
+
if (runtime.globalBenchmarkRunning) {
|
|
714
|
+
sendJson(res, 409, { error: 'Global benchmark already running' })
|
|
715
|
+
return
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const body = await readJsonBody(req)
|
|
719
|
+
noteUserActivity()
|
|
720
|
+
const healthPriority = { up: 0, pending: 1, timeout: 2, noauth: 3, auth_error: 4, down: 5 }
|
|
721
|
+
const modelsToBenchmark = parseVisibleBenchmarkModels(body)
|
|
722
|
+
.sort((a, b) => {
|
|
723
|
+
const hpA = healthPriority[a.status] ?? 6
|
|
724
|
+
const hpB = healthPriority[b.status] ?? 6
|
|
725
|
+
if (hpA !== hpB) return hpA - hpB
|
|
726
|
+
const pingA = typeof a.pings?.[a.pings.length - 1]?.ms === 'number' ? a.pings[a.pings.length - 1].ms : 99999
|
|
727
|
+
const pingB = typeof b.pings?.[b.pings.length - 1]?.ms === 'number' ? b.pings[b.pings.length - 1].ms : 99999
|
|
728
|
+
return pingA - pingB
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
runtime.globalBenchmarkRunning = true
|
|
732
|
+
runtime.globalBenchmarkTotal = modelsToBenchmark.length
|
|
733
|
+
runtime.globalBenchmarkCompleted = 0
|
|
734
|
+
broadcastUpdate({ immediate: true })
|
|
735
|
+
|
|
736
|
+
const tasks = modelsToBenchmark.map((model) => async () => {
|
|
299
737
|
try {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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 }))
|
|
738
|
+
return await runSingleBenchmark(model)
|
|
739
|
+
} finally {
|
|
740
|
+
runtime.globalBenchmarkCompleted += 1
|
|
741
|
+
broadcastUpdate({ immediate: true })
|
|
326
742
|
}
|
|
327
743
|
})
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
744
|
+
|
|
745
|
+
void runWithConcurrency(tasks, GLOBAL_BENCHMARK_CONCURRENCY).finally(() => {
|
|
746
|
+
runtime.globalBenchmarkRunning = false
|
|
747
|
+
runtime.globalBenchmarkTotal = 0
|
|
748
|
+
runtime.globalBenchmarkCompleted = 0
|
|
749
|
+
broadcastUpdate({ immediate: true })
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
sendJson(res, 202, { started: true, total: modelsToBenchmark.length })
|
|
753
|
+
return
|
|
331
754
|
}
|
|
332
|
-
|
|
755
|
+
|
|
756
|
+
default:
|
|
757
|
+
// 📖 Serve Vite's /assets/* bundle, and our static favicon set that
|
|
758
|
+
// 📖 Vite copies verbatim from web/public/ into web/dist/. The legacy
|
|
759
|
+
// 📖 /favicon.ico lives at web/public/favicon.ico (root of public/).
|
|
760
|
+
if (
|
|
761
|
+
url.pathname.startsWith('/assets/')
|
|
762
|
+
|| url.pathname.startsWith('/favicons/')
|
|
763
|
+
|| url.pathname === '/favicon.ico'
|
|
764
|
+
|| url.pathname.endsWith('.js')
|
|
765
|
+
|| url.pathname.endsWith('.css')
|
|
766
|
+
|| url.pathname.endsWith('.png')
|
|
767
|
+
|| url.pathname.endsWith('.svg')
|
|
768
|
+
|| url.pathname.endsWith('.webmanifest')
|
|
769
|
+
|| url.pathname.endsWith('.xml')
|
|
770
|
+
|| url.pathname.endsWith('.ico')
|
|
771
|
+
) {
|
|
772
|
+
serveDistFile(res, url.pathname)
|
|
773
|
+
return
|
|
774
|
+
}
|
|
775
|
+
res.writeHead(404)
|
|
776
|
+
res.end('Not Found')
|
|
777
|
+
}
|
|
778
|
+
} catch (err) {
|
|
779
|
+
if (!res.writableEnded) sendJson(res, 500, { error: err?.message || 'Internal server error' })
|
|
333
780
|
}
|
|
334
781
|
}
|
|
335
782
|
|
|
336
|
-
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
337
|
-
|
|
338
783
|
function checkPortInUse(port) {
|
|
339
784
|
return new Promise((resolve) => {
|
|
340
785
|
const s = createServer()
|
|
@@ -352,8 +797,6 @@ export async function inspectExistingWebServer(port) {
|
|
|
352
797
|
const timeout = setTimeout(() => controller.abort(), 750)
|
|
353
798
|
|
|
354
799
|
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
800
|
const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
|
358
801
|
signal: controller.signal,
|
|
359
802
|
headers: { Accept: 'application/json' },
|
|
@@ -387,16 +830,13 @@ function openBrowser(url) {
|
|
|
387
830
|
})
|
|
388
831
|
}
|
|
389
832
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
export async function startWebServer(port = 3333, { open = true, startPingLoop = true } = {}) {
|
|
833
|
+
export async function startWebServer(port = DEFAULT_WEB_PORT, { open = true, startPingLoop = true } = {}) {
|
|
393
834
|
const portStatus = await inspectExistingWebServer(port)
|
|
394
835
|
|
|
395
836
|
if (portStatus.inUse && portStatus.isFcm) {
|
|
396
837
|
const url = `http://localhost:${port}`
|
|
397
|
-
|
|
398
838
|
console.log()
|
|
399
|
-
console.log(
|
|
839
|
+
console.log(' ⚡ free-coding-models Web Dashboard already running')
|
|
400
840
|
console.log(` 🌐 ${url}`)
|
|
401
841
|
console.log()
|
|
402
842
|
if (open) openBrowser(url)
|
|
@@ -413,31 +853,56 @@ export async function startWebServer(port = 3333, { open = true, startPingLoop =
|
|
|
413
853
|
}
|
|
414
854
|
|
|
415
855
|
const url = `http://localhost:${resolvedPort}`
|
|
856
|
+
const server = createServer((req, res) => void handleRequest(req, res))
|
|
857
|
+
startedServer = server
|
|
416
858
|
|
|
417
|
-
|
|
418
|
-
|
|
859
|
+
io = new Server(server, {
|
|
860
|
+
cors: { origin: '*', methods: ['GET', 'POST'] },
|
|
861
|
+
transports: ['websocket', 'polling'],
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
io.on('connection', (socket) => {
|
|
865
|
+
noteUserActivity()
|
|
866
|
+
socket.emit('models:update', getModelsPayload())
|
|
867
|
+
socket.on('client:activity', () => noteUserActivity())
|
|
868
|
+
socket.on('models:refresh', () => socket.emit('models:update', getModelsPayload()))
|
|
869
|
+
})
|
|
419
870
|
|
|
420
871
|
server.listen(resolvedPort, () => {
|
|
421
872
|
console.log()
|
|
422
|
-
console.log(
|
|
873
|
+
console.log(' ⚡ free-coding-models Web Dashboard')
|
|
423
874
|
console.log(` 🌐 ${url}`)
|
|
424
|
-
console.log(` 📊 Monitoring ${results.filter(r => !r.cliOnly).length} models across ${Object.keys(sources).length} providers`)
|
|
875
|
+
console.log(` 📊 Monitoring ${results.filter((r) => !r.cliOnly).length} models across ${Object.keys(sources).length} providers`)
|
|
425
876
|
console.log()
|
|
426
|
-
console.log(
|
|
877
|
+
console.log(' Press Ctrl+C to stop')
|
|
427
878
|
console.log()
|
|
879
|
+
if (startPingLoop && !pingLoopTimer) {
|
|
880
|
+
runtime.lastPingTime = Date.now()
|
|
881
|
+
runtime.nextPingAt = runtime.lastPingTime + runtime.activePingInterval
|
|
882
|
+
startPingCycle()
|
|
883
|
+
}
|
|
428
884
|
if (open) openBrowser(url)
|
|
429
885
|
})
|
|
430
886
|
|
|
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
887
|
server.on('close', () => {
|
|
439
|
-
|
|
888
|
+
clearTimeout(pingLoopTimer)
|
|
889
|
+
clearTimeout(broadcastTimer)
|
|
890
|
+
clearInterval(heartbeatTimer)
|
|
891
|
+
for (const res of [...sseClients]) {
|
|
892
|
+
try { res.end() } catch {}
|
|
893
|
+
}
|
|
894
|
+
sseClients.clear()
|
|
895
|
+
io?.close()
|
|
896
|
+
io = null
|
|
897
|
+
if (startedServer === server) startedServer = null
|
|
440
898
|
})
|
|
441
899
|
|
|
900
|
+
heartbeatTimer = setInterval(() => {
|
|
901
|
+
refreshPingMode()
|
|
902
|
+
for (const res of [...sseClients]) {
|
|
903
|
+
try { res.write(': heartbeat\n\n') } catch { sseClients.delete(res) }
|
|
904
|
+
}
|
|
905
|
+
}, 15_000)
|
|
906
|
+
|
|
442
907
|
return server
|
|
443
908
|
}
|