free-coding-models 0.5.57 → 0.5.59

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/src/tui/app.js CHANGED
@@ -99,7 +99,7 @@ import { buildMergedModels } from '../core/model-merger.js'
99
99
  import { loadOpenCodeConfig, saveOpenCodeConfig } from '../core/opencode-config.js'
100
100
  import { usageForRow as _usageForRow } from '../core/usage-reader.js'
101
101
  import { buildProviderModelTokenKey, loadTokenUsageByProviderModel } from '../core/token-usage-reader.js'
102
- import { parseOpenRouterResponse, fetchProviderQuota as _fetchProviderQuotaFromModule } from '../core/provider-quota-fetchers.js'
102
+ import { parseOpenRouterResponse, fetchProviderQuota as _fetchProviderQuotaFromModule, getAllQuotas as _getAllPassiveQuotasFromModule } from '../core/provider-quota-fetchers.js'
103
103
  import { isKnownQuotaTelemetry } from '../core/quota-capabilities.js'
104
104
  import { ALT_ENTER, ALT_LEAVE, ALT_HOME, PING_TIMEOUT, PING_INTERVAL, FPS, COL_MODEL, COL_MS, CELL_W, FRAMES, TIER_CYCLE, VERDICT_CYCLE, HEALTH_CYCLE, SETTINGS_OVERLAY_BG, HELP_OVERLAY_BG, RECOMMEND_OVERLAY_BG, OVERLAY_PANEL_WIDTH, TABLE_HEADER_LINES, TABLE_FOOTER_LINES, TABLE_FIXED_LINES, WIDTH_WARNING_MIN_COLS, msCell, spinCell } from '../core/constants.js'
105
105
  import { TIER_COLOR } from './tier-colors.js'
@@ -128,6 +128,17 @@ import { startExternalTool } from '../core/tool-launchers.js'
128
128
  import { getToolInstallPlan, installToolWithPlan, isToolInstalled } from '../core/tool-bootstrap.js'
129
129
  import { getConfiguredInstallableProviders, installProviderEndpoints, refreshInstalledEndpoints, getInstallTargetModes, getProviderCatalogModels } from '../core/endpoint-installer.js'
130
130
  import { loadCache, saveCache, clearCache, getCacheAge } from '../core/cache.js'
131
+ import {
132
+ loadCache as loadProbeCache,
133
+ flushCache as flushProbeCache,
134
+ clearCache as clearProbeCache,
135
+ getModelsDueForProbe,
136
+ isCacheFresh,
137
+ recordProbeResults,
138
+ getCacheStats as getProbeCacheStats,
139
+ pruneStaleEntries as pruneProbeCacheStaleEntries,
140
+ DEFAULT_PROBE_TTL_MS,
141
+ } from '../core/probe-cache.js'
131
142
  import { checkConfigSecurity } from '../core/security.js'
132
143
  import { buildCliHelpText } from './cli-help.js'
133
144
  import { detectActiveTheme, THEME_BG_RGB, getTheme, patchThemeBg } from './theme.js'
@@ -424,6 +435,78 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
424
435
  }
425
436
  }
426
437
 
