free-coding-models 0.5.27 → 0.5.29

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 (37) hide show
  1. package/bin/free-coding-models.js +10 -2
  2. package/changelog/v0.5.27.md +2 -0
  3. package/changelog/v0.5.28.md +5 -0
  4. package/changelog/v0.5.29.md +19 -0
  5. package/package.json +1 -1
  6. package/sources.js +3 -2
  7. package/src/core/config.js +25 -18
  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/router-daemon.js +160 -43
  12. package/src/core/router-dashboard.js +63 -25
  13. package/src/core/shared-helpers.js +117 -0
  14. package/src/core/sync-set.js +6 -21
  15. package/src/core/tool-launchers.js +24 -92
  16. package/src/core/utils.js +26 -100
  17. package/src/tui/app.js +18 -2
  18. package/src/tui/command-palette.js +3 -1
  19. package/src/tui/key-handler.js +229 -23
  20. package/src/tui/overlays.js +13 -1
  21. package/src/tui/render-table.js +21 -5
  22. package/src/tui/tui-state.js +6 -0
  23. package/web/dist/assets/index-BMU58Jju.js +41 -0
  24. package/web/dist/assets/index-Dd1jOGEn.css +1 -0
  25. package/web/dist/index.html +2 -2
  26. package/web/server.js +5 -0
  27. package/web/src/components/atoms/HealthCell.jsx +12 -7
  28. package/web/src/components/atoms/HealthCell.module.css +4 -0
  29. package/web/src/components/atoms/StatusDot.jsx +5 -2
  30. package/web/src/components/atoms/StatusDot.module.css +12 -7
  31. package/web/src/components/dashboard/ModelTable.jsx +5 -2
  32. package/web/src/components/dashboard/ModelTable.module.css +9 -0
  33. package/web/src/components/router/RouterView.jsx +124 -56
  34. package/web/src/components/router/RouterView.module.css +135 -1
  35. package/src/core/product-flags.js +0 -9
  36. package/web/dist/assets/index-JUPDB-J-.js +0 -41
  37. package/web/dist/assets/index-avN2GM3H.css +0 -1
@@ -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
 
@@ -13,3 +13,5 @@
13
13
 
14
14
  ### Fixed
15
15
  - Fixed first-run usability for keyless providers by allowing Kilo and LLM7 to count as usable providers without forcing the API key wizard.
