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/README.md CHANGED
@@ -145,7 +145,15 @@ docker run -p 19280:19280 ghcr.io/vava-nessa/free-coding-models:latest
145
145
  docker run -p 19280:19280 -e OPENROUTER_API_KEY=your_key ghcr.io/vava-nessa/free-coding-models:latest
146
146
  ```
147
147
 
148
- Access the web dashboard at `http://localhost:19280/` and configure your coding tool to use `http://localhost:19280/v1` with model `fcm`.
148
+ Access the daemon web dashboard at `http://localhost:19280/` and configure your coding tool to use `http://localhost:19280/v1` with model `fcm`.
149
+
150
+ For the full TUI-style catalog dashboard from an npm install, run:
151
+
152
+ ```bash
153
+ free-coding-models web
154
+ ```
155
+
156
+ This starts the realtime Web Dashboard locally, opens it in your browser, and uses `http://localhost:3333/` by default. Override the port with `FCM_WEB_PORT=3334 free-coding-models web`.
149
157
 
150
158
  ### Available Image Tags
151
159
 
@@ -53,6 +53,16 @@ async function main() {
53
53
  process.exit(0);
54
54
  }
55
55
 
56
+ // 📖 Standalone web dashboard: same full-catalog ping UI as the TUI, served
57
+ // 📖 locally with Socket.IO/SSE/REST realtime updates.
58
+ if (cliArgs.webMode) {
59
+ const { startWebServer } = await import('../web/server.js');
60
+ const parsedPort = Number.parseInt(process.env.FCM_WEB_PORT || process.env.FCM_PORT || '3333', 10);
61
+ const port = Number.isFinite(parsedPort) && parsedPort > 0 ? parsedPort : 3333;
62
+ await startWebServer(port, { open: true, startPingLoop: true });
63
+ return;
64
+ }
65
+
56
66
  // 📖 Router daemon lifecycle flags run before the TUI so automation and
57
67
  // 📖 editor integrations can manage the local OpenAI-compatible endpoint.
