free-coding-models 0.1.63 β 0.1.65
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 +99 -23
- package/bin/free-coding-models.js +546 -123
- package/lib/config.js +27 -3
- package/package.json +1 -1
- package/sources.js +57 -4
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* During benchmarking, users can navigate with arrow keys and press Enter to act on the selected model.
|
|
11
11
|
*
|
|
12
12
|
* π― Key features:
|
|
13
|
-
* - Parallel pings across all models with animated real-time updates (
|
|
13
|
+
* - Parallel pings across all models with animated real-time updates (multi-provider)
|
|
14
14
|
* - Continuous monitoring with 2-second ping intervals (never stops)
|
|
15
15
|
* - Rolling averages calculated from ALL successful pings since start
|
|
16
16
|
* - Best-per-tier highlighting with medals (π₯π₯π₯)
|
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
* - Startup mode menu (OpenCode CLI vs OpenCode Desktop vs OpenClaw) when no flag is given
|
|
20
20
|
* - Automatic config detection and model setup for both tools
|
|
21
21
|
* - JSON config stored in ~/.free-coding-models.json (auto-migrates from old plain-text)
|
|
22
|
-
* - Multi-provider support via sources.js (NIM
|
|
23
|
-
* - Settings screen (P key) to manage API keys
|
|
22
|
+
* - Multi-provider support via sources.js (NIM/Groq/Cerebras/OpenRouter/Hugging Face/Replicate/DeepInfra/... β extensible)
|
|
23
|
+
* - Settings screen (P key) to manage API keys, provider toggles, analytics, and manual updates
|
|
24
|
+
* - Favorites system: toggle with F, pin rows to top, persist between sessions
|
|
24
25
|
* - Uptime percentage tracking (successful pings / total pings)
|
|
25
26
|
* - Sortable columns (R/Y/O/M/L/A/S/N/H/V/U keys)
|
|
26
27
|
* - Tier filtering via T key (cycles S+βSβA+βAβA-βB+βBβCβAll)
|
|
@@ -32,15 +33,17 @@
|
|
|
32
33
|
* - `getTelemetryTerminal`: Infer terminal family (Terminal.app, iTerm2, kitty, etc.)
|
|
33
34
|
* - `isTelemetryDebugEnabled` / `telemetryDebug`: Optional runtime telemetry diagnostics via env
|
|
34
35
|
* - `sendUsageTelemetry`: Fire-and-forget anonymous app-start event
|
|
35
|
-
* - `
|
|
36
|
+
* - `ensureFavoritesConfig` / `toggleFavoriteModel`: Persist and toggle pinned favorites
|
|
37
|
+
* - `promptApiKey`: Interactive wizard for first-time multi-provider API key setup
|
|
36
38
|
* - `promptModeSelection`: Startup menu to choose OpenCode vs OpenClaw
|
|
37
|
-
* - `ping`:
|
|
39
|
+
* - `buildPingRequest` / `ping`: Build provider-specific probe requests and measure latency
|
|
38
40
|
* - `renderTable`: Generate ASCII table with colored latency indicators and status emojis
|
|
39
41
|
* - `getAvg`: Calculate average latency from all successful pings
|
|
40
42
|
* - `getVerdict`: Determine verdict string based on average latency (Overloaded for 429)
|
|
41
43
|
* - `getUptime`: Calculate uptime percentage from ping history
|
|
42
44
|
* - `sortResults`: Sort models by various columns
|
|
43
45
|
* - `checkNvidiaNimConfig`: Check if NVIDIA NIM provider is configured in OpenCode
|
|
46
|
+
* - `isTcpPortAvailable` / `resolveOpenCodeTmuxPort`: Pick a safe OpenCode port when running in tmux
|
|
44
47
|
* - `startOpenCode`: Launch OpenCode CLI with selected model (configures if needed)
|
|
45
48
|
* - `startOpenCodeDesktop`: Set model in shared config & open OpenCode Desktop app
|
|
46
49
|
* - `loadOpenClawConfig` / `saveOpenClawConfig`: Manage ~/.openclaw/openclaw.json
|
|
@@ -57,8 +60,8 @@
|
|
|
57
60
|
* βοΈ Configuration:
|
|
58
61
|
* - API keys stored per-provider in ~/.free-coding-models.json (0600 perms)
|
|
59
62
|
* - Old ~/.free-coding-models plain-text auto-migrated as nvidia key on first run
|
|
60
|
-
* - Env vars override config: NVIDIA_API_KEY, GROQ_API_KEY, CEREBRAS_API_KEY
|
|
61
|
-
* - Models loaded from sources.js β
|
|
63
|
+
* - Env vars override config: NVIDIA_API_KEY, GROQ_API_KEY, CEREBRAS_API_KEY, OPENROUTER_API_KEY, HUGGINGFACE_API_KEY/HF_TOKEN, REPLICATE_API_TOKEN, DEEPINFRA_API_KEY/DEEPINFRA_TOKEN, FIREWORKS_API_KEY, etc.
|
|
64
|
+
* - Models loaded from sources.js β all provider/model definitions are centralized there
|
|
62
65
|
* - OpenCode config: ~/.config/opencode/opencode.json
|
|
63
66
|
* - OpenClaw config: ~/.openclaw/openclaw.json
|
|
64
67
|
* - Ping timeout: 15s per attempt
|
|
@@ -86,6 +89,7 @@ import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from
|
|
|
86
89
|
import { randomUUID } from 'crypto'
|
|
87
90
|
import { homedir } from 'os'
|
|
88
91
|
import { join, dirname } from 'path'
|
|
92
|
+
import { createServer } from 'net'
|
|
89
93
|
import { MODELS, sources } from '../sources.js'
|
|
90
94
|
import { patchOpenClawModelsJson } from '../patch-openclaw-models.js'
|
|
91
95
|
import { getAvg, getVerdict, getUptime, sortResults, filterByTier, findBestModel, parseArgs, TIER_ORDER, VERDICT_ORDER, TIER_LETTER_MAP } from '../lib/utils.js'
|
|
@@ -157,6 +161,53 @@ function ensureTelemetryConfig(config) {
|
|
|
157
161
|
}
|
|
158
162
|
}
|
|
159
163
|
|
|
164
|
+
// π Ensure favorites config shape exists and remains clean.
|
|
165
|
+
// π Stored format: ["providerKey/modelId", ...] in insertion order.
|
|
166
|
+
function ensureFavoritesConfig(config) {
|
|
167
|
+
if (!Array.isArray(config.favorites)) config.favorites = []
|
|
168
|
+
const seen = new Set()
|
|
169
|
+
config.favorites = config.favorites.filter((entry) => {
|
|
170
|
+
if (typeof entry !== 'string' || entry.trim().length === 0) return false
|
|
171
|
+
if (seen.has(entry)) return false
|
|
172
|
+
seen.add(entry)
|
|
173
|
+
return true
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// π Build deterministic key used to persist one favorite model row.
|
|
178
|
+
function toFavoriteKey(providerKey, modelId) {
|
|
179
|
+
return `${providerKey}/${modelId}`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// π Sync per-row favorite metadata from config (used by renderer and sorter).
|
|
183
|
+
function syncFavoriteFlags(results, config) {
|
|
184
|
+
ensureFavoritesConfig(config)
|
|
185
|
+
const favoriteRankMap = new Map(config.favorites.map((entry, index) => [entry, index]))
|
|
186
|
+
for (const row of results) {
|
|
187
|
+
const favoriteKey = toFavoriteKey(row.providerKey, row.modelId)
|
|
188
|
+
const rank = favoriteRankMap.get(favoriteKey)
|
|
189
|
+
row.favoriteKey = favoriteKey
|
|
190
|
+
row.isFavorite = rank !== undefined
|
|
191
|
+
row.favoriteRank = rank !== undefined ? rank : Number.MAX_SAFE_INTEGER
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// π Toggle favorite state and persist immediately.
|
|
196
|
+
// π Returns true when row is now favorite, false when removed.
|
|
197
|
+
function toggleFavoriteModel(config, providerKey, modelId) {
|
|
198
|
+
ensureFavoritesConfig(config)
|
|
199
|
+
const favoriteKey = toFavoriteKey(providerKey, modelId)
|
|
200
|
+
const existingIndex = config.favorites.indexOf(favoriteKey)
|
|
201
|
+
if (existingIndex >= 0) {
|
|
202
|
+
config.favorites.splice(existingIndex, 1)
|
|
203
|
+
saveConfig(config)
|
|
204
|
+
return false
|
|
205
|
+
}
|
|
206
|
+
config.favorites.push(favoriteKey)
|
|
207
|
+
saveConfig(config)
|
|
208
|
+
return true
|
|
209
|
+
}
|
|
210
|
+
|
|
160
211
|
// π Create or reuse a persistent anonymous distinct_id for PostHog.
|
|
161
212
|
// π Stored locally in config so one user is stable over time without personal data.
|
|
162
213
|
function getTelemetryDistinctId(config) {
|
|
@@ -414,14 +465,25 @@ async function sendUsageTelemetry(config, cliArgs, payload) {
|
|
|
414
465
|
}
|
|
415
466
|
}
|
|
416
467
|
|
|
417
|
-
|
|
468
|
+
// π checkForUpdateDetailed: Fetch npm latest version with explicit error details.
|
|
469
|
+
// π Used by settings manual-check flow to display meaningful status in the UI.
|
|
470
|
+
async function checkForUpdateDetailed() {
|
|
418
471
|
try {
|
|
419
472
|
const res = await fetch('https://registry.npmjs.org/free-coding-models/latest', { signal: AbortSignal.timeout(5000) })
|
|
420
|
-
if (!res.ok) return null
|
|
473
|
+
if (!res.ok) return { latestVersion: null, error: `HTTP ${res.status}` }
|
|
421
474
|
const data = await res.json()
|
|
422
|
-
if (data.version && data.version !== LOCAL_VERSION) return data.version
|
|
423
|
-
|
|
424
|
-
|
|
475
|
+
if (data.version && data.version !== LOCAL_VERSION) return { latestVersion: data.version, error: null }
|
|
476
|
+
return { latestVersion: null, error: null }
|
|
477
|
+
} catch (error) {
|
|
478
|
+
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
479
|
+
return { latestVersion: null, error: message }
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// π checkForUpdate: Backward-compatible wrapper for startup update prompt.
|
|
484
|
+
async function checkForUpdate() {
|
|
485
|
+
const { latestVersion } = await checkForUpdateDetailed()
|
|
486
|
+
return latestVersion
|
|
425
487
|
}
|
|
426
488
|
|
|
427
489
|
function runUpdate(latestVersion) {
|
|
@@ -486,7 +548,7 @@ function runUpdate(latestVersion) {
|
|
|
486
548
|
|
|
487
549
|
// βββ First-run wizard βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
488
550
|
// π Shown when NO provider has a key configured yet.
|
|
489
|
-
// π Steps through all
|
|
551
|
+
// π Steps through all configured providers sequentially β each is optional (Enter to skip).
|
|
490
552
|
// π At least one key must be entered to proceed. Keys saved to ~/.free-coding-models.json.
|
|
491
553
|
// π Returns the nvidia key (or null) for backward-compat with the rest of main().
|
|
492
554
|
async function promptApiKey(config) {
|
|
@@ -495,81 +557,17 @@ async function promptApiKey(config) {
|
|
|
495
557
|
console.log(chalk.dim(' Enter keys for any provider you want to use. Press Enter to skip one.'))
|
|
496
558
|
console.log()
|
|
497
559
|
|
|
498
|
-
// π
|
|
499
|
-
const providers =
|
|
500
|
-
{
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
key: 'groq',
|
|
510
|
-
label: 'Groq',
|
|
511
|
-
color: chalk.rgb(249, 103, 20),
|
|
512
|
-
url: 'https://console.groq.com/keys',
|
|
513
|
-
hint: 'API Keys β Create API Key',
|
|
514
|
-
prefix: 'gsk_',
|
|
515
|
-
},
|
|
516
|
-
{
|
|
517
|
-
key: 'cerebras',
|
|
518
|
-
label: 'Cerebras',
|
|
519
|
-
color: chalk.rgb(0, 180, 255),
|
|
520
|
-
url: 'https://cloud.cerebras.ai',
|
|
521
|
-
hint: 'API Keys β Create',
|
|
522
|
-
prefix: 'csk_ / cauth_',
|
|
523
|
-
},
|
|
524
|
-
{
|
|
525
|
-
key: 'sambanova',
|
|
526
|
-
label: 'SambaNova',
|
|
527
|
-
color: chalk.rgb(255, 165, 0),
|
|
528
|
-
url: 'https://cloud.sambanova.ai/apis',
|
|
529
|
-
hint: 'API Keys β Create ($5 free trial, 3 months)',
|
|
530
|
-
prefix: 'sn-',
|
|
531
|
-
},
|
|
532
|
-
{
|
|
533
|
-
key: 'openrouter',
|
|
534
|
-
label: 'OpenRouter',
|
|
535
|
-
color: chalk.rgb(120, 80, 255),
|
|
536
|
-
url: 'https://openrouter.ai/settings/keys',
|
|
537
|
-
hint: 'API Keys β Create key (50 free req/day, shared quota)',
|
|
538
|
-
prefix: 'sk-or-',
|
|
539
|
-
},
|
|
540
|
-
{
|
|
541
|
-
key: 'codestral',
|
|
542
|
-
label: 'Mistral Codestral',
|
|
543
|
-
color: chalk.rgb(255, 100, 100),
|
|
544
|
-
url: 'https://codestral.mistral.ai',
|
|
545
|
-
hint: 'API Keys β Create key (30 req/min, 2000/day β phone required)',
|
|
546
|
-
prefix: 'csk-',
|
|
547
|
-
},
|
|
548
|
-
{
|
|
549
|
-
key: 'hyperbolic',
|
|
550
|
-
label: 'Hyperbolic',
|
|
551
|
-
color: chalk.rgb(0, 200, 150),
|
|
552
|
-
url: 'https://app.hyperbolic.ai/settings',
|
|
553
|
-
hint: 'Settings β API Keys ($1 free trial)',
|
|
554
|
-
prefix: 'eyJ',
|
|
555
|
-
},
|
|
556
|
-
{
|
|
557
|
-
key: 'scaleway',
|
|
558
|
-
label: 'Scaleway',
|
|
559
|
-
color: chalk.rgb(130, 0, 250),
|
|
560
|
-
url: 'https://console.scaleway.com/iam/api-keys',
|
|
561
|
-
hint: 'IAM β API Keys (1M free tokens)',
|
|
562
|
-
prefix: 'scw-',
|
|
563
|
-
},
|
|
564
|
-
{
|
|
565
|
-
key: 'googleai',
|
|
566
|
-
label: 'Google AI Studio',
|
|
567
|
-
color: chalk.rgb(66, 133, 244),
|
|
568
|
-
url: 'https://aistudio.google.com/apikey',
|
|
569
|
-
hint: 'Get API key (free Gemma models, 14.4K req/day)',
|
|
570
|
-
prefix: 'AIza',
|
|
571
|
-
},
|
|
572
|
-
]
|
|
560
|
+
// π Build providers from sources to keep setup in sync with actual supported providers.
|
|
561
|
+
const providers = Object.keys(sources).map((key) => {
|
|
562
|
+
const meta = PROVIDER_METADATA[key] || {}
|
|
563
|
+
return {
|
|
564
|
+
key,
|
|
565
|
+
label: meta.label || sources[key]?.name || key,
|
|
566
|
+
color: meta.color || chalk.white,
|
|
567
|
+
url: meta.signupUrl || 'https://example.com',
|
|
568
|
+
hint: meta.signupHint || 'Create API key',
|
|
569
|
+
}
|
|
570
|
+
})
|
|
573
571
|
|
|
574
572
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
575
573
|
|
|
@@ -785,6 +783,16 @@ function calculateViewport(terminalRows, scrollOffset, totalModels) {
|
|
|
785
783
|
return { startIdx: scrollOffset, endIdx, hasAbove, hasBelow }
|
|
786
784
|
}
|
|
787
785
|
|
|
786
|
+
// π Favorites are always pinned at the top and keep insertion order.
|
|
787
|
+
// π Non-favorites still use the active sort column/direction.
|
|
788
|
+
function sortResultsWithPinnedFavorites(results, sortColumn, sortDirection) {
|
|
789
|
+
const favoriteRows = results
|
|
790
|
+
.filter((r) => r.isFavorite)
|
|
791
|
+
.sort((a, b) => a.favoriteRank - b.favoriteRank)
|
|
792
|
+
const nonFavoriteRows = sortResults(results.filter((r) => !r.isFavorite), sortColumn, sortDirection)
|
|
793
|
+
return [...favoriteRows, ...nonFavoriteRows]
|
|
794
|
+
}
|
|
795
|
+
|
|
788
796
|
// π renderTable: mode param controls footer hint text (opencode vs openclaw)
|
|
789
797
|
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) {
|
|
790
798
|
// π Filter out hidden models for display
|
|
@@ -852,7 +860,7 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
|
|
|
852
860
|
const W_UPTIME = 6
|
|
853
861
|
|
|
854
862
|
// π Sort models using the shared helper
|
|
855
|
-
const sorted =
|
|
863
|
+
const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection)
|
|
856
864
|
|
|
857
865
|
const lines = [
|
|
858
866
|
` ${chalk.bold('β‘ Free Coding Models')} ${chalk.dim('v' + LOCAL_VERSION)}${modeBadge}${modeHint}${tierBadge}${originBadge} ` +
|
|
@@ -944,7 +952,10 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
|
|
|
944
952
|
// π Show provider name from sources map (NIM / Groq / Cerebras)
|
|
945
953
|
const providerName = sources[r.providerKey]?.name ?? r.providerKey ?? 'NIM'
|
|
946
954
|
const source = chalk.green(providerName.padEnd(W_SOURCE))
|
|
947
|
-
|
|
955
|
+
// π Favorites get a leading star in Model column.
|
|
956
|
+
const favoritePrefix = r.isFavorite ? 'β ' : ''
|
|
957
|
+
const nameWidth = Math.max(0, W_MODEL - favoritePrefix.length)
|
|
958
|
+
const name = favoritePrefix + r.label.slice(0, nameWidth).padEnd(nameWidth)
|
|
948
959
|
const sweScore = r.sweScore ?? 'β'
|
|
949
960
|
const sweCell = sweScore !== 'β' && parseFloat(sweScore) >= 50
|
|
950
961
|
? chalk.greenBright(sweScore.padEnd(W_SWE))
|
|
@@ -1074,8 +1085,12 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
|
|
|
1074
1085
|
// π Build row with double space between columns (order: Rank, Tier, SWE%, CTX, Model, Origin, Latest Ping, Avg Ping, Health, Verdict, Up%)
|
|
1075
1086
|
const row = ' ' + num + ' ' + tier + ' ' + sweCell + ' ' + ctxCell + ' ' + name + ' ' + source + ' ' + pingCell + ' ' + avgCell + ' ' + status + ' ' + speedCell + ' ' + uptimeCell
|
|
1076
1087
|
|
|
1077
|
-
if (isCursor) {
|
|
1088
|
+
if (isCursor && r.isFavorite) {
|
|
1089
|
+
lines.push(chalk.bgRgb(120, 60, 0)(row))
|
|
1090
|
+
} else if (isCursor) {
|
|
1078
1091
|
lines.push(chalk.bgRgb(139, 0, 139)(row))
|
|
1092
|
+
} else if (r.isFavorite) {
|
|
1093
|
+
lines.push(chalk.bgRgb(90, 45, 0)(row))
|
|
1079
1094
|
} else {
|
|
1080
1095
|
lines.push(row)
|
|
1081
1096
|
}
|
|
@@ -1094,7 +1109,7 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
|
|
|
1094
1109
|
: mode === 'opencode-desktop'
|
|
1095
1110
|
? chalk.rgb(0, 200, 255)('EnterβOpenDesktop')
|
|
1096
1111
|
: chalk.rgb(0, 200, 255)('EnterβOpenCode')
|
|
1097
|
-
lines.push(chalk.dim(` ββ Navigate β’ `) + actionHint + chalk.dim(` β’ R/Y/O/M/L/A/S/C/H/V/U Sort β’ T Tier β’ N Origin β’ Wβ/Xβ (${intervalSec}s) β’ Z Mode β’ `) + chalk.yellow('P') + chalk.dim(` Settings β’ `) + chalk.bgGreenBright.black.bold(' K Help ') + chalk.dim(` β’ Ctrl+C Exit`))
|
|
1112
|
+
lines.push(chalk.dim(` ββ Navigate β’ `) + actionHint + chalk.dim(` β’ F Favorite β’ R/Y/O/M/L/A/S/C/H/V/U Sort β’ T Tier β’ N Origin β’ Wβ/Xβ (${intervalSec}s) β’ Z Mode β’ `) + chalk.yellow('P') + chalk.dim(` Settings β’ `) + chalk.bgGreenBright.black.bold(' K Help ') + chalk.dim(` β’ Ctrl+C Exit`))
|
|
1098
1113
|
lines.push('')
|
|
1099
1114
|
lines.push(
|
|
1100
1115
|
chalk.rgb(255, 150, 200)(' Made with π & β by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
|
|
@@ -1121,23 +1136,50 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
|
|
|
1121
1136
|
// βββ HTTP ping ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
1122
1137
|
|
|
1123
1138
|
// π ping: Send a single chat completion request to measure model availability and latency.
|
|
1124
|
-
// π
|
|
1139
|
+
// π providerKey and url determine provider-specific request format.
|
|
1125
1140
|
// π apiKey can be null β in that case no Authorization header is sent.
|
|
1126
1141
|
// π A 401 response still tells us the server is UP and gives us real latency.
|
|
1127
|
-
|
|
1142
|
+
function buildPingRequest(apiKey, modelId, providerKey, url) {
|
|
1143
|
+
if (providerKey === 'replicate') {
|
|
1144
|
+
// π Replicate uses /v1/predictions with a different payload than OpenAI chat-completions.
|
|
1145
|
+
const replicateHeaders = { 'Content-Type': 'application/json', Prefer: 'wait=4' }
|
|
1146
|
+
if (apiKey) replicateHeaders.Authorization = `Token ${apiKey}`
|
|
1147
|
+
return {
|
|
1148
|
+
url,
|
|
1149
|
+
headers: replicateHeaders,
|
|
1150
|
+
body: { version: modelId, input: { prompt: 'hi' } },
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
1155
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`
|
|
1156
|
+
if (providerKey === 'openrouter') {
|
|
1157
|
+
// π OpenRouter recommends optional app identification headers.
|
|
1158
|
+
headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
|
|
1159
|
+
headers['X-Title'] = 'free-coding-models'
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
return {
|
|
1163
|
+
url,
|
|
1164
|
+
headers,
|
|
1165
|
+
body: { model: modelId, messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 },
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
async function ping(apiKey, modelId, providerKey, url) {
|
|
1128
1170
|
const ctrl = new AbortController()
|
|
1129
1171
|
const timer = setTimeout(() => ctrl.abort(), PING_TIMEOUT)
|
|
1130
1172
|
const t0 = performance.now()
|
|
1131
1173
|
try {
|
|
1132
|
-
|
|
1133
|
-
const
|
|
1134
|
-
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
|
1135
|
-
const resp = await fetch(url, {
|
|
1174
|
+
const req = buildPingRequest(apiKey, modelId, providerKey, url)
|
|
1175
|
+
const resp = await fetch(req.url, {
|
|
1136
1176
|
method: 'POST', signal: ctrl.signal,
|
|
1137
|
-
headers,
|
|
1138
|
-
body: JSON.stringify(
|
|
1177
|
+
headers: req.headers,
|
|
1178
|
+
body: JSON.stringify(req.body),
|
|
1139
1179
|
})
|
|
1140
|
-
|
|
1180
|
+
// π Normalize all HTTP 2xx statuses to "200" so existing verdict/avg logic still works.
|
|
1181
|
+
const code = resp.status >= 200 && resp.status < 300 ? '200' : String(resp.status)
|
|
1182
|
+
return { code, ms: Math.round(performance.now() - t0) }
|
|
1141
1183
|
} catch (err) {
|
|
1142
1184
|
const isTimeout = err.name === 'AbortError'
|
|
1143
1185
|
return {
|
|
@@ -1178,12 +1220,112 @@ const ENV_VAR_NAMES = {
|
|
|
1178
1220
|
cerebras: 'CEREBRAS_API_KEY',
|
|
1179
1221
|
sambanova: 'SAMBANOVA_API_KEY',
|
|
1180
1222
|
openrouter: 'OPENROUTER_API_KEY',
|
|
1223
|
+
huggingface:'HUGGINGFACE_API_KEY',
|
|
1224
|
+
replicate: 'REPLICATE_API_TOKEN',
|
|
1225
|
+
deepinfra: 'DEEPINFRA_API_KEY',
|
|
1226
|
+
fireworks: 'FIREWORKS_API_KEY',
|
|
1181
1227
|
codestral: 'CODESTRAL_API_KEY',
|
|
1182
1228
|
hyperbolic: 'HYPERBOLIC_API_KEY',
|
|
1183
1229
|
scaleway: 'SCALEWAY_API_KEY',
|
|
1184
1230
|
googleai: 'GOOGLE_API_KEY',
|
|
1185
1231
|
}
|
|
1186
1232
|
|
|
1233
|
+
// π Provider metadata used by the setup wizard and Settings details panel.
|
|
1234
|
+
// π Keeps signup links + rate limits centralized so UI stays consistent.
|
|
1235
|
+
const PROVIDER_METADATA = {
|
|
1236
|
+
nvidia: {
|
|
1237
|
+
label: 'NVIDIA NIM',
|
|
1238
|
+
color: chalk.rgb(118, 185, 0),
|
|
1239
|
+
signupUrl: 'https://build.nvidia.com',
|
|
1240
|
+
signupHint: 'Profile β API Keys β Generate',
|
|
1241
|
+
rateLimits: 'Free tier (provider quota by model)',
|
|
1242
|
+
},
|
|
1243
|
+
groq: {
|
|
1244
|
+
label: 'Groq',
|
|
1245
|
+
color: chalk.rgb(249, 103, 20),
|
|
1246
|
+
signupUrl: 'https://console.groq.com/keys',
|
|
1247
|
+
signupHint: 'API Keys β Create API Key',
|
|
1248
|
+
rateLimits: 'Free dev tier (provider quota)',
|
|
1249
|
+
},
|
|
1250
|
+
cerebras: {
|
|
1251
|
+
label: 'Cerebras',
|
|
1252
|
+
color: chalk.rgb(0, 180, 255),
|
|
1253
|
+
signupUrl: 'https://cloud.cerebras.ai',
|
|
1254
|
+
signupHint: 'API Keys β Create',
|
|
1255
|
+
rateLimits: 'Free dev tier (provider quota)',
|
|
1256
|
+
},
|
|
1257
|
+
sambanova: {
|
|
1258
|
+
label: 'SambaNova',
|
|
1259
|
+
color: chalk.rgb(255, 165, 0),
|
|
1260
|
+
signupUrl: 'https://sambanova.ai/developers',
|
|
1261
|
+
signupHint: 'Developers portal β Create API key',
|
|
1262
|
+
rateLimits: 'Dev tier generous quota',
|
|
1263
|
+
},
|
|
1264
|
+
openrouter: {
|
|
1265
|
+
label: 'OpenRouter',
|
|
1266
|
+
color: chalk.rgb(120, 80, 255),
|
|
1267
|
+
signupUrl: 'https://openrouter.ai/keys',
|
|
1268
|
+
signupHint: 'API Keys β Create',
|
|
1269
|
+
rateLimits: '50 req/day, 20/min (:free shared quota)',
|
|
1270
|
+
},
|
|
1271
|
+
huggingface: {
|
|
1272
|
+
label: 'Hugging Face Inference',
|
|
1273
|
+
color: chalk.rgb(255, 182, 0),
|
|
1274
|
+
signupUrl: 'https://huggingface.co/settings/tokens',
|
|
1275
|
+
signupHint: 'Settings β Access Tokens',
|
|
1276
|
+
rateLimits: 'Free monthly credits (~$0.10)',
|
|
1277
|
+
},
|
|
1278
|
+
replicate: {
|
|
1279
|
+
label: 'Replicate',
|
|
1280
|
+
color: chalk.rgb(120, 160, 255),
|
|
1281
|
+
signupUrl: 'https://replicate.com/account/api-tokens',
|
|
1282
|
+
signupHint: 'Account β API Tokens',
|
|
1283
|
+
rateLimits: 'Developer free quota',
|
|
1284
|
+
},
|
|
1285
|
+
deepinfra: {
|
|
1286
|
+
label: 'DeepInfra',
|
|
1287
|
+
color: chalk.rgb(0, 180, 140),
|
|
1288
|
+
signupUrl: 'https://deepinfra.com/login',
|
|
1289
|
+
signupHint: 'Login β API keys',
|
|
1290
|
+
rateLimits: 'Free dev tier (low-latency quota)',
|
|
1291
|
+
},
|
|
1292
|
+
fireworks: {
|
|
1293
|
+
label: 'Fireworks AI',
|
|
1294
|
+
color: chalk.rgb(255, 80, 50),
|
|
1295
|
+
signupUrl: 'https://fireworks.ai',
|
|
1296
|
+
signupHint: 'Create account β Generate API key',
|
|
1297
|
+
rateLimits: '$1 free credits (new dev accounts)',
|
|
1298
|
+
},
|
|
1299
|
+
codestral: {
|
|
1300
|
+
label: 'Mistral Codestral',
|
|
1301
|
+
color: chalk.rgb(255, 100, 100),
|
|
1302
|
+
signupUrl: 'https://codestral.mistral.ai',
|
|
1303
|
+
signupHint: 'API Keys β Create',
|
|
1304
|
+
rateLimits: '30 req/min, 2000/day',
|
|
1305
|
+
},
|
|
1306
|
+
hyperbolic: {
|
|
1307
|
+
label: 'Hyperbolic',
|
|
1308
|
+
color: chalk.rgb(0, 200, 150),
|
|
1309
|
+
signupUrl: 'https://app.hyperbolic.ai/settings',
|
|
1310
|
+
signupHint: 'Settings β API Keys',
|
|
1311
|
+
rateLimits: '$1 free trial credits',
|
|
1312
|
+
},
|
|
1313
|
+
scaleway: {
|
|
1314
|
+
label: 'Scaleway',
|
|
1315
|
+
color: chalk.rgb(130, 0, 250),
|
|
1316
|
+
signupUrl: 'https://console.scaleway.com/iam/api-keys',
|
|
1317
|
+
signupHint: 'IAM β API Keys',
|
|
1318
|
+
rateLimits: '1M free tokens',
|
|
1319
|
+
},
|
|
1320
|
+
googleai: {
|
|
1321
|
+
label: 'Google AI Studio',
|
|
1322
|
+
color: chalk.rgb(66, 133, 244),
|
|
1323
|
+
signupUrl: 'https://aistudio.google.com/apikey',
|
|
1324
|
+
signupHint: 'Get API key',
|
|
1325
|
+
rateLimits: '14.4K req/day, 30/min',
|
|
1326
|
+
},
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1187
1329
|
// π OpenCode config location varies by platform
|
|
1188
1330
|
// π Windows: %APPDATA%\opencode\opencode.json (or sometimes ~/.config/opencode)
|
|
1189
1331
|
// π macOS/Linux: ~/.config/opencode/opencode.json
|
|
@@ -1193,6 +1335,45 @@ const OPENCODE_CONFIG = isWindows
|
|
|
1193
1335
|
|
|
1194
1336
|
// π Fallback to .config on Windows if AppData doesn't exist
|
|
1195
1337
|
const OPENCODE_CONFIG_FALLBACK = join(homedir(), '.config', 'opencode', 'opencode.json')
|
|
1338
|
+
const OPENCODE_PORT_RANGE_START = 4096
|
|
1339
|
+
const OPENCODE_PORT_RANGE_END = 5096
|
|
1340
|
+
|
|
1341
|
+
// π isTcpPortAvailable: checks if a local TCP port is free for OpenCode.
|
|
1342
|
+
// π Used to avoid tmux sub-agent port conflicts when multiple projects run in parallel.
|
|
1343
|
+
function isTcpPortAvailable(port) {
|
|
1344
|
+
return new Promise((resolve) => {
|
|
1345
|
+
const server = createServer()
|
|
1346
|
+
server.once('error', () => resolve(false))
|
|
1347
|
+
server.once('listening', () => {
|
|
1348
|
+
server.close(() => resolve(true))
|
|
1349
|
+
})
|
|
1350
|
+
server.listen(port)
|
|
1351
|
+
})
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// π resolveOpenCodeTmuxPort: selects a safe port for OpenCode when inside tmux.
|
|
1355
|
+
// π Priority:
|
|
1356
|
+
// π 1) OPENCODE_PORT from env (if valid and available)
|
|
1357
|
+
// π 2) First available port in 4096-5095
|
|
1358
|
+
async function resolveOpenCodeTmuxPort() {
|
|
1359
|
+
const envPortRaw = process.env.OPENCODE_PORT
|
|
1360
|
+
const envPort = Number.parseInt(envPortRaw || '', 10)
|
|
1361
|
+
|
|
1362
|
+
if (Number.isInteger(envPort) && envPort > 0 && envPort <= 65535) {
|
|
1363
|
+
if (await isTcpPortAvailable(envPort)) {
|
|
1364
|
+
return { port: envPort, source: 'env' }
|
|
1365
|
+
}
|
|
1366
|
+
console.log(chalk.yellow(` β OPENCODE_PORT=${envPort} is already in use; selecting another port for this run.`))
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
for (let port = OPENCODE_PORT_RANGE_START; port < OPENCODE_PORT_RANGE_END; port++) {
|
|
1370
|
+
if (await isTcpPortAvailable(port)) {
|
|
1371
|
+
return { port, source: 'auto' }
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
return null
|
|
1376
|
+
}
|
|
1196
1377
|
|
|
1197
1378
|
function getOpenCodeConfigPath() {
|
|
1198
1379
|
if (existsSync(OPENCODE_CONFIG)) return OPENCODE_CONFIG
|
|
@@ -1243,10 +1424,30 @@ async function spawnOpenCode(args, providerKey, fcmConfig) {
|
|
|
1243
1424
|
const envVarName = ENV_VAR_NAMES[providerKey]
|
|
1244
1425
|
const resolvedKey = getApiKey(fcmConfig, providerKey)
|
|
1245
1426
|
const childEnv = { ...process.env }
|
|
1427
|
+
const finalArgs = [...args]
|
|
1428
|
+
const hasExplicitPortArg = finalArgs.includes('--port')
|
|
1246
1429
|
if (envVarName && resolvedKey) childEnv[envVarName] = resolvedKey
|
|
1247
1430
|
|
|
1431
|
+
// π In tmux, OpenCode sub-agents need a listening port to open extra panes.
|
|
1432
|
+
// π We auto-pick one if the user did not provide --port explicitly.
|
|
1433
|
+
if (process.env.TMUX && !hasExplicitPortArg) {
|
|
1434
|
+
const tmuxPort = await resolveOpenCodeTmuxPort()
|
|
1435
|
+
if (tmuxPort) {
|
|
1436
|
+
const portValue = String(tmuxPort.port)
|
|
1437
|
+
childEnv.OPENCODE_PORT = portValue
|
|
1438
|
+
finalArgs.push('--port', portValue)
|
|
1439
|
+
if (tmuxPort.source === 'env') {
|
|
1440
|
+
console.log(chalk.dim(` πΊ tmux detected β using OPENCODE_PORT=${portValue}.`))
|
|
1441
|
+
} else {
|
|
1442
|
+
console.log(chalk.dim(` πΊ tmux detected β using OpenCode port ${portValue} for sub-agent panes.`))
|
|
1443
|
+
}
|
|
1444
|
+
} else {
|
|
1445
|
+
console.log(chalk.yellow(` β tmux detected but no free OpenCode port found in ${OPENCODE_PORT_RANGE_START}-${OPENCODE_PORT_RANGE_END - 1}; launching without --port.`))
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1248
1449
|
const { spawn } = await import('child_process')
|
|
1249
|
-
const child = spawn('opencode',
|
|
1450
|
+
const child = spawn('opencode', finalArgs, {
|
|
1250
1451
|
stdio: 'inherit',
|
|
1251
1452
|
shell: true,
|
|
1252
1453
|
detached: false,
|
|
@@ -1269,7 +1470,7 @@ async function spawnOpenCode(args, providerKey, fcmConfig) {
|
|
|
1269
1470
|
|
|
1270
1471
|
// βββ Start OpenCode ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
1271
1472
|
// π Launches OpenCode with the selected model.
|
|
1272
|
-
// π Handles
|
|
1473
|
+
// π Handles nvidia + all OpenAI-compatible providers defined in sources.js.
|
|
1273
1474
|
// π For nvidia: checks if NIM is configured, sets provider.models entry, spawns with nvidia/model-id.
|
|
1274
1475
|
// π For groq/cerebras: OpenCode has built-in support -- just sets model in config and spawns.
|
|
1275
1476
|
// π Model format: { modelId, label, tier, providerKey }
|
|
@@ -1357,6 +1558,14 @@ After installation, you can use: opencode --model ${modelRef}`
|
|
|
1357
1558
|
await spawnOpenCode([], providerKey, fcmConfig)
|
|
1358
1559
|
}
|
|
1359
1560
|
} else {
|
|
1561
|
+
if (providerKey === 'replicate') {
|
|
1562
|
+
console.log(chalk.yellow(' β Replicate models are monitor-only for now in OpenCode mode.'))
|
|
1563
|
+
console.log(chalk.dim(' Reason: Replicate uses /v1/predictions instead of OpenAI chat-completions.'))
|
|
1564
|
+
console.log(chalk.dim(' You can still benchmark this model in the TUI and use other providers for OpenCode launch.'))
|
|
1565
|
+
console.log()
|
|
1566
|
+
return
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1360
1569
|
// π Groq: built-in OpenCode provider -- needs provider block with apiKey in opencode.json.
|
|
1361
1570
|
// π Cerebras: NOT built-in -- needs @ai-sdk/openai-compatible + baseURL, like NVIDIA.
|
|
1362
1571
|
// π Both need the model registered in provider.<key>.models so OpenCode can find it.
|
|
@@ -1413,6 +1622,36 @@ After installation, you can use: opencode --model ${modelRef}`
|
|
|
1413
1622
|
},
|
|
1414
1623
|
models: {}
|
|
1415
1624
|
}
|
|
1625
|
+
} else if (providerKey === 'huggingface') {
|
|
1626
|
+
config.provider.huggingface = {
|
|
1627
|
+
npm: '@ai-sdk/openai-compatible',
|
|
1628
|
+
name: 'Hugging Face Inference',
|
|
1629
|
+
options: {
|
|
1630
|
+
baseURL: 'https://router.huggingface.co/v1',
|
|
1631
|
+
apiKey: '{env:HUGGINGFACE_API_KEY}'
|
|
1632
|
+
},
|
|
1633
|
+
models: {}
|
|
1634
|
+
}
|
|
1635
|
+
} else if (providerKey === 'deepinfra') {
|
|
1636
|
+
config.provider.deepinfra = {
|
|
1637
|
+
npm: '@ai-sdk/openai-compatible',
|
|
1638
|
+
name: 'DeepInfra',
|
|
1639
|
+
options: {
|
|
1640
|
+
baseURL: 'https://api.deepinfra.com/v1/openai',
|
|
1641
|
+
apiKey: '{env:DEEPINFRA_API_KEY}'
|
|
1642
|
+
},
|
|
1643
|
+
models: {}
|
|
1644
|
+
}
|
|
1645
|
+
} else if (providerKey === 'fireworks') {
|
|
1646
|
+
config.provider.fireworks = {
|
|
1647
|
+
npm: '@ai-sdk/openai-compatible',
|
|
1648
|
+
name: 'Fireworks AI',
|
|
1649
|
+
options: {
|
|
1650
|
+
baseURL: 'https://api.fireworks.ai/inference/v1',
|
|
1651
|
+
apiKey: '{env:FIREWORKS_API_KEY}'
|
|
1652
|
+
},
|
|
1653
|
+
models: {}
|
|
1654
|
+
}
|
|
1416
1655
|
} else if (providerKey === 'codestral') {
|
|
1417
1656
|
config.provider.codestral = {
|
|
1418
1657
|
npm: '@ai-sdk/openai-compatible',
|
|
@@ -1488,7 +1727,7 @@ After installation, you can use: opencode --model ${modelRef}`
|
|
|
1488
1727
|
// βββ Start OpenCode Desktop βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
1489
1728
|
// π startOpenCodeDesktop: Same config logic as startOpenCode, but opens the Desktop app.
|
|
1490
1729
|
// π OpenCode Desktop shares config at the same location as CLI.
|
|
1491
|
-
// π Handles
|
|
1730
|
+
// π Handles nvidia + all OpenAI-compatible providers defined in sources.js.
|
|
1492
1731
|
// π No need to wait for exit β Desktop app stays open independently.
|
|
1493
1732
|
async function startOpenCodeDesktop(model, fcmConfig) {
|
|
1494
1733
|
const providerKey = model.providerKey ?? 'nvidia'
|
|
@@ -1589,6 +1828,14 @@ ${isWindows ? 'set NVIDIA_API_KEY=your_key_here' : 'export NVIDIA_API_KEY=your_k
|
|
|
1589
1828
|
console.log()
|
|
1590
1829
|
}
|
|
1591
1830
|
} else {
|
|
1831
|
+
if (providerKey === 'replicate') {
|
|
1832
|
+
console.log(chalk.yellow(' β Replicate models are monitor-only for now in OpenCode Desktop mode.'))
|
|
1833
|
+
console.log(chalk.dim(' Reason: Replicate uses /v1/predictions instead of OpenAI chat-completions.'))
|
|
1834
|
+
console.log(chalk.dim(' You can still benchmark this model in the TUI and use other providers for Desktop launch.'))
|
|
1835
|
+
console.log()
|
|
1836
|
+
return
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1592
1839
|
// π Groq: built-in OpenCode provider β needs provider block with apiKey in opencode.json.
|
|
1593
1840
|
// π Cerebras: NOT built-in β needs @ai-sdk/openai-compatible + baseURL, like NVIDIA.
|
|
1594
1841
|
// π Both need the model registered in provider.<key>.models so OpenCode can find it.
|
|
@@ -1643,6 +1890,36 @@ ${isWindows ? 'set NVIDIA_API_KEY=your_key_here' : 'export NVIDIA_API_KEY=your_k
|
|
|
1643
1890
|
},
|
|
1644
1891
|
models: {}
|
|
1645
1892
|
}
|
|
1893
|
+
} else if (providerKey === 'huggingface') {
|
|
1894
|
+
config.provider.huggingface = {
|
|
1895
|
+
npm: '@ai-sdk/openai-compatible',
|
|
1896
|
+
name: 'Hugging Face Inference',
|
|
1897
|
+
options: {
|
|
1898
|
+
baseURL: 'https://router.huggingface.co/v1',
|
|
1899
|
+
apiKey: '{env:HUGGINGFACE_API_KEY}'
|
|
1900
|
+
},
|
|
1901
|
+
models: {}
|
|
1902
|
+
}
|
|
1903
|
+
} else if (providerKey === 'deepinfra') {
|
|
1904
|
+
config.provider.deepinfra = {
|
|
1905
|
+
npm: '@ai-sdk/openai-compatible',
|
|
1906
|
+
name: 'DeepInfra',
|
|
1907
|
+
options: {
|
|
1908
|
+
baseURL: 'https://api.deepinfra.com/v1/openai',
|
|
1909
|
+
apiKey: '{env:DEEPINFRA_API_KEY}'
|
|
1910
|
+
},
|
|
1911
|
+
models: {}
|
|
1912
|
+
}
|
|
1913
|
+
} else if (providerKey === 'fireworks') {
|
|
1914
|
+
config.provider.fireworks = {
|
|
1915
|
+
npm: '@ai-sdk/openai-compatible',
|
|
1916
|
+
name: 'Fireworks AI',
|
|
1917
|
+
options: {
|
|
1918
|
+
baseURL: 'https://api.fireworks.ai/inference/v1',
|
|
1919
|
+
apiKey: '{env:FIREWORKS_API_KEY}'
|
|
1920
|
+
},
|
|
1921
|
+
models: {}
|
|
1922
|
+
}
|
|
1646
1923
|
} else if (providerKey === 'codestral') {
|
|
1647
1924
|
config.provider.codestral = {
|
|
1648
1925
|
npm: '@ai-sdk/openai-compatible',
|
|
@@ -1854,7 +2131,7 @@ async function runFiableMode(config) {
|
|
|
1854
2131
|
const pingPromises = results.map(r => {
|
|
1855
2132
|
const rApiKey = getApiKey(config, r.providerKey)
|
|
1856
2133
|
const url = sources[r.providerKey]?.url
|
|
1857
|
-
return ping(rApiKey, r.modelId, url).then(({ code, ms }) => {
|
|
2134
|
+
return ping(rApiKey, r.modelId, r.providerKey, url).then(({ code, ms }) => {
|
|
1858
2135
|
r.pings.push({ ms, code })
|
|
1859
2136
|
if (code === '200') {
|
|
1860
2137
|
r.status = 'up'
|
|
@@ -1919,6 +2196,7 @@ async function main() {
|
|
|
1919
2196
|
// π Load JSON config (auto-migrates old plain-text ~/.free-coding-models if needed)
|
|
1920
2197
|
const config = loadConfig()
|
|
1921
2198
|
ensureTelemetryConfig(config)
|
|
2199
|
+
ensureFavoritesConfig(config)
|
|
1922
2200
|
|
|
1923
2201
|
// π Check if any provider has a key β if not, run the first-time setup wizard
|
|
1924
2202
|
const hasAnyKey = Object.keys(sources).some(pk => !!getApiKey(config, pk))
|
|
@@ -2002,6 +2280,7 @@ async function main() {
|
|
|
2002
2280
|
httpCode: null,
|
|
2003
2281
|
hidden: false, // π Simple flag to hide/show models
|
|
2004
2282
|
}))
|
|
2283
|
+
syncFavoriteFlags(results, config)
|
|
2005
2284
|
|
|
2006
2285
|
// π Clamp scrollOffset so cursor is always within the visible viewport window.
|
|
2007
2286
|
// π Called after every cursor move, sort change, and terminal resize.
|
|
@@ -2052,6 +2331,9 @@ async function main() {
|
|
|
2052
2331
|
settingsEditMode: false, // π Whether we're in inline key editing mode
|
|
2053
2332
|
settingsEditBuffer: '', // π Typed characters for the API key being edited
|
|
2054
2333
|
settingsTestResults: {}, // π { providerKey: 'pending'|'ok'|'fail'|null }
|
|
2334
|
+
settingsUpdateState: 'idle', // π 'idle'|'checking'|'available'|'up-to-date'|'error'|'installing'
|
|
2335
|
+
settingsUpdateLatestVersion: null, // π Latest npm version discovered from manual check
|
|
2336
|
+
settingsUpdateError: null, // π Last update-check error message for maintenance row
|
|
2055
2337
|
config, // π Live reference to the config object (updated on save)
|
|
2056
2338
|
visibleSorted: [], // π Cached visible+sorted models β shared between render loop and key handlers
|
|
2057
2339
|
helpVisible: false, // π Whether the help overlay (K key) is active
|
|
@@ -2089,6 +2371,11 @@ async function main() {
|
|
|
2089
2371
|
const activeTier = TIER_CYCLE[tierFilterMode]
|
|
2090
2372
|
const activeOrigin = ORIGIN_CYCLE[originFilterMode]
|
|
2091
2373
|
state.results.forEach(r => {
|
|
2374
|
+
// π Favorites stay visible regardless of tier/origin filters.
|
|
2375
|
+
if (r.isFavorite) {
|
|
2376
|
+
r.hidden = false
|
|
2377
|
+
return
|
|
2378
|
+
}
|
|
2092
2379
|
// π Apply both tier and origin filters β model is hidden if it fails either
|
|
2093
2380
|
const tierHide = activeTier !== null && r.tier !== activeTier
|
|
2094
2381
|
const originHide = activeOrigin !== null && r.providerKey !== activeOrigin
|
|
@@ -2105,18 +2392,21 @@ async function main() {
|
|
|
2105
2392
|
function renderSettings() {
|
|
2106
2393
|
const providerKeys = Object.keys(sources)
|
|
2107
2394
|
const telemetryRowIdx = providerKeys.length
|
|
2395
|
+
const updateRowIdx = providerKeys.length + 1
|
|
2108
2396
|
const EL = '\x1b[K'
|
|
2109
2397
|
const lines = []
|
|
2110
2398
|
|
|
2111
2399
|
lines.push('')
|
|
2112
2400
|
lines.push(` ${chalk.bold('β Settings')} ${chalk.dim('β free-coding-models v' + LOCAL_VERSION)}`)
|
|
2113
2401
|
lines.push('')
|
|
2114
|
-
lines.push(` ${chalk.bold('Providers')}`)
|
|
2402
|
+
lines.push(` ${chalk.bold('π§© Providers')}`)
|
|
2403
|
+
lines.push(` ${chalk.dim(' ' + 'β'.repeat(112))}`)
|
|
2115
2404
|
lines.push('')
|
|
2116
2405
|
|
|
2117
2406
|
for (let i = 0; i < providerKeys.length; i++) {
|
|
2118
2407
|
const pk = providerKeys[i]
|
|
2119
2408
|
const src = sources[pk]
|
|
2409
|
+
const meta = PROVIDER_METADATA[pk] || {}
|
|
2120
2410
|
const isCursor = i === state.settingsCursor
|
|
2121
2411
|
const enabled = isProviderEnabled(state.config, pk)
|
|
2122
2412
|
const keyVal = state.config.apiKeys?.[pk] ?? ''
|
|
@@ -2140,22 +2430,37 @@ async function main() {
|
|
|
2140
2430
|
if (testResult === 'pending') testBadge = chalk.yellow('[Testingβ¦]')
|
|
2141
2431
|
else if (testResult === 'ok') testBadge = chalk.greenBright('[Test β
]')
|
|
2142
2432
|
else if (testResult === 'fail') testBadge = chalk.red('[Test β]')
|
|
2433
|
+
const rateSummary = chalk.dim((meta.rateLimits || 'No limit info').slice(0, 36))
|
|
2143
2434
|
|
|
2144
|
-
const enabledBadge = enabled ? chalk.greenBright('β
') : chalk.
|
|
2145
|
-
const providerName = chalk.bold(src.name.padEnd(
|
|
2435
|
+
const enabledBadge = enabled ? chalk.greenBright('β
') : chalk.redBright('β')
|
|
2436
|
+
const providerName = chalk.bold((meta.label || src.name || pk).slice(0, 22).padEnd(22))
|
|
2146
2437
|
const bullet = isCursor ? chalk.bold.cyan(' β― ') : chalk.dim(' ')
|
|
2147
2438
|
|
|
2148
|
-
const row = `${bullet}[ ${enabledBadge} ] ${providerName} ${keyDisplay.padEnd(30)} ${testBadge}`
|
|
2439
|
+
const row = `${bullet}[ ${enabledBadge} ] ${providerName} ${keyDisplay.padEnd(30)} ${testBadge} ${rateSummary}`
|
|
2149
2440
|
lines.push(isCursor ? chalk.bgRgb(30, 30, 60)(row) : row)
|
|
2150
2441
|
}
|
|
2151
2442
|
|
|
2152
2443
|
lines.push('')
|
|
2153
|
-
|
|
2444
|
+
const selectedProviderKey = providerKeys[Math.min(state.settingsCursor, providerKeys.length - 1)]
|
|
2445
|
+
const selectedSource = sources[selectedProviderKey]
|
|
2446
|
+
const selectedMeta = PROVIDER_METADATA[selectedProviderKey] || {}
|
|
2447
|
+
if (selectedSource && state.settingsCursor < telemetryRowIdx) {
|
|
2448
|
+
const selectedKey = getApiKey(state.config, selectedProviderKey)
|
|
2449
|
+
const setupStatus = selectedKey ? chalk.green('API key detected β
') : chalk.yellow('API key missing β ')
|
|
2450
|
+
lines.push(` ${chalk.bold('Setup Instructions')} β ${selectedMeta.label || selectedSource.name || selectedProviderKey}`)
|
|
2451
|
+
lines.push(chalk.dim(` 1) Create a ${selectedMeta.label || selectedSource.name} account: ${selectedMeta.signupUrl || 'signup link missing'}`))
|
|
2452
|
+
lines.push(chalk.dim(` 2) ${selectedMeta.signupHint || 'Generate an API key and paste it with Enter on this row'}`))
|
|
2453
|
+
lines.push(chalk.dim(` 3) Press ${chalk.yellow('T')} to test your key. Status: ${setupStatus}`))
|
|
2454
|
+
lines.push('')
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
lines.push(` ${chalk.bold('π Analytics')}`)
|
|
2458
|
+
lines.push(` ${chalk.dim(' ' + 'β'.repeat(112))}`)
|
|
2154
2459
|
lines.push('')
|
|
2155
2460
|
|
|
2156
2461
|
const telemetryCursor = state.settingsCursor === telemetryRowIdx
|
|
2157
2462
|
const telemetryEnabled = state.config.telemetry?.enabled === true
|
|
2158
|
-
const telemetryStatus = telemetryEnabled ? chalk.greenBright('β
Enabled') : chalk.
|
|
2463
|
+
const telemetryStatus = telemetryEnabled ? chalk.greenBright('β
Enabled') : chalk.redBright('β Disabled')
|
|
2159
2464
|
const telemetryRowBullet = telemetryCursor ? chalk.bold.cyan(' β― ') : chalk.dim(' ')
|
|
2160
2465
|
const telemetryEnv = parseTelemetryEnv(process.env.FREE_CODING_MODELS_TELEMETRY)
|
|
2161
2466
|
const telemetrySource = telemetryEnv === null
|
|
@@ -2164,11 +2469,35 @@ async function main() {
|
|
|
2164
2469
|
const telemetryRow = `${telemetryRowBullet}${chalk.bold('Anonymous usage analytics').padEnd(44)} ${telemetryStatus} ${telemetrySource}`
|
|
2165
2470
|
lines.push(telemetryCursor ? chalk.bgRgb(30, 30, 60)(telemetryRow) : telemetryRow)
|
|
2166
2471
|
|
|
2472
|
+
lines.push('')
|
|
2473
|
+
lines.push(` ${chalk.bold('π Maintenance')}`)
|
|
2474
|
+
lines.push(` ${chalk.dim(' ' + 'β'.repeat(112))}`)
|
|
2475
|
+
lines.push('')
|
|
2476
|
+
|
|
2477
|
+
const updateCursor = state.settingsCursor === updateRowIdx
|
|
2478
|
+
const updateBullet = updateCursor ? chalk.bold.cyan(' β― ') : chalk.dim(' ')
|
|
2479
|
+
const updateState = state.settingsUpdateState
|
|
2480
|
+
const latestFound = state.settingsUpdateLatestVersion
|
|
2481
|
+
const updateActionLabel = updateState === 'available' && latestFound
|
|
2482
|
+
? `Install update (v${latestFound})`
|
|
2483
|
+
: 'Check for updates manually'
|
|
2484
|
+
let updateStatus = chalk.dim('Press Enter or U to check npm registry')
|
|
2485
|
+
if (updateState === 'checking') updateStatus = chalk.yellow('Checking npm registryβ¦')
|
|
2486
|
+
if (updateState === 'available' && latestFound) updateStatus = chalk.greenBright(`Update available: v${latestFound} (Enter to install)`)
|
|
2487
|
+
if (updateState === 'up-to-date') updateStatus = chalk.green('Already on latest version')
|
|
2488
|
+
if (updateState === 'error') updateStatus = chalk.red('Check failed (press U to retry)')
|
|
2489
|
+
if (updateState === 'installing') updateStatus = chalk.cyan('Installing updateβ¦')
|
|
2490
|
+
const updateRow = `${updateBullet}${chalk.bold(updateActionLabel).padEnd(44)} ${updateStatus}`
|
|
2491
|
+
lines.push(updateCursor ? chalk.bgRgb(30, 30, 60)(updateRow) : updateRow)
|
|
2492
|
+
if (updateState === 'error' && state.settingsUpdateError) {
|
|
2493
|
+
lines.push(chalk.red(` ${state.settingsUpdateError}`))
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2167
2496
|
lines.push('')
|
|
2168
2497
|
if (state.settingsEditMode) {
|
|
2169
2498
|
lines.push(chalk.dim(' Type API key β’ Enter Save β’ Esc Cancel'))
|
|
2170
2499
|
} else {
|
|
2171
|
-
lines.push(chalk.dim(' ββ Navigate β’ Enter Edit key / Toggle analytics β’ Space Toggle enabled β’ T Test key β’ Esc Close'))
|
|
2500
|
+
lines.push(chalk.dim(' ββ Navigate β’ Enter Edit key / Toggle analytics / Check-or-Install update β’ Space Toggle enabled β’ T Test key β’ U Check updates β’ Esc Close'))
|
|
2172
2501
|
}
|
|
2173
2502
|
lines.push('')
|
|
2174
2503
|
|
|
@@ -2187,6 +2516,7 @@ async function main() {
|
|
|
2187
2516
|
lines.push('')
|
|
2188
2517
|
lines.push(` ${chalk.bold('β Keyboard Shortcuts')} ${chalk.dim('β press K or Esc to close')}`)
|
|
2189
2518
|
lines.push('')
|
|
2519
|
+
lines.push(` ${chalk.bold('Main TUI')}`)
|
|
2190
2520
|
lines.push(` ${chalk.bold('Navigation')}`)
|
|
2191
2521
|
lines.push(` ${chalk.yellow('ββ')} Navigate rows`)
|
|
2192
2522
|
lines.push(` ${chalk.yellow('Enter')} Select model and launch`)
|
|
@@ -2204,10 +2534,30 @@ async function main() {
|
|
|
2204
2534
|
lines.push(` ${chalk.yellow('W')} Decrease ping interval (faster)`)
|
|
2205
2535
|
lines.push(` ${chalk.yellow('X')} Increase ping interval (slower)`)
|
|
2206
2536
|
lines.push(` ${chalk.yellow('Z')} Cycle launch mode ${chalk.dim('(OpenCode CLI β OpenCode Desktop β OpenClaw)')}`)
|
|
2207
|
-
lines.push(` ${chalk.yellow('
|
|
2537
|
+
lines.push(` ${chalk.yellow('F')} Toggle favorite on selected row ${chalk.dim('(β pinned at top, persisted)')}`)
|
|
2538
|
+
lines.push(` ${chalk.yellow('P')} Open settings ${chalk.dim('(manage API keys, provider toggles, analytics, manual update)')}`)
|
|
2208
2539
|
lines.push(` ${chalk.yellow('K')} / ${chalk.yellow('Esc')} Show/hide this help`)
|
|
2209
2540
|
lines.push(` ${chalk.yellow('Ctrl+C')} Exit`)
|
|
2210
2541
|
lines.push('')
|
|
2542
|
+
lines.push(` ${chalk.bold('Settings (P)')}`)
|
|
2543
|
+
lines.push(` ${chalk.yellow('ββ')} Navigate rows`)
|
|
2544
|
+
lines.push(` ${chalk.yellow('Enter')} Edit key / toggle analytics / check-install update`)
|
|
2545
|
+
lines.push(` ${chalk.yellow('Space')} Toggle provider enable/disable`)
|
|
2546
|
+
lines.push(` ${chalk.yellow('T')} Test selected provider key`)
|
|
2547
|
+
lines.push(` ${chalk.yellow('U')} Check updates manually`)
|
|
2548
|
+
lines.push(` ${chalk.yellow('Esc')} Close settings`)
|
|
2549
|
+
lines.push('')
|
|
2550
|
+
lines.push(` ${chalk.bold('CLI Flags')}`)
|
|
2551
|
+
lines.push(` ${chalk.dim('Usage: free-coding-models [options]')}`)
|
|
2552
|
+
lines.push(` ${chalk.cyan('free-coding-models --opencode')} ${chalk.dim('OpenCode CLI mode')}`)
|
|
2553
|
+
lines.push(` ${chalk.cyan('free-coding-models --opencode-desktop')} ${chalk.dim('OpenCode Desktop mode')}`)
|
|
2554
|
+
lines.push(` ${chalk.cyan('free-coding-models --openclaw')} ${chalk.dim('OpenClaw mode')}`)
|
|
2555
|
+
lines.push(` ${chalk.cyan('free-coding-models --best')} ${chalk.dim('Only top tiers (A+, S, S+)')}`)
|
|
2556
|
+
lines.push(` ${chalk.cyan('free-coding-models --fiable')} ${chalk.dim('10s reliability analysis')}`)
|
|
2557
|
+
lines.push(` ${chalk.cyan('free-coding-models --tier S|A|B|C')} ${chalk.dim('Filter by tier letter')}`)
|
|
2558
|
+
lines.push(` ${chalk.cyan('free-coding-models --no-telemetry')} ${chalk.dim('Disable telemetry for this run')}`)
|
|
2559
|
+
lines.push(` ${chalk.dim('Flags can be combined: --openclaw --tier S')}`)
|
|
2560
|
+
lines.push('')
|
|
2211
2561
|
const cleared = lines.map(l => l + EL)
|
|
2212
2562
|
const remaining = state.terminalRows > 0 ? Math.max(0, state.terminalRows - cleared.length) : 0
|
|
2213
2563
|
for (let i = 0; i < remaining; i++) cleared.push(EL)
|
|
@@ -2227,15 +2577,51 @@ async function main() {
|
|
|
2227
2577
|
if (!testModel) { state.settingsTestResults[providerKey] = 'fail'; return }
|
|
2228
2578
|
|
|
2229
2579
|
state.settingsTestResults[providerKey] = 'pending'
|
|
2230
|
-
const { code } = await ping(testKey, testModel, src.url)
|
|
2580
|
+
const { code } = await ping(testKey, testModel, providerKey, src.url)
|
|
2231
2581
|
state.settingsTestResults[providerKey] = code === '200' ? 'ok' : 'fail'
|
|
2232
2582
|
}
|
|
2233
2583
|
|
|
2584
|
+
// π Manual update checker from settings; keeps status visible in maintenance row.
|
|
2585
|
+
async function checkUpdatesFromSettings() {
|
|
2586
|
+
if (state.settingsUpdateState === 'checking' || state.settingsUpdateState === 'installing') return
|
|
2587
|
+
state.settingsUpdateState = 'checking'
|
|
2588
|
+
state.settingsUpdateError = null
|
|
2589
|
+
const { latestVersion, error } = await checkForUpdateDetailed()
|
|
2590
|
+
if (error) {
|
|
2591
|
+
state.settingsUpdateState = 'error'
|
|
2592
|
+
state.settingsUpdateLatestVersion = null
|
|
2593
|
+
state.settingsUpdateError = error
|
|
2594
|
+
return
|
|
2595
|
+
}
|
|
2596
|
+
if (latestVersion) {
|
|
2597
|
+
state.settingsUpdateState = 'available'
|
|
2598
|
+
state.settingsUpdateLatestVersion = latestVersion
|
|
2599
|
+
state.settingsUpdateError = null
|
|
2600
|
+
return
|
|
2601
|
+
}
|
|
2602
|
+
state.settingsUpdateState = 'up-to-date'
|
|
2603
|
+
state.settingsUpdateLatestVersion = null
|
|
2604
|
+
state.settingsUpdateError = null
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
// π Leaves TUI cleanly, then runs npm global update command.
|
|
2608
|
+
function launchUpdateFromSettings(latestVersion) {
|
|
2609
|
+
if (!latestVersion) return
|
|
2610
|
+
state.settingsUpdateState = 'installing'
|
|
2611
|
+
clearInterval(ticker)
|
|
2612
|
+
clearTimeout(state.pingIntervalObj)
|
|
2613
|
+
process.stdin.removeListener('keypress', onKeyPress)
|
|
2614
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
2615
|
+
process.stdin.pause()
|
|
2616
|
+
process.stdout.write(ALT_LEAVE)
|
|
2617
|
+
runUpdate(latestVersion)
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2234
2620
|
// Apply CLI --tier filter if provided
|
|
2235
2621
|
if (cliArgs.tierFilter) {
|
|
2236
2622
|
const allowed = TIER_LETTER_MAP[cliArgs.tierFilter]
|
|
2237
2623
|
state.results.forEach(r => {
|
|
2238
|
-
r.hidden = !allowed.includes(r.tier)
|
|
2624
|
+
r.hidden = r.isFavorite ? false : !allowed.includes(r.tier)
|
|
2239
2625
|
})
|
|
2240
2626
|
}
|
|
2241
2627
|
|
|
@@ -2259,6 +2645,7 @@ async function main() {
|
|
|
2259
2645
|
if (state.settingsOpen) {
|
|
2260
2646
|
const providerKeys = Object.keys(sources)
|
|
2261
2647
|
const telemetryRowIdx = providerKeys.length
|
|
2648
|
+
const updateRowIdx = providerKeys.length + 1
|
|
2262
2649
|
|
|
2263
2650
|
// π Edit mode: capture typed characters for the API key
|
|
2264
2651
|
if (state.settingsEditMode) {
|
|
@@ -2301,6 +2688,11 @@ async function main() {
|
|
|
2301
2688
|
// π Re-index results
|
|
2302
2689
|
results.forEach((r, i) => { r.idx = i + 1 })
|
|
2303
2690
|
state.results = results
|
|
2691
|
+
syncFavoriteFlags(state.results, state.config)
|
|
2692
|
+
applyTierFilter()
|
|
2693
|
+
const visible = state.results.filter(r => !r.hidden)
|
|
2694
|
+
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
2695
|
+
if (state.cursor >= state.visibleSorted.length) state.cursor = Math.max(0, state.visibleSorted.length - 1)
|
|
2304
2696
|
adjustScrollOffset(state)
|
|
2305
2697
|
// π Re-ping all models that were 'noauth' (got 401 without key) but now have a key
|
|
2306
2698
|
// π This makes the TUI react immediately when a user adds an API key in settings
|
|
@@ -2320,7 +2712,7 @@ async function main() {
|
|
|
2320
2712
|
return
|
|
2321
2713
|
}
|
|
2322
2714
|
|
|
2323
|
-
if (key.name === 'down' && state.settingsCursor <
|
|
2715
|
+
if (key.name === 'down' && state.settingsCursor < updateRowIdx) {
|
|
2324
2716
|
state.settingsCursor++
|
|
2325
2717
|
return
|
|
2326
2718
|
}
|
|
@@ -2333,6 +2725,14 @@ async function main() {
|
|
|
2333
2725
|
saveConfig(state.config)
|
|
2334
2726
|
return
|
|
2335
2727
|
}
|
|
2728
|
+
if (state.settingsCursor === updateRowIdx) {
|
|
2729
|
+
if (state.settingsUpdateState === 'available' && state.settingsUpdateLatestVersion) {
|
|
2730
|
+
launchUpdateFromSettings(state.settingsUpdateLatestVersion)
|
|
2731
|
+
return
|
|
2732
|
+
}
|
|
2733
|
+
checkUpdatesFromSettings()
|
|
2734
|
+
return
|
|
2735
|
+
}
|
|
2336
2736
|
|
|
2337
2737
|
// π Enter edit mode for the selected provider's key
|
|
2338
2738
|
const pk = providerKeys[state.settingsCursor]
|
|
@@ -2349,6 +2749,7 @@ async function main() {
|
|
|
2349
2749
|
saveConfig(state.config)
|
|
2350
2750
|
return
|
|
2351
2751
|
}
|
|
2752
|
+
if (state.settingsCursor === updateRowIdx) return
|
|
2352
2753
|
|
|
2353
2754
|
// π Toggle enabled/disabled for selected provider
|
|
2354
2755
|
const pk = providerKeys[state.settingsCursor]
|
|
@@ -2360,7 +2761,7 @@ async function main() {
|
|
|
2360
2761
|
}
|
|
2361
2762
|
|
|
2362
2763
|
if (key.name === 't') {
|
|
2363
|
-
if (state.settingsCursor === telemetryRowIdx) return
|
|
2764
|
+
if (state.settingsCursor === telemetryRowIdx || state.settingsCursor === updateRowIdx) return
|
|
2364
2765
|
|
|
2365
2766
|
// π Test the selected provider's key (fires a real ping)
|
|
2366
2767
|
const pk = providerKeys[state.settingsCursor]
|
|
@@ -2368,6 +2769,11 @@ async function main() {
|
|
|
2368
2769
|
return
|
|
2369
2770
|
}
|
|
2370
2771
|
|
|
2772
|
+
if (key.name === 'u') {
|
|
2773
|
+
checkUpdatesFromSettings()
|
|
2774
|
+
return
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2371
2777
|
if (key.ctrl && key.name === 'c') { exit(0); return }
|
|
2372
2778
|
return // π Swallow all other keys while settings is open
|
|
2373
2779
|
}
|
|
@@ -2400,12 +2806,29 @@ async function main() {
|
|
|
2400
2806
|
}
|
|
2401
2807
|
// π Recompute visible sorted list and reset cursor to top to avoid stale index
|
|
2402
2808
|
const visible = state.results.filter(r => !r.hidden)
|
|
2403
|
-
state.visibleSorted =
|
|
2809
|
+
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
2404
2810
|
state.cursor = 0
|
|
2405
2811
|
state.scrollOffset = 0
|
|
2406
2812
|
return
|
|
2407
2813
|
}
|
|
2408
2814
|
|
|
2815
|
+
// π F key: toggle favorite on the currently selected row and persist to config.
|
|
2816
|
+
if (key.name === 'f') {
|
|
2817
|
+
const selected = state.visibleSorted[state.cursor]
|
|
2818
|
+
if (!selected) return
|
|
2819
|
+
toggleFavoriteModel(state.config, selected.providerKey, selected.modelId)
|
|
2820
|
+
syncFavoriteFlags(state.results, state.config)
|
|
2821
|
+
applyTierFilter()
|
|
2822
|
+
const selectedKey = toFavoriteKey(selected.providerKey, selected.modelId)
|
|
2823
|
+
const visible = state.results.filter(r => !r.hidden)
|
|
2824
|
+
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
2825
|
+
const newCursor = state.visibleSorted.findIndex(r => toFavoriteKey(r.providerKey, r.modelId) === selectedKey)
|
|
2826
|
+
if (newCursor >= 0) state.cursor = newCursor
|
|
2827
|
+
else if (state.cursor >= state.visibleSorted.length) state.cursor = Math.max(0, state.visibleSorted.length - 1)
|
|
2828
|
+
adjustScrollOffset(state)
|
|
2829
|
+
return
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2409
2832
|
// π Interval adjustment keys: W=decrease (faster), X=increase (slower)
|
|
2410
2833
|
// π Minimum 1s, maximum 60s
|
|
2411
2834
|
if (key.name === 'w') {
|
|
@@ -2420,7 +2843,7 @@ async function main() {
|
|
|
2420
2843
|
applyTierFilter()
|
|
2421
2844
|
// π Recompute visible sorted list and reset cursor to avoid stale index into new filtered set
|
|
2422
2845
|
const visible = state.results.filter(r => !r.hidden)
|
|
2423
|
-
state.visibleSorted =
|
|
2846
|
+
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
2424
2847
|
state.cursor = 0
|
|
2425
2848
|
state.scrollOffset = 0
|
|
2426
2849
|
return
|
|
@@ -2432,7 +2855,7 @@ async function main() {
|
|
|
2432
2855
|
applyTierFilter()
|
|
2433
2856
|
// π Recompute visible sorted list and reset cursor to avoid stale index into new filtered set
|
|
2434
2857
|
const visible = state.results.filter(r => !r.hidden)
|
|
2435
|
-
state.visibleSorted =
|
|
2858
|
+
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
2436
2859
|
state.cursor = 0
|
|
2437
2860
|
state.scrollOffset = 0
|
|
2438
2861
|
return
|
|
@@ -2542,7 +2965,7 @@ async function main() {
|
|
|
2542
2965
|
// π Cache visible+sorted models each frame so Enter handler always matches the display
|
|
2543
2966
|
if (!state.settingsOpen) {
|
|
2544
2967
|
const visible = state.results.filter(r => !r.hidden)
|
|
2545
|
-
state.visibleSorted =
|
|
2968
|
+
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
2546
2969
|
}
|
|
2547
2970
|
const content = state.settingsOpen
|
|
2548
2971
|
? renderSettings()
|
|
@@ -2554,7 +2977,7 @@ async function main() {
|
|
|
2554
2977
|
|
|
2555
2978
|
// π Populate visibleSorted before the first frame so Enter works immediately
|
|
2556
2979
|
const initialVisible = state.results.filter(r => !r.hidden)
|
|
2557
|
-
state.visibleSorted =
|
|
2980
|
+
state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection)
|
|
2558
2981
|
|
|
2559
2982
|
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))
|
|
2560
2983
|
|
|
@@ -2566,7 +2989,7 @@ async function main() {
|
|
|
2566
2989
|
const pingModel = async (r) => {
|
|
2567
2990
|
const providerApiKey = getApiKey(state.config, r.providerKey) ?? null
|
|
2568
2991
|
const providerUrl = sources[r.providerKey]?.url ?? sources.nvidia.url
|
|
2569
|
-
const { code, ms } = await ping(providerApiKey, r.modelId, providerUrl)
|
|
2992
|
+
const { code, ms } = await ping(providerApiKey, r.modelId, r.providerKey, providerUrl)
|
|
2570
2993
|
|
|
2571
2994
|
// π Store ping result as object with ms and code
|
|
2572
2995
|
// π ms = actual response time (even for errors like 429)
|