free-coding-models 0.5.28 → 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.
@@ -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.
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.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
  }
@@ -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
  }
@@ -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()) {
@@ -1386,6 +1409,11 @@ class RouterRuntime {
1386
1409
  ...this.statusPayload(),
1387
1410
  tokens: this.tokenTracker.summary(),
1388
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),
1389
1417
  requestLog: this.requestLog.slice(0, 20),
1390
1418
  circuitBreakers: Object.fromEntries([...this.circuit.entries()].map(([key, value]) => [key, {
1391
1419
  state: value.authError ? 'AUTH_ERROR' : value.stale ? 'STALE' : value.unsupported ? 'UNSUPPORTED' : value.state,
@@ -1501,7 +1529,7 @@ class RouterRuntime {
1501
1529
  // 📖 skip that provider as a candidate for replacements.
1502
1530
  const providerProbeStats = new Map() // provider -> { probed: n, authError: n, stale: n, alive: n }
1503
1531
  for (const [providerKey, source] of Object.entries(sources)) {
1504
- if (!isRouteableProvider(providerKey)) continue
1532
+ if (!isRouteableProvider(providerKey, sources)) continue
1505
1533
  if (!providerProbeStats.has(providerKey)) providerProbeStats.set(providerKey, { probed: 0, authError: 0, stale: 0, alive: 0 })
1506
1534
  for (const [modelId, , tier, sweScore, ctx] of source.models || []) {
1507
1535
  const key = `${providerKey}/${modelId}`
@@ -2646,7 +2674,7 @@ class RouterRuntime {
2646
2674
  if (req.method === 'GET' && url.pathname === '/api/router/catalog') {
2647
2675
  const rows = []
2648
2676
  for (const [providerKey, source] of Object.entries(sources)) {
2649
- if (!isRouteableProvider(providerKey)) continue
2677
+ if (!isRouteableProvider(providerKey, sources)) continue
2650
2678
  if (!Array.isArray(source.models)) continue
2651
2679
  for (const [modelId, label, tier, sweScore, ctx] of source.models) {
2652
2680
  rows.push({
@@ -3028,7 +3056,7 @@ export async function buildDefaultRouterSet(config = {}, maxModels, options = {}
3028
3056
  if (maxModels === undefined) maxModels = Math.max(5, keyedProviders.size * 2)
3029
3057
  const entries = []
3030
3058
  for (const [providerKey, source] of Object.entries(sources)) {
3031
- if (!isRouteableProvider(providerKey)) continue
3059
+ if (!isRouteableProvider(providerKey, sources)) continue
3032
3060
  for (const [model, label, tier, sweScore, ctx] of source.models || []) {
3033
3061
  entries.push({
3034
3062
  provider: providerKey,
@@ -3184,7 +3212,7 @@ export function createRouterRuntimeForTest({ config, port = 0, logger = null, to
3184
3212
  function createDefaultProbeFn(apiKeys) {
3185
3213
  return async (entry) => {
3186
3214
  const { provider, model } = entry
3187
- if (!isRouteableProvider(provider)) return { ok: false, code: 'NOT_ROUTEABLE', latencyMs: 0 }
3215
+ if (!isRouteableProvider(provider, sources)) return { ok: false, code: 'NOT_ROUTEABLE', latencyMs: 0 }
3188
3216
  const url = resolveProviderUrl(provider)
3189
3217
  if (!url) return { ok: false, code: 'NO_URL', latencyMs: 0 }
3190
3218
  const apiKey = getApiKey({ apiKeys: apiKeys || {} }, provider) || ''
@@ -3239,7 +3267,7 @@ function buildDefaultRouterSetSync(config = {}, maxModels = 5) {
3239
3267
  .map(([provider]) => provider))
3240
3268
  const entries = []
3241
3269
  for (const [providerKey, source] of Object.entries(sources)) {
3242
- if (!isRouteableProvider(providerKey)) continue
3270
+ if (!isRouteableProvider(providerKey, sources)) continue
3243
3271
  for (const [model, label, tier, sweScore, ctx] of source.models || []) {
3244
3272
  entries.push({ provider: providerKey, model, label, tier, sweScore, ctx, hasKey: keyedProviders.has(providerKey) })
3245
3273
  }
@@ -3323,7 +3351,7 @@ function buildRouterSetFromFavorites(config) {
3323
3351
  if (slashIdx < 0) continue
3324
3352
  const providerKey = fav.slice(0, slashIdx)
3325
3353
  const modelId = fav.slice(slashIdx + 1)
3326
- if (!isRouteableProvider(providerKey)) continue
3354
+ if (!isRouteableProvider(providerKey, sources)) continue
3327
3355
  const source = sources[providerKey]
3328
3356
  if (!source) continue
3329
3357
  const found = (source.models || []).find((m) => m[0] === modelId)
@@ -3381,6 +3409,13 @@ async function listenWithFallback(server, preferredPort, logger, host = '127.0.0
3381
3409
  export async function runRouterDaemon() {
3382
3410
  const config = loadConfig()
3383
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
+ }
3384
3419
  const logger = new RouterLogger(ROUTER_LOG_PATH, router.logLevel)
3385
3420
  const runtime = new RouterRuntime({ config, port: router.port, logger })
3386
3421
  runtime.installProcessSafety()
@@ -33,6 +33,7 @@
33
33
  * @exports fetchRouterSets, createRouterSet, renameRouterSet, duplicateRouterSet
34
34
  * @exports deleteRouterSet, activateRouterSet, updateRouterSetModels
35
35
  * @exports addModelToRouterSet, removeModelFromRouterSet, reorderRouterSetModel
36
+ * @exports setDashboardNotice
36
37
  *
37
38
  * @see ./router-daemon.js — daemon endpoints consumed by this screen
38
39
  * @see ./overlays.js — overlay factory that mounts this renderer
@@ -42,7 +43,7 @@
42
43
  import chalk from 'chalk'
43
44
  import { existsSync, readFileSync } from 'node:fs'
44
45
  import { displayWidth, padEndDisplay, sliceOverlayLines, tintOverlayLines } from '../tui/render-helpers.js'
45
- import { ROUTER_DEFAULT_PORT, ROUTER_MAX_PORT, ROUTER_PID_PATH, ROUTER_PORT_PATH, getRouterPortRange } from './router-daemon.js'
46
+ import { ROUTER_DEFAULT_PORT, ROUTER_MAX_PORT, getRouterPidPath, getRouterPortPath, getRouterPortRange } from './router-daemon.js'
46
47
  import { themeColors, getTierRgb } from '../tui/theme.js'
47
48
  import { formatTokenTotalCompact } from './token-usage-reader.js'
48
49
  import { sendUsageTelemetry } from './telemetry.js'
@@ -150,14 +151,16 @@ async function fetchJson(url, options = {}) {
150
151
  }
151
152
 
152
153
  function readDaemonFiles() {
153
- const recordedPort = readNumberFile(ROUTER_PORT_PATH)
154
- const recordedPid = readNumberFile(ROUTER_PID_PATH)
154
+ const portPath = getRouterPortPath()
155
+ const pidPath = getRouterPidPath()
156
+ const recordedPort = readNumberFile(portPath)
157
+ const recordedPid = readNumberFile(pidPath)
155
158
  return {
156
159
  port: recordedPort,
157
160
  pid: recordedPid,
158
161
  pidAlive: recordedPid ? isProcessAlive(recordedPid) : false,
159
- hasPidFile: existsSync(ROUTER_PID_PATH),
160
- hasPortFile: existsSync(ROUTER_PORT_PATH),
162
+ hasPidFile: existsSync(pidPath),
163
+ hasPortFile: existsSync(portPath),
161
164
  }
162
165
  }
163
166
 
@@ -168,7 +171,7 @@ function buildPortCandidates(state) {
168
171
  ? state.routerDashboardBaseUrl.match(/:(\d+)$/)
169
172
  : null
170
173
  const baseUrlPort = baseUrlMatch ? Number.parseInt(baseUrlMatch[1], 10) : null
171
- const filePort = readNumberFile(ROUTER_PORT_PATH)
174
+ const filePort = readNumberFile(getRouterPortPath())
172
175
  const { defaultPort, maxPort } = getRouterPortRange()
173
176
  for (const port of [baseUrlPort, currentPort, filePort, defaultPort]) {
174
177
  if (Number.isInteger(port) && port > 0 && !ports.includes(port)) ports.push(port)
@@ -259,6 +262,21 @@ export function normalizeRouterDashboardSnapshot(healthPayload, statsPayload) {
259
262
  const merged = { ...health, ...stats }
260
263
  const models = Array.isArray(stats.models) ? stats.models.map(normalizeModelHealth) : []
261
264
  const requestLog = Array.isArray(stats.requestLog) ? stats.requestLog.map(normalizeRequestEntry) : []
265
+ // 📖 routingOrder — priority-first attempt order from /stats (issue #120).
266
+ // 📖 routingOrder[0].key is the model that will serve the next request, so the
267
+ // 📖 TUI can mark it with a ▶ NEXT glyph. Guarded so older daemons without
268
+ // 📖 the field still render fine.
269
+ const routingOrder = Array.isArray(stats.routingOrder)
270
+ ? stats.routingOrder
271
+ .filter((entry) => isRecord(entry) && typeof entry.key === 'string')
272
+ .map((entry) => ({
273
+ key: entry.key,
274
+ provider: safeString(entry.provider, ''),
275
+ model: safeString(entry.model, ''),
276
+ priority: toFiniteNumber(entry.priority, 0),
277
+ state: safeString(entry.state, 'UNKNOWN'),
278
+ }))
279
+ : []
262
280
 
263
281
  return {
264
282
  ok: merged.ok === true,
@@ -281,6 +299,7 @@ export function normalizeRouterDashboardSnapshot(healthPayload, statsPayload) {
281
299
  stalePid: toFiniteNumber(merged.stalePid, null),
282
300
  tokens: normalizeTokens(stats.tokens),
283
301
  models,
302
+ routingOrder,
284
303
  requestLog,
285
304
  }
286
305
  }
@@ -331,7 +350,7 @@ function statusBadge(status, snapshot) {
331
350
  return themeColors.error('○ UNREACHABLE')
332
351
  }
333
352
 
334
- function setDashboardNotice(state, type, message, ttlMs = 3500) {
353
+ export function setDashboardNotice(state, type, message, ttlMs = 3500) {
335
354
  state.routerDashboardNotice = { type, message, at: Date.now() }
336
355
  if (state.routerDashboardNoticeTimer) clearTimeout(state.routerDashboardNoticeTimer)
337
356
  state.routerDashboardNoticeTimer = setTimeout(() => {
@@ -843,15 +862,19 @@ export function renderRouterDashboard(state, deps = {}) {
843
862
  lines.push(` ${paintBanner(bannerLine)}`)
844
863
  lines.push('')
845
864
 
846
- // ── Quick Setup (connection info) ───────────────────────────────────────────
847
- const port = snapshot.port || state.routerDashboardPort || '—'
848
- const baseUrl = isRunning ? `http://localhost:${port}/v1` : `http://localhost:${port}/v1`
849
- lines.push(` ${themeColors.textBold('Quick Setup')} ${themeColors.dim('— paste into your coding tool')}`)
850
- lines.push(` ${themeColors.dim('URL')} ${themeColors.info(baseUrl)}`)
851
- lines.push(` ${themeColors.dim('Model')} ${themeColors.info('fcm')}`)
852
- lines.push(` ${themeColors.dim('API Key')} ${themeColors.info('fcm-local')}`)
865
+ // ── Quick Setup (connection info) — HERO section ──────────────────────────
866
+ // 📖 Always visible with default port 19280 so users can copy even when stopped.
867
+ const { defaultPort: currentDefaultPort } = getRouterPortRange()
868
+ const port = snapshot.port || state.routerDashboardPort || currentDefaultPort
869
+ const baseUrl = `http://localhost:${port}/v1`
870
+ lines.push(` ${themeColors.textBold('Quick Setup')} ${themeColors.dim('— paste into your coding tool config')}`)
871
+ lines.push(` ${themeColors.dim('URL')} ${themeColors.infoBold(baseUrl)}`)
872
+ lines.push(` ${themeColors.dim('Model')} ${themeColors.infoBold('fcm')}`)
873
+ lines.push(` ${themeColors.dim('API Key')} ${themeColors.infoBold('fcm-local')}`)
853
874
  if (isRunning) {
854
875
  lines.push(` ${themeColors.dim('Uptime')} ${themeColors.success(formatRouterDuration(snapshot.uptimeSeconds))} ${themeColors.dim('Requests routed:')} ${themeColors.info(String(snapshot.requestsRouted))}`)
876
+ } else {
877
+ lines.push(` ${themeColors.dim('Hint')} ${themeColors.dim('Start the daemon to enable routing')}`)
855
878
  }
856
879
  lines.push(` ${separator}`)
857
880
  lines.push('')
@@ -868,7 +891,8 @@ export function renderRouterDashboard(state, deps = {}) {
868
891
  const cursor = state.routerDashboardCursorIndex ?? 0
869
892
 
870
893
  if (favorites.length === 0) {
871
- lines.push(` ${themeColors.warning('No favorites yet.')} ${themeColors.dim('Press Esc, then F on any model to add it.')}`)
894
+ lines.push(` ${themeColors.warning('No favorites yet. Press Esc, then F on any model to add it.')}`)
895
+ lines.push(` ${themeColors.dim('Favorites become your router fallback chain — #1 is tried first.')}`)
872
896
  } else {
873
897
  // 📖 Priority keycap glyphs for the fallback order
874
898
  const KEYCAPS = ['1️⃣','2️⃣','3️⃣','4️⃣','5️⃣','6️⃣','7️⃣','8️⃣','9️⃣','🔟']
@@ -880,8 +904,13 @@ export function renderRouterDashboard(state, deps = {}) {
880
904
  healthByKey.set(`${m.provider}/${m.model}`, m)
881
905
  }
882
906
 
883
- // 📖 Column headers
884
- lines.push(` ${themeColors.dim(padEndDisplay('PRI', 4))} ${themeColors.dim(padEndDisplay('MODEL', 42))} ${themeColors.dim(padEndDisplay('DAEMON STATUS', 16))} ${themeColors.dim(padEndDisplay('AVG PING', 8))} ${themeColors.dim('VERDICT')}`)
907
+ // 📖 The model the daemon will serve on the next request (priority-first,
908
+ // 📖 see issue #120). Marked with NEXT so the user understands the top
909
+ // 📖 of the chain is what actually handles traffic — not whichever is fastest.
910
+ const nextToServeKey = snapshot.routingOrder?.[0]?.key || null
911
+
912
+ // 📖 Column headers — leading space lines up with the ▶ NEXT marker column.
913
+ lines.push(` ${themeColors.dim(padEndDisplay('PRI', 4))} ${themeColors.dim(padEndDisplay('MODEL', 42))} ${themeColors.dim(padEndDisplay('STATUS', 16))} ${themeColors.dim(padEndDisplay('AVG PING', 8))} ${themeColors.dim('VERDICT')}`)
885
914
 
886
915
  for (let i = 0; i < favorites.length; i++) {
887
916
  const favKey = favorites[i]
@@ -926,14 +955,14 @@ export function renderRouterDashboard(state, deps = {}) {
926
955
  }
927
956
 
928
957
  // 📖 Get global metrics from main table state
929
- let avgPingDisplay = themeColors.dim('———')
958
+ let avgPingDisplay = themeColors.dim('')
930
959
  let verdictDisplay = themeColors.dim('Pending ⏳')
931
960
 
932
961
  if (mainResult) {
933
962
  // Avg Ping
934
963
  const avg = getAvg(mainResult)
935
964
  if (avg !== Infinity) {
936
- const str = String(avg).padEnd(4)
965
+ const str = `${avg}ms`
937
966
  avgPingDisplay = avg < 500 ? themeColors.metricGood(str) : avg < 1500 ? themeColors.metricWarn(str) : themeColors.metricBad(str)
938
967
  }
939
968
 
@@ -957,7 +986,13 @@ export function renderRouterDashboard(state, deps = {}) {
957
986
  verdictDisplay = padEndDisplay(verdictDisplay, 14)
958
987
  }
959
988
 
960
- const rowText = ` ${padEndDisplay(priorityGlyph(i), 4)} ${padEndDisplay(favKey, 42)} ${padEndDisplay(healthLabel, 16)} ${padEndDisplay(avgPingDisplay, 8)} ${verdictDisplay}`
989
+ // 📖 Prefix the next-to-serve model with a NEXT marker so the active
990
+ // 📖 routing target is obvious. Only shown when the daemon is actually
991
+ // 📖 running and has reported a routing order (stopped → no marker).
992
+ const nextMarker = (nextToServeKey && nextToServeKey === favKey)
993
+ ? themeColors.successBold('▶')
994
+ : themeColors.dim(' ')
995
+ const rowText = ` ${nextMarker} ${padEndDisplay(priorityGlyph(i), 4)} ${padEndDisplay(favKey, 42)} ${padEndDisplay(healthLabel, 16)} ${padEndDisplay(avgPingDisplay, 8)} ${verdictDisplay}`
961
996
 
962
997
  if (isCursorRow) {
963
998
  lines.push(themeColors.bgCursor(rowText + ' '.repeat(Math.max(0, width - displayWidth(rowText) - 3))))
@@ -996,14 +1031,14 @@ export function renderRouterDashboard(state, deps = {}) {
996
1031
  lines.push(` ${separator}`)
997
1032
  lines.push('')
998
1033
 
999
- // ── Token Summary (compact) ─────────────────────────────────────────────────
1000
- lines.push(` ${themeColors.textBold('Tokens')} ${themeColors.dim('Today:')} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.today.total_tokens))} ${themeColors.dim('All-time:')} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.all_time.total_tokens))} ${themeColors.dim('Requests:')} ${snapshot.tokens.today.requests}/${snapshot.tokens.all_time.requests}`)
1034
+ // ── Token Summary (compact, visual) ─────────────────────────────────────────
1035
+ lines.push(` ${themeColors.textBold('📊 Tokens')} ${themeColors.dim('Today:')} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.today.total_tokens))} ${themeColors.dim(`(${snapshot.tokens.today.requests} req)`)} ${themeColors.dim('Lifetime:')} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.all_time.total_tokens))} ${themeColors.dim(`(${snapshot.tokens.all_time.requests} req)`)}`)
1001
1036
 
1002
1037
  // ── Live Request Log (compact) ──────────────────────────────────────────────
1003
1038
  const requestRows = requestLogRows(state, snapshot)
1039
+ lines.push('')
1040
+ lines.push(` ${themeColors.textBold('Recent Requests')}`)
1004
1041
  if (requestRows.length > 0) {
1005
- lines.push('')
1006
- lines.push(` ${themeColors.textBold('Recent Requests')}`)
1007
1042
  const header = ` ${padEndDisplay('Time', 10)} ${padEndDisplay('Model', 34)} ${padEndDisplay('Status', 8)} ${padEndDisplay('Latency', 9)} Detail`
1008
1043
  lines.push(themeColors.dim(header))
1009
1044
  for (const row of requestRows.slice(0, 6)) {
@@ -1025,6 +1060,8 @@ export function renderRouterDashboard(state, deps = {}) {
1025
1060
  `${compactText(detail, Math.max(10, width - 68)).trimEnd()}`
1026
1061
  )
1027
1062
  }
1063
+ } else {
1064
+ lines.push(` ${themeColors.dim('No requests routed yet')}`)
1028
1065
  }
1029
1066
 
1030
1067
  // ── Health check speed ──────────────────────────────────────────────────────
@@ -1048,7 +1085,8 @@ export function renderRouterDashboard(state, deps = {}) {
1048
1085
 
1049
1086
  // ── Footer ──────────────────────────────────────────────────────────────────
1050
1087
  lines.push('')
1051
- lines.push(` ${themeColors.hotkey('↑↓')} ${themeColors.dim('Navigate')} ${themeColors.dim('•')} ${themeColors.hotkey('Shift+↑↓')} ${themeColors.dim('Reorder')} ${themeColors.dim('•')} ${themeColors.hotkey('S')} ${themeColors.dim(isStopped ? 'Start daemon' : 'Stop daemon')} ${themeColors.dim('•')} ${themeColors.hotkey('I')} ${themeColors.dim(`Health check: ${probeLabel}`)} ${themeColors.dim('•')} ${themeColors.hotkey('C')} ${themeColors.dim('Clear log')} ${themeColors.dim('•')} ${themeColors.hotkey('Esc')} ${themeColors.dim('Back')}`)
1088
+ lines.push(` ${separator}`)
1089
+ lines.push(` ${themeColors.hotkey('↑↓')} ${themeColors.dim('Navigate')} ${themeColors.dim('•')} ${themeColors.hotkey('Shift+↑↓')} ${themeColors.dim('Reorder')} ${themeColors.dim('•')} ${themeColors.hotkey('S')} ${themeColors.dim(isStopped ? 'Start daemon' : 'Stop daemon')} ${themeColors.dim('•')} ${themeColors.hotkey('I')} ${themeColors.dim(`Health check: ${probeLabel}`)} ${themeColors.dim('•')} ${themeColors.hotkey('C')} ${themeColors.dim('Clear log')} ${themeColors.dim('•')} ${themeColors.hotkey('R')} ${themeColors.dim('Sync best')} ${themeColors.dim('•')} ${themeColors.hotkey('Esc')} ${themeColors.dim('Back')}`)
1052
1090
 
1053
1091
  const { visible, offset } = sliceOverlayLines(lines, state.routerDashboardScrollOffset || 0, state.terminalRows || 24)
1054
1092
  state.routerDashboardScrollOffset = offset