free-coding-models 0.1.85 β 0.1.86
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 +12 -5
- package/bin/free-coding-models.js +16 -7
- package/package.json +1 -1
- package/sources.js +12 -10
- package/src/config.js +13 -2
- package/src/key-handler.js +145 -8
- package/src/overlays.js +4 -1
- package/src/provider-metadata.js +20 -20
- package/src/render-table.js +87 -68
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<img src="https://img.shields.io/npm/v/free-coding-models?color=76b900&label=npm&logo=npm" alt="npm version">
|
|
3
3
|
<img src="https://img.shields.io/node/v/free-coding-models?color=76b900&logo=node.js" alt="node version">
|
|
4
4
|
<img src="https://img.shields.io/npm/l/free-coding-models?color=76b900" alt="license">
|
|
5
|
-
<img src="https://img.shields.io/badge/models-
|
|
5
|
+
<img src="https://img.shields.io/badge/models-159-76b900?logo=nvidia" alt="models count">
|
|
6
6
|
<img src="https://img.shields.io/badge/providers-20-blue" alt="providers count">
|
|
7
7
|
</p>
|
|
8
8
|
|
|
@@ -90,7 +90,9 @@
|
|
|
90
90
|
- **πΆ Status indicators** β UP β
Β· No Key π Β· Timeout β³ Β· Overloaded π₯ Β· Not Found π«
|
|
91
91
|
- **π Keyless latency** β Models are pinged even without an API key
|
|
92
92
|
- **π· Tier filtering** β Filter models by tier letter (S, A, B, C)
|
|
93
|
-
|
|
93
|
+
- **β Persistent favorites** β Press `F` on a selected row to pin/unpin it
|
|
94
|
+
- **π Configured-only by default** β Press `E` to toggle showing only providers with configured API keys; the choice persists across sessions and profiles
|
|
95
|
+
- **πͺ Width guardrail** β If your terminal is too narrow, the TUI shows a centered warning instead of rendering a broken table
|
|
94
96
|
|
|
95
97
|
---
|
|
96
98
|
|
|
@@ -235,7 +237,7 @@ Use `ββ` arrows to select, `Enter` to confirm. Then the TUI launches with yo
|
|
|
235
237
|
You can add or change keys anytime with the P key in the TUI.
|
|
236
238
|
```
|
|
237
239
|
|
|
238
|
-
You don't need all
|
|
240
|
+
You don't need all twenty providers β skip any provider by pressing Enter. At least one key is required.
|
|
239
241
|
|
|
240
242
|
### Adding or changing keys later
|
|
241
243
|
|
|
@@ -269,6 +271,7 @@ Press **`P`** to open the Settings screen at any time:
|
|
|
269
271
|
|
|
270
272
|
Manual update is in the same Settings screen (`P`) under **Maintenance** (Enter to check, Enter again to install when an update is available).
|
|
271
273
|
Favorites are also persisted in the same config file and survive restarts.
|
|
274
|
+
The main table now starts in `Configured Only` mode, so if nothing is set up yet you can press `P` and add your first API key immediately.
|
|
272
275
|
|
|
273
276
|
### Environment variable overrides
|
|
274
277
|
|
|
@@ -376,7 +379,7 @@ TOGETHER_API_KEY=together_xxx free-coding-models
|
|
|
376
379
|
|
|
377
380
|
## π€ Coding Models
|
|
378
381
|
|
|
379
|
-
**
|
|
382
|
+
**159 coding models** across 20 providers and 8 tiers, ranked by [SWE-bench Verified](https://www.swebench.com) β the industry-standard benchmark measuring real GitHub issue resolution. Scores are self-reported by providers unless noted.
|
|
380
383
|
|
|
381
384
|
### Alibaba Cloud (DashScope) (8 models)
|
|
382
385
|
|
|
@@ -733,7 +736,7 @@ This script:
|
|
|
733
736
|
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
734
737
|
```
|
|
735
738
|
|
|
736
|
-
**Result:** Continuous monitoring interface that stays open until you select a model or press Ctrl+C. Rolling averages give you accurate long-term latency data, the stability score reveals which models are truly consistent vs. deceptively spikey, and you can configure your tool of choice with one keystroke.
|
|
739
|
+
**Result:** Continuous monitoring interface that stays open until you select a model or press Ctrl+C. Rolling averages give you accurate long-term latency data, the stability score reveals which models are truly consistent vs. deceptively spikey, and you can configure your tool of choice with one keystroke. If the terminal is too narrow, the app shows a centered warning instead of a truncated table.
|
|
737
740
|
|
|
738
741
|
---
|
|
739
742
|
|
|
@@ -795,6 +798,9 @@ This script:
|
|
|
795
798
|
"perplexity": { "enabled": true },
|
|
796
799
|
"zai": { "enabled": true }
|
|
797
800
|
},
|
|
801
|
+
"settings": {
|
|
802
|
+
"hideUnconfiguredModels": true
|
|
803
|
+
},
|
|
798
804
|
"favorites": [
|
|
799
805
|
"nvidia/deepseek-ai/deepseek-v3.2"
|
|
800
806
|
]
|
|
@@ -830,6 +836,7 @@ This script:
|
|
|
830
836
|
- **F** β Toggle favorite on selected model (β in Model column, pinned at top)
|
|
831
837
|
- **T** β Cycle tier filter (All β S+ β S β A+ β A β A- β B+ β B β C β All)
|
|
832
838
|
- **D** β Cycle provider filter (All β NIM β Groq β ...)
|
|
839
|
+
- **E** β Toggle configured-only mode (on by default, persisted across sessions and profiles)
|
|
833
840
|
- **Z** β Cycle mode (OpenCode CLI β OpenCode Desktop β OpenClaw)
|
|
834
841
|
- **X** β **Toggle Token Logs** (view recent request/token usage logs)
|
|
835
842
|
- **P** β Open Settings (manage API keys, toggles, updates, profiles)
|
|
@@ -177,9 +177,10 @@ async function main() {
|
|
|
177
177
|
ensureFavoritesConfig(config)
|
|
178
178
|
|
|
179
179
|
// π If --profile <name> was passed, load that profile into the live config
|
|
180
|
+
let startupProfileSettings = null
|
|
180
181
|
if (cliArgs.profileName) {
|
|
181
|
-
|
|
182
|
-
if (!
|
|
182
|
+
startupProfileSettings = loadProfile(config, cliArgs.profileName)
|
|
183
|
+
if (!startupProfileSettings) {
|
|
183
184
|
console.error(chalk.red(` Unknown profile "${cliArgs.profileName}". Available: ${listProfiles(config).join(', ') || '(none)'}`))
|
|
184
185
|
process.exit(1)
|
|
185
186
|
}
|
|
@@ -336,8 +337,8 @@ async function main() {
|
|
|
336
337
|
frame: 0,
|
|
337
338
|
cursor: 0,
|
|
338
339
|
selectedModel: null,
|
|
339
|
-
sortColumn: 'avg',
|
|
340
|
-
sortDirection: 'asc',
|
|
340
|
+
sortColumn: startupProfileSettings?.sortColumn || 'avg',
|
|
341
|
+
sortDirection: startupProfileSettings?.sortAsc === false ? 'desc' : 'asc',
|
|
341
342
|
pingInterval: PING_MODE_INTERVALS.speed, // π Effective live interval derived from the active ping mode.
|
|
342
343
|
pingMode: 'speed', // π Current ping mode: speed | normal | slow | forced.
|
|
343
344
|
pingModeSource: 'startup', // π Why this mode is active: startup | manual | auto | idle | activity.
|
|
@@ -348,8 +349,10 @@ async function main() {
|
|
|
348
349
|
mode, // π 'opencode' or 'openclaw' β controls Enter action
|
|
349
350
|
tierFilterMode: 0, // π Index into TIER_CYCLE (0=All, 1=S+, 2=S, ...)
|
|
350
351
|
originFilterMode: 0, // π Index into ORIGIN_CYCLE (0=All, then providers)
|
|
352
|
+
hideUnconfiguredModels: startupProfileSettings?.hideUnconfiguredModels === true || config.settings?.hideUnconfiguredModels === true, // π Hide providers with no configured API key when true.
|
|
351
353
|
scrollOffset: 0, // π First visible model index in viewport
|
|
352
354
|
terminalRows: process.stdout.rows || 24, // π Current terminal height
|
|
355
|
+
terminalCols: process.stdout.columns || 80, // π Current terminal width
|
|
353
356
|
// π Settings screen state (P key opens it)
|
|
354
357
|
settingsOpen: false, // π Whether settings overlay is active
|
|
355
358
|
settingsCursor: 0, // π Which provider row is selected in settings
|
|
@@ -408,6 +411,7 @@ async function main() {
|
|
|
408
411
|
// π Re-clamp viewport on terminal resize
|
|
409
412
|
process.stdout.on('resize', () => {
|
|
410
413
|
state.terminalRows = process.stdout.rows || 24
|
|
414
|
+
state.terminalCols = process.stdout.columns || 80
|
|
411
415
|
adjustScrollOffset(state)
|
|
412
416
|
})
|
|
413
417
|
|
|
@@ -479,13 +483,18 @@ async function main() {
|
|
|
479
483
|
|
|
480
484
|
// π originFilterMode: index into ORIGIN_CYCLE, 0=All, then each provider key in order
|
|
481
485
|
const ORIGIN_CYCLE = [null, ...Object.keys(sources)]
|
|
482
|
-
state.tierFilterMode = 0
|
|
486
|
+
state.tierFilterMode = startupProfileSettings?.tierFilter ? Math.max(0, TIER_CYCLE.indexOf(startupProfileSettings.tierFilter)) : 0
|
|
483
487
|
state.originFilterMode = 0
|
|
484
488
|
|
|
485
489
|
function applyTierFilter() {
|
|
486
490
|
const activeTier = TIER_CYCLE[state.tierFilterMode]
|
|
487
491
|
const activeOrigin = ORIGIN_CYCLE[state.originFilterMode]
|
|
488
492
|
state.results.forEach(r => {
|
|
493
|
+
const unconfiguredHide = state.hideUnconfiguredModels && !getApiKey(state.config, r.providerKey)
|
|
494
|
+
if (unconfiguredHide) {
|
|
495
|
+
r.hidden = true
|
|
496
|
+
return
|
|
497
|
+
}
|
|
489
498
|
// π Favorites stay visible regardless of tier/origin filters.
|
|
490
499
|
if (r.isFavorite) {
|
|
491
500
|
r.hidden = false
|
|
@@ -643,7 +652,7 @@ async function main() {
|
|
|
643
652
|
? overlays.renderHelp()
|
|
644
653
|
: state.logVisible
|
|
645
654
|
? overlays.renderLog()
|
|
646
|
-
: renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus, state.pingMode, state.pingModeSource)
|
|
655
|
+
: renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.terminalCols, state.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels)
|
|
647
656
|
process.stdout.write(ALT_HOME + content)
|
|
648
657
|
}, Math.round(1000 / FPS))
|
|
649
658
|
|
|
@@ -651,7 +660,7 @@ async function main() {
|
|
|
651
660
|
const initialVisible = state.results.filter(r => !r.hidden)
|
|
652
661
|
state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection)
|
|
653
662
|
|
|
654
|
-
process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus, state.pingMode, state.pingModeSource))
|
|
663
|
+
process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.terminalCols, state.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels))
|
|
655
664
|
|
|
656
665
|
// π If --recommend was passed, auto-open the Smart Recommend overlay on start
|
|
657
666
|
if (cliArgs.recommendMode) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "free-coding-models",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.86",
|
|
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",
|
package/sources.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* - ctx: Context window size in tokens (e.g., "128k", "32k")
|
|
13
13
|
*
|
|
14
14
|
* Add new sources here to support additional providers beyond NIM.
|
|
15
|
+
* Public provider catalogs drift often, so these IDs are periodically
|
|
16
|
+
* refreshed against official docs and live model endpoints when available.
|
|
15
17
|
*
|
|
16
18
|
* π― Tier scale (based on SWE-bench Verified):
|
|
17
19
|
* - S+: 70%+ (elite frontier coders)
|
|
@@ -114,9 +116,9 @@ export const cerebras = [
|
|
|
114
116
|
['llama-4-scout-17b-16e-instruct', 'Llama 4 Scout', 'A', '44.0%', '10M'],
|
|
115
117
|
['qwen-3-32b', 'Qwen3 32B', 'A+', '50.0%', '128k'],
|
|
116
118
|
['gpt-oss-120b', 'GPT OSS 120B', 'S', '60.0%', '128k'],
|
|
117
|
-
['qwen-3-235b-a22b',
|
|
119
|
+
['qwen-3-235b-a22b-instruct-2507', 'Qwen3 235B', 'S+', '70.0%', '128k'],
|
|
118
120
|
['llama3.1-8b', 'Llama 3.1 8B', 'B', '28.8%', '128k'],
|
|
119
|
-
['glm-4.
|
|
121
|
+
['zai-glm-4.7', 'GLM 4.7', 'S+', '73.8%', '200k'],
|
|
120
122
|
]
|
|
121
123
|
|
|
122
124
|
// π SambaNova source - https://cloud.sambanova.ai
|
|
@@ -124,14 +126,15 @@ export const cerebras = [
|
|
|
124
126
|
// π OpenAI-compatible API, supports all major coding models including DeepSeek V3/R1, Qwen3, Llama 4
|
|
125
127
|
export const sambanova = [
|
|
126
128
|
// ββ S+ tier ββ
|
|
127
|
-
['
|
|
129
|
+
['MiniMax-M2.5', 'MiniMax M2.5', 'S+', '74.0%', '160k'],
|
|
128
130
|
// ββ S tier ββ
|
|
129
131
|
['DeepSeek-R1-0528', 'DeepSeek R1 0528', 'S', '61.0%', '128k'],
|
|
130
132
|
['DeepSeek-V3.1', 'DeepSeek V3.1', 'S', '62.0%', '128k'],
|
|
131
133
|
['DeepSeek-V3-0324', 'DeepSeek V3 0324', 'S', '62.0%', '128k'],
|
|
134
|
+
['DeepSeek-V3.2', 'DeepSeek V3.2', 'S+', '73.1%', '8k'],
|
|
132
135
|
['Llama-4-Maverick-17B-128E-Instruct', 'Llama 4 Maverick', 'S', '62.0%', '1M'],
|
|
133
136
|
['gpt-oss-120b', 'GPT OSS 120B', 'S', '60.0%', '128k'],
|
|
134
|
-
['
|
|
137
|
+
['DeepSeek-V3.1-Terminus', 'DeepSeek V3.1 Term', 'S', '68.4%', '128k'],
|
|
135
138
|
// ββ A+ tier ββ
|
|
136
139
|
['Qwen3-32B', 'Qwen3 32B', 'A+', '50.0%', '128k'],
|
|
137
140
|
// ββ A tier ββ
|
|
@@ -140,24 +143,23 @@ export const sambanova = [
|
|
|
140
143
|
['Meta-Llama-3.3-70B-Instruct', 'Llama 3.3 70B', 'A-', '39.5%', '128k'],
|
|
141
144
|
// ββ B tier ββ
|
|
142
145
|
['Meta-Llama-3.1-8B-Instruct', 'Llama 3.1 8B', 'B', '28.8%', '128k'],
|
|
143
|
-
// ββ A tier β requested Llama3-Groq coding tuned family ββ
|
|
144
|
-
['Llama-3-Groq-70B-Tool-Use', 'Llama3-Groq 70B', 'A', '43.0%', '128k'],
|
|
145
146
|
]
|
|
146
147
|
|
|
147
148
|
// π OpenRouter source - https://openrouter.ai
|
|
148
149
|
// π Free :free models with shared quota β 50 free req/day
|
|
149
150
|
// π API keys at https://openrouter.ai/keys
|
|
150
151
|
export const openrouter = [
|
|
151
|
-
['qwen/qwen3-coder:
|
|
152
|
-
['
|
|
153
|
-
['
|
|
152
|
+
['qwen/qwen3-coder:free', 'Qwen3 Coder 480B', 'S+', '70.6%', '262k'],
|
|
153
|
+
['z-ai/glm-4.5-air:free', 'GLM 4.5 Air', 'S+', '72.0%', '128k'],
|
|
154
|
+
['google/gemma-3-27b-it:free', 'Gemma 3 27B', 'B', '22.0%', '128k'],
|
|
154
155
|
['stepfun/step-3.5-flash:free', 'Step 3.5 Flash', 'S+', '74.4%', '256k'],
|
|
155
|
-
['deepseek/deepseek-r1-0528:free', 'DeepSeek R1 0528', 'S', '61.0%', '128k'],
|
|
156
156
|
['qwen/qwen3-next-80b-a3b-instruct:free', 'Qwen3 80B Instruct', 'S', '65.0%', '128k'],
|
|
157
157
|
['openai/gpt-oss-120b:free', 'GPT OSS 120B', 'S', '60.0%', '128k'],
|
|
158
158
|
['openai/gpt-oss-20b:free', 'GPT OSS 20B', 'A', '42.0%', '128k'],
|
|
159
159
|
['nvidia/nemotron-3-nano-30b-a3b:free', 'Nemotron Nano 30B', 'A', '43.0%', '128k'],
|
|
160
160
|
['meta-llama/llama-3.3-70b-instruct:free', 'Llama 3.3 70B', 'A-', '39.5%', '128k'],
|
|
161
|
+
['mistralai/mistral-small-3.1-24b-instruct:free', 'Mistral Small 3.1', 'B+', '30.0%', '128k'],
|
|
162
|
+
['google/gemma-3-12b-it:free', 'Gemma 3 12B', 'C', '15.0%', '128k'],
|
|
161
163
|
]
|
|
162
164
|
|
|
163
165
|
// π Hugging Face Inference source - https://huggingface.co
|
package/src/config.js
CHANGED
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
* - apiKeys: API keys per provider (can differ between work/personal setups)
|
|
72
72
|
* - providers: enabled/disabled state per provider
|
|
73
73
|
* - favorites: list of pinned favorite models
|
|
74
|
-
* - settings: extra TUI preferences (tierFilter, sortColumn, sortAsc, pingInterval)
|
|
74
|
+
* - settings: extra TUI preferences (tierFilter, sortColumn, sortAsc, pingInterval, hideUnconfiguredModels)
|
|
75
75
|
*
|
|
76
76
|
* π When a profile is loaded via --profile <name> or Shift+P, the main config's
|
|
77
77
|
* apiKeys/providers/favorites are replaced with the profile's values. The profile
|
|
@@ -164,6 +164,8 @@ export function loadConfig() {
|
|
|
164
164
|
// π Ensure the shape is always complete β fill missing sections with defaults
|
|
165
165
|
if (!parsed.apiKeys) parsed.apiKeys = {}
|
|
166
166
|
if (!parsed.providers) parsed.providers = {}
|
|
167
|
+
if (!parsed.settings || typeof parsed.settings !== 'object') parsed.settings = {}
|
|
168
|
+
if (typeof parsed.settings.hideUnconfiguredModels !== 'boolean') parsed.settings.hideUnconfiguredModels = true
|
|
167
169
|
// π Favorites: list of "providerKey/modelId" pinned rows.
|
|
168
170
|
if (!Array.isArray(parsed.favorites)) parsed.favorites = []
|
|
169
171
|
parsed.favorites = parsed.favorites.filter((fav) => typeof fav === 'string' && fav.trim().length > 0)
|
|
@@ -173,6 +175,10 @@ export function loadConfig() {
|
|
|
173
175
|
if (typeof parsed.telemetry.anonymousId !== 'string' || !parsed.telemetry.anonymousId.trim()) parsed.telemetry.anonymousId = null
|
|
174
176
|
// π Ensure profiles section exists (added in profile system)
|
|
175
177
|
if (!parsed.profiles || typeof parsed.profiles !== 'object') parsed.profiles = {}
|
|
178
|
+
for (const profile of Object.values(parsed.profiles)) {
|
|
179
|
+
if (!profile || typeof profile !== 'object') continue
|
|
180
|
+
profile.settings = profile.settings ? { ..._emptyProfileSettings(), ...profile.settings } : _emptyProfileSettings()
|
|
181
|
+
}
|
|
176
182
|
if (parsed.activeProfile && typeof parsed.activeProfile !== 'string') parsed.activeProfile = null
|
|
177
183
|
return parsed
|
|
178
184
|
} catch {
|
|
@@ -385,7 +391,7 @@ export function isProviderEnabled(config, providerKey) {
|
|
|
385
391
|
* π These settings are saved/restored when switching profiles so each profile
|
|
386
392
|
* can have different sort, filter, and ping preferences.
|
|
387
393
|
*
|
|
388
|
-
* @returns {{ tierFilter: string|null, sortColumn: string, sortAsc: boolean, pingInterval: number }}
|
|
394
|
+
* @returns {{ tierFilter: string|null, sortColumn: string, sortAsc: boolean, pingInterval: number, hideUnconfiguredModels: boolean }}
|
|
389
395
|
*/
|
|
390
396
|
export function _emptyProfileSettings() {
|
|
391
397
|
return {
|
|
@@ -393,6 +399,7 @@ export function _emptyProfileSettings() {
|
|
|
393
399
|
sortColumn: 'avg', // π default sort column
|
|
394
400
|
sortAsc: true, // π true = ascending (fastest first for latency)
|
|
395
401
|
pingInterval: 10000, // π default ms between pings in the steady "normal" mode
|
|
402
|
+
hideUnconfiguredModels: true, // π true = default to providers that are actually configured
|
|
396
403
|
}
|
|
397
404
|
}
|
|
398
405
|
|
|
@@ -505,6 +512,10 @@ function _emptyConfig() {
|
|
|
505
512
|
return {
|
|
506
513
|
apiKeys: {},
|
|
507
514
|
providers: {},
|
|
515
|
+
// π Global TUI preferences that should persist even without a named profile.
|
|
516
|
+
settings: {
|
|
517
|
+
hideUnconfiguredModels: true,
|
|
518
|
+
},
|
|
508
519
|
// π Pinned favorites rendered at top of the table ("providerKey/modelId").
|
|
509
520
|
favorites: [],
|
|
510
521
|
// π Telemetry consent is explicit. null = not decided yet.
|
package/src/key-handler.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file key-handler.js
|
|
3
|
-
* @description Factory for the main TUI keypress handler.
|
|
3
|
+
* @description Factory for the main TUI keypress handler and provider key-test model selection.
|
|
4
4
|
*
|
|
5
5
|
* @details
|
|
6
6
|
* This module encapsulates the full onKeyPress switch used by the TUI,
|
|
@@ -8,11 +8,93 @@
|
|
|
8
8
|
* OpenCode/OpenClaw launch actions. It also keeps the live key bindings
|
|
9
9
|
* aligned with the highlighted letters shown in the table headers.
|
|
10
10
|
*
|
|
11
|
+
* It also owns the "test key" model selection used by the Settings overlay.
|
|
12
|
+
* Some providers expose models in `/v1/models` that are not actually callable
|
|
13
|
+
* on the chat-completions endpoint. To avoid false negatives when a user
|
|
14
|
+
* presses `T` in Settings, the helpers below discover candidate model IDs,
|
|
15
|
+
* merge them with repo defaults, then probe several until one is accepted.
|
|
16
|
+
*
|
|
11
17
|
* β Functions:
|
|
18
|
+
* - `buildProviderModelsUrl` β derive the matching `/models` endpoint when available
|
|
19
|
+
* - `parseProviderModelIds` β extract model ids from an OpenAI-style `/models` payload
|
|
20
|
+
* - `listProviderTestModels` β build an ordered candidate list for provider key verification
|
|
21
|
+
* - `classifyProviderTestOutcome` β convert attempted HTTP codes into a settings badge state
|
|
12
22
|
* - `createKeyHandler` β returns the async keypress handler
|
|
13
23
|
*
|
|
14
|
-
* @exports { createKeyHandler }
|
|
24
|
+
* @exports { buildProviderModelsUrl, parseProviderModelIds, listProviderTestModels, classifyProviderTestOutcome, createKeyHandler }
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// π Some providers need an explicit probe model because the first catalog entry
|
|
28
|
+
// π is not guaranteed to be accepted by their chat endpoint.
|
|
29
|
+
const PROVIDER_TEST_MODEL_OVERRIDES = {
|
|
30
|
+
sambanova: ['DeepSeek-V3-0324'],
|
|
31
|
+
nvidia: ['deepseek-ai/deepseek-v3.1-terminus', 'openai/gpt-oss-120b'],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* π buildProviderModelsUrl derives the matching `/models` endpoint for providers
|
|
36
|
+
* π that expose an OpenAI-compatible model list next to `/chat/completions`.
|
|
37
|
+
* @param {string} url
|
|
38
|
+
* @returns {string|null}
|
|
15
39
|
*/
|
|
40
|
+
export function buildProviderModelsUrl(url) {
|
|
41
|
+
if (typeof url !== 'string' || !url.includes('/chat/completions')) return null
|
|
42
|
+
return url.replace(/\/chat\/completions$/, '/models')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* π parseProviderModelIds extracts ids from a standard OpenAI-style `/models` response.
|
|
47
|
+
* π Invalid payloads return an empty list so the key-test flow can safely fall back.
|
|
48
|
+
* @param {unknown} data
|
|
49
|
+
* @returns {string[]}
|
|
50
|
+
*/
|
|
51
|
+
export function parseProviderModelIds(data) {
|
|
52
|
+
if (!data || typeof data !== 'object' || !Array.isArray(data.data)) return []
|
|
53
|
+
return data.data
|
|
54
|
+
.map(entry => (entry && typeof entry.id === 'string') ? entry.id.trim() : '')
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* π listProviderTestModels builds the ordered probe list used by the Settings `T` key.
|
|
60
|
+
* π Order matters:
|
|
61
|
+
* π 1. provider-specific known-good overrides
|
|
62
|
+
* π 2. discovered `/models` ids that also exist in this repo
|
|
63
|
+
* π 3. all discovered `/models` ids
|
|
64
|
+
* π 4. repo static model ids as final fallback
|
|
65
|
+
* @param {string} providerKey
|
|
66
|
+
* @param {{ models?: Array<[string, string, string, string, string]> } | undefined} src
|
|
67
|
+
* @param {string[]} [discoveredModelIds=[]]
|
|
68
|
+
* @returns {string[]}
|
|
69
|
+
*/
|
|
70
|
+
export function listProviderTestModels(providerKey, src, discoveredModelIds = []) {
|
|
71
|
+
const staticModelIds = Array.isArray(src?.models) ? src.models.map(model => model[0]).filter(Boolean) : []
|
|
72
|
+
const staticModelSet = new Set(staticModelIds)
|
|
73
|
+
const preferredDiscoveredIds = discoveredModelIds.filter(modelId => staticModelSet.has(modelId))
|
|
74
|
+
const orderedCandidates = [
|
|
75
|
+
...(PROVIDER_TEST_MODEL_OVERRIDES[providerKey] ?? []),
|
|
76
|
+
...preferredDiscoveredIds,
|
|
77
|
+
...discoveredModelIds,
|
|
78
|
+
...staticModelIds,
|
|
79
|
+
]
|
|
80
|
+
return [...new Set(orderedCandidates)]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* π classifyProviderTestOutcome maps attempted probe codes to a user-facing test result.
|
|
85
|
+
* π This keeps Settings more honest than a binary success/fail badge:
|
|
86
|
+
* π - `rate_limited` means the key is valid but the provider is currently throttling
|
|
87
|
+
* π - `no_callable_model` means the provider responded, but none of the attempted models were callable
|
|
88
|
+
* @param {string[]} codes
|
|
89
|
+
* @returns {'ok'|'fail'|'rate_limited'|'no_callable_model'}
|
|
90
|
+
*/
|
|
91
|
+
export function classifyProviderTestOutcome(codes) {
|
|
92
|
+
if (codes.includes('200')) return 'ok'
|
|
93
|
+
if (codes.includes('401') || codes.includes('403')) return 'fail'
|
|
94
|
+
if (codes.length > 0 && codes.every(code => code === '429')) return 'rate_limited'
|
|
95
|
+
if (codes.length > 0 && codes.every(code => code === '404' || code === '410')) return 'no_callable_model'
|
|
96
|
+
return 'fail'
|
|
97
|
+
}
|
|
16
98
|
|
|
17
99
|
export function createKeyHandler(ctx) {
|
|
18
100
|
const {
|
|
@@ -83,13 +165,45 @@ export function createKeyHandler(ctx) {
|
|
|
83
165
|
const testKey = getApiKey(state.config, providerKey)
|
|
84
166
|
if (!testKey) { state.settingsTestResults[providerKey] = 'fail'; return }
|
|
85
167
|
|
|
86
|
-
// π Use the first model in the provider's list for the test ping
|
|
87
|
-
const testModel = src.models[0]?.[0]
|
|
88
|
-
if (!testModel) { state.settingsTestResults[providerKey] = 'fail'; return }
|
|
89
|
-
|
|
90
168
|
state.settingsTestResults[providerKey] = 'pending'
|
|
91
|
-
const
|
|
92
|
-
|
|
169
|
+
const discoveredModelIds = []
|
|
170
|
+
const modelsUrl = buildProviderModelsUrl(src.url)
|
|
171
|
+
|
|
172
|
+
if (modelsUrl) {
|
|
173
|
+
try {
|
|
174
|
+
const headers = { Authorization: `Bearer ${testKey}` }
|
|
175
|
+
if (providerKey === 'openrouter') {
|
|
176
|
+
headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
|
|
177
|
+
headers['X-Title'] = 'free-coding-models'
|
|
178
|
+
}
|
|
179
|
+
const modelsResp = await fetch(modelsUrl, { headers })
|
|
180
|
+
if (modelsResp.ok) {
|
|
181
|
+
const data = await modelsResp.json()
|
|
182
|
+
discoveredModelIds.push(...parseProviderModelIds(data))
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
// π Discovery failure is non-fatal; we still have repo-defined fallbacks.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const candidateModels = listProviderTestModels(providerKey, src, discoveredModelIds)
|
|
190
|
+
if (candidateModels.length === 0) { state.settingsTestResults[providerKey] = 'fail'; return }
|
|
191
|
+
const attemptedCodes = []
|
|
192
|
+
|
|
193
|
+
for (const testModel of candidateModels.slice(0, 8)) {
|
|
194
|
+
const { code } = await ping(testKey, testModel, providerKey, src.url)
|
|
195
|
+
attemptedCodes.push(code)
|
|
196
|
+
if (code === '200') {
|
|
197
|
+
state.settingsTestResults[providerKey] = 'ok'
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
if (code === '401' || code === '403') {
|
|
201
|
+
state.settingsTestResults[providerKey] = 'fail'
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
state.settingsTestResults[providerKey] = classifyProviderTestOutcome(attemptedCodes)
|
|
93
207
|
}
|
|
94
208
|
|
|
95
209
|
// π Manual update checker from settings; keeps status visible in maintenance row.
|
|
@@ -146,6 +260,7 @@ export function createKeyHandler(ctx) {
|
|
|
146
260
|
sortColumn: state.sortColumn,
|
|
147
261
|
sortAsc: state.sortDirection === 'asc',
|
|
148
262
|
pingInterval: state.pingInterval,
|
|
263
|
+
hideUnconfiguredModels: state.hideUnconfiguredModels,
|
|
149
264
|
})
|
|
150
265
|
setActiveProfile(state.config, name)
|
|
151
266
|
state.activeProfile = name
|
|
@@ -759,6 +874,7 @@ export function createKeyHandler(ctx) {
|
|
|
759
874
|
sortColumn: state.sortColumn,
|
|
760
875
|
sortAsc: state.sortDirection === 'asc',
|
|
761
876
|
pingInterval: state.pingInterval,
|
|
877
|
+
hideUnconfiguredModels: state.hideUnconfiguredModels,
|
|
762
878
|
})
|
|
763
879
|
setActiveProfile(state.config, 'default')
|
|
764
880
|
state.activeProfile = 'default'
|
|
@@ -786,6 +902,7 @@ export function createKeyHandler(ctx) {
|
|
|
786
902
|
} else {
|
|
787
903
|
state.tierFilterMode = 0
|
|
788
904
|
}
|
|
905
|
+
state.hideUnconfiguredModels = settings.hideUnconfiguredModels === true
|
|
789
906
|
state.activeProfile = nextProfile
|
|
790
907
|
// π Rebuild favorites from profile data
|
|
791
908
|
syncFavoriteFlags(state.results, state.config)
|
|
@@ -886,6 +1003,26 @@ export function createKeyHandler(ctx) {
|
|
|
886
1003
|
setPingMode(PING_MODE_CYCLE[nextIdx], 'manual')
|
|
887
1004
|
}
|
|
888
1005
|
|
|
1006
|
+
// π E toggles hiding models whose provider has no configured API key.
|
|
1007
|
+
// π The preference is saved globally and mirrored into the active profile.
|
|
1008
|
+
if (key.name === 'e') {
|
|
1009
|
+
state.hideUnconfiguredModels = !state.hideUnconfiguredModels
|
|
1010
|
+
if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
|
|
1011
|
+
state.config.settings.hideUnconfiguredModels = state.hideUnconfiguredModels
|
|
1012
|
+
if (state.activeProfile && state.config.profiles?.[state.activeProfile]) {
|
|
1013
|
+
const profile = state.config.profiles[state.activeProfile]
|
|
1014
|
+
if (!profile.settings || typeof profile.settings !== 'object') profile.settings = {}
|
|
1015
|
+
profile.settings.hideUnconfiguredModels = state.hideUnconfiguredModels
|
|
1016
|
+
}
|
|
1017
|
+
saveConfig(state.config)
|
|
1018
|
+
applyTierFilter()
|
|
1019
|
+
const visible = state.results.filter(r => !r.hidden)
|
|
1020
|
+
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
1021
|
+
state.cursor = 0
|
|
1022
|
+
state.scrollOffset = 0
|
|
1023
|
+
return
|
|
1024
|
+
}
|
|
1025
|
+
|
|
889
1026
|
// π Tier toggle key: T = cycle through each individual tier (All β S+ β S β A+ β A β A- β B+ β B β C β All)
|
|
890
1027
|
if (key.name === 't') {
|
|
891
1028
|
state.tierFilterMode = (state.tierFilterMode + 1) % TIER_CYCLE.length
|
package/src/overlays.js
CHANGED
|
@@ -105,6 +105,8 @@ export function createOverlayRenderers(state, deps) {
|
|
|
105
105
|
let testBadge = chalk.dim('[Test β]')
|
|
106
106
|
if (testResult === 'pending') testBadge = chalk.yellow('[Testingβ¦]')
|
|
107
107
|
else if (testResult === 'ok') testBadge = chalk.greenBright('[Test β
]')
|
|
108
|
+
else if (testResult === 'rate_limited') testBadge = chalk.yellow('[Rate limit β³]')
|
|
109
|
+
else if (testResult === 'no_callable_model') testBadge = chalk.magenta('[No model β ]')
|
|
108
110
|
else if (testResult === 'fail') testBadge = chalk.red('[Test β]')
|
|
109
111
|
const rateSummary = chalk.dim((meta.rateLimits || 'No limit info').slice(0, 36))
|
|
110
112
|
|
|
@@ -279,6 +281,7 @@ export function createOverlayRenderers(state, deps) {
|
|
|
279
281
|
lines.push('')
|
|
280
282
|
lines.push(` ${chalk.bold('Controls')}`)
|
|
281
283
|
lines.push(` ${chalk.yellow('W')} Toggle ping mode ${chalk.dim('(speed 2s β normal 10s β slow 30s β forced 4s)')}`)
|
|
284
|
+
lines.push(` ${chalk.yellow('E')} Toggle configured models only ${chalk.dim('(enabled by default, persisted globally + in profiles)')}`)
|
|
282
285
|
lines.push(` ${chalk.yellow('X')} Toggle token log page ${chalk.dim('(shows recent request usage from request-log.jsonl)')}`)
|
|
283
286
|
lines.push(` ${chalk.yellow('Z')} Cycle tool mode ${chalk.dim('(OpenCode CLI β OpenCode Desktop β OpenClaw)')}`)
|
|
284
287
|
lines.push(` ${chalk.yellow('F')} Toggle favorite on selected row ${chalk.dim('(β pinned at top, persisted)')}`)
|
|
@@ -288,7 +291,7 @@ export function createOverlayRenderers(state, deps) {
|
|
|
288
291
|
lines.push(` ${chalk.yellow('P')} Open settings ${chalk.dim('(manage API keys, provider toggles, manual update)')}`)
|
|
289
292
|
lines.push(` ${chalk.yellow('Shift+P')} Cycle config profile ${chalk.dim('(switch between saved profiles live)')}`)
|
|
290
293
|
lines.push(` ${chalk.yellow('Shift+S')} Save current config as a named profile ${chalk.dim('(inline prompt β type name + Enter)')}`)
|
|
291
|
-
lines.push(` ${chalk.dim('Profiles store: favorites, sort, tier filter, ping interval, API keys.')}`)
|
|
294
|
+
lines.push(` ${chalk.dim('Profiles store: favorites, sort, tier filter, ping interval, configured-only filter, API keys.')}`)
|
|
292
295
|
lines.push(` ${chalk.dim('Use --profile <name> to load a profile on startup.')}`)
|
|
293
296
|
lines.push(` ${chalk.yellow('K')} / ${chalk.yellow('Esc')} Show/hide this help`)
|
|
294
297
|
lines.push(` ${chalk.yellow('Ctrl+C')} Exit`)
|
package/src/provider-metadata.js
CHANGED
|
@@ -77,140 +77,140 @@ export const OPENCODE_MODEL_MAP = {
|
|
|
77
77
|
export const PROVIDER_METADATA = {
|
|
78
78
|
nvidia: {
|
|
79
79
|
label: 'NVIDIA NIM',
|
|
80
|
-
color: chalk.rgb(
|
|
80
|
+
color: chalk.rgb(178, 235, 190),
|
|
81
81
|
signupUrl: 'https://build.nvidia.com',
|
|
82
82
|
signupHint: 'Profile β API Keys β Generate',
|
|
83
83
|
rateLimits: 'Free tier (provider quota by model)',
|
|
84
84
|
},
|
|
85
85
|
groq: {
|
|
86
86
|
label: 'Groq',
|
|
87
|
-
color: chalk.rgb(
|
|
87
|
+
color: chalk.rgb(255, 204, 188),
|
|
88
88
|
signupUrl: 'https://console.groq.com/keys',
|
|
89
89
|
signupHint: 'API Keys β Create API Key',
|
|
90
90
|
rateLimits: 'Free dev tier (provider quota)',
|
|
91
91
|
},
|
|
92
92
|
cerebras: {
|
|
93
93
|
label: 'Cerebras',
|
|
94
|
-
color: chalk.rgb(
|
|
94
|
+
color: chalk.rgb(179, 229, 252),
|
|
95
95
|
signupUrl: 'https://cloud.cerebras.ai',
|
|
96
96
|
signupHint: 'API Keys β Create',
|
|
97
97
|
rateLimits: 'Free dev tier (provider quota)',
|
|
98
98
|
},
|
|
99
99
|
sambanova: {
|
|
100
100
|
label: 'SambaNova',
|
|
101
|
-
color: chalk.rgb(255,
|
|
101
|
+
color: chalk.rgb(255, 224, 178),
|
|
102
102
|
signupUrl: 'https://cloud.sambanova.ai/apis',
|
|
103
103
|
signupHint: 'SambaCloud portal β Create API key',
|
|
104
104
|
rateLimits: 'Dev tier generous quota',
|
|
105
105
|
},
|
|
106
106
|
openrouter: {
|
|
107
107
|
label: 'OpenRouter',
|
|
108
|
-
color: chalk.rgb(
|
|
108
|
+
color: chalk.rgb(225, 190, 231),
|
|
109
109
|
signupUrl: 'https://openrouter.ai/keys',
|
|
110
110
|
signupHint: 'API Keys β Create',
|
|
111
111
|
rateLimits: '50 req/day, 20/min (:free shared quota)',
|
|
112
112
|
},
|
|
113
113
|
huggingface: {
|
|
114
114
|
label: 'Hugging Face Inference',
|
|
115
|
-
color: chalk.rgb(255,
|
|
115
|
+
color: chalk.rgb(255, 245, 157),
|
|
116
116
|
signupUrl: 'https://huggingface.co/settings/tokens',
|
|
117
117
|
signupHint: 'Settings β Access Tokens',
|
|
118
118
|
rateLimits: 'Free monthly credits (~$0.10)',
|
|
119
119
|
},
|
|
120
120
|
replicate: {
|
|
121
121
|
label: 'Replicate',
|
|
122
|
-
color: chalk.rgb(
|
|
122
|
+
color: chalk.rgb(187, 222, 251),
|
|
123
123
|
signupUrl: 'https://replicate.com/account/api-tokens',
|
|
124
124
|
signupHint: 'Account β API Tokens',
|
|
125
125
|
rateLimits: 'Developer free quota',
|
|
126
126
|
},
|
|
127
127
|
deepinfra: {
|
|
128
128
|
label: 'DeepInfra',
|
|
129
|
-
color: chalk.rgb(
|
|
129
|
+
color: chalk.rgb(178, 223, 219),
|
|
130
130
|
signupUrl: 'https://deepinfra.com/login',
|
|
131
131
|
signupHint: 'Login β API keys',
|
|
132
132
|
rateLimits: 'Free dev tier (low-latency quota)',
|
|
133
133
|
},
|
|
134
134
|
fireworks: {
|
|
135
135
|
label: 'Fireworks AI',
|
|
136
|
-
color: chalk.rgb(255,
|
|
136
|
+
color: chalk.rgb(255, 205, 210),
|
|
137
137
|
signupUrl: 'https://fireworks.ai',
|
|
138
138
|
signupHint: 'Create account β Generate API key',
|
|
139
139
|
rateLimits: '$1 free credits (new dev accounts)',
|
|
140
140
|
},
|
|
141
141
|
codestral: {
|
|
142
142
|
label: 'Mistral Codestral',
|
|
143
|
-
color: chalk.rgb(
|
|
143
|
+
color: chalk.rgb(248, 187, 208),
|
|
144
144
|
signupUrl: 'https://codestral.mistral.ai',
|
|
145
145
|
signupHint: 'API Keys β Create',
|
|
146
146
|
rateLimits: '30 req/min, 2000/day',
|
|
147
147
|
},
|
|
148
148
|
hyperbolic: {
|
|
149
149
|
label: 'Hyperbolic',
|
|
150
|
-
color: chalk.rgb(
|
|
150
|
+
color: chalk.rgb(200, 230, 201),
|
|
151
151
|
signupUrl: 'https://app.hyperbolic.ai/settings',
|
|
152
152
|
signupHint: 'Settings β API Keys',
|
|
153
153
|
rateLimits: '$1 free trial credits',
|
|
154
154
|
},
|
|
155
155
|
scaleway: {
|
|
156
156
|
label: 'Scaleway',
|
|
157
|
-
color: chalk.rgb(
|
|
157
|
+
color: chalk.rgb(129, 212, 250),
|
|
158
158
|
signupUrl: 'https://console.scaleway.com/iam/api-keys',
|
|
159
159
|
signupHint: 'IAM β API Keys',
|
|
160
160
|
rateLimits: '1M free tokens',
|
|
161
161
|
},
|
|
162
162
|
googleai: {
|
|
163
163
|
label: 'Google AI Studio',
|
|
164
|
-
color: chalk.rgb(
|
|
164
|
+
color: chalk.rgb(187, 222, 251),
|
|
165
165
|
signupUrl: 'https://aistudio.google.com/apikey',
|
|
166
166
|
signupHint: 'Get API key',
|
|
167
167
|
rateLimits: '14.4K req/day, 30/min',
|
|
168
168
|
},
|
|
169
169
|
siliconflow: {
|
|
170
170
|
label: 'SiliconFlow',
|
|
171
|
-
color: chalk.rgb(
|
|
171
|
+
color: chalk.rgb(178, 235, 242),
|
|
172
172
|
signupUrl: 'https://cloud.siliconflow.cn/account/ak',
|
|
173
173
|
signupHint: 'API Keys β Create',
|
|
174
174
|
rateLimits: 'Free models: usually 100 RPM, varies by model',
|
|
175
175
|
},
|
|
176
176
|
together: {
|
|
177
177
|
label: 'Together AI',
|
|
178
|
-
color: chalk.rgb(
|
|
178
|
+
color: chalk.rgb(197, 225, 165),
|
|
179
179
|
signupUrl: 'https://api.together.ai/settings/api-keys',
|
|
180
180
|
signupHint: 'Settings β API keys',
|
|
181
181
|
rateLimits: 'Credits/promos vary by account (check console)',
|
|
182
182
|
},
|
|
183
183
|
cloudflare: {
|
|
184
184
|
label: 'Cloudflare Workers AI',
|
|
185
|
-
color: chalk.rgb(
|
|
185
|
+
color: chalk.rgb(255, 204, 128),
|
|
186
186
|
signupUrl: 'https://dash.cloudflare.com',
|
|
187
187
|
signupHint: 'Create AI API token + set CLOUDFLARE_ACCOUNT_ID',
|
|
188
188
|
rateLimits: 'Free: 10k neurons/day, text-gen 300 RPM',
|
|
189
189
|
},
|
|
190
190
|
perplexity: {
|
|
191
191
|
label: 'Perplexity API',
|
|
192
|
-
color: chalk.rgb(
|
|
192
|
+
color: chalk.rgb(159, 234, 201),
|
|
193
193
|
signupUrl: 'https://www.perplexity.ai/settings/api',
|
|
194
194
|
signupHint: 'Generate API key (billing may be required)',
|
|
195
195
|
rateLimits: 'Tiered limits by spend (default ~50 RPM)',
|
|
196
196
|
},
|
|
197
197
|
qwen: {
|
|
198
198
|
label: 'Alibaba Cloud (DashScope)',
|
|
199
|
-
color: chalk.rgb(255,
|
|
199
|
+
color: chalk.rgb(255, 224, 130),
|
|
200
200
|
signupUrl: 'https://modelstudio.console.alibabacloud.com',
|
|
201
201
|
signupHint: 'Model Studio β API Key β Create (1M free tokens, 90 days)',
|
|
202
202
|
rateLimits: '1M free tokens per model (Singapore region, 90 days)',
|
|
203
203
|
},
|
|
204
204
|
zai: {
|
|
205
205
|
label: 'ZAI (z.ai)',
|
|
206
|
-
color: chalk.rgb(
|
|
206
|
+
color: chalk.rgb(174, 213, 255),
|
|
207
207
|
signupUrl: 'https://z.ai',
|
|
208
208
|
signupHint: 'Sign up and generate an API key',
|
|
209
209
|
rateLimits: 'Free tier (generous quota)',
|
|
210
210
|
},
|
|
211
211
|
iflow: {
|
|
212
212
|
label: 'iFlow',
|
|
213
|
-
color: chalk.rgb(
|
|
213
|
+
color: chalk.rgb(220, 231, 117),
|
|
214
214
|
signupUrl: 'https://platform.iflow.cn',
|
|
215
215
|
signupHint: 'Register β Personal Information β Generate API Key (7-day expiry)',
|
|
216
216
|
rateLimits: 'Free for individuals (no request limits)',
|
package/src/render-table.js
CHANGED
|
@@ -43,29 +43,29 @@ import { calculateViewport, sortResultsWithPinnedFavorites, renderProxyStatusLin
|
|
|
43
43
|
const require = createRequire(import.meta.url)
|
|
44
44
|
const { version: LOCAL_VERSION } = require('../package.json')
|
|
45
45
|
|
|
46
|
-
// π Provider column palette:
|
|
47
|
-
// π
|
|
46
|
+
// π Provider column palette: soft pastel rainbow so each provider stays easy
|
|
47
|
+
// π to spot without turning the table into a harsh neon wall.
|
|
48
48
|
const PROVIDER_COLOR = {
|
|
49
|
-
nvidia: [
|
|
50
|
-
groq: [
|
|
51
|
-
cerebras: [
|
|
52
|
-
sambanova: [
|
|
53
|
-
openrouter: [
|
|
54
|
-
huggingface: [
|
|
55
|
-
replicate: [
|
|
56
|
-
deepinfra: [
|
|
57
|
-
fireworks: [
|
|
58
|
-
codestral: [
|
|
59
|
-
hyperbolic: [
|
|
60
|
-
scaleway: [
|
|
61
|
-
googleai: [
|
|
62
|
-
siliconflow: [
|
|
63
|
-
together: [
|
|
64
|
-
cloudflare: [
|
|
65
|
-
perplexity: [
|
|
66
|
-
qwen: [
|
|
67
|
-
zai: [
|
|
68
|
-
iflow: [
|
|
49
|
+
nvidia: [178, 235, 190],
|
|
50
|
+
groq: [255, 204, 188],
|
|
51
|
+
cerebras: [179, 229, 252],
|
|
52
|
+
sambanova: [255, 224, 178],
|
|
53
|
+
openrouter: [225, 190, 231],
|
|
54
|
+
huggingface: [255, 245, 157],
|
|
55
|
+
replicate: [187, 222, 251],
|
|
56
|
+
deepinfra: [178, 223, 219],
|
|
57
|
+
fireworks: [255, 205, 210],
|
|
58
|
+
codestral: [248, 187, 208],
|
|
59
|
+
hyperbolic: [200, 230, 201],
|
|
60
|
+
scaleway: [129, 212, 250],
|
|
61
|
+
googleai: [187, 222, 251],
|
|
62
|
+
siliconflow: [178, 235, 242],
|
|
63
|
+
together: [197, 225, 165],
|
|
64
|
+
cloudflare: [255, 204, 128],
|
|
65
|
+
perplexity: [159, 234, 201],
|
|
66
|
+
qwen: [255, 224, 130],
|
|
67
|
+
zai: [174, 213, 255],
|
|
68
|
+
iflow: [220, 231, 117],
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
// π Active proxy reference for footer status line (set by bin/free-coding-models.js).
|
|
@@ -77,7 +77,7 @@ export function setActiveProxy(proxyInstance) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
// βββ renderTable: mode param controls footer hint text (opencode vs openclaw) βββββββββ
|
|
80
|
-
export 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, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto') {
|
|
80
|
+
export 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, terminalCols = 0, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false) {
|
|
81
81
|
// π Filter out hidden models for display
|
|
82
82
|
const visibleResults = results.filter(r => !r.hidden)
|
|
83
83
|
|
|
@@ -85,48 +85,48 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
85
85
|
const down = visibleResults.filter(r => r.status === 'down').length
|
|
86
86
|
const timeout = visibleResults.filter(r => r.status === 'timeout').length
|
|
87
87
|
const pending = visibleResults.filter(r => r.status === 'pending').length
|
|
88
|
+
const totalVisible = visibleResults.length
|
|
89
|
+
const completedPings = Math.max(0, totalVisible - pending)
|
|
88
90
|
|
|
89
91
|
// π Calculate seconds until next ping
|
|
90
92
|
const timeSinceLastPing = Date.now() - lastPingTime
|
|
91
93
|
const timeUntilNextPing = Math.max(0, pingInterval - timeSinceLastPing)
|
|
92
|
-
const secondsUntilNext =
|
|
93
|
-
|
|
94
|
-
const phase = pending > 0
|
|
95
|
-
? chalk.dim(`discovering β ${pending} remainingβ¦`)
|
|
96
|
-
: pendingPings > 0
|
|
97
|
-
? chalk.dim(`pinging β ${pendingPings} in flightβ¦`)
|
|
98
|
-
: chalk.dim(`next ping ${secondsUntilNext}s`)
|
|
94
|
+
const secondsUntilNext = timeUntilNextPing / 1000
|
|
95
|
+
const secondsUntilNextLabel = secondsUntilNext.toFixed(1)
|
|
99
96
|
|
|
100
97
|
const intervalSec = Math.round(pingInterval / 1000)
|
|
101
98
|
const pingModeMeta = {
|
|
102
|
-
speed: { label:
|
|
103
|
-
normal: { label:
|
|
104
|
-
slow: { label:
|
|
105
|
-
forced: { label:
|
|
99
|
+
speed: { label: 'fast', color: chalk.bold.rgb(255, 210, 80) },
|
|
100
|
+
normal: { label: 'normal', color: chalk.bold.rgb(120, 210, 255) },
|
|
101
|
+
slow: { label: 'slow', color: chalk.bold.rgb(255, 170, 90) },
|
|
102
|
+
forced: { label: 'forced', color: chalk.bold.rgb(255, 120, 120) },
|
|
106
103
|
}
|
|
107
104
|
const activePingMode = pingModeMeta[pingMode] ?? pingModeMeta.normal
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
? chalk.
|
|
111
|
-
:
|
|
112
|
-
? chalk.
|
|
113
|
-
:
|
|
114
|
-
? chalk.
|
|
115
|
-
:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
105
|
+
const pingProgressText = `${completedPings}/${totalVisible}`
|
|
106
|
+
const nextCountdownColor = secondsUntilNext > 8
|
|
107
|
+
? chalk.red.bold
|
|
108
|
+
: secondsUntilNext >= 4
|
|
109
|
+
? chalk.yellow.bold
|
|
110
|
+
: secondsUntilNext < 1
|
|
111
|
+
? chalk.greenBright.bold
|
|
112
|
+
: chalk.green.bold
|
|
113
|
+
const pingControlBadge =
|
|
114
|
+
activePingMode.color(' [ ') +
|
|
115
|
+
chalk.yellow.bold('W') +
|
|
116
|
+
activePingMode.color(` Ping Interval : ${intervalSec}s (${activePingMode.label}) - ${pingProgressText} - next : `) +
|
|
117
|
+
nextCountdownColor(`${secondsUntilNextLabel}s`) +
|
|
118
|
+
activePingMode.color(' ]')
|
|
119
|
+
|
|
120
|
+
// π Tool badge keeps the active launch target visible in the header, so the
|
|
121
|
+
// π footer no longer needs a redundant Enter action or mode toggle reminder.
|
|
119
122
|
let modeBadge
|
|
120
123
|
if (mode === 'openclaw') {
|
|
121
|
-
modeBadge = chalk.bold.rgb(255, 100, 50)(' [
|
|
124
|
+
modeBadge = chalk.bold.rgb(255, 100, 50)(' [ ') + chalk.yellow.bold('Z') + chalk.bold.rgb(255, 100, 50)(' Tool : OpenClaw ]')
|
|
122
125
|
} else if (mode === 'opencode-desktop') {
|
|
123
|
-
modeBadge = chalk.bold.rgb(0, 200, 255)(' [
|
|
126
|
+
modeBadge = chalk.bold.rgb(0, 200, 255)(' [ ') + chalk.yellow.bold('Z') + chalk.bold.rgb(0, 200, 255)(' Tool : OpenCode Desktop ]')
|
|
124
127
|
} else {
|
|
125
|
-
modeBadge = chalk.bold.rgb(0, 200, 255)(' [
|
|
128
|
+
modeBadge = chalk.bold.rgb(0, 200, 255)(' [ ') + chalk.yellow.bold('Z') + chalk.bold.rgb(0, 200, 255)(' Tool : OpenCode CLI ]')
|
|
126
129
|
}
|
|
127
|
-
|
|
128
|
-
// π Add mode toggle hint
|
|
129
|
-
const modeHint = chalk.dim.yellow(' (Z to toggle)')
|
|
130
130
|
|
|
131
131
|
// π Tier filter badge shown when filtering is active (shows exact tier name)
|
|
132
132
|
const TIER_CYCLE_NAMES = [null, 'S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
|
|
@@ -172,16 +172,29 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
172
172
|
const W_UPTIME = 6
|
|
173
173
|
const W_TOKENS = 7
|
|
174
174
|
const W_USAGE = 7
|
|
175
|
+
const MIN_TABLE_WIDTH = 166
|
|
176
|
+
|
|
177
|
+
if (terminalCols > 0 && terminalCols < MIN_TABLE_WIDTH) {
|
|
178
|
+
const lines = []
|
|
179
|
+
const blankLines = Math.max(0, Math.floor(((terminalRows || 24) - 3) / 2))
|
|
180
|
+
const warning = 'Please maximize your terminal for optimal use. The current terminal width is too small for the full table.'
|
|
181
|
+
const padLeft = Math.max(0, Math.floor((terminalCols - warning.length) / 2))
|
|
182
|
+
for (let i = 0; i < blankLines; i++) lines.push('')
|
|
183
|
+
lines.push(' '.repeat(padLeft) + chalk.red.bold(warning))
|
|
184
|
+
while (terminalRows > 0 && lines.length < terminalRows) lines.push('')
|
|
185
|
+
const EL = '\x1b[K'
|
|
186
|
+
return lines.map(line => line + EL).join('\n')
|
|
187
|
+
}
|
|
175
188
|
|
|
176
189
|
// π Sort models using the shared helper
|
|
177
190
|
const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection)
|
|
178
191
|
|
|
179
192
|
const lines = [
|
|
180
|
-
` ${chalk.greenBright.bold(
|
|
193
|
+
` ${chalk.greenBright.bold(`β
Free-Coding-Models v${LOCAL_VERSION}`)}${modeBadge}${pingControlBadge}${tierBadge}${originBadge}${profileBadge}${chalk.reset('')} ` +
|
|
181
194
|
chalk.greenBright(`β
${up}`) + chalk.dim(' up ') +
|
|
182
195
|
chalk.yellow(`β³ ${timeout}`) + chalk.dim(' timeout ') +
|
|
183
196
|
chalk.red(`β ${down}`) + chalk.dim(' down ') +
|
|
184
|
-
|
|
197
|
+
'',
|
|
185
198
|
'',
|
|
186
199
|
]
|
|
187
200
|
|
|
@@ -276,6 +289,16 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
276
289
|
chalk.dim('β'.repeat(W_USAGE))
|
|
277
290
|
)
|
|
278
291
|
|
|
292
|
+
if (sorted.length === 0) {
|
|
293
|
+
lines.push('')
|
|
294
|
+
if (hideUnconfiguredModels) {
|
|
295
|
+
lines.push(` ${chalk.redBright.bold('Press P to configure your API key.')}`)
|
|
296
|
+
lines.push(` ${chalk.dim('No configured provider currently exposes visible models in the table.')}`)
|
|
297
|
+
} else {
|
|
298
|
+
lines.push(` ${chalk.yellow.bold('No models match the current filters.')}`)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
279
302
|
// π Viewport clipping: only render models that fit on screen
|
|
280
303
|
const vp = calculateViewport(terminalRows, scrollOffset, sorted.length)
|
|
281
304
|
|
|
@@ -303,6 +326,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
303
326
|
const prefixDisplayWidth = 2
|
|
304
327
|
const nameWidth = Math.max(0, W_MODEL - prefixDisplayWidth)
|
|
305
328
|
const name = favoritePrefix + r.label.slice(0, nameWidth).padEnd(nameWidth)
|
|
329
|
+
const modelColor = chalk.rgb(...providerRgb)
|
|
306
330
|
const sweScore = r.sweScore ?? 'β'
|
|
307
331
|
// π SWE% colorized on the same gradient as Tier:
|
|
308
332
|
// β₯70% bright neon green (S+), β₯60% green (S), β₯50% yellow-green (A+),
|
|
@@ -490,10 +514,11 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
490
514
|
uptimeCell = chalk.red(uptimeStr.padEnd(W_UPTIME))
|
|
491
515
|
}
|
|
492
516
|
|
|
493
|
-
// π
|
|
494
|
-
|
|
517
|
+
// π Model text now mirrors the provider hue so provider affinity is visible
|
|
518
|
+
// π even before the eye reaches the Provider column.
|
|
519
|
+
const nameCell = isCursor ? modelColor.bold(name) : modelColor(name)
|
|
495
520
|
const sourceCursorText = providerName.padEnd(W_SOURCE)
|
|
496
|
-
const sourceCell = isCursor ? chalk.
|
|
521
|
+
const sourceCell = isCursor ? chalk.rgb(...providerRgb).bold(sourceCursorText) : source
|
|
497
522
|
|
|
498
523
|
// π Usage column β provider-scoped remaining quota when measurable,
|
|
499
524
|
// π otherwise a green dot to show "usable but not meaningfully quantifiable".
|
|
@@ -527,12 +552,12 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
527
552
|
const row = ' ' + num + ' ' + tier + ' ' + sweCell + ' ' + ctxCell + ' ' + nameCell + ' ' + sourceCell + ' ' + pingCell + ' ' + avgCell + ' ' + status + ' ' + speedCell + ' ' + stabCell + ' ' + uptimeCell + ' ' + tokensCell + ' ' + usageCell
|
|
528
553
|
|
|
529
554
|
if (isCursor) {
|
|
530
|
-
lines.push(chalk.bgRgb(
|
|
555
|
+
lines.push(chalk.bgRgb(155, 55, 135)(row))
|
|
531
556
|
} else if (r.isRecommended) {
|
|
532
557
|
// π Medium green background for recommended models (distinguishable from favorites)
|
|
533
558
|
lines.push(chalk.bgRgb(15, 40, 15)(row))
|
|
534
559
|
} else if (r.isFavorite) {
|
|
535
|
-
lines.push(chalk.bgRgb(
|
|
560
|
+
lines.push(chalk.bgRgb(88, 64, 10)(row))
|
|
536
561
|
} else {
|
|
537
562
|
lines.push(row)
|
|
538
563
|
}
|
|
@@ -548,15 +573,11 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
548
573
|
} else {
|
|
549
574
|
lines.push('')
|
|
550
575
|
}
|
|
551
|
-
// π Footer hints
|
|
576
|
+
// π Footer hints keep only navigation and secondary actions now that the
|
|
577
|
+
// π active tool target is already visible in the header badge.
|
|
552
578
|
const hotkey = (keyLabel, text) => chalk.yellow(keyLabel) + chalk.dim(text)
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
: mode === 'opencode-desktop'
|
|
556
|
-
? hotkey('Enter', 'βOpenDesktop')
|
|
557
|
-
: hotkey('Enter', 'βOpenCode')
|
|
558
|
-
// π Line 1: core navigation + sorting shortcuts
|
|
559
|
-
lines.push(chalk.dim(` ββ Navigate β’ `) + actionHint + chalk.dim(` β’ `) + hotkey('F', ' Toggle Favorite') + chalk.dim(` β’ Press Highlighted letters in column named to sort & filter β’ `) + hotkey('T', ' Tier') + chalk.dim(` β’ `) + hotkey('D', ' Provider') + chalk.dim(` β’ `) + hotkey('W', ' Ping Mode : FAST/NORMAL/SLOW/FORCED') + chalk.dim(` β’ `) + hotkey('Z', ' Tool Mode') + chalk.dim(` β’ `) + hotkey('X', ' Token Logs') + chalk.dim(` β’ `) + hotkey('P', ' Settings') + chalk.dim(` β’ `) + hotkey('K', ' Help'))
|
|
579
|
+
// π Line 1: core navigation + filtering shortcuts
|
|
580
|
+
lines.push(chalk.dim(` ββ Navigate β’ `) + hotkey('F', ' Toggle Favorite') + chalk.dim(` β’ `) + hotkey('T', ' Tier') + chalk.dim(` β’ `) + hotkey('D', ' Provider') + chalk.dim(` β’ `) + hotkey('E', ' Configured Only') + chalk.dim(` β’ `) + hotkey('X', ' Token Logs') + chalk.dim(` β’ `) + hotkey('P', ' Settings') + chalk.dim(` β’ `) + hotkey('K', ' Help'))
|
|
560
581
|
// π Line 2: profiles, recommend, feature request, bug report, and extended hints β gives visibility to less-obvious features
|
|
561
582
|
lines.push(chalk.dim(` `) + hotkey('β§P', ' Cycle profile') + chalk.dim(` β’ `) + hotkey('β§S', ' Save profile') + chalk.dim(` β’ `) + hotkey('Q', ' Smart Recommend') + chalk.dim(` β’ `) + hotkey('J', ' Request feature') + chalk.dim(` β’ `) + hotkey('I', ' Report bug'))
|
|
562
583
|
// π Proxy status line β always rendered with explicit state (starting/running/failed/stopped)
|
|
@@ -575,8 +596,6 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
575
596
|
chalk.dim(' β ') +
|
|
576
597
|
chalk.rgb(200, 150, 255)('https://discord.gg/5MbTnDC3Md') +
|
|
577
598
|
chalk.dim(' β’ ') +
|
|
578
|
-
chalk.dim(`v${LOCAL_VERSION}`) +
|
|
579
|
-
chalk.dim(' β’ ') +
|
|
580
599
|
chalk.dim('Ctrl+C Exit')
|
|
581
600
|
)
|
|
582
601
|
|