free-coding-models 0.5.28 → 0.5.30

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 (64) hide show
  1. package/README.md +1 -1
  2. package/bin/free-coding-models.js +10 -2
  3. package/changelog/v0.5.29.md +19 -0
  4. package/changelog/v0.5.30.md +15 -0
  5. package/package.json +1 -1
  6. package/sources.js +3 -2
  7. package/src/core/config.js +1 -14
  8. package/src/core/endpoint-installer.js +5 -9
  9. package/src/core/legacy-proxy-cleanup.js +2 -0
  10. package/src/core/model-merger.js +4 -13
  11. package/src/core/playground.js +6 -4
  12. package/src/core/router-daemon.js +101 -43
  13. package/src/core/router-dashboard.js +63 -25
  14. package/src/core/shared-helpers.js +117 -0
  15. package/src/core/sync-set.js +11 -24
  16. package/src/core/tool-launchers.js +24 -92
  17. package/src/core/utils.js +26 -100
  18. package/src/tui/app.js +2 -2
  19. package/src/tui/command-palette.js +1 -0
  20. package/src/tui/key-handler.js +77 -20
  21. package/src/tui/render-table.js +3 -3
  22. package/web/dist/assets/index-CsFt3qt5.css +1 -0
  23. package/web/dist/assets/index-I6N91rH7.js +40 -0
  24. package/web/dist/favicon.ico +0 -0
  25. package/web/dist/favicons/apple-touch-icon.png +0 -0
  26. package/web/dist/favicons/favicon-16x16.png +0 -0
  27. package/web/dist/favicons/favicon-192x192.png +0 -0
  28. package/web/dist/favicons/favicon-32x32.png +0 -0
  29. package/web/dist/favicons/favicon-48x48.png +0 -0
  30. package/web/dist/favicons/favicon-512x512.png +0 -0
  31. package/web/dist/favicons/favicon-96x96.png +0 -0
  32. package/web/dist/favicons/favicon.ico +0 -0
  33. package/web/dist/favicons/mstile-150x150.png +0 -0
  34. package/web/dist/favicons/mstile-310x150.png +0 -0
  35. package/web/dist/favicons/mstile-310x310.png +0 -0
  36. package/web/dist/favicons/mstile-512x512.png +0 -0
  37. package/web/dist/favicons/mstile-70x70.png +0 -0
  38. package/web/dist/index.html +2 -2
  39. package/web/public/favicon.ico +0 -0
  40. package/web/public/favicons/apple-touch-icon.png +0 -0
  41. package/web/public/favicons/favicon-16x16.png +0 -0
  42. package/web/public/favicons/favicon-192x192.png +0 -0
  43. package/web/public/favicons/favicon-32x32.png +0 -0
  44. package/web/public/favicons/favicon-48x48.png +0 -0
  45. package/web/public/favicons/favicon-512x512.png +0 -0
  46. package/web/public/favicons/favicon-96x96.png +0 -0
  47. package/web/public/favicons/favicon.ico +0 -0
  48. package/web/public/favicons/mstile-150x150.png +0 -0
  49. package/web/public/favicons/mstile-310x150.png +0 -0
  50. package/web/public/favicons/mstile-310x310.png +0 -0
  51. package/web/public/favicons/mstile-512x512.png +0 -0
  52. package/web/public/favicons/mstile-70x70.png +0 -0
  53. package/web/server.js +34 -4
  54. package/web/src/components/dashboard/ExpandedDetailRow.jsx +10 -113
  55. package/web/src/components/dashboard/ExpandedDetailRow.module.css +9 -0
  56. package/web/src/components/playground/PlaygroundChat.jsx +527 -0
  57. package/web/src/components/playground/PlaygroundChat.module.css +382 -0
  58. package/web/src/components/playground/PlaygroundView.jsx +104 -442
  59. package/web/src/components/playground/PlaygroundView.module.css +10 -0
  60. package/web/src/components/router/RouterView.jsx +268 -56
  61. package/web/src/components/router/RouterView.module.css +416 -1
  62. package/src/core/product-flags.js +0 -9
  63. package/web/dist/assets/index-BRFowJv5.css +0 -1
  64. package/web/dist/assets/index-Pr3waI0-.js +0 -41
package/README.md CHANGED
@@ -184,7 +184,7 @@ and every TUI capability that's safe to port ships behind a button or chip.
184
184
  | **Theme** | Tri-state `auto / dark / light` cycle (TUI `G`) — auto follows the OS preference. |
185
185
  | **Smart Recommend** | Header "Recommend" opens the 3-question wizard, runs the 10s analysis phase, then returns the Top 3 shared-score recommendations with Pin + install-endpoint actions. |
186
186
  | **Endpoint installs** | The Web never starts external tools. It writes the selected provider/model endpoint into the chosen tool config (`/api/install-endpoint`), then users start their tool themselves. |