58
68
  if (cliArgs.daemonMode || cliArgs.daemonBackgroundMode || cliArgs.daemonStopMode || cliArgs.daemonStatusMode) {
@@ -0,0 +1,24 @@
1
+ # Changelog v0.5.1 - 2026-05-31
2
+
3
+ ### Added
4
+ - Added `free-coding-models web` as the npm-friendly way to launch the full TUI-style realtime Web Dashboard from a global install.
5
+ - Added Socket.IO as the primary dashboard realtime transport, with SSE and REST polling fallbacks so the UI keeps updating even when one transport is unavailable.
6
+ - Added `/api/state` support for dashboard clients and daemon dashboard clients, including ping mode, countdown, per-model ping state, and benchmark progress metadata.
7
+ - Added tests that lock every visible web table column as sortable, including display-only columns such as `❔`, Last Ping, AI Latency, TPS, and Trend.
8
+
9
+ ### Changed
10
+ - Reworked the standalone web server to mirror the TUI ping cadence more closely: startup speed mode, normal cadence, idle slow mode, per-model `isPinging`, and frequent incremental updates.
11
+ - Changed the Web Dashboard AI Latency global benchmark to benchmark only the models currently visible after filters and search, instead of always benchmarking the full catalog.
12
+ - Made every dashboard table column cycle through ascending, descending, and reset sorting, with missing values consistently pushed to the bottom.
13
+ - Removed the top stats card row from the dashboard for a cleaner, table-first layout.
14
+ - Removed the `ms` suffix from Last Ping and Avg cells in the dashboard table to make dense latency columns easier to scan.
15
+ - Improved dashboard table borders and added visible column separators, including stronger light-theme borders.
16
+ - Updated npm and web documentation to distinguish the full catalog dashboard (`free-coding-models web`, default `localhost:3333`) from the router daemon dashboard (`free-coding-models --daemon`, default `localhost:19280`).
17
+
18
+ ### Fixed
19
+ - Fixed `free-coding-models web` being parsed as an API key by treating `web` as a real subcommand.
20
+ - Fixed dashboard benchmark spinners so only the actively benchmarked row shows running state instead of making unrelated rows spin during global scans.
21
+ - Fixed benchmark result keys by using provider/model identifiers, avoiding collisions and invisible results when providers share model ids.
22
+ - Fixed the Tier “All” filter mismatch in the Web Dashboard.
23
+ - Fixed light-theme button contrast where accent buttons could render unreadable black-on-black text.
24
+ - Fixed the web development readiness check so it waits for the correct local server response.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Find the fastest coding LLM models in seconds — ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
5
5
  "keywords": [
6
6
  "nvidia",
@@ -53,6 +53,7 @@
53
53
  "start": "node bin/free-coding-models.js",
54
54
  "test": "node --test test/test.js",
55
55
  "prepack": "npm run build:web",
56
+ "dev": "node scripts/dev-web.mjs",
56
57
  "dev:web": "node scripts/dev-web.mjs",
57
58
  "build:web": "vite build",
58
59
  "preview:web": "vite preview",
@@ -60,7 +61,11 @@
60
61
  "test:fcm:mock": "node scripts/testfcm-runner.mjs --tool crush --tool-bin-dir test/fixtures/mock-bin"
61
62
  },
62
63
  "dependencies": {
63
- "chalk": "^5.6.2"
64
+ "@tabler/icons-react": "^3.44.0",
65
+ "@tanstack/react-table": "^8.21.3",
66
+ "chalk": "^5.6.2",
67
+ "socket.io": "^4.8.3",
68
+ "socket.io-client": "^4.8.3"
64
69
  },
65
70
  "packageManager": "pnpm@10.33.2",
66
71
  "engines": {
@@ -48,6 +48,7 @@ import {
48
48
  saveConfig,
49
49
  } from './config.js'
50
50
  import { buildChatCompletionPingBody, resolveCloudflareUrl, shouldUseDisabledThinkingForProvider } from './ping.js'
51
+ import { benchmarkModel, BENCHMARK_TIMEOUT_MS } from './benchmark.js'
51
52
  import { sendUsageTelemetry } from './telemetry.js'
52
53
 
53
54
  export const ROUTER_DEFAULT_PORT = 19280
@@ -343,12 +344,32 @@ function getWebModelsPayload(runtime) {
343
344
  pingCount: pings.length,
344
345
  hasApiKey,
345
346
  inRouterSet: inSetIndex.has(`${providerKey}::${modelId}`),
347
+ benchmarkKey: key,
348
+ isBenchmarking: runtime.webBenchmarkRunning?.has(key) || false,
349
+ benchmark: runtime.webBenchmarkResults?.get(key) || null,
346
350
  })
347
351
  }
348
352
  }
349
353
  return payload
350
354
  }
351
355
 
