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.
@@ -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.27",
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",
@@ -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
- const json = JSON.stringify(persistedConfig, null, 2)
688
- writeFileSync(tempPath, json, { mode: 0o600 })
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: '🔄', description: 'Reset filters and sort', keywords: ['reset', 'view', 'sort', 'filters'] },
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
@@ -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
- // 📖 Also handles the raw \x15 byte as a fallback for terminals where readline doesn't
1516
- // 📖 set key.ctrl properly (same pattern as Ctrl+C → \x03 fallback).
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 cleanupLegacyProxyRowIdx = startupAiSpeedScanRowIdx + 1
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
@@ -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 cleanupLegacyProxyRowIdx = startupAiSpeedScanRowIdx + 1
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 🔟)')}`)
@@ -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
- // 📖 Line 3: Speed Test (Ctrl+A) + Global Benchmark (Ctrl+U) + Last release
1161
- if (releaseLabel || speedTestLabel || globalBenchmarkLabel) {
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
  ]
@@ -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)