free-coding-models 0.5.28 → 0.5.29

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.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @file shared-helpers.js
3
+ * @description Shared utility functions used across multiple modules.
4
+ *
5
+ * @details
6
+ * 📖 DRY helpers extracted from router-daemon.js, tool-launchers.js,
7
+ * 📖 endpoint-installer.js, and legacy-proxy-cleanup.js to eliminate
8
+ * duplicate implementations of the same patterns.
9
+ *
10
+ * @functions
11
+ * → sleep — Promise-based setTimeout
12
+ * → ensureDir — Create parent directory if missing
13
+ * → readJson — Read and parse JSON file with fallback
14
+ * → writeJson — Write JSON file with directory creation
15
+ * → atomicWriteJson — Atomic write via temp file + rename
16
+ * → safeJsonParse — JSON.parse with fallback
17
+ * → maskApiKey — Mask API key for display (show last 4 chars)
18
+ *
19
+ * @exports sleep, ensureDir, readJson, writeJson, atomicWriteJson, safeJsonParse, maskApiKey
20
+ */
21
+
22
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
23
+ import { dirname } from 'node:path'
24
+
25
+ /**
26
+ * 📖 Promise-based sleep. Used by daemon probe staggering, TUI animations, etc.
27
+ * @param {number} ms
28
+ * @returns {Promise<void>}
29
+ */
30
+ export function sleep(ms) {
31
+ return new Promise((resolve) => setTimeout(resolve, ms))
32
+ }
33
+
34
+ /**
35
+ * 📖 Create parent directory of `filePath` if it doesn't exist.
36
+ * @param {string} filePath
37
+ */
38
+ export function ensureDir(filePath) {
39
+ const dir = dirname(filePath)
40
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
41
+ }
42
+
43
+ /**
44
+ * 📖 Read and parse a JSON file. Returns `fallback` on any error.
45
+ * @param {string} filePath
46
+ * @param {*} [fallback=null]
47
+ * @returns {*}
48
+ */
49
+ export function readJson(filePath, fallback = null) {
50
+ if (!existsSync(filePath)) return fallback
51
+ try {
52
+ return JSON.parse(readFileSync(filePath, 'utf8'))
53
+ } catch {
54
+ return fallback
55
+ }
56
+ }
57
+
58
+ /**
59
+ * 📖 Write JSON to file, creating parent directories as needed.
60
+ * @param {string} filePath
61
+ * @param {*} value
62
+ * @param {object} [options]
63
+ * @param {boolean} [options.backup=false] — Not implemented here; callers handle it
64
+ */
65
+ export function writeJson(filePath, value) {
66
+ ensureDir(filePath)
67
+ writeFileSync(filePath, JSON.stringify(value, null, 2))
68
+ }
69
+
70
+ /**
71
+ * 📖 Atomic JSON write: writes to a temp file, then renames over the target.
72
+ * Prevents partial writes from corrupting the file on crash.
73
+ * @param {string} path
74
+ * @param {*} data
75
+ * @param {number} [mode=0o600]
76
+ */
77
+ export function atomicWriteJson(path, data, mode = 0o600) {
78
+ const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`
79
+ writeFileSync(tempPath, JSON.stringify(data, null, 2), { mode })
80
+ renameSync(tempPath, path)
81
+ }
82
+
83
+ /**
84
+ * 📖 JSON.parse with fallback. Returns `fallback` on parse failure.
85
+ * @param {string} raw
86
+ * @param {*} [fallback=null]
87
+ * @returns {*}
88
+ */
89
+ export function safeJsonParse(raw, fallback = null) {
90
+ try {
91
+ return JSON.parse(raw)
92
+ } catch {
93
+ return fallback
94
+ }
95
+ }
96
+
97
+ /**
98
+ * 📖 Mask an API key for display. Shows last 4 chars, rest as bullets.
99
+ * @param {string} key
100
+ * @returns {string}
101
+ */
102
+ /**
103
+ * 📖 Check if a provider supports routing (has chat/completions URL, not CLI-only).
104
+ * @param {string} providerKey
105
+ * @param {Record<string, {url?: string, cliOnly?: boolean}>} sources — provider catalog
106
+ * @returns {boolean}
107
+ */
108
+ export function isRouteableProvider(providerKey, sources) {
109
+ const source = sources[providerKey]
110
+ return Boolean(source?.url && !source.cliOnly && source.url.includes('/chat/completions'))
111
+ }
112
+
113
+ export function maskApiKey(key) {
114
+ if (!key || typeof key !== 'string') return ''
115
+ if (key.length <= 8) return '••••••••'
116
+ return '••••••••' + key.slice(-4)
117
+ }
@@ -39,9 +39,8 @@ import {
39
39
  import { resolveCloudflareUrl } from './ping.js'
40
40
  import { ROUTER_PID_PATH } from './router-daemon.js'
41
41
  import { existsSync, readFileSync } from 'node:fs'
42
-
43
- // 📖 Tier ordering best tiers first, used for scoring candidates.
44
- const TIER_ORDER = ['S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
42
+ import { TIER_ORDER, parseSweToNum } from './utils.js'
43
+ import { isRouteableProvider } from './shared-helpers.js'
45
44
 
46
45
  // 📖 Numeric value per tier for composite scoring.
47
46
  const TIER_SCORES = {
@@ -69,14 +68,7 @@ const OPENROUTER_FREE_MODEL_IDS = new Set([
69
68
  'openrouter/owl-alpha',
70
69
  ])
71
70
 
72
- /**
73
- * Check whether a provider's catalog entry supports routing (has a
74
- * chat/completions URL and is not CLI-only).
75
- */
76
- function isRouteableProvider(providerKey) {
77
- const source = sources[providerKey]
78
- return Boolean(source?.url && !source.cliOnly && source.url.includes('/chat/completions'))
79
- }
71
+ // 📖 isRouteableProvider imported from shared-helpers.js (needs `sources` param)
80
72
 
81
73
  /**
82
74
  * Resolve the upstream URL for a provider, handling Cloudflare template substitution.
@@ -98,14 +90,7 @@ function isOpenRouterFreeModelId(modelId) {
98
90
  return String(modelId).endsWith(':free') || OPENROUTER_FREE_MODEL_IDS.has(String(modelId))
99
91
  }
100
92
 
101
- /**
102
- * Parse a SWE-bench percentage string like "49.2%" to a number.
103
- */
104
- function parseSwePercent(value) {
105
- if (typeof value !== 'string') return 0
106
- const numeric = parseFloat(value.replace('%', '').trim())
107
- return Number.isFinite(numeric) ? numeric : 0
108
- }
93
+ // 📖 parseSwePercent replaced by shared parseSweToNum (same logic)
109
94
 
110
95
  /**
111
96
  * Score a candidate model for ranking. Higher is better.
@@ -156,12 +141,12 @@ export function buildSyncCandidates(apiKeys, options = {}) {
156
141
 
157
142
  for (const [providerKey, sourceData] of Object.entries(sources)) {
158
143
  if (!apiKeys[providerKey]) continue
159
- if (!isRouteableProvider(providerKey)) continue
144
+ if (!isRouteableProvider(providerKey, sources)) continue
160
145
 
161
146
  for (const tuple of sourceData.models || []) {
162
147
  const [modelId, label = '', tier = '', swe = '0%'] = tuple
163
148
  if (typeof modelId !== 'string' || !modelId.trim()) continue
164
- const swePercent = parseSwePercent(swe)
149
+ const swePercent = parseSweToNum(swe)
165
150
  if (shouldSkipModel(providerKey, modelId, tier, swePercent, options)) continue
166
151
  const score = scoreCandidate(providerKey, modelId, label, tier, swePercent)
167
152
  candidates.push({
@@ -39,7 +39,7 @@
39
39
  import chalk from 'chalk'
40
40
  import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'fs'
41
41
  import { homedir } from 'os'
42
- import { dirname, join } from 'path'
42
+ import { join } from 'path'
43
43
  import { spawn, spawnSync } from 'child_process'
44
44
  import { sources } from '../../sources.js'
45
45
  import { PROVIDER_COLOR } from '../tui/render-table.js'
@@ -48,6 +48,7 @@ import { ENV_VAR_NAMES, isWindows } from './provider-metadata.js'
48
48
  import { getToolMeta, TOOL_METADATA } from './tool-metadata.js'
49
49
  import { PROVIDER_METADATA } from './provider-metadata.js'
50
50
  import { resolveToolBinaryPath } from './tool-bootstrap.js'
51
+ import { ensureDir, readJson, writeJson } from './shared-helpers.js'
51
52
 
52
53
  const OPENAI_COMPAT_ENV_KEYS = [
53
54
  'OPENAI_API_KEY',
@@ -60,11 +61,6 @@ const OPENAI_COMPAT_ENV_KEYS = [
60
61
  ]
61
62
  const SANITIZED_TOOL_ENV_KEYS = [...OPENAI_COMPAT_ENV_KEYS]
62
63
 
63
- function ensureDir(filePath) {
64
- const dir = dirname(filePath)
65
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
66
- }
67
-
68
64
  // 📖 Parse a context window string (e.g. "128k", "1M", "32k") to token count number.
69
65
  function parseCtxToTokens(ctx) {
70
66
  if (!ctx || typeof ctx !== 'string') return null
@@ -104,19 +100,7 @@ function backupIfExists(filePath) {
104
100
  return backupPath
105
101
  }
106
102
 
107
- function readJson(filePath, fallback) {
108
- if (!existsSync(filePath)) return fallback
109
- try {
110
- return JSON.parse(readFileSync(filePath, 'utf8'))
111
- } catch {
112
- return fallback
113
- }
114
- }
115
-
116
- function writeJson(filePath, value) {
117
- ensureDir(filePath)
118
- writeFileSync(filePath, JSON.stringify(value, null, 2))
119
- }
103
+ // 📖 readJson/writeJson imported from shared-helpers.js
120
104
 
121
105
  function getProviderBaseUrl(providerKey) {
122
106
  const url = sources[providerKey]?.url
@@ -966,55 +950,8 @@ export async function startExternalTool(mode, model, config) {
966
950
  console.log(chalk.cyan(` ▶ Launching ${meta.label} with ${chalk.bold(model.label)}...`))
967
951
  printConfigArtifacts(meta.label, launchPlan.configArtifacts)
968
952
 
969
- if (mode === 'aider') {
970
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
971
- }
972
-
973
- if (mode === 'crush') {
974
- console.log(chalk.dim(' 📖 Crush will use the provider directly for this launch.'))
975
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
976
- }
977
-
978
- if (mode === 'goose') {
979
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
980
- }
981
-
982
- if (mode === 'qwen') {
983
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
984
- }
985
-
986
- if (mode === 'openhands') {
987
- console.log(chalk.dim(` 📖 OpenHands launched with model: ${model.modelId}`))
988
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
989
- }
990
-
991
- if (mode === 'amp') {
992
- console.log(chalk.dim(` 📖 Amp config updated with model: ${model.modelId}`))
993
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
994
- }
995
-
996
- if (mode === 'pi') {
997
- // 📖 Pi supports --provider and --model flags for guaranteed auto-selection
998
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
999
- }
1000
-
1001
- if (mode === 'hermes') {
1002
- // 📖 Restart the Hermes gateway so the new model config takes effect immediately
1003
- restartHermesGateway()
1004
- console.log(chalk.dim(` 📖 Hermes Agent configured with model: ${model.modelId}`))
1005
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
1006
- }
1007
-
1008
- if (mode === 'continue') {
1009
- console.log(chalk.dim(` 📖 Continue CLI configured with model: ${model.modelId}`))
1010
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
1011
- }
1012
-
1013
- if (mode === 'cline') {
1014
- console.log(chalk.dim(` 📖 Cline configured with model: ${model.modelId}`))
1015
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
1016
- }
1017
-
953
+ // 📖 Pre-launch hooks for tools that need special treatment
954
+ if (mode === 'hermes') restartHermesGateway()
1018
955
  if (mode === 'xcode') {
1019
956
  const xcodeUrl = launchPlan.baseUrl ? launchPlan.baseUrl.replace(/\/v1$/, '').replace(/\/v1\/chat\/completions$/, '') : ''
1020
957
  console.log(chalk.bold.cyan('\n 🛠️ Xcode Intelligence Setup Instructions:'))
@@ -1027,29 +964,24 @@ export async function startExternalTool(mode, model, config) {
1027
964
  console.log(chalk.dim(' Description: ') + chalk.green(`FCM - ${sources[model.providerKey]?.name || model.providerKey}`))
1028
965
  console.log(chalk.white(` 4. Click Add, then select `) + chalk.bold(model.modelId) + chalk.white(` from the list.\n`))
1029
966
  console.log(chalk.dim(` 📖 Attempting to launch Xcode...`))
1030
- return spawnCommand(launchPlan.command, launchPlan.args, launchPlan.env)
1031
967
  }
1032
-
1033
- if (mode === 'caveman') {
1034
- console.log(chalk.dim(` 📖 Launching Caveman Code...`))
1035
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
1036
- }
1037
-
1038
- if (mode === 'jcode') {
1039
- console.log(chalk.dim(` 📖 Launching jcode...`))
1040
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
1041
- }
1042
-
1043
- if (mode === 'copilot') {
1044
- console.log(chalk.dim(` 📖 Copilot CLI configured with model: ${model.modelId}`))
1045
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
1046
- }
1047
-
1048
- if (mode === 'forgecode') {
1049
- console.log(chalk.dim(` 📖 ForgeCode configured with model: ${model.modelId}`))
1050
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
1051
- }
1052
-
1053
- console.log(chalk.red(` X Unsupported external tool mode: ${mode}`))
1054
- return 1
968
+ if (mode === 'crush') console.log(chalk.dim(' 📖 Crush will use the provider directly for this launch.'))
969
+
970
+ // 📖 Tool-specific info messages (only for modes that have no prepare-step message)
971
+ const infoMessages = {
972
+ openhands: ` 📖 OpenHands launched with model: ${model.modelId}`,
973
+ amp: ` 📖 Amp config updated with model: ${model.modelId}`,
974
+ hermes: ` 📖 Hermes Agent configured with model: ${model.modelId}`,
975
+ continue: ` 📖 Continue CLI configured with model: ${model.modelId}`,
976
+ cline: ` 📖 Cline configured with model: ${model.modelId}`,
977
+ caveman: ' 📖 Launching Caveman Code...',
978
+ jcode: ' 📖 Launching jcode...',
979
+ copilot: ` 📖 Copilot CLI configured with model: ${model.modelId}`,
980
+ forgecode: ` 📖 ForgeCode configured with model: ${model.modelId}`,
981
+ }
982
+ if (infoMessages[mode]) console.log(chalk.dim(infoMessages[mode]))
983
+
984
+ // 📖 xcode uses raw command ("open"), everything else resolves via tool-bootstrap
985
+ const command = mode === 'xcode' ? launchPlan.command : resolveLaunchCommand(mode, launchPlan.command)
986
+ return spawnCommand(command, launchPlan.args, launchPlan.env)
1055
987
  }
package/src/core/utils.js CHANGED
@@ -41,6 +41,7 @@
41
41
  * @exports sortResults, filterByTier, findBestModel, parseArgs
42
42
  * @exports scoreModelForTask, getTopRecommendations
43
43
  * @exports TIER_ORDER, VERDICT_ORDER, TIER_LETTER_MAP, TASK_TYPES, PRIORITY_TYPES, CONTEXT_BUDGETS
44
+ * @exports parseCtxToK, parseSweToNum, formatCtxWindow, labelFromId, NEW_MODELS, getVersionStatusInfo, formatResultsAsJSON
44
45
  *
45
46
  * @see bin/free-coding-models.js — main CLI that imports these utils
46
47
  * @see sources.js — model definitions consumed by these functions
@@ -115,80 +116,27 @@ export const getAvg = (r) => {
115
116
  //
116
117
  // 📖 The "wasUpBefore" check is key — it distinguishes between a model that's
117
118
  // temporarily flaky vs one that was never reachable in the first place.
119
+ // 📖 NEW_MODEL_DURATION_MS — how long a model shows the 🆕 badge after being added.
120
+ // 📖 5 days in milliseconds.
121
+ export const NEW_MODEL_DURATION_MS = 5 * 24 * 60 * 60 * 1000
122
+
123
+ /**
124
+ * 📖 Check if a model should display the 🆕 badge.
125
+ * 📖 A model is "new" if its addedDate is within the last 5 days.
126
+ * 📖 `addedDate` comes from sources.js as the optional 6th element of model tuples.
127
+ * @param {string|null|undefined} addedDate — ISO date string (e.g. '2026-06-10')
128
+ * @returns {boolean}
129
+ */
130
+ export function isNewModel(addedDate) {
131
+ if (!addedDate || typeof addedDate !== 'string') return false
132
+ const added = Date.parse(addedDate)
133
+ if (!Number.isFinite(added)) return false
134
+ return (Date.now() - added) < NEW_MODEL_DURATION_MS
135
+ }
136
+
137
+ // 📖 NEW_MODELS kept for backward compat with web/website surfaces that
138
+ // 📖 don't have addedDate. Will be removed once all surfaces migrate.
118
139
  export const NEW_MODELS = new Set([
119
- 'nvidia/nemotron-3-ultra-550b-a55b',
120
- '@cf/meta/llama-3.2-90b-instruct',
121
- '@cf/mistralai/mistral-7b-instruct-v0.2',
122
- '@cf/google/gemma-2-9b-it',
123
- '@cf/anthropic/claude-3-5-sonnet',
124
- '@cf/openai/gpt-4o-mini',
125
- '@cf/qwen/qwen3-30b-a3b-fp8',
126
- '@cf/qwen/qwen2.5-coder-32b-instruct',
127
- '@cf/meta/llama-3.3-70b-instruct-fp8-fast',
128
- '@cf/google/gemma-4-26b-a4b-it',
129
- '@cf/mistralai/mistral-small-3.1-24b-instruct',
130
- '@cf/ibm/granite-4.0-h-micro',
131
- 'minimaxai/minimax-m2.7',
132
- 'z-ai/glm-5.1',
133
- 'moonshotai/kimi-k2.6',
134
- 'stepfun-ai/step-3.5-flash',
135
- 'stepfun-ai/step-3.7-flash',
136
- 'qwen/qwen3-coder-480b-a35b-instruct',
137
- 'qwen/qwen3.5-397b-a17b',
138
- 'meta/llama-4-maverick-17b-128e-instruct',
139
- 'mistralai/mistral-medium-3.5-128b',
140
- 'mistralai/mistral-small-4-119b-2603',
141
- 'qwen/qwen3.5-122b-a10b',
142
- 'mistralai/mistral-large-3-675b-instruct-2512',
143
- 'nvidia/nemotron-3-super-120b-a12b',
144
- 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning',
145
- 'google/gemma-4-31b-it',
146
- 'bytedance/seed-oss-36b-instruct',
147
- 'stockmark/stockmark-2-100b-instruct',
148
- 'mistralai/ministral-14b-instruct-2512',
149
- 'meta/llama-3.2-11b-vision-instruct',
150
- 'microsoft/phi-4-mini-instruct',
151
- 'gemma-3-12b-it',
152
- 'nvidia/nemotron-nano-9b-v2',
153
- 'openrouter/owl-alpha',
154
- 'nousresearch/hermes-3-llama-3.1-405b:free',
155
- 'nvidia/nemotron-nano-30b-a3b:free',
156
- 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free',
157
- 'meta-llama/llama-3.3-70b-instruct:free',
158
- 'meta-llama/llama-3.2-3b-instruct:free',
159
- 'liquid/lfm-2.5-1.2b-instruct:free',
160
- 'liquid/lfm-2.5-1.2b-thinking:free',
161
- 'qwen3.7-max',
162
- 'qwen3-max',
163
- 'qwen3.6-plus',
164
- 'qwen3-235b-a22b',
165
- 'qwen3.5-plus',
166
- 'qwen3-coder-plus',
167
- 'qwen3-coder-next',
168
- 'qwen3.6-flash',
169
- 'qwen3.5-flash',
170
- 'qwen3-coder-flash',
171
- 'qwen3-32b',
172
- 'qwen3-coder-30b-a3b-instruct',
173
- 'holo2-30b-a3b',
174
- 'llama-3.3-70b-instruct',
175
- 'mistral-small-3.2-24b-instruct-2506',
176
- 'gemma-3-27b-it',
177
- 'qwen3.5-397b-a17b',
178
- 'qwen3-coder-30b-a3b-instruct',
179
- 'gpt-oss-120b',
180
- 'gpt-oss-20b',
181
- 'Meta-Llama-3_3-70B-Instruct',
182
- 'Qwen3-32B',
183
- 'Mistral-Small-3.2-24B-Instruct-2506',
184
- 'Mistral-7B-Instruct-v0.3',
185
- 'Mistral-Nemo-Instruct-2407',
186
- 'Qwen3.5-9B',
187
- 'big-pickle',
188
- 'deepseek-v4-flash-free',
189
- 'mimo-v2.5-free',
190
- 'nemotron-3-super-free',
191
- 'minimax-m3-free'
192
140
  ]);
193
141
 
194
142
  export const getVerdict = (r) => {
@@ -376,34 +324,12 @@ export const sortResults = (results, sortColumn, sortDirection, { benchmarkResul
376
324
  break
377
325
  case 'swe': {
378
326
  // 📖 Sort by SWE-bench score — higher is better
379
- // 📖 Parse percentage strings like "49.2%", "73.1%" or use 0 for missing values
380
- const parseSwe = (score) => {
381
- if (!score || score === '—') return 0
382
- const num = parseFloat(score.replace('%', ''))
383
- return isNaN(num) ? 0 : num
384
- }
385
- cmp = parseSwe(a.sweScore) - parseSwe(b.sweScore)
327
+ cmp = parseSweToNum(a.sweScore) - parseSweToNum(b.sweScore)
386
328
  break
387
329
  }
388
330
  case 'ctx': {
389
- // 📖 Sort by context window size — larger is better
390
- // 📖 Parse strings like "128k", "32k", "1m" into numeric tokens
391
- const parseCtx = (ctx) => {
392
- if (!ctx || ctx === '—') return 0
393
- const str = ctx.toLowerCase()
394
- // 📖 Handle millions (1m = 1000k)
395
- if (str.includes('m')) {
396
- const num = parseFloat(str.replace('m', ''))
397
- return num * 1000
398
- }
399
- // 📖 Handle thousands (128k)
400
- if (str.includes('k')) {
401
- const num = parseFloat(str.replace('k', ''))
402
- return num
403
- }
404
- return 0
405
- }
406
- cmp = parseCtx(a.ctx) - parseCtx(b.ctx)
331
+ // 📖 Sort by context window size — larger is better (uses parseCtxToK)
332
+ cmp = parseCtxToK(a.ctx) - parseCtxToK(b.ctx)
407
333
  break
408
334
  }
409
335
  case 'condition':
@@ -727,7 +653,7 @@ export const CONTEXT_BUDGETS = {
727
653
 
728
654
  // 📖 parseCtxToK: Convert context window string ("128k", "1m", "200k") into numeric K tokens.
729
655
  // 📖 Used by the scoring engine to compare against CONTEXT_BUDGETS thresholds.
730
- function parseCtxToK(ctx) {
656
+ export function parseCtxToK(ctx) {
731
657
  if (!ctx || ctx === '—') return 0
732
658
  const str = ctx.toLowerCase()
733
659
  if (str.includes('m')) return parseFloat(str.replace('m', '')) * 1000
@@ -756,7 +682,7 @@ export function labelFromId(id) {
756
682
 
757
683
  // 📖 parseSweToNum: Convert SWE-bench score string ("49.2%", "73.1%") into a 0–100 number.
758
684
  // 📖 Returns 0 for missing or invalid scores.
759
- function parseSweToNum(sweScore) {
685
+ export function parseSweToNum(sweScore) {
760
686
  if (!sweScore || sweScore === '—') return 0
761
687
  const num = parseFloat(sweScore.replace('%', ''))
762
688
  return isNaN(num) ? 0 : num
package/src/tui/app.js CHANGED
@@ -307,8 +307,8 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
307
307
 
308
308
  let results = MODELS
309
309
  .filter(([,,,,,providerKey]) => isProviderEnabled(config, providerKey))
310
- .map(([modelId, label, tier, sweScore, ctx, providerKey], i) => ({
311
- idx: i + 1, modelId, label, tier, sweScore, ctx, providerKey,
310
+ .map(([modelId, label, tier, sweScore, ctx, providerKey, addedDate], i) => ({
311
+ idx: i + 1, modelId, label, tier, sweScore, ctx, providerKey, addedDate: addedDate || null,
312
312
  status: 'pending',
313
313
  pings: [], // 📖 All ping results (ms or 'TIMEOUT')
314
314
  httpCode: null,
@@ -202,6 +202,7 @@ const BASE_COMMAND_TREE = [
202
202
  { id: 'action-cycle-theme', label: 'Cycle theme', shortcut: 'G', icon: '🌗', description: 'Switch dark/light/auto', keywords: ['theme', 'dark', 'light', 'auto'] },
203
203
  { id: 'action-reset-view', label: 'Reset view', shortcut: 'N', icon: '\u{1F504}', description: 'Reset filters and sort', keywords: ['reset', 'view', 'sort', 'filters'] },
204
204
  { id: 'action-probe-404', label: 'Probe 404 Models', shortcut: 'Ctrl+Shift+P', icon: '\u{1F50D}', description: 'Test all configured models for 404/410 errors. Auto-hides broken models.', keywords: ['probe', '404', 'broken', 'dead', 'health', 'check', 'test', 'verify'] },
205
+ { id: 'action-toggle-auto-hide-broken', label: 'Toggle Auto-hide Broken Models', icon: '\u{1F6A9}', description: 'Enable/disable auto-hiding of 404/410 models. Unhides all when disabled.', keywords: ['auto', 'hide', 'broken', '404', 'models', 'toggle', 'probe'] },
205
206
  ],
206
207
  },
207
208
  // 📖 Pages - directly at root level, not in submenu