438
+ // 📖 Probe-cache (t1): persistent per-provider health cache with TTL + auto-hide broken.
439
+ // 📖 Lives at ~/.free-coding-models/probe-cache.json, shared with daemon + Tauri surfaces.
440
+ // 📖 Honors CLI flags: --reprobe (force clear), --probe-ttl <ms>, --show-broken (don't hide).
441
+ if (cliArgs.reprobeMode) {
442
+ // 📖 --reprobe / --no-cache: nuke the cache so this run pings everything fresh.
443
+ clearProbeCache()
444
+ }
445
+ const probeCacheTtlMs = cliArgs.probeTtlMs ?? DEFAULT_PROBE_TTL_MS
446
+ state.probeCacheTtlMs = probeCacheTtlMs
447
+ state.showBrokenMode = !!cliArgs.showBrokenMode
448
+ const probeCache = loadProbeCache()
449
+
450
+ // 📖 Prune entries whose modelId is no longer in the catalog (one-time per boot).
451
+ for (const providerKey of new Set(state.results.map(r => r.providerKey))) {
452
+ const liveIds = state.results.filter(r => r.providerKey === providerKey).map(r => r.modelId)
453
+ pruneProbeCacheStaleEntries(providerKey, liveIds)
454
+ }
455
+
456
+ // 📖 Apply probe-cache to results: auto-hide broken, pre-fill fresh ok stats.
457
+ let probeCacheHits = 0
458
+ let probeCacheMisses = 0
459
+ for (const r of state.results) {
460
+ const entry = probeCache?.providers?.[r.providerKey]?.models?.[r.modelId]
461
+ if (!entry) {
462
+ probeCacheMisses++
463
+ continue
464
+ }
465
+ if (entry.status === 'broken') {
466
+ probeCacheHits++
467
+ r.cachedBroken = true
468
+ r.lastProbedAt = entry.lastProbedAt
469
+ r.lastError = entry.lastError ?? null
470
+ // 📖 Auto-hide broken models (unless --show-broken or feature disabled).
471
+ const autoHideEnabled = config.settings?.autoHideBrokenModels !== false
472
+ if (!state.showBrokenMode && autoHideEnabled) {
473
+ r.hidden = true
474
+ r.status = 'down'
475
+ }
476
+ continue
477
+ }
478
+ if (entry.status !== 'ok') {
479
+ probeCacheMisses++
480
+ continue
481
+ }
482
+ if (entry.probeVersion !== 2) {
483
+ // 📖 Stale version — count as miss, force re-probe this run.
484
+ probeCacheMisses++
485
+ continue
486
+ }
487
+ if (Date.now() - entry.lastProbedAt < probeCacheTtlMs) {
488
+ // 📖 Fresh ok entry — pre-fill the row so the TUI has data on frame 1.
489
+ probeCacheHits++
490
+ r.fromProbeCache = true
491
+ r.lastProbedAt = entry.lastProbedAt
492
+ const cachedLatency = typeof entry.latencyMs === 'number' ? entry.latencyMs : 0
493
+ r.avg = cachedLatency
494
+ r.p95 = cachedLatency
495
+ r.jitter = 0
496
+ r.stability = 100
497
+ r.uptime = 100
498
+ r.verdict = 'Cached'
499
+ r.status = 'up'
500
+ r.httpCode = '200'
501
+ r.pings = [{ ms: cachedLatency, code: '200' }]
502
+ } else {
503
+ probeCacheMisses++
504
+ }
505
+ }
506
+ state.probeCacheHits = probeCacheHits
507
+ state.probeCacheMisses = probeCacheMisses
508
+ state.probeCacheBrokenHidden = state.results.filter(r => r.cachedBroken && r.hidden).length
509
+
427
510
  // 📖 Define pingModel before JSON mode so `--json` can reuse the same provider-aware
428
511
  // 📖 ping path as the interactive TUI without waiting for the PTY/render loop setup.