16
+ - Fixed Docker dashboard 404s by serving the Web Router Dashboard aliases (`/api/router/status`, `/api/router/stats`, `/api/router/sets`, `/api/router/tokens`, `/api/router/quick-setup`) and `/api/changelog` directly from the router daemon.
17
+ - Fixed confusing "pending" status for models not in the active router set. Models outside the set now show a distinct "NOT IN SET" label with a dim dot and faded row, instead of the animated yellow "wait" indicator. Applies to Web Dashboard (both Docker daemon and local dev mode).
@@ -0,0 +1,5 @@
1
+ # Changelog v0.5.28 - 2026-06-11
2
+
3
+ ### Fixed
4
+ - Fixed Docker dashboard 404s — the router daemon now serves 10 web dashboard API aliases (`/api/router/status`, `/api/router/stats`, `/api/router/sets`, `/api/router/tokens`, `/api/router/quick-setup`, `/api/router/start`, `/api/router/stop`, `/api/router/probe-mode`, `/api/router/sets/:name/*`, `/api/changelog`) directly, so the React frontend works identically in Docker mode and local dev. Cross-origin guards protect mutating endpoints. (Fixes #116, reported by @stgreenb)
5
+ - Fixed confusing "pending" status for models not in the active router set — models outside the set now show a distinct "NOT IN SET" label with a dim gray dot and faded row opacity, instead of the animated yellow "wait" indicator. The `inRouterSet` field is now exposed by both the Docker daemon and the local web server. (Fixes #117, reported by @stgreenb)
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.5.27",
3
+ "version": "0.5.29",
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
  }
@@ -210,6 +210,18 @@ function normalizeFavoriteList(favorites) {
210
210
  return normalized
211
211
  }
212
212
 
213
+ // 📖 normalizeStringSet: Convert an array (from JSON) or existing Set into a Set<string>.
214
+ // 📖 Used for hiddenModels and similar set-shaped config fields.
215
+ function normalizeStringSet(value) {
216
+ if (value instanceof Set) return value
217
+ if (!Array.isArray(value)) return new Set()
218
+ const result = new Set()
219
+ for (const entry of value) {
220
+ if (typeof entry === 'string' && entry.trim()) result.add(entry.trim())
221
+ }
222
+ return result
223
+ }
224
+
213
225
  function normalizeApiKeyValue(value) {
214
226
  if (Array.isArray(value)) {
215
227
  const normalized = []
@@ -263,6 +275,7 @@ function normalizeSettingsSection(settings) {
263
275
  hideUnconfiguredModels: typeof safeSettings.hideUnconfiguredModels === 'boolean' ? safeSettings.hideUnconfiguredModels : true,
264
276
  favoritesPinnedAndSticky: typeof safeSettings.favoritesPinnedAndSticky === 'boolean' ? safeSettings.favoritesPinnedAndSticky : false,
265
277
  runAiSpeedTestOnStartup: typeof safeSettings.runAiSpeedTestOnStartup === 'boolean' ? safeSettings.runAiSpeedTestOnStartup : false,
278
+ autoHideBrokenModels: typeof safeSettings.autoHideBrokenModels === 'boolean' ? safeSettings.autoHideBrokenModels : true,
266
279
  theme: ['dark', 'light', 'auto'].includes(safeSettings.theme) ? safeSettings.theme : 'auto',
267
280
  }
268
281
  }
@@ -451,16 +464,6 @@ export function defaultRouterPrePromptText() {
451
464
  return DEFAULT_ROUTER_SETTINGS.prePrompt.text
452
465
  }
453
466
 
454
- function normalizeProfileSettings(settings) {
455
- const safeSettings = isPlainObject(settings) ? { ...settings } : {}
456
- return {
457
- ..._emptyProfileSettings(),
458
- ...safeSettings,
459
- theme: ['dark', 'light', 'auto'].includes(safeSettings.theme) ? safeSettings.theme : 'auto',
460
- }
461
- }
462
-
463
-
464
467
 
465
468
  function normalizeConfigShape(config) {
466
469
  const safeConfig = isPlainObject(config) ? config : {}
@@ -471,8 +474,9 @@ function normalizeConfigShape(config) {
471
474
  favorites: normalizeFavoriteList(safeConfig.favorites),
472
475
  telemetry: normalizeTelemetrySection(safeConfig.telemetry),
473
476
  endpointInstalls: normalizeEndpointInstalls(safeConfig.endpointInstalls),
474
-
475
-
477
+ // 📖 hiddenModels: Set of "provider/modelId" keys auto-hidden by the 404 probe (Ctrl+Shift+P).
478
+ // 📖 Only populated when settings.autoHideBrokenModels is true (default).
479
+ hiddenModels: normalizeStringSet(safeConfig.hiddenModels),
476
480
  }
477
481
  const normalizedRouter = normalizeRouterConfig(safeConfig.router)
478
482
  if (normalizedRouter) normalized.router = normalizedRouter
@@ -513,10 +517,7 @@ function mergeEndpointInstalls(diskEndpointInstalls, incomingEndpointInstalls) {
513
517
  return [...merged.values()]
514
518
  }
515
519
 
516
- function mergeProfiles(diskProfiles, incomingProfiles, options = {}) {
517
- // 📖 Profile system removed - return empty object
518
- return {}
519
- }
520
+
520
521
 
521
522
  /**
522
523
  * 📖 buildPersistedConfig merges the latest disk snapshot with the in-memory config so
@@ -684,8 +685,12 @@ export function saveConfig(config, options = {}) {
684
685
 
685
686
  try {
686
687
  const persistedConfig = buildPersistedConfig(config, readStoredConfigSnapshot(), options)
687
- const json = JSON.stringify(persistedConfig, null, 2)
688
- writeFileSync(tempPath, json, { mode: 0o600 })
688
+ // 📖 Serialize Sets to arrays for JSON compatibility (e.g. hiddenModels)
689
+ const jsonSafe = JSON.stringify(persistedConfig, (key, value) => {
690
+ if (value instanceof Set) return [...value]
691
+ return value
692
+ }, 2)
693
+ writeFileSync(tempPath, jsonSafe, { mode: 0o600 })
689
694
  renameSync(tempPath, CONFIG_PATH)
690
695
 
691
696
  // 📖 Verify the write succeeded by reading back and validating
@@ -1082,6 +1087,7 @@ export function _emptyProfileSettings() {
1082
1087
  hideUnconfiguredModels: true, // 📖 true = default to providers that are actually configured
1083
1088
  favoritesPinnedAndSticky: false, // 📖 default mode keeps favorites as normal starred rows; press Y to pin+stick them.
1084
1089
  runAiSpeedTestOnStartup: false, // 📖 opt-in: automatically fire the Ctrl+U global AI Speed Test after startup.
1090
+ autoHideBrokenModels: true, // 📖 opt-out: auto-hide models that return 404/410 from probe (Ctrl+Shift+P).
1085
1091
  preferredToolMode: 'opencode', // 📖 remember the last Z-selected launcher across app restarts
1086
1092
  theme: 'auto', // 📖 'auto' follows the terminal/OS theme, override with 'dark' or 'light' if needed
1087
1093
  }
@@ -1131,5 +1137,6 @@ function _emptyConfig() {
1131
1137
  telemetry: { enabled: null, consentVersion: 0, anonymousId: null },
1132
1138
  endpointInstalls: [],
1133
1139
  settings: _emptyProfileSettings(),
1140
+ hiddenModels: new Set(),
1134
1141
  }
1135
1142
  }
@@ -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
  }
@@ -49,7 +49,10 @@ import {
49
49
  } from './config.js'
50
50
  import { buildChatCompletionPingBody, ping, resolveCloudflareUrl, shouldUseDisabledThinkingForProvider } from './ping.js'
51
51
  import { benchmarkModel, BENCHMARK_TIMEOUT_MS } from './benchmark.js'
52
+ import { loadChangelog } from './changelog-loader.js'
52
53
  import { sendUsageTelemetry } from './telemetry.js'
54
+ import { TIER_ORDER } from './utils.js'
55
+ import { atomicWriteJson, safeJsonParse, sleep, maskApiKey, isRouteableProvider } from './shared-helpers.js'
53
56
 
54
57
  export const ROUTER_DEFAULT_PORT = 19280
55
58
  export const ROUTER_MAX_PORT = 19289
@@ -58,15 +61,28 @@ export const ROUTER_MAX_PORT_DEV = 29289
58
61
 
59
62
  // 📖 Dev mode uses -dev suffixed files so the local dev daemon never clashes
60
63
  // 📖 with a production install running on the same machine.
61
- 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()
62
72
  export const ROUTER_PID_PATH = join(homedir(), `.free-coding-models-daemon${_dev ? '-dev' : ''}.pid`)
63
73
  export const ROUTER_PORT_PATH = join(homedir(), `.free-coding-models-daemon${_dev ? '-dev' : ''}.port`)
64
74
  export const ROUTER_LOG_PATH = join(homedir(), `.free-coding-models-daemon${_dev ? '-dev' : ''}.log`)
65
75
  export const ROUTER_TOKENS_PATH = join(homedir(), `.free-coding-models-tokens${_dev ? '-dev' : ''}.json`)
66
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
+
67
83
  // 📖 Returns effective port range for current mode (dev vs production)
68
84
  export function getRouterPortRange() {
69
- return _dev
85
+ return _isDev()
70
86
  ? { defaultPort: ROUTER_DEFAULT_PORT_DEV, maxPort: ROUTER_MAX_PORT_DEV }
71
87
  : { defaultPort: ROUTER_DEFAULT_PORT, maxPort: ROUTER_MAX_PORT }
72
88
  }
@@ -82,7 +98,6 @@ const MAX_PROBE_WINDOW = 20
82
98
  const TOKEN_FLUSH_INTERVAL_MS = 60000
83
99
  const CONFIG_RELOAD_INTERVAL_MS = 10000
84
100
  const STATS_RETENTION_DAYS = 90
85
- const TIER_ORDER = ['S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
86
101
  const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503])
87
102
  const AUTH_STATUS_CODES = new Set([401, 403])
88
103
  const RATE_LIMIT_HEADER_NAMES = [
@@ -109,14 +124,7 @@ function modelKey(provider, model) {
109
124
  return `${provider}/${model}`
110
125
  }
111
126
 
112
- function safeJsonParse(raw, fallback = null) {
113
- try {
114
- return JSON.parse(raw)
115
- } catch {
116
- return fallback
117
- }
118
- }
119
-
127
+ // 📖 parseJsonResult is still local — it returns {ok, value/error} which is different from safeJsonParse
120
128
  function parseJsonResult(raw) {
121
129
  try {
122
130
  return { ok: true, value: JSON.parse(raw) }
@@ -125,16 +133,6 @@ function parseJsonResult(raw) {
125
133
  }
126
134
  }
127
135
 
128
- function atomicWriteJson(path, data, mode = 0o600) {
129
- const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`
130
- writeFileSync(tempPath, JSON.stringify(data, null, 2), { mode })
131
- renameSync(tempPath, path)
132
- }
133
-
134
- function sleep(ms) {
135
- return new Promise((resolve) => setTimeout(resolve, ms))
136
- }
137
-
138
136
  function isProcessAlive(pid) {
139
137
  if (!Number.isInteger(pid) || pid <= 0) return false
140
138
  try {
@@ -207,12 +205,6 @@ function isLikelyHtmlResponse(headers, text = '') {
207
205
 
208
206
  // ─── Web Dashboard Helpers ─────────────────────────────────────────────────────
209
207
 
210
- function maskApiKey(key) {
211
- if (!key || typeof key !== 'string') return ''
212
- if (key.length <= 8) return '••••••••'
213
- return '••••••••' + key.slice(-4)
214
- }
215
-
216
208
  // 📖 Same-origin / loopback check for state-changing or secret-revealing
217
209
  // 📖 endpoints. Blocks CSRF from malicious tabs and key exfiltration from
218
210
  // 📖 cross-origin scripts. Plain CLI calls (curl/fetch without Origin) are
@@ -539,11 +531,6 @@ function getApiModelId(providerKey, modelId) {
539
531
  return providerKey === 'zai' ? modelId.replace(/^zai\//, '') : modelId
540
532
  }
541
533
 
542
- function isRouteableProvider(providerKey) {
543
- const source = sources[providerKey]
544
- return Boolean(source?.url && !source.cliOnly && source.url.includes('/chat/completions'))
545
- }
546
-
547
534
  function resolveProviderUrl(providerKey) {
548
535
  const url = sources[providerKey]?.url
549
536
  if (!url) return null
@@ -897,7 +884,7 @@ class RouterRuntime {
897
884
  tier,
898
885
  sweScore,
899
886
  ctx,
900
- routeable: isRouteableProvider(providerKey),
887
+ routeable: isRouteableProvider(providerKey, sources),
901
888
  })
902
889
  }
903
890
  }
@@ -1177,6 +1164,25 @@ class RouterRuntime {
1177
1164
  })
1178
1165
  }
1179
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.
1180
1186
  getRoutingCandidates(set) {
1181
1187
  const scored = this.scoreCandidates(set)
1182
1188
  const usable = scored.filter((candidate) => {
@@ -1188,8 +1194,26 @@ class RouterRuntime {
1188
1194
  })
1189
1195
  const closed = usable.filter((candidate) => candidate.circuit.state === 'CLOSED')
1190
1196
  const halfOpen = usable.filter((candidate) => candidate.circuit.state === 'HALF_OPEN')
1191
- const byScore = (a, b) => b.score - a.score || a.priority - b.priority
1192
- 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
+ }))
1193
1217
  }
1194
1218
 
1195
1219
  getModelHealth(set = this.getSet()) {
@@ -1385,6 +1409,11 @@ class RouterRuntime {
1385
1409
  ...this.statusPayload(),
1386
1410
  tokens: this.tokenTracker.summary(),
1387
1411
  models: this.getModelHealth(activeSet),
1412
+ // 📖 routingOrder — the exact attempt order for the next request
1413
+ // 📖 (priority-first among healthy models). routingOrder[0] is what will
1414
+ // 📖 serve the next chat completion. Surfaced so dashboards can mark the
1415
+ // 📖 "next" model and label Primary vs Fallback semantics. See issue #120.
1416
+ routingOrder: this.getRoutingOrder(activeSet),
1388
1417
  requestLog: this.requestLog.slice(0, 20),
1389
1418
  circuitBreakers: Object.fromEntries([...this.circuit.entries()].map(([key, value]) => [key, {
1390
1419
  state: value.authError ? 'AUTH_ERROR' : value.stale ? 'STALE' : value.unsupported ? 'UNSUPPORTED' : value.state,
@@ -1500,7 +1529,7 @@ class RouterRuntime {
1500
1529
  // 📖 skip that provider as a candidate for replacements.
1501
1530
  const providerProbeStats = new Map() // provider -> { probed: n, authError: n, stale: n, alive: n }
1502
1531
  for (const [providerKey, source] of Object.entries(sources)) {
1503
- if (!isRouteableProvider(providerKey)) continue
1532
+ if (!isRouteableProvider(providerKey, sources)) continue
1504
1533
  if (!providerProbeStats.has(providerKey)) providerProbeStats.set(providerKey, { probed: 0, authError: 0, stale: 0, alive: 0 })
1505
1534
  for (const [modelId, , tier, sweScore, ctx] of source.models || []) {
1506
1535
  const key = `${providerKey}/${modelId}`
@@ -2397,8 +2426,8 @@ class RouterRuntime {
2397
2426
  * 📖 with only the ones that come back 2xx. Returns the new set + a
2398
2427
  * 📖 sample of probe results so the UI can show "what changed".
2399
2428
  */
2400
- async handleSyncSetRequest(req, res, requestId) {
2401
- const url = req.url ? new URL(req.url, 'http://localhost') : null
2429
+ async handleSyncSetRequest(req, res, requestId, routeUrl = null) {
2430
+ const url = routeUrl || (req.url ? new URL(req.url, 'http://localhost') : null)
2402
2431
  const pathname = url ? url.pathname : ''
2403
2432
  const setSyncMatch = pathname.match(/^\/sets\/([^/]+)\/sync$/)
2404
2433
  if (!setSyncMatch) {
@@ -2524,6 +2553,87 @@ class RouterRuntime {
2524
2553
  await this.handleProbeModeRequest(req, res, requestId)
2525
2554
  return
2526
2555
  }
2556
+
2557
+ // 📖 Docker mode serves the built Web Dashboard directly from the daemon
2558
+ // 📖 on :19280. The React app uses the same `/api/router/*` routes as
2559
+ // 📖 local dev (`web/server.js`), so the daemon must expose aliases for
2560
+ // 📖 its canonical `/health`, `/stats`, and `/sets` APIs instead of
2561
+ // 📖 forcing the frontend to special-case Docker.
2562
+ if (req.method === 'GET' && url.pathname === '/api/router/status') {
2563
+ sendJson(res, 200, this.statusPayload(), { 'x-request-id': requestId })
2564
+ return
2565
+ }
2566
+ if (req.method === 'GET' && url.pathname === '/api/router/stats') {
2567
+ sendJson(res, 200, this.statsPayload(), { 'x-request-id': requestId })
2568
+ return
2569
+ }
2570
+ if (req.method === 'GET' && url.pathname === '/api/router/tokens') {
2571
+ sendJson(res, 200, this.tokenTracker.summary(), { 'x-request-id': requestId })
2572
+ return
2573
+ }
2574
+ if (req.method === 'GET' && url.pathname === '/api/router/quick-setup') {
2575
+ const router = this.routerConfig()
2576
+ sendJson(res, 200, {
2577
+ running: true,
2578
+ port: this.port,
2579
+ baseUrl: `http://127.0.0.1:${this.port}/v1`,
2580
+ model: 'fcm',
2581
+ activeSet: router.activeSet || DEFAULT_ROUTER_SETTINGS.activeSet,
2582
+ apiKey: 'not-needed',
2583
+ }, { 'x-request-id': requestId })
2584
+ return
2585
+ }
2586
+ if (url.pathname === '/api/router/start') {
2587
+ if (req.method !== 'POST') {
2588
+ sendError(res, 405, 'Method not allowed', 'invalid_request_error', 'method_not_allowed', requestId, { allowed: ['POST'] })
2589
+ return
2590
+ }
2591
+ if (!isSameOriginOrLocal(req)) {
2592
+ sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
2593
+ return
2594
+ }
2595
+ sendJson(res, 200, { ...this.statusPayload(), alreadyRunning: true }, { 'x-request-id': requestId })
2596
+ return
2597
+ }
2598
+ if (url.pathname === '/api/router/stop') {
2599
+ if (req.method !== 'POST') {
2600
+ sendError(res, 405, 'Method not allowed', 'invalid_request_error', 'method_not_allowed', requestId, { allowed: ['POST'] })
2601
+ return
2602
+ }
2603
+ if (!isSameOriginOrLocal(req)) {
2604
+ sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
2605
+ return
2606
+ }
2607
+ sendJson(res, 200, { ok: true, stopped: true, message: 'Daemon shutting down' }, { 'x-request-id': requestId })
2608
+ setTimeout(() => this.shutdown(0), 50)
2609
+ return
2610
+ }
2611
+ if (url.pathname === '/api/router/probe-mode' && req.method === 'POST') {
2612
+ if (!isSameOriginOrLocal(req)) {
2613
+ sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
2614
+ return
2615
+ }
2616
+ await this.handleProbeModeRequest(req, res, requestId)
2617
+ return
2618
+ }
2619
+ if (req.method === 'GET' && url.pathname === '/api/changelog') {
2620
+ sendJson(res, 200, loadChangelog(), { 'x-request-id': requestId })
2621
+ return
2622
+ }
2623
+ if (url.pathname === '/api/router/sets' || url.pathname.startsWith('/api/router/sets/')) {
2624
+ if (req.method !== 'GET' && !isSameOriginOrLocal(req)) {
2625
+ sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
2626
+ return
2627
+ }
2628
+ const aliasedUrl = new URL(req.url, `http://localhost:${this.port}`)
2629
+ aliasedUrl.pathname = aliasedUrl.pathname.replace(/^\/api\/router/, '')
2630
+ if (/^\/sets\/[^/]+\/sync$/.test(aliasedUrl.pathname) && req.method === 'POST') {
2631
+ await this.handleSyncSetRequest(req, res, requestId, aliasedUrl)
2632
+ return
2633
+ }
2634
+ await this.handleSetsRequest(req, res, aliasedUrl, requestId)
2635
+ return
2636
+ }
2527
2637
  if (url.pathname === '/sets' || url.pathname.startsWith('/sets/')) {
2528
2638
  // 📖 /sets/:name/sync has a different return type (rebuilds the
2529
2639
  // 📖 set from probes) so it gets its own handler.
@@ -2564,7 +2674,7 @@ class RouterRuntime {
2564
2674
  if (req.method === 'GET' && url.pathname === '/api/router/catalog') {
2565
2675
  const rows = []
2566
2676
  for (const [providerKey, source] of Object.entries(sources)) {
2567
- if (!isRouteableProvider(providerKey)) continue
2677
+ if (!isRouteableProvider(providerKey, sources)) continue
2568
2678
  if (!Array.isArray(source.models)) continue
2569
2679
  for (const [modelId, label, tier, sweScore, ctx] of source.models) {
2570
2680
  rows.push({
@@ -2946,7 +3056,7 @@ export async function buildDefaultRouterSet(config = {}, maxModels, options = {}
2946
3056
  if (maxModels === undefined) maxModels = Math.max(5, keyedProviders.size * 2)
2947
3057
  const entries = []
2948
3058
  for (const [providerKey, source] of Object.entries(sources)) {
2949
- if (!isRouteableProvider(providerKey)) continue
3059
+ if (!isRouteableProvider(providerKey, sources)) continue
2950
3060
  for (const [model, label, tier, sweScore, ctx] of source.models || []) {
2951
3061
  entries.push({
2952
3062
  provider: providerKey,
@@ -3102,7 +3212,7 @@ export function createRouterRuntimeForTest({ config, port = 0, logger = null, to
3102
3212
  function createDefaultProbeFn(apiKeys) {
3103
3213
  return async (entry) => {
3104
3214
  const { provider, model } = entry
3105
- if (!isRouteableProvider(provider)) return { ok: false, code: 'NOT_ROUTEABLE', latencyMs: 0 }
3215
+ if (!isRouteableProvider(provider, sources)) return { ok: false, code: 'NOT_ROUTEABLE', latencyMs: 0 }
3106
3216
  const url = resolveProviderUrl(provider)
3107
3217
  if (!url) return { ok: false, code: 'NO_URL', latencyMs: 0 }
3108
3218
  const apiKey = getApiKey({ apiKeys: apiKeys || {} }, provider) || ''
@@ -3157,7 +3267,7 @@ function buildDefaultRouterSetSync(config = {}, maxModels = 5) {
3157
3267
  .map(([provider]) => provider))
3158
3268
  const entries = []
3159
3269
  for (const [providerKey, source] of Object.entries(sources)) {
3160
- if (!isRouteableProvider(providerKey)) continue
3270
+ if (!isRouteableProvider(providerKey, sources)) continue
3161
3271
  for (const [model, label, tier, sweScore, ctx] of source.models || []) {
3162
3272
  entries.push({ provider: providerKey, model, label, tier, sweScore, ctx, hasKey: keyedProviders.has(providerKey) })
3163
3273
  }
@@ -3241,7 +3351,7 @@ function buildRouterSetFromFavorites(config) {
3241
3351
  if (slashIdx < 0) continue
3242
3352
  const providerKey = fav.slice(0, slashIdx)
3243
3353
  const modelId = fav.slice(slashIdx + 1)
3244
- if (!isRouteableProvider(providerKey)) continue
3354
+ if (!isRouteableProvider(providerKey, sources)) continue
3245
3355
  const source = sources[providerKey]
3246
3356
  if (!source) continue
3247
3357
  const found = (source.models || []).find((m) => m[0] === modelId)
@@ -3299,6 +3409,13 @@ async function listenWithFallback(server, preferredPort, logger, host = '127.0.0
3299
3409
  export async function runRouterDaemon() {
3300
3410
  const config = loadConfig()
3301
3411
  const router = await ensureRouterConfigForDaemon(config)
3412
+ // 📖 In dev mode, override the saved port with the dev default so a local
3413
+ // 📖 checkout doesn't clash with a production install on the same machine.
3414
+ // 📖 The saved config has port: 19280 (production); dev should use 29280.
3415
+ const { defaultPort: devDefault } = getRouterPortRange()
3416
+ if (_dev && router.port !== devDefault && router.port === DEFAULT_ROUTER_SETTINGS.port) {
3417
+ router.port = devDefault
3418
+ }
3302
3419
  const logger = new RouterLogger(ROUTER_LOG_PATH, router.logLevel)
3303
3420
  const runtime = new RouterRuntime({ config, port: router.port, logger })
3304
3421
  runtime.installProcessSafety()