free-coding-models 0.1.83 → 0.1.84

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.1.83",
3
+ "version": "0.1.84",
4
4
  "description": "Find the fastest coding LLM models in seconds — ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
5
5
  "keywords": [
6
6
  "nvidia",
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "files": [
42
42
  "bin/",
43
- "lib/",
43
+ "src/",
44
44
  "sources.js",
45
45
  "patch-openclaw.js",
46
46
  "patch-openclaw-models.js",
@@ -0,0 +1,197 @@
1
+ /**
2
+ * @file analysis.js
3
+ * @description Analysis functions for model reliability scoring and dynamic model discovery.
4
+ *
5
+ * @details
6
+ * This module provides high-level analysis functions:
7
+ * - Fiable mode: 10-second reliability analysis to find the most stable model
8
+ * - Dynamic OpenRouter model discovery: Fetch free models from OpenRouter API
9
+ * - Tier filtering with validation
10
+ *
11
+ * 🎯 Key features:
12
+ * - Run 10-second reliability analysis across all models
13
+ * - Find best model based on uptime, avg latency, and stability
14
+ * - Fetch real-time OpenRouter free models (replaces static list)
15
+ * - Tier filtering validation with helpful error messages
16
+ *
17
+ * → Functions:
18
+ * - `runFiableMode`: Analyze models for 10 seconds and output the most reliable one
19
+ * - `filterByTierOrExit`: Filter models by tier, exit with error if tier is invalid
20
+ * - `fetchOpenRouterFreeModels`: Fetch live free models from OpenRouter API
21
+ *
22
+ * 📦 Dependencies:
23
+ * - ../sources.js: MODELS, sources
24
+ * - ../src/utils.js: findBestModel, filterByTier, formatCtxWindow, labelFromId
25
+ * - ../src/config.js: isProviderEnabled, getApiKey
26
+ * - ../src/ping.js: ping
27
+ * - chalk: Terminal colors and formatting
28
+ * - ../src/constants.js: TIER_LETTER_MAP (for validation)
29
+ *
30
+ * ⚙️ Configuration:
31
+ * - Analysis duration: 10 seconds (hardcoded in runFiableMode)
32
+ * - OpenRouter tier map: Known SWE-bench scores for popular models (fallback for unknown)
33
+ *
34
+ * @see {@link ../src/utils.js} findBestModel implementation
35
+ * @see {@link ../src/ping.js} ping implementation
36
+ */
37
+
38
+ import { MODELS, sources } from '../sources.js'
39
+ import { findBestModel, filterByTier, formatCtxWindow, labelFromId, TIER_LETTER_MAP } from '../src/utils.js'
40
+ import { isProviderEnabled, getApiKey } from '../src/config.js'
41
+ import { ping } from '../src/ping.js'
42
+ import chalk from 'chalk'
43
+
44
+ // 📖 runFiableMode: Analyze models for reliability over 10 seconds, output the best one.
45
+ // 📖 Filters to enabled providers with keys, runs initial pings, then waits.
46
+ // 📖 Uses findBestModel() from utils.js to select based on uptime/avg/stability.
47
+ export async function runFiableMode(config) {
48
+ console.log(chalk.cyan(' ⚡ Analyzing models for reliability (10 seconds)...'))
49
+ console.log()
50
+
51
+ // 📖 Only include models from enabled providers that have API keys
52
+ let results = MODELS
53
+ .filter(([,,,,,providerKey]) => {
54
+ return isProviderEnabled(config, providerKey) && getApiKey(config, providerKey)
55
+ })
56
+ .map(([modelId, label, tier, sweScore, ctx, providerKey], i) => ({
57
+ idx: i + 1, modelId, label, tier, sweScore, ctx, providerKey,
58
+ status: 'pending',
59
+ pings: [],
60
+ httpCode: null,
61
+ }))
62
+
63
+ const startTime = Date.now()
64
+ const analysisDuration = 10000 // 10 seconds
65
+
66
+ // 📖 Run initial pings using per-provider API key and URL
67
+ const pingPromises = results.map(r => {
68
+ const rApiKey = getApiKey(config, r.providerKey)
69
+ const url = sources[r.providerKey]?.url
70
+ return ping(rApiKey, r.modelId, r.providerKey, url).then(({ code, ms }) => {
71
+ r.pings.push({ ms, code })
72
+ if (code === '200') {
73
+ r.status = 'up'
74
+ } else if (code === '000') {
75
+ r.status = 'timeout'
76
+ } else {
77
+ r.status = 'down'
78
+ r.httpCode = code
79
+ }
80
+ })
81
+ })
82
+
83
+ await Promise.allSettled(pingPromises)
84
+
85
+ // 📖 Continue pinging for the remaining time
86
+ const remainingTime = Math.max(0, analysisDuration - (Date.now() - startTime))
87
+ if (remainingTime > 0) {
88
+ await new Promise(resolve => setTimeout(resolve, remainingTime))
89
+ }
90
+
91
+ // 📖 Find best model
92
+ const best = findBestModel(results)
93
+
94
+ if (!best) {
95
+ console.log(chalk.red(' ✖ No reliable model found'))
96
+ process.exit(1)
97
+ }
98
+
99
+ // 📖 Output in format: providerName/modelId
100
+ const providerName = sources[best.providerKey]?.name ?? best.providerKey ?? 'nvidia'
101
+ console.log(chalk.green(` ✓ Most reliable model:`))
102
+ console.log(chalk.bold(` ${providerName}/${best.modelId}`))
103
+ console.log()
104
+ console.log(chalk.dim(` 📊 Stats:`))
105
+ const { getAvg, getUptime } = await import('./utils.js')
106
+ console.log(chalk.dim(` Avg ping: ${getAvg(best)}ms`))
107
+ console.log(chalk.dim(` Uptime: ${getUptime(best)}%`))
108
+ console.log(chalk.dim(` Status: ${best.status === 'up' ? '✅ UP' : '❌ DOWN'}`))
109
+
110
+ process.exit(0)
111
+ }
112
+
113
+ // 📖 filterByTierOrExit: Filter models by tier letter (S/A/B/C).
114
+ // 📖 Wrapper around filterByTier() that exits with error message instead of returning null.
115
+ // 📖 This is used by CLI argument parsing to fail fast on invalid tier input.
116
+ export function filterByTierOrExit(results, tierLetter) {
117
+ const filtered = filterByTier(results, tierLetter)
118
+ if (filtered === null) {
119
+ console.error(chalk.red(` ✖ Unknown tier "${tierLetter}". Valid tiers: S, A, B, C`))
120
+ process.exit(1)
121
+ }
122
+ return filtered
123
+ }
124
+
125
+ // ─── Dynamic OpenRouter free model discovery ──────────────────────────────────
126
+ // 📖 Fetches the live list of free models from OpenRouter's public API at startup.
127
+ // 📖 Replaces the static openrouter entries in MODELS with fresh data so new free
128
+ // 📖 models appear automatically without a code update.
129
+ // 📖 Falls back silently to the static list on network failure.
130
+
131
+ // 📖 Known SWE-bench scores for OpenRouter free models.
132
+ // 📖 Keyed by base model ID (without the :free suffix).
133
+ // 📖 Unknown models default to tier 'B' / '25.0%'.
134
+ const OPENROUTER_TIER_MAP = {
135
+ 'qwen/qwen3-coder': ['S+', '70.6%'],
136
+ 'mistralai/devstral-2': ['S+', '72.2%'],
137
+ 'stepfun/step-3.5-flash': ['S+', '74.4%'],
138
+ 'deepseek/deepseek-r1-0528': ['S', '61.0%'],
139
+ 'qwen/qwen3-next-80b-a3b-instruct': ['S', '65.0%'],
140
+ 'openai/gpt-oss-120b': ['S', '60.0%'],
141
+ 'openai/gpt-oss-20b': ['A', '42.0%'],
142
+ 'nvidia/nemotron-3-nano-30b-a3b': ['A', '43.0%'],
143
+ 'meta-llama/llama-3.3-70b-instruct': ['A-', '39.5%'],
144
+ 'mimo-v2-flash': ['A', '45.0%'],
145
+ 'google/gemma-3-27b-it': ['A-', '36.0%'],
146
+ 'google/gemma-3-12b-it': ['B+', '30.0%'],
147
+ 'google/gemma-3-4b-it': ['B', '22.0%'],
148
+ 'google/gemma-3n-e4b-it': ['B', '22.0%'],
149
+ 'google/gemma-3n-e2b-it': ['B', '18.0%'],
150
+ 'meta-llama/llama-3.2-3b-instruct': ['B', '20.0%'],
151
+ 'mistralai/mistral-small-3.1-24b-instruct': ['A-', '35.0%'],
152
+ 'qwen/qwen3-4b': ['B', '22.0%'],
153
+ 'nousresearch/hermes-3-llama-3.1-405b': ['A', '40.0%'],
154
+ 'nvidia/nemotron-nano-9b-v2': ['B+', '28.0%'],
155
+ 'nvidia/nemotron-nano-12b-v2-vl': ['B+', '30.0%'],
156
+ 'z-ai/glm-4.5-air': ['A-', '38.0%'],
157
+ 'arcee-ai/trinity-large-preview': ['A', '40.0%'],
158
+ 'arcee-ai/trinity-mini': ['B+', '28.0%'],
159
+ 'upstage/solar-pro-3': ['A-', '35.0%'],
160
+ 'cognitivecomputations/dolphin-mistral-24b-venice-edition': ['B+', '28.0%'],
161
+ 'liquid/lfm-2.5-1.2b-thinking': ['B', '18.0%'],
162
+ 'liquid/lfm-2.5-1.2b-instruct': ['B', '18.0%'],
163
+ }
164
+
165
+ // 📖 fetchOpenRouterFreeModels: Fetch live free models from OpenRouter API.
166
+ // 📖 Returns array of tuples [modelId, label, tier, sweScore, ctx] or null on failure.
167
+ // 📖 Formats context windows using formatCtxWindow and labels using labelFromId.
168
+ // 📖 Uses OPENROUTER_TIER_MAP for known models; others default to tier 'B'/'25.0%'.
169
+ export async function fetchOpenRouterFreeModels() {
170
+ try {
171
+ const controller = new AbortController()
172
+ const timeout = setTimeout(() => controller.abort(), 5000)
173
+ const res = await fetch('https://openrouter.ai/api/v1/models', {
174
+ signal: controller.signal,
175
+ headers: {
176
+ 'HTTP-Referer': 'https://github.com/vava-nessa/free-coding-models',
177
+ 'X-Title': 'free-coding-models',
178
+ },
179
+ })
180
+ clearTimeout(timeout)
181
+ if (!res.ok) return null
182
+ const json = await res.json()
183
+ if (!json.data || !Array.isArray(json.data)) return null
184
+
185
+ const freeModels = json.data.filter(m => m.id && m.id.endsWith(':free'))
186
+
187
+ return freeModels.map(m => {
188
+ const baseId = m.id.replace(/:free$/, '')
189
+ const [tier, swe] = OPENROUTER_TIER_MAP[baseId] || ['B', '25.0%']
190
+ const ctx = formatCtxWindow(m.context_length)
191
+ const label = labelFromId(m.id)
192
+ return [m.id, label, tier, swe, ctx]
193
+ })
194
+ } catch {
195
+ return null
196
+ }
197
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * @file constants.js
3
+ * @description Pure terminal/TUI constants extracted from bin/free-coding-models.js.
4
+ *
5
+ * @details
6
+ * This module centralises every "magic number" and escape-sequence constant that
7
+ * the TUI rendering pipeline depends on. Having them here means:
8
+ * - They are importable by unit tests without pulling in the entire CLI entry point.
9
+ * - A single source of truth for column widths, timing values, overlay colours, etc.
10
+ * - `msCell` and `spinCell` live here too because they only depend on `CELL_W`,
11
+ * `FRAMES`, and chalk — all of which are available at module scope.
12
+ *
13
+ * ⚙️ Key configuration:
14
+ * - `PING_TIMEOUT` / `PING_INTERVAL` control how aggressive the health-check loop is.
15
+ * - `FPS` controls animation frame rate (braille spinner).
16
+ * - `COL_MODEL` / `COL_MS` control legacy ping-column widths (retained for compat).
17
+ * - `CELL_W` is derived from `COL_MS` and used by `msCell` / `spinCell`.
18
+ * - `TABLE_HEADER_LINES` + `TABLE_FOOTER_LINES` = `TABLE_FIXED_LINES` must stay in sync
19
+ * with the actual number of lines rendered by `renderTable()` in bin/.
20
+ * - Overlay background colours (chalk.bgRgb) make each overlay panel visually distinct.
21
+ *
22
+ * @functions
23
+ * → msCell(ms) — Formats a latency value into a fixed-width coloured cell string
24
+ * → spinCell(f, o) — Returns a braille spinner cell at frame f with optional offset o
25
+ *
26
+ * @exports
27
+ * ALT_ENTER, ALT_LEAVE, ALT_HOME,
28
+ * PING_TIMEOUT, PING_INTERVAL,
29
+ * FPS, COL_MODEL, COL_MS, CELL_W,
30
+ * FRAMES, TIER_CYCLE,
31
+ * SETTINGS_OVERLAY_BG, HELP_OVERLAY_BG, RECOMMEND_OVERLAY_BG, LOG_OVERLAY_BG,
32
+ * OVERLAY_PANEL_WIDTH,
33
+ * TABLE_HEADER_LINES, TABLE_FOOTER_LINES, TABLE_FIXED_LINES,
34
+ * msCell, spinCell
35
+ *
36
+ * @see bin/free-coding-models.js — main entry point that imports these constants
37
+ * @see src/tier-colors.js — TIER_COLOR map (chalk-dependent, separate module)
38
+ */
39
+
40
+ import chalk from 'chalk'
41
+
42
+ // 📖 Alternate screen ANSI escape sequences used to enter/leave the TUI buffer.
43
+ // 📖 \x1b[?1049h = enter alt screen \x1b[?1049l = leave alt screen
44
+ // 📖 \x1b[?25l = hide cursor \x1b[?25h = show cursor
45
+ // 📖 \x1b[H = cursor to top
46
+ // 📖 \x1b[?7l disables auto-wrap so wide rows clip at the right edge instead of
47
+ // 📖 wrapping to the next line (which would double the row height and overflow).
48
+ export const ALT_ENTER = '\x1b[?1049h\x1b[?25l\x1b[?7l'
49
+ export const ALT_LEAVE = '\x1b[?7h\x1b[?1049l\x1b[?25h'
50
+ export const ALT_HOME = '\x1b[H'
51
+
52
+ // 📖 Timing constants — control how fast the health-check loop runs.
53
+ export const PING_TIMEOUT = 15_000 // 📖 15s per attempt before abort
54
+ export const PING_INTERVAL = 3_000 // 📖 3s between pings for fast model selection feedback
55
+
56
+ // 📖 Animation and column-width constants.
57
+ export const FPS = 12
58
+ export const COL_MODEL = 22
59
+ // 📖 COL_MS = dashes in hline per ping column = visual width including 2 padding spaces.
60
+ // 📖 Max value: 12001ms = 7 chars. padStart(COL_MS-2) fits content, +2 spaces = COL_MS dashes.
61
+ export const COL_MS = 11
62
+
63
+ // 📖 CELL_W = visual content width of a single ms/spinner cell (COL_MS minus 2 border spaces).
64
+ export const CELL_W = COL_MS - 2 // 📖 9 chars of content per ms cell
65
+
66
+ // 📖 Braille spinner frames for the "pinging..." animation.
67
+ export const FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏']
68
+
69
+ // 📖 TIER_CYCLE: ordered list of tier-filter states cycled by the T key.
70
+ // 📖 Index 0 = no filter (show all), then each tier name in descending quality order.
71
+ export const TIER_CYCLE = [null, 'S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
72
+
73
+ // 📖 Overlay background chalk functions — each overlay panel has a distinct tint
74
+ // 📖 so users can tell Settings, Help, Recommend, and Log panels apart at a glance.
75
+ export const SETTINGS_OVERLAY_BG = chalk.bgRgb(14, 20, 30)
76
+ export const HELP_OVERLAY_BG = chalk.bgRgb(24, 16, 32)
77
+ export const RECOMMEND_OVERLAY_BG = chalk.bgRgb(10, 25, 15) // 📖 Green tint for Smart Recommend
78
+ export const LOG_OVERLAY_BG = chalk.bgRgb(10, 20, 26) // 📖 Dark blue-green tint for Log page
79
+
80
+ // 📖 OVERLAY_PANEL_WIDTH: fixed character width of all overlay panels so background
81
+ // 📖 tint fills the panel consistently regardless of content length.
82
+ export const OVERLAY_PANEL_WIDTH = 116
83
+
84
+ // 📖 Table row-budget constants — must stay in sync with renderTable()'s actual output.
85
+ // 📖 If this drifts, model rows overflow and can push the title row out of view.
86
+ export const TABLE_HEADER_LINES = 4 // 📖 title, spacer, column headers, separator
87
+ export const TABLE_FOOTER_LINES = 5 // 📖 spacer, hints line 1, hints line 2, spacer, credit+links
88
+ export const TABLE_FIXED_LINES = TABLE_HEADER_LINES + TABLE_FOOTER_LINES
89
+
90
+ // ─── Small cell-formatting helpers ────────────────────────────────────────────
91
+
92
+ /**
93
+ * 📖 msCell: Renders a latency measurement into a right-padded coloured cell.
94
+ * 📖 null → dim dash (not yet pinged)
95
+ * 📖 'TIMEOUT' → red TIMEOUT text
96
+ * 📖 <500ms → bright green, <1500ms → yellow, else red
97
+ * @param {number|string|null} ms
98
+ * @returns {string}
99
+ */
100
+ export const msCell = (ms) => {
101
+ if (ms === null) return chalk.dim('—'.padStart(CELL_W))
102
+ const str = String(ms).padStart(CELL_W)
103
+ if (ms === 'TIMEOUT') return chalk.red(str)
104
+ if (ms < 500) return chalk.greenBright(str)
105
+ if (ms < 1500) return chalk.yellow(str)
106
+ return chalk.red(str)
107
+ }
108
+
109
+ /**
110
+ * 📖 spinCell: Returns a braille spinner character padded to CELL_W.
111
+ * 📖 f = current frame index, o = row offset so each row animates differently.
112
+ * @param {number} f - global frame counter
113
+ * @param {number} [o=0] - per-row offset to stagger animation
114
+ * @returns {string}
115
+ */
116
+ export const spinCell = (f, o = 0) => chalk.dim.yellow(FRAMES[(f + o) % FRAMES.length].padEnd(CELL_W))
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @file favorites.js
3
+ * @description Favorites management for model rows — persisted per user in ~/.free-coding-models.json.
4
+ * Extracted from bin/free-coding-models.js to allow unit testing in isolation.
5
+ *
6
+ * @details
7
+ * Favorites are stored as an ordered array of strings in the format "providerKey/modelId"
8
+ * (e.g. "groq/llama-3.1-70b-versatile"). Insertion order matters: it determines the
9
+ * `favoriteRank` used by `sortResultsWithPinnedFavorites` to keep pinned rows at the top.
10
+ *
11
+ * How it works at runtime:
12
+ * 1. On startup, `syncFavoriteFlags()` is called once to attach `isFavorite`/`favoriteRank`
13
+ * metadata to every result row based on the persisted favorites list.
14
+ * 2. When the user presses F, `toggleFavoriteModel()` mutates the config array and persists
15
+ * immediately via `saveConfig()`.
16
+ * 3. The renderer reads `r.isFavorite` and `r.favoriteRank` from the row to decide whether
17
+ * to show the ⭐ prefix and how to sort the row relative to non-favorites.
18
+ *
19
+ * @functions
20
+ * → ensureFavoritesConfig(config) — Ensure config.favorites is a clean deduped array
21
+ * → toFavoriteKey(providerKey, modelId) — Build the canonical "providerKey/modelId" string
22
+ * → syncFavoriteFlags(results, config) — Attach isFavorite/favoriteRank to result rows
23
+ * → toggleFavoriteModel(config, providerKey, modelId) — Add/remove favorite and persist
24
+ *
25
+ * @exports
26
+ * ensureFavoritesConfig, toFavoriteKey, syncFavoriteFlags, toggleFavoriteModel
27
+ *
28
+ * @see src/config.js — saveConfig used here to persist changes immediately
29
+ * @see bin/free-coding-models.js — calls syncFavoriteFlags on startup and toggleFavoriteModel on F key
30
+ */
31
+
32
+ import { saveConfig } from './config.js'
33
+
34
+ /**
35
+ * 📖 Ensure favorites config shape exists and remains clean.
36
+ * 📖 Stored format: ["providerKey/modelId", ...] in insertion order.
37
+ * @param {Record<string, unknown>} config
38
+ */
39
+ export function ensureFavoritesConfig(config) {
40
+ if (!Array.isArray(config.favorites)) config.favorites = []
41
+ const seen = new Set()
42
+ config.favorites = config.favorites.filter((entry) => {
43
+ if (typeof entry !== 'string' || entry.trim().length === 0) return false
44
+ if (seen.has(entry)) return false
45
+ seen.add(entry)
46
+ return true
47
+ })
48
+ }
49
+
50
+ /**
51
+ * 📖 Build deterministic key used to persist one favorite model row.
52
+ * @param {string} providerKey
53
+ * @param {string} modelId
54
+ * @returns {string}
55
+ */
56
+ export function toFavoriteKey(providerKey, modelId) {
57
+ return `${providerKey}/${modelId}`
58
+ }
59
+
60
+ /**
61
+ * 📖 Sync per-row favorite metadata from config (used by renderer and sorter).
62
+ * 📖 Mutates each row in-place — adds favoriteKey, isFavorite, favoriteRank.
63
+ * @param {Array<Record<string, unknown>>} results
64
+ * @param {Record<string, unknown>} config
65
+ */
66
+ export function syncFavoriteFlags(results, config) {
67
+ ensureFavoritesConfig(config)
68
+ const favoriteRankMap = new Map(config.favorites.map((entry, index) => [entry, index]))
69
+ for (const row of results) {
70
+ const favoriteKey = toFavoriteKey(row.providerKey, row.modelId)
71
+ const rank = favoriteRankMap.get(favoriteKey)
72
+ row.favoriteKey = favoriteKey
73
+ row.isFavorite = rank !== undefined
74
+ row.favoriteRank = rank !== undefined ? rank : Number.MAX_SAFE_INTEGER
75
+ }
76
+ }
77
+
78
+ /**
79
+ * 📖 Toggle favorite state and persist immediately.
80
+ * 📖 Returns true when row is now favorite, false when removed.
81
+ * @param {Record<string, unknown>} config
82
+ * @param {string} providerKey
83
+ * @param {string} modelId
84
+ * @returns {boolean}
85
+ */
86
+ export function toggleFavoriteModel(config, providerKey, modelId) {
87
+ ensureFavoritesConfig(config)
88
+ const favoriteKey = toFavoriteKey(providerKey, modelId)
89
+ const existingIndex = config.favorites.indexOf(favoriteKey)
90
+ if (existingIndex >= 0) {
91
+ config.favorites.splice(existingIndex, 1)
92
+ saveConfig(config)
93
+ return false
94
+ }
95
+ config.favorites.push(favoriteKey)
96
+ saveConfig(config)
97
+ return true
98
+ }