187
- | **Router Dashboard** | Header "Router" opens a full modal with daemon start/stop, model health table with circuit breaker badges, request log, probe mode selector, and quick-setup card (copy base URL + model to clipboard). |
187
+ | **Router Dashboard** | Header "Router" opens a full modal with daemon start/stop, model health table with circuit breaker badges, request log, probe mode selector, quick-setup card (copy base URL + model to clipboard), **"Probe all" AI Latency/TPS benchmarking**, and a **"Test Router" mini playground** to live-route chats through the fallback chain. |
188
188
  | **Token Usage** | Integrated inside Analytics: today + all-time summary cards, 7-day usage bar chart, top models and top providers breakdown. |
189
189
  | **Installed Models** | Header overflow → "Installed Models" opens a modal that scans all tool configs (Goose, Crush, Aider, Kilo, Qwen, Pi, OpenHands, Amp) and shows configured models with soft-delete (backup saved). |
190
190
  | **Install Endpoints wizard** | Header overflow → "Install Endpoints" opens a 4-step wizard: pick provider → pick tool → select models → install. Writes managed provider catalogs into tool configs using the same engine as the TUI. |
@@ -4,8 +4,16 @@
4
4
  * @description Live terminal availability checker for coding LLM models with OpenCode & OpenClaw integration.
5
5
  */
6
6
 
