free-coding-models 0.4.3 → 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 (142) hide show
  1. package/README.md +9 -1
  2. package/bin/free-coding-models.js +19 -9
  3. package/changelog/v0.5.0.md +15 -0
  4. package/changelog/v0.5.1.md +24 -0
  5. package/package.json +7 -2
  6. package/src/{analysis.js → core/analysis.js} +5 -5
  7. package/src/{constants.js → core/constants.js} +1 -1
  8. package/src/{endpoint-installer.js → core/endpoint-installer.js} +1 -1
  9. package/src/{installed-models-manager.js → core/installed-models-manager.js} +1 -1
  10. package/src/{kilo.js → core/kilo.js} +1 -2
  11. package/src/{openclaw.js → core/openclaw.js} +1 -1
  12. package/src/{opencode.js → core/opencode.js} +2 -1
  13. package/src/{ping-loop.js → core/ping-loop.js} +1 -1
  14. package/src/{router-daemon.js → core/router-daemon.js} +169 -4
  15. package/src/{router-dashboard.js → core/router-dashboard.js} +2 -2
  16. package/src/{setup.js → core/setup.js} +1 -1
  17. package/src/{sync-set.js → core/sync-set.js} +1 -1
  18. package/src/{telemetry.js → core/telemetry.js} +1 -1
  19. package/src/{tool-launchers.js → core/tool-launchers.js} +2 -2
  20. package/src/{updater.js → core/updater.js} +1 -1
  21. package/src/{utils.js → core/utils.js} +2 -0
  22. package/src/{app.js → tui/app.js} +38 -38
  23. package/src/{cli-help.js → tui/cli-help.js} +3 -1
  24. package/src/{command-palette.js → tui/command-palette.js} +2 -2
  25. package/src/{key-handler.js → tui/key-handler.js} +11 -11
  26. package/src/{overlays.js → tui/overlays.js} +2 -2
  27. package/src/{render-helpers.js → tui/render-helpers.js} +2 -2
  28. package/src/{render-table.js → tui/render-table.js} +9 -9
  29. package/src/{tui-filters.js → tui/tui-filters.js} +3 -3
  30. package/src/{tui-state.js → tui/tui-state.js} +1 -1
  31. package/web/README.md +46 -0
  32. package/web/dist/assets/index-ByGf4Kq-.js +14 -0
  33. package/web/dist/assets/index-Ds7wmHBv.css +1 -0
  34. package/web/dist/index.html +3 -6
  35. package/web/index.html +1 -4
  36. package/web/package.json +11 -0
  37. package/web/server.js +609 -214
  38. package/web/src/App.jsx +54 -12
  39. package/web/src/components/analytics/AnalyticsView.jsx +10 -4
  40. package/web/src/components/atoms/AILatencyCell.jsx +38 -0
  41. package/web/src/components/atoms/AILatencyCell.module.css +43 -0
  42. package/web/src/components/atoms/HealthCell.jsx +53 -0
  43. package/web/src/components/atoms/HealthCell.module.css +15 -0
  44. package/web/src/components/atoms/LastPingCell.jsx +35 -0
  45. package/web/src/components/atoms/LastPingCell.module.css +35 -0
  46. package/web/src/components/atoms/MoodCell.jsx +25 -0
  47. package/web/src/components/atoms/MoodCell.module.css +6 -0
  48. package/web/src/components/atoms/RankCell.jsx +9 -0
  49. package/web/src/components/atoms/RankCell.module.css +9 -0
  50. package/web/src/components/atoms/TPSCell.jsx +36 -0
  51. package/web/src/components/atoms/TPSCell.module.css +38 -0
  52. package/web/src/components/atoms/VerdictBadge.jsx +30 -7
  53. package/web/src/components/atoms/VerdictBadge.module.css +24 -15
  54. package/web/src/components/dashboard/ExportModal.jsx +9 -4
  55. package/web/src/components/dashboard/FilterBar.jsx +112 -10
  56. package/web/src/components/dashboard/FilterBar.module.css +86 -1
  57. package/web/src/components/dashboard/ModelTable.jsx +293 -52
  58. package/web/src/components/dashboard/ModelTable.module.css +131 -33
  59. package/web/src/components/dashboard/StatsBar.jsx +7 -5
  60. package/web/src/components/layout/Footer.jsx +1 -1
  61. package/web/src/components/layout/Header.jsx +43 -9
  62. package/web/src/components/layout/Header.module.css +38 -4
  63. package/web/src/components/layout/Sidebar.jsx +19 -11
  64. package/web/src/components/layout/Sidebar.module.css +15 -5
  65. package/web/src/components/settings/SettingsView.jsx +24 -6
  66. package/web/src/components/settings/SettingsView.module.css +0 -1
  67. package/web/src/global.css +70 -73
  68. package/web/src/hooks/useFilter.js +117 -25
  69. package/web/src/hooks/useSSE.js +33 -9
  70. package/web/src/hooks/useSocket.js +200 -0
  71. package/web/vite.config.js +41 -0
  72. package/src/graphify-out/cache/089db1c1def873cf6d112f1590da4490e61e691aff0db41e006aa2fb15ba0656.json +0 -1
  73. package/src/graphify-out/cache/0b510b53cf1a1393fb52b1fc3bbbf88b63938e961ec5b82119a2e9715fee8bd7.json +0 -1
  74. package/src/graphify-out/cache/0ec9a95a326bde58e0316889018b278062d06d494d0f31ba177c9de71e5fed2d.json +0 -1
  75. package/src/graphify-out/cache/1548663a24a68dce740ebab1bd1d3091048c9604e9d067a1650a42a6d82541d4.json +0 -1
  76. package/src/graphify-out/cache/1783af63cb6d0dfb4d469009f71ac83a74ba0b33d48186ff2c6e63f9429e900a.json +0 -1
  77. package/src/graphify-out/cache/1e109f5eb5dc4fd285871c3613e32b6b14a8c225f4080ee34b51c7e1a1764571.json +0 -1
  78. package/src/graphify-out/cache/1eb24dbeb69b46c8bc1caf925df2f2a964af0f33aea143adf8ddf88e017db6ca.json +0 -1
  79. package/src/graphify-out/cache/21e1bcfed11685e8347243f9d8516072dda183266a4bfe22c52fb31753a446c8.json +0 -1
  80. package/src/graphify-out/cache/2327473478b9c4b1940bf7ef66c9ee960b3cba8d5302e56b625df8274246e0b4.json +0 -1
  81. package/src/graphify-out/cache/25955b81fd25454c8fa90fb71a47db8d1215cf621beb8ff3cbd580aaf011b4f3.json +0 -1
  82. package/src/graphify-out/cache/2739677f19c702f88f3de0a0bac475066adbda98709907ad3de967aef689f86d.json +0 -1
  83. package/src/graphify-out/cache/2bba03422f6b3ee7f5b5d29cc90314a064d259e5822a176657bda3e04505cf00.json +0 -1
  84. package/src/graphify-out/cache/2ddf1d2c6d10147b0402446bc71a7988187b79b6210dd7e7250be8c555b9ff35.json +0 -1
  85. package/src/graphify-out/cache/2ee07457a5767c95a57f8e9eb95b28f800044f35666e0715e9d88ad1103a092e.json +0 -1
  86. package/src/graphify-out/cache/2fe9f75dc2951c417f2c8dd22749092cf550dc67599f1c8d1866900dc6e9154e.json +0 -1
  87. package/src/graphify-out/cache/41c4b7c27e7fc3e2948d3a4bf95a72de2ed9a6f0463994babdce8ed2cc84598c.json +0 -1
  88. package/src/graphify-out/cache/5028defd54b7fbd3c7e444973e493de036e097e9b1d2a7cae7f19b88d68aacde.json +0 -1
  89. package/src/graphify-out/cache/5b133aba3fb16410c5b1fdbd1730039fc7fa1ac93abd99d7be08f60da70fc8d4.json +0 -1
  90. package/src/graphify-out/cache/74252e5b0978d85ab3421a3de1a9384aa282ffd2be2cfe7db2530139089f4275.json +0 -1
  91. package/src/graphify-out/cache/7695ebeea056095edd14332963cc43354ef3a097caf46f1e28d0f01369642901.json +0 -1
  92. package/src/graphify-out/cache/777aa7085c395a935c6556bbde182cd871edb61f3a685ed8068ec0c8f6fb0075.json +0 -1
  93. package/src/graphify-out/cache/82a723881980e82273c113def8315533d7da28827e300413d9ad30f27b7407df.json +0 -1
  94. package/src/graphify-out/cache/86b87c9603e6cd188f42c7eed3b86c291d48a781c223a707e74f3e7ed0c02a21.json +0 -1
  95. package/src/graphify-out/cache/890fead9a78cadaed560a2d2453916121fa605c3e43a334910ac4bc951a9ef6d.json +0 -1
  96. package/src/graphify-out/cache/89d3ea66f52783caa775ef9a30923d7d6225e1d8ae9e962f4741b8c7785dab1e.json +0 -1
  97. package/src/graphify-out/cache/8cc82cd9edce41f0e1c092f14a94fd52bf847addf3237b616dc5a9e505bd05bd.json +0 -1
  98. package/src/graphify-out/cache/93ba2e25e3ff7ad525f397902345fbd375df7315de7b402e20cc803c14eccde8.json +0 -1
  99. package/src/graphify-out/cache/99beed29580b9c7bfecfee794cb3d8e535fcf0eb3b92113108f88bdd0a8e79b3.json +0 -1
  100. package/src/graphify-out/cache/aeeb931fa477c65ce2e51d8149957350fa54225c613222bbbe8448998d1afd3d.json +0 -1
  101. package/src/graphify-out/cache/baf91bef5b5ecb2a476433b6cc0c48c563c54ee2d07fc3c192e543685e3e7222.json +0 -1
  102. package/src/graphify-out/cache/bd98b94ac4e9b92b6336d47b26e0366b51a4eaf0711d722f05f98dfae23ab42b.json +0 -1
  103. package/src/graphify-out/cache/bfcb51e9328e9cbfbee4f6fee0f56635d7b03488addc9f6c4e4b190b70a73362.json +0 -1
  104. package/src/graphify-out/cache/c0d3dabeb093aa758c49eadf41b87ecc96a16c1449c2670aaf48cbfc891d8da6.json +0 -1
  105. package/src/graphify-out/cache/c20d6630236f473c1406068c3ae205853e649b216495c93dfec055dd222c55cf.json +0 -1
  106. package/src/graphify-out/cache/c22b9122816bebce0a2f79af41a986559d01e00163dbcd579c5755621b4cb483.json +0 -1
  107. package/src/graphify-out/cache/ca556ec14453ddb8f9e0c5a832dac90d77111b9bad5f8c2d80d272e2e7a06371.json +0 -1
  108. package/src/graphify-out/cache/d6dbc9135dfa35a756b3b09b06700e4bc229fdccba11bb963f2ba44028e0bbae.json +0 -1
  109. package/src/graphify-out/cache/e1cf71276f1779d0fa075f79bd7c8a9fd0b8eef6932ac043137451b7c7fa7cbe.json +0 -1
  110. package/src/graphify-out/cache/e4b3be14494467df2d2ed389bc4f18f099021cb5bc355b901fa88387b2d8b8a2.json +0 -1
  111. package/src/graphify-out/cache/eaea0dded097f6f9553b654220046c6ec0c9be592a5973d906564ee60af34e0d.json +0 -1
  112. package/src/graphify-out/cache/ef07d0cd2675d1f79d2a2fdbf3bc3319687638751e9ce89b0d0d97ed1cd9f7e1.json +0 -1
  113. package/src/graphify-out/cache/f81272d6eb8aaff9e96d5a1d9f06777db70ac3652a646b951ded51f79871d733.json +0 -1
  114. package/src/graphify-out/cache/f9619dd92186f75a6dbda937e0c606647153918524cdb5763f956e6ec2a9e386.json +0 -1
  115. package/src/graphify-out/cache/fd88b1b2ff4bfcae08559d9c2aaeeb9a3f1e2f5cd8928762c311196956c170a5.json +0 -1
  116. package/web/dist/assets/index-CGN-0_A0.css +0 -1
  117. package/web/dist/assets/index-Czwis3ab.js +0 -11
  118. /package/src/{benchmark.js → core/benchmark.js} +0 -0
  119. /package/src/{cache.js → core/cache.js} +0 -0
  120. /package/src/{changelog-loader.js → core/changelog-loader.js} +0 -0
  121. /package/src/{config.js → core/config.js} +0 -0
  122. /package/src/{favorites.js → core/favorites.js} +0 -0
  123. /package/src/{kilo-config.js → core/kilo-config.js} +0 -0
  124. /package/src/{legacy-proxy-cleanup.js → core/legacy-proxy-cleanup.js} +0 -0
  125. /package/src/{model-merger.js → core/model-merger.js} +0 -0
  126. /package/src/{opencode-config.js → core/opencode-config.js} +0 -0
  127. /package/src/{ping.js → core/ping.js} +0 -0
  128. /package/src/{product-flags.js → core/product-flags.js} +0 -0
  129. /package/src/{provider-metadata.js → core/provider-metadata.js} +0 -0
  130. /package/src/{provider-quota-fetchers.js → core/provider-quota-fetchers.js} +0 -0
  131. /package/src/{quota-capabilities.js → core/quota-capabilities.js} +0 -0
  132. /package/src/{security.js → core/security.js} +0 -0
  133. /package/src/{shell-env.js → core/shell-env.js} +0 -0
  134. /package/src/{testfcm.js → core/testfcm.js} +0 -0
  135. /package/src/{token-usage-reader.js → core/token-usage-reader.js} +0 -0
  136. /package/src/{tool-bootstrap.js → core/tool-bootstrap.js} +0 -0
  137. /package/src/{tool-metadata.js → core/tool-metadata.js} +0 -0
  138. /package/src/{usage-reader.js → core/usage-reader.js} +0 -0
  139. /package/src/{mouse.js → tui/mouse.js} +0 -0
  140. /package/src/{theme.js → tui/theme.js} +0 -0
  141. /package/src/{tier-colors.js → tui/tier-colors.js} +0 -0
  142. /package/src/{ui-config.js → tui/ui-config.js} +0 -0
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
 
