free-coding-models 0.3.35 → 0.3.36

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/web/index.html ADDED
@@ -0,0 +1,318 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>free-coding-models — Live Dashboard</title>
7
+ <meta name="description" content="Find the fastest free coding LLM model in seconds. Live dashboard monitoring 230+ models across 24 providers.">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
11
+ <link rel="stylesheet" href="/styles.css">
12
+ </head>
13
+ <body>
14
+ <!-- ─── Background Effects ─── -->
15
+ <div class="bg-grid"></div>
16
+ <div class="bg-glow bg-glow--1"></div>
17
+ <div class="bg-glow bg-glow--2"></div>
18
+ <div class="bg-glow bg-glow--3"></div>
19
+
20
+ <!-- ─── Toast Container ─── -->
21
+ <div class="toast-container" id="toast-container"></div>
22
+
23
+ <!-- ─── Sidebar ─── -->
24
+ <aside class="sidebar" id="sidebar">
25
+ <div class="sidebar__logo">
26
+ <span class="sidebar__logo-icon">⚡</span>
27
+ <span class="sidebar__logo-text">FCM</span>
28
+ </div>
29
+ <nav class="sidebar__nav">
30
+ <button class="sidebar__nav-item sidebar__nav-item--active" data-view="dashboard" id="nav-dashboard" title="Dashboard">
31
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
32
+ <span class="sidebar__nav-label">Dashboard</span>
33
+ </button>
34
+ <button class="sidebar__nav-item" data-view="settings" id="nav-settings" title="Settings">
35
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
36
+ <span class="sidebar__nav-label">Settings</span>
37
+ </button>
38
+ <button class="sidebar__nav-item" data-view="analytics" id="nav-analytics" title="Analytics">
39
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
40
+ <span class="sidebar__nav-label">Analytics</span>
41
+ </button>
42
+ </nav>
43
+ <div class="sidebar__bottom">
44
+ <button class="sidebar__nav-item" id="sidebar-theme-toggle" title="Toggle Theme">
45
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
46
+ <span class="sidebar__nav-label">Theme</span>
47
+ </button>
48
+ </div>
49
+ </aside>
50
+
51
+ <!-- ─── Main Content ─── -->
52
+ <div class="app-content" id="app-content">
53
+
54
+ <!-- ═══════ DASHBOARD VIEW ═══════ -->
55
+ <div class="view" id="view-dashboard">
56
+
57
+ <!-- Header -->
58
+ <header class="header" id="header">
59
+ <div class="header__left">
60
+ <button class="btn btn--icon sidebar-toggle" id="sidebar-toggle" title="Toggle Sidebar">
61
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
62
+ </button>
63
+ <div class="logo">
64
+ <span class="logo__icon">⚡</span>
65
+ <span class="logo__text">free-coding-models</span>
66
+ </div>
67
+ <span class="header__version" id="version-badge">v0.3.35</span>
68
+ </div>
69
+ <div class="header__center">
70
+ <div class="search-bar" id="search-bar">
71
+ <svg class="search-bar__icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
72
+ <input type="text" class="search-bar__input" id="search-input" placeholder="Search models, providers, tiers..." autocomplete="off">
73
+ <kbd class="search-bar__kbd">Ctrl+K</kbd>
74
+ </div>
75
+ </div>
76
+ <div class="header__right">
77
+ <button class="btn btn--icon" id="theme-toggle" title="Toggle theme">
78
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
79
+ </button>
80
+ <button class="btn btn--icon" id="export-btn" title="Export Data">
81
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
82
+ </button>
83
+ <button class="btn btn--primary" id="settings-btn">
84
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
85
+ Settings
86
+ </button>
87
+ </div>
88
+ </header>
89
+
90
+ <!-- Stats Bar -->
91
+ <section class="stats-bar" id="stats-bar">
92
+ <div class="stat-card" id="stat-total">
93
+ <div class="stat-card__icon">📊</div>
94
+ <div class="stat-card__body">
95
+ <div class="stat-card__value" id="stat-total-value">0</div>
96
+ <div class="stat-card__label">Total Models</div>
97
+ </div>
98
+ </div>
99
+ <div class="stat-card" id="stat-online">
100
+ <div class="stat-card__icon">🟢</div>
101
+ <div class="stat-card__body">
102
+ <div class="stat-card__value" id="stat-online-value">0</div>
103
+ <div class="stat-card__label">Online</div>
104
+ </div>
105
+ </div>
106
+ <div class="stat-card" id="stat-avg">
107
+ <div class="stat-card__icon">⚡</div>
108
+ <div class="stat-card__body">
109
+ <div class="stat-card__value" id="stat-avg-value">—</div>
110
+ <div class="stat-card__label">Avg Latency</div>
111
+ </div>
112
+ </div>
113
+ <div class="stat-card" id="stat-best">
114
+ <div class="stat-card__icon">🏆</div>
115
+ <div class="stat-card__body">
116
+ <div class="stat-card__value" id="stat-best-value">—</div>
117
+ <div class="stat-card__label">Fastest Model</div>
118
+ </div>
119
+ </div>
120
+ <div class="stat-card" id="stat-providers">
121
+ <div class="stat-card__icon">🌐</div>
122
+ <div class="stat-card__body">
123
+ <div class="stat-card__value" id="stat-providers-value">0</div>
124
+ <div class="stat-card__label">Providers</div>
125
+ </div>
126
+ </div>
127
+ </section>
128
+
129
+ <!-- Filters -->
130
+ <section class="filters" id="filters">
131
+ <div class="filters__group">
132
+ <label class="filter-label">Tier</label>
133
+ <div class="tier-filters" id="tier-filters">
134
+ <button class="tier-btn tier-btn--active" data-tier="all">All</button>
135
+ <button class="tier-btn" data-tier="S+">S+</button>
136
+ <button class="tier-btn" data-tier="S">S</button>
137
+ <button class="tier-btn" data-tier="A+">A+</button>
138
+ <button class="tier-btn" data-tier="A">A</button>
139
+ <button class="tier-btn" data-tier="A-">A-</button>
140
+ <button class="tier-btn" data-tier="B+">B+</button>
141
+ <button class="tier-btn" data-tier="B">B</button>
142
+ <button class="tier-btn" data-tier="C">C</button>
143
+ </div>
144
+ </div>
145
+ <div class="filters__group">
146
+ <label class="filter-label">Status</label>
147
+ <div class="status-filters" id="status-filters">
148
+ <button class="status-btn status-btn--active" data-status="all">All</button>
149
+ <button class="status-btn" data-status="up">Online</button>
150
+ <button class="status-btn" data-status="down">Offline</button>
151
+ <button class="status-btn" data-status="pending">Pending</button>
152
+ </div>
153
+ </div>
154
+ <div class="filters__group">
155
+ <label class="filter-label">Provider</label>
156
+ <select class="provider-select" id="provider-filter">
157
+ <option value="all">All Providers</option>
158
+ </select>
159
+ </div>
160
+ <div class="filters__spacer"></div>
161
+ <div class="filters__group">
162
+ <div class="live-indicator" id="live-indicator">
163
+ <span class="live-dot"></span>
164
+ <span class="live-text">LIVE</span>
165
+ </div>
166
+ </div>
167
+ </section>
168
+
169
+ <!-- Main Table -->
170
+ <main class="main" id="main">
171
+ <div class="table-container" id="table-container">
172
+ <table class="models-table" id="models-table">
173
+ <thead>
174
+ <tr>
175
+ <th class="th--rank sortable" data-sort="idx">#</th>
176
+ <th class="th--tier sortable" data-sort="tier">Tier</th>
177
+ <th class="th--model sortable" data-sort="label">Model</th>
178
+ <th class="th--provider sortable" data-sort="origin">Provider</th>
179
+ <th class="th--swe sortable" data-sort="sweScore">SWE %</th>
180
+ <th class="th--ctx sortable" data-sort="ctx">Context</th>
181
+ <th class="th--ping sortable" data-sort="latestPing">Ping</th>
182
+ <th class="th--avg sortable" data-sort="avg">Avg</th>
183
+ <th class="th--stability sortable" data-sort="stability">Stability</th>
184
+ <th class="th--verdict sortable" data-sort="verdict">Verdict</th>
185
+ <th class="th--uptime sortable" data-sort="uptime">Uptime</th>
186
+ <th class="th--sparkline">Trend</th>
187
+ </tr>
188
+ </thead>
189
+ <tbody id="table-body">
190
+ <tr class="loading-row">
191
+ <td colspan="12">
192
+ <div class="loading-spinner">
193
+ <div class="spinner"></div>
194
+ <span>Connecting to ping engine...</span>
195
+ </div>
196
+ </td>
197
+ </tr>
198
+ </tbody>
199
+ </table>
200
+ </div>
201
+ </main>
202
+ </div>
203
+
204
+ <!-- ═══════ SETTINGS VIEW ═══════ -->
205
+ <div class="view view--hidden" id="view-settings">
206
+ <div class="settings-page">
207
+ <div class="settings-page__header">
208
+ <h1 class="settings-page__title">⚙️ Provider Settings</h1>
209
+ <p class="settings-page__subtitle">Manage your API keys and provider configurations. Keys are stored locally in <code>~/.free-coding-models.json</code></p>
210
+ </div>
211
+
212
+ <!-- Settings Toolbar -->
213
+ <div class="settings-toolbar" id="settings-toolbar">
214
+ <div class="settings-toolbar__search">
215
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
216
+ <input type="text" id="settings-search" placeholder="Search providers..." autocomplete="off">
217
+ </div>
218
+ <div class="settings-toolbar__actions">
219
+ <button class="btn" id="settings-expand-all">Expand All</button>
220
+ <button class="btn" id="settings-collapse-all">Collapse All</button>
221
+ </div>
222
+ </div>
223
+
224
+ <!-- Provider Cards Container -->
225
+ <div class="settings-providers" id="settings-providers">
226
+ <!-- Populated by JS -->
227
+ </div>
228
+ </div>
229
+ </div>
230
+
231
+ <!-- ═══════ ANALYTICS VIEW ═══════ -->
232
+ <div class="view view--hidden" id="view-analytics">
233
+ <div class="analytics-page">
234
+ <div class="analytics-page__header">
235
+ <h1 class="analytics-page__title">📊 Analytics</h1>
236
+ <p class="analytics-page__subtitle">Real-time insights across all providers and models</p>
237
+ </div>
238
+ <div class="analytics-grid" id="analytics-grid">
239
+ <!-- Provider Health Overview -->
240
+ <div class="analytics-card analytics-card--wide" id="analytics-provider-health">
241
+ <h3 class="analytics-card__title">Provider Health Overview</h3>
242
+ <div class="analytics-card__body" id="provider-health-body">
243
+ <!-- Populated by JS -->
244
+ </div>
245
+ </div>
246
+ <!-- Top 10 Fastest -->
247
+ <div class="analytics-card" id="analytics-leaderboard">
248
+ <h3 class="analytics-card__title">🏆 Fastest Models</h3>
249
+ <div class="analytics-card__body" id="leaderboard-body">
250
+ <!-- Populated by JS -->
251
+ </div>
252
+ </div>
253
+ <!-- Tier Distribution -->
254
+ <div class="analytics-card" id="analytics-tier-dist">
255
+ <h3 class="analytics-card__title">Tier Distribution</h3>
256
+ <div class="analytics-card__body" id="tier-dist-body">
257
+ <!-- Populated by JS -->
258
+ </div>
259
+ </div>
260
+ </div>
261
+ </div>
262
+ </div>
263
+
264
+ <!-- ─── Footer ─── -->
265
+ <footer class="footer">
266
+ <div class="footer__left">
267
+ <span>Made with ❤️ by <a href="https://vavanessa.dev" target="_blank">Vava-Nessa</a></span>
268
+ </div>
269
+ <div class="footer__right">
270
+ <a href="https://github.com/vava-nessa/free-coding-models" target="_blank">GitHub</a>
271
+ <a href="https://discord.gg/ZTNFHvvCkU" target="_blank">Discord</a>
272
+ </div>
273
+ </footer>
274
+ </div>
275
+
276
+ <!-- ─── Model Detail Panel ─── -->
277
+ <div class="detail-panel" id="detail-panel" hidden>
278
+ <div class="detail-panel__header">
279
+ <h3 class="detail-panel__title" id="detail-title">Model Details</h3>
280
+ <button class="detail-panel__close" id="detail-close">&times;</button>
281
+ </div>
282
+ <div class="detail-panel__body" id="detail-body">
283
+ <!-- Populated by JS -->
284
+ </div>
285
+ </div>
286
+
287
+ <!-- ─── Export Modal ─── -->
288
+ <div class="modal-overlay" id="export-modal" hidden>
289
+ <div class="modal">
290
+ <div class="modal__header">
291
+ <h2 class="modal__title">📤 Export Data</h2>
292
+ <button class="modal__close" id="export-close">&times;</button>
293
+ </div>
294
+ <div class="modal__body">
295
+ <div class="export-options">
296
+ <button class="export-option" id="export-json">
297
+ <span class="export-option__icon">{ }</span>
298
+ <span class="export-option__label">Export as JSON</span>
299
+ <span class="export-option__desc">Full model data with all metrics</span>
300
+ </button>
301
+ <button class="export-option" id="export-csv">
302
+ <span class="export-option__icon">📊</span>
303
+ <span class="export-option__label">Export as CSV</span>
304
+ <span class="export-option__desc">Spreadsheet-compatible format</span>
305
+ </button>
306
+ <button class="export-option" id="export-clipboard">
307
+ <span class="export-option__icon">📋</span>
308
+ <span class="export-option__label">Copy to Clipboard</span>
309
+ <span class="export-option__desc">Copy model summary as text</span>
310
+ </button>
311
+ </div>
312
+ </div>
313
+ </div>
314
+ </div>
315
+
316
+ <script src="/app.js" type="module"></script>
317
+ </body>
318
+ </html>
package/web/server.js ADDED
@@ -0,0 +1,317 @@
1
+ /**
2
+ * @file web/server.js
3
+ * @description HTTP server for the free-coding-models Web Dashboard V2.
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.
8
+ *
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/config → Current config (sanitized — masked keys)
15
+ * GET /api/key/:prov → Reveal a provider's full API key
16
+ * GET /api/events → SSE stream of live ping results
17
+ * POST /api/settings → Update API keys / provider toggles
18
+ */
19
+
20
+ import { createServer } from 'node:http'
21
+ import { readFileSync } from 'node:fs'
22
+ import { join, dirname } from 'node:path'
23
+ import { fileURLToPath } from 'node:url'
24
+
25
+ import { sources, MODELS } from '../sources.js'
26
+ import { loadConfig, getApiKey, saveConfig, isProviderEnabled } from '../src/config.js'
27
+ import { ping } from '../src/ping.js'
28
+ import {
29
+ getAvg, getVerdict, getUptime, getP95, getJitter,
30
+ getStabilityScore, TIER_ORDER
31
+ } from '../src/utils.js'
32
+
33
+ const __dirname = dirname(fileURLToPath(import.meta.url))
34
+
35
+ // ─── State ───────────────────────────────────────────────────────────────────
36
+
37
+ let config = loadConfig()
38
+
39
+ // Build results array from MODELS (same shape as the TUI)
40
+ const results = MODELS.map(([modelId, label, tier, sweScore, ctx, providerKey], idx) => ({
41
+ idx: idx + 1,
42
+ modelId,
43
+ label,
44
+ tier,
45
+ sweScore,
46
+ ctx,
47
+ providerKey,
48
+ status: 'pending',
49
+ pings: [],
50
+ httpCode: null,
51
+ origin: sources[providerKey]?.name || providerKey,
52
+ url: sources[providerKey]?.url || null,
53
+ cliOnly: sources[providerKey]?.cliOnly || false,
54
+ zenOnly: sources[providerKey]?.zenOnly || false,
55
+ }))
56
+
57
+ // SSE clients
58
+ const sseClients = new Set()
59
+
60
+ // ─── Ping Loop ───────────────────────────────────────────────────────────────
61
+ // Uses recursive setTimeout (not setInterval) to prevent overlapping rounds.
62
+ // Each new round starts only after the previous one completes.
63
+
64
+ let pingRound = 0
65
+ let pingLoopRunning = false
66
+
67
+ async function pingAllModels() {
68
+ if (pingLoopRunning) return // guard against overlapping calls
69
+ pingLoopRunning = true
70
+ pingRound++
71
+ const batchSize = 30
72
+ // P2 fix: honor provider enabled flags — skip disabled providers
73
+ const modelsToPing = results.filter(r =>
74
+ !r.cliOnly && r.url && isProviderEnabled(config, r.providerKey)
75
+ )
76
+
77
+ for (let i = 0; i < modelsToPing.length; i += batchSize) {
78
+ const batch = modelsToPing.slice(i, i + batchSize)
79
+ const promises = batch.map(async (r) => {
80
+ const apiKey = getApiKey(config, r.providerKey)
81
+ try {
82
+ const result = await ping(apiKey, r.modelId, r.providerKey, r.url)
83
+ r.httpCode = result.code
84
+ if (result.code === '200') {
85
+ r.status = 'up'
86
+ r.pings.push({ ms: result.ms, code: result.code })
87
+ } else if (result.code === '401') {
88
+ r.status = 'up'
89
+ r.pings.push({ ms: result.ms, code: result.code })
90
+ } else if (result.code === '429') {
91
+ r.status = 'up'
92
+ r.pings.push({ ms: result.ms, code: result.code })
93
+ } else if (result.code === '000') {
94
+ r.status = 'timeout'
95
+ } else {
96
+ r.status = 'down'
97
+ r.pings.push({ ms: result.ms, code: result.code })
98
+ }
99
+ // Keep only last 60 pings
100
+ if (r.pings.length > 60) r.pings = r.pings.slice(-60)
101
+ } catch {
102
+ r.status = 'timeout'
103
+ }
104
+ })
105
+ await Promise.all(promises)
106
+ }
107
+
108
+ // Broadcast update to all SSE clients
109
+ broadcastUpdate()
110
+ pingLoopRunning = false
111
+ }
112
+
113
+ function broadcastUpdate() {
114
+ const data = JSON.stringify(getModelsPayload())
115
+ for (const client of sseClients) {
116
+ try {
117
+ client.write(`data: ${data}\n\n`)
118
+ } catch {
119
+ sseClients.delete(client)
120
+ }
121
+ }
122
+ }
123
+
124
+ function getModelsPayload() {
125
+ return results.map(r => ({
126
+ idx: r.idx,
127
+ modelId: r.modelId,
128
+ label: r.label,
129
+ tier: r.tier,
130
+ sweScore: r.sweScore,
131
+ ctx: r.ctx,
132
+ providerKey: r.providerKey,
133
+ origin: r.origin,
134
+ status: r.status,
135
+ httpCode: r.httpCode,
136
+ cliOnly: r.cliOnly,
137
+ zenOnly: r.zenOnly,
138
+ avg: getAvg(r),
139
+ verdict: getVerdict(r),
140
+ uptime: getUptime(r),
141
+ p95: getP95(r),
142
+ jitter: getJitter(r),
143
+ stability: getStabilityScore(r),
144
+ latestPing: r.pings.length > 0 ? r.pings[r.pings.length - 1].ms : null,
145
+ latestCode: r.pings.length > 0 ? r.pings[r.pings.length - 1].code : null,
146
+ pingHistory: r.pings.slice(-20).map(p => ({ ms: p.ms, code: p.code })),
147
+ pingCount: r.pings.length,
148
+ hasApiKey: !!getApiKey(config, r.providerKey),
149
+ }))
150
+ }
151
+
152
+ function getConfigPayload() {
153
+ // Sanitize — show which providers have keys, but not the actual keys
154
+ const providers = {}
155
+ for (const [key, src] of Object.entries(sources)) {
156
+ const rawKey = getApiKey(config, key)
157
+ providers[key] = {
158
+ name: src.name,
159
+ hasKey: !!rawKey,
160
+ maskedKey: rawKey ? maskApiKey(rawKey) : null,
161
+ enabled: isProviderEnabled(config, key),
162
+ modelCount: src.models?.length || 0,
163
+ cliOnly: src.cliOnly || false,
164
+ }
165
+ }
166
+ return { providers, totalModels: MODELS.length }
167
+ }
168
+
169
+ function maskApiKey(key) {
170
+ if (!key || typeof key !== 'string') return ''
171
+ if (key.length <= 8) return '••••••••'
172
+ return '••••••••' + key.slice(-4)
173
+ }
174
+
175
+ // ─── HTTP Server ─────────────────────────────────────────────────────────────
176
+
177
+ function serveFile(res, filename, contentType) {
178
+ try {
179
+ const content = readFileSync(join(__dirname, filename), 'utf8')
180
+ res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache' })
181
+ res.end(content)
182
+ } catch {
183
+ res.writeHead(404)
184
+ res.end('Not Found')
185
+ }
186
+ }
187
+
188
+ function handleRequest(req, res) {
189
+ // CORS for local dev
190
+ res.setHeader('Access-Control-Allow-Origin', '*')
191
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
192
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
193
+
194
+ if (req.method === 'OPTIONS') {
195
+ res.writeHead(204)
196
+ res.end()
197
+ return
198
+ }
199
+
200
+ const url = new URL(req.url, `http://${req.headers.host}`)
201
+
202
+ // ─── API: Reveal full key for a provider ───
203
+ const keyMatch = url.pathname.match(/^\/api\/key\/(.+)$/)
204
+ if (keyMatch) {
205
+ const providerKey = decodeURIComponent(keyMatch[1])
206
+ const rawKey = getApiKey(config, providerKey)
207
+ res.writeHead(200, { 'Content-Type': 'application/json' })
208
+ res.end(JSON.stringify({ key: rawKey || null }))
209
+ return
210
+ }
211
+
212
+ switch (url.pathname) {
213
+ case '/':
214
+ serveFile(res, 'index.html', 'text/html; charset=utf-8')
215
+ break
216
+
217
+ case '/styles.css':
218
+ serveFile(res, 'styles.css', 'text/css; charset=utf-8')
219
+ break
220
+
221
+ case '/app.js':
222
+ serveFile(res, 'app.js', 'application/javascript; charset=utf-8')
223
+ break
224
+
225
+ case '/api/models':
226
+ res.writeHead(200, { 'Content-Type': 'application/json' })
227
+ res.end(JSON.stringify(getModelsPayload()))
228
+ break
229
+
230
+ case '/api/config':
231
+ res.writeHead(200, { 'Content-Type': 'application/json' })
232
+ res.end(JSON.stringify(getConfigPayload()))
233
+ break
234
+
235
+ case '/api/events':
236
+ // SSE endpoint
237
+ res.writeHead(200, {
238
+ 'Content-Type': 'text/event-stream',
239
+ 'Cache-Control': 'no-cache',
240
+ 'Connection': 'keep-alive',
241
+ })
242
+ res.write(`data: ${JSON.stringify(getModelsPayload())}\n\n`)
243
+ sseClients.add(res)
244
+ req.on('close', () => sseClients.delete(res))
245
+ break
246
+
247
+ case '/api/settings':
248
+ if (req.method === 'POST') {
249
+ let body = ''
250
+ req.on('data', chunk => body += chunk)
251
+ req.on('end', () => {
252
+ try {
253
+ const settings = JSON.parse(body)
254
+ if (settings.apiKeys) {
255
+ for (const [key, value] of Object.entries(settings.apiKeys)) {
256
+ if (value) config.apiKeys[key] = value
257
+ else delete config.apiKeys[key]
258
+ }
259
+ }
260
+ if (settings.providers) {
261
+ for (const [key, value] of Object.entries(settings.providers)) {
262
+ if (!config.providers[key]) config.providers[key] = {}
263
+ config.providers[key].enabled = value.enabled !== false
264
+ }
265
+ }
266
+ // P2 fix: catch saveConfig failures and report to client
267
+ try {
268
+ saveConfig(config)
269
+ } catch (saveErr) {
270
+ res.writeHead(500, { 'Content-Type': 'application/json' })
271
+ res.end(JSON.stringify({ success: false, error: 'Failed to save config: ' + saveErr.message }))
272
+ return
273
+ }
274
+ res.writeHead(200, { 'Content-Type': 'application/json' })
275
+ res.end(JSON.stringify({ success: true }))
276
+ } catch (err) {
277
+ res.writeHead(400, { 'Content-Type': 'application/json' })
278
+ res.end(JSON.stringify({ error: err.message }))
279
+ }
280
+ })
281
+ } else {
282
+ res.writeHead(405)
283
+ res.end('Method Not Allowed')
284
+ }
285
+ break
286
+
287
+ default:
288
+ res.writeHead(404)
289
+ res.end('Not Found')
290
+ }
291
+ }
292
+
293
+ // ─── Exports ─────────────────────────────────────────────────────────────────
294
+
295
+ export async function startWebServer(port = 3333) {
296
+ const server = createServer(handleRequest)
297
+
298
+ server.listen(port, () => {
299
+ console.log()
300
+ console.log(` ⚡ free-coding-models Web Dashboard`)
301
+ console.log(` 🌐 http://localhost:${port}`)
302
+ console.log(` 📊 Monitoring ${results.filter(r => !r.cliOnly).length} models across ${Object.keys(sources).length} providers`)
303
+ console.log()
304
+ console.log(` Press Ctrl+C to stop`)
305
+ console.log()
306
+ })
307
+
308
+ // P1 fix: serialize ping rounds — each round starts only after the
309
+ // previous one finishes, preventing overlapping concurrent mutations.
310
+ async function schedulePingLoop() {
311
+ await pingAllModels()
312
+ setTimeout(schedulePingLoop, 10_000)
313
+ }
314
+ schedulePingLoop()
315
+
316
+ return server
317
+ }