free-coding-models 0.5.28 → 0.5.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/free-coding-models.js +10 -2
- package/changelog/v0.5.29.md +19 -0
- package/changelog/v0.5.30.md +15 -0
- package/package.json +1 -1
- package/sources.js +3 -2
- package/src/core/config.js +1 -14
- 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/playground.js +6 -4
- package/src/core/router-daemon.js +101 -43
- package/src/core/router-dashboard.js +63 -25
- package/src/core/shared-helpers.js +117 -0
- package/src/core/sync-set.js +11 -24
- package/src/core/tool-launchers.js +24 -92
- package/src/core/utils.js +26 -100
- package/src/tui/app.js +2 -2
- package/src/tui/command-palette.js +1 -0
- package/src/tui/key-handler.js +77 -20
- package/src/tui/render-table.js +3 -3
- package/web/dist/assets/index-CsFt3qt5.css +1 -0
- package/web/dist/assets/index-I6N91rH7.js +40 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/favicons/apple-touch-icon.png +0 -0
- package/web/dist/favicons/favicon-16x16.png +0 -0
- package/web/dist/favicons/favicon-192x192.png +0 -0
- package/web/dist/favicons/favicon-32x32.png +0 -0
- package/web/dist/favicons/favicon-48x48.png +0 -0
- package/web/dist/favicons/favicon-512x512.png +0 -0
- package/web/dist/favicons/favicon-96x96.png +0 -0
- package/web/dist/favicons/favicon.ico +0 -0
- package/web/dist/favicons/mstile-150x150.png +0 -0
- package/web/dist/favicons/mstile-310x150.png +0 -0
- package/web/dist/favicons/mstile-310x310.png +0 -0
- package/web/dist/favicons/mstile-512x512.png +0 -0
- package/web/dist/favicons/mstile-70x70.png +0 -0
- package/web/dist/index.html +2 -2
- package/web/public/favicon.ico +0 -0
- package/web/public/favicons/apple-touch-icon.png +0 -0
- package/web/public/favicons/favicon-16x16.png +0 -0
- package/web/public/favicons/favicon-192x192.png +0 -0
- package/web/public/favicons/favicon-32x32.png +0 -0
- package/web/public/favicons/favicon-48x48.png +0 -0
- package/web/public/favicons/favicon-512x512.png +0 -0
- package/web/public/favicons/favicon-96x96.png +0 -0
- package/web/public/favicons/favicon.ico +0 -0
- package/web/public/favicons/mstile-150x150.png +0 -0
- package/web/public/favicons/mstile-310x150.png +0 -0
- package/web/public/favicons/mstile-310x310.png +0 -0
- package/web/public/favicons/mstile-512x512.png +0 -0
- package/web/public/favicons/mstile-70x70.png +0 -0
- package/web/server.js +34 -4
- package/web/src/components/dashboard/ExpandedDetailRow.jsx +10 -113
- package/web/src/components/dashboard/ExpandedDetailRow.module.css +9 -0
- package/web/src/components/playground/PlaygroundChat.jsx +527 -0
- package/web/src/components/playground/PlaygroundChat.module.css +382 -0
- package/web/src/components/playground/PlaygroundView.jsx +104 -442
- package/web/src/components/playground/PlaygroundView.module.css +10 -0
- package/web/src/components/router/RouterView.jsx +268 -56
- package/web/src/components/router/RouterView.module.css +416 -1
- package/src/core/product-flags.js +0 -9
- package/web/dist/assets/index-BRFowJv5.css +0 -1
- package/web/dist/assets/index-Pr3waI0-.js +0 -41
package/README.md
CHANGED
|
@@ -184,7 +184,7 @@ and every TUI capability that's safe to port ships behind a button or chip.
|
|
|
184
184
|
| **Theme** | Tri-state `auto / dark / light` cycle (TUI `G`) — auto follows the OS preference. |
|
|
185
185
|
| **Smart Recommend** | Header "Recommend" opens the 3-question wizard, runs the 10s analysis phase, then returns the Top 3 shared-score recommendations with Pin + install-endpoint actions. |
|
|
186
186
|
| **Endpoint installs** | The Web never starts external tools. It writes the selected provider/model endpoint into the chosen tool config (`/api/install-endpoint`), then users start their tool themselves. |
|
|
187
|
-
| **Router Dashboard** | Header "Router" opens a full modal with daemon start/stop, model health table with circuit breaker badges, request log, probe mode selector,
|
|
187
|
+
| **Router Dashboard** | Header "Router" opens a full modal with daemon start/stop, model health table with circuit breaker badges, request log, probe mode selector, quick-setup card (copy base URL + model to clipboard), **"Probe all" AI Latency/TPS benchmarking**, and a **"Test Router" mini playground** to live-route chats through the fallback chain. |
|
|
188
188
|
| **Token Usage** | Integrated inside Analytics: today + all-time summary cards, 7-day usage bar chart, top models and top providers breakdown. |
|
|
189
189
|
| **Installed Models** | Header overflow → "Installed Models" opens a modal that scans all tool configs (Goose, Crush, Aider, Kilo, Qwen, Pi, OpenHands, Amp) and shows configured models with soft-delete (backup saved). |
|
|
190
190
|
| **Install Endpoints wizard** | Header overflow → "Install Endpoints" opens a 4-step wizard: pick provider → pick tool → select models → install. Writes managed provider catalogs into tool configs using the same engine as the TUI. |
|
|
@@ -4,8 +4,16 @@
|
|
|
4
4
|
* @description Live terminal availability checker for coding LLM models with OpenCode & OpenClaw integration.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
// 📖 --dev mode: must set FCM_DEV before any module imports resolve daemon paths
|
|
8
|
-
|
|
7
|
+
// 📖 --dev mode: must set FCM_DEV before any module imports resolve daemon paths.
|
|
8
|
+
// 📖 Also auto-detect git checkouts — a repo checkout is always in dev mode because
|
|
9
|
+
// 📖 the router daemon must use dev ports/files to avoid clashing with a production
|
|
10
|
+
// 📖 npm install running on the same machine.
|
|
11
|
+
// 📖 IMPORTANT: these checks MUST run synchronously before any static imports
|
|
12
|
+
// 📖 resolve, because router-daemon.js reads FCM_DEV at module load time.
|
|
13
|
+
import { existsSync } from 'node:fs'
|
|
14
|
+
import { join, dirname } from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
if (process.argv.includes('--dev') || (!process.env.FCM_DEV && existsSync(join(dirname(fileURLToPath(import.meta.url)), '..', '.git')))) {
|
|
9
17
|
process.env.FCM_DEV = '1'
|
|
10
18
|
}
|
|
11
19
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog v0.5.29 - 2026-06-15
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
- **Router now respects your priority order (fixes #120, reported by @jammin1911).** Previously, priority was only 20% of the routing score, so a healthy low-priority model (e.g. GPT-OSS 120B) could serve traffic even when higher-priority models you deliberately ranked above it were also healthy. Routing is now **strict priority-first**: `#1` is always tried first while it is healthy, and the health score is only used as a tiebreaker between models that share the same priority (e.g. cold-start ties). Circuit-breaker safety is preserved — closed (healthy) models always come before half-open (recovering) ones. Your fallback chain is now authoritative.
|
|
5
|
+
- **Daemon startup errors are now surfaced instead of swallowed.** The TUI captures `--daemon-bg` stdout/stderr and shows the real failure reason (port in use, config corruption, etc.) as a dashboard notice, instead of silently flipping back to "stopped".
|
|
6
|
+
- **Dev checkouts no longer clash with production installs.** A git checkout now auto-enables dev mode (`FCM_DEV=1`) so its router daemon uses dev ports/files (`29280`, `-dev` suffixed PID/port/log files) and never collides with a globally-installed `free-coding-models` running on the same machine. Dynamic path resolvers pick up `FCM_DEV` changes that happen after module load.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **`routingOrder` field in `/stats`** — exposes the exact attempt order the daemon will use for the next request (priority-first among healthy models). `routingOrder[0]` is the model that will serve the next chat completion. Dashboards use it to show which model is active.
|
|
10
|
+
- **Clearer priority indicators in the Router UI (all surfaces)** so users understand the fallback chain at a glance:
|
|
11
|
+
- **Web Dashboard:** the active-set list now labels `#1` as a highlighted **Primary** badge (accent-colored) and the rest as numbered fallbacks. A one-line legend explains *"Primary tries first → Fallback on failure / rate-limit"*. The model that will serve the next request gets a green accent border + tint (**Next up** badge in the legend). Tooltips on every badge explain the semantics.
|
|
12
|
+
- **TUI Dashboard:** a `▶ NEXT` marker (green) is drawn on the exact model the daemon will route to next, so the top of your fallback chain is obvious. Empty-state copy now explains *"Favorites become your router fallback chain — #1 is tried first."*
|
|
13
|
+
- **Always-visible Quick Setup card** on the Router dashboard (Web + TUI) with sensible defaults (`localhost:19280/v1`, model `fcm`, key `fcm-local`), so you can copy your tool config even before starting the daemon. The Web version adds a one-click **Copy all** button and a hero glow when running.
|
|
14
|
+
- **Better empty states** across the Web Router view: the active-set list and request log now show helpful guidance ("No models in the active set → Add models or Sync best", "No requests yet. Start coding to see traffic here.") instead of disappearing.
|
|
15
|
+
- **Auto-expanding request log** — the Web request log expands automatically the first time traffic appears, so you don't have to click to see your routed requests.
|
|
16
|
+
- **More prominent Start button** — the stopped-state "Start Router" CTA is now larger and easier to hit.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- Router candidate sorting changed from `score DESC, priority ASC` to `priority ASC, score DESC` — the user's explicit ranking now wins. The `scoring` weights config is retained (still used for the health table and same-tier tiebreaks), so existing user configs and config normalization are unaffected.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog v0.5.30 - 2026-06-15
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
- **Harmonized Playgrounds across all surfaces (fixes Playground UI mismatches).** Created a single shared `<PlaygroundChat>` React component that acts as the single source of truth for the full-screen Playground modal, the Router View's "Test Router" panel, and the Expanded Detail Row's "Mini Playground".
|
|
5
|
+
- **Universal real-time response metadata.** Under every assistant reply in all three playground locations, users now see the serving model (correctly resolved, even when routed through `fcm`), total latency, generated token count, and tokens per second (TPS). The addressed model or the router's Primary pick is also displayed under every user bubble for clarity.
|
|
6
|
+
- **Web Router Benchmarking (`Probe all`).** Added a "Probe all" action to the Web Router Dashboard. This lets you benchmark AI Latency and TPS on all models in the active routing set concurrently inside the daemon process. A visual progress bar displays the benchmarking status, and results flow back into each row's AI Latency cell.
|
|
7
|
+
- **New server proxy endpoint `/api/router/probe-all`** in `web/server.js` to handle the web dashboard's probe request and safely proxy it to the daemon's `/api/global-benchmark` with an extended timeout.
|
|
8
|
+
- **Playground daemon-status alignment tests.** Added unit tests in `test/test.js` to ensure the `/health` and `/stats` daemon endpoints consistently report `running: true` alongside `ok: true`, preventing a mismatch where the router dashboard would report "running" but the playground would report "offline".
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- **Dynamic port and PID resolvers for dev mode (`FCM_DEV=1`).** Refined paths in `src/core/router-daemon.js`, `src/core/playground.js`, `src/core/sync-set.js`, and `web/server.js` to use dynamic functions (`getRouterPortPath()`, `getRouterPidPath()`). This ensures dev checkouts correctly detect and signal the dev daemon (`-dev` port/PID files) instead of reading stale production constants resolved at module load time.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **Fixed false "router offline" alerts.** The Playground now correctly trusts the daemon status because both `/health` and `/stats` endpoints uniformly report `running: true`.
|
|
15
|
+
- **Restored served-model badge for routed streams.** Forwarded the daemon's custom `x-fcm-router-model` response header in stream completions through the web proxy, so the playground can display which specific provider/model actually resolved the request when calling the `fcm` auto-router.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "free-coding-models",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.30",
|
|
4
4
|
"description": "Find the fastest coding LLM models in seconds — ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nvidia",
|
package/sources.js
CHANGED
|
@@ -549,7 +549,8 @@ export const sources = {
|
|
|
549
549
|
export const MODELS = [];
|
|
550
550
|
for (const [sourceKey, sourceData] of Object.entries(sources)) {
|
|
551
551
|
if (!sourceData || !sourceData.models) continue
|
|
552
|
-
for (const
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/core/playground.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { displayWidth, sliceOverlayLines, tintOverlayLines } from '../tui/render-helpers.js'
|
|
25
|
-
import { ROUTER_PORT_PATH } from './router-daemon.js'
|
|
25
|
+
import { ROUTER_PORT_PATH, getRouterPortPath } from './router-daemon.js'
|
|
26
26
|
import { existsSync, readFileSync } from 'node:fs'
|
|
27
27
|
import { themeColors } from '../tui/theme.js'
|
|
28
28
|
|
|
@@ -295,10 +295,12 @@ async function readSseStream(resp, fetchFn, controller) {
|
|
|
295
295
|
* 📖 can stand alone in the TUI process.
|
|
296
296
|
*/
|
|
297
297
|
async function readDaemonPort() {
|
|
298
|
-
// 📖 Try the recorded port file first.
|
|
298
|
+
// 📖 Try the recorded port file first. Use the dynamic resolver so dev
|
|
299
|
+
// 📖 checkouts (FCM_DEV=1) read the `-dev` port file and find the dev daemon.
|
|
299
300
|
try {
|
|
300
|
-
|
|
301
|
-
|
|
301
|
+
const portPath = getRouterPortPath()
|
|
302
|
+
if (existsSync(portPath)) {
|
|
303
|
+
const raw = readFileSync(portPath, 'utf8').trim()
|
|
302
304
|
if (/^\d+$/.test(raw)) return Number(raw)
|
|
303
305
|
}
|
|
304
306
|
} catch {}
|
|
@@ -51,6 +51,8 @@ import { buildChatCompletionPingBody, ping, resolveCloudflareUrl, shouldUseDisab
|
|
|
51
51
|
import { benchmarkModel, BENCHMARK_TIMEOUT_MS } from './benchmark.js'
|
|
52
52
|
import { loadChangelog } from './changelog-loader.js'
|
|
53
53
|
import { sendUsageTelemetry } from './telemetry.js'
|
|
54
|
+
import { TIER_ORDER } from './utils.js'
|
|
55
|
+
import { atomicWriteJson, safeJsonParse, sleep, maskApiKey, isRouteableProvider } from './shared-helpers.js'
|
|
54
56
|
|
|
55
57
|
export const ROUTER_DEFAULT_PORT = 19280
|
|
56
58
|
export const ROUTER_MAX_PORT = 19289
|
|
@@ -59,15 +61,28 @@ export const ROUTER_MAX_PORT_DEV = 29289
|
|
|
59
61
|
|
|
60
62
|
// 📖 Dev mode uses -dev suffixed files so the local dev daemon never clashes
|
|
61
63
|
// 📖 with a production install running on the same machine.
|
|
62
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1193
|
-
|
|
1197
|
+
// 📖 Priority ascending (1 before 2); within the same priority, healthier
|
|
1198
|
+
// 📖 score wins so cold-start ties resolve deterministically.
|
|
1199
|
+
const byPriorityThenHealth = (a, b) => a.priority - b.priority || b.score - a.score
|
|
1200
|
+
return [...closed.sort(byPriorityThenHealth), ...halfOpen.sort(byPriorityThenHealth)]
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// 📖 getRoutingOrder — slim projection of getRoutingCandidates for the /stats
|
|
1204
|
+
// 📖 payload and dashboards. Exposes the EXACT order the router will attempt
|
|
1205
|
+
// 📖 on the next request, so the UI can mark the model that will serve it
|
|
1206
|
+
// 📖 (routingOrder[0]) and label every entry as Primary vs Fallback.
|
|
1207
|
+
// 📖 Cheap to compute: reuses getRoutingCandidates + already-recorded health.
|
|
1208
|
+
getRoutingOrder(set) {
|
|
1209
|
+
return this.getRoutingCandidates(set).map((candidate) => ({
|
|
1210
|
+
key: candidate.key,
|
|
1211
|
+
provider: candidate.provider,
|
|
1212
|
+
model: candidate.model,
|
|
1213
|
+
priority: candidate.priority,
|
|
1214
|
+
state: candidate.circuit?.state || 'UNKNOWN',
|
|
1215
|
+
score: Number(candidate.score.toFixed(4)),
|
|
1216
|
+
}))
|
|
1194
1217
|
}
|
|
1195
1218
|
|
|
1196
1219
|
getModelHealth(set = this.getSet()) {
|
|
@@ -1210,6 +1233,11 @@ class RouterRuntime {
|
|
|
1210
1233
|
last_latency_ms: candidate.stats.last?.latencyMs ?? null,
|
|
1211
1234
|
uptime: candidate.stats.uptime,
|
|
1212
1235
|
last_error: candidate.circuit?.lastError || null,
|
|
1236
|
+
// 📖 AI Latency benchmark results for the Router Dashboard's "Probe all"
|
|
1237
|
+
// 📖 button. Mirrors the per-model fields already exposed on /api/models
|
|
1238
|
+
// 📖 so the set list can show live AI latency + TPS after a probe.
|
|
1239
|
+
isBenchmarking: this.webBenchmarkRunning?.has(candidate.key) || false,
|
|
1240
|
+
benchmark: this.webBenchmarkResults?.get(candidate.key) || null,
|
|
1213
1241
|
}))
|
|
1214
1242
|
}
|
|
1215
1243
|
|
|
@@ -1346,6 +1374,11 @@ class RouterRuntime {
|
|
|
1346
1374
|
const activeSet = this.getSet(router.activeSet)
|
|
1347
1375
|
return {
|
|
1348
1376
|
ok: true,
|
|
1377
|
+
// 📖 `running` mirrors `ok` so every consumer (Router view reads `ok`,
|
|
1378
|
+
// 📖 Playground reads `running`) agrees on daemon state. Without this,
|
|
1379
|
+
// 📖 the Playground showed "router offline" even when the Router card
|
|
1380
|
+
// 📖 said "Running" — both hit /api/router/status but read different fields.
|
|
1381
|
+
running: true,
|
|
1349
1382
|
pid: process.pid,
|
|
1350
1383
|
port: this.port,
|
|
1351
1384
|
enabled: router.enabled,
|
|
@@ -1386,6 +1419,19 @@ class RouterRuntime {
|
|
|
1386
1419
|
...this.statusPayload(),
|
|
1387
1420
|
tokens: this.tokenTracker.summary(),
|
|
1388
1421
|
models: this.getModelHealth(activeSet),
|
|
1422
|
+
// 📖 routingOrder — the exact attempt order for the next request
|
|
1423
|
+
// 📖 (priority-first among healthy models). routingOrder[0] is what will
|
|
1424
|
+
// 📖 serve the next chat completion. Surfaced so dashboards can mark the
|
|
1425
|
+
// 📖 "next" model and label Primary vs Fallback semantics. See issue #120.
|
|
1426
|
+
routingOrder: this.getRoutingOrder(activeSet),
|
|
1427
|
+
// 📖 Global AI Latency probe progress — powers the Router Dashboard's
|
|
1428
|
+
// 📖 "Probe all" button progress bar. Per-model results live on each
|
|
1429
|
+
// 📖 entry of `models` above (isBenchmarking / benchmark).
|
|
1430
|
+
globalBenchmark: {
|
|
1431
|
+
running: this.webGlobalBenchmarkRunning || false,
|
|
1432
|
+
total: this.webGlobalBenchmarkTotal || 0,
|
|
1433
|
+
completed: this.webGlobalBenchmarkCompleted || 0,
|
|
1434
|
+
},
|
|
1389
1435
|
requestLog: this.requestLog.slice(0, 20),
|
|
1390
1436
|
circuitBreakers: Object.fromEntries([...this.circuit.entries()].map(([key, value]) => [key, {
|
|
1391
1437
|
state: value.authError ? 'AUTH_ERROR' : value.stale ? 'STALE' : value.unsupported ? 'UNSUPPORTED' : value.state,
|
|
@@ -1501,7 +1547,7 @@ class RouterRuntime {
|
|
|
1501
1547
|
// 📖 skip that provider as a candidate for replacements.
|
|
1502
1548
|
const providerProbeStats = new Map() // provider -> { probed: n, authError: n, stale: n, alive: n }
|
|
1503
1549
|
for (const [providerKey, source] of Object.entries(sources)) {
|
|
1504
|
-
if (!isRouteableProvider(providerKey)) continue
|
|
1550
|
+
if (!isRouteableProvider(providerKey, sources)) continue
|
|
1505
1551
|
if (!providerProbeStats.has(providerKey)) providerProbeStats.set(providerKey, { probed: 0, authError: 0, stale: 0, alive: 0 })
|
|
1506
1552
|
for (const [modelId, , tier, sweScore, ctx] of source.models || []) {
|
|
1507
1553
|
const key = `${providerKey}/${modelId}`
|
|
@@ -2646,7 +2692,7 @@ class RouterRuntime {
|
|
|
2646
2692
|
if (req.method === 'GET' && url.pathname === '/api/router/catalog') {
|
|
2647
2693
|
const rows = []
|
|
2648
2694
|
for (const [providerKey, source] of Object.entries(sources)) {
|
|
2649
|
-
if (!isRouteableProvider(providerKey)) continue
|
|
2695
|
+
if (!isRouteableProvider(providerKey, sources)) continue
|
|
2650
2696
|
if (!Array.isArray(source.models)) continue
|
|
2651
2697
|
for (const [modelId, label, tier, sweScore, ctx] of source.models) {
|
|
2652
2698
|
rows.push({
|
|
@@ -3028,7 +3074,7 @@ export async function buildDefaultRouterSet(config = {}, maxModels, options = {}
|
|
|
3028
3074
|
if (maxModels === undefined) maxModels = Math.max(5, keyedProviders.size * 2)
|
|
3029
3075
|
const entries = []
|
|
3030
3076
|
for (const [providerKey, source] of Object.entries(sources)) {
|
|
3031
|
-
if (!isRouteableProvider(providerKey)) continue
|
|
3077
|
+
if (!isRouteableProvider(providerKey, sources)) continue
|
|
3032
3078
|
for (const [model, label, tier, sweScore, ctx] of source.models || []) {
|
|
3033
3079
|
entries.push({
|
|
3034
3080
|
provider: providerKey,
|
|
@@ -3184,7 +3230,7 @@ export function createRouterRuntimeForTest({ config, port = 0, logger = null, to
|
|
|
3184
3230
|
function createDefaultProbeFn(apiKeys) {
|
|
3185
3231
|
return async (entry) => {
|
|
3186
3232
|
const { provider, model } = entry
|
|
3187
|
-
if (!isRouteableProvider(provider)) return { ok: false, code: 'NOT_ROUTEABLE', latencyMs: 0 }
|
|
3233
|
+
if (!isRouteableProvider(provider, sources)) return { ok: false, code: 'NOT_ROUTEABLE', latencyMs: 0 }
|
|
3188
3234
|
const url = resolveProviderUrl(provider)
|
|
3189
3235
|
if (!url) return { ok: false, code: 'NO_URL', latencyMs: 0 }
|
|
3190
3236
|
const apiKey = getApiKey({ apiKeys: apiKeys || {} }, provider) || ''
|
|
@@ -3239,7 +3285,7 @@ function buildDefaultRouterSetSync(config = {}, maxModels = 5) {
|
|
|
3239
3285
|
.map(([provider]) => provider))
|
|
3240
3286
|
const entries = []
|
|
3241
3287
|
for (const [providerKey, source] of Object.entries(sources)) {
|
|
3242
|
-
if (!isRouteableProvider(providerKey)) continue
|
|
3288
|
+
if (!isRouteableProvider(providerKey, sources)) continue
|
|
3243
3289
|
for (const [model, label, tier, sweScore, ctx] of source.models || []) {
|
|
3244
3290
|
entries.push({ provider: providerKey, model, label, tier, sweScore, ctx, hasKey: keyedProviders.has(providerKey) })
|
|
3245
3291
|
}
|
|
@@ -3323,7 +3369,7 @@ function buildRouterSetFromFavorites(config) {
|
|
|
3323
3369
|
if (slashIdx < 0) continue
|
|
3324
3370
|
const providerKey = fav.slice(0, slashIdx)
|
|
3325
3371
|
const modelId = fav.slice(slashIdx + 1)
|
|
3326
|
-
if (!isRouteableProvider(providerKey)) continue
|
|
3372
|
+
if (!isRouteableProvider(providerKey, sources)) continue
|
|
3327
3373
|
const source = sources[providerKey]
|
|
3328
3374
|
if (!source) continue
|
|
3329
3375
|
const found = (source.models || []).find((m) => m[0] === modelId)
|
|
@@ -3381,6 +3427,13 @@ async function listenWithFallback(server, preferredPort, logger, host = '127.0.0
|
|
|
3381
3427
|
export async function runRouterDaemon() {
|
|
3382
3428
|
const config = loadConfig()
|
|
3383
3429
|
const router = await ensureRouterConfigForDaemon(config)
|
|
3430
|
+
// 📖 In dev mode, override the saved port with the dev default so a local
|
|
3431
|
+
// 📖 checkout doesn't clash with a production install on the same machine.
|
|
3432
|
+
// 📖 The saved config has port: 19280 (production); dev should use 29280.
|
|
3433
|
+
const { defaultPort: devDefault } = getRouterPortRange()
|
|
3434
|
+
if (_dev && router.port !== devDefault && router.port === DEFAULT_ROUTER_SETTINGS.port) {
|
|
3435
|
+
router.port = devDefault
|
|
3436
|
+
}
|
|
3384
3437
|
const logger = new RouterLogger(ROUTER_LOG_PATH, router.logLevel)
|
|
3385
3438
|
const runtime = new RouterRuntime({ config, port: router.port, logger })
|
|
3386
3439
|
runtime.installProcessSafety()
|
|
@@ -3443,7 +3496,12 @@ export async function runRouterDaemon() {
|
|
|
3443
3496
|
export async function getRouterDaemonStatus() {
|
|
3444
3497
|
const { defaultPort, maxPort } = getRouterPortRange()
|
|
3445
3498
|
const ports = []
|
|
3446
|
-
|
|
3499
|
+
// 📖 Use the dynamic path resolvers so dev checkouts (FCM_DEV=1) read the
|
|
3500
|
+
// 📖 `-dev` port/pid files and discover the dev daemon. The static constants
|
|
3501
|
+
// 📖 are frozen at module load and would always point at the production files.
|
|
3502
|
+
const portPath = getRouterPortPath()
|
|
3503
|
+
const pidPath = getRouterPidPath()
|
|
3504
|
+
const recordedPort = readNumberFile(portPath)
|
|
3447
3505
|
if (recordedPort) ports.push(recordedPort)
|
|
3448
3506
|
for (let port = defaultPort; port <= maxPort; port += 1) {
|
|
3449
3507
|
if (!ports.includes(port)) ports.push(port)
|
|
@@ -3456,7 +3514,7 @@ export async function getRouterDaemonStatus() {
|
|
|
3456
3514
|
// 📖 Keep scanning the small discovery range.
|
|
3457
3515
|
}
|
|
3458
3516
|
}
|
|
3459
|
-
const pid = readNumberFile(
|
|
3517
|
+
const pid = readNumberFile(pidPath)
|
|
3460
3518
|
return {
|
|
3461
3519
|
ok: false,
|
|
3462
3520
|
running: false,
|