free-coding-models 0.5.74 → 0.5.77
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 +16 -1
- package/changelog/v0.5.75.md +7 -0
- package/changelog/v0.5.76.md +16 -0
- package/changelog/v0.5.77.md +11 -0
- package/package.json +1 -1
- package/src/core/config.js +71 -10
- package/src/core/router-daemon.js +68 -16
- package/src/core/router-dashboard.js +23 -3
- package/src/tui/command-palette.js +19 -2
- package/src/tui/overlays.js +2 -0
- package/src/tui/tui-state.js +7 -0
- package/web/dist/assets/{index-BZkCdRST.js → index-BjEXVUHp.js} +3 -3
- package/web/dist/assets/{index-wI9xrm0w.css → index-C5-Ywvv3.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/server.js +4 -3
- package/web/src/components/router/RouterView.jsx +65 -2
- package/web/src/components/router/RouterView.module.css +109 -0
package/README.md
CHANGED
|
@@ -894,6 +894,9 @@ A: Yes — see [`sources.js`](./sources.js) for the model catalog format.
|
|
|
894
894
|
<td align="center" width="120"><a href="https://github.com/stgreenb"><img src="https://avatars.githubusercontent.com/u/18483964?v=4&s=80" width="80" height="80" style="border-radius:50%" alt="stgreenb"></a></td>
|
|
895
895
|
<td align="center" width="120"><a href="https://github.com/MoriDanWork"><img src="https://avatars.githubusercontent.com/u/55363096?v=4&s=80" width="80" height="80" style="border-radius:50%" alt="MoriDanWork"></a></td>
|
|
896
896
|
<td align="center" width="120"><a href="https://github.com/fan92rus"><img src="https://avatars.githubusercontent.com/u/13201333?v=4&s=80" width="80" height="80" style="border-radius:50%" alt="fan92rus"></a></td>
|
|
897
|
+
<td align="center" width="120"><a href="https://github.com/Muhammad95959"><img src="https://avatars.githubusercontent.com/u/75130655?v=4&s=80" width="80" height="80" style="border-radius:50%" alt="Muhammad95959"></a></td>
|
|
898
|
+
<td align="center" width="120"><a href="https://github.com/FaintFlower"><img src="https://avatars.githubusercontent.com/u/310248465?v=4&s=80" width="80" height="80" style="border-radius:50%" alt="FaintFlower"></a></td>
|
|
899
|
+
<td align="center" width="120"><a href="https://github.com/lehneres"><img src="https://avatars.githubusercontent.com/u/7437288?v=4&s=80" width="80" height="80" style="border-radius:50%" alt="lehneres"></a></td>
|
|
897
900
|
</tr>
|
|
898
901
|
<tr>
|
|
899
902
|
<td align="center"><a href="https://github.com/vava-nessa"><sub><b>vava-nessa</b></sub></a></td>
|
|
@@ -906,9 +909,21 @@ A: Yes — see [`sources.js`](./sources.js) for the model catalog format.
|
|
|
906
909
|
<td align="center"><a href="https://github.com/stgreenb"><sub><b>stgreenb</b></sub></a></td>
|
|
907
910
|
<td align="center"><a href="https://github.com/MoriDanWork"><sub><b>MoriDanWork</b></sub></a></td>
|
|
908
911
|
<td align="center"><a href="https://github.com/fan92rus"><sub><b>fan92rus</b></sub></a></td>
|
|
912
|
+
<td align="center"><a href="https://github.com/Muhammad95959"><sub><b>Muhammad95959</b></sub></a></td>
|
|
913
|
+
<td align="center"><a href="https://github.com/FaintFlower"><sub><b>FaintFlower</b></sub></a></td>
|
|
914
|
+
<td align="center"><a href="https://github.com/lehneres"><sub><b>lehneres</b></sub></a></td>
|
|
909
915
|
</tr>
|
|
910
916
|
<tr>
|
|
911
|
-
<td align="center" colspan="
|
|
917
|
+
<td align="center" colspan="13"><sub>🛡️ <b>fan92rus</b> — Windows path traversal fix (<code>path.sep</code>)</sub></td>
|
|
918
|
+
</tr>
|
|
919
|
+
<tr>
|
|
920
|
+
<td align="center" colspan="13"><sub>📁 <b>Muhammad95959</b> — <code>--config-dir</code> flag & XDG support</sub></td>
|
|
921
|
+
</tr>
|
|
922
|
+
<tr>
|
|
923
|
+
<td align="center" colspan="13"><sub>⭐ <b>FaintFlower</b> — Star History chart mirror fix</sub></td>
|
|
924
|
+
</tr>
|
|
925
|
+
<tr>
|
|
926
|
+
<td align="center" colspan="13"><sub>🚀 <b>lehneres</b> — Proxmox VE installation scripts</sub></td>
|
|
912
927
|
</tr>
|
|
913
928
|
</table>
|
|
914
929
|
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Changelog v0.5.75 - 2026-08-22
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
- **Contributors table now lists the two latest contributors** — `README.md:883` previously showed 10 avatars ending at `fan92rus`. Added `@Muhammad95959` (`--config-dir` flag, PR #158) and `@FaintFlower` (Star History mirror, PR #159) with their avatars and a dedicated footer line each, and updated `colspan` from 10 to 12. `AGENTS.md:104` contributor list also updated so future automation picks them up.
|
|
5
|
+
|
|
6
|
+
### Docs
|
|
7
|
+
- **No code changes** — pure documentation sync so the published npm README and GitHub README correctly credit recent PR authors.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog v0.5.76 - 2026-08-22
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
- **Proxmox VE one-command deployment** — new `scripts/proxmoxve-create.sh` and `scripts/proxmoxve-install.sh` built on the `community-scripts/ProxmoxVE` framework (Debian 12, 2 vCPU / 2 GB RAM / 8 GB disk, `fcm-web` systemd service on `19280` with `update` subcommand). Thanks @lehneres (PR #157).
|
|
5
|
+
- **Live router request tracking** — `src/core/router-daemon.js:890` now keeps an `activeRequests` map (requestId, model, current candidate, attempt, tokens, last activity) surfaced in both `src/core/router-dashboard.js` (TUI "Active Requests" panel) and `web/src/components/router/RouterView.jsx` (web "Active Requests" panel with 2s polling, request IDs in log). Helps see routing vs stalled vs processing in real time.
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **Router retry & failover robustness** — `src/core/router-daemon.js:125` retryable codes now include `408` and `504`, `maxAttempts` is correctly `1 + maxRetries` (was `maxRetries`), and stream stall after partial response now sends a `CAUTION` delta instead of an `error` payload so clients stay connected. Added `getRouterTokensPath()` and fixed `web/server.js` to use it. (PR #157)
|
|
9
|
+
- **Config persistence no longer clobbers custom router settings** — `src/core/config.js:340` adds `normalizeSyncSection` / `normalizeUpdaterSection`, deep-merges `router.failover` / `probeIntervals` / `circuitBreaker` / `scoring` in `buildPersistedConfig`, and `normalizeRouterFailover` falls back to router-level values. Partial updates (e.g. telemetry auto-save) no longer reset custom values to defaults. (PR #157)
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- **Major dependency updates (deps #151)** — `@tanstack/react-table` `8.21.3` -> `9.1.2` (requires `node >=20`, `react >=18`, new `@tanstack/react-store` + `@tanstack/store` peers) and `chalk` `5.6.2` -> `6.0.0` (requires `node >=22`, adds underline styles/colors). `package.json:85` `engines` bumped from `>=18.0.0` to `>=22.0.0` to reflect the new requirement. All checks green (`audit`, `lockfile-lint`, `Vercel`, `notify-discord`). `pnpm test` still `813/813`.
|
|
13
|
+
- **Contributors table** — `README.md:885` now shows 13 avatars (added `lehneres` alongside `Muhammad95959` and `FaintFlower`) with corrected `colspan 13` and footer line `🚀 lehneres — Proxmox VE installation scripts`. `AGENTS.md:104` updated.
|
|
14
|
+
|
|
15
|
+
### Tests
|
|
16
|
+
- **Updated SSE truncation assertion** — `test/test.js:3160` now asserts `CAUTION` + `Stream truncated by router` instead of `fcm_stream_failover` to match the new stream handling. All 813 tests pass.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog v0.5.77 - 2026-08-23
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
- **Release 0.5.76 failed to build and never reached npm** — the `deps: major-updates` merge in 0.5.76 bumped `@tanstack/react-table` `8.21.3` -> `9.1.2` and `chalk` `5.6.2` -> `6.0.0`. Table v9 changed its exports (`useReactTable` / `getCoreRowModel` no longer at the top level, new `useTable` + `tableFeatures` API), so `web/src/components/dashboard/ModelTable.jsx:17` threw `MISSING_EXPORT` and `vite build` failed in CI. Reverted both deps to their last working majors (`8.21.3` and `5.6.2`), restored `web/src/components/dashboard/ModelTable.jsx:17` to the v8 API (`useReactTable`, `createColumnHelper`), and set `package.json:85` `engines` back to `>=18.0.0` with `pnpm-lock.yaml` synced. The major bump is kept on hold until a proper v9 migration (`useTable` + `tableFeatures` or `useLegacyTable`) is done and manually tested. `pnpm test` is `813/813` and `pnpm build:web` now succeeds again.
|
|
5
|
+
- **Test `engines requires node >= 18` was flipped to `>=22`** — reverted `test/test.js:2376` to expect `18` to match the reverted engines.
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- No new features — this is a pure build-fix release so the Proxmox + router tracking from 0.5.76 (which was merged but never published) can finally ship as 0.5.77.
|
|
9
|
+
|
|
10
|
+
### Tests
|
|
11
|
+
- 813/813 passing, `vite build` green.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "free-coding-models",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.77",
|
|
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/src/core/config.js
CHANGED
|
@@ -342,12 +342,30 @@ function normalizeTelemetrySection(telemetry) {
|
|
|
342
342
|
}
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
+
function normalizeSyncSection(sync) {
|
|
346
|
+
const safeSync = isPlainObject(sync) ? { ...sync } : {}
|
|
347
|
+
return {
|
|
348
|
+
enabled: typeof safeSync.enabled === 'boolean' ? safeSync.enabled : false,
|
|
349
|
+
lastSyncedAt: typeof safeSync.lastSyncedAt === 'string' ? safeSync.lastSyncedAt : null,
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function normalizeUpdaterSection(updater) {
|
|
354
|
+
const safeUpdater = isPlainObject(updater) ? { ...updater } : {}
|
|
355
|
+
return {
|
|
356
|
+
lastCheckedAt: typeof safeUpdater.lastCheckedAt === 'string' ? safeUpdater.lastCheckedAt : null,
|
|
357
|
+
lastNotificationAt: typeof safeUpdater.lastNotificationAt === 'string' ? safeUpdater.lastNotificationAt : null,
|
|
358
|
+
skippedVersions: Array.isArray(safeUpdater.skippedVersions) ? safeUpdater.skippedVersions.filter(v => typeof v === 'string') : [],
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
345
362
|
function normalizeRouterName(value, fallback = '') {
|
|
346
363
|
if (typeof value !== 'string') return fallback
|
|
347
364
|
return value.trim().replace(/\s+/g, '-').replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 64) || fallback
|
|
348
365
|
}
|
|
349
366
|
|
|
350
367
|
function normalizePositiveInteger(value, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
|
|
368
|
+
if (value === null || value === undefined || value === '') return fallback
|
|
351
369
|
const numeric = Number(value)
|
|
352
370
|
if (!Number.isFinite(numeric)) return fallback
|
|
353
371
|
return Math.max(min, Math.min(max, Math.round(numeric)))
|
|
@@ -413,12 +431,21 @@ function normalizeRouterCircuitBreaker(circuitBreaker) {
|
|
|
413
431
|
}
|
|
414
432
|
}
|
|
415
433
|
|
|
416
|
-
function normalizeRouterFailover(failover) {
|
|
434
|
+
function normalizeRouterFailover(failover, routerFallback = {}) {
|
|
417
435
|
const safeFailover = isPlainObject(failover) ? failover : {}
|
|
436
|
+
const numberOrNull = (val) => {
|
|
437
|
+
const n = Number(val)
|
|
438
|
+
return (val !== null && val !== '' && Number.isFinite(n)) ? n : null
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const rawMaxRetries = numberOrNull(safeFailover.maxRetries) ?? numberOrNull(routerFallback.maxRetries)
|
|
442
|
+
const rawStreamStall = numberOrNull(safeFailover.streamStallTimeoutMs) ?? numberOrNull(routerFallback.streamStallTimeoutMs)
|
|
443
|
+
const rawRequestTimeout = numberOrNull(safeFailover.requestTimeoutMs) ?? numberOrNull(routerFallback.requestTimeoutMs)
|
|
444
|
+
|
|
418
445
|
return {
|
|
419
|
-
maxRetries: normalizePositiveInteger(
|
|
420
|
-
streamStallTimeoutMs: normalizePositiveInteger(
|
|
421
|
-
requestTimeoutMs: normalizePositiveInteger(
|
|
446
|
+
maxRetries: normalizePositiveInteger(rawMaxRetries, DEFAULT_ROUTER_SETTINGS.failover.maxRetries, { min: 1, max: 20 }),
|
|
447
|
+
streamStallTimeoutMs: normalizePositiveInteger(rawStreamStall, DEFAULT_ROUTER_SETTINGS.failover.streamStallTimeoutMs, { min: 1000, max: 120000 }),
|
|
448
|
+
requestTimeoutMs: normalizePositiveInteger(rawRequestTimeout, DEFAULT_ROUTER_SETTINGS.failover.requestTimeoutMs, { min: 1000, max: 300000 }),
|
|
422
449
|
}
|
|
423
450
|
}
|
|
424
451
|
|
|
@@ -470,7 +497,7 @@ export function normalizeRouterConfig(router) {
|
|
|
470
497
|
probeMode,
|
|
471
498
|
probeIntervals: normalizeRouterIntervals(router.probeIntervals),
|
|
472
499
|
circuitBreaker: normalizeRouterCircuitBreaker(router.circuitBreaker),
|
|
473
|
-
failover: normalizeRouterFailover(router.failover),
|
|
500
|
+
failover: normalizeRouterFailover(router.failover, router),
|
|
474
501
|
scoring: normalizeRouterScoring(router.scoring),
|
|
475
502
|
logLevel,
|
|
476
503
|
prePrompt: normalizeRouterPrePrompt(router.prePrompt),
|
|
@@ -528,6 +555,8 @@ function normalizeConfigShape(config) {
|
|
|
528
555
|
settings: normalizeSettingsSection(safeConfig.settings),
|
|
529
556
|
favorites: normalizeFavoriteList(safeConfig.favorites),
|
|
530
557
|
telemetry: normalizeTelemetrySection(safeConfig.telemetry),
|
|
558
|
+
sync: normalizeSyncSection(safeConfig.sync),
|
|
559
|
+
updater: normalizeUpdaterSection(safeConfig.updater),
|
|
531
560
|
endpointInstalls: normalizeEndpointInstalls(safeConfig.endpointInstalls),
|
|
532
561
|
// 📖 hiddenModels: Set of "provider/modelId" keys auto-hidden by the 404 probe (Ctrl+Shift+P).
|
|
533
562
|
// 📖 Only populated when settings.autoHideBrokenModels is true (default).
|
|
@@ -595,10 +624,15 @@ export function buildPersistedConfig(incomingConfig, diskConfig = _emptyConfig()
|
|
|
595
624
|
favorites: options.replaceFavorites === true
|
|
596
625
|
? [...normalizedIncoming.favorites]
|
|
597
626
|
: mergeOrderedUniqueStrings(normalizedIncoming.favorites, normalizedDisk.favorites),
|
|
598
|
-
telemetry:
|
|
599
|
-
...normalizedDisk.telemetry,
|
|
600
|
-
|
|
601
|
-
|
|
627
|
+
telemetry: isPlainObject(incomingConfig.telemetry)
|
|
628
|
+
? { ...normalizedDisk.telemetry, ...normalizedIncoming.telemetry }
|
|
629
|
+
: cloneConfigValue(normalizedDisk.telemetry),
|
|
630
|
+
sync: isPlainObject(incomingConfig.sync)
|
|
631
|
+
? { ...normalizedDisk.sync, ...normalizedIncoming.sync }
|
|
632
|
+
: cloneConfigValue(normalizedDisk.sync),
|
|
633
|
+
updater: isPlainObject(incomingConfig.updater)
|
|
634
|
+
? { ...normalizedDisk.updater, ...normalizedIncoming.updater }
|
|
635
|
+
: cloneConfigValue(normalizedDisk.updater),
|
|
602
636
|
// 📖 Managed endpoint installs sometimes need an exact snapshot so stale disk
|
|
603
637
|
// 📖 records do not come back after a fresh install/refresh pass.
|
|
604
638
|
endpointInstalls: options.replaceEndpointInstalls === true
|
|
@@ -606,7 +640,32 @@ export function buildPersistedConfig(incomingConfig, diskConfig = _emptyConfig()
|
|
|
606
640
|
: mergeEndpointInstalls(normalizedDisk.endpointInstalls, normalizedIncoming.endpointInstalls),
|
|
607
641
|
// 📖 Profile system removed - always null
|
|
608
642
|
}
|
|
609
|
-
if (
|
|
643
|
+
if (isPlainObject(incomingConfig.router) && isPlainObject(normalizedDisk.router)) {
|
|
644
|
+
// 📖 Deep merge router settings so partial updates (e.g. from telemetry auto-save)
|
|
645
|
+
// 📖 do not reset custom failover/scoring/intervals to defaults.
|
|
646
|
+
merged.router = {
|
|
647
|
+
...normalizedDisk.router,
|
|
648
|
+
...normalizedIncoming.router,
|
|
649
|
+
failover: {
|
|
650
|
+
...normalizedDisk.router.failover,
|
|
651
|
+
...(isPlainObject(incomingConfig.router.failover) ? normalizedIncoming.router.failover : {}),
|
|
652
|
+
// 📖 Also support top-level maxRetries/timeout overrides from incoming RAW config
|
|
653
|
+
...(typeof incomingConfig.router.maxRetries === 'number' ? { maxRetries: incomingConfig.router.maxRetries } : {}),
|
|
654
|
+
},
|
|
655
|
+
probeIntervals: {
|
|
656
|
+
...normalizedDisk.router.probeIntervals,
|
|
657
|
+
...(isPlainObject(incomingConfig.router.probeIntervals) ? normalizedIncoming.router.probeIntervals : {}),
|
|
658
|
+
},
|
|
659
|
+
circuitBreaker: {
|
|
660
|
+
...normalizedDisk.router.circuitBreaker,
|
|
661
|
+
...(isPlainObject(incomingConfig.router.circuitBreaker) ? normalizedIncoming.router.circuitBreaker : {}),
|
|
662
|
+
},
|
|
663
|
+
scoring: {
|
|
664
|
+
...normalizedDisk.router.scoring,
|
|
665
|
+
...(isPlainObject(incomingConfig.router.scoring) ? normalizedIncoming.router.scoring : {}),
|
|
666
|
+
},
|
|
667
|
+
}
|
|
668
|
+
} else if (Object.prototype.hasOwnProperty.call(incomingConfig, 'router')) {
|
|
610
669
|
merged.router = cloneConfigValue(normalizedIncoming.router)
|
|
611
670
|
} else if (Object.prototype.hasOwnProperty.call(normalizedDisk, 'router')) {
|
|
612
671
|
merged.router = cloneConfigValue(normalizedDisk.router)
|
|
@@ -1244,6 +1303,8 @@ function _emptyConfig() {
|
|
|
1244
1303
|
providers: {},
|
|
1245
1304
|
favorites: [],
|
|
1246
1305
|
telemetry: { enabled: null, consentVersion: 0, anonymousId: null },
|
|
1306
|
+
sync: { enabled: false, lastSyncedAt: null },
|
|
1307
|
+
updater: { lastCheckedAt: null, lastNotificationAt: null, skippedVersions: [] },
|
|
1247
1308
|
endpointInstalls: [],
|
|
1248
1309
|
settings: _emptyProfileSettings(),
|
|
1249
1310
|
hiddenModels: new Set(),
|
|
@@ -105,6 +105,7 @@ export const ROUTER_TOKENS_PATH = join(homedir(), `.free-coding-models-tokens${_
|
|
|
105
105
|
export function getRouterPidPath() { return join(homedir(), `.free-coding-models-daemon${_isDev() ? '-dev' : ''}.pid`) }
|
|
106
106
|
export function getRouterPortPath() { return join(homedir(), `.free-coding-models-daemon${_isDev() ? '-dev' : ''}.port`) }
|
|
107
107
|
export function getRouterLogPath() { return join(homedir(), `.free-coding-models-daemon${_isDev() ? '-dev' : ''}.log`) }
|
|
108
|
+
export function getRouterTokensPath() { return join(homedir(), `.free-coding-models-tokens${_isDev() ? '-dev' : ''}.json`) }
|
|
108
109
|
|
|
109
110
|
// 📖 Returns effective port range for current mode (dev vs production)
|
|
110
111
|
export function getRouterPortRange() {
|
|
@@ -124,7 +125,7 @@ const MAX_PROBE_WINDOW = 20
|
|
|
124
125
|
const TOKEN_FLUSH_INTERVAL_MS = 60000
|
|
125
126
|
const CONFIG_RELOAD_INTERVAL_MS = 10000
|
|
126
127
|
const STATS_RETENTION_DAYS = 90
|
|
127
|
-
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 529])
|
|
128
|
+
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504, 529])
|
|
128
129
|
const AUTH_STATUS_CODES = new Set([401, 403])
|
|
129
130
|
const RATE_LIMIT_HEADER_NAMES = [
|
|
130
131
|
'retry-after',
|
|
@@ -889,6 +890,7 @@ class RouterRuntime {
|
|
|
889
890
|
this.probeWindows = new Map()
|
|
890
891
|
this.circuit = new Map()
|
|
891
892
|
this.requestLog = []
|
|
893
|
+
this.activeRequests = new Map()
|
|
892
894
|
this.sseClients = new Set()
|
|
893
895
|
this.lastProbeAt = null
|
|
894
896
|
this.totalRequestsRouted = 0
|
|
@@ -1608,6 +1610,15 @@ class RouterRuntime {
|
|
|
1608
1610
|
completed: this.webGlobalBenchmarkCompleted || 0,
|
|
1609
1611
|
},
|
|
1610
1612
|
requestLog: this.requestLog.slice(0, 20),
|
|
1613
|
+
activeRequests: Array.from(this.activeRequests.values()).map(r => ({
|
|
1614
|
+
requestId: r.requestId,
|
|
1615
|
+
at: r.at,
|
|
1616
|
+
model: r.model,
|
|
1617
|
+
current_model: r.current_model,
|
|
1618
|
+
attempts: r.attempts,
|
|
1619
|
+
tokens: r.tokens,
|
|
1620
|
+
stalled: r.stalled
|
|
1621
|
+
})),
|
|
1611
1622
|
circuitBreakers: Object.fromEntries([...this.circuit.entries()].map(([key, value]) => [key, {
|
|
1612
1623
|
state: value.authError ? 'AUTH_ERROR' : value.stale ? 'STALE' : value.unsupported ? 'UNSUPPORTED' : value.state,
|
|
1613
1624
|
consecutiveFailures: value.consecutiveFailures,
|
|
@@ -1963,6 +1974,15 @@ class RouterRuntime {
|
|
|
1963
1974
|
}
|
|
1964
1975
|
|
|
1965
1976
|
async routeRequest({ req, res, body, setName, requestId }) {
|
|
1977
|
+
this.activeRequests.set(requestId, {
|
|
1978
|
+
requestId,
|
|
1979
|
+
at: Date.now(),
|
|
1980
|
+
model: body?.model || 'fcm',
|
|
1981
|
+
current_model: null,
|
|
1982
|
+
attempts: 0,
|
|
1983
|
+
tokens: 0,
|
|
1984
|
+
stalled: false
|
|
1985
|
+
})
|
|
1966
1986
|
if (this.shuttingDown) {
|
|
1967
1987
|
sendError(res, 503, 'Daemon is shutting down', 'service_unavailable', 'daemon_shutting_down', requestId)
|
|
1968
1988
|
return
|
|
@@ -1988,7 +2008,7 @@ class RouterRuntime {
|
|
|
1988
2008
|
|
|
1989
2009
|
const candidates = this.getRoutingCandidates(set)
|
|
1990
2010
|
const maxRetries = this.routerConfig().failover.maxRetries
|
|
1991
|
-
const maxAttempts =
|
|
2011
|
+
const maxAttempts = 1 + maxRetries
|
|
1992
2012
|
if (candidates.length === 0) {
|
|
1993
2013
|
const health = this.getModelHealth(set)
|
|
1994
2014
|
const quotaExhausted = [...this.quotaExhausted].filter((key) => set.models.some((model) => modelKey(model.provider, model.model) === key))
|
|
@@ -2042,6 +2062,13 @@ class RouterRuntime {
|
|
|
2042
2062
|
for (const candidate of candidates) {
|
|
2043
2063
|
if (attemptIndex >= maxAttempts) break
|
|
2044
2064
|
if (blockedProviders.has(candidate.provider)) continue
|
|
2065
|
+
|
|
2066
|
+
const activeReq = this.activeRequests.get(requestId)
|
|
2067
|
+
if (activeReq) {
|
|
2068
|
+
activeReq.current_model = candidate.key
|
|
2069
|
+
activeReq.attempts = attemptIndex + 1
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2045
2072
|
tried.push(candidate.key)
|
|
2046
2073
|
const result = body.stream === true
|
|
2047
2074
|
? await this.proxyStreamingRequest({ req, res, body, candidate, requestId, attemptIndex })
|
|
@@ -2101,6 +2128,7 @@ class RouterRuntime {
|
|
|
2101
2128
|
})
|
|
2102
2129
|
} finally {
|
|
2103
2130
|
this.inFlight -= 1
|
|
2131
|
+
this.activeRequests.delete(requestId)
|
|
2104
2132
|
}
|
|
2105
2133
|
}
|
|
2106
2134
|
|
|
@@ -2269,6 +2297,11 @@ class RouterRuntime {
|
|
|
2269
2297
|
|
|
2270
2298
|
async proxyStreamingRequest({ req, res, body, candidate, requestId, attemptIndex }) {
|
|
2271
2299
|
const key = candidate.key
|
|
2300
|
+
const activeReq = this.activeRequests.get(requestId)
|
|
2301
|
+
if (activeReq) {
|
|
2302
|
+
activeReq.current_model = key
|
|
2303
|
+
if (activeReq.last_activity_at) activeReq.last_activity_at = Date.now()
|
|
2304
|
+
}
|
|
2272
2305
|
const apiKey = this.getApiKeyForProvider(candidate.provider)
|
|
2273
2306
|
// 📖 Guard: bail early if provider URL cannot be resolved
|
|
2274
2307
|
const providerUrl = resolveProviderUrl(candidate.provider)
|
|
@@ -2397,6 +2430,10 @@ class RouterRuntime {
|
|
|
2397
2430
|
// 📖 Guard: ensure chunk value is safe for Buffer conversion
|
|
2398
2431
|
const buf = Buffer.isBuffer(chunk.value) ? chunk.value : Buffer.from(chunk.value)
|
|
2399
2432
|
res.write(buf)
|
|
2433
|
+
if (activeReq) {
|
|
2434
|
+
if (activeReq.last_activity_at) activeReq.last_activity_at = Date.now()
|
|
2435
|
+
activeReq.tokens += 1 // Increment a counter to show progress
|
|
2436
|
+
}
|
|
2400
2437
|
}
|
|
2401
2438
|
|
|
2402
2439
|
this.markSuccess(key, latencyMs)
|
|
@@ -2443,15 +2480,18 @@ class RouterRuntime {
|
|
|
2443
2480
|
this.logger.warn(`Stream stall after partial response from ${key}, attempting failover`, { request_id: requestId, reason })
|
|
2444
2481
|
if (!res.writableEnded) {
|
|
2445
2482
|
try {
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2483
|
+
// 📖 Issue #137: failover even after a partial response.
|
|
2484
|
+
// 📖 We use a regular chat delta instead of an error payload so
|
|
2485
|
+
// 📖 that clients (which often abort on "error") stay connected.
|
|
2486
|
+
const failoverMsg = `\n\n> [!CAUTION]\n> Stream truncated by router due to upstream ${reason}; failing over to next model.\n\n`
|
|
2487
|
+
const deltaPayload = JSON.stringify({
|
|
2488
|
+
choices: [{
|
|
2489
|
+
index: 0,
|
|
2490
|
+
delta: { content: failoverMsg },
|
|
2491
|
+
finish_reason: null,
|
|
2492
|
+
}],
|
|
2453
2493
|
})
|
|
2454
|
-
res.write(`data: ${
|
|
2494
|
+
res.write(`data: ${deltaPayload}\n\n`)
|
|
2455
2495
|
} catch { /* best-effort */ }
|
|
2456
2496
|
}
|
|
2457
2497
|
return { done: false, failoverToNext: true, reason: `stream_stall_${reason}` }
|
|
@@ -3301,8 +3341,8 @@ class RouterRuntime {
|
|
|
3301
3341
|
flushProbeCache() // 📖 t1: persist any pending probe-cache deltas before exit
|
|
3302
3342
|
if (this.runtimeTelemetryDirty) flushRuntimeTelemetryStore() // 📖 t3
|
|
3303
3343
|
try { this.server?.close() } catch {}
|
|
3304
|
-
try { unlinkSync(
|
|
3305
|
-
try { unlinkSync(
|
|
3344
|
+
try { unlinkSync(getRouterPidPath()) } catch {}
|
|
3345
|
+
try { unlinkSync(getRouterPortPath()) } catch {}
|
|
3306
3346
|
void sendUsageTelemetry(this.config, {}, {
|
|
3307
3347
|
event: 'app_daemon_stop',
|
|
3308
3348
|
mode: 'daemon',
|
|
@@ -3846,16 +3886,28 @@ export async function startRouterDaemonBackground() {
|
|
|
3846
3886
|
}
|
|
3847
3887
|
|
|
3848
3888
|
export async function stopRouterDaemon() {
|
|
3849
|
-
const
|
|
3850
|
-
|
|
3889
|
+
const pidPath = getRouterPidPath()
|
|
3890
|
+
let pid = readNumberFile(pidPath)
|
|
3891
|
+
|
|
3892
|
+
// 📖 Fallback: if the PID file is missing or invalid, try to discover the
|
|
3893
|
+
// 📖 running daemon via its health port and get the PID from there.
|
|
3894
|
+
if (!pid) {
|
|
3895
|
+
const status = await getRouterDaemonStatus()
|
|
3896
|
+
if (status.ok && status.pid) pid = status.pid
|
|
3897
|
+
}
|
|
3898
|
+
|
|
3899
|
+
if (!pid) return { ok: false, stopped: false, error: 'No daemon PID found' }
|
|
3851
3900
|
if (!isProcessAlive(pid)) {
|
|
3852
|
-
try { unlinkSync(
|
|
3901
|
+
try { unlinkSync(pidPath) } catch {}
|
|
3853
3902
|
return { ok: true, stopped: false, stalePid: pid }
|
|
3854
3903
|
}
|
|
3855
3904
|
process.kill(pid, 'SIGTERM')
|
|
3856
3905
|
for (let i = 0; i < 60; i += 1) {
|
|
3857
3906
|
await sleep(250)
|
|
3858
|
-
if (!isProcessAlive(pid))
|
|
3907
|
+
if (!isProcessAlive(pid)) {
|
|
3908
|
+
try { unlinkSync(pidPath) } catch {}
|
|
3909
|
+
return { ok: true, stopped: true, pid }
|
|
3910
|
+
}
|
|
3859
3911
|
}
|
|
3860
3912
|
return { ok: false, stopped: false, pid, error: 'Daemon did not stop before timeout' }
|
|
3861
3913
|
}
|
|
@@ -301,6 +301,7 @@ export function normalizeRouterDashboardSnapshot(healthPayload, statsPayload) {
|
|
|
301
301
|
models,
|
|
302
302
|
routingOrder,
|
|
303
303
|
requestLog,
|
|
304
|
+
activeRequests: Array.isArray(merged.activeRequests) ? merged.activeRequests : [],
|
|
304
305
|
}
|
|
305
306
|
}
|
|
306
307
|
|
|
@@ -1034,12 +1035,29 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
1034
1035
|
// ── Token Summary (compact, visual) ─────────────────────────────────────────
|
|
1035
1036
|
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)`)}`)
|
|
1036
1037
|
|
|
1038
|
+
// ── Active Requests ────────────────────────────────────────────────────────
|
|
1039
|
+
lines.push('')
|
|
1040
|
+
lines.push(` ${themeColors.warningBold('⚡ Active Requests')} ${themeColors.dim(`(${snapshot.activeRequests?.length || 0})`)}`)
|
|
1041
|
+
if (snapshot.activeRequests?.length > 0) {
|
|
1042
|
+
for (const req of snapshot.activeRequests) {
|
|
1043
|
+
const model = compactText(req.model, 24)
|
|
1044
|
+
const current = compactText(req.current_model || 'Routing...', 24)
|
|
1045
|
+
const duration = formatRouterDuration(Math.floor((Date.now() - req.started_at) / 1000))
|
|
1046
|
+
const status = req.stalled ? themeColors.errorBold('STALLED?') : themeColors.success('Processing...')
|
|
1047
|
+
const tokens = req.stream ? ` ${themeColors.dim(`(${req.tokens} tok)`)}` : ''
|
|
1048
|
+
const shortId = req.request_id ? themeColors.dim(`[${req.request_id.slice(-4)}] `) : ''
|
|
1049
|
+
lines.push(` ${themeColors.dim('•')} ${shortId}${model} → ${themeColors.info(current)} ${status}${tokens} ${themeColors.dim(duration)}`)
|
|
1050
|
+
}
|
|
1051
|
+
} else {
|
|
1052
|
+
lines.push(` ${themeColors.dim('No active requests')}`)
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1037
1055
|
// ── Live Request Log (compact) ──────────────────────────────────────────────
|
|
1038
1056
|
const requestRows = requestLogRows(state, snapshot)
|
|
1039
1057
|
lines.push('')
|
|
1040
1058
|
lines.push(` ${themeColors.textBold('Recent Requests')}`)
|
|
1041
1059
|
if (requestRows.length > 0) {
|
|
1042
|
-
const header = ` ${padEndDisplay('Time', 10)} ${padEndDisplay('Model', 34)} ${padEndDisplay('Status', 8)} ${padEndDisplay('Latency', 9)} Detail`
|
|
1060
|
+
const header = ` ${padEndDisplay('ID', 5)} ${padEndDisplay('Time', 10)} ${padEndDisplay('Model', 34)} ${padEndDisplay('Status', 8)} ${padEndDisplay('Latency', 9)} Detail`
|
|
1043
1061
|
lines.push(themeColors.dim(header))
|
|
1044
1062
|
for (const row of requestRows.slice(0, 6)) {
|
|
1045
1063
|
const atMs = Date.parse(row.at)
|
|
@@ -1052,12 +1070,14 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
1052
1070
|
row.stream ? 'stream' : '',
|
|
1053
1071
|
row.error || '',
|
|
1054
1072
|
].filter(Boolean).join(', ') || '—'
|
|
1073
|
+
const shortId = row.request_id ? row.request_id.slice(-4) : '—'
|
|
1055
1074
|
lines.push(
|
|
1056
|
-
` ${padEndDisplay(
|
|
1075
|
+
` ${padEndDisplay(shortId, 5)} ` +
|
|
1076
|
+
`${padEndDisplay(time, 10)} ` +
|
|
1057
1077
|
`${compactText(row.model, 34)} ` +
|
|
1058
1078
|
`${padEndDisplay(statusColor(statusText), 8)} ` +
|
|
1059
1079
|
`${padEndDisplay(latency, 9)} ` +
|
|
1060
|
-
`${compactText(detail, Math.max(10, width -
|
|
1080
|
+
`${compactText(detail, Math.max(10, width - 74)).trimEnd()}`
|
|
1061
1081
|
)
|
|
1062
1082
|
}
|
|
1063
1083
|
} else {
|
|
@@ -219,12 +219,29 @@ const BASE_COMMAND_TREE = [
|
|
|
219
219
|
/**
|
|
220
220
|
* 📖 Build the command palette tree with dynamic model filters.
|
|
221
221
|
* @param {Array} visibleModels - Optional list of visible models to create model filter entries
|
|
222
|
+
* @param {object} config - Optional FCM config to customize dynamic labels
|
|
222
223
|
* @returns {Array} The command tree with model filters added
|
|
223
224
|
*/
|
|
224
|
-
export function buildCommandPaletteTree(visibleModels = []) {
|
|
225
|
+
export function buildCommandPaletteTree(visibleModels = [], config = null) {
|
|
225
226
|
// 📖 Clone the base tree
|
|
226
227
|
const tree = JSON.parse(JSON.stringify(BASE_COMMAND_TREE))
|
|
227
|
-
|
|
228
|
+
|
|
229
|
+
// 📖 Update Normal mode label if config is provided
|
|
230
|
+
if (config) {
|
|
231
|
+
const normalInterval = config.settings?.pingInterval ?? 10000
|
|
232
|
+
const normalSec = Math.round(normalInterval / 1000)
|
|
233
|
+
const actionsNode = tree.find(n => n.id === 'actions')
|
|
234
|
+
const pingNode = actionsNode?.children.find(n => n.id === 'action-ping-mode')
|
|
235
|
+
const normalCommand = pingNode?.children.find(n => n.id === 'action-set-ping-normal')
|
|
236
|
+
if (normalCommand) {
|
|
237
|
+
normalCommand.label = `Normal mode (${normalSec}s)`
|
|
238
|
+
if (Array.isArray(normalCommand.keywords)) {
|
|
239
|
+
normalCommand.keywords = normalCommand.keywords.filter(k => !k.endsWith('s'))
|
|
240
|
+
normalCommand.keywords.push(`${normalSec}s`)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
228
245
|
// 📖 Find the filter-model category and add dynamic model entries
|
|
229
246
|
const filterModelCategory = tree.find(cat => cat.id === 'filters')
|
|
230
247
|
?.children.find(sub => sub.id === 'filter-model')
|
package/src/tui/overlays.js
CHANGED
|
@@ -336,6 +336,8 @@ export function createOverlayRenderers(state, deps) {
|
|
|
336
336
|
themeColors.dim(' ') +
|
|
337
337
|
themeColors.footerLove('Made with 💖 & ☕ by ') +
|
|
338
338
|
themeColors.link('\x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
|
|
339
|
+
themeColors.dim(' • ⭐ ') +
|
|
340
|
+
themeColors.link('\x1b]8;;https://github.com/vava-nessa/free-coding-models/graphs/contributors\x1b\\Contributors\x1b]8;;\x1b\\') +
|
|
339
341
|
themeColors.dim(' • 💬 ') +
|
|
340
342
|
themeColors.footerDiscord('\x1b]8;;https://discord.gg/ZTNFHvvCkU\x1b\\Join the Discord\x1b]8;;\x1b\\') +
|
|
341
343
|
themeColors.dim(' • ☕ ') +
|
package/src/tui/tui-state.js
CHANGED
|
@@ -87,6 +87,9 @@ export function createTuiState({
|
|
|
87
87
|
}) {
|
|
88
88
|
const now = Date.now()
|
|
89
89
|
|
|
90
|
+
// 📖 Dynamic normal interval from config (default 10s)
|
|
91
|
+
const normalInterval = config.settings?.pingInterval ?? PING_MODE_INTERVALS.normal
|
|
92
|
+
|
|
90
93
|
return {
|
|
91
94
|
// 📖 Core data: model results (mutated in-place by ping loop)
|
|
92
95
|
results,
|
|
@@ -107,6 +110,10 @@ export function createTuiState({
|
|
|
107
110
|
pingInterval: PING_MODE_INTERVALS.speed,
|
|
108
111
|
pingMode: 'speed',
|
|
109
112
|
pingModeSource: 'startup',
|
|
113
|
+
pingModeIntervals: {
|
|
114
|
+
...PING_MODE_INTERVALS,
|
|
115
|
+
normal: normalInterval,
|
|
116
|
+
},
|
|
110
117
|
speedModeUntil: now + SPEED_MODE_DURATION_MS,
|
|
111
118
|
lastPingTime: now,
|
|
112
119
|
lastUserActivityAt: now,
|