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 +8 -3
- package/changelog/v0.5.76.md +16 -0
- package/changelog/v0.5.77.md +11 -0
- package/changelog/v0.5.78.md +17 -0
- package/package.json +1 -1
- package/src/core/config.js +71 -10
- package/src/core/endpoint-installer.js +4 -2
- package/src/core/router-daemon.js +68 -16
- package/src/core/router-dashboard.js +23 -3
- package/src/core/schema-normalizer.js +18 -1
- package/src/core/tool-launchers.js +6 -0
- package/src/tui/command-palette.js +19 -2
- package/src/tui/key-handler.js +1 -3
- package/src/tui/overlays.js +2 -0
- package/src/tui/tui-state.js +18 -4
- package/web/dist/assets/{index-wI9xrm0w.css → index-C5-Ywvv3.css} +1 -1
- package/web/dist/assets/{index-C98mFTET.js → index-DV-1tGVj.js} +3 -3
- 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/web/src/hooks/useToolMode.js +1 -1
- package/web/src/utils/m3.js +2 -1
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="
|
|
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="
|
|
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="
|
|
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.
|
|
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",
|
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(),
|
|
@@ -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
|
-
// 📖
|
|
57
|
-
const INSTALL_TARGET_MODES = ['opencode', 'opencode-desktop', 'opencode-web', 'openclaw', 'crush', 'goose', '
|
|
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 =
|
|
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 {
|
|
@@ -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')
|
package/src/tui/key-handler.js
CHANGED
|
@@ -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
|
-
|
|
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
|
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,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:
|
|
108
|
-
pingMode:
|
|
109
|
-
pingModeSource: 'startup',
|
|
110
|
-
|
|
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,
|