free-coding-models 0.1.82 β 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.
- package/README.md +53 -40
- package/bin/free-coding-models.js +676 -66
- package/lib/account-manager.js +600 -0
- package/lib/config.js +122 -0
- package/lib/error-classifier.js +154 -0
- package/lib/log-reader.js +174 -0
- package/lib/model-merger.js +78 -0
- package/lib/opencode-sync.js +159 -0
- package/lib/provider-quota-fetchers.js +319 -0
- package/lib/proxy-server.js +543 -0
- package/lib/quota-capabilities.js +79 -0
- package/lib/request-transformer.js +180 -0
- package/lib/token-stats.js +242 -0
- package/lib/usage-reader.js +203 -0
- package/lib/utils.js +55 -0
- package/package.json +1 -1
- package/sources.js +3 -2
|
@@ -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
|
-
// π
|
|
246
|
-
config.telemetry.enabled
|
|
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
|
|
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
|
|
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 '
|
|
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.
|
|
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.
|
|
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
|
-
// π
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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'
|
|
@@ -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
|
-
|
|
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
|
|
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,7 +3145,8 @@ 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
|
|
2809
3151
|
settingsErrorMsg: null, // π Temporary error message to display in settings
|
|
2810
3152
|
settingsTestResults: {}, // π { providerKey: 'pending'|'ok'|'fail'|null }
|
|
@@ -2842,6 +3184,17 @@ async function main() {
|
|
|
2842
3184
|
bugReportBuffer: '', // π Typed characters for the bug report message
|
|
2843
3185
|
bugReportStatus: 'idle', // π 'idle'|'sending'|'success'|'error' β webhook send status
|
|
2844
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 })
|
|
2845
3198
|
}
|
|
2846
3199
|
|
|
2847
3200
|
// π Re-clamp viewport on terminal resize
|
|
@@ -2850,6 +3203,12 @@ async function main() {
|
|
|
2850
3203
|
adjustScrollOffset(state)
|
|
2851
3204
|
})
|
|
2852
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
|
+
|
|
2853
3212
|
// π Enter alternate screen β animation runs here, zero scrollback pollution
|
|
2854
3213
|
process.stdout.write(ALT_ENTER)
|
|
2855
3214
|
|
|
@@ -2918,16 +3277,24 @@ async function main() {
|
|
|
2918
3277
|
const isCursor = i === state.settingsCursor
|
|
2919
3278
|
const enabled = isProviderEnabled(state.config, pk)
|
|
2920
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
|
|
2921
3283
|
|
|
2922
3284
|
// π Build API key display β mask most chars, show last 4
|
|
2923
3285
|
let keyDisplay
|
|
2924
|
-
if (state.settingsEditMode && isCursor) {
|
|
2925
|
-
// π Inline editing: show typed buffer with cursor indicator
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
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
|
|
2931
3298
|
} else {
|
|
2932
3299
|
keyDisplay = chalk.dim('(no key set)')
|
|
2933
3300
|
}
|
|
@@ -3025,7 +3392,12 @@ async function main() {
|
|
|
3025
3392
|
if (state.settingsEditMode) {
|
|
3026
3393
|
lines.push(chalk.dim(' Type API key β’ Enter Save β’ Esc Cancel'))
|
|
3027
3394
|
} else {
|
|
3028
|
-
lines.push(chalk.dim(' ββ Navigate β’ Enter Edit key
|
|
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}`))
|
|
3029
3401
|
}
|
|
3030
3402
|
lines.push('')
|
|
3031
3403
|
|
|
@@ -3091,6 +3463,9 @@ async function main() {
|
|
|
3091
3463
|
lines.push('')
|
|
3092
3464
|
lines.push(` ${chalk.cyan('Up%')} Uptime β ratio of successful pings to total pings ${chalk.dim('Sort:')} ${chalk.yellow('U')}`)
|
|
3093
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.')}`)
|
|
3094
3469
|
|
|
3095
3470
|
lines.push('')
|
|
3096
3471
|
lines.push(` ${chalk.bold('Main TUI')}`)
|
|
@@ -3100,7 +3475,8 @@ async function main() {
|
|
|
3100
3475
|
lines.push('')
|
|
3101
3476
|
lines.push(` ${chalk.bold('Controls')}`)
|
|
3102
3477
|
lines.push(` ${chalk.yellow('W')} Decrease ping interval (faster)`)
|
|
3103
|
-
lines.push(` ${chalk.yellow('
|
|
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)')}`)
|
|
3104
3480
|
lines.push(` ${chalk.yellow('Z')} Cycle launch mode ${chalk.dim('(OpenCode CLI β OpenCode Desktop β OpenClaw)')}`)
|
|
3105
3481
|
lines.push(` ${chalk.yellow('F')} Toggle favorite on selected row ${chalk.dim('(β pinned at top, persisted)')}`)
|
|
3106
3482
|
lines.push(` ${chalk.yellow('Q')} Smart Recommend ${chalk.dim('(π― find the best model for your task β questionnaire + live analysis)')}`)
|
|
@@ -3145,7 +3521,94 @@ async function main() {
|
|
|
3145
3521
|
return cleared.join('\n')
|
|
3146
3522
|
}
|
|
3147
3523
|
|
|
3148
|
-
// βββ
|
|
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
|
+
|
|
3149
3612
|
// π renderRecommend: Draw the Smart Recommend overlay with 3 phases:
|
|
3150
3613
|
// 1. 'questionnaire' β ask 3 questions (task type, priority, context budget)
|
|
3151
3614
|
// 2. 'analyzing' β loading screen with progress bar (10s, 2 pings/sec)
|
|
@@ -3823,6 +4286,23 @@ async function main() {
|
|
|
3823
4286
|
return
|
|
3824
4287
|
}
|
|
3825
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
|
+
|
|
3826
4306
|
// π Smart Recommend overlay: full keyboard handling while overlay is open.
|
|
3827
4307
|
if (state.recommendOpen) {
|
|
3828
4308
|
if (key.ctrl && key.name === 'c') { exit(0); return }
|
|
@@ -3939,10 +4419,10 @@ async function main() {
|
|
|
3939
4419
|
const profileStartIdx = updateRowIdx + 1
|
|
3940
4420
|
const maxRowIdx = savedProfiles.length > 0 ? profileStartIdx + savedProfiles.length - 1 : updateRowIdx
|
|
3941
4421
|
|
|
3942
|
-
// π Edit mode: capture typed characters for the API key
|
|
3943
|
-
if (state.settingsEditMode) {
|
|
4422
|
+
// π Edit/Add-key mode: capture typed characters for the API key
|
|
4423
|
+
if (state.settingsEditMode || state.settingsAddKeyMode) {
|
|
3944
4424
|
if (key.name === 'return') {
|
|
3945
|
-
// π Save the new key and exit edit mode
|
|
4425
|
+
// π Save the new key and exit edit/add mode
|
|
3946
4426
|
const pk = providerKeys[state.settingsCursor]
|
|
3947
4427
|
const newKey = state.settingsEditBuffer.trim()
|
|
3948
4428
|
if (newKey) {
|
|
@@ -3950,19 +4430,28 @@ async function main() {
|
|
|
3950
4430
|
if (pk === 'openrouter' && !newKey.startsWith('sk-or-')) {
|
|
3951
4431
|
// π Don't save corrupted keys - show warning and cancel
|
|
3952
4432
|
state.settingsEditMode = false
|
|
4433
|
+
state.settingsAddKeyMode = false
|
|
3953
4434
|
state.settingsEditBuffer = ''
|
|
3954
4435
|
state.settingsErrorMsg = 'β οΈ OpenRouter keys must start with "sk-or-". Key not saved.'
|
|
3955
4436
|
setTimeout(() => { state.settingsErrorMsg = null }, 3000)
|
|
3956
4437
|
return
|
|
3957
4438
|
}
|
|
3958
|
-
state.
|
|
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
|
+
}
|
|
3959
4446
|
saveConfig(state.config)
|
|
3960
4447
|
}
|
|
3961
4448
|
state.settingsEditMode = false
|
|
4449
|
+
state.settingsAddKeyMode = false
|
|
3962
4450
|
state.settingsEditBuffer = ''
|
|
3963
4451
|
} else if (key.name === 'escape') {
|
|
3964
4452
|
// π Cancel without saving
|
|
3965
4453
|
state.settingsEditMode = false
|
|
4454
|
+
state.settingsAddKeyMode = false
|
|
3966
4455
|
state.settingsEditBuffer = ''
|
|
3967
4456
|
} else if (key.name === 'backspace') {
|
|
3968
4457
|
state.settingsEditBuffer = state.settingsEditBuffer.slice(0, -1)
|
|
@@ -3977,6 +4466,10 @@ async function main() {
|
|
|
3977
4466
|
if (key.name === 'escape' || key.name === 'p') {
|
|
3978
4467
|
// π Close settings β rebuild results to reflect provider changes
|
|
3979
4468
|
state.settingsOpen = false
|
|
4469
|
+
state.settingsEditMode = false
|
|
4470
|
+
state.settingsAddKeyMode = false
|
|
4471
|
+
state.settingsEditBuffer = ''
|
|
4472
|
+
state.settingsSyncStatus = null // π Clear sync status on close
|
|
3980
4473
|
// π Rebuild results: add models from newly enabled providers, remove disabled
|
|
3981
4474
|
results = MODELS
|
|
3982
4475
|
.filter(([,,,,,pk]) => isProviderEnabled(state.config, pk))
|
|
@@ -4137,6 +4630,63 @@ async function main() {
|
|
|
4137
4630
|
}
|
|
4138
4631
|
|
|
4139
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
|
+
|
|
4140
4690
|
return // π Swallow all other keys while settings is open
|
|
4141
4691
|
}
|
|
4142
4692
|
|
|
@@ -4145,6 +4695,7 @@ async function main() {
|
|
|
4145
4695
|
state.settingsOpen = true
|
|
4146
4696
|
state.settingsCursor = 0
|
|
4147
4697
|
state.settingsEditMode = false
|
|
4698
|
+
state.settingsAddKeyMode = false
|
|
4148
4699
|
state.settingsEditBuffer = ''
|
|
4149
4700
|
state.settingsScrollOffset = 0
|
|
4150
4701
|
return
|
|
@@ -4224,6 +4775,7 @@ async function main() {
|
|
|
4224
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
|
|
4225
4776
|
// π T is reserved for tier filter cycling β tier sort moved to Y
|
|
4226
4777
|
// π N is now reserved for origin filter cycling
|
|
4778
|
+
// π G (Shift+G) is handled separately below for usage sort
|
|
4227
4779
|
const sortKeys = {
|
|
4228
4780
|
'r': 'rank', 'y': 'tier', 'o': 'origin', 'm': 'model',
|
|
4229
4781
|
'l': 'ping', 'a': 'avg', 's': 'swe', 'c': 'ctx', 'h': 'condition', 'v': 'verdict', 'b': 'stability', 'u': 'uptime'
|
|
@@ -4246,6 +4798,22 @@ async function main() {
|
|
|
4246
4798
|
return
|
|
4247
4799
|
}
|
|
4248
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
|
+
|
|
4249
4817
|
// π F key: toggle favorite on the currently selected row and persist to config.
|
|
4250
4818
|
if (key.name === 'f') {
|
|
4251
4819
|
const selected = state.visibleSorted[state.cursor]
|
|
@@ -4290,11 +4858,12 @@ async function main() {
|
|
|
4290
4858
|
return
|
|
4291
4859
|
}
|
|
4292
4860
|
|
|
4293
|
-
// π Interval adjustment keys: W=decrease (faster),
|
|
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.
|
|
4294
4863
|
// π Minimum 1s, maximum 60s
|
|
4295
4864
|
if (key.name === 'w') {
|
|
4296
4865
|
state.pingInterval = Math.max(1000, state.pingInterval - 1000)
|
|
4297
|
-
} else if (key.name === '
|
|
4866
|
+
} else if (str === '=' || key.name === '=') {
|
|
4298
4867
|
state.pingInterval = Math.min(60000, state.pingInterval + 1000)
|
|
4299
4868
|
}
|
|
4300
4869
|
|
|
@@ -4338,8 +4907,11 @@ async function main() {
|
|
|
4338
4907
|
return
|
|
4339
4908
|
}
|
|
4340
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 '='.
|
|
4341
4912
|
if (key.name === 'x') {
|
|
4342
|
-
state.
|
|
4913
|
+
state.logVisible = !state.logVisible
|
|
4914
|
+
if (state.logVisible) state.logScrollOffset = 0
|
|
4343
4915
|
return
|
|
4344
4916
|
}
|
|
4345
4917
|
|
|
@@ -4409,7 +4981,14 @@ async function main() {
|
|
|
4409
4981
|
} else if (state.mode === 'opencode-desktop') {
|
|
4410
4982
|
await startOpenCodeDesktop(userSelected, state.config)
|
|
4411
4983
|
} else {
|
|
4412
|
-
|
|
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
|
+
}
|
|
4413
4992
|
}
|
|
4414
4993
|
process.exit(0)
|
|
4415
4994
|
}
|
|
@@ -4441,7 +5020,9 @@ async function main() {
|
|
|
4441
5020
|
? renderBugReport()
|
|
4442
5021
|
: state.helpVisible
|
|
4443
5022
|
? renderHelp()
|
|
4444
|
-
:
|
|
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)
|
|
4445
5026
|
process.stdout.write(ALT_HOME + content)
|
|
4446
5027
|
}, Math.round(1000 / FPS))
|
|
4447
5028
|
|
|
@@ -4449,7 +5030,7 @@ async function main() {
|
|
|
4449
5030
|
const initialVisible = state.results.filter(r => !r.hidden)
|
|
4450
5031
|
state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection)
|
|
4451
5032
|
|
|
4452
|
-
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))
|
|
4453
5034
|
|
|
4454
5035
|
// π If --recommend was passed, auto-open the Smart Recommend overlay on start
|
|
4455
5036
|
if (cliArgs.recommendMode) {
|
|
@@ -4471,7 +5052,14 @@ async function main() {
|
|
|
4471
5052
|
const pingModel = async (r) => {
|
|
4472
5053
|
const providerApiKey = getApiKey(state.config, r.providerKey) ?? null
|
|
4473
5054
|
const providerUrl = sources[r.providerKey]?.url ?? sources.nvidia.url
|
|
4474
|
-
|
|
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
|
+
}
|
|
4475
5063
|
|
|
4476
5064
|
// π Store ping result as object with ms and code
|
|
4477
5065
|
// π ms = actual response time (even for errors like 429)
|
|
@@ -4492,16 +5080,38 @@ async function main() {
|
|
|
4492
5080
|
r.status = 'down'
|
|
4493
5081
|
r.httpCode = code
|
|
4494
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
|
+
}
|
|
4495
5093
|
}
|
|
4496
5094
|
|
|
4497
5095
|
// π Initial ping of all models
|
|
4498
5096
|
const initialPing = Promise.all(state.results.map(r => pingModel(r)))
|
|
4499
5097
|
|
|
4500
|
-
// π Continuous ping loop with dynamic interval (adjustable with W
|
|
5098
|
+
// π Continuous ping loop with dynamic interval (adjustable with W/= keys)
|
|
4501
5099
|
const schedulePing = () => {
|
|
4502
5100
|
state.pingIntervalObj = setTimeout(async () => {
|
|
4503
5101
|
state.lastPingTime = Date.now()
|
|
4504
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
|
+
|
|
4505
5115
|
state.results.forEach(r => {
|
|
4506
5116
|
pingModel(r).catch(() => {
|
|
4507
5117
|
// Individual ping failures don't crash the loop
|
|
@@ -4521,7 +5131,7 @@ async function main() {
|
|
|
4521
5131
|
|
|
4522
5132
|
// π Keep interface running forever - user can select anytime or Ctrl+C to exit
|
|
4523
5133
|
// π The pings continue running in background with dynamic interval
|
|
4524
|
-
// π User can press W to decrease interval (faster pings) or
|
|
5134
|
+
// π User can press W to decrease interval (faster pings) or = to increase (slower)
|
|
4525
5135
|
// π Current interval shown in header: "next ping Xs"
|
|
4526
5136
|
}
|
|
4527
5137
|
|