free-coding-models 0.3.62 → 0.3.64

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.
@@ -104,7 +104,7 @@ export const PROVIDER_COLOR = new Proxy({}, {
104
104
  })
105
105
 
106
106
  // ─── renderTable: mode param controls footer hint text (opencode vs openclaw) ─────────
107
- export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, legacyStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, legacyFlag = false, startupLatestVersion = null, versionAlertsEnabled = true, favoritesPinnedAndSticky = false, customTextFilter = null, lastReleaseDate = null, legacyFooterHidden = false, verdictFilterMode = 0, healthFilterMode = 0, routerFooterRunning = false, routerFooterActiveSet = null, routerFooterTodayTokens = 0, routerFooterAllTimeTokens = 0, routerFooterRequests = 0) {
107
+ export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, legacyStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, legacyFlag = false, startupLatestVersion = null, versionAlertsEnabled = true, favoritesPinnedAndSticky = false, customTextFilter = null, lastReleaseDate = null, legacyFooterHidden = false, verdictFilterMode = 0, healthFilterMode = 0, bestModeOnly = false, routerFooterRunning = false, routerFooterActiveSet = null, routerFooterTodayTokens = 0, routerFooterAllTimeTokens = 0, routerFooterRequests = 0) {
108
108
  // 📖 Filter out hidden models for display
109
109
  const visibleResults = results.filter(r => !r.hidden)
110
110
  void legacyFooterHidden
@@ -747,7 +747,11 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
747
747
  // 📖 Active filter pills use a loud green background so tier/provider/configured-only
748
748
  // 📖 states are obvious even when the user misses the smaller header badges.
749
749
  const configuredBadgeBg = getTheme() === 'dark' ? [52, 120, 88] : [195, 234, 206]
750
+
751
+ const configuredFilterActive = hideUnconfiguredModels || bestModeOnly
752
+ const configuredFilterText = bestModeOnly ? 'Usable only' : (hideUnconfiguredModels ? 'Configured only' : 'Active only')
750
753
  const activeHotkey = (keyLabel, text, bg) => themeColors.badge(`${keyLabel}${text}`, bg, getReadableTextRgb(bg))
754
+ const activeFilterHotkey = (keyLabel, text, bg) => themeColors.hotkey(keyLabel) + themeColors.badge(text, bg, getReadableTextRgb(bg))
751
755
 
752
756
  // 📖 Mouse support: build footer hotkey zones alongside the footer lines.
753
757
  // 📖 Each zone records { key, row (1-based terminal row), xStart, xEnd (1-based display cols) }.
@@ -767,11 +771,13 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
767
771
  { text: ' • ', key: null },
768
772
  { text: originFilterMode > 0 ? `D Provider (${activeOriginLabel})` : 'D Provider', key: 'd' },
769
773
  { text: ' • ', key: null },
770
- { text: 'E Active only', key: 'e' },
774
+ { text: `E ${configuredFilterText}`, key: 'e' },
771
775
  { text: ' • ', key: null },
772
776
  { text: 'P Settings', key: 'p' },
773
777
  { text: ' • ', key: null },
774
778
  { text: 'I Help', key: 'i' },
779
+ { text: ' • ', key: null },
780
+ { text: 'N Reset', key: 'n' },
775
781
  ]
776
782
  const footerRow1 = lines.length + 1 // 📖 1-based terminal row (line hasn't been pushed yet)
777
783
  let xPos = 1
@@ -795,11 +801,15 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
795
801
  ? activeHotkey('D', ` Provider (${activeOriginLabel})`, PROVIDER_COLOR[[null, ...Object.keys(sources)][originFilterMode]] || [255, 255, 255])
796
802
  : hotkey('D', ' Provider')) +
797
803
  themeColors.dim(` • `) +
798
- (hideUnconfiguredModels ? activeHotkey('E', ' Working only', configuredBadgeBg) : hotkey('E', ' Working only')) +
804
+ (configuredFilterActive
805
+ ? activeFilterHotkey('E', configuredFilterText, configuredBadgeBg)
806
+ : hotkey('E', ' Active only')) +
799
807
  themeColors.dim(` • `) +
800
808
  hotkey('P', ' Settings') +
801
809
  themeColors.dim(` • `) +
802
- hotkey('I', ' Help')
810
+ hotkey('I', ' Help') +
811
+ themeColors.dim(` • `) +
812
+ hotkey('N', ' Reset')
803
813
  )
