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.
- package/README.md +53 -40
- package/bin/free-coding-models.js +692 -69
- 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 +4 -3
|
@@ -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'
|
|
@@ -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/
|
|
1445
|
-
signupHint: '
|
|
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://
|
|
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
|
-
|
|
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,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
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
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
|
|
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('
|
|
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
|
-
// βββ
|
|
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
|
-
|
|
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),
|
|
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 === '
|
|
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.
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|