free-coding-models 0.1.81 β†’ 0.1.83

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.
@@ -95,8 +95,23 @@ import { createServer as createHttpServer } from 'http'
95
95
  import { request as httpsRequest } from 'https'
96
96
  import { MODELS, sources } from '../sources.js'
97
97
  import { patchOpenClawModelsJson } from '../patch-openclaw-models.js'
98
- import { getAvg, getVerdict, getUptime, getP95, getJitter, getStabilityScore, sortResults, filterByTier, findBestModel, parseArgs, TIER_ORDER, VERDICT_ORDER, TIER_LETTER_MAP, scoreModelForTask, getTopRecommendations, TASK_TYPES, PRIORITY_TYPES, CONTEXT_BUDGETS, formatCtxWindow, labelFromId } from '../lib/utils.js'
99
- import { loadConfig, saveConfig, getApiKey, isProviderEnabled, saveAsProfile, loadProfile, listProfiles, deleteProfile, getActiveProfileName, setActiveProfile, _emptyProfileSettings } from '../lib/config.js'
98
+ import { getAvg, getVerdict, getUptime, getP95, getJitter, getStabilityScore, sortResults, filterByTier, findBestModel, parseArgs, TIER_ORDER, VERDICT_ORDER, TIER_LETTER_MAP, scoreModelForTask, getTopRecommendations, TASK_TYPES, PRIORITY_TYPES, CONTEXT_BUDGETS, formatCtxWindow, labelFromId, getProxyStatusInfo } from '../lib/utils.js'
99
+ import { loadConfig, saveConfig, getApiKey, resolveApiKeys, addApiKey, removeApiKey, isProviderEnabled, saveAsProfile, loadProfile, listProfiles, deleteProfile, getActiveProfileName, setActiveProfile, _emptyProfileSettings } from '../lib/config.js'
100
+ import { buildMergedModels } from '../lib/model-merger.js'
101
+ import { ProxyServer } from '../lib/proxy-server.js'
102
+ import { loadOpenCodeConfig, saveOpenCodeConfig, syncToOpenCode, restoreOpenCodeBackup } from '../lib/opencode-sync.js'
103
+ import { usageForRow as _usageForRow } from '../lib/usage-reader.js'
104
+ import { loadRecentLogs } from '../lib/log-reader.js'
105
+ import { parseOpenRouterResponse, fetchProviderQuota as _fetchProviderQuotaFromModule } from '../lib/provider-quota-fetchers.js'
106
+ import { isKnownQuotaTelemetry } from '../lib/quota-capabilities.js'
107
+
108
+ // πŸ“– mergedModels: cross-provider grouped model list (one entry per label, N providers each)
109
+ // πŸ“– mergedModelByLabel: fast lookup map from display label β†’ merged model entry
110
+ const mergedModels = buildMergedModels(MODELS)
111
+ const mergedModelByLabel = new Map(mergedModels.map(m => [m.label, m]))
112
+
113
+ // πŸ“– Provider quota cache is managed by lib/provider-quota-fetchers.js (TTL + backoff).
114
+ // πŸ“– Usage placeholder logic uses isKnownQuotaTelemetry() from lib/quota-capabilities.js.
100
115
 
101
116
  const require = createRequire(import.meta.url)
102
117
  const readline = require('readline')