356
+ function getWebStatePayload(runtime) {
357
+ const router = runtime.routerConfig()
358
+ const probeInterval = router.probeIntervals?.[router.probeMode] || DEFAULT_ROUTER_SETTINGS.probeIntervals.balanced
359
+ return {
360
+ pingMode: router.probeMode === 'aggressive' ? 'speed' : router.probeMode === 'eco' ? 'slow' : 'normal',
361
+ pingModeSource: 'daemon-probe-mode',
362
+ pingInterval: probeInterval,
363
+ nextPingAt: runtime.lastProbeAt ? runtime.lastProbeAt + probeInterval : null,
364
+ pendingPings: runtime.probeTimeouts?.size || 0,
365
+ isPinging: (runtime.probeTimeouts?.size || 0) > 0,
366
+ globalBenchmarkRunning: runtime.webGlobalBenchmarkRunning || false,
367
+ globalBenchmarkTotal: runtime.webGlobalBenchmarkTotal || 0,
368
+ globalBenchmarkCompleted: runtime.webGlobalBenchmarkCompleted || 0,
369
+ models: getWebModelsPayload(runtime),
370
+ }
371
+ }
372
+
352
373
  function getWebConfigPayload(runtime) {
353
374
  const providers = {}
354
375
  for (const [key, src] of Object.entries(sources)) {
@@ -786,6 +807,11 @@ class RouterRuntime {
786
807
  this.quotaExhausted = new Set()
787
808
  this.quotaDetails = new Map()
788
809
  this.staleNotifications = new Set()
810
+ this.webBenchmarkRunning = new Set()
811
+ this.webBenchmarkResults = new Map()
812
+ this.webGlobalBenchmarkRunning = false
813
+ this.webGlobalBenchmarkTotal = 0
814
+ this.webGlobalBenchmarkCompleted = 0
789
815
  this.refreshRouteState()
790
816
  }
791
817
 
@@ -1131,6 +1157,99 @@ class RouterRuntime {
1131
1157
  }
1132
1158
  }
1133
1159
 
1160
+ broadcastWebState() {
1161
+ this.broadcast('models', getWebStatePayload(this))
1162
+ }
1163
+
1164
+ async runWebBenchmark(providerKey, modelId) {
1165
+ const key = modelKey(providerKey, modelId)
1166
+ if (this.webBenchmarkRunning.has(key)) return { skipped: true }
1167
+ const source = sources[providerKey]
1168
+ if (!source?.url) {
1169
+ return { ok: false, code: 'UNSUPPORTED', totalMs: 0, error: 'Provider has no benchmark URL', retries: 0 }
1170
+ }
1171
+
1172
+ this.webBenchmarkRunning.add(key)
1173
+ this.broadcastWebState()
1174
+ try {
1175
+ const result = await benchmarkModel({
1176
+ apiKey: this.getApiKeyForProvider(providerKey) ?? null,
1177
+ modelId,
1178
+ providerKey,
1179
+ url: source.url,
1180
+ timeoutMs: BENCHMARK_TIMEOUT_MS,
1181
+ })
1182
+ this.webBenchmarkResults.set(key, result)
1183
+ return result
1184
+ } catch (err) {
1185
+ const fallback = { ok: false, code: 'ERR', totalMs: 0, error: err?.message || 'Benchmark failed', retries: 0 }
1186
+ this.webBenchmarkResults.set(key, fallback)
1187
+ return fallback
1188
+ } finally {
1189
+ this.webBenchmarkRunning.delete(key)
1190
+ this.broadcastWebState()
1191
+ }
1192
+ }
1193
+
1194
+ async runWebGlobalBenchmark(models) {
1195
+ if (this.webGlobalBenchmarkRunning) return { started: false, error: 'Global benchmark already running' }
1196
+ const knownModels = []
1197
+ const seen = new Set()
1198
+ for (const item of Array.isArray(models) ? models : []) {
1199
+ const providerKey = typeof item?.providerKey === 'string' ? item.providerKey : ''
1200
+ const modelId = typeof item?.modelId === 'string' ? item.modelId : ''
1201
+ const key = modelKey(providerKey, modelId)
1202
+ if (!this.modelCatalog.has(key) || seen.has(key)) continue
1203
+ seen.add(key)
1204
+ knownModels.push({ providerKey, modelId, key })
1205
+ }
1206
+
1207
+ const fallbackModels = knownModels.length > 0
1208
+ ? knownModels
1209
+ : [...this.modelCatalog.values()].filter((m) => sources[m.providerKey]?.url && !sources[m.providerKey]?.cliOnly)
1210
+
1211
+ this.webGlobalBenchmarkRunning = true
1212
+ this.webGlobalBenchmarkTotal = fallbackModels.length
1213
+ this.webGlobalBenchmarkCompleted = 0
1214
+ this.broadcastWebState()
1215
+
1216
+ const healthPriority = { up: 0, pending: 1, timeout: 2, noauth: 3, auth_error: 4, down: 5 }
1217
+ const sorted = [...fallbackModels].sort((a, b) => {
1218
+ const aw = this.probeWindows.get(modelKey(a.providerKey, a.modelId)) || []
1219
+ const bw = this.probeWindows.get(modelKey(b.providerKey, b.modelId)) || []
1220
+ const aLast = aw.at(-1)
1221
+ const bLast = bw.at(-1)
1222
+ const aState = aLast?.ok ? 'up' : aw.length ? 'down' : 'pending'
1223
+ const bState = bLast?.ok ? 'up' : bw.length ? 'down' : 'pending'
1224
+ const hpA = healthPriority[aState] ?? 6
1225
+ const hpB = healthPriority[bState] ?? 6
1226
+ if (hpA !== hpB) return hpA - hpB
1227
+ return (aLast?.latencyMs ?? 99999) - (bLast?.latencyMs ?? 99999)
1228
+ })
1229
+
1230
+ const workers = new Array(Math.min(5, sorted.length)).fill(null).map(async () => {
1231
+ while (sorted.length > 0) {
1232
+ const next = sorted.shift()
1233
+ if (!next) break
1234
+ try {
1235
+ await this.runWebBenchmark(next.providerKey, next.modelId)
1236
+ } finally {
1237
+ this.webGlobalBenchmarkCompleted += 1
1238
+ this.broadcastWebState()
1239
+ }
1240
+ }
1241
+ })
1242
+
1243
+ void Promise.all(workers).finally(() => {
1244
+ this.webGlobalBenchmarkRunning = false
1245
+ this.webGlobalBenchmarkTotal = 0
1246
+ this.webGlobalBenchmarkCompleted = 0
1247
+ this.broadcastWebState()
1248
+ })
1249
+
1250
+ return { started: true, total: fallbackModels.length }
1251
+ }
1252
+
1134
1253
  statusPayload() {
1135
1254
  const router = this.routerConfig()
1136
1255
  const activeSet = this.getSet(router.activeSet)
@@ -1892,6 +2011,10 @@ class RouterRuntime {
1892
2011
  sendJson(res, 200, getWebModelsPayload(this), { 'x-request-id': requestId })
1893
2012
  return
1894
2013
  }
2014
+ if (req.method === 'GET' && url.pathname === '/api/state') {
2015
+ sendJson(res, 200, getWebStatePayload(this), { 'x-request-id': requestId })
2016
+ return
2017
+ }
1895
2018
  if (req.method === 'GET' && url.pathname === '/api/config') {
1896
2019
  sendJson(res, 200, getWebConfigPayload(this), { 'x-request-id': requestId })
1897
2020
  return
@@ -1907,11 +2030,53 @@ class RouterRuntime {
1907
2030
  'Connection': 'keep-alive',
1908
2031
  'x-request-id': requestId,
1909
2032
  })
1910
- res.write(`data: ${JSON.stringify(getWebModelsPayload(this))}\n\n`)
2033
+ res.write(`data: ${JSON.stringify(getWebStatePayload(this))}\n\n`)
1911
2034
  this.sseClients.add(res)
1912
2035
  req.on('close', () => this.sseClients.delete(res))
1913
2036
  return
1914
2037
  }
2038
+ if (req.method === 'POST' && url.pathname === '/api/activity') {
2039
+ sendJson(res, 200, { ok: true }, { 'x-request-id': requestId })
2040
+ return
2041
+ }
2042
+ if (req.method === 'POST' && url.pathname === '/api/benchmark') {
2043
+ const body = await readJsonBody(req)
2044
+ const providerKey = typeof body.providerKey === 'string' ? body.providerKey : ''
2045
+ const modelId = typeof body.modelId === 'string' ? body.modelId : ''
2046
+ if (!this.modelCatalog.has(modelKey(providerKey, modelId))) {
2047
+ sendJson(res, 404, { error: 'Model not found' }, { 'x-request-id': requestId })
2048
+ return
2049
+ }
2050
+ if (this.webBenchmarkRunning.has(modelKey(providerKey, modelId))) {
2051
+ sendJson(res, 409, { error: 'Benchmark already in progress for this model' }, { 'x-request-id': requestId })
2052
+ return
2053
+ }
2054
+ const result = await this.runWebBenchmark(providerKey, modelId)
2055
+ sendJson(res, 200, result, { 'x-request-id': requestId })
2056
+ return
2057
+ }
2058
+ if (url.pathname === '/api/global-benchmark') {
2059
+ if (req.method === 'GET') {
2060
+ sendJson(res, 200, {
2061
+ running: this.webGlobalBenchmarkRunning,
2062
+ total: this.webGlobalBenchmarkTotal,
2063
+ completed: this.webGlobalBenchmarkCompleted,
2064
+ }, { 'x-request-id': requestId })
2065
+ return
2066
+ }
2067
+ if (req.method !== 'POST') {
2068
+ sendError(res, 405, 'Method not allowed', 'invalid_request_error', 'method_not_allowed', requestId)
2069
+ return
2070
+ }
2071
+ if (this.webGlobalBenchmarkRunning) {
2072
+ sendJson(res, 409, { error: 'Global benchmark already running' }, { 'x-request-id': requestId })
2073
+ return
2074
+ }
2075
+ const body = await readJsonBody(req)
2076
+ const result = await this.runWebGlobalBenchmark(body.models)
2077
+ sendJson(res, result.started ? 202 : 409, result, { 'x-request-id': requestId })
2078
+ return
2079
+ }
1915
2080
  if (req.method === 'GET' && url.pathname.startsWith('/api/key/')) {
1916
2081
  // 📖 Reveals raw API keys — same-origin only to prevent malicious sites
1917
2082
  // 📖 from exfiltrating provider credentials via XHR/fetch.
package/src/core/utils.js CHANGED
@@ -471,6 +471,8 @@ export function parseArgs(argv) {
471
471
  flags.push(arg.toLowerCase())
472
472
  } else if (skipIndices.has(i)) {
473
473
  // 📖 Skip — this is a value for --tier, not an API key
474
+ } else if (i === 0 && arg.toLowerCase() === 'web') {
475
+ // 📖 `free-coding-models web` is a subcommand, not a provider API key.
474
476
  } else if (!apiKey) {
475
477
  apiKey = arg
476
478
  }
@@ -36,6 +36,7 @@ const ANALYSIS_FLAGS = [
36
36
  ]
37
37
 
38
38
  const CONFIG_FLAGS = [
39
+ { flag: 'web | --web | --gui', description: 'Start the full-catalog realtime Web Dashboard' },
39
40
  { flag: '--daemon', description: 'Start the FCM Router daemon + web dashboard (same port)' },
40
41
  { flag: '--daemon-bg', description: 'Start the FCM Router daemon in the background' },
41
42
  { flag: '--daemon-status', description: 'Print FCM Router daemon status JSON' },
@@ -47,6 +48,7 @@ const CONFIG_FLAGS = [
47
48
 
48
49
  const EXAMPLES = [
49
50
  'free-coding-models --help',
51
+ 'free-coding-models web',
50
52
  'free-coding-models --daemon',
51
53
  'free-coding-models --daemon-bg',
52
54
  'free-coding-models --daemon-status',
@@ -412,7 +412,7 @@ export function renderTable({
412
412
  })
413
413
 
414
414
  const lines = [
415
- ` ${themeColors.accentBold(`🚀 free-coding-models v${LOCAL_VERSION}`)}${modeBadge}${pingControlBadge}${tierBadge}${originBadge}${chalk.reset('')} ` +
415
+ ` ${chalk.rgb(118, 185, 0).bgRgb(0, 0, 0).bold(' > ')}${chalk.rgb(118, 185, 0).bgRgb(0, 0, 0).bold('free')}${chalk.rgb(255, 255, 255).bgRgb(0, 0, 0).bold('-coding-models')}${chalk.rgb(118, 185, 0).bgRgb(0, 0, 0).bold('_ ')} ${themeColors.dim(`v${LOCAL_VERSION}`)}${modeBadge}${pingControlBadge}${tierBadge}${originBadge}${chalk.reset('')} ` +
416
416
  themeColors.dim('📦 ') + themeColors.accentBold(`${completedPings}/${totalVisible}`) + themeColors.dim(' ') +
417
417
  themeColors.success(`✅ ${up}`) + themeColors.dim(' up ') +
418
418
  themeColors.warning(`⏳ ${timeout}`) + themeColors.dim(' timeout ') +
package/web/README.md CHANGED
@@ -14,11 +14,14 @@ To maintain maximum code sharing, **95%+ of all components and logic are kept co
14
14
 
15
15
  ## ⚡ API & Event Integration
16
16
 
17
- The React app relies on HTTP and Server-Sent Events (SSE) to talk to the engine:
18
- * `GET /api/models`: Fetches the live model catalog, complete with stability scores and latency details.
19
- * `GET /api/config`: Retrieves active provider toggles (keys are masked).
20
- * `POST /api/settings`: Updates API keys and provider preferences.
21
- * `GET /api/events` / `EventSource`: Listens for real-time SSE updates broadcasted by the ping and benchmark loops.
17
+ The React app uses a realtime-first connection strategy against the local engine:
18
+ * **Socket.IO** is preferred in dev/web-server mode for instant per-model ping and benchmark updates.
19
+ * **`GET /api/events` / `EventSource`** is the streaming fallback used by daemon/Docker surfaces.
20
+ * **`GET /api/state`** returns the wrapped live dashboard state for REST fallback polling.
21
+ * **`GET /api/models`** remains the legacy flat model catalog endpoint for simple clients.
22
+ * **`GET /api/config`** retrieves active provider toggles (keys are masked).
23
+ * **`POST /api/settings`** updates API keys and provider preferences.
24
+ * **`POST /api/global-benchmark`** benchmarks only the models currently visible in the web table, so filters/search control the benchmark scope.
22
25
 
23
26
  ---
24
27