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.
- package/bin/free-coding-models.js +10 -2
- package/changelog/v0.5.27.md +2 -0
- package/changelog/v0.5.28.md +5 -0
- package/changelog/v0.5.29.md +19 -0
- package/package.json +1 -1
- package/sources.js +3 -2
- package/src/core/config.js +25 -18
- package/src/core/endpoint-installer.js +5 -9
- package/src/core/legacy-proxy-cleanup.js +2 -0
- package/src/core/model-merger.js +4 -13
- package/src/core/router-daemon.js +160 -43
- package/src/core/router-dashboard.js +63 -25
- package/src/core/shared-helpers.js +117 -0
- package/src/core/sync-set.js +6 -21
- package/src/core/tool-launchers.js +24 -92
- package/src/core/utils.js +26 -100
- package/src/tui/app.js +18 -2
- package/src/tui/command-palette.js +3 -1
- package/src/tui/key-handler.js +229 -23
- package/src/tui/overlays.js +13 -1
- package/src/tui/render-table.js +21 -5
- package/src/tui/tui-state.js +6 -0
- package/web/dist/assets/index-BMU58Jju.js +41 -0
- package/web/dist/assets/index-Dd1jOGEn.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +5 -0
- package/web/src/components/atoms/HealthCell.jsx +12 -7
- package/web/src/components/atoms/HealthCell.module.css +4 -0
- package/web/src/components/atoms/StatusDot.jsx +5 -2
- package/web/src/components/atoms/StatusDot.module.css +12 -7
- package/web/src/components/dashboard/ModelTable.jsx +5 -2
- package/web/src/components/dashboard/ModelTable.module.css +9 -0
- package/web/src/components/router/RouterView.jsx +124 -56
- package/web/src/components/router/RouterView.module.css +135 -1
- package/src/core/product-flags.js +0 -9
- package/web/dist/assets/index-JUPDB-J-.js +0 -41
- 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
|
-
|
|
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
|
|
package/changelog/v0.5.27.md
CHANGED
|
@@ -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.
|
|
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
|
|
553
|
-
|
|
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
|
}
|
package/src/core/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
688
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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'))
|
package/src/core/model-merger.js
CHANGED
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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 (
|
|
54
|
+
if (parseSweToNum(sweScore) > parseSweToNum(group.sweScore)) {
|
|
64
55
|
group.sweScore = sweScore
|
|
65
56
|
}
|
|
66
57
|
// Keep largest context
|
|
67
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1192
|
-
|
|
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()
|