free-coding-models 0.5.27 → 0.5.28
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/changelog/v0.5.27.md +2 -0
- package/changelog/v0.5.28.md +5 -0
- package/package.json +1 -1
- package/src/core/config.js +24 -4
- package/src/core/router-daemon.js +84 -2
- package/src/tui/app.js +16 -0
- package/src/tui/command-palette.js +2 -1
- package/src/tui/key-handler.js +152 -3
- package/src/tui/overlays.js +13 -1
- package/src/tui/render-table.js +18 -2
- package/src/tui/tui-state.js +6 -0
- package/web/dist/assets/index-BRFowJv5.css +1 -0
- package/web/dist/assets/index-Pr3waI0-.js +41 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +5 -0
- package/web/src/components/atoms/HealthCell.jsx +12 -7
- package/web/src/components/atoms/HealthCell.module.css +4 -0
- package/web/src/components/atoms/StatusDot.jsx +5 -2
- package/web/src/components/atoms/StatusDot.module.css +12 -7
- package/web/src/components/dashboard/ModelTable.jsx +5 -2
- package/web/src/components/dashboard/ModelTable.module.css +9 -0
- package/web/dist/assets/index-JUPDB-J-.js +0 -41
- package/web/dist/assets/index-avN2GM3H.css +0 -1
package/changelog/v0.5.27.md
CHANGED
|
@@ -13,3 +13,5 @@
|
|
|
13
13
|
|
|
14
14
|
### Fixed
|
|
15
15
|
- Fixed first-run usability for keyless providers by allowing Kilo and LLM7 to count as usable providers without forcing the API key wizard.
|
|
16
|
+
- Fixed Docker dashboard 404s by serving the Web Router Dashboard aliases (`/api/router/status`, `/api/router/stats`, `/api/router/sets`, `/api/router/tokens`, `/api/router/quick-setup`) and `/api/changelog` directly from the router daemon.
|
|
17
|
+
- Fixed confusing "pending" status for models not in the active router set. Models outside the set now show a distinct "NOT IN SET" label with a dim dot and faded row, instead of the animated yellow "wait" indicator. Applies to Web Dashboard (both Docker daemon and local dev mode).
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Changelog v0.5.28 - 2026-06-11
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
- Fixed Docker dashboard 404s — the router daemon now serves 10 web dashboard API aliases (`/api/router/status`, `/api/router/stats`, `/api/router/sets`, `/api/router/tokens`, `/api/router/quick-setup`, `/api/router/start`, `/api/router/stop`, `/api/router/probe-mode`, `/api/router/sets/:name/*`, `/api/changelog`) directly, so the React frontend works identically in Docker mode and local dev. Cross-origin guards protect mutating endpoints. (Fixes #116, reported by @stgreenb)
|
|
5
|
+
- Fixed confusing "pending" status for models not in the active router set — models outside the set now show a distinct "NOT IN SET" label with a dim gray dot and faded row opacity, instead of the animated yellow "wait" indicator. The `inRouterSet` field is now exposed by both the Docker daemon and the local web server. (Fixes #117, reported by @stgreenb)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "free-coding-models",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.28",
|
|
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
|
@@ -210,6 +210,18 @@ function normalizeFavoriteList(favorites) {
|
|
|
210
210
|
return normalized
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
// 📖 normalizeStringSet: Convert an array (from JSON) or existing Set into a Set<string>.
|
|
214
|
+
// 📖 Used for hiddenModels and similar set-shaped config fields.
|
|
215
|
+
function normalizeStringSet(value) {
|
|
216
|
+
if (value instanceof Set) return value
|
|
217
|
+
if (!Array.isArray(value)) return new Set()
|
|
218
|
+
const result = new Set()
|
|
219
|
+
for (const entry of value) {
|
|
220
|
+
if (typeof entry === 'string' && entry.trim()) result.add(entry.trim())
|
|
221
|
+
}
|
|
222
|
+
return result
|
|
223
|
+
}
|
|
224
|
+
|
|
213
225
|
function normalizeApiKeyValue(value) {
|
|
214
226
|
if (Array.isArray(value)) {
|
|
215
227
|
const normalized = []
|
|
@@ -263,6 +275,7 @@ function normalizeSettingsSection(settings) {
|
|
|
263
275
|
hideUnconfiguredModels: typeof safeSettings.hideUnconfiguredModels === 'boolean' ? safeSettings.hideUnconfiguredModels : true,
|
|
264
276
|
favoritesPinnedAndSticky: typeof safeSettings.favoritesPinnedAndSticky === 'boolean' ? safeSettings.favoritesPinnedAndSticky : false,
|
|
265
277
|
runAiSpeedTestOnStartup: typeof safeSettings.runAiSpeedTestOnStartup === 'boolean' ? safeSettings.runAiSpeedTestOnStartup : false,
|
|
278
|
+
autoHideBrokenModels: typeof safeSettings.autoHideBrokenModels === 'boolean' ? safeSettings.autoHideBrokenModels : true,
|
|
266
279
|
theme: ['dark', 'light', 'auto'].includes(safeSettings.theme) ? safeSettings.theme : 'auto',
|
|
267
280
|
}
|
|
268
281
|
}
|
|
@@ -471,8 +484,9 @@ function normalizeConfigShape(config) {
|
|
|
471
484
|
favorites: normalizeFavoriteList(safeConfig.favorites),
|
|
472
485
|
telemetry: normalizeTelemetrySection(safeConfig.telemetry),
|
|
473
486
|
endpointInstalls: normalizeEndpointInstalls(safeConfig.endpointInstalls),
|
|
474
|
-
|
|
475
|
-
|
|
487
|
+
// 📖 hiddenModels: Set of "provider/modelId" keys auto-hidden by the 404 probe (Ctrl+Shift+P).
|
|
488
|
+
// 📖 Only populated when settings.autoHideBrokenModels is true (default).
|
|
489
|
+
hiddenModels: normalizeStringSet(safeConfig.hiddenModels),
|
|
476
490
|
}
|
|
477
491
|
const normalizedRouter = normalizeRouterConfig(safeConfig.router)
|
|
478
492
|
if (normalizedRouter) normalized.router = normalizedRouter
|
|
@@ -684,8 +698,12 @@ export function saveConfig(config, options = {}) {
|
|
|
684
698
|
|
|
685
699
|
try {
|
|
686
700
|
const persistedConfig = buildPersistedConfig(config, readStoredConfigSnapshot(), options)
|
|
687
|
-
|
|
688
|
-
|
|
701
|
+
// 📖 Serialize Sets to arrays for JSON compatibility (e.g. hiddenModels)
|
|
702
|
+
const jsonSafe = JSON.stringify(persistedConfig, (key, value) => {
|
|
703
|
+
if (value instanceof Set) return [...value]
|
|
704
|
+
return value
|
|
705
|
+
}, 2)
|
|
706
|
+
writeFileSync(tempPath, jsonSafe, { mode: 0o600 })
|
|
689
707
|
renameSync(tempPath, CONFIG_PATH)
|
|
690
708
|
|
|
691
709
|
// 📖 Verify the write succeeded by reading back and validating
|
|
@@ -1082,6 +1100,7 @@ export function _emptyProfileSettings() {
|
|
|
1082
1100
|
hideUnconfiguredModels: true, // 📖 true = default to providers that are actually configured
|
|
1083
1101
|
favoritesPinnedAndSticky: false, // 📖 default mode keeps favorites as normal starred rows; press Y to pin+stick them.
|
|
1084
1102
|
runAiSpeedTestOnStartup: false, // 📖 opt-in: automatically fire the Ctrl+U global AI Speed Test after startup.
|
|
1103
|
+
autoHideBrokenModels: true, // 📖 opt-out: auto-hide models that return 404/410 from probe (Ctrl+Shift+P).
|
|
1085
1104
|
preferredToolMode: 'opencode', // 📖 remember the last Z-selected launcher across app restarts
|
|
1086
1105
|
theme: 'auto', // 📖 'auto' follows the terminal/OS theme, override with 'dark' or 'light' if needed
|
|
1087
1106
|
}
|
|
@@ -1131,5 +1150,6 @@ function _emptyConfig() {
|
|
|
1131
1150
|
telemetry: { enabled: null, consentVersion: 0, anonymousId: null },
|
|
1132
1151
|
endpointInstalls: [],
|
|
1133
1152
|
settings: _emptyProfileSettings(),
|
|
1153
|
+
hiddenModels: new Set(),
|
|
1134
1154
|
}
|
|
1135
1155
|
}
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
} from './config.js'
|
|
50
50
|
import { buildChatCompletionPingBody, ping, resolveCloudflareUrl, shouldUseDisabledThinkingForProvider } from './ping.js'
|
|
51
51
|
import { benchmarkModel, BENCHMARK_TIMEOUT_MS } from './benchmark.js'
|
|
52
|
+
import { loadChangelog } from './changelog-loader.js'
|
|
52
53
|
import { sendUsageTelemetry } from './telemetry.js'
|
|
53
54
|
|
|
54
55
|
export const ROUTER_DEFAULT_PORT = 19280
|
|
@@ -2397,8 +2398,8 @@ class RouterRuntime {
|
|
|
2397
2398
|
* 📖 with only the ones that come back 2xx. Returns the new set + a
|
|
2398
2399
|
* 📖 sample of probe results so the UI can show "what changed".
|
|
2399
2400
|
*/
|
|
2400
|
-
async handleSyncSetRequest(req, res, requestId) {
|
|
2401
|
-
const url = req.url ? new URL(req.url, 'http://localhost') : null
|
|
2401
|
+
async handleSyncSetRequest(req, res, requestId, routeUrl = null) {
|
|
2402
|
+
const url = routeUrl || (req.url ? new URL(req.url, 'http://localhost') : null)
|
|
2402
2403
|
const pathname = url ? url.pathname : ''
|
|
2403
2404
|
const setSyncMatch = pathname.match(/^\/sets\/([^/]+)\/sync$/)
|
|
2404
2405
|
if (!setSyncMatch) {
|
|
@@ -2524,6 +2525,87 @@ class RouterRuntime {
|
|
|
2524
2525
|
await this.handleProbeModeRequest(req, res, requestId)
|
|
2525
2526
|
return
|
|
2526
2527
|
}
|
|
2528
|
+
|
|
2529
|
+
// 📖 Docker mode serves the built Web Dashboard directly from the daemon
|
|
2530
|
+
// 📖 on :19280. The React app uses the same `/api/router/*` routes as
|
|
2531
|
+
// 📖 local dev (`web/server.js`), so the daemon must expose aliases for
|
|
2532
|
+
// 📖 its canonical `/health`, `/stats`, and `/sets` APIs instead of
|
|
2533
|
+
// 📖 forcing the frontend to special-case Docker.
|
|
2534
|
+
if (req.method === 'GET' && url.pathname === '/api/router/status') {
|
|
2535
|
+
sendJson(res, 200, this.statusPayload(), { 'x-request-id': requestId })
|
|
2536
|
+
return
|
|
2537
|
+
}
|
|
2538
|
+
if (req.method === 'GET' && url.pathname === '/api/router/stats') {
|
|
2539
|
+
sendJson(res, 200, this.statsPayload(), { 'x-request-id': requestId })
|
|
2540
|
+
return
|
|
2541
|
+
}
|
|
2542
|
+
if (req.method === 'GET' && url.pathname === '/api/router/tokens') {
|
|
2543
|
+
sendJson(res, 200, this.tokenTracker.summary(), { 'x-request-id': requestId })
|
|
2544
|
+
return
|
|
2545
|
+
}
|
|
2546
|
+
if (req.method === 'GET' && url.pathname === '/api/router/quick-setup') {
|
|
2547
|
+
const router = this.routerConfig()
|
|
2548
|
+
sendJson(res, 200, {
|
|
2549
|
+
running: true,
|
|
2550
|
+
port: this.port,
|
|
2551
|
+
baseUrl: `http://127.0.0.1:${this.port}/v1`,
|
|
2552
|
+
model: 'fcm',
|
|
2553
|
+
activeSet: router.activeSet || DEFAULT_ROUTER_SETTINGS.activeSet,
|
|
2554
|
+
apiKey: 'not-needed',
|
|
2555
|
+
}, { 'x-request-id': requestId })
|
|
2556
|
+
return
|
|
2557
|
+
}
|
|
2558
|
+
if (url.pathname === '/api/router/start') {
|
|
2559
|
+
if (req.method !== 'POST') {
|
|
2560
|
+
sendError(res, 405, 'Method not allowed', 'invalid_request_error', 'method_not_allowed', requestId, { allowed: ['POST'] })
|
|
2561
|
+
return
|
|
2562
|
+
}
|
|
2563
|
+
if (!isSameOriginOrLocal(req)) {
|
|
2564
|
+
sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
|
|
2565
|
+
return
|
|
2566
|
+
}
|
|
2567
|
+
sendJson(res, 200, { ...this.statusPayload(), alreadyRunning: true }, { 'x-request-id': requestId })
|
|
2568
|
+
return
|
|
2569
|
+
}
|
|
2570
|
+
if (url.pathname === '/api/router/stop') {
|
|
2571
|
+
if (req.method !== 'POST') {
|
|
2572
|
+
sendError(res, 405, 'Method not allowed', 'invalid_request_error', 'method_not_allowed', requestId, { allowed: ['POST'] })
|
|
2573
|
+
return
|
|
2574
|
+
}
|
|
2575
|
+
if (!isSameOriginOrLocal(req)) {
|
|
2576
|
+
sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
|
|
2577
|
+
return
|
|
2578
|
+
}
|
|
2579
|
+
sendJson(res, 200, { ok: true, stopped: true, message: 'Daemon shutting down' }, { 'x-request-id': requestId })
|
|
2580
|
+
setTimeout(() => this.shutdown(0), 50)
|
|
2581
|
+
return
|
|
2582
|
+
}
|
|
2583
|
+
if (url.pathname === '/api/router/probe-mode' && req.method === 'POST') {
|
|
2584
|
+
if (!isSameOriginOrLocal(req)) {
|
|
2585
|
+
sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
|
|
2586
|
+
return
|
|
2587
|
+
}
|
|
2588
|
+
await this.handleProbeModeRequest(req, res, requestId)
|
|
2589
|
+
return
|
|
2590
|
+
}
|
|
2591
|
+
if (req.method === 'GET' && url.pathname === '/api/changelog') {
|
|
2592
|
+
sendJson(res, 200, loadChangelog(), { 'x-request-id': requestId })
|
|
2593
|
+
return
|
|
2594
|
+
}
|
|
2595
|
+
if (url.pathname === '/api/router/sets' || url.pathname.startsWith('/api/router/sets/')) {
|
|
2596
|
+
if (req.method !== 'GET' && !isSameOriginOrLocal(req)) {
|
|
2597
|
+
sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
|
|
2598
|
+
return
|
|
2599
|
+
}
|
|
2600
|
+
const aliasedUrl = new URL(req.url, `http://localhost:${this.port}`)
|
|
2601
|
+
aliasedUrl.pathname = aliasedUrl.pathname.replace(/^\/api\/router/, '')
|
|
2602
|
+
if (/^\/sets\/[^/]+\/sync$/.test(aliasedUrl.pathname) && req.method === 'POST') {
|
|
2603
|
+
await this.handleSyncSetRequest(req, res, requestId, aliasedUrl)
|
|
2604
|
+
return
|
|
2605
|
+
}
|
|
2606
|
+
await this.handleSetsRequest(req, res, aliasedUrl, requestId)
|
|
2607
|
+
return
|
|
2608
|
+
}
|
|
2527
2609
|
if (url.pathname === '/sets' || url.pathname.startsWith('/sets/')) {
|
|
2528
2610
|
// 📖 /sets/:name/sync has a different return type (rebuilds the
|
|
2529
2611
|
// 📖 set from probes) so it gets its own handler.
|
package/src/tui/app.js
CHANGED
|
@@ -315,6 +315,18 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
|
|
|
315
315
|
isPinging: false, // 📖 Per-row live flag so Last Ping can keep last value and show a spinner during refresh.
|
|
316
316
|
hidden: false, // 📖 Simple flag to hide/show models
|
|
317
317
|
}))
|
|
318
|
+
|
|
319
|
+
// 📖 Auto-hide models that were previously marked broken by the 404 probe.
|
|
320
|
+
// 📖 Only applies when autoHideBrokenModels setting is enabled (default).
|
|
321
|
+
if (config.settings?.autoHideBrokenModels !== false && config.hiddenModels instanceof Set) {
|
|
322
|
+
for (const r of results) {
|
|
323
|
+
const modelKey = `${r.providerKey}/${r.modelId}`
|
|
324
|
+
if (config.hiddenModels.has(modelKey)) {
|
|
325
|
+
r.hidden = true
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
318
330
|
syncFavoriteFlags(results, config)
|
|
319
331
|
// 📖 Garbage-collect favorites that reference models no longer in sources.js,
|
|
320
332
|
// 📖 so the router dashboard only shows real, launchable models.
|
|
@@ -826,6 +838,10 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
|
|
|
826
838
|
benchmarkResults: state.benchmarkResults,
|
|
827
839
|
benchmarkRunning: state.benchmarkRunning,
|
|
828
840
|
headerFlashColumn: state.headerFlashColumn,
|
|
841
|
+
probeRunning: state.probeRunning,
|
|
842
|
+
probeTotal: state.probeTotal,
|
|
843
|
+
probeCompleted: state.probeCompleted,
|
|
844
|
+
probeHiddenCount: state.probeHiddenCount,
|
|
829
845
|
}
|
|
830
846
|
if (state.commandPaletteOpen) {
|
|
831
847
|
if (!state.commandPaletteFrozenTable) {
|
|
@@ -200,7 +200,8 @@ const BASE_COMMAND_TREE = [
|
|
|
200
200
|
],
|
|
201
201
|
},
|
|
202
202
|
{ id: 'action-cycle-theme', label: 'Cycle theme', shortcut: 'G', icon: '🌗', description: 'Switch dark/light/auto', keywords: ['theme', 'dark', 'light', 'auto'] },
|
|
203
|
-
{ id: 'action-reset-view', label: 'Reset view', shortcut: 'N', icon: '
|
|
203
|
+
{ id: 'action-reset-view', label: 'Reset view', shortcut: 'N', icon: '\u{1F504}', description: 'Reset filters and sort', keywords: ['reset', 'view', 'sort', 'filters'] },
|
|
204
|
+
{ id: 'action-probe-404', label: 'Probe 404 Models', shortcut: 'Ctrl+Shift+P', icon: '\u{1F50D}', description: 'Test all configured models for 404/410 errors. Auto-hides broken models.', keywords: ['probe', '404', 'broken', 'dead', 'health', 'check', 'test', 'verify'] },
|
|
204
205
|
],
|
|
205
206
|
},
|
|
206
207
|
// 📖 Pages - directly at root level, not in submenu
|
package/src/tui/key-handler.js
CHANGED
|
@@ -792,6 +792,42 @@ export function createKeyHandler(ctx) {
|
|
|
792
792
|
})
|
|
793
793
|
}
|
|
794
794
|
|
|
795
|
+
// 📖 toggleAutoHideBrokenModels: Toggle auto-hiding of 404/410 models from probe.
|
|
796
|
+
// 📖 When disabling, unhide all previously hidden models so they become visible again.
|
|
797
|
+
function toggleAutoHideBrokenModels() {
|
|
798
|
+
if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
|
|
799
|
+
const wasEnabled = state.config.settings.autoHideBrokenModels !== false
|
|
800
|
+
state.config.settings.autoHideBrokenModels = !wasEnabled
|
|
801
|
+
|
|
802
|
+
if (!wasEnabled) {
|
|
803
|
+
// 📖 Just enabled — models that were hidden before enabling will be picked up on next probe
|
|
804
|
+
} else {
|
|
805
|
+
// 📖 Just disabled — unhide all probe-hidden models so user can see them again
|
|
806
|
+
if (state.config.hiddenModels instanceof Set && state.config.hiddenModels.size > 0) {
|
|
807
|
+
const keysToUnhide = [...state.config.hiddenModels]
|
|
808
|
+
state.config.hiddenModels.clear()
|
|
809
|
+
for (const key of keysToUnhide) {
|
|
810
|
+
const [pk, ...rest] = key.split('/')
|
|
811
|
+
const modelId = rest.join('/')
|
|
812
|
+
const result = state.results.find(r => r.providerKey === pk && r.modelId === modelId)
|
|
813
|
+
if (result) result.hidden = false
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
saveConfig(state.config)
|
|
819
|
+
const hiddenCount = state.config.hiddenModels instanceof Set ? state.config.hiddenModels.size : 0
|
|
820
|
+
state.settingsSyncStatus = {
|
|
821
|
+
type: 'success',
|
|
822
|
+
msg: state.config.settings.autoHideBrokenModels
|
|
823
|
+
? `✅ Auto-hide enabled — broken models (404/410) from Ctrl+Shift+P probe are hidden automatically.${hiddenCount ? ` (${hiddenCount} currently hidden)` : ''}`
|
|
824
|
+
: '✅ Auto-hide disabled — all models are visible. Previously hidden models have been unhidden.',
|
|
825
|
+
}
|
|
826
|
+
trackAppAction('auto_hide_broken_models_toggled', {
|
|
827
|
+
enabled: state.config.settings.autoHideBrokenModels !== false,
|
|
828
|
+
})
|
|
829
|
+
}
|
|
830
|
+
|
|
795
831
|
function toggleShellEnv() {
|
|
796
832
|
if (!state.config.settings) state.config.settings = {}
|
|
797
833
|
const currentlyEnabled = state.config.settings.shellEnvEnabled === true
|
|
@@ -1132,6 +1168,96 @@ export function createKeyHandler(ctx) {
|
|
|
1132
1168
|
return Promise.all(workers).then(() => results)
|
|
1133
1169
|
}
|
|
1134
1170
|
|
|
1171
|
+
// 📖 runBrokenModelProbe: Probe all configured models for 404/broken endpoints.
|
|
1172
|
+
// 📖 Sends a real chat-completion request to each model with an API key.
|
|
1173
|
+
// 📖 Models returning 404 or 410 are auto-hidden when setting is enabled.
|
|
1174
|
+
// 📖 Results flash live in the TUI table — models flip status in real time.
|
|
1175
|
+
async function runBrokenModelProbe(state) {
|
|
1176
|
+
if (state.probeRunning) return
|
|
1177
|
+
|
|
1178
|
+
// 📖 Only probe models where the user has an API key configured
|
|
1179
|
+
const probeable = state.results.filter(r => {
|
|
1180
|
+
const apiKey = getApiKey(state.config, r.providerKey)
|
|
1181
|
+
return !!apiKey
|
|
1182
|
+
})
|
|
1183
|
+
|
|
1184
|
+
if (probeable.length === 0) return
|
|
1185
|
+
|
|
1186
|
+
state.probeRunning = true
|
|
1187
|
+
state.probeTotal = probeable.length
|
|
1188
|
+
state.probeCompleted = 0
|
|
1189
|
+
state.probeHiddenCount = 0
|
|
1190
|
+
|
|
1191
|
+
const autoHide = state.config.settings?.autoHideBrokenModels !== false
|
|
1192
|
+
const HTTP_CODES_404 = new Set([404, '404'])
|
|
1193
|
+
const HTTP_CODES_GONE = new Set([410, '410'])
|
|
1194
|
+
|
|
1195
|
+
const tasks = probeable.map(r => async () => {
|
|
1196
|
+
const apiKey = getApiKey(state.config, r.providerKey) ?? null
|
|
1197
|
+
const providerUrl = sources[r.providerKey]?.url ?? null
|
|
1198
|
+
if (!apiKey || !providerUrl) {
|
|
1199
|
+
state.probeCompleted++
|
|
1200
|
+
return { model: r, ok: false, reason: 'no_key' }
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
try {
|
|
1204
|
+
const { code } = await ping(apiKey, r.modelId, r.providerKey, providerUrl)
|
|
1205
|
+
state.probeCompleted++
|
|
1206
|
+
|
|
1207
|
+
if (HTTP_CODES_404.has(code) || HTTP_CODES_GONE.has(code)) {
|
|
1208
|
+
// 📖 Model is broken (404/410) — mark it
|
|
1209
|
+
r.status = 'down'
|
|
1210
|
+
r.httpCode = String(code)
|
|
1211
|
+
|
|
1212
|
+
if (autoHide) {
|
|
1213
|
+
const modelKey = `${r.providerKey}/${r.modelId}`
|
|
1214
|
+
if (!state.config.hiddenModels) state.config.hiddenModels = new Set()
|
|
1215
|
+
state.config.hiddenModels.add(modelKey)
|
|
1216
|
+
r.hidden = true
|
|
1217
|
+
state.probeHiddenCount++
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
return { model: r, ok: false, code, reason: 'broken' }
|
|
1221
|
+
} else if (code === '200') {
|
|
1222
|
+
// 📖 Model is alive — unhide if it was previously hidden by probe
|
|
1223
|
+
const modelKey = `${r.providerKey}/${r.modelId}`
|
|
1224
|
+
if (state.config.hiddenModels?.has(modelKey)) {
|
|
1225
|
+
state.config.hiddenModels.delete(modelKey)
|
|
1226
|
+
r.hidden = false
|
|
1227
|
+
}
|
|
1228
|
+
r.status = 'up'
|
|
1229
|
+
return { model: r, ok: true, code }
|
|
1230
|
+
} else {
|
|
1231
|
+
// 📖 Other codes (401, 429, etc.) — leave status unchanged, don't hide
|
|
1232
|
+
return { model: r, ok: false, code, reason: 'other' }
|
|
1233
|
+
}
|
|
1234
|
+
} catch (err) {
|
|
1235
|
+
state.probeCompleted++
|
|
1236
|
+
return { model: r, ok: false, reason: 'error', error: err?.message }
|
|
1237
|
+
}
|
|
1238
|
+
})
|
|
1239
|
+
|
|
1240
|
+
await runWithConcurrency(tasks, 5)
|
|
1241
|
+
|
|
1242
|
+
// 📖 Persist hidden models set to config
|
|
1243
|
+
if (autoHide && state.probeHiddenCount > 0) {
|
|
1244
|
+
saveConfig(state.config)
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
state.probeRunning = false
|
|
1248
|
+
// 📖 Keep totals visible for a few seconds so user can see results
|
|
1249
|
+
setTimeout(() => {
|
|
1250
|
+
if (!state.probeRunning) {
|
|
1251
|
+
state.probeTotal = 0
|
|
1252
|
+
state.probeCompleted = 0
|
|
1253
|
+
// 📖 Don't reset probeHiddenCount immediately — user needs to see how many were hidden
|
|
1254
|
+
setTimeout(() => {
|
|
1255
|
+
state.probeHiddenCount = 0
|
|
1256
|
+
}, 5000)
|
|
1257
|
+
}
|
|
1258
|
+
}, 3000)
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1135
1261
|
// 📖 runGlobalBenchmark: Benchmark all visible models with up to 5 concurrent requests.
|
|
1136
1262
|
// 📖 Results are stored in state.benchmarkResults (same format as individual benchmarks).
|
|
1137
1263
|
async function runGlobalBenchmark(state) {
|
|
@@ -1486,6 +1612,7 @@ export function createKeyHandler(ctx) {
|
|
|
1486
1612
|
case 'action-toggle-favorite': return toggleFavoriteOnSelectedRow()
|
|
1487
1613
|
case 'action-toggle-favorite-mode': return toggleFavoritesDisplayMode()
|
|
1488
1614
|
case 'action-reset-view': return resetViewSettings()
|
|
1615
|
+
case 'action-probe-404': return runBrokenModelProbe(state)
|
|
1489
1616
|
default:
|
|
1490
1617
|
return
|
|
1491
1618
|
}
|
|
@@ -1512,13 +1639,22 @@ export function createKeyHandler(ctx) {
|
|
|
1512
1639
|
}
|
|
1513
1640
|
|
|
1514
1641
|
// 📖 Ctrl+U: Global AI Speed Benchmark (benchmark all visible models, 5 concurrent)
|
|
1515
|
-
//
|
|
1516
|
-
//
|
|
1642
|
+
// Also handles the raw \x15 byte as a fallback for terminals where readline doesn't
|
|
1643
|
+
// set key.ctrl properly (same pattern as Ctrl+C → \x03 fallback).
|
|
1517
1644
|
if ((key.ctrl && key.name === 'u') || str === '\x15') {
|
|
1518
1645
|
await runGlobalBenchmark(state)
|
|
1519
1646
|
return
|
|
1520
1647
|
}
|
|
1521
1648
|
|
|
1649
|
+
// 📖 Ctrl+Shift+P: Probe all configured models for 404/broken endpoints.
|
|
1650
|
+
// 📖 Sends a real chat-completion request to every model that has an API key.
|
|
1651
|
+
// 📖 Models returning 404/410 are auto-hidden (when setting is enabled).
|
|
1652
|
+
// 📖 Results flash live in the table — models flip from down→up in real time.
|
|
1653
|
+
if (key.ctrl && key.shift && key.name === 'p') {
|
|
1654
|
+
await runBrokenModelProbe(state)
|
|
1655
|
+
return
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1522
1658
|
// 📖 Command palette captures the keyboard while active.
|
|
1523
1659
|
if (state.commandPaletteOpen) {
|
|
1524
1660
|
if (key.ctrl && key.name === 'c') { exit(0); return }
|
|
@@ -2534,7 +2670,8 @@ export function createKeyHandler(ctx) {
|
|
|
2534
2670
|
const themeRowIdx = updateRowIdx + 1
|
|
2535
2671
|
const favoritesModeRowIdx = themeRowIdx + 1
|
|
2536
2672
|
const startupAiSpeedScanRowIdx = favoritesModeRowIdx + 1
|
|
2537
|
-
const
|
|
2673
|
+
const autoHideBrokenModelsRowIdx = startupAiSpeedScanRowIdx + 1
|
|
2674
|
+
const cleanupLegacyProxyRowIdx = autoHideBrokenModelsRowIdx + 1
|
|
2538
2675
|
const changelogViewRowIdx = cleanupLegacyProxyRowIdx + 1
|
|
2539
2676
|
const shellEnvRowIdx = changelogViewRowIdx + 1
|
|
2540
2677
|
// 📖 Profile system removed - API keys now persist permanently across all sessions
|
|
@@ -2696,6 +2833,12 @@ export function createKeyHandler(ctx) {
|
|
|
2696
2833
|
return
|
|
2697
2834
|
}
|
|
2698
2835
|
|
|
2836
|
+
// 📖 Auto-hide broken models toggle
|
|
2837
|
+
if (state.settingsCursor === autoHideBrokenModelsRowIdx) {
|
|
2838
|
+
toggleAutoHideBrokenModels()
|
|
2839
|
+
return
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2699
2842
|
if (state.settingsCursor === cleanupLegacyProxyRowIdx) {
|
|
2700
2843
|
runLegacyProxyCleanup()
|
|
2701
2844
|
return
|
|
@@ -2753,6 +2896,11 @@ export function createKeyHandler(ctx) {
|
|
|
2753
2896
|
toggleStartupAiSpeedScan()
|
|
2754
2897
|
return
|
|
2755
2898
|
}
|
|
2899
|
+
// 📖 Auto-hide broken models toggle (space)
|
|
2900
|
+
if (state.settingsCursor === autoHideBrokenModelsRowIdx) {
|
|
2901
|
+
toggleAutoHideBrokenModels()
|
|
2902
|
+
return
|
|
2903
|
+
}
|
|
2756
2904
|
// 📖 Profile system removed - API keys now persist permanently across all sessions
|
|
2757
2905
|
|
|
2758
2906
|
// 📖 Toggle enabled/disabled for selected provider
|
|
@@ -2770,6 +2918,7 @@ export function createKeyHandler(ctx) {
|
|
|
2770
2918
|
|| state.settingsCursor === themeRowIdx
|
|
2771
2919
|
|| state.settingsCursor === favoritesModeRowIdx
|
|
2772
2920
|
|| state.settingsCursor === startupAiSpeedScanRowIdx
|
|
2921
|
+
|| state.settingsCursor === autoHideBrokenModelsRowIdx
|
|
2773
2922
|
|| state.settingsCursor === cleanupLegacyProxyRowIdx
|
|
2774
2923
|
|| state.settingsCursor === changelogViewRowIdx
|
|
2775
2924
|
) return
|
package/src/tui/overlays.js
CHANGED
|
@@ -121,7 +121,8 @@ export function createOverlayRenderers(state, deps) {
|
|
|
121
121
|
const themeRowIdx = updateRowIdx + 1
|
|
122
122
|
const favoritesModeRowIdx = themeRowIdx + 1
|
|
123
123
|
const startupAiSpeedScanRowIdx = favoritesModeRowIdx + 1
|
|
124
|
-
const
|
|
124
|
+
const autoHideBrokenModelsRowIdx = startupAiSpeedScanRowIdx + 1
|
|
125
|
+
const cleanupLegacyProxyRowIdx = autoHideBrokenModelsRowIdx + 1
|
|
125
126
|
const changelogViewRowIdx = cleanupLegacyProxyRowIdx + 1
|
|
126
127
|
const shellEnvRowIdx = changelogViewRowIdx + 1
|
|
127
128
|
const EL = '\x1b[K'
|
|
@@ -277,6 +278,16 @@ export function createOverlayRenderers(state, deps) {
|
|
|
277
278
|
cursorLineByRow[startupAiSpeedScanRowIdx] = lines.length
|
|
278
279
|
lines.push(state.settingsCursor === startupAiSpeedScanRowIdx ? themeColors.bgCursorSettingsList(startupAiSpeedScanRow) : startupAiSpeedScanRow)
|
|
279
280
|
|
|
281
|
+
// 📖 Auto-hide broken models row: toggles auto-hiding of 404/410 models from probe.
|
|
282
|
+
const autoHideEnabled = state.config.settings?.autoHideBrokenModels !== false
|
|
283
|
+
const hiddenCount = state.config.hiddenModels instanceof Set ? state.config.hiddenModels.size : 0
|
|
284
|
+
const autoHideStatus = autoHideEnabled
|
|
285
|
+
? themeColors.successBold(`✅ Enabled (${hiddenCount} hidden)`)
|
|
286
|
+
: themeColors.errorBold('❌ Disabled')
|
|
287
|
+
const autoHideRow = `${bullet(state.settingsCursor === autoHideBrokenModelsRowIdx)}${themeColors.textBold('Auto-hide Broken Models').padEnd(44)} ${autoHideStatus}`
|
|
288
|
+
cursorLineByRow[autoHideBrokenModelsRowIdx] = lines.length
|
|
289
|
+
lines.push(state.settingsCursor === autoHideBrokenModelsRowIdx ? themeColors.bgCursorSettingsList(autoHideRow) : autoHideRow)
|
|
290
|
+
|
|
280
291
|
if (updateState === 'error' && state.settingsUpdateError) {
|
|
281
292
|
lines.push(themeColors.error(` ${state.settingsUpdateError}`))
|
|
282
293
|
}
|
|
@@ -950,6 +961,7 @@ export function createOverlayRenderers(state, deps) {
|
|
|
950
961
|
lines.push(` ${key('Ctrl+P')} Open ⚡️ command palette ${hint('(search and run actions quickly)')}`)
|
|
951
962
|
lines.push(` ${key('Ctrl+A')} AI Speed Test ${hint('(benchmark selected model → time + TPS)')}`)
|
|
952
963
|
lines.push(` ${key('Ctrl+U')} Global AI Speed Test ${hint('(benchmark all models; Settings can auto-run it on startup)')}`)
|
|
964
|
+
lines.push(` ${key('Ctrl+Shift+P')} Probe 404 Models ${hint('(test all configured models; auto-hide broken 404/410)')}`)
|
|
953
965
|
lines.push(` ${key('E')} Cycle filter mode ${hint('(Normal → Configured only → Usable only)')}`)
|
|
954
966
|
lines.push(` ${key('Z')} Cycle tool mode ${hint('(📦 OpenCode → π Pi → 🪼 jcode → 📦 Desktop → 🦞 OpenClaw → 💘 Crush → 🪿 Goose → 🛠 Aider → 🐉 Qwen → 🤲 OpenHands → ⚡ Amp)')}`)
|
|
955
967
|
lines.push(` ${key('F')} Toggle favorite on selected row ${hint('(1️⃣2️⃣3️⃣ = router fallback order, capped at 🔟)')}`)
|
package/src/tui/render-table.js
CHANGED
|
@@ -192,6 +192,10 @@ export function renderTable({
|
|
|
192
192
|
benchmarkResults = {},
|
|
193
193
|
benchmarkRunning = new Set(),
|
|
194
194
|
headerFlashColumn = null,
|
|
195
|
+
probeRunning = false,
|
|
196
|
+
probeTotal = 0,
|
|
197
|
+
probeCompleted = 0,
|
|
198
|
+
probeHiddenCount = 0,
|
|
195
199
|
} = _) {
|
|
196
200
|
// 📖 Filter out hidden models for display
|
|
197
201
|
const visibleResults = results.filter(r => !r.hidden)
|
|
@@ -1157,13 +1161,25 @@ export function renderTable({
|
|
|
1157
1161
|
const speedTestLabel = chalk.bgRgb(...currentPalette().badgeSpeedTestBg).rgb(...currentPalette().badgeSpeedTestFg).bold(' NEW ⭐️ Ctrl+A 🤖 AI Speed Test ')
|
|
1158
1162
|
const globalBenchmarkLabel = chalk.bgRgb(...currentPalette().badgeBenchmarkBg).rgb(...currentPalette().badgeBenchmarkFg).bold(' NEW Ctrl+U : Global AI Speed Test (Uses a lot of requests!) ')
|
|
1159
1163
|
|
|
1160
|
-
// 📖
|
|
1161
|
-
|
|
1164
|
+
// 📖 Probe badge: show progress when 404 probe is running or recently completed
|
|
1165
|
+
let probeLabel = ''
|
|
1166
|
+
if (probeRunning) {
|
|
1167
|
+
const pct = probeTotal > 0 ? Math.round((probeCompleted / probeTotal) * 100) : 0
|
|
1168
|
+
const bar = '█'.repeat(Math.floor(pct / 5)) + '░'.repeat(20 - Math.floor(pct / 5))
|
|
1169
|
+
probeLabel = chalk.bgRgb(180, 40, 40).rgb(255, 255, 255).bold(` 🔍 Probe ${bar} ${probeCompleted}/${probeTotal} `)
|
|
1170
|
+
} else if (probeHiddenCount > 0 && probeTotal > 0) {
|
|
1171
|
+
probeLabel = chalk.bgRgb(120, 60, 60).rgb(255, 200, 200).bold(` 🔍 Probe done: ${probeHiddenCount} broken model${probeHiddenCount > 1 ? 's' : ''} hidden `)
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// 📖 Line 3: Speed Test + Global Benchmark + Probe + Last release
|
|
1175
|
+
if (releaseLabel || speedTestLabel || globalBenchmarkLabel || probeLabel) {
|
|
1162
1176
|
const parts = [
|
|
1163
1177
|
{ text: ' ', key: null },
|
|
1164
1178
|
{ text: speedTestLabel, key: 'a' },
|
|
1165
1179
|
{ text: ' ', key: null },
|
|
1166
1180
|
{ text: globalBenchmarkLabel, key: 'u' },
|
|
1181
|
+
{ text: probeLabel ? ' ' : '', key: null },
|
|
1182
|
+
{ text: probeLabel, key: null },
|
|
1167
1183
|
{ text: ' ', key: null },
|
|
1168
1184
|
{ text: releaseLabel, key: null },
|
|
1169
1185
|
]
|
package/src/tui/tui-state.js
CHANGED
|
@@ -280,6 +280,12 @@ export function createTuiState({
|
|
|
280
280
|
globalBenchmarkTotal: 0,
|
|
281
281
|
globalBenchmarkCompleted: 0,
|
|
282
282
|
|
|
283
|
+
// 📖 404 Probe state (Ctrl+Shift+P)
|
|
284
|
+
probeRunning: false,
|
|
285
|
+
probeTotal: 0,
|
|
286
|
+
probeCompleted: 0,
|
|
287
|
+
probeHiddenCount: 0,
|
|
288
|
+
|
|
283
289
|
// 📖 Header click flash animation: briefly highlights the clicked column header
|
|
284
290
|
// 📖 with an inverse/bright style for ~250ms (3 frames at 12 FPS).
|
|
285
291
|
headerFlashColumn: null, // 📖 Column name being flashed (null = no flash active)
|