804
814
 
805
815
  // 📖 Line 2: command palette + GitHub
@@ -825,7 +835,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
825
835
  const starLink = '⭐ ' + themeColors.link('\x1b]8;;https://github.com/vava-nessa/free-coding-models\x1b\\GitHub\x1b]8;;\x1b\\')
826
836
  lines.push(
827
837
  ' ' + paletteLabel + themeColors.dim(` • `) + starLink + themeColors.dim(` • `) +
828
- chalk.rgb(255, 168, 209).bold('\x1b]8;;https://x.com/vavanessadev\x1b\\Support me by following me on X ! @vavanessadev\x1b]8;;\x1b\\')
838
+ chalk.rgb(255, 168, 209).bold('\x1b]8;;https://x.com/vavanessadev\x1b\\Follow @vavanessadev on X for updates and support\x1b]8;;\x1b\\')
829
839
  )
830
840
 
831
841
  if (versionStatus.isOutdated) {
@@ -637,7 +637,8 @@ class RouterRuntime {
637
637
  reloadConfigFromDisk() {
638
638
  try {
639
639
  const nextConfig = loadConfig()
640
- if (!nextConfig.router) nextConfig.router = this.routerConfig()
640
+ // 📖 Always rebuild the router set from favorites so UI toggles apply dynamically
641
+ ensureRouterConfigForDaemon(nextConfig, true)
641
642
  this.config = nextConfig
642
643
  this.refreshRouteState()
643
644
  this.scheduleProbeLoop()
@@ -649,12 +650,10 @@ class RouterRuntime {
649
650
  }
650
651
 
651
652
  getApiKeyForProvider(providerKey) {
652
- // 📖 Router background startup should work without inherited shell env, so
653
- // 📖 config keys are primary. Env is only a fallback for headless sessions.
654
653
  const configured = this.config?.apiKeys?.[providerKey]
655
654
  if (Array.isArray(configured)) return configured.find(Boolean) || null
656
655
  if (typeof configured === 'string' && configured.trim()) return configured.trim()
657
- return getApiKey({ apiKeys: {}, providers: {} }, providerKey)
656
+ return null
658
657
  }
659
658
 
660
659
  getSet(setName = null) {
@@ -1052,15 +1051,18 @@ class RouterRuntime {
1052
1051
  let errorCode = 'all_models_unavailable'
1053
1052
  let errorType = 'service_unavailable'
1054
1053
  if (health.length > 0) {
1055
- if (health.every((h) => h.state === 'AUTH_ERROR')) {
1054
+ const allAuthError = health.length > 0 && health.every((h) => h.state === 'AUTH_ERROR')
1055
+ const allAuthOrQuota = health.length > 0 && health.every((h) => h.state === 'AUTH_ERROR' || quotaExhausted.includes(h.key))
1056
+ const allStaleOrUnsupported = health.every((h) => h.state === 'STALE' || h.state === 'UNSUPPORTED')
1057
+ if (allAuthError) {
1056
1058
  statusCode = 401
1057
1059
  errorCode = 'invalid_api_key'
1058
1060
  errorType = 'invalid_request_error'
1059
- } else if (health.every((h) => h.state === 'AUTH_ERROR' || quotaExhausted.includes(h.key))) {
1061
+ } else if (allAuthOrQuota) {
1060
1062
  statusCode = 429
1061
1063
  errorCode = 'insufficient_quota'
1062
1064
  errorType = 'insufficient_quota'
1063
- } else if (health.every((h) => h.state === 'STALE' || h.state === 'UNSUPPORTED')) {
1065
+ } else if (allStaleOrUnsupported) {
1064
1066
  statusCode = 400
1065
1067
  errorCode = 'invalid_model'
1066
1068
  errorType = 'invalid_request_error'
@@ -1808,7 +1810,7 @@ export function createRouterRuntimeForTest({ config, port = 0, logger = null, to
1808
1810
  })
1809
1811
  }
1810
1812
 
1811
- function ensureRouterConfigForDaemon(config) {
1813
+ function ensureRouterConfigForDaemon(config, skipSave = false) {
1812
1814
  // 📖 Always rebuild from favorites or defaults — no more manual set management
1813
1815
  const favSet = buildRouterSetFromFavorites(config)
1814
1816
  const activeSet = favSet || buildDefaultRouterSet(config)
@@ -1819,7 +1821,7 @@ function ensureRouterConfigForDaemon(config) {
1819
1821
  activeSet: activeSet.name,
1820
1822
  sets: { [activeSet.name]: activeSet },
1821
1823
  })
1822
- saveConfig(config)
1824
+ if (!skipSave) saveConfig(config)
1823
1825
  return config.router
1824
1826
  }
1825
1827
 
@@ -43,9 +43,10 @@ import chalk from 'chalk'
43
43
  import { existsSync, readFileSync } from 'node:fs'
44
44
  import { displayWidth, padEndDisplay, sliceOverlayLines, tintOverlayLines } from './render-helpers.js'
45
45
  import { ROUTER_DEFAULT_PORT, ROUTER_MAX_PORT, ROUTER_PID_PATH, ROUTER_PORT_PATH, getRouterPortRange } from './router-daemon.js'
46
- import { themeColors } from './theme.js'
46
+ import { themeColors, getTierRgb } from './theme.js'
47
47
  import { formatTokenTotalCompact } from './token-usage-reader.js'
48
48
  import { sendUsageTelemetry } from './telemetry.js'
49
+ import { getAvg, getVerdict } from './utils.js'
49
50
 
50
51
  export const ROUTER_DASHBOARD_POLL_INTERVAL_MS = 2000
51
52
  export const ROUTER_DASHBOARD_FETCH_TIMEOUT_MS = 1200
@@ -859,7 +860,7 @@ export function renderRouterDashboard(state, deps = {}) {
859
860
  // 📖 Instead of the old "sets" system, show the user's favorites from the main
860
861
  // 📖 table as the router fallback chain. #1 = tried first, #2 = next, etc.
861
862
  lines.push(` ${themeColors.textBold('Router Models')} ${themeColors.dim('— your favorites, in fallback order')}`)
862
- lines.push(` ${themeColors.dim('Star models with F in the main table. Ctrl+↑↓ to reorder here.')}`)
863
+ lines.push(` ${themeColors.dim('Star models with F in the main table. Shift+↑↓ to reorder here.')}`)
863
864
  lines.push('')
864
865
 
865
866
  // 📖 Build the favorites list — pull from config.favorites + daemon health data
@@ -879,33 +880,84 @@ export function renderRouterDashboard(state, deps = {}) {
879
880
  healthByKey.set(`${m.provider}/${m.model}`, m)
880
881
  }
881
882
 
883
+ // 📖 Column headers
884
+ lines.push(` ${themeColors.dim(padEndDisplay('PRI', 4))} ${themeColors.dim(padEndDisplay('MODEL', 42))} ${themeColors.dim(padEndDisplay('DAEMON STATUS', 16))} ${themeColors.dim(padEndDisplay('AVG PING', 8))} ${themeColors.dim('VERDICT')}`)
885
+
882
886
  for (let i = 0; i < favorites.length; i++) {
883
887
  const favKey = favorites[i]
884
888
  const health = healthByKey.get(favKey)
885
889
  const isCursorRow = i === cursor
886
890
 
887
- // 📖 Human-readable health label instead of circuit breaker jargon
888
- let healthLabel
889
- if (!isRunning) {
890
- healthLabel = themeColors.dim('—')
891
- } else if (health) {
892
- const st = safeString(health.state, 'UNKNOWN').toUpperCase()
893
- if (st === 'CLOSED') healthLabel = themeColors.success('✅ Healthy')
894
- else if (st === 'HALF_OPEN') healthLabel = themeColors.warning('⚠️ Recovering')
895
- else if (st === 'OPEN') healthLabel = themeColors.error('❌ Down')
896
- else if (st === 'AUTH_ERROR') healthLabel = themeColors.error('🔑 Auth Error')
897
- else if (st === 'STALE') healthLabel = themeColors.dim('💀 Stale')
898
- else if (st === 'UNSUPPORTED') healthLabel = themeColors.dim('✖ Unsupported')
899
- else healthLabel = themeColors.dim(`? ${st}`)
891
+ let healthLabel = themeColors.dim(padEndDisplay('—', 16))
892
+
893
+ const mainResult = state.results?.find(r => `${r.providerKey}/${r.modelId}` === favKey)
894
+
895
+ if (mainResult) {
896
+ let statusText, statusColor
897
+ if (mainResult.status === 'noauth') {
898
+ statusText = `🔑 NO KEY`
899
+ statusColor = themeColors.dim
900
+ } else if (mainResult.status === 'auth_error') {
901
+ statusText = `🔐 AUTH FAIL`
902
+ statusColor = themeColors.errorBold
903
+ } else if (mainResult.status === 'pending') {
904
+ statusText = `⏳ PENDING`
905
+ statusColor = themeColors.warning
906
+ } else if (mainResult.status === 'up') {
907
+ statusText = `✅ UP`
908
+ statusColor = themeColors.success
909
+ } else if (mainResult.status === 'timeout') {
910
+ statusText = `⏳ TIMEOUT`
911
+ statusColor = themeColors.warning
912
+ } else if (mainResult.status === 'down') {
913
+ const code = mainResult.httpCode ?? 'ERR'
914
+ const errorEmojis = { '429': '🔥', '404': '🚫', '500': '💥', '502': '🔌', '503': '🔒', '504': '⏰' }
915
+ const errorLabels = { '404': '404 NOT FOUND', '410': '410 GONE', '429': '429 TRY LATER', '500': '500 ERROR' }
916
+ const emoji = errorEmojis[code] || '❌'
917
+ statusText = `${emoji} ${errorLabels[code] || code}`
918
+ statusColor = themeColors.error
919
+ } else {
920
+ statusText = '?'
921
+ statusColor = themeColors.dim
922
+ }
923
+ healthLabel = statusColor(padEndDisplay(statusText, 16))
900
924
  } else {
901
- healthLabel = themeColors.dim('⏳ Pending')
925
+ healthLabel = themeColors.dim(padEndDisplay('⏳ Pending', 16))
902
926
  }
903
927
 
904
- const latency = health && Number.isFinite(health.last_latency_ms)
905
- ? themeColors.dim(`${Math.round(health.last_latency_ms)}ms`)
906
- : ''
928
+ // 📖 Get global metrics from main table state
929
+ let avgPingDisplay = themeColors.dim('———')
930
+ let verdictDisplay = themeColors.dim('Pending ⏳')
931
+
932
+ if (mainResult) {
933
+ // Avg Ping
934
+ const avg = getAvg(mainResult)
935
+ if (avg !== Infinity) {
936
+ const str = String(avg).padEnd(4)
937
+ avgPingDisplay = avg < 500 ? themeColors.metricGood(str) : avg < 1500 ? themeColors.metricWarn(str) : themeColors.metricBad(str)
938
+ }
939
+
940
+ // Verdict
941
+ const verdict = getVerdict(mainResult)
942
+ let verdictText, verdictColor
943
+ switch (verdict) {
944
+ case 'Perfect': verdictText = 'Perfect 🚀'; verdictColor = themeColors.successBold; break
945
+ case 'Normal': verdictText = 'Normal ✅'; verdictColor = themeColors.metricGood; break
946
+ case 'Spiky': verdictText = 'Spiky 📈'; verdictColor = (t) => chalk.bold.rgb(...getTierRgb('A+'))(t); break
947
+ case 'Slow': verdictText = 'Slow 🐢'; verdictColor = (t) => chalk.bold.rgb(...getTierRgb('A-'))(t); break
948
+ case 'Very Slow': verdictText = 'Very Slow 🐌'; verdictColor = (t) => chalk.bold.rgb(...getTierRgb('B+'))(t); break
949
+ case 'Overloaded': verdictText = 'Overloaded 🔥'; verdictColor = (t) => chalk.bold.rgb(...getTierRgb('B'))(t); break
950
+ case 'Unstable': verdictText = 'Unstable ⚠️'; verdictColor = themeColors.errorBold; break
951
+ case 'Not Active': verdictText = 'Not Active 👻'; verdictColor = themeColors.dim; break
952
+ case 'Pending': verdictText = 'Pending ⏳'; verdictColor = themeColors.dim; break
953
+ default: verdictText = 'Unusable 💀'; verdictColor = (t) => chalk.bold.rgb(...getTierRgb('C'))(t); break
954
+ }
955
+ verdictDisplay = verdictColor(padEndDisplay(verdictText, 14))
956
+ } else {
957
+ verdictDisplay = padEndDisplay(verdictDisplay, 14)
958
+ }
907
959
 
908
- const rowText = ` ${padEndDisplay(priorityGlyph(i), 4)} ${padEndDisplay(favKey, 42)} ${padEndDisplay(healthLabel, 18)} ${latency}`
960
+ const rowText = ` ${padEndDisplay(priorityGlyph(i), 4)} ${padEndDisplay(favKey, 42)} ${padEndDisplay(healthLabel, 16)} ${padEndDisplay(avgPingDisplay, 8)} ${verdictDisplay}`
909
961
 
910
962
  if (isCursorRow) {
911
963
  lines.push(themeColors.bgCursor(rowText + ' '.repeat(Math.max(0, width - displayWidth(rowText) - 3))))
@@ -915,6 +967,31 @@ export function renderRouterDashboard(state, deps = {}) {
915
967
  }
916
968
  }
917
969
 
970
+ // 📖 Toggle Daemon Button
971
+ lines.push('')
972
+ const btnCursor = favorites.length
973
+ const isBtnCursor = cursor === btnCursor
974
+ const btnText = isStopped ? '▶ Start Router Daemon' : '⏹ Stop Router Daemon'
975
+ const btnRowText = ` [ ${btnText} ]`
976
+
977
+ if (isBtnCursor) {
978
+ lines.push(themeColors.bgCursor(btnRowText + ' '.repeat(Math.max(0, width - displayWidth(btnRowText) - 3))))
979
+ } else {
980
+ lines.push(btnRowText)
981
+ }
982
+
983
+ // 📖 Install Endpoint Button
984
+ const installBtnCursor = favorites.length + 1
985
+ const isInstallBtnCursor = cursor === installBtnCursor
986
+ const installBtnText = '🔌 Install Router Endpoint to CLI Tool'
987
+ const installBtnRowText = ` [ ${installBtnText} ]`
988
+
989
+ if (isInstallBtnCursor) {
990
+ lines.push(themeColors.bgCursor(installBtnRowText + ' '.repeat(Math.max(0, width - displayWidth(installBtnRowText) - 3))))
991
+ } else {
992
+ lines.push(installBtnRowText)
993
+ }
994
+
918
995
  lines.push('')
919
996
  lines.push(` ${separator}`)
920
997
  lines.push('')
@@ -958,7 +1035,7 @@ export function renderRouterDashboard(state, deps = {}) {
958
1035
  // ── Error/Notice display ────────────────────────────────────────────────────
959
1036
  if (state.routerDashboardError && isStopped) {
960
1037
  lines.push('')
961
- lines.push(` ${themeColors.dim('Start the daemon with:')} ${themeColors.info('free-coding-models --daemon-bg')}`)
1038
+ lines.push(` ${themeColors.dim('Press')} ${themeColors.hotkey('S')} ${themeColors.dim('to start it now.')}`)
962
1039
  } else if (state.routerDashboardError) {
963
1040
  lines.push('')
964
1041
  lines.push(` ${themeColors.warning(state.routerDashboardError)}`)
@@ -971,7 +1048,7 @@ export function renderRouterDashboard(state, deps = {}) {
971
1048
 
972
1049
  // ── Footer ──────────────────────────────────────────────────────────────────
973
1050
  lines.push('')
974
- lines.push(` ${themeColors.hotkey('↑↓')} ${themeColors.dim('Navigate')} ${themeColors.dim('•')} ${themeColors.hotkey('Ctrl+↑↓')} ${themeColors.dim('Reorder')} ${themeColors.dim('•')} ${themeColors.hotkey('I')} ${themeColors.dim(`Health check: ${probeLabel}`)} ${themeColors.dim('•')} ${themeColors.hotkey('C')} ${themeColors.dim('Clear log')} ${themeColors.dim('•')} ${themeColors.hotkey('Esc')} ${themeColors.dim('Back')}`)
1051
+ lines.push(` ${themeColors.hotkey('↑↓')} ${themeColors.dim('Navigate')} ${themeColors.dim('•')} ${themeColors.hotkey('Shift+↑↓')} ${themeColors.dim('Reorder')} ${themeColors.dim('•')} ${themeColors.hotkey('S')} ${themeColors.dim(isStopped ? 'Start daemon' : 'Stop daemon')} ${themeColors.dim('•')} ${themeColors.hotkey('I')} ${themeColors.dim(`Health check: ${probeLabel}`)} ${themeColors.dim('•')} ${themeColors.hotkey('C')} ${themeColors.dim('Clear log')} ${themeColors.dim('•')} ${themeColors.hotkey('Esc')} ${themeColors.dim('Back')}`)
975
1052
 
976
1053
  const { visible, offset } = sliceOverlayLines(lines, state.routerDashboardScrollOffset || 0, state.terminalRows || 24)
977
1054
  state.routerDashboardScrollOffset = offset