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/README.md +6 -17
- package/bin/free-coding-models.js +297 -4754
- package/package.json +2 -2
- package/src/analysis.js +197 -0
- package/src/constants.js +116 -0
- package/src/favorites.js +98 -0
- package/src/key-handler.js +1005 -0
- package/src/openclaw.js +131 -0
- package/src/opencode.js +952 -0
- package/src/overlays.js +840 -0
- package/src/ping.js +186 -0
- package/src/provider-metadata.js +218 -0
- package/src/quota-capabilities.js +112 -0
- package/src/render-helpers.js +239 -0
- package/src/render-table.js +567 -0
- package/src/setup.js +105 -0
- package/src/telemetry.js +382 -0
- package/src/tier-colors.js +37 -0
- package/{lib → src}/token-stats.js +71 -3
- package/src/token-usage-reader.js +63 -0
- package/src/updater.js +237 -0
- package/{lib → src}/usage-reader.js +63 -21
- package/lib/quota-capabilities.js +0 -79
- /package/{lib → src}/account-manager.js +0 -0
- /package/{lib → src}/config.js +0 -0
- /package/{lib → src}/error-classifier.js +0 -0
- /package/{lib → src}/log-reader.js +0 -0
- /package/{lib → src}/model-merger.js +0 -0
- /package/{lib → src}/opencode-sync.js +0 -0
- /package/{lib → src}/provider-quota-fetchers.js +0 -0
- /package/{lib → src}/proxy-server.js +0 -0
- /package/{lib → src}/request-transformer.js +0 -0
- /package/{lib → src}/utils.js +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "free-coding-models",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
"
|
|
43
|
+
"src/",
|
|
44
44
|
"sources.js",
|
|
45
45
|
"patch-openclaw.js",
|
|
46
46
|
"patch-openclaw-models.js",
|
package/src/analysis.js
ADDED
|
@@ -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
|
+
}
|
package/src/constants.js
ADDED
|
@@ -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))
|
package/src/favorites.js
ADDED
|
@@ -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
|
+
}
|