7
- // 📖 --dev mode: must set FCM_DEV before any module imports resolve daemon paths
8
- if (process.argv.includes('--dev')) {
7
+ // 📖 --dev mode: must set FCM_DEV before any module imports resolve daemon paths.
8
+ // 📖 Also auto-detect git checkouts — a repo checkout is always in dev mode because
9
+ // 📖 the router daemon must use dev ports/files to avoid clashing with a production
10
+ // 📖 npm install running on the same machine.
11
+ // 📖 IMPORTANT: these checks MUST run synchronously before any static imports
12
+ // 📖 resolve, because router-daemon.js reads FCM_DEV at module load time.
13
+ import { existsSync } from 'node:fs'
14
+ import { join, dirname } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+ if (process.argv.includes('--dev') || (!process.env.FCM_DEV && existsSync(join(dirname(fileURLToPath(import.meta.url)), '..', '.git')))) {
9
17
  process.env.FCM_DEV = '1'
10
18
  }
11
19
 
@@ -0,0 +1,19 @@
1
+ # Changelog v0.5.29 - 2026-06-15
2
+
3
+ ### Fixed
4
+ - **Router now respects your priority order (fixes #120, reported by @jammin1911).** Previously, priority was only 20% of the routing score, so a healthy low-priority model (e.g. GPT-OSS 120B) could serve traffic even when higher-priority models you deliberately ranked above it were also healthy. Routing is now **strict priority-first**: `#1` is always tried first while it is healthy, and the health score is only used as a tiebreaker between models that share the same priority (e.g. cold-start ties). Circuit-breaker safety is preserved — closed (healthy) models always come before half-open (recovering) ones. Your fallback chain is now authoritative.
5
+ - **Daemon startup errors are now surfaced instead of swallowed.** The TUI captures `--daemon-bg` stdout/stderr and shows the real failure reason (port in use, config corruption, etc.) as a dashboard notice, instead of silently flipping back to "stopped".
6
+ - **Dev checkouts no longer clash with production installs.** A git checkout now auto-enables dev mode (`FCM_DEV=1`) so its router daemon uses dev ports/files (`29280`, `-dev` suffixed PID/port/log files) and never collides with a globally-installed `free-coding-models` running on the same machine. Dynamic path resolvers pick up `FCM_DEV` changes that happen after module load.
7
+
8
+ ### Added
9
+ - **`routingOrder` field in `/stats`** — exposes the exact attempt order the daemon will use for the next request (priority-first among healthy models). `routingOrder[0]` is the model that will serve the next chat completion. Dashboards use it to show which model is active.
10
+ - **Clearer priority indicators in the Router UI (all surfaces)** so users understand the fallback chain at a glance:
11
+ - **Web Dashboard:** the active-set list now labels `#1` as a highlighted **Primary** badge (accent-colored) and the rest as numbered fallbacks. A one-line legend explains *"Primary tries first → Fallback on failure / rate-limit"*. The model that will serve the next request gets a green accent border + tint (**Next up** badge in the legend). Tooltips on every badge explain the semantics.
12
+ - **TUI Dashboard:** a `▶ NEXT` marker (green) is drawn on the exact model the daemon will route to next, so the top of your fallback chain is obvious. Empty-state copy now explains *"Favorites become your router fallback chain — #1 is tried first."*
13
+ - **Always-visible Quick Setup card** on the Router dashboard (Web + TUI) with sensible defaults (`localhost:19280/v1`, model `fcm`, key `fcm-local`), so you can copy your tool config even before starting the daemon. The Web version adds a one-click **Copy all** button and a hero glow when running.
14
+ - **Better empty states** across the Web Router view: the active-set list and request log now show helpful guidance ("No models in the active set → Add models or Sync best", "No requests yet. Start coding to see traffic here.") instead of disappearing.
15
+ - **Auto-expanding request log** — the Web request log expands automatically the first time traffic appears, so you don't have to click to see your routed requests.
16
+ - **More prominent Start button** — the stopped-state "Start Router" CTA is now larger and easier to hit.
17
+
18
+ ### Changed
19
+ - Router candidate sorting changed from `score DESC, priority ASC` to `priority ASC, score DESC` — the user's explicit ranking now wins. The `scoring` weights config is retained (still used for the health table and same-tier tiebreaks), so existing user configs and config normalization are unaffected.
@@ -0,0 +1,15 @@
1
+ # Changelog v0.5.30 - 2026-06-15
2
+
3
+ ### Added
4
+ - **Harmonized Playgrounds across all surfaces (fixes Playground UI mismatches).** Created a single shared `<PlaygroundChat>` React component that acts as the single source of truth for the full-screen Playground modal, the Router View's "Test Router" panel, and the Expanded Detail Row's "Mini Playground".
5
+ - **Universal real-time response metadata.** Under every assistant reply in all three playground locations, users now see the serving model (correctly resolved, even when routed through `fcm`), total latency, generated token count, and tokens per second (TPS). The addressed model or the router's Primary pick is also displayed under every user bubble for clarity.
6
+ - **Web Router Benchmarking (`Probe all`).** Added a "Probe all" action to the Web Router Dashboard. This lets you benchmark AI Latency and TPS on all models in the active routing set concurrently inside the daemon process. A visual progress bar displays the benchmarking status, and results flow back into each row's AI Latency cell.
7
+ - **New server proxy endpoint `/api/router/probe-all`** in `web/server.js` to handle the web dashboard's probe request and safely proxy it to the daemon's `/api/global-benchmark` with an extended timeout.
8
+ - **Playground daemon-status alignment tests.** Added unit tests in `test/test.js` to ensure the `/health` and `/stats` daemon endpoints consistently report `running: true` alongside `ok: true`, preventing a mismatch where the router dashboard would report "running" but the playground would report "offline".
9
+
10
+ ### Changed
11
+ - **Dynamic port and PID resolvers for dev mode (`FCM_DEV=1`).** Refined paths in `src/core/router-daemon.js`, `src/core/playground.js`, `src/core/sync-set.js`, and `web/server.js` to use dynamic functions (`getRouterPortPath()`, `getRouterPidPath()`). This ensures dev checkouts correctly detect and signal the dev daemon (`-dev` port/PID files) instead of reading stale production constants resolved at module load time.
12
+
13
+ ### Fixed
14
+ - **Fixed false "router offline" alerts.** The Playground now correctly trusts the daemon status because both `/health` and `/stats` endpoints uniformly report `running: true`.
15
+ - **Restored served-model badge for routed streams.** Forwarded the daemon's custom `x-fcm-router-model` response header in stream completions through the web proxy, so the playground can display which specific provider/model actually resolved the request when calling the `fcm` auto-router.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.5.28",
3
+ "version": "0.5.30",
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",
package/sources.js CHANGED
@@ -549,7 +549,8 @@ export const sources = {
549
549
  export const MODELS = [];
550
550
  for (const [sourceKey, sourceData] of Object.entries(sources)) {
551
551
  if (!sourceData || !sourceData.models) continue
552
- for (const [modelId, label, tier, sweScore, ctx] of sourceData.models) {
553
- MODELS.push([modelId, label, tier, sweScore, ctx, sourceKey])
552
+ for (const model of sourceData.models) {
553
+ const [modelId, label, tier, sweScore, ctx, addedDate] = model
554
+ MODELS.push([modelId, label, tier, sweScore, ctx, sourceKey, addedDate || null])
554
555
  }
555
556
  }
@@ -464,16 +464,6 @@ export function defaultRouterPrePromptText() {
464
464
  return DEFAULT_ROUTER_SETTINGS.prePrompt.text
465
465
  }
466
466
 
467
- function normalizeProfileSettings(settings) {
468
- const safeSettings = isPlainObject(settings) ? { ...settings } : {}
469
- return {
470
- ..._emptyProfileSettings(),
471
- ...safeSettings,
472
- theme: ['dark', 'light', 'auto'].includes(safeSettings.theme) ? safeSettings.theme : 'auto',
473
- }
474
- }
475
-
476
-
477
467
 
478
468
  function normalizeConfigShape(config) {
479
469
  const safeConfig = isPlainObject(config) ? config : {}
@@ -527,10 +517,7 @@ function mergeEndpointInstalls(diskEndpointInstalls, incomingEndpointInstalls) {
527
517
  return [...merged.values()]
528
518
  }
529
519
 
530
- function mergeProfiles(diskProfiles, incomingProfiles, options = {}) {
531
- // 📖 Profile system removed - return empty object
532
- return {}
533
- }
520
+
534
521
 
535
522
  /**
536
523
  * 📖 buildPersistedConfig merges the latest disk snapshot with the in-memory config so
@@ -47,6 +47,7 @@ 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'
50
+ import { ensureDir, readJson as sharedReadJson } from './shared-helpers.js'
50
51
 
51
52
  // 📖 replicate uses /v1/predictions (not /chat/completions), so it's not OpenAI-compatible.
52
53
  // 📖 zai and opencode-zen ARE OpenAI-compatible and CAN be installed into any tool.
@@ -72,9 +73,8 @@ function getDefaultPaths() {
72
73
  }
73
74
  }
74
75
 
75
- function ensureDirFor(filePath) {
76
- mkdirSync(dirname(filePath), { recursive: true })
77
- }
76
+ // 📖 ensureDirFor replaced by shared ensureDir (same logic)
77
+ const ensureDirFor = ensureDir
78
78
 
79
79
  function backupIfExists(filePath) {
80
80
  if (!existsSync(filePath)) return null
@@ -83,13 +83,9 @@ function backupIfExists(filePath) {
83
83
  return backupPath
84
84
  }
85
85
 
86
+ // 📖 readJson with default fallback of {} — matches shared helper's signature
86
87
  function readJson(filePath, fallback = {}) {
87
- if (!existsSync(filePath)) return fallback
88
- try {
89
- return JSON.parse(readFileSync(filePath, 'utf8'))
90
- } catch {
91
- return fallback
92
- }
88
+ return sharedReadJson(filePath, fallback)
93
89
  }
94
90
 
95
91
  function writeJson(filePath, value, { backup = true } = {}) {
@@ -81,6 +81,8 @@ function noteError(summary, filePath, error) {
81
81
  summary.errors.push(`${filePath}: ${error instanceof Error ? error.message : String(error)}`)
82
82
  }
83
83
 
84
+ // 📖 Thin wrappers: readJsonFile propagates parse errors (callers have try/catch).
85
+ // 📖 writeJsonFile appends a trailing newline for clean diffs.
84
86
  function readJsonFile(filePath, fallback = null) {
85
87
  if (!existsSync(filePath)) return fallback
86
88
  return JSON.parse(readFileSync(filePath, 'utf8'))
@@ -1,15 +1,6 @@
1
- const TIER_RANK = { 'S+': 0, 'S': 1, 'A+': 2, 'A': 3, 'A-': 4, 'B+': 5, 'B': 6, 'C': 7 }
2
-
3
- function parseCtxK(ctx) {
4
- if (!ctx) return 0
5
- const s = ctx.toLowerCase()
6
- if (s.endsWith('m')) return parseFloat(s) * 1000
7
- return parseFloat(s) || 0
8
- }
1
+ import { parseCtxToK, parseSweToNum } from './utils.js'
9
2
 
10
- function parseSwePercent(swe) {
11
- return parseFloat(swe) || 0
12
- }
3
+ const TIER_RANK = { 'S+': 0, 'S': 1, 'A+': 2, 'A': 3, 'A-': 4, 'B+': 5, 'B': 6, 'C': 7 }
13
4
 
14
5
  /**
15
6
  * Generate a unique slug from a label.
@@ -60,11 +51,11 @@ export function buildMergedModels(models) {
60
51
  group.tier = tier
61
52
  }
62
53
  // Keep highest SWE score
63
- if (parseSwePercent(sweScore) > parseSwePercent(group.sweScore)) {
54
+ if (parseSweToNum(sweScore) > parseSweToNum(group.sweScore)) {
64
55
  group.sweScore = sweScore
65
56
  }
66
57
  // Keep largest context
67
- if (parseCtxK(ctx) > parseCtxK(group.ctx)) {
58
+ if (parseCtxToK(ctx) > parseCtxToK(group.ctx)) {
68
59
  group.ctx = ctx
69
60
  }
70
61
  }
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  import { displayWidth, sliceOverlayLines, tintOverlayLines } from '../tui/render-helpers.js'
25
- import { ROUTER_PORT_PATH } from './router-daemon.js'
25
+ import { ROUTER_PORT_PATH, getRouterPortPath } from './router-daemon.js'
26
26
  import { existsSync, readFileSync } from 'node:fs'
27
27
  import { themeColors } from '../tui/theme.js'
28
28
 
@@ -295,10 +295,12 @@ async function readSseStream(resp, fetchFn, controller) {
295
295
  * 📖 can stand alone in the TUI process.
296
296
  */
297
297
  async function readDaemonPort() {
298
- // 📖 Try the recorded port file first.
298
+ // 📖 Try the recorded port file first. Use the dynamic resolver so dev
299
+ // 📖 checkouts (FCM_DEV=1) read the `-dev` port file and find the dev daemon.
299
300
  try {
300
- if (existsSync(ROUTER_PORT_PATH)) {
301
- const raw = readFileSync(ROUTER_PORT_PATH, 'utf8').trim()
301
+ const portPath = getRouterPortPath()
302
+ if (existsSync(portPath)) {
303
+ const raw = readFileSync(portPath, 'utf8').trim()
302
304
  if (/^\d+$/.test(raw)) return Number(raw)
303
305
  }
304
306
  } catch {}
@@ -51,6 +51,8 @@ import { buildChatCompletionPingBody, ping, resolveCloudflareUrl, shouldUseDisab
51
51
  import { benchmarkModel, BENCHMARK_TIMEOUT_MS } from './benchmark.js'
52
52
  import { loadChangelog } from './changelog-loader.js'
53
53
  import { sendUsageTelemetry } from './telemetry.js'
54
+ import { TIER_ORDER } from './utils.js'
55
+ import { atomicWriteJson, safeJsonParse, sleep, maskApiKey, isRouteableProvider } from './shared-helpers.js'
54
56
 
55
57
  export const ROUTER_DEFAULT_PORT = 19280
56
58
  export const ROUTER_MAX_PORT = 19289
@@ -59,15 +61,28 @@ export const ROUTER_MAX_PORT_DEV = 29289
59
61
 
60
62
  // 📖 Dev mode uses -dev suffixed files so the local dev daemon never clashes
61
63
  // 📖 with a production install running on the same machine.
62
- const _dev = typeof process.env.FCM_DEV !== 'undefined' ? !!process.env.FCM_DEV : false
64
+ // 📖 IMPORTANT: _isDev() is a function, not a constant, so it picks up FCM_DEV
65
+ // 📖 changes that happen after module load (e.g. the bin entry point setting
66
+ // 📖 FCM_DEV=1 on git checkouts). Constant exports for PID/PORT/LOG paths
67
+ // 📖 are still computed eagerly — they are only used by the daemon child process
68
+ // 📖 which always has FCM_DEV set before import. The TUI and dashboard use
69
+ // 📖 getRouterPortRange() and getRouterPidPath() for dynamic resolution.
70
+ function _isDev() { return typeof process.env.FCM_DEV !== 'undefined' ? !!process.env.FCM_DEV : false }
71
+ const _dev = _isDev()
63
72
  export const ROUTER_PID_PATH = join(homedir(), `.free-coding-models-daemon${_dev ? '-dev' : ''}.pid`)
64
73
  export const ROUTER_PORT_PATH = join(homedir(), `.free-coding-models-daemon${_dev ? '-dev' : ''}.port`)
65
74
  export const ROUTER_LOG_PATH = join(homedir(), `.free-coding-models-daemon${_dev ? '-dev' : ''}.log`)
66
75
  export const ROUTER_TOKENS_PATH = join(homedir(), `.free-coding-models-tokens${_dev ? '-dev' : ''}.json`)
67
76
 
77
+ // 📖 Dynamic path resolvers — used by the TUI dashboard which may have FCM_DEV
78
+ // 📖 set after module load time (git checkout auto-detection in bin/ entry).
79
+ export function getRouterPidPath() { return join(homedir(), `.free-coding-models-daemon${_isDev() ? '-dev' : ''}.pid`) }
80
+ export function getRouterPortPath() { return join(homedir(), `.free-coding-models-daemon${_isDev() ? '-dev' : ''}.port`) }
81
+ export function getRouterLogPath() { return join(homedir(), `.free-coding-models-daemon${_isDev() ? '-dev' : ''}.log`) }
82
+
68
83
  // 📖 Returns effective port range for current mode (dev vs production)
69
84
  export function getRouterPortRange() {
70
- return _dev
85
+ return _isDev()
71
86
  ? { defaultPort: ROUTER_DEFAULT_PORT_DEV, maxPort: ROUTER_MAX_PORT_DEV }
72
87
  : { defaultPort: ROUTER_DEFAULT_PORT, maxPort: ROUTER_MAX_PORT }
73
88
  }
@@ -83,7 +98,6 @@ const MAX_PROBE_WINDOW = 20
83
98
  const TOKEN_FLUSH_INTERVAL_MS = 60000
84
99
  const CONFIG_RELOAD_INTERVAL_MS = 10000
85
100
  const STATS_RETENTION_DAYS = 90
86
- const TIER_ORDER = ['S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
87
101
  const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503])
88
102
  const AUTH_STATUS_CODES = new Set([401, 403])
89
103
  const RATE_LIMIT_HEADER_NAMES = [
@@ -110,14 +124,7 @@ function modelKey(provider, model) {
110
124
  return `${provider}/${model}`
111
125
  }
112
126
 
113
- function safeJsonParse(raw, fallback = null) {
114
- try {
115
- return JSON.parse(raw)
116
- } catch {
117
- return fallback
118
- }
119
- }
120
-
127
+ // 📖 parseJsonResult is still local — it returns {ok, value/error} which is different from safeJsonParse
121
128
  function parseJsonResult(raw) {
122
129
  try {
123
130
  return { ok: true, value: JSON.parse(raw) }
@@ -126,16 +133,6 @@ function parseJsonResult(raw) {
126
133
  }
127
134
  }
128
135
 
129
- function atomicWriteJson(path, data, mode = 0o600) {
130
- const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`
131
- writeFileSync(tempPath, JSON.stringify(data, null, 2), { mode })
132
- renameSync(tempPath, path)
133
- }
134
-
135
- function sleep(ms) {
136
- return new Promise((resolve) => setTimeout(resolve, ms))
137
- }
138
-
139
136
  function isProcessAlive(pid) {
140
137
  if (!Number.isInteger(pid) || pid <= 0) return false
141
138
  try {
@@ -208,12 +205,6 @@ function isLikelyHtmlResponse(headers, text = '') {
208
205
 
209
206
  // ─── Web Dashboard Helpers ─────────────────────────────────────────────────────
210
207
 
211
- function maskApiKey(key) {
212
- if (!key || typeof key !== 'string') return ''
213
- if (key.length <= 8) return '••••••••'
214
- return '••••••••' + key.slice(-4)
215
- }
216
-
217
208
  // 📖 Same-origin / loopback check for state-changing or secret-revealing
218
209
  // 📖 endpoints. Blocks CSRF from malicious tabs and key exfiltration from
219
210
  // 📖 cross-origin scripts. Plain CLI calls (curl/fetch without Origin) are
@@ -540,11 +531,6 @@ function getApiModelId(providerKey, modelId) {
540
531
  return providerKey === 'zai' ? modelId.replace(/^zai\//, '') : modelId
541
532
  }
542
533
 
543
- function isRouteableProvider(providerKey) {
544
- const source = sources[providerKey]
545
- return Boolean(source?.url && !source.cliOnly && source.url.includes('/chat/completions'))
546
- }
547
-
548
534
  function resolveProviderUrl(providerKey) {
549
535
  const url = sources[providerKey]?.url
550
536
  if (!url) return null
@@ -898,7 +884,7 @@ class RouterRuntime {
898
884
  tier,
899
885
  sweScore,
900
886
  ctx,
901
- routeable: isRouteableProvider(providerKey),
887
+ routeable: isRouteableProvider(providerKey, sources),
902
888
  })
903
889
  }
904
890
  }
@@ -1178,6 +1164,25 @@ class RouterRuntime {
1178
1164
  })
1179
1165
  }
1180
1166
 
1167
+ // 📖 getRoutingCandidates — the ordered list of models the router will try,
1168
+ // 📖 in EXACT attempt order. This is the heart of routing.
1169
+ // 📖
1170
+ // 📖 Strategy (priority-first): the user's priority order is authoritative.
1171
+ // 📖 A model ranked #1 is always tried first while it is healthy, even if a
1172
+ // 📖 lower-priority model has a better health score. The health score is only
1173
+ // 📖 used to break ties between models that share the same priority — which
1174
+ // 📖 happens in practice when multiple models tie because they have no probe
1175
+ // 📖 data yet (cold start) or identical stats.
1176
+ // 📖
1177
+ // 📖 Why: before this, priority was only 20% of the score and a fast
1178
+ // 📖 low-priority model could steal traffic from a deliberately higher-ranked
1179
+ // 📖 one (see issue #120 — GPT-OSS 120B served despite higher-priority models
1180
+ // 📖 being healthy). Users set the fallback chain on purpose; routing must
1181
+ // 📖 respect it.
1182
+ // 📖
1183
+ // 📖 Circuit-breaker safety is preserved: CLOSED (healthy) models always come
1184
+ // 📖 before HALF_OPEN (probing after cooldown) models, so a recovering model
1185
+ // 📖 never pre-empts a known-good one.
1181
1186
  getRoutingCandidates(set) {
1182
1187
  const scored = this.scoreCandidates(set)
1183
1188
  const usable = scored.filter((candidate) => {
@@ -1189,8 +1194,26 @@ class RouterRuntime {
1189
1194
  })
1190
1195
  const closed = usable.filter((candidate) => candidate.circuit.state === 'CLOSED')
1191
1196
  const halfOpen = usable.filter((candidate) => candidate.circuit.state === 'HALF_OPEN')
1192
- const byScore = (a, b) => b.score - a.score || a.priority - b.priority
1193
- return [...closed.sort(byScore), ...halfOpen.sort(byScore)]
1197
+ // 📖 Priority ascending (1 before 2); within the same priority, healthier
1198
+ // 📖 score wins so cold-start ties resolve deterministically.
1199
+ const byPriorityThenHealth = (a, b) => a.priority - b.priority || b.score - a.score
1200
+ return [...closed.sort(byPriorityThenHealth), ...halfOpen.sort(byPriorityThenHealth)]
1201
+ }
1202
+
1203
+ // 📖 getRoutingOrder — slim projection of getRoutingCandidates for the /stats
1204
+ // 📖 payload and dashboards. Exposes the EXACT order the router will attempt
1205
+ // 📖 on the next request, so the UI can mark the model that will serve it
1206
+ // 📖 (routingOrder[0]) and label every entry as Primary vs Fallback.
1207
+ // 📖 Cheap to compute: reuses getRoutingCandidates + already-recorded health.
1208
+ getRoutingOrder(set) {
1209
+ return this.getRoutingCandidates(set).map((candidate) => ({
1210
+ key: candidate.key,
1211
+ provider: candidate.provider,
1212
+ model: candidate.model,
1213
+ priority: candidate.priority,
1214
+ state: candidate.circuit?.state || 'UNKNOWN',
1215
+ score: Number(candidate.score.toFixed(4)),
1216
+ }))
1194
1217
  }
1195
1218
 
1196
1219
  getModelHealth(set = this.getSet()) {
@@ -1210,6 +1233,11 @@ class RouterRuntime {
1210
1233
  last_latency_ms: candidate.stats.last?.latencyMs ?? null,
1211
1234
  uptime: candidate.stats.uptime,
1212
1235
  last_error: candidate.circuit?.lastError || null,
1236
+ // 📖 AI Latency benchmark results for the Router Dashboard's "Probe all"
1237
+ // 📖 button. Mirrors the per-model fields already exposed on /api/models
1238
+ // 📖 so the set list can show live AI latency + TPS after a probe.
1239
+ isBenchmarking: this.webBenchmarkRunning?.has(candidate.key) || false,
1240
+ benchmark: this.webBenchmarkResults?.get(candidate.key) || null,
1213
1241
  }))
1214
1242
  }
1215
1243
 
@@ -1346,6 +1374,11 @@ class RouterRuntime {
1346
1374
  const activeSet = this.getSet(router.activeSet)
1347
1375
  return {
1348
1376
  ok: true,
1377
+ // 📖 `running` mirrors `ok` so every consumer (Router view reads `ok`,
1378
+ // 📖 Playground reads `running`) agrees on daemon state. Without this,
1379
+ // 📖 the Playground showed "router offline" even when the Router card
1380
+ // 📖 said "Running" — both hit /api/router/status but read different fields.
1381
+ running: true,
1349
1382
  pid: process.pid,
1350
1383
  port: this.port,
1351
1384
  enabled: router.enabled,
@@ -1386,6 +1419,19 @@ class RouterRuntime {
1386
1419
  ...this.statusPayload(),
1387
1420
  tokens: this.tokenTracker.summary(),
1388
1421
  models: this.getModelHealth(activeSet),
1422
+ // 📖 routingOrder — the exact attempt order for the next request
1423
+ // 📖 (priority-first among healthy models). routingOrder[0] is what will
1424
+ // 📖 serve the next chat completion. Surfaced so dashboards can mark the
1425
+ // 📖 "next" model and label Primary vs Fallback semantics. See issue #120.
1426
+ routingOrder: this.getRoutingOrder(activeSet),
1427
+ // 📖 Global AI Latency probe progress — powers the Router Dashboard's
1428
+ // 📖 "Probe all" button progress bar. Per-model results live on each
1429
+ // 📖 entry of `models` above (isBenchmarking / benchmark).
1430
+ globalBenchmark: {
1431
+ running: this.webGlobalBenchmarkRunning || false,
1432
+ total: this.webGlobalBenchmarkTotal || 0,
1433
+ completed: this.webGlobalBenchmarkCompleted || 0,
1434
+ },
1389
1435
  requestLog: this.requestLog.slice(0, 20),
1390
1436
  circuitBreakers: Object.fromEntries([...this.circuit.entries()].map(([key, value]) => [key, {
1391
1437
  state: value.authError ? 'AUTH_ERROR' : value.stale ? 'STALE' : value.unsupported ? 'UNSUPPORTED' : value.state,
@@ -1501,7 +1547,7 @@ class RouterRuntime {
1501
1547
  // 📖 skip that provider as a candidate for replacements.
1502
1548
  const providerProbeStats = new Map() // provider -> { probed: n, authError: n, stale: n, alive: n }
1503
1549
  for (const [providerKey, source] of Object.entries(sources)) {
1504
- if (!isRouteableProvider(providerKey)) continue
1550
+ if (!isRouteableProvider(providerKey, sources)) continue
1505
1551
  if (!providerProbeStats.has(providerKey)) providerProbeStats.set(providerKey, { probed: 0, authError: 0, stale: 0, alive: 0 })
1506
1552
  for (const [modelId, , tier, sweScore, ctx] of source.models || []) {
1507
1553
  const key = `${providerKey}/${modelId}`
@@ -2646,7 +2692,7 @@ class RouterRuntime {
2646
2692
  if (req.method === 'GET' && url.pathname === '/api/router/catalog') {
2647
2693
  const rows = []
2648
2694
  for (const [providerKey, source] of Object.entries(sources)) {
2649
- if (!isRouteableProvider(providerKey)) continue
2695
+ if (!isRouteableProvider(providerKey, sources)) continue
2650
2696
  if (!Array.isArray(source.models)) continue
2651
2697
  for (const [modelId, label, tier, sweScore, ctx] of source.models) {
2652
2698
  rows.push({
@@ -3028,7 +3074,7 @@ export async function buildDefaultRouterSet(config = {}, maxModels, options = {}
3028
3074
  if (maxModels === undefined) maxModels = Math.max(5, keyedProviders.size * 2)
3029
3075
  const entries = []
3030
3076
  for (const [providerKey, source] of Object.entries(sources)) {
3031
- if (!isRouteableProvider(providerKey)) continue
3077
+ if (!isRouteableProvider(providerKey, sources)) continue
3032
3078
  for (const [model, label, tier, sweScore, ctx] of source.models || []) {
3033
3079
  entries.push({
3034
3080
  provider: providerKey,
@@ -3184,7 +3230,7 @@ export function createRouterRuntimeForTest({ config, port = 0, logger = null, to
3184
3230
  function createDefaultProbeFn(apiKeys) {
3185
3231
  return async (entry) => {
3186
3232
  const { provider, model } = entry
3187
- if (!isRouteableProvider(provider)) return { ok: false, code: 'NOT_ROUTEABLE', latencyMs: 0 }
3233
+ if (!isRouteableProvider(provider, sources)) return { ok: false, code: 'NOT_ROUTEABLE', latencyMs: 0 }
3188
3234
  const url = resolveProviderUrl(provider)
3189
3235
  if (!url) return { ok: false, code: 'NO_URL', latencyMs: 0 }
3190
3236
  const apiKey = getApiKey({ apiKeys: apiKeys || {} }, provider) || ''
@@ -3239,7 +3285,7 @@ function buildDefaultRouterSetSync(config = {}, maxModels = 5) {
3239
3285
  .map(([provider]) => provider))
3240
3286
  const entries = []
3241
3287
  for (const [providerKey, source] of Object.entries(sources)) {
3242
- if (!isRouteableProvider(providerKey)) continue
3288
+ if (!isRouteableProvider(providerKey, sources)) continue
3243
3289
  for (const [model, label, tier, sweScore, ctx] of source.models || []) {
3244
3290
  entries.push({ provider: providerKey, model, label, tier, sweScore, ctx, hasKey: keyedProviders.has(providerKey) })
3245
3291
  }
@@ -3323,7 +3369,7 @@ function buildRouterSetFromFavorites(config) {
3323
3369
  if (slashIdx < 0) continue
3324
3370
  const providerKey = fav.slice(0, slashIdx)
3325
3371
  const modelId = fav.slice(slashIdx + 1)
3326
- if (!isRouteableProvider(providerKey)) continue
3372
+ if (!isRouteableProvider(providerKey, sources)) continue
3327
3373
  const source = sources[providerKey]
3328
3374
  if (!source) continue
3329
3375
  const found = (source.models || []).find((m) => m[0] === modelId)
@@ -3381,6 +3427,13 @@ async function listenWithFallback(server, preferredPort, logger, host = '127.0.0
3381
3427
  export async function runRouterDaemon() {
3382
3428
  const config = loadConfig()
3383
3429
  const router = await ensureRouterConfigForDaemon(config)
3430
+ // 📖 In dev mode, override the saved port with the dev default so a local
3431
+ // 📖 checkout doesn't clash with a production install on the same machine.
3432
+ // 📖 The saved config has port: 19280 (production); dev should use 29280.
3433
+ const { defaultPort: devDefault } = getRouterPortRange()
3434
+ if (_dev && router.port !== devDefault && router.port === DEFAULT_ROUTER_SETTINGS.port) {
3435
+ router.port = devDefault
3436
+ }
3384
3437
  const logger = new RouterLogger(ROUTER_LOG_PATH, router.logLevel)
3385
3438
  const runtime = new RouterRuntime({ config, port: router.port, logger })
3386
3439
  runtime.installProcessSafety()
@@ -3443,7 +3496,12 @@ export async function runRouterDaemon() {
3443
3496
  export async function getRouterDaemonStatus() {
3444
3497
  const { defaultPort, maxPort } = getRouterPortRange()
3445
3498
  const ports = []
3446
- const recordedPort = readNumberFile(ROUTER_PORT_PATH)
3499
+ // 📖 Use the dynamic path resolvers so dev checkouts (FCM_DEV=1) read the
3500
+ // 📖 `-dev` port/pid files and discover the dev daemon. The static constants
3501
+ // 📖 are frozen at module load and would always point at the production files.
3502
+ const portPath = getRouterPortPath()
3503
+ const pidPath = getRouterPidPath()
3504
+ const recordedPort = readNumberFile(portPath)
3447
3505
  if (recordedPort) ports.push(recordedPort)
3448
3506
  for (let port = defaultPort; port <= maxPort; port += 1) {
3449
3507
  if (!ports.includes(port)) ports.push(port)
@@ -3456,7 +3514,7 @@ export async function getRouterDaemonStatus() {
3456
3514
  // 📖 Keep scanning the small discovery range.
3457
3515
  }
3458
3516
  }
3459
- const pid = readNumberFile(ROUTER_PID_PATH)
3517
+ const pid = readNumberFile(pidPath)
3460
3518
  return {
3461
3519
  ok: false,
3462
3520
  running: false,