429
512
  pingModel = async (r) => {
@@ -445,6 +528,26 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
445
528
 
446
529
  r.pings.push({ ms, code })
447
530
 
531
+ // 📖 Probe-cache (t1): record the result so future runs can skip healthy models
532
+ // 📖 and auto-hide broken ones. Uses the module-level in-memory cache; flushed
533
+ // 📖 on exit (see exit()) and on graceful shutdown.
534
+ const probeStatus = code === '200' ? 'ok' : 'broken'
535
+ recordProbeResults(r.providerKey, [{
536
+ modelId: r.modelId,
537
+ status: probeStatus,
538
+ latencyMs: typeof ms === 'number' ? ms : null,
539
+ lastError: probeStatus === 'broken' ? String(code) : null,
540
+ }])
541
+ // 📖 If a previously-broken model comes back ok, un-hide it automatically so the
542
+ // 📖 user sees the recovery without a restart. (Skip when --show-broken keeps them visible.)
543
+ if (probeStatus === 'ok' && r.cachedBroken && !state.showBrokenMode) {
544
+ r.cachedBroken = false
545
+ r.hidden = false
546
+ r.lastError = null
547
+ // 📖 Re-evaluate the broken-hidden counter for the footer chip.
548
+ state.probeCacheBrokenHidden = state.results.filter(x => x.cachedBroken && x.hidden).length
549
+ }
550
+
448
551
  if (code === '200') {
449
552
  r.status = 'up'
450
553
  } else if (code === '000') {
@@ -541,6 +644,7 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
541
644
  // 📖 Ensure we always leave alt screen cleanly (Ctrl+C, crash, normal exit)
542
645
  const exit = (code = 0) => {
543
646
  saveCache(state.results, state.pingMode)
647
+ flushProbeCache()
544
648
  clearInterval(ticker)
545
649
  clearTimeout(state.pingIntervalObj)
546
650
  clearInterval(state.versionRecheckTimer)
@@ -845,6 +949,15 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
845
949
  probeTotal: state.probeTotal,
846
950
  probeCompleted: state.probeCompleted,
847
951
  probeHiddenCount: state.probeHiddenCount,
952
+ // 📖 Probe-cache (t1): footer chip stats
953
+ probeCacheHits: state.probeCacheHits || 0,
954
+ probeCacheMisses: state.probeCacheMisses || 0,
955
+ probeCacheBrokenHidden: state.probeCacheBrokenHidden || 0,
956
+ showBrokenMode: !!state.showBrokenMode,
957
+ // 📖 Passive quota (t2): live snapshots from response headers, populated
958
+ // 📖 by pings (every ping pre-warms quota for its provider). See
959
+ // 📖 src/core/provider-quota-fetchers.js for getAllPassiveQuotas().
960
+ quota: Object.fromEntries(_getAllPassiveQuotasFromModule()),
848
961
  }
849
962
  if (state.commandPaletteOpen) {
850
963
  if (!state.commandPaletteFrozenTable) {
@@ -970,8 +1083,13 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
970
1083
 
971
1084
  // ── Continuous ping loop — ping all models every N seconds forever ──────────
972
1085
 
973
- // 📖 Initial ping of all models
974
- const initialPing = Promise.all(state.results.map(r => pingModel(r)))
1086
+ // 📖 Initial ping: skip models that are still fresh in the probe-cache (t1).
1087
+ // 📖 They already have valid data pre-filled and will be re-probed when their TTL expires.
1088
+ // 📖 Broken models are always re-probed so recovery is detected.
1089
+ const initialDueModels = state.results.filter(r => !isCacheFresh(r.providerKey, r.modelId, {
1090
+ ttlMs: state.probeCacheTtlMs,
1091
+ }))
1092
+ const initialPing = Promise.all(initialDueModels.map(r => pingModel(r)))
975
1093
 
976
1094
  // 📖 Continuous ping loop with mode-driven cadence.
977
1095
  const runPingCycle = async () => {
@@ -1008,6 +1126,13 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
1008
1126
  if (!state.config.favorites.includes(favKey)) return
1009
1127
  }
1010
1128
 
1129
+ // 📖 Probe-cache (t1): skip models that are still fresh + ok in the cache.
1130
+ // 📖 Broken models always pass through isCacheFresh() (it returns false for them),
1131
+ // 📖 which gives us automatic recovery detection for free.
1132
+ if (isCacheFresh(r.providerKey, r.modelId, { ttlMs: state.probeCacheTtlMs })) {
1133
+ return
1134
+ }
1135
+
1011
1136
  pingModel(r).catch(() => {
1012
1137
  // Individual ping failures don't crash the loop
1013
1138
  })
@@ -1031,8 +1156,11 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
1031
1156
  await initialPing
1032
1157
  scheduleAiSpeedScanOnStartup()
1033
1158
 
1034
- // 📖 Save cache after initial pings complete for faster next startup
1159
+ // 📖 Save caches after initial pings complete for faster next startup.
1160
+ // 📖 Both caches live side-by-side: the old per-session cache for instant frame-1
1161
+ // 📖 data, and the new persistent probe-cache for cross-session skip + auto-hide.
1035
1162
  saveCache(state.results, state.pingMode)
1163
+ flushProbeCache()
1036
1164
 
1037
1165
  // 📖 Background version re-check: poll npm registry every 5 minutes.
1038
1166
  // 📖 If a new version appears (wasn't there at startup), update the banner live.
@@ -33,6 +33,9 @@ const ANALYSIS_FLAGS = [
33
33
  { flag: '--ping-interval <ms>', description: 'Override ping interval in milliseconds' },
34
34
  { flag: '--hide-unconfigured', description: 'Hide models without configured API keys' },
35
35
  { flag: '--show-unconfigured', description: 'Show all models regardless of API key config' },
36
+ { flag: '--reprobe, --no-cache', description: 'Force-rebuild the persistent probe-cache this run (skip cached healthy models)' },
37
+ { flag: '--probe-ttl <ms>', description: 'Override probe-cache TTL (default 24h); healthy models within TTL are not re-pinged' },
38
+ { flag: '--show-broken', description: "Don't auto-hide models that the probe-cache marked broken (one-shot override)" },
36
39
  ]
37
40
 
38
41
  const CONFIG_FLAGS = [
@@ -2784,6 +2784,25 @@ export function createKeyHandler(ctx) {
2784
2784
  return
2785
2785
  }
2786
2786
 
2787
+ // 📖 Shift+B: Toggle visibility of probe-cache-broken models (t1).
2788
+ // 📖 When toggled on, hidden broken rows become visible (greyed/dimmed via render-table).
2789
+ // 📖 When toggled off, they go back to hidden. Persisted for the current session.
2790
+ if (key.name === 'b' && key.shift && !key.ctrl && !key.meta) {
2791
+ const nextShowBroken = !state.showBrokenMode
2792
+ state.showBrokenMode = nextShowBroken
2793
+ // 📖 If enabling, restore all broken rows to visible; if disabling, re-hide them.
2794
+ for (const r of state.results) {
2795
+ if (r.cachedBroken) {
2796
+ r.hidden = !nextShowBroken
2797
+ }
2798
+ }
2799
+ // 📖 Refresh the footer counter chip + cursor bounds.
2800
+ state.probeCacheBrokenHidden = state.results.filter(x => x.cachedBroken && x.hidden).length
2801
+ applyTierFilter()
2802
+ refreshVisibleSorted({ resetCursor: false })
2803
+ return
2804
+ }
2805
+
2787
2806
  // 📖 X clears the active free-text filter set from the command palette.
2788
2807
  if (key.name === 'x' && !key.ctrl && !key.meta) {
2789
2808
  if (!state.customTextFilter) return
@@ -151,6 +151,14 @@ export const PROVIDER_COLOR = new Proxy({}, {
151
151
  * routerFooterTodayTokens?: number,
152
152
  * routerFooterAllTimeTokens?: number,
153
153
  * routerFooterRequests?: number,
154
+ * probeCacheHits?: number, // t1: number of fresh ok entries served from probe-cache
155
+ * probeCacheMisses?: number, // t1: number of models that needed a live probe
156
+ * probeCacheBrokenHidden?: number, // t1: number of broken models auto-hidden this session
157
+ * showBrokenMode?: boolean, // t1: true when Shift+B has un-hidden broken models
158
+ * quota?: Record<string, { // t2: live quota from response headers
159
+ * remaining: number, limit: number, percent: number,
160
+ * source: 'header'|'endpoint', lastUpdated: number, windowType?: string,
161
+ * }>,
154
162
  * }} opts
155
163
  * @returns {string}
156
164
  */
@@ -197,6 +205,11 @@ export function renderTable({
197
205
  probeTotal = 0,
198
206
  probeCompleted = 0,
199
207
  probeHiddenCount = 0,
208
+ probeCacheHits = 0,
209
+ probeCacheMisses = 0,
210
+ probeCacheBrokenHidden = 0,
211
+ showBrokenMode = false,
212
+ quota = {},
200
213
  } = _) {
201
214
  // 📖 Filter out hidden models for display
202
215
  const visibleResults = results.filter(r => !r.hidden)
@@ -1199,8 +1212,39 @@ export function renderTable({
1199
1212
  probeLabel = chalk.bgRgb(120, 60, 60).rgb(255, 200, 200).bold(` 🔍 Probe done: ${probeHiddenCount} broken model${probeHiddenCount > 1 ? 's' : ''} hidden `)
1200
1213
  }
1201
1214
 
1202
- // 📖 Line 3: Speed Test + Global Benchmark + Probe + Last release
1203
- if (releaseLabel || speedTestLabel || globalBenchmarkLabel || probeLabel) {
1215
+ // 📖 Probe-cache chip (t1): `⚡ N cached · 🔴 M broken (Shift+B)`
1216
+ // 📖 Always shown once any probe-cache state exists — gives the user a hint that
1217
+ // 📖 cache hits are happening (and that broken models are being hidden for them).
1218
+ let probeCacheLabel = ''
1219
+ if (probeCacheHits > 0 || probeCacheBrokenHidden > 0 || showBrokenMode) {
1220
+ const cachedTxt = `⚡ ${probeCacheHits} cached`
1221
+ const brokenTxt = probeCacheBrokenHidden > 0
1222
+ ? ` 🔴 ${probeCacheBrokenHidden} broken${showBrokenMode ? ' (visible)' : ' (Shift+B)'}`
1223
+ : ''
1224
+ probeCacheLabel = chalk.bgRgb(40, 90, 140).rgb(220, 235, 255).bold(` ${cachedTxt}${brokenTxt} `)
1225
+ }
1226
+
1227
+ // 📖 Passive quota chip (t2): `📊 groq 78% · sambanova 41%` (top-N depleted first).
1228
+ // 📖 Shows live rate-limit headers that the daemon / pings collected from upstream
1229
+ // 📖 responses. Zero extra network requests — see provider-quota-fetchers.js.
1230
+ // 📖 Cap at 5 providers to avoid footer bloat; pick most-depleted first.
1231
+ let quotaLabel = ''
1232
+ if (quota && typeof quota === 'object') {
1233
+ const entries = Object.entries(quota)
1234
+ .filter(([, s]) => s && typeof s.percent === 'number')
1235
+ .sort((a, b) => a[1].percent - b[1].percent) // 📖 most depleted first
1236
+ .slice(0, 5)
1237
+ if (entries.length > 0) {
1238
+ const parts = entries.map(([providerKey, snap]) => {
1239
+ const icon = snap.percent <= 10 ? '🚨' : snap.percent <= 25 ? '⚠️ ' : '📊'
1240
+ return `${icon} ${providerKey} ${snap.percent}%`
1241
+ })
1242
+ quotaLabel = chalk.bgRgb(60, 100, 60).rgb(220, 255, 220).bold(` ${parts.join(' · ')} `)
1243
+ }
1244
+ }
1245
+
1246
+ // 📖 Line 3: Speed Test + Global Benchmark + Probe + Probe-cache + Quota + Last release
1247
+ if (releaseLabel || speedTestLabel || globalBenchmarkLabel || probeLabel || probeCacheLabel || quotaLabel) {
1204
1248
  const parts = [
1205
1249
  { text: ' ', key: null },
1206
1250
  { text: speedTestLabel, key: 'a' },
@@ -1208,6 +1252,10 @@ export function renderTable({
1208
1252
  { text: globalBenchmarkLabel, key: 'u' },
1209
1253
  { text: probeLabel ? ' ' : '', key: null },
1210
1254
  { text: probeLabel, key: null },
1255
+ { text: probeCacheLabel ? ' ' : '', key: null },
1256
+ { text: probeCacheLabel, key: null },
1257
+ { text: quotaLabel ? ' ' : '', key: null },
1258
+ { text: quotaLabel, key: null },
1211
1259
  { text: ' ', key: null },
1212
1260
  { text: releaseLabel, key: null },
1213
1261
  ]
@@ -286,6 +286,17 @@ export function createTuiState({
286
286
  probeCompleted: 0,
287
287
  probeHiddenCount: 0,
288
288
 
289
+ // 📖 Persistent probe-cache (t1): TTL'd cross-session health cache.
290
+ // 📖 - probeCacheHits / Misses: telemetry counters per session (footer chip).
291
+ // 📖 - probeCacheBrokenHidden: live count of broken rows currently hidden.
292
+ // 📖 - probeCacheTtlMs: TTL override from --probe-ttl CLI flag (default 24h).
293
+ // 📖 - showBrokenMode: Shift+B toggle; true = broken rows are visible (dimmed).
294
+ probeCacheHits: 0,
295
+ probeCacheMisses: 0,
296
+ probeCacheBrokenHidden: 0,
297
+ probeCacheTtlMs: null, // 📖 Set in app.js from cliArgs.probeTtlMs || DEFAULT
298
+ showBrokenMode: false,
299
+
289
300
  // 📖 Header click flash animation: briefly highlights the clicked column header
290
301
  // 📖 with an inverse/bright style for ~250ms (3 frames at 12 FPS).
291
302
  headerFlashColumn: null, // 📖 Column name being flashed (null = no flash active)