free-coding-models 0.5.75 → 0.5.78

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 CHANGED
@@ -896,6 +896,7 @@ A: Yes — see [`sources.js`](./sources.js) for the model catalog format.
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
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
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>
899
900
  </tr>
900
901
  <tr>
901
902
  <td align="center"><a href="https://github.com/vava-nessa"><sub><b>vava-nessa</b></sub></a></td>
@@ -910,15 +911,19 @@ A: Yes — see [`sources.js`](./sources.js) for the model catalog format.
910
911
  <td align="center"><a href="https://github.com/fan92rus"><sub><b>fan92rus</b></sub></a></td>
911
912
  <td align="center"><a href="https://github.com/Muhammad95959"><sub><b>Muhammad95959</b></sub></a></td>
912
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>
913
915
  </tr>
914
916
  <tr>
915
- <td align="center" colspan="12"><sub>🛡️ <b>fan92rus</b> — Windows path traversal fix (<code>path.sep</code>)</sub></td>
917
+ <td align="center" colspan="13"><sub>🛡️ <b>fan92rus</b> — Windows path traversal fix (<code>path.sep</code>)</sub></td>
916
918
  </tr>
917
919
  <tr>
918
- <td align="center" colspan="12"><sub>📁 <b>Muhammad95959</b> — <code>--config-dir</code> flag & XDG support</sub></td>
920
+ <td align="center" colspan="13"><sub>📁 <b>Muhammad95959</b> — <code>--config-dir</code> flag & XDG support</sub></td>
919
921
  </tr>
920
922
  <tr>
921
- <td align="center" colspan="12"><sub>⭐ <b>FaintFlower</b> — Star History chart mirror fix</sub></td>
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>
922
927
  </tr>
923
928
  </table>
924
929
 
