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.
@@ -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 (3 providers: NIM, Groq, Cerebras)
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, Groq, Cerebras β€” extensible)
23
- * - Settings screen (P key) to manage API keys per provider, enable/disable, test 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
- * - `promptApiKey`: Interactive wizard for first-time NVIDIA API key setup
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`: Perform HTTP request to NIM endpoint with timeout handling
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 β€” 53 models across NIM, Groq, Cerebras
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
- async function checkForUpdate() {
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
- } catch {}
424
- return null
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 3 providers sequentially β€” each is optional (Enter to skip).
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
- // πŸ“– Provider definitions: label, key field, url for getting the key
499
- const providers = [
500
- {
501
- key: 'nvidia',
502
- label: 'NVIDIA NIM',
503
- color: chalk.rgb(118, 185, 0),
504
- url: 'https://build.nvidia.com',
505
- hint: 'Profile β†’ API Keys β†’ Generate',
506
- prefix: 'nvapi-',
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 = sortResults(visibleResults, sortColumn, sortDirection)
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
- const name = r.label.slice(0, W_MODEL).padEnd(W_MODEL)
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
- // πŸ“– url param is the provider's endpoint URL β€” differs per provider (NIM, Groq, Cerebras).
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
- async function ping(apiKey, modelId, url) {
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
- // πŸ“– Only attach Authorization header when a key is available
1133
- const headers = { 'Content-Type': 'application/json' }
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({ model: modelId, messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 }),
1177
+ headers: req.headers,
1178
+ body: JSON.stringify(req.body),
1139
1179
  })
1140
- return { code: String(resp.status), ms: Math.round(performance.now() - t0) }
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', args, {
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 all 3 providers: nvidia (needs custom provider config), groq & cerebras (built-in in OpenCode).
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 all 3 providers: nvidia (needs custom provider config), groq & cerebras (built-in).
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.dim('⬜')
2145
- const providerName = chalk.bold(src.name.padEnd(10))
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
- lines.push(` ${chalk.bold('Analytics')}`)
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.dim('⬜ Disabled')
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('P')} Open settings ${chalk.dim('(manage API keys, provider toggles, analytics toggle)')}`)
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 < telemetryRowIdx) {
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 = sortResults(visible, state.sortColumn, state.sortDirection)
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 = sortResults(visible, state.sortColumn, state.sortDirection)
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 = sortResults(visible, state.sortColumn, state.sortDirection)
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 = sortResults(visible, state.sortColumn, state.sortDirection)
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 = sortResults(initialVisible, state.sortColumn, state.sortDirection)
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)