@@ -242,8 +257,10 @@ function ensureTelemetryConfig(config) {
242
257
  if (!config.telemetry || typeof config.telemetry !== 'object') {
243
258
  config.telemetry = { enabled: true, anonymousId: null }
244
259
  }
245
- // πŸ“– Always force telemetry to true, overriding any previous user choice
246
- config.telemetry.enabled = true
260
+ // πŸ“– Only default enabled when unset; do not override a user's explicit opt-out
261
+ if (typeof config.telemetry.enabled !== 'boolean') {
262
+ config.telemetry.enabled = true
263
+ }
247
264
  if (typeof config.telemetry.anonymousId !== 'string' || !config.telemetry.anonymousId.trim()) {
248
265
  config.telemetry.anonymousId = null
249
266
  }
@@ -472,7 +489,7 @@ function runUpdate(latestVersion) {
472
489
 
473
490
  // πŸ“– Relaunch automatically with the same arguments
474
491
  const args = process.argv.slice(2)
475
- execSync(`node bin/free-coding-models.js ${args.join(' ')}`, { stdio: 'inherit' })
492
+ execSync(`node ${process.argv[1]} ${args.join(' ')}`, { stdio: 'inherit' })
476
493
  process.exit(0)
477
494
  } catch (err) {
478
495
  console.log()
@@ -495,7 +512,7 @@ function runUpdate(latestVersion) {
495
512
 
496
513
  // πŸ“– Relaunch automatically with the same arguments
497
514
  const args = process.argv.slice(2)
498
- execSync(`node bin/free-coding-models.js ${args.join(' ')}`, { stdio: 'inherit' })
515
+ execSync(`node ${process.argv[1]} ${args.join(' ')}`, { stdio: 'inherit' })
499
516
  process.exit(0)
500
517
  } catch (sudoErr) {
501
518
  console.log()
@@ -728,6 +745,7 @@ const spinCell = (f, o = 0) => chalk.dim.yellow(FRAMES[(f + o) % FRAMES.length].
728
745
  const SETTINGS_OVERLAY_BG = chalk.bgRgb(14, 20, 30)
729
746
  const HELP_OVERLAY_BG = chalk.bgRgb(24, 16, 32)
730
747
  const RECOMMEND_OVERLAY_BG = chalk.bgRgb(10, 25, 15) // πŸ“– Green tint for Smart Recommend
748
+ const LOG_OVERLAY_BG = chalk.bgRgb(10, 20, 26) // πŸ“– Dark blue-green tint for Log page
731
749
  const OVERLAY_PANEL_WIDTH = 116
732
750
 
733
751
  // πŸ“– Strip ANSI color/control sequences to estimate visible text width before padding.
@@ -735,6 +753,13 @@ function stripAnsi(input) {
735
753
  return String(input).replace(/\x1b\[[0-9;]*m/g, '').replace(/\x1b\][^\x1b]*\x1b\\/g, '')
736
754
  }
737
755
 
756
+ // πŸ“– maskApiKey: Mask all but first 4 and last 3 characters of an API key.
757
+ // πŸ“– Prevents accidental disclosure of secrets in TUI display.
758
+ function maskApiKey(key) {
759
+ if (!key || key.length < 10) return '***'
760
+ return key.slice(0, 4) + '***' + key.slice(-3)
761
+ }
762
+
738
763
  // πŸ“– Calculate display width of a string in terminal columns.
739
764
  // πŸ“– Emojis and other wide characters occupy 2 columns, variation selectors (U+FE0F) are zero-width.
740
765
  // πŸ“– This avoids pulling in a full `string-width` dependency for a lightweight CLI tool.
@@ -858,8 +883,29 @@ function sortResultsWithPinnedFavorites(results, sortColumn, sortDirection) {
858
883
  return [...bothRows, ...recommendedRows, ...favoriteRows, ...nonSpecialRows]
859
884
  }
860
885
 
886
+ // πŸ“– renderProxyStatusLine: Maps proxyStartupStatus + active proxy into a chalk-coloured footer line.
887
+ // πŸ“– Always returns a non-empty string (no hidden states) so the footer row is always present.
888
+ // πŸ“– Delegates state classification to the pure getProxyStatusInfo helper (testable in utils.js).
889
+ function renderProxyStatusLine(proxyStartupStatus, proxyInstance) {
890
+ const info = getProxyStatusInfo(proxyStartupStatus, !!proxyInstance)
891
+ switch (info.state) {
892
+ case 'starting':
893
+ return chalk.dim(' ') + chalk.yellow('⟳ Proxy') + chalk.dim(' starting…')
894
+ case 'running': {
895
+ const portPart = info.port ? chalk.dim(` :${info.port}`) : ''
896
+ const acctPart = info.accountCount != null ? chalk.dim(` Β· ${info.accountCount} account${info.accountCount === 1 ? '' : 's'}`) : ''
897
+ return chalk.dim(' ') + chalk.rgb(57, 255, 20)('πŸ”€ Proxy') + chalk.rgb(57, 255, 20)(' running') + portPart + acctPart
898
+ }
899
+ case 'failed':
900
+ return chalk.dim(' ') + chalk.red('βœ— Proxy failed') + chalk.dim(` β€” ${info.reason}`)
901
+ default:
902
+ // stopped / not configured β€” dim but always present
903
+ return chalk.dim(' πŸ”€ Proxy not configured')
904
+ }
905
+ }
906
+
861
907
  // πŸ“– renderTable: mode param controls footer hint text (opencode vs openclaw)
862
- 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, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '') {
908
+ 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, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null) {
863
909
  // πŸ“– Filter out hidden models for display
864
910
  const visibleResults = results.filter(r => !r.hidden)
865
911
 
@@ -930,6 +976,7 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
930
976
  const W_VERDICT = 14
931
977
  const W_STAB = 11
932
978
  const W_UPTIME = 6
979
+ const W_USAGE = 7
933
980
 
934
981
  // πŸ“– Sort models using the shared helper
935
982
  const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection)
@@ -960,6 +1007,7 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
960
1007
  const verdictH = sortColumn === 'verdict' ? dir + ' Verdict' : 'Verdict'
961
1008
  const stabH = sortColumn === 'stability' ? dir + ' Stability' : 'Stability'
962
1009
  const uptimeH = sortColumn === 'uptime' ? dir + ' Up%' : 'Up%'
1010
+ const usageH = sortColumn === 'usage' ? dir + ' Usage' : 'Usage'
963
1011
 
964
1012
  // πŸ“– Helper to colorize first letter for keyboard shortcuts
965
1013
  // πŸ“– IMPORTANT: Pad PLAIN TEXT first, then apply colors to avoid alignment issues
@@ -978,9 +1026,11 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
978
1026
  const originH_c = sortColumn === 'origin'
979
1027
  ? chalk.bold.cyan(originLabel.padEnd(W_SOURCE))
980
1028
  : (originFilterMode > 0 ? chalk.bold.rgb(100, 200, 255)(originLabel.padEnd(W_SOURCE)) : (() => {
981
- // πŸ“– Custom colorization for Origin: highlight 'N' (the filter key) at the end
1029
+ // πŸ“– Custom colorization for Origin: highlight 'O' (the sort key) at start
1030
+ const first = originLabel[0]
1031
+ const rest = originLabel.slice(1)
982
1032
  const padding = ' '.repeat(Math.max(0, W_SOURCE - originLabel.length))
983
- return chalk.dim('Origi') + chalk.yellow('N') + chalk.dim(padding)
1033
+ return chalk.yellow(first) + chalk.dim(rest + padding)
984
1034
  })())
985
1035
  const modelH_c = colorFirst(modelH, W_MODEL)
986
1036
  const sweH_c = sortColumn === 'swe' ? chalk.bold.cyan(sweH.padEnd(W_SWE)) : colorFirst(sweH, W_SWE)
@@ -993,12 +1043,18 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
993
1043
  const stabH_c = sortColumn === 'stability' ? chalk.bold.cyan(stabH.padEnd(W_STAB)) : (() => {
994
1044
  const plain = 'Stability'
995
1045
  const padding = ' '.repeat(Math.max(0, W_STAB - plain.length))
996
- return chalk.dim('Sta') + chalk.white.bold('B') + chalk.dim('ility' + padding)
1046
+ return chalk.dim('Sta') + chalk.yellow.bold('B') + chalk.dim('ility' + padding)
997
1047
  })()
998
1048
  const uptimeH_c = sortColumn === 'uptime' ? chalk.bold.cyan(uptimeH.padEnd(W_UPTIME)) : colorFirst(uptimeH, W_UPTIME, chalk.green)
1049
+ // πŸ“– Custom colorization for Usage: highlight 'G' (Shift+G = sort key)
1050
+ const usageH_c = sortColumn === 'usage' ? chalk.bold.cyan(usageH.padEnd(W_USAGE)) : (() => {
1051
+ const plain = 'Usage'
1052
+ const padding = ' '.repeat(Math.max(0, W_USAGE - plain.length))
1053
+ return chalk.dim('Usa') + chalk.yellow.bold('G') + chalk.dim('e' + padding)
1054
+ })()
999
1055
 
1000
- // πŸ“– Header with proper spacing (column order: Rank, Tier, SWE%, CTX, Model, Origin, Latest Ping, Avg Ping, Health, Verdict, Stability, Up%)
1001
- lines.push(' ' + rankH_c + ' ' + tierH_c + ' ' + sweH_c + ' ' + ctxH_c + ' ' + modelH_c + ' ' + originH_c + ' ' + pingH_c + ' ' + avgH_c + ' ' + healthH_c + ' ' + verdictH_c + ' ' + stabH_c + ' ' + uptimeH_c)
1056
+ // πŸ“– Header with proper spacing (column order: Rank, Tier, SWE%, CTX, Model, Origin, Latest Ping, Avg Ping, Health, Verdict, Stability, Up%, Usage)
1057
+ lines.push(' ' + rankH_c + ' ' + tierH_c + ' ' + sweH_c + ' ' + ctxH_c + ' ' + modelH_c + ' ' + originH_c + ' ' + pingH_c + ' ' + avgH_c + ' ' + healthH_c + ' ' + verdictH_c + ' ' + stabH_c + ' ' + uptimeH_c + ' ' + usageH_c)
1002
1058
 
1003
1059
  // πŸ“– Separator line
1004
1060
  lines.push(
@@ -1014,7 +1070,8 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
1014
1070
  chalk.dim('─'.repeat(W_STATUS)) + ' ' +
1015
1071
  chalk.dim('─'.repeat(W_VERDICT)) + ' ' +
1016
1072
  chalk.dim('─'.repeat(W_STAB)) + ' ' +
1017
- chalk.dim('─'.repeat(W_UPTIME))
1073
+ chalk.dim('─'.repeat(W_UPTIME)) + ' ' +
1074
+ chalk.dim('─'.repeat(W_USAGE))
1018
1075
  )
1019
1076
 
1020
1077
  // πŸ“– Viewport clipping: only render models that fit on screen
@@ -1033,7 +1090,7 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
1033
1090
  // πŸ“– Left-aligned columns - pad plain text first, then colorize
1034
1091
  const num = chalk.dim(String(r.idx).padEnd(W_RANK))
1035
1092
  const tier = tierFn(r.tier.padEnd(W_TIER))
1036
- // πŸ“– Show provider name from sources map (NIM / Groq / Cerebras)
1093
+ // πŸ“– Keep terminal view provider-specific so each row is monitorable per provider
1037
1094
  const providerName = sources[r.providerKey]?.name ?? r.providerKey ?? 'NIM'
1038
1095
  const source = chalk.green(providerName.padEnd(W_SOURCE))
1039
1096
  // πŸ“– Favorites: always reserve 2 display columns at the start of Model column.
@@ -1217,10 +1274,28 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
1217
1274
 
1218
1275
  // πŸ“– When cursor is on this row, render Model and Origin in bright white for readability
1219
1276
  const nameCell = isCursor ? chalk.white.bold(favoritePrefix + r.label.slice(0, nameWidth).padEnd(nameWidth)) : name
1220
- const sourceCell = isCursor ? chalk.white.bold(providerName.padEnd(W_SOURCE)) : source
1277
+ const sourceCursorText = providerName.padEnd(W_SOURCE)
1278
+ const sourceCell = isCursor ? chalk.white.bold(sourceCursorText) : source
1279
+
1280
+ // πŸ“– Usage column β€” quota percent remaining from token-stats.json (higher = more quota left)
1281
+ let usageCell
1282
+ if (r.usagePercent !== undefined && r.usagePercent !== null) {
1283
+ const usageStr = Math.round(r.usagePercent) + '%'
1284
+ if (r.usagePercent >= 80) {
1285
+ usageCell = chalk.greenBright(usageStr.padEnd(W_USAGE))
1286
+ } else if (r.usagePercent >= 50) {
1287
+ usageCell = chalk.yellow(usageStr.padEnd(W_USAGE))
1288
+ } else if (r.usagePercent >= 20) {
1289
+ usageCell = chalk.rgb(255, 165, 0)(usageStr.padEnd(W_USAGE)) // orange
1290
+ } else {
1291
+ usageCell = chalk.red(usageStr.padEnd(W_USAGE))
1292
+ }
1293
+ } else {
1294
+ usageCell = chalk.dim(usagePlaceholderForProvider(r.providerKey).padEnd(W_USAGE))
1295
+ }
1221
1296
 
1222
- // πŸ“– Build row with double space between columns (order: Rank, Tier, SWE%, CTX, Model, Origin, Latest Ping, Avg Ping, Health, Verdict, Stability, Up%)
1223
- const row = ' ' + num + ' ' + tier + ' ' + sweCell + ' ' + ctxCell + ' ' + nameCell + ' ' + sourceCell + ' ' + pingCell + ' ' + avgCell + ' ' + status + ' ' + speedCell + ' ' + stabCell + ' ' + uptimeCell
1297
+ // πŸ“– Build row with double space between columns (order: Rank, Tier, SWE%, CTX, Model, Origin, Latest Ping, Avg Ping, Health, Verdict, Stability, Up%, Usage)
1298
+ const row = ' ' + num + ' ' + tier + ' ' + sweCell + ' ' + ctxCell + ' ' + nameCell + ' ' + sourceCell + ' ' + pingCell + ' ' + avgCell + ' ' + status + ' ' + speedCell + ' ' + stabCell + ' ' + uptimeCell + ' ' + usageCell
1224
1299
 
1225
1300
  if (isCursor) {
1226
1301
  lines.push(chalk.bgRgb(50, 0, 60)(row))
@@ -1253,10 +1328,11 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
1253
1328
  ? chalk.rgb(0, 200, 255)('Enterβ†’OpenDesktop')
1254
1329
  : chalk.rgb(0, 200, 255)('Enterβ†’OpenCode')
1255
1330
  // πŸ“– Line 1: core navigation + sorting shortcuts
1256
- lines.push(chalk.dim(` ↑↓ Navigate β€’ `) + actionHint + chalk.dim(` β€’ `) + chalk.yellow('F') + chalk.dim(` Favorite β€’ R/Y/O/M/L/A/S/C/H/V/B/U Sort β€’ `) + chalk.yellow('T') + chalk.dim(` Tier β€’ `) + chalk.yellow('N') + chalk.dim(` Origin β€’ W↓/X↑ (${intervalSec}s) β€’ `) + chalk.rgb(255, 100, 50).bold('Z') + chalk.dim(` Mode β€’ `) + chalk.yellow('P') + chalk.dim(` Settings β€’ `) + chalk.rgb(0, 255, 80).bold('K') + chalk.dim(` Help`))
1331
+ lines.push(chalk.dim(` ↑↓ Navigate β€’ `) + actionHint + chalk.dim(` β€’ `) + chalk.yellow('F') + chalk.dim(` Favorite β€’ R/Y/O/M/L/A/S/C/H/V/B/U/`) + chalk.yellow('G') + chalk.dim(` Sort β€’ `) + chalk.yellow('T') + chalk.dim(` Tier β€’ `) + chalk.yellow('N') + chalk.dim(` Origin β€’ W↓/=↑ (${intervalSec}s) β€’ `) + chalk.rgb(255, 100, 50).bold('Z') + chalk.dim(` Mode β€’ `) + chalk.yellow('X') + chalk.dim(` Logs β€’ `) + chalk.yellow('P') + chalk.dim(` Settings β€’ `) + chalk.rgb(0, 255, 80).bold('K') + chalk.dim(` Help`))
1257
1332
  // πŸ“– Line 2: profiles, recommend, feature request, bug report, and extended hints β€” gives visibility to less-obvious features
1258
1333
  lines.push(chalk.dim(` `) + chalk.rgb(200, 150, 255).bold('⇧P') + chalk.dim(` Cycle profile β€’ `) + chalk.rgb(200, 150, 255).bold('⇧S') + chalk.dim(` Save profile β€’ `) + chalk.rgb(0, 200, 180).bold('Q') + chalk.dim(` Smart Recommend β€’ `) + chalk.rgb(57, 255, 20).bold('J') + chalk.dim(` Request feature β€’ `) + chalk.rgb(255, 87, 51).bold('I') + chalk.dim(` Report bug β€’ `) + chalk.yellow('E') + chalk.dim(`/`) + chalk.yellow('D') + chalk.dim(` Tier ↑↓ β€’ `) + chalk.yellow('Esc') + chalk.dim(` Close overlay β€’ Ctrl+C Exit`))
1259
- lines.push('')
1334
+ // πŸ“– Proxy status line β€” always rendered with explicit state (starting/running/failed/stopped)
1335
+ lines.push(renderProxyStatusLine(proxyStartupStatus, activeProxy))
1260
1336
  lines.push(
1261
1337
  chalk.rgb(255, 150, 200)(' Made with πŸ’– & β˜• by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
1262
1338
  chalk.dim(' β€’ ') +
@@ -1353,18 +1429,77 @@ async function ping(apiKey, modelId, providerKey, url) {
1353
1429
  })
1354
1430
  // πŸ“– Normalize all HTTP 2xx statuses to "200" so existing verdict/avg logic still works.
1355
1431
  const code = resp.status >= 200 && resp.status < 300 ? '200' : String(resp.status)
1356
- return { code, ms: Math.round(performance.now() - t0) }
1432
+ return {
1433
+ code,
1434
+ ms: Math.round(performance.now() - t0),
1435
+ quotaPercent: extractQuotaPercent(resp.headers),
1436
+ }
1357
1437
  } catch (err) {
1358
1438
  const isTimeout = err.name === 'AbortError'
1359
1439
  return {
1360
1440
  code: isTimeout ? '000' : 'ERR',
1361
- ms: isTimeout ? 'TIMEOUT' : Math.round(performance.now() - t0)
1441
+ ms: isTimeout ? 'TIMEOUT' : Math.round(performance.now() - t0),
1442
+ quotaPercent: null,
1362
1443
  }
1363
1444
  } finally {
1364
1445
  clearTimeout(timer)
1365
1446
  }
1366
1447
  }
1367
1448
 
1449
+ function getHeaderValue(headers, key) {
1450
+ if (!headers) return null
1451
+ if (typeof headers.get === 'function') return headers.get(key)
1452
+ return headers[key] ?? headers[key.toLowerCase()] ?? null
1453
+ }
1454
+
1455
+ function extractQuotaPercent(headers) {
1456
+ const variants = [
1457
+ ['x-ratelimit-remaining', 'x-ratelimit-limit'],
1458
+ ['x-ratelimit-remaining-requests', 'x-ratelimit-limit-requests'],
1459
+ ['ratelimit-remaining', 'ratelimit-limit'],
1460
+ ['ratelimit-remaining-requests', 'ratelimit-limit-requests'],
1461
+ ]
1462
+
1463
+ for (const [remainingKey, limitKey] of variants) {
1464
+ const remainingRaw = getHeaderValue(headers, remainingKey)
1465
+ const limitRaw = getHeaderValue(headers, limitKey)
1466
+ const remaining = parseFloat(remainingRaw)
1467
+ const limit = parseFloat(limitRaw)
1468
+ if (Number.isFinite(remaining) && Number.isFinite(limit) && limit > 0) {
1469
+ const pct = Math.round((remaining / limit) * 100)
1470
+ return Math.max(0, Math.min(100, pct))
1471
+ }
1472
+ }
1473
+
1474
+ return null
1475
+ }
1476
+
1477
+ // ─── Provider endpoint quota polling ─────────────────────────────────────────
1478
+ // πŸ“– Moved to lib/provider-quota-fetchers.js for modularity + SiliconFlow support.
1479
+ // πŸ“– parseOpenRouterResponse re-exported here for extractQuotaPercent usage.
1480
+
1481
+ async function fetchOpenRouterQuotaPercent(apiKey) {
1482
+ // Delegate to module; uses module-level cache + error backoff
1483
+ return _fetchProviderQuotaFromModule('openrouter', apiKey)
1484
+ }
1485
+
1486
+ async function fetchProviderQuotaPercent(providerKey, apiKey) {
1487
+ // Delegate to unified module entrypoint (handles openrouter + siliconflow)
1488
+ return _fetchProviderQuotaFromModule(providerKey, apiKey)
1489
+ }
1490
+
1491
+ async function getProviderQuotaPercentCached(providerKey, apiKey) {
1492
+ // The module already implements TTL cache and error backoff internally.
1493
+ // This wrapper preserves the existing call-site API.
1494
+ return fetchProviderQuotaPercent(providerKey, apiKey)
1495
+ }
1496
+
1497
+ function usagePlaceholderForProvider(providerKey) {
1498
+ // πŸ“– 'N/A' for providers with no reliable quota signal (unknown telemetry type),
1499
+ // πŸ“– '--' for providers that expose quota via headers or a dedicated endpoint.
1500
+ return isKnownQuotaTelemetry(providerKey) ? '--' : 'N/A'
1501
+ }
1502
+
1368
1503
  // ─── OpenCode integration ──────────────────────────────────────────────────────
1369
1504
  // πŸ“– Platform-specific config path
1370
1505
  const isWindows = process.platform === 'win32'
@@ -1441,8 +1576,8 @@ const PROVIDER_METADATA = {
1441
1576
  sambanova: {
1442
1577
  label: 'SambaNova',
1443
1578
  color: chalk.rgb(255, 165, 0),
1444
- signupUrl: 'https://sambanova.ai/developers',
1445
- signupHint: 'Developers portal β†’ Create API key',
1579
+ signupUrl: 'https://cloud.sambanova.ai/apis',
1580
+ signupHint: 'SambaCloud portal β†’ Create API key',
1446
1581
  rateLimits: 'Dev tier generous quota',
1447
1582
  },
1448
1583
  openrouter: {
@@ -1539,7 +1674,7 @@ const PROVIDER_METADATA = {
1539
1674
  qwen: {
1540
1675
  label: 'Alibaba Cloud (DashScope)',
1541
1676
  color: chalk.rgb(255, 140, 0),
1542
- signupUrl: 'https://dashscope.console.alibabacloud.com',
1677
+ signupUrl: 'https://modelstudio.console.alibabacloud.com',
1543
1678
  signupHint: 'Model Studio β†’ API Key β†’ Create (1M free tokens, 90 days)',
1544
1679
  rateLimits: '1M free tokens per model (Singapore region, 90 days)',
1545
1680
  },
@@ -1606,25 +1741,6 @@ function getOpenCodeConfigPath() {
1606
1741
  return OPENCODE_CONFIG
1607
1742
  }
1608
1743
 
1609
- function loadOpenCodeConfig() {
1610
- const configPath = getOpenCodeConfigPath()
1611
- if (!existsSync(configPath)) return { provider: {} }
1612
- try {
1613
- return JSON.parse(readFileSync(configPath, 'utf8'))
1614
- } catch {
1615
- return { provider: {} }
1616
- }
1617
- }
1618
-
1619
- function saveOpenCodeConfig(config) {
1620
- const configPath = getOpenCodeConfigPath()
1621
- const dir = dirname(configPath)
1622
- if (!existsSync(dir)) {
1623
- mkdirSync(dir, { recursive: true })
1624
- }
1625
- writeFileSync(configPath, JSON.stringify(config, null, 2))
1626
- }
1627
-
1628
1744
  // ─── Shared OpenCode spawn helper ──────────────────────────────────────────────
1629
1745
  // πŸ“– Resolves the actual API key from config/env and passes it as an env var
1630
1746
  // πŸ“– to the child process so OpenCode's {env:GROQ_API_KEY} references work
@@ -2099,6 +2215,223 @@ async function startOpenCode(model, fcmConfig) {
2099
2215
  }
2100
2216
  }
2101
2217
 
2218
+ // ─── Proxy lifecycle (multi-account rotation) ─────────────────────────────────
2219
+ // πŸ“– Module-level proxy state β€” shared between startProxyAndLaunch, cleanupProxy, and renderTable.
2220
+ let activeProxy = null // πŸ“– ProxyServer instance while proxy is running, null otherwise
2221
+ let proxyCleanedUp = false // πŸ“– Guards against double-cleanup on concurrent exit signals
2222
+ let exitHandlersRegistered = false // πŸ“– Guards against registering handlers multiple times
2223
+
2224
+ // πŸ“– cleanupProxy: Gracefully stops the active proxy server if one is running.
2225
+ // πŸ“– Called on OpenCode exit and on process exit signals.
2226
+ async function cleanupProxy() {
2227
+ if (proxyCleanedUp || !activeProxy) return
2228
+ proxyCleanedUp = true
2229
+ const proxy = activeProxy
2230
+ activeProxy = null
2231
+ try {
2232
+ await proxy.stop()
2233
+ } catch { /* best-effort */ }
2234
+ }
2235
+
2236
+ // πŸ“– registerExitHandlers: Ensures SIGINT/SIGTERM/exit handlers are registered exactly once.
2237
+ // πŸ“– Cleans up the proxy before the process exits so we don't leave a dangling HTTP server.
2238
+ function registerExitHandlers() {
2239
+ if (exitHandlersRegistered) return
2240
+ exitHandlersRegistered = true
2241
+ const cleanup = () => { cleanupProxy().catch(() => {}) }
2242
+ process.once('SIGINT', cleanup)
2243
+ process.once('SIGTERM', cleanup)
2244
+ process.once('exit', cleanup)
2245
+ }
2246
+
2247
+ // πŸ“– startProxyAndLaunch: Starts ProxyServer with N accounts and launches OpenCode via fcm-proxy.
2248
+ // πŸ“– Falls back to the normal direct flow if the proxy cannot start.
2249
+ function buildProxyTopologyFromConfig(fcmConfig) {
2250
+ const accounts = []
2251
+ const proxyModels = {}
2252
+
2253
+ for (const merged of mergedModels) {
2254
+ proxyModels[merged.slug] = { name: merged.label }
2255
+
2256
+ for (const providerEntry of merged.providers) {
2257
+ const keys = resolveApiKeys(fcmConfig, providerEntry.providerKey)
2258
+ const providerSource = sources[providerEntry.providerKey]
2259
+ if (!providerSource) continue
2260
+
2261
+ const rawUrl = resolveCloudflareUrl(providerSource.url)
2262
+ const baseUrl = rawUrl.replace(/\/chat\/completions$/, '')
2263
+
2264
+ keys.forEach((apiKey, keyIdx) => {
2265
+ accounts.push({
2266
+ id: `${providerEntry.providerKey}/${merged.slug}/${keyIdx}`,
2267
+ providerKey: providerEntry.providerKey,
2268
+ proxyModelId: merged.slug,
2269
+ modelId: providerEntry.modelId,
2270
+ url: baseUrl,
2271
+ apiKey,
2272
+ })
2273
+ })
2274
+ }
2275
+ }
2276
+
2277
+ return { accounts, proxyModels }
2278
+ }
2279
+
2280
+ async function ensureProxyRunning(fcmConfig, { forceRestart = false } = {}) {
2281
+ registerExitHandlers()
2282
+ proxyCleanedUp = false
2283
+
2284
+ if (forceRestart && activeProxy) {
2285
+ await cleanupProxy()
2286
+ }
2287
+
2288
+ const existingStatus = activeProxy?.getStatus?.()
2289
+ if (existingStatus?.running === true) {
2290
+ // Derive available slugs from the running proxy's accounts
2291
+ const availableModelSlugs = new Set(
2292
+ (activeProxy._accounts || []).map(a => a.proxyModelId).filter(Boolean)
2293
+ )
2294
+ return {
2295
+ port: existingStatus.port,
2296
+ accountCount: existingStatus.accountCount,
2297
+ proxyToken: activeProxy?._proxyApiKey,
2298
+ proxyModels: null,
2299
+ availableModelSlugs,
2300
+ }
2301
+ }
2302
+
2303
+ const { accounts, proxyModels } = buildProxyTopologyFromConfig(fcmConfig)
2304
+ if (accounts.length === 0) {
2305
+ throw new Error('No API keys found for proxy-capable models')
2306
+ }
2307
+
2308
+ const proxyToken = `fcm_${randomUUID().replace(/-/g, '')}`
2309
+ const proxy = new ProxyServer({ accounts, proxyApiKey: proxyToken })
2310
+ const { port } = await proxy.start()
2311
+ activeProxy = proxy
2312
+
2313
+ const availableModelSlugs = new Set(accounts.map(a => a.proxyModelId).filter(Boolean))
2314
+ return { port, accountCount: accounts.length, proxyToken, proxyModels, availableModelSlugs }
2315
+ }
2316
+
2317
+ // πŸ“– autoStartProxyIfSynced: Fire-and-forget startup orchestrator.
2318
+ // πŸ“– Reads OpenCode config; if fcm-proxy provider is present, starts the proxy.
2319
+ // πŸ“– Updates state.proxyStartupStatus with explicit transitions:
2320
+ // πŸ“– 'starting' β†’ 'running' (with port/accountCount) or 'failed' (with reason).
2321
+ // πŸ“– After the proxy starts, rewrites opencode.json with the runtime port/token so
2322
+ // πŸ“– OpenCode immediately points to the live proxy (not a stale persisted value).
2323
+ // πŸ“– Non-FCM providers and other top-level keys are preserved by mergeOcConfig.
2324
+ // πŸ“– Never throws β€” must not crash startup.
2325
+ async function autoStartProxyIfSynced(fcmConfig, state) {
2326
+ try {
2327
+ const ocConfig = loadOpenCodeConfig()
2328
+ if (!ocConfig?.provider?.['fcm-proxy']) {
2329
+ // πŸ“– No synced fcm-proxy entry β€” nothing to auto-start.
2330
+ return
2331
+ }
2332
+
2333
+ state.proxyStartupStatus = { phase: 'starting' }
2334
+
2335
+ const started = await ensureProxyRunning(fcmConfig)
2336
+
2337
+ // πŸ“– Rewrite opencode.json with the runtime port/token assigned by the OS.
2338
+ // πŸ“– This is safe: mergeOcConfig (called inside syncToOpenCode) preserves all
2339
+ // πŸ“– non-FCM providers (anthropic, openai, google, etc.) and other top-level
2340
+ // πŸ“– keys ($schema, mcp, plugin, command, model).
2341
+ syncToOpenCode(fcmConfig, sources, mergedModels, {
2342
+ proxyPort: started.port,
2343
+ proxyToken: started.proxyToken,
2344
+ availableModelSlugs: started.availableModelSlugs,
2345
+ })
2346
+
2347
+ state.proxyStartupStatus = {
2348
+ phase: 'running',
2349
+ port: started.port,
2350
+ accountCount: started.accountCount,
2351
+ }
2352
+ } catch (err) {
2353
+ state.proxyStartupStatus = {
2354
+ phase: 'failed',
2355
+ reason: err?.message ?? String(err),
2356
+ }
2357
+ }
2358
+ }
2359
+
2360
+ async function startProxyAndLaunch(model, fcmConfig) {
2361
+ try {
2362
+ const started = await ensureProxyRunning(fcmConfig, { forceRestart: true })
2363
+ const merged = mergedModelByLabel.get(model.label)
2364
+ const defaultProxyModelId = merged?.slug ?? model.modelId
2365
+
2366
+ if (!started.proxyModels || Object.keys(started.proxyModels).length === 0) {
2367
+ throw new Error('Proxy model catalog is empty')
2368
+ }
2369
+
2370
+ console.log(chalk.dim(` πŸ”€ Multi-account proxy listening on port ${started.port} (${started.accountCount} accounts)`))
2371
+ await startOpenCodeWithProxy(model, started.port, defaultProxyModelId, started.proxyModels, fcmConfig, started.proxyToken)
2372
+ } catch (err) {
2373
+ console.error(chalk.red(` βœ— Proxy failed to start: ${err.message}`))
2374
+ console.log(chalk.dim(' Falling back to direct single-account flow…'))
2375
+ await cleanupProxy()
2376
+ await startOpenCode(model, fcmConfig)
2377
+ }
2378
+ }
2379
+
2380
+ // πŸ“– startOpenCodeWithProxy: Registers fcm-proxy provider in OpenCode config,
2381
+ // πŸ“– spawns OpenCode with that provider, then removes the ephemeral config after exit.
2382
+ async function startOpenCodeWithProxy(model, port, proxyModelId, proxyModels, fcmConfig, proxyToken) {
2383
+ const config = loadOpenCodeConfig()
2384
+ if (!config.provider) config.provider = {}
2385
+ const previousProxyProvider = config.provider['fcm-proxy']
2386
+ const previousModel = config.model
2387
+
2388
+ const fallbackModelId = Object.keys(proxyModels)[0]
2389
+ const selectedProxyModelId = proxyModels[proxyModelId] ? proxyModelId : fallbackModelId
2390
+
2391
+ // πŸ“– Register ephemeral fcm-proxy provider pointing to our local proxy server
2392
+ config.provider['fcm-proxy'] = {
2393
+ npm: '@ai-sdk/openai-compatible',
2394
+ name: 'FCM Proxy',
2395
+ options: {
2396
+ baseURL: `http://127.0.0.1:${port}/v1`,
2397
+ apiKey: proxyToken
2398
+ },
2399
+ models: proxyModels
2400
+ }
2401
+ config.model = `fcm-proxy/${selectedProxyModelId}`
2402
+ saveOpenCodeConfig(config)
2403
+
2404
+ console.log(chalk.green(` Setting ${chalk.bold(model.label)} via proxy as default for OpenCode…`))
2405
+ console.log(chalk.dim(` Model: fcm-proxy/${selectedProxyModelId} β€’ Proxy: http://127.0.0.1:${port}/v1`))
2406
+ console.log(chalk.dim(` Catalog: ${Object.keys(proxyModels).length} models available via fcm-proxy`))
2407
+ console.log()
2408
+
2409
+ try {
2410
+ await spawnOpenCode(['--model', `fcm-proxy/${selectedProxyModelId}`], 'fcm-proxy', fcmConfig)
2411
+ } finally {
2412
+ // πŸ“– Best-effort cleanup: restore previous fcm-proxy/model values if they existed
2413
+ try {
2414
+ const savedCfg = loadOpenCodeConfig()
2415
+ if (!savedCfg.provider) savedCfg.provider = {}
2416
+
2417
+ if (previousProxyProvider) {
2418
+ savedCfg.provider['fcm-proxy'] = previousProxyProvider
2419
+ } else if (savedCfg.provider['fcm-proxy']) {
2420
+ delete savedCfg.provider['fcm-proxy']
2421
+ }
2422
+
2423
+ if (typeof previousModel === 'string' && previousModel.length > 0) {
2424
+ savedCfg.model = previousModel
2425
+ } else if (typeof savedCfg.model === 'string' && savedCfg.model.startsWith('fcm-proxy/')) {
2426
+ delete savedCfg.model
2427
+ }
2428
+
2429
+ saveOpenCodeConfig(savedCfg)
2430
+ } catch { /* best-effort */ }
2431
+ await cleanupProxy()
2432
+ }
2433
+ }
2434
+
2102
2435
  // ─── Start OpenCode Desktop ─────────────────────────────────────────────────────
2103
2436
  // πŸ“– startOpenCodeDesktop: Same config logic as startOpenCode, but opens the Desktop app.
2104
2437
  // πŸ“– OpenCode Desktop shares config at the same location as CLI.
@@ -2756,6 +3089,14 @@ async function main() {
2756
3089
  }))
2757
3090
  syncFavoriteFlags(results, config)
2758
3091
 
3092
+ // πŸ“– Load usage data from token-stats.json and attach usagePercent to each result row.
3093
+ // πŸ“– usagePercent is the quota percent remaining (0–100). undefined = no data available.
3094
+ // πŸ“– Freshness-aware: snapshots older than 30 minutes are excluded (shown as N/A in UI).
3095
+ for (const r of results) {
3096
+ const pct = _usageForRow(r.providerKey, r.modelId)
3097
+ r.usagePercent = typeof pct === 'number' ? pct : undefined
3098
+ }
3099
+
2759
3100
  // πŸ“– Clamp scrollOffset so cursor is always within the visible viewport window.
2760
3101
  // πŸ“– Called after every cursor move, sort change, and terminal resize.
2761
3102
  const adjustScrollOffset = (st) => {
@@ -2786,7 +3127,7 @@ async function main() {
2786
3127
  // πŸ“– Add interactive selection state - cursor index and user's choice
2787
3128
  // πŸ“– sortColumn: 'rank'|'tier'|'origin'|'model'|'ping'|'avg'|'status'|'verdict'|'uptime'
2788
3129
  // πŸ“– sortDirection: 'asc' (default) or 'desc'
2789
- // πŸ“– pingInterval: current interval in ms (default 2000, adjustable with W/X keys)
3130
+ // πŸ“– pingInterval: current interval in ms (default 2000, adjustable with W/= keys)
2790
3131
  // πŸ“– tierFilter: current tier filter letter (null = all, 'S' = S+/S, 'A' = A+/A/A-, etc.)
2791
3132
  const state = {
2792
3133
  results,
@@ -2796,7 +3137,7 @@ async function main() {
2796
3137
  selectedModel: null,
2797
3138
  sortColumn: 'avg',
2798
3139
  sortDirection: 'asc',
2799
- pingInterval: PING_INTERVAL, // πŸ“– Track current interval for W/X keys
3140
+ pingInterval: PING_INTERVAL, // πŸ“– Track current interval for W/= keys
2800
3141
  lastPingTime: Date.now(), // πŸ“– Track when last ping cycle started
2801
3142
  mode, // πŸ“– 'opencode' or 'openclaw' β€” controls Enter action
2802
3143
  scrollOffset: 0, // πŸ“– First visible model index in viewport
@@ -2804,8 +3145,10 @@ async function main() {
2804
3145
  // πŸ“– Settings screen state (P key opens it)
2805
3146
  settingsOpen: false, // πŸ“– Whether settings overlay is active
2806
3147
  settingsCursor: 0, // πŸ“– Which provider row is selected in settings
2807
- settingsEditMode: false, // πŸ“– Whether we're in inline key editing mode
3148
+ settingsEditMode: false, // πŸ“– Whether we're in inline key editing mode (edit primary key)
3149
+ settingsAddKeyMode: false, // πŸ“– Whether we're in add-key mode (append a new key to provider)
2808
3150
  settingsEditBuffer: '', // πŸ“– Typed characters for the API key being edited
3151
+ settingsErrorMsg: null, // πŸ“– Temporary error message to display in settings
2809
3152
  settingsTestResults: {}, // πŸ“– { providerKey: 'pending'|'ok'|'fail'|null }
2810
3153
  settingsUpdateState: 'idle', // πŸ“– 'idle'|'checking'|'available'|'up-to-date'|'error'|'installing'
2811
3154
  settingsUpdateLatestVersion: null, // πŸ“– Latest npm version discovered from manual check
@@ -2841,6 +3184,17 @@ async function main() {
2841
3184
  bugReportBuffer: '', // πŸ“– Typed characters for the bug report message
2842
3185
  bugReportStatus: 'idle', // πŸ“– 'idle'|'sending'|'success'|'error' β€” webhook send status
2843
3186
  bugReportError: null, // πŸ“– Last webhook error message
3187
+ // πŸ“– OpenCode sync status (S key in settings)
3188
+ settingsSyncStatus: null, // πŸ“– { type: 'success'|'error', msg: string } β€” shown in settings footer
3189
+ // πŸ“– Log page overlay state (X key opens it)
3190
+ logVisible: false, // πŸ“– Whether the log page overlay is active
3191
+ logScrollOffset: 0, // πŸ“– Vertical scroll offset for log overlay viewport
3192
+ // πŸ“– Proxy startup status β€” set by autoStartProxyIfSynced, consumed by Task 3 indicator
3193
+ // πŸ“– null = not configured/not attempted
3194
+ // πŸ“– { phase: 'starting' } β€” proxy start in progress
3195
+ // πŸ“– { phase: 'running', port, accountCount } β€” proxy is live
3196
+ // πŸ“– { phase: 'failed', reason } β€” proxy failed to start
3197
+ proxyStartupStatus: null, // πŸ“– Startup-phase proxy status (null | { phase, ...details })
2844
3198
  }
2845
3199
 
2846
3200
  // πŸ“– Re-clamp viewport on terminal resize
@@ -2849,6 +3203,12 @@ async function main() {
2849
3203
  adjustScrollOffset(state)
2850
3204
  })
2851
3205
 
3206
+ // πŸ“– Auto-start proxy on launch if OpenCode config already has an fcm-proxy provider.
3207
+ // πŸ“– Fire-and-forget: does not block UI startup. state.proxyStartupStatus is updated async.
3208
+ if (mode === 'opencode' || mode === 'opencode-desktop') {
3209
+ void autoStartProxyIfSynced(config, state)
3210
+ }
3211
+
2852
3212
  // πŸ“– Enter alternate screen β€” animation runs here, zero scrollback pollution
2853
3213
  process.stdout.write(ALT_ENTER)
2854
3214
 
@@ -2902,6 +3262,9 @@ async function main() {
2902
3262
 
2903
3263
  lines.push('')
2904
3264
  lines.push(` ${chalk.bold('βš™ Settings')} ${chalk.dim('β€” free-coding-models v' + LOCAL_VERSION)}`)
3265
+ if (state.settingsErrorMsg) {
3266
+ lines.push(` ${chalk.red.bold(state.settingsErrorMsg)}`)
3267
+ }
2905
3268
  lines.push('')
2906
3269
  lines.push(` ${chalk.bold('🧩 Providers')}`)
2907
3270
  lines.push(` ${chalk.dim(' ' + '─'.repeat(112))}`)
@@ -2914,16 +3277,24 @@ async function main() {
2914
3277
  const isCursor = i === state.settingsCursor
2915
3278
  const enabled = isProviderEnabled(state.config, pk)
2916
3279
  const keyVal = state.config.apiKeys?.[pk] ?? ''
3280
+ // πŸ“– Resolve all keys for this provider (for multi-key display)
3281
+ const allKeys = resolveApiKeys(state.config, pk)
3282
+ const keyCount = allKeys.length
2917
3283
 
2918
3284
  // πŸ“– Build API key display β€” mask most chars, show last 4
2919
3285
  let keyDisplay
2920
- if (state.settingsEditMode && isCursor) {
2921
- // πŸ“– Inline editing: show typed buffer with cursor indicator
2922
- keyDisplay = chalk.cyanBright(`${state.settingsEditBuffer || ''}▏`)
2923
- } else if (keyVal) {
2924
- const visible = keyVal.slice(-4)
2925
- const masked = 'β€’'.repeat(Math.min(16, Math.max(4, keyVal.length - 4)))
2926
- keyDisplay = chalk.dim(masked + visible)
3286
+ if ((state.settingsEditMode || state.settingsAddKeyMode) && isCursor) {
3287
+ // πŸ“– Inline editing/adding: show typed buffer with cursor indicator
3288
+ const modePrefix = state.settingsAddKeyMode ? chalk.dim('[+] ') : ''
3289
+ keyDisplay = chalk.cyanBright(`${modePrefix}${state.settingsEditBuffer || ''}▏`)
3290
+ } else if (keyCount > 0) {
3291
+ // πŸ“– Show the primary (first/string) key masked + count indicator for extras
3292
+ const primaryKey = allKeys[0]
3293
+ const visible = primaryKey.slice(-4)
3294
+ const masked = 'β€’'.repeat(Math.min(16, Math.max(4, primaryKey.length - 4)))
3295
+ const keyMasked = chalk.dim(masked + visible)
3296
+ const extra = keyCount > 1 ? chalk.cyan(` (+${keyCount - 1} more)`) : ''
3297
+ keyDisplay = keyMasked + extra
2927
3298
  } else {
2928
3299
  keyDisplay = chalk.dim('(no key set)')
2929
3300
  }
@@ -3021,7 +3392,12 @@ async function main() {
3021
3392
  if (state.settingsEditMode) {
3022
3393
  lines.push(chalk.dim(' Type API key β€’ Enter Save β€’ Esc Cancel'))
3023
3394
  } else {
3024
- lines.push(chalk.dim(' ↑↓ Navigate β€’ Enter Edit key / Toggle / Load profile β€’ Space Toggle β€’ T Test key β€’ U Updates β€’ ⌫ Delete profile β€’ Esc Close'))
3395
+ lines.push(chalk.dim(' ↑↓ Navigate β€’ Enter Edit key β€’ + Add key β€’ - Remove key β€’ Space Toggle β€’ T Test key β€’ S Syncβ†’OpenCode β€’ R Restore backup β€’ U Updates β€’ ⌫ Delete profile β€’ Esc Close'))
3396
+ }
3397
+ // πŸ“– Show sync/restore status message if set
3398
+ if (state.settingsSyncStatus) {
3399
+ const { type, msg } = state.settingsSyncStatus
3400
+ lines.push(type === 'success' ? chalk.greenBright(` ${msg}`) : chalk.yellow(` ${msg}`))
3025
3401
  }
3026
3402
  lines.push('')
3027
3403
 
@@ -3087,6 +3463,9 @@ async function main() {
3087
3463
  lines.push('')
3088
3464
  lines.push(` ${chalk.cyan('Up%')} Uptime β€” ratio of successful pings to total pings ${chalk.dim('Sort:')} ${chalk.yellow('U')}`)
3089
3465
  lines.push(` ${chalk.dim('If a model only works half the time, you\'ll waste time retrying. Higher = more reliable.')}`)
3466
+ lines.push('')
3467
+ lines.push(` ${chalk.cyan('Usage')} Quota percent remaining (from token-stats.json) ${chalk.dim('Sort:')} ${chalk.yellow('Shift+G')}`)
3468
+ lines.push(` ${chalk.dim('Shows how much of your quota is still available. Green = plenty left, red = running low.')}`)
3090
3469
 
3091
3470
  lines.push('')
3092
3471
  lines.push(` ${chalk.bold('Main TUI')}`)
@@ -3096,7 +3475,8 @@ async function main() {
3096
3475
  lines.push('')
3097
3476
  lines.push(` ${chalk.bold('Controls')}`)
3098
3477
  lines.push(` ${chalk.yellow('W')} Decrease ping interval (faster)`)
3099
- lines.push(` ${chalk.yellow('X')} Increase ping interval (slower)`)
3478
+ lines.push(` ${chalk.yellow('=')} Increase ping interval (slower) ${chalk.dim('(was X β€” X is now the log page)')}`)
3479
+ lines.push(` ${chalk.yellow('X')} Toggle request log page ${chalk.dim('(shows recent requests from request-log.jsonl)')}`)
3100
3480
  lines.push(` ${chalk.yellow('Z')} Cycle launch mode ${chalk.dim('(OpenCode CLI β†’ OpenCode Desktop β†’ OpenClaw)')}`)
3101
3481
  lines.push(` ${chalk.yellow('F')} Toggle favorite on selected row ${chalk.dim('(⭐ pinned at top, persisted)')}`)
3102
3482
  lines.push(` ${chalk.yellow('Q')} Smart Recommend ${chalk.dim('(🎯 find the best model for your task β€” questionnaire + live analysis)')}`)
@@ -3141,7 +3521,94 @@ async function main() {
3141
3521
  return cleared.join('\n')
3142
3522
  }
3143
3523
 
3144
- // ─── Smart Recommend overlay renderer ─────────────────────────────────────
3524
+ // ─── Log page overlay renderer ────────────────────────────────────────────
3525
+ // πŸ“– renderLog: Draw the log page overlay showing recent requests from
3526
+ // πŸ“– ~/.free-coding-models/request-log.jsonl, newest-first.
3527
+ // πŸ“– Toggled with X key. Esc or X closes.
3528
+ function renderLog() {
3529
+ const EL = '\x1b[K'
3530
+ const lines = []
3531
+ lines.push('')
3532
+ lines.push(` ${chalk.bold('πŸ“‹ Request Log')} ${chalk.dim('β€” recent requests β€’ ↑↓ scroll β€’ X or Esc close')}`)
3533
+ lines.push('')
3534
+
3535
+ // πŸ“– Load recent log entries β€” bounded read, newest-first, malformed lines skipped.
3536
+ const logRows = loadRecentLogs({ limit: 200 })
3537
+
3538
+ if (logRows.length === 0) {
3539
+ lines.push(chalk.dim(' No log entries found.'))
3540
+ lines.push(chalk.dim(' Logs are written to ~/.free-coding-models/request-log.jsonl'))
3541
+ lines.push(chalk.dim(' when requests are proxied through the multi-account rotation proxy.'))
3542
+ } else {
3543
+ // πŸ“– Column widths for the log table
3544
+ const W_TIME = 19
3545
+ const W_TYPE = 18
3546
+ const W_PROV = 14
3547
+ const W_MODEL = 36
3548
+ const W_STATUS = 8
3549
+ const W_TOKENS = 9
3550
+ const W_LAT = 10
3551
+
3552
+ // πŸ“– Header row
3553
+ const hTime = chalk.dim('Time'.padEnd(W_TIME))
3554
+ const hType = chalk.dim('Type'.padEnd(W_TYPE))
3555
+ const hProv = chalk.dim('Provider'.padEnd(W_PROV))
3556
+ const hModel = chalk.dim('Model'.padEnd(W_MODEL))
3557
+ const hStatus = chalk.dim('Status'.padEnd(W_STATUS))
3558
+ const hTok = chalk.dim('Tokens'.padEnd(W_TOKENS))
3559
+ const hLat = chalk.dim('Latency'.padEnd(W_LAT))
3560
+ lines.push(` ${hTime} ${hType} ${hProv} ${hModel} ${hStatus} ${hTok} ${hLat}`)
3561
+ lines.push(chalk.dim(' ' + '─'.repeat(W_TIME + W_TYPE + W_PROV + W_MODEL + W_STATUS + W_TOKENS + W_LAT + 12)))
3562
+
3563
+ for (const row of logRows) {
3564
+ // πŸ“– Format time as HH:MM:SS (strip the date part for compactness)
3565
+ let timeStr = row.time
3566
+ try {
3567
+ const d = new Date(row.time)
3568
+ if (!Number.isNaN(d.getTime())) {
3569
+ timeStr = d.toISOString().replace('T', ' ').slice(0, 19)
3570
+ }
3571
+ } catch { /* keep raw */ }
3572
+
3573
+ // πŸ“– Color-code status
3574
+ let statusCell
3575
+ const sc = String(row.status)
3576
+ if (sc === '200') {
3577
+ statusCell = chalk.greenBright(sc.padEnd(W_STATUS))
3578
+ } else if (sc === '429') {
3579
+ statusCell = chalk.yellow(sc.padEnd(W_STATUS))
3580
+ } else if (sc.startsWith('5') || sc === 'error') {
3581
+ statusCell = chalk.red(sc.padEnd(W_STATUS))
3582
+ } else {
3583
+ statusCell = chalk.dim(sc.padEnd(W_STATUS))
3584
+ }
3585
+
3586
+ const tokStr = row.tokens > 0 ? String(row.tokens) : '--'
3587
+ const latStr = row.latency > 0 ? `${row.latency}ms` : '--'
3588
+
3589
+ const timeCell = chalk.dim(timeStr.slice(0, W_TIME).padEnd(W_TIME))
3590
+ const typeCell = chalk.magenta((row.requestType || '--').slice(0, W_TYPE).padEnd(W_TYPE))
3591
+ const provCell = chalk.cyan(row.provider.slice(0, W_PROV).padEnd(W_PROV))
3592
+ const modelCell = chalk.white(row.model.slice(0, W_MODEL).padEnd(W_MODEL))
3593
+ const tokCell = chalk.dim(tokStr.padEnd(W_TOKENS))
3594
+ const latCell = chalk.dim(latStr.padEnd(W_LAT))
3595
+
3596
+ lines.push(` ${timeCell} ${typeCell} ${provCell} ${modelCell} ${statusCell} ${tokCell} ${latCell}`)
3597
+ }
3598
+ }
3599
+
3600
+ lines.push('')
3601
+ lines.push(chalk.dim(` Showing up to 200 most recent entries β€’ X or Esc close`))
3602
+ lines.push('')
3603
+
3604
+ const { visible, offset } = sliceOverlayLines(lines, state.logScrollOffset, state.terminalRows)
3605
+ state.logScrollOffset = offset
3606
+ const tintedLines = tintOverlayLines(visible, LOG_OVERLAY_BG)
3607
+ const cleared = tintedLines.map(l => l + EL)
3608
+ return cleared.join('\n')
3609
+ }
3610
+
3611
+
3145
3612
  // πŸ“– renderRecommend: Draw the Smart Recommend overlay with 3 phases:
3146
3613
  // 1. 'questionnaire' β€” ask 3 questions (task type, priority, context budget)
3147
3614
  // 2. 'analyzing' β€” loading screen with progress bar (10s, 2 pings/sec)
@@ -3819,6 +4286,23 @@ async function main() {
3819
4286
  return
3820
4287
  }
3821
4288
 
4289
+ // πŸ“– Log page overlay: full keyboard navigation + key swallowing while overlay is open.
4290
+ if (state.logVisible) {
4291
+ const pageStep = Math.max(1, (state.terminalRows || 1) - 2)
4292
+ if (key.name === 'escape' || key.name === 'x') {
4293
+ state.logVisible = false
4294
+ return
4295
+ }
4296
+ if (key.name === 'up') { state.logScrollOffset = Math.max(0, state.logScrollOffset - 1); return }
4297
+ if (key.name === 'down') { state.logScrollOffset += 1; return }
4298
+ if (key.name === 'pageup') { state.logScrollOffset = Math.max(0, state.logScrollOffset - pageStep); return }
4299
+ if (key.name === 'pagedown') { state.logScrollOffset += pageStep; return }
4300
+ if (key.name === 'home') { state.logScrollOffset = 0; return }
4301
+ if (key.name === 'end') { state.logScrollOffset = Number.MAX_SAFE_INTEGER; return }
4302
+ if (key.ctrl && key.name === 'c') { exit(0); return }
4303
+ return
4304
+ }
4305
+
3822
4306
  // πŸ“– Smart Recommend overlay: full keyboard handling while overlay is open.
3823
4307
  if (state.recommendOpen) {
3824
4308
  if (key.ctrl && key.name === 'c') { exit(0); return }
@@ -3935,21 +4419,39 @@ async function main() {
3935
4419
  const profileStartIdx = updateRowIdx + 1
3936
4420
  const maxRowIdx = savedProfiles.length > 0 ? profileStartIdx + savedProfiles.length - 1 : updateRowIdx
3937
4421
 
3938
- // πŸ“– Edit mode: capture typed characters for the API key
3939
- if (state.settingsEditMode) {
4422
+ // πŸ“– Edit/Add-key mode: capture typed characters for the API key
4423
+ if (state.settingsEditMode || state.settingsAddKeyMode) {
3940
4424
  if (key.name === 'return') {
3941
- // πŸ“– Save the new key and exit edit mode
4425
+ // πŸ“– Save the new key and exit edit/add mode
3942
4426
  const pk = providerKeys[state.settingsCursor]
3943
4427
  const newKey = state.settingsEditBuffer.trim()
3944
4428
  if (newKey) {
3945
- state.config.apiKeys[pk] = newKey
4429
+ // πŸ“– Validate OpenRouter keys start with "sk-or-" to detect corruption
4430
+ if (pk === 'openrouter' && !newKey.startsWith('sk-or-')) {
4431
+ // πŸ“– Don't save corrupted keys - show warning and cancel
4432
+ state.settingsEditMode = false
4433
+ state.settingsAddKeyMode = false
4434
+ state.settingsEditBuffer = ''
4435
+ state.settingsErrorMsg = '⚠️ OpenRouter keys must start with "sk-or-". Key not saved.'
4436
+ setTimeout(() => { state.settingsErrorMsg = null }, 3000)
4437
+ return
4438
+ }
4439
+ if (state.settingsAddKeyMode) {
4440
+ // πŸ“– Add-key mode: append new key (addApiKey handles duplicates/empty)
4441
+ addApiKey(state.config, pk, newKey)
4442
+ } else {
4443
+ // πŸ“– Edit mode: replace the primary key (string-level)
4444
+ state.config.apiKeys[pk] = newKey
4445
+ }
3946
4446
  saveConfig(state.config)
3947
4447
  }
3948
4448
  state.settingsEditMode = false
4449
+ state.settingsAddKeyMode = false
3949
4450
  state.settingsEditBuffer = ''
3950
4451
  } else if (key.name === 'escape') {
3951
4452
  // πŸ“– Cancel without saving
3952
4453
  state.settingsEditMode = false
4454
+ state.settingsAddKeyMode = false
3953
4455
  state.settingsEditBuffer = ''
3954
4456
  } else if (key.name === 'backspace') {
3955
4457
  state.settingsEditBuffer = state.settingsEditBuffer.slice(0, -1)
@@ -3964,6 +4466,10 @@ async function main() {
3964
4466
  if (key.name === 'escape' || key.name === 'p') {
3965
4467
  // πŸ“– Close settings β€” rebuild results to reflect provider changes
3966
4468
  state.settingsOpen = false
4469
+ state.settingsEditMode = false
4470
+ state.settingsAddKeyMode = false
4471
+ state.settingsEditBuffer = ''
4472
+ state.settingsSyncStatus = null // πŸ“– Clear sync status on close
3967
4473
  // πŸ“– Rebuild results: add models from newly enabled providers, remove disabled
3968
4474
  results = MODELS
3969
4475
  .filter(([,,,,,pk]) => isProviderEnabled(state.config, pk))
@@ -4124,6 +4630,63 @@ async function main() {
4124
4630
  }
4125
4631
 
4126
4632
  if (key.ctrl && key.name === 'c') { exit(0); return }
4633
+
4634
+ // πŸ“– S key: sync FCM provider entries to OpenCode config (merge, don't replace)
4635
+ if (key.name === 's' && !key.shift && !key.ctrl) {
4636
+ try {
4637
+ // πŸ“– Sync now also ensures proxy is running, so OpenCode can use fcm-proxy immediately.
4638
+ const started = await ensureProxyRunning(state.config)
4639
+ const result = syncToOpenCode(state.config, sources, mergedModels, {
4640
+ proxyPort: started.port,
4641
+ proxyToken: started.proxyToken,
4642
+ availableModelSlugs: started.availableModelSlugs,
4643
+ })
4644
+ state.settingsSyncStatus = {
4645
+ type: 'success',
4646
+ msg: `βœ… Synced ${result.providerKey} (${result.modelCount} models), proxy running on :${started.port}`,
4647
+ }
4648
+ } catch (err) {
4649
+ state.settingsSyncStatus = { type: 'error', msg: `❌ Sync failed: ${err.message}` }
4650
+ }
4651
+ return
4652
+ }
4653
+
4654
+ // πŸ“– R key: restore OpenCode config from backup (opencode.json.bak)
4655
+ if (key.name === 'r' && !key.shift && !key.ctrl) {
4656
+ try {
4657
+ const restored = restoreOpenCodeBackup()
4658
+ state.settingsSyncStatus = restored
4659
+ ? { type: 'success', msg: 'βœ… OpenCode config restored from backup' }
4660
+ : { type: 'error', msg: '⚠ No backup found (opencode.json.bak)' }
4661
+ } catch (err) {
4662
+ state.settingsSyncStatus = { type: 'error', msg: `❌ Restore failed: ${err.message}` }
4663
+ }
4664
+ return
4665
+ }
4666
+
4667
+ // πŸ“– + key: open add-key input (empty buffer) β€” appends new key on Enter
4668
+ if ((str === '+' || key.name === '+') && state.settingsCursor < providerKeys.length) {
4669
+ state.settingsEditBuffer = '' // πŸ“– Start with empty buffer (not existing key)
4670
+ state.settingsAddKeyMode = true // πŸ“– Add mode: Enter will append, not replace
4671
+ state.settingsEditMode = false
4672
+ return
4673
+ }
4674
+
4675
+ // πŸ“– - key: remove one key (last by default) instead of deleting entire provider
4676
+ if ((str === '-' || key.name === '-') && state.settingsCursor < providerKeys.length) {
4677
+ const pk = providerKeys[state.settingsCursor]
4678
+ const removed = removeApiKey(state.config, pk) // removes last key; collapses array-of-1 to string
4679
+ if (removed) {
4680
+ saveConfig(state.config)
4681
+ const remaining = resolveApiKeys(state.config, pk).length
4682
+ const msg = remaining > 0
4683
+ ? `βœ… Removed one key for ${pk} (${remaining} remaining)`
4684
+ : `βœ… Removed last API key for ${pk}`
4685
+ state.settingsSyncStatus = { type: 'success', msg }
4686
+ }
4687
+ return
4688
+ }
4689
+
4127
4690
  return // πŸ“– Swallow all other keys while settings is open
4128
4691
  }
4129
4692
 
@@ -4132,6 +4695,7 @@ async function main() {
4132
4695
  state.settingsOpen = true
4133
4696
  state.settingsCursor = 0
4134
4697
  state.settingsEditMode = false
4698
+ state.settingsAddKeyMode = false
4135
4699
  state.settingsEditBuffer = ''
4136
4700
  state.settingsScrollOffset = 0
4137
4701
  return
@@ -4211,6 +4775,7 @@ async function main() {
4211
4775
  // πŸ“– Sorting keys: R=rank, Y=tier, O=origin, M=model, L=latest ping, A=avg ping, S=SWE-bench, C=context, H=health, V=verdict, B=stability, U=uptime
4212
4776
  // πŸ“– T is reserved for tier filter cycling β€” tier sort moved to Y
4213
4777
  // πŸ“– N is now reserved for origin filter cycling
4778
+ // πŸ“– G (Shift+G) is handled separately below for usage sort
4214
4779
  const sortKeys = {
4215
4780
  'r': 'rank', 'y': 'tier', 'o': 'origin', 'm': 'model',
4216
4781
  'l': 'ping', 'a': 'avg', 's': 'swe', 'c': 'ctx', 'h': 'condition', 'v': 'verdict', 'b': 'stability', 'u': 'uptime'
@@ -4233,6 +4798,22 @@ async function main() {
4233
4798
  return
4234
4799
  }
4235
4800
 
4801
+ // πŸ“– Shift+G: sort by usage (quota percent remaining from token-stats.json)
4802
+ if (key.name === 'g' && key.shift && !key.ctrl) {
4803
+ const col = 'usage'
4804
+ if (state.sortColumn === col) {
4805
+ state.sortDirection = state.sortDirection === 'asc' ? 'desc' : 'asc'
4806
+ } else {
4807
+ state.sortColumn = col
4808
+ state.sortDirection = 'asc'
4809
+ }
4810
+ const visible = state.results.filter(r => !r.hidden)
4811
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
4812
+ state.cursor = 0
4813
+ state.scrollOffset = 0
4814
+ return
4815
+ }
4816
+
4236
4817
  // πŸ“– F key: toggle favorite on the currently selected row and persist to config.
4237
4818
  if (key.name === 'f') {
4238
4819
  const selected = state.visibleSorted[state.cursor]
@@ -4277,11 +4858,12 @@ async function main() {
4277
4858
  return
4278
4859
  }
4279
4860
 
4280
- // πŸ“– Interval adjustment keys: W=decrease (faster), X=increase (slower)
4861
+ // πŸ“– Interval adjustment keys: W=decrease (faster), ==increase (slower)
4862
+ // πŸ“– X was previously used for interval increase but is now reserved for the log page overlay.
4281
4863
  // πŸ“– Minimum 1s, maximum 60s
4282
4864
  if (key.name === 'w') {
4283
4865
  state.pingInterval = Math.max(1000, state.pingInterval - 1000)
4284
- } else if (key.name === 'x') {
4866
+ } else if (str === '=' || key.name === '=') {
4285
4867
  state.pingInterval = Math.min(60000, state.pingInterval + 1000)
4286
4868
  }
4287
4869
 
@@ -4325,8 +4907,11 @@ async function main() {
4325
4907
  return
4326
4908
  }
4327
4909
 
4910
+ // πŸ“– X key: toggle the log page overlay (shows recent requests from request-log.jsonl).
4911
+ // πŸ“– NOTE: X was previously used for ping-interval increase; that binding moved to '='.
4328
4912
  if (key.name === 'x') {
4329
- state.pingInterval = Math.min(60000, state.pingInterval + 1000)
4913
+ state.logVisible = !state.logVisible
4914
+ if (state.logVisible) state.logScrollOffset = 0
4330
4915
  return
4331
4916
  }
4332
4917
 
@@ -4396,7 +4981,14 @@ async function main() {
4396
4981
  } else if (state.mode === 'opencode-desktop') {
4397
4982
  await startOpenCodeDesktop(userSelected, state.config)
4398
4983
  } else {
4399
- await startOpenCode(userSelected, state.config)
4984
+ const topology = buildProxyTopologyFromConfig(state.config)
4985
+ if (topology.accounts.length === 0) {
4986
+ console.log(chalk.yellow(` No API keys found for proxy model catalog. Falling back to direct flow.`))
4987
+ console.log()
4988
+ await startOpenCode(userSelected, state.config)
4989
+ } else {
4990
+ await startProxyAndLaunch(userSelected, state.config)
4991
+ }
4400
4992
  }
4401
4993
  process.exit(0)
4402
4994
  }
@@ -4428,7 +5020,9 @@ async function main() {
4428
5020
  ? renderBugReport()
4429
5021
  : state.helpVisible
4430
5022
  ? renderHelp()
4431
- : renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, tierFilterMode, state.scrollOffset, state.terminalRows, originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer)
5023
+ : state.logVisible
5024
+ ? renderLog()
5025
+ : renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, tierFilterMode, state.scrollOffset, state.terminalRows, originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus)
4432
5026
  process.stdout.write(ALT_HOME + content)
4433
5027
  }, Math.round(1000 / FPS))
4434
5028
 
@@ -4436,7 +5030,7 @@ async function main() {
4436
5030
  const initialVisible = state.results.filter(r => !r.hidden)
4437
5031
  state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection)
4438
5032
 
4439
- process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, tierFilterMode, state.scrollOffset, state.terminalRows, originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer))
5033
+ process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, tierFilterMode, state.scrollOffset, state.terminalRows, originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus))
4440
5034
 
4441
5035
  // πŸ“– If --recommend was passed, auto-open the Smart Recommend overlay on start
4442
5036
  if (cliArgs.recommendMode) {
@@ -4458,7 +5052,14 @@ async function main() {
4458
5052
  const pingModel = async (r) => {
4459
5053
  const providerApiKey = getApiKey(state.config, r.providerKey) ?? null
4460
5054
  const providerUrl = sources[r.providerKey]?.url ?? sources.nvidia.url
4461
- const { code, ms } = await ping(providerApiKey, r.modelId, r.providerKey, providerUrl)
5055
+ let { code, ms, quotaPercent } = await ping(providerApiKey, r.modelId, r.providerKey, providerUrl)
5056
+
5057
+ if ((quotaPercent === null || quotaPercent === undefined) && providerApiKey) {
5058
+ const providerQuota = await getProviderQuotaPercentCached(r.providerKey, providerApiKey)
5059
+ if (typeof providerQuota === 'number' && Number.isFinite(providerQuota)) {
5060
+ quotaPercent = providerQuota
5061
+ }
5062
+ }
4462
5063
 
4463
5064
  // πŸ“– Store ping result as object with ms and code
4464
5065
  // πŸ“– ms = actual response time (even for errors like 429)
@@ -4479,16 +5080,38 @@ async function main() {
4479
5080
  r.status = 'down'
4480
5081
  r.httpCode = code
4481
5082
  }
5083
+
5084
+ if (typeof quotaPercent === 'number' && Number.isFinite(quotaPercent)) {
5085
+ r.usagePercent = quotaPercent
5086
+ // Provider-level fallback: apply latest known quota to sibling rows on same provider.
5087
+ for (const sibling of state.results) {
5088
+ if (sibling.providerKey === r.providerKey && (sibling.usagePercent === undefined || sibling.usagePercent === null)) {
5089
+ sibling.usagePercent = quotaPercent
5090
+ }
5091
+ }
5092
+ }
4482
5093
  }
4483
5094
 
4484
5095
  // πŸ“– Initial ping of all models
4485
5096
  const initialPing = Promise.all(state.results.map(r => pingModel(r)))
4486
5097
 
4487
- // πŸ“– Continuous ping loop with dynamic interval (adjustable with W/X keys)
5098
+ // πŸ“– Continuous ping loop with dynamic interval (adjustable with W/= keys)
4488
5099
  const schedulePing = () => {
4489
5100
  state.pingIntervalObj = setTimeout(async () => {
4490
5101
  state.lastPingTime = Date.now()
4491
5102
 
5103
+ // πŸ“– Refresh persisted usage snapshots each cycle so proxy writes appear live in table.
5104
+ // πŸ“– Freshness-aware: stale snapshots (>30m) are excluded and row reverts to undefined.
5105
+ for (const r of state.results) {
5106
+ const pct = _usageForRow(r.providerKey, r.modelId)
5107
+ if (typeof pct === 'number' && Number.isFinite(pct)) {
5108
+ r.usagePercent = pct
5109
+ } else {
5110
+ // If snapshot is now stale or gone, clear the cached value so UI shows N/A.
5111
+ r.usagePercent = undefined
5112
+ }
5113
+ }
5114
+
4492
5115
  state.results.forEach(r => {
4493
5116
  pingModel(r).catch(() => {
4494
5117
  // Individual ping failures don't crash the loop
@@ -4508,7 +5131,7 @@ async function main() {
4508
5131
 
4509
5132
  // πŸ“– Keep interface running forever - user can select anytime or Ctrl+C to exit
4510
5133
  // πŸ“– The pings continue running in background with dynamic interval
4511
- // πŸ“– User can press W to decrease interval (faster pings) or X to increase (slower)
5134
+ // πŸ“– User can press W to decrease interval (faster pings) or = to increase (slower)
4512
5135
  // πŸ“– Current interval shown in header: "next ping Xs"
4513
5136
  }
4514
5137