@@ -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.
@@ -0,0 +1,17 @@
1
+ # Changelog v0.5.78 - 2026-08-23
2
+
3
+ ### Fixed
4
+ - **Router forwards `prompt_cache_key` to NVIDIA -> HTTP 400 (fixes #160).** Clients using OpenAI JS SDK (pi agent) set `prompt_cache_key` to session UUID for prompt caching; NVIDIA rejects it with `Unsupported parameter(s): prompt_cache_key`. Added `prompt_cache_key` to `STRIP_PARAMS` in `src/core/schema-normalizer.js` and added `nvidia: normalizeNvidia` (strip + orphan tool drop) to `PROVIDER_NORMALIZERS` so `normalizeRequestBody(body, 'nvidia')` now strips the hint before forwarding. Verified with direct replay against `integrate.api.nvidia.com` and `pnpm test` 813/813. Thanks @pelvity.
5
+
6
+ - **Config `pingInterval` not respected at startup (fixes #155).** TUI always started in speed mode (2s) ignoring `settings.pingInterval` and `--ping-interval`. Now `src/tui/tui-state.js` `createTuiState()` derives `initialInterval` from `config.settings.pingInterval` (already merged from CLI in `src/tui/app.js:283`) and maps to mode via `intervalToPingMode` (30000 -> slow, 10000 -> normal, 4000 -> forced, <=3000 -> speed). Sets `pingModeSource` to `config` vs `startup` and only sets `speedModeUntil` when initial mode is speed. Users with 30s interval no longer burn quota on startup burst. Thanks @BetterToAutomateTheWorld.
7
+
8
+ - **jcode configuration option for CLI and web (fixes #145).** CLI Z cycle already had `jcode` but launch hung (stdin left in raw mode, REPL appeared frozen) and web had no jcode option at all.
9
+ - CLI hang: `src/tui/key-handler.js` `launchSelectedModel` now calls `stopUi({ resetRawMode: true })` (was `stopUi()` with raw mode still enabled) and `src/core/tool-launchers.js` `spawnCommand` defensively resets `stdin.setRawMode(false)` before handing TTY to child.
10
+ - Web: Added `jcode` to `INSTALL_TARGET_MODES` in `src/core/endpoint-installer.js` (now 15 targets, `jcode` after `pi` to match `TOOL_MODE_ORDER` order) with env-based installer (`~/.fcm-jcode-env`), mirrored in `web/src/utils/m3.js` `INSTALL_ENDPOINT_TOOL_MODES`, and fixed `web/src/hooks/useToolMode.js` cycle bug (`TOOL_MODE_ORDER` -> `INSTALL_ENDPOINT_TOOL_MODES`). Web `/api/tool-mode` now accepts `jcode` without 422 and dropdown/cycle shows it. Thanks @plahakps.
11
+
12
+ ### Changed
13
+ - `src/core/schema-normalizer.js` JSDoc now exports `normalizeNvidia` and clarifies per-provider normalization.
14
+ - `web/src/utils/m3.js` and `src/core/endpoint-installer.js` tool order now matches CLI's `TOOL_MODE_ORDER` (opencode -> pi -> jcode -> opencode-desktop ...).
15
+
16
+ ### Tests
17
+ - 813/813 passing. Updated `test/test.js` endpoint install target expectation to include `jcode`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.5.75",
3
+ "version": "0.5.78",
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",
@@ -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(safeFailover.maxRetries, DEFAULT_ROUTER_SETTINGS.failover.maxRetries, { min: 1, max: 10 }),
420
- streamStallTimeoutMs: normalizePositiveInteger(safeFailover.streamStallTimeoutMs, DEFAULT_ROUTER_SETTINGS.failover.streamStallTimeoutMs, { min: 1000, max: 120000 }),
421
- requestTimeoutMs: normalizePositiveInteger(safeFailover.requestTimeoutMs, DEFAULT_ROUTER_SETTINGS.failover.requestTimeoutMs, { min: 1000, max: 300000 }),
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
- ...normalizedIncoming.telemetry,
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 (Object.prototype.hasOwnProperty.call(normalizedIncoming, 'router')) {
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(),
@@ -53,8 +53,8 @@ import { ensureDir, readJson as sharedReadJson } from './shared-helpers.js'
53
53
  // 📖 zai and opencode-zen ARE OpenAI-compatible and CAN be installed into any tool.
54
54
  const DIRECT_INSTALL_UNSUPPORTED_PROVIDERS = new Set(['replicate'])
55
55
  // 📖 Install Endpoints only lists tools whose persisted config shape is actually supported here.
56
- // 📖 Launch-only tools stay out: the Web dashboard configures endpoints, it never starts CLIs.
57
- const INSTALL_TARGET_MODES = ['opencode', 'opencode-desktop', 'opencode-web', 'openclaw', 'crush', 'goose', 'pi', 'aider', 'qwen', 'openhands', 'amp', 'forgecode', 'fcm_router', 'zcode']
56
+ // 📖 jcode is CLI-only and uses an env-file helper (same as openhands) selectable in both CLI and Web.
57
+ const INSTALL_TARGET_MODES = ['opencode', 'pi', 'jcode', 'opencode-desktop', 'opencode-web', 'openclaw', 'crush', 'goose', 'aider', 'qwen', 'openhands', 'amp', 'forgecode', 'fcm_router', 'zcode']
58
58
 
59
59
  function getDefaultPaths() {
60
60
  const home = homedir()
@@ -839,6 +839,8 @@ export function installProviderEndpoints(config, providerKey, toolMode, options
839
839
  installResult = installIntoAmp(providerKey, models, apiKey, paths)
840
840
  } else if (canonicalToolMode === 'qwen') {
841
841
  installResult = installIntoQwen(providerKey, models, apiKey, paths)
842
+ } else if (canonicalToolMode === 'jcode') {
843
+ installResult = installIntoEnvBasedTool(providerKey, models, apiKey, canonicalToolMode, paths)
842
844
  } else if (canonicalToolMode === 'openhands') {
843
845
  installResult = installIntoEnvBasedTool(providerKey, models, apiKey, canonicalToolMode, paths)
844
846
  } else if (canonicalToolMode === 'fcm_router') {
@@ -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 = Math.max(1, maxRetries)
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
- const errorPayload = JSON.stringify({
2447
- error: {
2448
- message: `Stream truncated by router due to upstream ${reason}; failing over to next model.`,
2449
- type: 'stream_error',
2450
- code: 'fcm_stream_failover',
2451
- reason,
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: ${errorPayload}\n\n`)
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(ROUTER_PID_PATH) } catch {}
3305
- try { unlinkSync(ROUTER_PORT_PATH) } catch {}
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 pid = readNumberFile(ROUTER_PID_PATH)
3850
- if (!pid) return { ok: false, stopped: false, error: 'No daemon PID file found' }
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(ROUTER_PID_PATH) } catch {}
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)) return { ok: true, stopped: true, 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(time, 10)} ` +
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 - 68)).trimEnd()}`
1080
+ `${compactText(detail, Math.max(10, width - 74)).trimEnd()}`
1061
1081
  )
1062
1082
  }
1063
1083
  } else {
@@ -30,8 +30,9 @@
30
30
  * → `clampTemperature` — clamps `temperature` to the provider's accepted range
31
31
  * → `normalizeZai` — for `zai` (GLM) provider
32
32
  * → `normalizeMistral` — for `mistral` and `codestral` providers
33
+ * → `normalizeNvidia` — for `nvidia` provider (strips unsupported params like prompt_cache_key)
33
34
  *
34
- * @exports normalizeRequestBody, normalizeZai, normalizeMistral, PROVIDER_NORMALIZERS
35
+ * @exports normalizeRequestBody, normalizeZai, normalizeMistral, normalizeNvidia, PROVIDER_NORMALIZERS
35
36
  *
36
37
  * @see src/core/router-daemon.js — calls `normalizeRequestBody` before forwarding upstream
37
38
  */
@@ -48,6 +49,7 @@ const STRIP_PARAMS = [
48
49
  'echo', // not in OpenAI spec
49
50
  'user', // PII risk; not used by FCM
50
51
  'metadata', // not always supported
52
+ 'prompt_cache_key', // OpenAI prompt-caching hint; NVIDIA rejects with 400
51
53
  'store', // GLM rejects
52
54
  ]
53
55
 
@@ -146,6 +148,20 @@ export function normalizeMistral(body) {
146
148
  return result
147
149
  }
148
150
 
151
+ /**
152
+ * 📖 `normalizeNvidia` — transforms a request body for the `nvidia` provider.
153
+ *
154
+ * 1. Strips parameters NVIDIA rejects with 400 (e.g. prompt_cache_key)
155
+ * 2. Removes orphan `tool` messages that lack a matching assistant tool_call
156
+ * 3. Strips `stream_options` when not streaming
157
+ */
158
+ export function normalizeNvidia(body) {
159
+ if (!body || typeof body !== 'object') return body
160
+ let result = stripUnsupportedParams(body)
161
+ result = dropOrphanToolMessages(result)
162
+ return result
163
+ }
164
+
149
165
  // 📖 Map of provider key → normalizer function. Providers that don't need
150
166
  // 📖 any tweak (most OpenAI-compat ones like Groq, Cerebras, etc.) are not
151
167
  // 📖 listed and pass through `normalizeRequestBody` unchanged.
@@ -153,6 +169,7 @@ export const PROVIDER_NORMALIZERS = {
153
169
  zai: normalizeZai,
154
170
  mistral: normalizeMistral,
155
171
  codestral: normalizeMistral,
172
+ nvidia: normalizeNvidia,
156
173
  }
157
174
 
158
175
  /**
@@ -209,6 +209,12 @@ const JCODE_NATIVE_PROVIDERS = {
209
209
  }
210
210
 
211
211
  function spawnCommand(command, args, env) {
212
+ // 📖 Defensive: ensure stdin is back in cooked mode before handing the TTY to
213
+ // 📖 an interactive child (jcode REPL etc). The TUI leaves raw mode enabled;
214
+ // 📖 if the caller forgot to reset it the child will appear to hang.
215
+ try {
216
+ if (process.stdin.isTTY && process.stdin.isRaw) process.stdin.setRawMode(false)
217
+ } catch {}
212
218
  return new Promise((resolve, reject) => {
213
219
  const child = spawn(command, args, {
214
220
  stdio: 'inherit',
@@ -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')
@@ -278,9 +278,7 @@ export function createKeyHandler(ctx) {
278
278
  userSelected = { modelId: selected.modelId, label: selected.label, tier: selected.tier, providerKey: selected.providerKey, ctx: selected.ctx }
279
279
 
280
280
  if (!uiAlreadyStopped) {
281
- readline.emitKeypressEvents(process.stdin)
282
- if (process.stdin.isTTY) process.stdin.setRawMode(true)
283
- stopUi()
281
+ stopUi({ resetRawMode: true })
284
282
  }
285
283
 
286
284
  // 📖 If router is enabled, push [selected, ...favorites] to daemon as the active set
@@ -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(' • ☕ ') +
@@ -87,6 +87,16 @@ 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
+ // 📖 Respect config.settings.pingInterval at startup (issue #155).
93
+ // 📖 CLI override is already merged into config.settings.pingInterval in app.js.
94
+ const configuredInterval = config.settings?.pingInterval
95
+ const hasConfiguredInterval =
96
+ typeof configuredInterval === 'number' && Number.isFinite(configuredInterval) && configuredInterval > 0
97
+ const initialInterval = hasConfiguredInterval ? configuredInterval : PING_MODE_INTERVALS.speed
98
+ const initialMode = intervalToPingMode(initialInterval)
99
+
90
100
  return {
91
101
  // 📖 Core data: model results (mutated in-place by ping loop)
92
102
  results,
@@ -104,10 +114,14 @@ export function createTuiState({
104
114
  sortDirection: (config.settings?.sortAsc ?? true) ? 'asc' : 'desc',
105
115
 
106
116
  // 📖 Ping cadence — drives the interval between background ping cycles
107
- pingInterval: PING_MODE_INTERVALS.speed,
108
- pingMode: 'speed',
109
- pingModeSource: 'startup',
110
- speedModeUntil: now + SPEED_MODE_DURATION_MS,
117
+ pingInterval: initialInterval,
118
+ pingMode: initialMode,
119
+ pingModeSource: hasConfiguredInterval ? 'config' : 'startup',
120
+ pingModeIntervals: {
121
+ ...PING_MODE_INTERVALS,
122
+ normal: normalInterval,
123
+ },
124
+ speedModeUntil: initialMode === 'speed' ? now + SPEED_MODE_DURATION_MS : null,
111
125
  lastPingTime: now,
112
126
  lastUserActivityAt: now,
113
127
  resumeSpeedOnActivity: false,