@@ -10,13 +10,13 @@ if (process.argv.includes('--dev')) {
10
10
  }
11
11
 
12
12
  import chalk from 'chalk';
13
- import { parseArgs, TIER_LETTER_MAP } from '../src/utils.js';
14
- import { loadConfig } from '../src/config.js';
15
- import { ensureTelemetryConfig } from '../src/telemetry.js';
16
- import { ensureFavoritesConfig } from '../src/favorites.js';
17
- import { buildCliHelpText } from '../src/cli-help.js';
18
- import { ALT_LEAVE } from '../src/constants.js';
19
- import { runApp } from '../src/app.js';
13
+ import { parseArgs, TIER_LETTER_MAP } from '../src/core/utils.js';
14
+ import { loadConfig } from '../src/core/config.js';
15
+ import { ensureTelemetryConfig } from '../src/core/telemetry.js';
16
+ import { ensureFavoritesConfig } from '../src/core/favorites.js';
17
+ import { buildCliHelpText } from '../src/tui/cli-help.js';
18
+ import { ALT_LEAVE } from '../src/core/constants.js';
19
+ import { runApp } from '../src/tui/app.js';
20
20
 
21
21
  // Global error handlers to ensure terminal is restored if something crashes catastrophically
22
22
  process.on('uncaughtException', (err) => {
@@ -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) {
@@ -61,7 +71,7 @@ async function main() {
61
71
  runRouterDaemon,
62
72
  startRouterDaemonBackground,
63
73
  stopRouterDaemon,
64
- } = await import('../src/router-daemon.js');
74
+ } = await import('../src/core/router-daemon.js');
65
75
 
66
76
  if (cliArgs.daemonMode) {
67
77
  await runRouterDaemon();
@@ -80,7 +90,7 @@ async function main() {
80
90
 
81
91
  // 📖 --sync-set [name] — auto-discover, probe, and populate a router set
82
92
  if (cliArgs.syncSetMode) {
83
- const { syncSet } = await import('../src/sync-set.js');
93
+ const { syncSet } = await import('../src/core/sync-set.js');
84
94
  const result = await syncSet({ name: cliArgs.syncSetName || 'auto' });
85
95
  console.log(JSON.stringify(result, null, 2));
86
96
  process.exit(result.ok ? 0 : 1);
@@ -0,0 +1,15 @@
1
+ # Changelog v0.5.0 - 2026-05-31
2
+
3
+ ### Added
4
+ - **Final Desktop PRD**: Completed `desktop/prd-desktop.md` defining Tauri v2 shell, Bun/Node sidecar integration, zombie process prevention (parent-death binding), and loopback-only security models.
5
+ - **Sub-Project Documentation**: Added `/web/README.md` and `/desktop/README.md` to guide modular frontend and desktop sidecar development.
6
+
7
+ ### Changed
8
+ - **Architectural Segregation (Core vs. TUI)**: Partitioned the codebase into isolated layers:
9
+ - `src/core/` for 100% shared business logic (routing, scoring, pings, daemon, quota, and config management) shared 1:1 with CLI, Docker, and Tauri sidecar.
10
+ - `src/tui/` for terminal-only layouts, keystroke handlers, ANSI renderers, and interactive loops.
11
+ - **Relative Import Pathways**: Restructured and re-routed relative imports across all entry points, TUI assets, and core logic files.
12
+ - **Port Consistency**: Standardized loopback proxy and daemon endpoints on port `19280` across all CLI, Docker, and Desktop configurations.
13
+
14
+ ### Fixed
15
+ - **Clean workspace**: Fully purged `.kandown` task manager logs, `.claude-mcp.json`, and stale planning/analysis documents to ensure a zero-noise, lightweight npm package profile.
@@ -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.4.3",
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": {
@@ -35,11 +35,11 @@
35
35
  * @see {@link ../src/ping.js} ping implementation
36
36
  */
37
37
 
38
- import { MODELS, sources } from '../sources.js'
39
- import { findBestModel, filterByTier, formatCtxWindow, labelFromId, TIER_LETTER_MAP } from '../src/utils.js'
40
- import { isProviderEnabled, getApiKey } from '../src/config.js'
41
- import { ping } from '../src/ping.js'
42
- import { PROVIDER_COLOR } from './render-table.js'
38
+ import { MODELS, sources } from '../../sources.js'
39
+ import { findBestModel, filterByTier, formatCtxWindow, labelFromId, TIER_LETTER_MAP } from './utils.js'
40
+ import { isProviderEnabled, getApiKey } from './config.js'
41
+ import { ping } from './ping.js'
42
+ import { PROVIDER_COLOR } from '../tui/render-table.js'
43
43
  import chalk from 'chalk'
44
44
 
45
45
  // 📖 runFiableMode: Analyze models for reliability over 10 seconds, output the best one.
@@ -48,7 +48,7 @@ import chalk from 'chalk'
48
48
  // 📖 \x1b[?7l disables auto-wrap so wide rows clip at the right edge instead of
49
49
  // 📖 wrapping to the next line (which would double the row height and overflow).
50
50
  // 📖 Mouse tracking sequences are appended/prepended so clicks and scroll work in the TUI.
51
- import { MOUSE_ENABLE, MOUSE_DISABLE } from './mouse.js'
51
+ import { MOUSE_ENABLE, MOUSE_DISABLE } from '../tui/mouse.js'
52
52
 
53
53
  export const ALT_ENTER = '\x1b[?1049h\x1b[?25l\x1b[?7l' + MOUSE_ENABLE
54
54
  export const ALT_LEAVE = MOUSE_DISABLE + '\x1b[?7h\x1b[?1049l\x1b[?25h'
@@ -43,7 +43,7 @@
43
43
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
44
44
  import { homedir } from 'node:os'
45
45
  import { dirname, join } from 'node:path'
46
- import { MODELS, sources } from '../sources.js'
46
+ import { MODELS, sources } from '../../sources.js'
47
47
  import { getApiKey, saveConfig } from './config.js'
48
48
  import { ENV_VAR_NAMES, PROVIDER_METADATA } from './provider-metadata.js'
49
49
  import { getToolMeta } from './tool-metadata.js'
@@ -48,7 +48,7 @@
48
48
  import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'
49
49
  import { homedir } from 'node:os'
50
50
  import { join, dirname } from 'node:path'
51
- import { sources } from '../sources.js'
51
+ import { sources } from '../../sources.js'
52
52
 
53
53
  const BACKUP_PATH = join(homedir(), '.free-coding-models-backups.json')
54
54
 
@@ -4,12 +4,11 @@
4
4
  */
5
5
 
6
6
  import chalk from 'chalk'
7
- import { PROVIDER_COLOR } from './render-table.js'
7
+ import { sources } from '../../sources.js'
8
8
  import { loadKiloConfig, saveKiloConfig, getKiloConfigPath } from './kilo-config.js'
9
9
  import { getApiKey } from './config.js'
10
10
  import { ENV_VAR_NAMES, OPENCODE_MODEL_MAP } from './provider-metadata.js'
11
11
  import { resolveToolBinaryPath } from './tool-bootstrap.js'
12
- import { sources } from '../sources.js'
13
12
 
14
13
  // 📖 Map source model IDs to Kilo built-in IDs (same as OpenCode).
15
14
  function getKiloModelId(providerKey, modelId) {
@@ -31,7 +31,7 @@ import { homedir } from 'os'
31
31
  import { dirname, join } from 'path'
32
32
  import { installProviderEndpoints } from './endpoint-installer.js'
33
33
  import { ENV_VAR_NAMES } from './provider-metadata.js'
34
- import { PROVIDER_COLOR } from './render-table.js'
34
+ import { PROVIDER_COLOR } from '../tui/render-table.js'
35
35
  import { resolveToolBinaryPath } from './tool-bootstrap.js'
36
36
  import { getApiKey } from './config.js'
37
37
  import { syncShellEnv } from './shell-env.js'
@@ -28,7 +28,8 @@ import { request as httpsRequest } from 'https'
28
28
  import { homedir } from 'os'
29
29
  import { join } from 'path'
30
30
  import { copyFileSync, existsSync } from 'fs'
31
- import { PROVIDER_COLOR } from './render-table.js'
31
+ import { PROVIDER_COLOR } from '../tui/render-table.js'
32
+ import { sources } from '../../sources.js'
32
33
  import { loadOpenCodeConfig, saveOpenCodeConfig } from './opencode-config.js'
33
34
  import { getApiKey } from './config.js'
34
35
  import { ENV_VAR_NAMES, OPENCODE_MODEL_MAP, isWindows, isMac, isLinux } from './provider-metadata.js'
@@ -28,7 +28,7 @@ import {
28
28
  PING_MODE_INTERVALS,
29
29
  SPEED_MODE_DURATION_MS,
30
30
  IDLE_SLOW_AFTER_MS,
31
- } from './tui-state.js'
31
+ } from '../tui/tui-state.js'
32
32
 
33
33
  /**
34
34
  * 📖 createPingLoop: Build the ping loop control functions for a given TUI state.
@@ -37,7 +37,7 @@ import { randomUUID } from 'node:crypto'
37
37
  import { appendFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
38
38
  import { homedir } from 'node:os'
39
39
  import { fileURLToPath } from 'node:url'
40
- import { MODELS, sources } from '../sources.js'
40
+ import { MODELS, sources } from '../../sources.js'
41
41
  import {
42
42
  CONFIG_PATH,
43
43
  DEFAULT_ROUTER_SETTINGS,
@@ -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
@@ -71,7 +72,7 @@ export function getRouterPortRange() {
71
72
  }
72
73
 
73
74
  const __dirname = dirname(fileURLToPath(import.meta.url))
74
- const CLI_ENTRY_PATH = join(__dirname, '..', 'bin', 'free-coding-models.js')
75
+ const CLI_ENTRY_PATH = join(__dirname, '..', '..', 'bin', 'free-coding-models.js')
75
76
  const MAX_BODY_BYTES = 10 * 1024 * 1024
76
77
  const MAX_REQUEST_LOG = 200
77
78
  const MAX_SSE_CLIENTS = 10
@@ -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)) {
@@ -365,7 +386,7 @@ function getWebConfigPayload(runtime) {
365
386
  return { providers, totalModels: MODELS.length }
366
387
  }
367
388
 
368
- const WEB_DIST_DIR = resolvePath(__dirname, '..', 'web', 'dist')
389
+ const WEB_DIST_DIR = resolvePath(__dirname, '..', '..', 'web', 'dist')
369
390
 
370
391
  function serveStaticFromDist(res, absPath) {
371
392
  const ext = absPath.slice(absPath.lastIndexOf('.'))
@@ -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.
@@ -41,9 +41,9 @@
41
41
 
42
42
  import chalk from 'chalk'
43
43
  import { existsSync, readFileSync } from 'node:fs'
44
- import { displayWidth, padEndDisplay, sliceOverlayLines, tintOverlayLines } from './render-helpers.js'
44
+ import { displayWidth, padEndDisplay, sliceOverlayLines, tintOverlayLines } from '../tui/render-helpers.js'
45
45
  import { ROUTER_DEFAULT_PORT, ROUTER_MAX_PORT, ROUTER_PID_PATH, ROUTER_PORT_PATH, getRouterPortRange } from './router-daemon.js'
46
- import { themeColors, getTierRgb } from './theme.js'
46
+ import { themeColors, getTierRgb } from '../tui/theme.js'
47
47
  import { formatTokenTotalCompact } from './token-usage-reader.js'
48
48
  import { sendUsageTelemetry } from './telemetry.js'
49
49
  import { getAvg, getVerdict } from './utils.js'
@@ -35,7 +35,7 @@
35
35
 
36
36
  import chalk from 'chalk'
37
37
  import { createRequire } from 'module'
38
- import { sources } from '../sources.js'
38
+ import { sources } from '../../sources.js'
39
39
  import { PROVIDER_METADATA } from './provider-metadata.js'
40
40
  import { saveConfig } from './config.js'
41
41
 
@@ -28,7 +28,7 @@
28
28
  * @see ../sources.js — model catalog
29
29
  */
30
30
 
31
- import { sources } from '../sources.js'
31
+ import { sources } from '../../sources.js'
32
32
  import {
33
33
  CONFIG_PATH,
34
34
  getApiKey,
@@ -51,7 +51,7 @@ import { createRequire } from 'module'
51
51
  import { saveConfig } from './config.js'
52
52
 
53
53
  const require = createRequire(import.meta.url)
54
- const pkg = require('../package.json')
54
+ const pkg = require('../../package.json')
55
55
  const LOCAL_VERSION = pkg.version
56
56
 
57
57
  // 📖 PostHog capture endpoint and defaults.
@@ -41,8 +41,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from
41
41
  import { homedir } from 'os'
42
42
  import { dirname, join } from 'path'
43
43
  import { spawn, spawnSync } from 'child_process'
44
- import { sources } from '../sources.js'
45
- import { PROVIDER_COLOR } from './render-table.js'
44
+ import { sources } from '../../sources.js'
45
+ import { PROVIDER_COLOR } from '../tui/render-table.js'
46
46
  import { getApiKey } from './config.js'
47
47
  import { ENV_VAR_NAMES, isWindows } from './provider-metadata.js'
48
48
  import { getToolMeta, TOOL_METADATA } from './tool-metadata.js'
@@ -46,7 +46,7 @@ import { accessSync, constants } from 'fs'
46
46
 
47
47
  const require = createRequire(import.meta.url)
48
48
  const readline = require('readline')
49
- const pkg = require('../package.json')
49
+ const pkg = require('../../package.json')
50
50
  const LOCAL_VERSION = pkg.version
51
51
 
52
52
  /**
@@ -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
  }