free-coding-models 0.1.64 → 0.1.66
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 +84 -18
- package/bin/free-coding-models.js +465 -40
- package/lib/config.js +25 -4
- package/package.json +1 -1
- package/sources.js +70 -2
package/README.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
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-
|
|
6
|
-
<img src="https://img.shields.io/badge/providers-
|
|
5
|
+
<img src="https://img.shields.io/badge/models-134-76b900?logo=nvidia" alt="models count">
|
|
6
|
+
<img src="https://img.shields.io/badge/providers-17-blue" alt="providers count">
|
|
7
7
|
</p>
|
|
8
8
|
|
|
9
9
|
<h1 align="center">free-coding-models</h1>
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
<p align="center">
|
|
26
26
|
<strong>Find the fastest coding LLM models in seconds</strong><br>
|
|
27
|
-
<sub>Ping free coding models from
|
|
27
|
+
<sub>Ping free coding models from 17 providers in real-time — pick the best one for OpenCode, OpenClaw, or any AI coding assistant</sub>
|
|
28
28
|
</p>
|
|
29
29
|
|
|
30
30
|
<p align="center">
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
## ✨ Features
|
|
48
48
|
|
|
49
49
|
- **🎯 Coding-focused** — Only LLM models optimized for code generation, not chat or vision
|
|
50
|
-
- **🌐 Multi-provider** —
|
|
51
|
-
- **⚙️ Settings screen** — Press `P` to manage provider API keys, enable/disable providers,
|
|
50
|
+
- **🌐 Multi-provider** — 134 models from NVIDIA NIM, Groq, Cerebras, SambaNova, OpenRouter, Hugging Face Inference, Replicate, DeepInfra, Fireworks AI, Codestral, Hyperbolic, Scaleway, Google AI, SiliconFlow, Together AI, Cloudflare Workers AI, and Perplexity API
|
|
51
|
+
- **⚙️ Settings screen** — Press `P` to manage provider API keys, enable/disable providers, test keys live, and manually check/install updates
|
|
52
52
|
- **🚀 Parallel pings** — All models tested simultaneously via native `fetch`
|
|
53
53
|
- **📊 Real-time animation** — Watch latency appear live in alternate screen buffer
|
|
54
54
|
- **🏆 Smart ranking** — Top 3 fastest models highlighted with medals 🥇🥈🥉
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
- **📶 Status indicators** — UP ✅ · No Key 🔑 · Timeout ⏳ · Overloaded 🔥 · Not Found 🚫
|
|
65
65
|
- **🔍 Keyless latency** — Models are pinged even without an API key — a `🔑 NO KEY` status confirms the server is reachable with real latency shown, so you can compare providers before committing to a key
|
|
66
66
|
- **🏷 Tier filtering** — Filter models by tier letter (S, A, B, C) with `--tier` flag or dynamically with `T` key
|
|
67
|
+
- **⭐ Persistent favorites** — Press `F` on a selected row to pin/unpin it; favorites stay at top with a dark orange background and a star before the model name
|
|
67
68
|
- **📊 Privacy-first analytics (optional)** — anonymous PostHog events with explicit consent + opt-out
|
|
68
69
|
|
|
69
70
|
---
|
|
@@ -87,10 +88,14 @@ Before using `free-coding-models`, make sure you have:
|
|
|
87
88
|
- **Hyperbolic** — [app.hyperbolic.ai/settings](https://app.hyperbolic.ai/settings) → API Keys ($1 free trial)
|
|
88
89
|
- **Scaleway** — [console.scaleway.com/iam/api-keys](https://console.scaleway.com/iam/api-keys) → IAM → API Keys (1M free tokens)
|
|
89
90
|
- **Google AI Studio** — [aistudio.google.com/apikey](https://aistudio.google.com/apikey) → Get API key (free Gemma models, 14.4K req/day)
|
|
91
|
+
- **SiliconFlow** — [cloud.siliconflow.cn/account/ak](https://cloud.siliconflow.cn/account/ak) → API Keys (free-model quotas vary by model)
|
|
92
|
+
- **Together AI** — [api.together.ai/settings/api-keys](https://api.together.ai/settings/api-keys) → API Keys (credits/promotions vary)
|
|
93
|
+
- **Cloudflare Workers AI** — [dash.cloudflare.com](https://dash.cloudflare.com) → Create API token + set `CLOUDFLARE_ACCOUNT_ID` (Free: 10k neurons/day)
|
|
94
|
+
- **Perplexity API** — [perplexity.ai/settings/api](https://www.perplexity.ai/settings/api) → API Key (tiered limits by spend)
|
|
90
95
|
3. **OpenCode** *(optional)* — [Install OpenCode](https://github.com/opencode-ai/opencode) to use the OpenCode integration
|
|
91
96
|
4. **OpenClaw** *(optional)* — [Install OpenClaw](https://openclaw.ai) to use the OpenClaw integration
|
|
92
97
|
|
|
93
|
-
> 💡 **Tip:** You don't need all
|
|
98
|
+
> 💡 **Tip:** You don't need all seventeen providers. One key is enough to get started. Add more later via the Settings screen (`P` key). Models without a key still show real latency (`🔑 NO KEY`) so you can evaluate providers before signing up.
|
|
94
99
|
|
|
95
100
|
---
|
|
96
101
|
|
|
@@ -171,13 +176,13 @@ When you run `free-coding-models` without `--opencode` or `--openclaw`, you get
|
|
|
171
176
|
Use `↑↓` arrows to select, `Enter` to confirm. Then the TUI launches with your chosen mode shown in the header badge.
|
|
172
177
|
|
|
173
178
|
**How it works:**
|
|
174
|
-
1. **Ping phase** — All enabled models are pinged in parallel (up to
|
|
179
|
+
1. **Ping phase** — All enabled models are pinged in parallel (up to 134 across 17 providers)
|
|
175
180
|
2. **Continuous monitoring** — Models are re-pinged every 2 seconds forever
|
|
176
181
|
3. **Real-time updates** — Watch "Latest", "Avg", and "Up%" columns update live
|
|
177
182
|
4. **Select anytime** — Use ↑↓ arrows to navigate, press Enter on a model to act
|
|
178
183
|
5. **Smart detection** — Automatically detects if NVIDIA NIM is configured in OpenCode or OpenClaw
|
|
179
184
|
|
|
180
|
-
Setup wizard (first run — walks through all
|
|
185
|
+
Setup wizard (first run — walks through all 17 providers):
|
|
181
186
|
|
|
182
187
|
```
|
|
183
188
|
🔑 First-time setup — API keys
|
|
@@ -207,7 +212,7 @@ Setup wizard (first run — walks through all 13 providers):
|
|
|
207
212
|
You can add or change keys anytime with the P key in the TUI.
|
|
208
213
|
```
|
|
209
214
|
|
|
210
|
-
You don't need all
|
|
215
|
+
You don't need all seventeen — skip any provider by pressing Enter. At least one key is required.
|
|
211
216
|
|
|
212
217
|
### Adding or changing keys later
|
|
213
218
|
|
|
@@ -227,18 +232,21 @@ Press **`P`** to open the Settings screen at any time:
|
|
|
227
232
|
2) Profile → API Keys → Generate
|
|
228
233
|
3) Press T to test your key
|
|
229
234
|
|
|
230
|
-
↑↓ Navigate • Enter Edit key • Space Toggle enabled • T Test key • Esc Close
|
|
235
|
+
↑↓ Navigate • Enter Edit key / Check-or-Install update • Space Toggle enabled • T Test key • U Check updates • Esc Close
|
|
231
236
|
```
|
|
232
237
|
|
|
233
238
|
- **↑↓** — navigate providers
|
|
234
239
|
- **Enter** — enter inline key edit mode (type your key, Enter to save, Esc to cancel)
|
|
235
240
|
- **Space** — toggle provider enabled/disabled
|
|
236
241
|
- **T** — fire a real test ping to verify the key works (shows ✅/❌)
|
|
242
|
+
- **U** — manually check npm for a newer version
|
|
237
243
|
- **Esc** — close settings and reload models list
|
|
238
244
|
|
|
239
245
|
Keys are saved to `~/.free-coding-models.json` (permissions `0600`).
|
|
240
246
|
|
|
241
247
|
Analytics toggle is in the same Settings screen (`P`) as a dedicated row (toggle with Enter or Space).
|
|
248
|
+
Manual update is in the same Settings screen (`P`) under **Maintenance** (Enter to check, Enter again to install when an update is available).
|
|
249
|
+
Favorites are also persisted in the same config file and survive restarts.
|
|
242
250
|
|
|
243
251
|
### Environment variable overrides
|
|
244
252
|
|
|
@@ -253,6 +261,10 @@ HUGGINGFACE_API_KEY=hf_xxx free-coding-models
|
|
|
253
261
|
REPLICATE_API_TOKEN=r8_xxx free-coding-models
|
|
254
262
|
DEEPINFRA_API_KEY=di_xxx free-coding-models
|
|
255
263
|
FIREWORKS_API_KEY=fw_xxx free-coding-models
|
|
264
|
+
SILICONFLOW_API_KEY=sk_xxx free-coding-models
|
|
265
|
+
TOGETHER_API_KEY=together_xxx free-coding-models
|
|
266
|
+
CLOUDFLARE_API_TOKEN=cf_xxx CLOUDFLARE_ACCOUNT_ID=your_account_id free-coding-models
|
|
267
|
+
PERPLEXITY_API_KEY=pplx_xxx free-coding-models
|
|
256
268
|
FREE_CODING_MODELS_TELEMETRY=0 free-coding-models
|
|
257
269
|
```
|
|
258
270
|
|
|
@@ -302,13 +314,46 @@ When enabled, telemetry events include: event name, app version, selected mode,
|
|
|
302
314
|
1. Sign up at [fireworks.ai](https://fireworks.ai)
|
|
303
315
|
2. Open Settings → Access Tokens and create a token
|
|
304
316
|
|
|
317
|
+
**Mistral Codestral**:
|
|
318
|
+
1. Sign up at [codestral.mistral.ai](https://codestral.mistral.ai)
|
|
319
|
+
2. Go to API Keys → Create
|
|
320
|
+
|
|
321
|
+
**Hyperbolic**:
|
|
322
|
+
1. Sign up at [app.hyperbolic.ai/settings](https://app.hyperbolic.ai/settings)
|
|
323
|
+
2. Create an API key in Settings
|
|
324
|
+
|
|
325
|
+
**Scaleway**:
|
|
326
|
+
1. Sign up at [console.scaleway.com/iam/api-keys](https://console.scaleway.com/iam/api-keys)
|
|
327
|
+
2. Go to IAM → API Keys
|
|
328
|
+
|
|
329
|
+
**Google AI Studio**:
|
|
330
|
+
1. Sign up at [aistudio.google.com/apikey](https://aistudio.google.com/apikey)
|
|
331
|
+
2. Create an API key for Gemini/Gemma endpoints
|
|
332
|
+
|
|
333
|
+
**SiliconFlow**:
|
|
334
|
+
1. Sign up at [cloud.siliconflow.cn/account/ak](https://cloud.siliconflow.cn/account/ak)
|
|
335
|
+
2. Create API key in Account → API Keys
|
|
336
|
+
|
|
337
|
+
**Together AI**:
|
|
338
|
+
1. Sign up at [api.together.ai/settings/api-keys](https://api.together.ai/settings/api-keys)
|
|
339
|
+
2. Create an API key in Settings
|
|
340
|
+
|
|
341
|
+
**Cloudflare Workers AI**:
|
|
342
|
+
1. Sign up at [dash.cloudflare.com](https://dash.cloudflare.com)
|
|
343
|
+
2. Create an API token with Workers AI permissions
|
|
344
|
+
3. Export both `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`
|
|
345
|
+
|
|
346
|
+
**Perplexity API**:
|
|
347
|
+
1. Sign up at [perplexity.ai/settings/api](https://www.perplexity.ai/settings/api)
|
|
348
|
+
2. Create API key (`PERPLEXITY_API_KEY`)
|
|
349
|
+
|
|
305
350
|
> 💡 **Free tiers** — each provider exposes a dev/free tier with its own quotas.
|
|
306
351
|
|
|
307
352
|
---
|
|
308
353
|
|
|
309
354
|
## 🤖 Coding Models
|
|
310
355
|
|
|
311
|
-
**
|
|
356
|
+
**134 coding models** across 17 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.
|
|
312
357
|
|
|
313
358
|
### NVIDIA NIM (44 models)
|
|
314
359
|
|
|
@@ -323,7 +368,7 @@ When enabled, telemetry events include: event name, app version, selected mode,
|
|
|
323
368
|
| **B** 20–30% | R1 Distill 8B (28.2%), R1 Distill 7B (22.6%) |
|
|
324
369
|
| **C** <20% | Gemma 2 9B (18.0%), Phi 4 Mini (14.0%), Phi 3.5 Mini (12.0%) |
|
|
325
370
|
|
|
326
|
-
### Groq (
|
|
371
|
+
### Groq (10 models)
|
|
327
372
|
|
|
328
373
|
| Tier | SWE-bench | Model |
|
|
329
374
|
|------|-----------|-------|
|
|
@@ -332,7 +377,7 @@ When enabled, telemetry events include: event name, app version, selected mode,
|
|
|
332
377
|
| **A** 40–50% | Llama 4 Scout (44.0%), R1 Distill 70B (43.9%) |
|
|
333
378
|
| **A-** 35–40% | Llama 3.3 70B (39.5%) |
|
|
334
379
|
|
|
335
|
-
### Cerebras (
|
|
380
|
+
### Cerebras (7 models)
|
|
336
381
|
|
|
337
382
|
| Tier | SWE-bench | Model |
|
|
338
383
|
|------|-----------|-------|
|
|
@@ -578,6 +623,11 @@ This script:
|
|
|
578
623
|
| `HYPERBOLIC_API_KEY` | Hyperbolic key |
|
|
579
624
|
| `SCALEWAY_API_KEY` | Scaleway key |
|
|
580
625
|
| `GOOGLE_API_KEY` | Google AI Studio key |
|
|
626
|
+
| `SILICONFLOW_API_KEY` | SiliconFlow key |
|
|
627
|
+
| `TOGETHER_API_KEY` | Together AI key |
|
|
628
|
+
| `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_API_KEY` | Cloudflare Workers AI token/key |
|
|
629
|
+
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID (required for Workers AI endpoint URL) |
|
|
630
|
+
| `PERPLEXITY_API_KEY` / `PPLX_API_KEY` | Perplexity API key |
|
|
581
631
|
| `FREE_CODING_MODELS_TELEMETRY` | `0` disables analytics, `1` enables analytics |
|
|
582
632
|
| `FREE_CODING_MODELS_POSTHOG_KEY` | PostHog project API key used for anonymous event capture |
|
|
583
633
|
| `FREE_CODING_MODELS_POSTHOG_HOST` | Optional PostHog ingest host (`https://eu.i.posthog.com` default) |
|
|
@@ -593,7 +643,11 @@ This script:
|
|
|
593
643
|
"openrouter": "sk-or-xxx",
|
|
594
644
|
"huggingface": "hf_xxx",
|
|
595
645
|
"replicate": "r8_xxx",
|
|
596
|
-
"deepinfra": "di_xxx"
|
|
646
|
+
"deepinfra": "di_xxx",
|
|
647
|
+
"siliconflow": "sk_xxx",
|
|
648
|
+
"together": "together_xxx",
|
|
649
|
+
"cloudflare": "cf_xxx",
|
|
650
|
+
"perplexity": "pplx_xxx"
|
|
597
651
|
},
|
|
598
652
|
"providers": {
|
|
599
653
|
"nvidia": { "enabled": true },
|
|
@@ -602,8 +656,15 @@ This script:
|
|
|
602
656
|
"openrouter": { "enabled": true },
|
|
603
657
|
"huggingface": { "enabled": true },
|
|
604
658
|
"replicate": { "enabled": true },
|
|
605
|
-
"deepinfra": { "enabled": true }
|
|
659
|
+
"deepinfra": { "enabled": true },
|
|
660
|
+
"siliconflow": { "enabled": true },
|
|
661
|
+
"together": { "enabled": true },
|
|
662
|
+
"cloudflare": { "enabled": true },
|
|
663
|
+
"perplexity": { "enabled": true }
|
|
606
664
|
},
|
|
665
|
+
"favorites": [
|
|
666
|
+
"nvidia/deepseek-ai/deepseek-v3.2"
|
|
667
|
+
],
|
|
607
668
|
"telemetry": {
|
|
608
669
|
"enabled": true,
|
|
609
670
|
"consentVersion": 1,
|
|
@@ -637,18 +698,23 @@ This script:
|
|
|
637
698
|
- **↑↓** — Navigate models
|
|
638
699
|
- **Enter** — Select model (launches OpenCode or sets OpenClaw default, depending on mode)
|
|
639
700
|
- **R/Y/O/M/L/A/S/N/H/V/U** — Sort by Rank/Tier/Origin/Model/LatestPing/Avg/SWE/Ctx/Health/Verdict/Uptime
|
|
701
|
+
- **F** — Toggle favorite on selected model (⭐ in Model column, pinned at top)
|
|
640
702
|
- **T** — Cycle tier filter (All → S+ → S → A+ → A → A- → B+ → B → C → All)
|
|
641
703
|
- **Z** — Cycle mode (OpenCode CLI → OpenCode Desktop → OpenClaw)
|
|
642
|
-
- **P** — Open Settings (manage API keys, provider toggles, analytics toggle)
|
|
704
|
+
- **P** — Open Settings (manage API keys, provider toggles, analytics toggle, manual update)
|
|
643
705
|
- **W** — Decrease ping interval (faster pings)
|
|
644
706
|
- **X** — Increase ping interval (slower pings)
|
|
707
|
+
- **K** / **Esc** — Show/hide help overlay
|
|
645
708
|
- **Ctrl+C** — Exit
|
|
646
709
|
|
|
710
|
+
Pressing **K** now shows a full in-app reference: main hotkeys, settings hotkeys, and CLI flags with usage examples.
|
|
711
|
+
|
|
647
712
|
**Keyboard shortcuts (Settings screen — `P` key):**
|
|
648
|
-
- **↑↓** — Navigate providers and
|
|
649
|
-
- **Enter** — Edit API key inline,
|
|
713
|
+
- **↑↓** — Navigate providers, analytics row, and maintenance row
|
|
714
|
+
- **Enter** — Edit API key inline, toggle analytics on analytics row, or check/install update on maintenance row
|
|
650
715
|
- **Space** — Toggle provider enabled/disabled, or toggle analytics on analytics row
|
|
651
716
|
- **T** — Test current provider's API key (fires a live ping)
|
|
717
|
+
- **U** — Check for updates manually from settings
|
|
652
718
|
- **Esc** — Close settings and return to main TUI
|
|
653
719
|
|
|
654
720
|
---
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
* - Automatic config detection and model setup for both tools
|
|
21
21
|
* - JSON config stored in ~/.free-coding-models.json (auto-migrates from old plain-text)
|
|
22
22
|
* - Multi-provider support via sources.js (NIM/Groq/Cerebras/OpenRouter/Hugging Face/Replicate/DeepInfra/... — extensible)
|
|
23
|
-
* - Settings screen (P key) to manage API keys
|
|
23
|
+
* - Settings screen (P key) to manage API keys, provider toggles, analytics, and manual updates
|
|
24
|
+
* - Favorites system: toggle with F, pin rows to top, persist between sessions
|
|
24
25
|
* - Uptime percentage tracking (successful pings / total pings)
|
|
25
26
|
* - Sortable columns (R/Y/O/M/L/A/S/N/H/V/U keys)
|
|
26
27
|
* - Tier filtering via T key (cycles S+→S→A+→A→A-→B+→B→C→All)
|
|
@@ -32,6 +33,7 @@
|
|
|
32
33
|
* - `getTelemetryTerminal`: Infer terminal family (Terminal.app, iTerm2, kitty, etc.)
|
|
33
34
|
* - `isTelemetryDebugEnabled` / `telemetryDebug`: Optional runtime telemetry diagnostics via env
|
|
34
35
|
* - `sendUsageTelemetry`: Fire-and-forget anonymous app-start event
|
|
36
|
+
* - `ensureFavoritesConfig` / `toggleFavoriteModel`: Persist and toggle pinned favorites
|
|
35
37
|
* - `promptApiKey`: Interactive wizard for first-time multi-provider API key setup
|
|
36
38
|
* - `promptModeSelection`: Startup menu to choose OpenCode vs OpenClaw
|
|
37
39
|
* - `buildPingRequest` / `ping`: Build provider-specific probe requests and measure latency
|
|
@@ -58,7 +60,8 @@
|
|
|
58
60
|
* ⚙️ Configuration:
|
|
59
61
|
* - API keys stored per-provider in ~/.free-coding-models.json (0600 perms)
|
|
60
62
|
* - Old ~/.free-coding-models plain-text auto-migrated as nvidia key on first run
|
|
61
|
-
* - Env vars override config: NVIDIA_API_KEY, GROQ_API_KEY, CEREBRAS_API_KEY, OPENROUTER_API_KEY, HUGGINGFACE_API_KEY/HF_TOKEN, REPLICATE_API_TOKEN, DEEPINFRA_API_KEY/DEEPINFRA_TOKEN, FIREWORKS_API_KEY, etc.
|
|
63
|
+
* - Env vars override config: NVIDIA_API_KEY, GROQ_API_KEY, CEREBRAS_API_KEY, OPENROUTER_API_KEY, HUGGINGFACE_API_KEY/HF_TOKEN, REPLICATE_API_TOKEN, DEEPINFRA_API_KEY/DEEPINFRA_TOKEN, FIREWORKS_API_KEY, SILICONFLOW_API_KEY, TOGETHER_API_KEY, PERPLEXITY_API_KEY, etc.
|
|
64
|
+
* - Cloudflare Workers AI requires both CLOUDFLARE_API_TOKEN (or CLOUDFLARE_API_KEY) and CLOUDFLARE_ACCOUNT_ID
|
|
62
65
|
* - Models loaded from sources.js — all provider/model definitions are centralized there
|
|
63
66
|
* - OpenCode config: ~/.config/opencode/opencode.json
|
|
64
67
|
* - OpenClaw config: ~/.openclaw/openclaw.json
|
|
@@ -159,6 +162,53 @@ function ensureTelemetryConfig(config) {
|
|
|
159
162
|
}
|
|
160
163
|
}
|
|
161
164
|
|
|
165
|
+
// 📖 Ensure favorites config shape exists and remains clean.
|
|
166
|
+
// 📖 Stored format: ["providerKey/modelId", ...] in insertion order.
|
|
167
|
+
function ensureFavoritesConfig(config) {
|
|
168
|
+
if (!Array.isArray(config.favorites)) config.favorites = []
|
|
169
|
+
const seen = new Set()
|
|
170
|
+
config.favorites = config.favorites.filter((entry) => {
|
|
171
|
+
if (typeof entry !== 'string' || entry.trim().length === 0) return false
|
|
172
|
+
if (seen.has(entry)) return false
|
|
173
|
+
seen.add(entry)
|
|
174
|
+
return true
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 📖 Build deterministic key used to persist one favorite model row.
|
|
179
|
+
function toFavoriteKey(providerKey, modelId) {
|
|
180
|
+
return `${providerKey}/${modelId}`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 📖 Sync per-row favorite metadata from config (used by renderer and sorter).
|
|
184
|
+
function syncFavoriteFlags(results, config) {
|
|
185
|
+
ensureFavoritesConfig(config)
|
|
186
|
+
const favoriteRankMap = new Map(config.favorites.map((entry, index) => [entry, index]))
|
|
187
|
+
for (const row of results) {
|
|
188
|
+
const favoriteKey = toFavoriteKey(row.providerKey, row.modelId)
|
|
189
|
+
const rank = favoriteRankMap.get(favoriteKey)
|
|
190
|
+
row.favoriteKey = favoriteKey
|
|
191
|
+
row.isFavorite = rank !== undefined
|
|
192
|
+
row.favoriteRank = rank !== undefined ? rank : Number.MAX_SAFE_INTEGER
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 📖 Toggle favorite state and persist immediately.
|
|
197
|
+
// 📖 Returns true when row is now favorite, false when removed.
|
|
198
|
+
function toggleFavoriteModel(config, providerKey, modelId) {
|
|
199
|
+
ensureFavoritesConfig(config)
|
|
200
|
+
const favoriteKey = toFavoriteKey(providerKey, modelId)
|
|
201
|
+
const existingIndex = config.favorites.indexOf(favoriteKey)
|
|
202
|
+
if (existingIndex >= 0) {
|
|
203
|
+
config.favorites.splice(existingIndex, 1)
|
|
204
|
+
saveConfig(config)
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
207
|
+
config.favorites.push(favoriteKey)
|
|
208
|
+
saveConfig(config)
|
|
209
|
+
return true
|
|
210
|
+
}
|
|
211
|
+
|
|
162
212
|
// 📖 Create or reuse a persistent anonymous distinct_id for PostHog.
|
|
163
213
|
// 📖 Stored locally in config so one user is stable over time without personal data.
|
|
164
214
|
function getTelemetryDistinctId(config) {
|
|
@@ -416,14 +466,25 @@ async function sendUsageTelemetry(config, cliArgs, payload) {
|
|
|
416
466
|
}
|
|
417
467
|
}
|
|
418
468
|
|
|
419
|
-
|
|
469
|
+
// 📖 checkForUpdateDetailed: Fetch npm latest version with explicit error details.
|
|
470
|
+
// 📖 Used by settings manual-check flow to display meaningful status in the UI.
|
|
471
|
+
async function checkForUpdateDetailed() {
|
|
420
472
|
try {
|
|
421
473
|
const res = await fetch('https://registry.npmjs.org/free-coding-models/latest', { signal: AbortSignal.timeout(5000) })
|
|
422
|
-
if (!res.ok) return null
|
|
474
|
+
if (!res.ok) return { latestVersion: null, error: `HTTP ${res.status}` }
|
|
423
475
|
const data = await res.json()
|
|
424
|
-
if (data.version && data.version !== LOCAL_VERSION) return data.version
|
|
425
|
-
|
|
426
|
-
|
|
476
|
+
if (data.version && data.version !== LOCAL_VERSION) return { latestVersion: data.version, error: null }
|
|
477
|
+
return { latestVersion: null, error: null }
|
|
478
|
+
} catch (error) {
|
|
479
|
+
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
480
|
+
return { latestVersion: null, error: message }
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// 📖 checkForUpdate: Backward-compatible wrapper for startup update prompt.
|
|
485
|
+
async function checkForUpdate() {
|
|
486
|
+
const { latestVersion } = await checkForUpdateDetailed()
|
|
487
|
+
return latestVersion
|
|
427
488
|
}
|
|
428
489
|
|
|
429
490
|
function runUpdate(latestVersion) {
|
|
@@ -695,6 +756,52 @@ const FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏']
|
|
|
695
756
|
// 📖 Spinner cell: braille (1-wide) + padding to fill CELL_W visual chars
|
|
696
757
|
const spinCell = (f, o = 0) => chalk.dim.yellow(FRAMES[(f + o) % FRAMES.length].padEnd(CELL_W))
|
|
697
758
|
|
|
759
|
+
// 📖 Overlay-specific backgrounds so Settings (P) and Help (K) are visually distinct
|
|
760
|
+
// 📖 from the main table and from each other.
|
|
761
|
+
const SETTINGS_OVERLAY_BG = chalk.bgRgb(14, 20, 30)
|
|
762
|
+
const HELP_OVERLAY_BG = chalk.bgRgb(24, 16, 32)
|
|
763
|
+
const OVERLAY_PANEL_WIDTH = 116
|
|
764
|
+
|
|
765
|
+
// 📖 Strip ANSI color/control sequences to estimate visible text width before padding.
|
|
766
|
+
function stripAnsi(input) {
|
|
767
|
+
return String(input).replace(/\x1b\[[0-9;]*m/g, '').replace(/\x1b\][^\x1b]*\x1b\\/g, '')
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// 📖 Tint overlay lines with a fixed dark panel width so the background is clearly visible.
|
|
771
|
+
function tintOverlayLines(lines, bgColor) {
|
|
772
|
+
return lines.map((line) => {
|
|
773
|
+
const text = String(line)
|
|
774
|
+
const visibleWidth = stripAnsi(text).length
|
|
775
|
+
const padding = ' '.repeat(Math.max(0, OVERLAY_PANEL_WIDTH - visibleWidth))
|
|
776
|
+
return bgColor(text + padding)
|
|
777
|
+
})
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// 📖 Clamp overlay scroll to valid bounds for the current terminal height.
|
|
781
|
+
function clampOverlayOffset(offset, totalLines, terminalRows) {
|
|
782
|
+
const viewportRows = Math.max(1, terminalRows || 1)
|
|
783
|
+
const maxOffset = Math.max(0, totalLines - viewportRows)
|
|
784
|
+
return Math.max(0, Math.min(maxOffset, offset))
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// 📖 Ensure a target line is visible inside overlay viewport (used by Settings cursor).
|
|
788
|
+
function keepOverlayTargetVisible(offset, targetLine, totalLines, terminalRows) {
|
|
789
|
+
const viewportRows = Math.max(1, terminalRows || 1)
|
|
790
|
+
let next = clampOverlayOffset(offset, totalLines, terminalRows)
|
|
791
|
+
if (targetLine < next) next = targetLine
|
|
792
|
+
else if (targetLine >= next + viewportRows) next = targetLine - viewportRows + 1
|
|
793
|
+
return clampOverlayOffset(next, totalLines, terminalRows)
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// 📖 Slice overlay lines to terminal viewport and pad with blanks to avoid stale frames.
|
|
797
|
+
function sliceOverlayLines(lines, offset, terminalRows) {
|
|
798
|
+
const viewportRows = Math.max(1, terminalRows || 1)
|
|
799
|
+
const nextOffset = clampOverlayOffset(offset, lines.length, terminalRows)
|
|
800
|
+
const visible = lines.slice(nextOffset, nextOffset + viewportRows)
|
|
801
|
+
while (visible.length < viewportRows) visible.push('')
|
|
802
|
+
return { visible, offset: nextOffset }
|
|
803
|
+
}
|
|
804
|
+
|
|
698
805
|
// ─── Table renderer ───────────────────────────────────────────────────────────
|
|
699
806
|
|
|
700
807
|
// 📖 Core logic functions (getAvg, getVerdict, getUptime, sortResults, etc.)
|
|
@@ -723,6 +830,16 @@ function calculateViewport(terminalRows, scrollOffset, totalModels) {
|
|
|
723
830
|
return { startIdx: scrollOffset, endIdx, hasAbove, hasBelow }
|
|
724
831
|
}
|
|
725
832
|
|
|
833
|
+
// 📖 Favorites are always pinned at the top and keep insertion order.
|
|
834
|
+
// 📖 Non-favorites still use the active sort column/direction.
|
|
835
|
+
function sortResultsWithPinnedFavorites(results, sortColumn, sortDirection) {
|
|
836
|
+
const favoriteRows = results
|
|
837
|
+
.filter((r) => r.isFavorite)
|
|
838
|
+
.sort((a, b) => a.favoriteRank - b.favoriteRank)
|
|
839
|
+
const nonFavoriteRows = sortResults(results.filter((r) => !r.isFavorite), sortColumn, sortDirection)
|
|
840
|
+
return [...favoriteRows, ...nonFavoriteRows]
|
|
841
|
+
}
|
|
842
|
+
|
|
726
843
|
// 📖 renderTable: mode param controls footer hint text (opencode vs openclaw)
|
|
727
844
|
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) {
|
|
728
845
|
// 📖 Filter out hidden models for display
|
|
@@ -790,7 +907,7 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
|
|
|
790
907
|
const W_UPTIME = 6
|
|
791
908
|
|
|
792
909
|
// 📖 Sort models using the shared helper
|
|
793
|
-
const sorted =
|
|
910
|
+
const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection)
|
|
794
911
|
|
|
795
912
|
const lines = [
|
|
796
913
|
` ${chalk.bold('⚡ Free Coding Models')} ${chalk.dim('v' + LOCAL_VERSION)}${modeBadge}${modeHint}${tierBadge}${originBadge} ` +
|
|
@@ -882,7 +999,10 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
|
|
|
882
999
|
// 📖 Show provider name from sources map (NIM / Groq / Cerebras)
|
|
883
1000
|
const providerName = sources[r.providerKey]?.name ?? r.providerKey ?? 'NIM'
|
|
884
1001
|
const source = chalk.green(providerName.padEnd(W_SOURCE))
|
|
885
|
-
|
|
1002
|
+
// 📖 Favorites get a leading star in Model column.
|
|
1003
|
+
const favoritePrefix = r.isFavorite ? '⭐ ' : ''
|
|
1004
|
+
const nameWidth = Math.max(0, W_MODEL - favoritePrefix.length)
|
|
1005
|
+
const name = favoritePrefix + r.label.slice(0, nameWidth).padEnd(nameWidth)
|
|
886
1006
|
const sweScore = r.sweScore ?? '—'
|
|
887
1007
|
const sweCell = sweScore !== '—' && parseFloat(sweScore) >= 50
|
|
888
1008
|
? chalk.greenBright(sweScore.padEnd(W_SWE))
|
|
@@ -1012,8 +1132,12 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
|
|
|
1012
1132
|
// 📖 Build row with double space between columns (order: Rank, Tier, SWE%, CTX, Model, Origin, Latest Ping, Avg Ping, Health, Verdict, Up%)
|
|
1013
1133
|
const row = ' ' + num + ' ' + tier + ' ' + sweCell + ' ' + ctxCell + ' ' + name + ' ' + source + ' ' + pingCell + ' ' + avgCell + ' ' + status + ' ' + speedCell + ' ' + uptimeCell
|
|
1014
1134
|
|
|
1015
|
-
if (isCursor) {
|
|
1135
|
+
if (isCursor && r.isFavorite) {
|
|
1136
|
+
lines.push(chalk.bgRgb(120, 60, 0)(row))
|
|
1137
|
+
} else if (isCursor) {
|
|
1016
1138
|
lines.push(chalk.bgRgb(139, 0, 139)(row))
|
|
1139
|
+
} else if (r.isFavorite) {
|
|
1140
|
+
lines.push(chalk.bgRgb(90, 45, 0)(row))
|
|
1017
1141
|
} else {
|
|
1018
1142
|
lines.push(row)
|
|
1019
1143
|
}
|
|
@@ -1032,7 +1156,7 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
|
|
|
1032
1156
|
: mode === 'opencode-desktop'
|
|
1033
1157
|
? chalk.rgb(0, 200, 255)('Enter→OpenDesktop')
|
|
1034
1158
|
: chalk.rgb(0, 200, 255)('Enter→OpenCode')
|
|
1035
|
-
lines.push(chalk.dim(` ↑↓ Navigate • `) + actionHint + chalk.dim(` • R/Y/O/M/L/A/S/C/H/V/U Sort • T Tier • N Origin • W↓/X↑ (${intervalSec}s) • Z Mode • `) + chalk.yellow('P') + chalk.dim(` Settings • `) + chalk.bgGreenBright.black.bold(' K Help ') + chalk.dim(` • Ctrl+C Exit`))
|
|
1159
|
+
lines.push(chalk.dim(` ↑↓ Navigate • `) + actionHint + chalk.dim(` • F Favorite • R/Y/O/M/L/A/S/C/H/V/U Sort • T Tier • N Origin • W↓/X↑ (${intervalSec}s) • Z Mode • `) + chalk.yellow('P') + chalk.dim(` Settings • `) + chalk.bgGreenBright.black.bold(' K Help ') + chalk.dim(` • Ctrl+C Exit`))
|
|
1036
1160
|
lines.push('')
|
|
1037
1161
|
lines.push(
|
|
1038
1162
|
chalk.rgb(255, 150, 200)(' Made with 💖 & ☕ by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
|
|
@@ -1062,6 +1186,15 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
|
|
|
1062
1186
|
// 📖 providerKey and url determine provider-specific request format.
|
|
1063
1187
|
// 📖 apiKey can be null — in that case no Authorization header is sent.
|
|
1064
1188
|
// 📖 A 401 response still tells us the server is UP and gives us real latency.
|
|
1189
|
+
function resolveCloudflareUrl(url) {
|
|
1190
|
+
// 📖 Cloudflare's OpenAI-compatible endpoint is account-scoped.
|
|
1191
|
+
// 📖 We resolve {account_id} from env so provider setup can stay simple in config.
|
|
1192
|
+
const accountId = (process.env.CLOUDFLARE_ACCOUNT_ID || '').trim()
|
|
1193
|
+
if (!url.includes('{account_id}')) return url
|
|
1194
|
+
if (!accountId) return url.replace('{account_id}', 'missing-account-id')
|
|
1195
|
+
return url.replace('{account_id}', encodeURIComponent(accountId))
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1065
1198
|
function buildPingRequest(apiKey, modelId, providerKey, url) {
|
|
1066
1199
|
if (providerKey === 'replicate') {
|
|
1067
1200
|
// 📖 Replicate uses /v1/predictions with a different payload than OpenAI chat-completions.
|
|
@@ -1074,6 +1207,17 @@ function buildPingRequest(apiKey, modelId, providerKey, url) {
|
|
|
1074
1207
|
}
|
|
1075
1208
|
}
|
|
1076
1209
|
|
|
1210
|
+
if (providerKey === 'cloudflare') {
|
|
1211
|
+
// 📖 Cloudflare Workers AI uses OpenAI-compatible payload but needs account_id in URL.
|
|
1212
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
1213
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`
|
|
1214
|
+
return {
|
|
1215
|
+
url: resolveCloudflareUrl(url),
|
|
1216
|
+
headers,
|
|
1217
|
+
body: { model: modelId, messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 },
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1077
1221
|
const headers = { 'Content-Type': 'application/json' }
|
|
1078
1222
|
if (apiKey) headers.Authorization = `Bearer ${apiKey}`
|
|
1079
1223
|
if (providerKey === 'openrouter') {
|
|
@@ -1151,6 +1295,10 @@ const ENV_VAR_NAMES = {
|
|
|
1151
1295
|
hyperbolic: 'HYPERBOLIC_API_KEY',
|
|
1152
1296
|
scaleway: 'SCALEWAY_API_KEY',
|
|
1153
1297
|
googleai: 'GOOGLE_API_KEY',
|
|
1298
|
+
siliconflow:'SILICONFLOW_API_KEY',
|
|
1299
|
+
together: 'TOGETHER_API_KEY',
|
|
1300
|
+
cloudflare: 'CLOUDFLARE_API_TOKEN',
|
|
1301
|
+
perplexity: 'PERPLEXITY_API_KEY',
|
|
1154
1302
|
}
|
|
1155
1303
|
|
|
1156
1304
|
// 📖 Provider metadata used by the setup wizard and Settings details panel.
|
|
@@ -1247,6 +1395,34 @@ const PROVIDER_METADATA = {
|
|
|
1247
1395
|
signupHint: 'Get API key',
|
|
1248
1396
|
rateLimits: '14.4K req/day, 30/min',
|
|
1249
1397
|
},
|
|
1398
|
+
siliconflow: {
|
|
1399
|
+
label: 'SiliconFlow',
|
|
1400
|
+
color: chalk.rgb(255, 120, 30),
|
|
1401
|
+
signupUrl: 'https://cloud.siliconflow.cn/account/ak',
|
|
1402
|
+
signupHint: 'API Keys → Create',
|
|
1403
|
+
rateLimits: 'Free models: usually 100 RPM, varies by model',
|
|
1404
|
+
},
|
|
1405
|
+
together: {
|
|
1406
|
+
label: 'Together AI',
|
|
1407
|
+
color: chalk.rgb(0, 180, 255),
|
|
1408
|
+
signupUrl: 'https://api.together.ai/settings/api-keys',
|
|
1409
|
+
signupHint: 'Settings → API keys',
|
|
1410
|
+
rateLimits: 'Credits/promos vary by account (check console)',
|
|
1411
|
+
},
|
|
1412
|
+
cloudflare: {
|
|
1413
|
+
label: 'Cloudflare Workers AI',
|
|
1414
|
+
color: chalk.rgb(242, 119, 36),
|
|
1415
|
+
signupUrl: 'https://dash.cloudflare.com',
|
|
1416
|
+
signupHint: 'Create AI API token + set CLOUDFLARE_ACCOUNT_ID',
|
|
1417
|
+
rateLimits: 'Free: 10k neurons/day, text-gen 300 RPM',
|
|
1418
|
+
},
|
|
1419
|
+
perplexity: {
|
|
1420
|
+
label: 'Perplexity API',
|
|
1421
|
+
color: chalk.rgb(0, 210, 190),
|
|
1422
|
+
signupUrl: 'https://www.perplexity.ai/settings/api',
|
|
1423
|
+
signupHint: 'Generate API key (billing may be required)',
|
|
1424
|
+
rateLimits: 'Tiered limits by spend (default ~50 RPM)',
|
|
1425
|
+
},
|
|
1250
1426
|
}
|
|
1251
1427
|
|
|
1252
1428
|
// 📖 OpenCode config location varies by platform
|
|
@@ -1615,6 +1791,53 @@ After installation, you can use: opencode --model ${modelRef}`
|
|
|
1615
1791
|
},
|
|
1616
1792
|
models: {}
|
|
1617
1793
|
}
|
|
1794
|
+
} else if (providerKey === 'siliconflow') {
|
|
1795
|
+
config.provider.siliconflow = {
|
|
1796
|
+
npm: '@ai-sdk/openai-compatible',
|
|
1797
|
+
name: 'SiliconFlow',
|
|
1798
|
+
options: {
|
|
1799
|
+
baseURL: 'https://api.siliconflow.com/v1',
|
|
1800
|
+
apiKey: '{env:SILICONFLOW_API_KEY}'
|
|
1801
|
+
},
|
|
1802
|
+
models: {}
|
|
1803
|
+
}
|
|
1804
|
+
} else if (providerKey === 'together') {
|
|
1805
|
+
config.provider.together = {
|
|
1806
|
+
npm: '@ai-sdk/openai-compatible',
|
|
1807
|
+
name: 'Together AI',
|
|
1808
|
+
options: {
|
|
1809
|
+
baseURL: 'https://api.together.xyz/v1',
|
|
1810
|
+
apiKey: '{env:TOGETHER_API_KEY}'
|
|
1811
|
+
},
|
|
1812
|
+
models: {}
|
|
1813
|
+
}
|
|
1814
|
+
} else if (providerKey === 'cloudflare') {
|
|
1815
|
+
const cloudflareAccountId = (process.env.CLOUDFLARE_ACCOUNT_ID || '').trim()
|
|
1816
|
+
if (!cloudflareAccountId) {
|
|
1817
|
+
console.log(chalk.yellow(' ⚠ Cloudflare Workers AI requires CLOUDFLARE_ACCOUNT_ID for OpenCode integration.'))
|
|
1818
|
+
console.log(chalk.dim(' Export CLOUDFLARE_ACCOUNT_ID and retry this selection.'))
|
|
1819
|
+
console.log()
|
|
1820
|
+
return
|
|
1821
|
+
}
|
|
1822
|
+
config.provider.cloudflare = {
|
|
1823
|
+
npm: '@ai-sdk/openai-compatible',
|
|
1824
|
+
name: 'Cloudflare Workers AI',
|
|
1825
|
+
options: {
|
|
1826
|
+
baseURL: `https://api.cloudflare.com/client/v4/accounts/${cloudflareAccountId}/ai/v1`,
|
|
1827
|
+
apiKey: '{env:CLOUDFLARE_API_TOKEN}'
|
|
1828
|
+
},
|
|
1829
|
+
models: {}
|
|
1830
|
+
}
|
|
1831
|
+
} else if (providerKey === 'perplexity') {
|
|
1832
|
+
config.provider.perplexity = {
|
|
1833
|
+
npm: '@ai-sdk/openai-compatible',
|
|
1834
|
+
name: 'Perplexity API',
|
|
1835
|
+
options: {
|
|
1836
|
+
baseURL: 'https://api.perplexity.ai',
|
|
1837
|
+
apiKey: '{env:PERPLEXITY_API_KEY}'
|
|
1838
|
+
},
|
|
1839
|
+
models: {}
|
|
1840
|
+
}
|
|
1618
1841
|
}
|
|
1619
1842
|
}
|
|
1620
1843
|
|
|
@@ -2119,6 +2342,7 @@ async function main() {
|
|
|
2119
2342
|
// 📖 Load JSON config (auto-migrates old plain-text ~/.free-coding-models if needed)
|
|
2120
2343
|
const config = loadConfig()
|
|
2121
2344
|
ensureTelemetryConfig(config)
|
|
2345
|
+
ensureFavoritesConfig(config)
|
|
2122
2346
|
|
|
2123
2347
|
// 📖 Check if any provider has a key — if not, run the first-time setup wizard
|
|
2124
2348
|
const hasAnyKey = Object.keys(sources).some(pk => !!getApiKey(config, pk))
|
|
@@ -2202,6 +2426,7 @@ async function main() {
|
|
|
2202
2426
|
httpCode: null,
|
|
2203
2427
|
hidden: false, // 📖 Simple flag to hide/show models
|
|
2204
2428
|
}))
|
|
2429
|
+
syncFavoriteFlags(results, config)
|
|
2205
2430
|
|
|
2206
2431
|
// 📖 Clamp scrollOffset so cursor is always within the visible viewport window.
|
|
2207
2432
|
// 📖 Called after every cursor move, sort change, and terminal resize.
|
|
@@ -2223,7 +2448,9 @@ async function main() {
|
|
|
2223
2448
|
st.scrollOffset = st.cursor - modelSlots + 1
|
|
2224
2449
|
}
|
|
2225
2450
|
// Final clamp
|
|
2226
|
-
|
|
2451
|
+
// 📖 Keep one extra scroll step when top indicator is visible,
|
|
2452
|
+
// 📖 otherwise the last rows become unreachable at the bottom.
|
|
2453
|
+
const maxOffset = Math.max(0, total - maxSlots + 1)
|
|
2227
2454
|
if (st.scrollOffset > maxOffset) st.scrollOffset = maxOffset
|
|
2228
2455
|
if (st.scrollOffset < 0) st.scrollOffset = 0
|
|
2229
2456
|
}
|
|
@@ -2252,9 +2479,14 @@ async function main() {
|
|
|
2252
2479
|
settingsEditMode: false, // 📖 Whether we're in inline key editing mode
|
|
2253
2480
|
settingsEditBuffer: '', // 📖 Typed characters for the API key being edited
|
|
2254
2481
|
settingsTestResults: {}, // 📖 { providerKey: 'pending'|'ok'|'fail'|null }
|
|
2482
|
+
settingsUpdateState: 'idle', // 📖 'idle'|'checking'|'available'|'up-to-date'|'error'|'installing'
|
|
2483
|
+
settingsUpdateLatestVersion: null, // 📖 Latest npm version discovered from manual check
|
|
2484
|
+
settingsUpdateError: null, // 📖 Last update-check error message for maintenance row
|
|
2255
2485
|
config, // 📖 Live reference to the config object (updated on save)
|
|
2256
2486
|
visibleSorted: [], // 📖 Cached visible+sorted models — shared between render loop and key handlers
|
|
2257
2487
|
helpVisible: false, // 📖 Whether the help overlay (K key) is active
|
|
2488
|
+
settingsScrollOffset: 0, // 📖 Vertical scroll offset for Settings overlay viewport
|
|
2489
|
+
helpScrollOffset: 0, // 📖 Vertical scroll offset for Help overlay viewport
|
|
2258
2490
|
}
|
|
2259
2491
|
|
|
2260
2492
|
// 📖 Re-clamp viewport on terminal resize
|
|
@@ -2289,6 +2521,11 @@ async function main() {
|
|
|
2289
2521
|
const activeTier = TIER_CYCLE[tierFilterMode]
|
|
2290
2522
|
const activeOrigin = ORIGIN_CYCLE[originFilterMode]
|
|
2291
2523
|
state.results.forEach(r => {
|
|
2524
|
+
// 📖 Favorites stay visible regardless of tier/origin filters.
|
|
2525
|
+
if (r.isFavorite) {
|
|
2526
|
+
r.hidden = false
|
|
2527
|
+
return
|
|
2528
|
+
}
|
|
2292
2529
|
// 📖 Apply both tier and origin filters — model is hidden if it fails either
|
|
2293
2530
|
const tierHide = activeTier !== null && r.tier !== activeTier
|
|
2294
2531
|
const originHide = activeOrigin !== null && r.providerKey !== activeOrigin
|
|
@@ -2305,8 +2542,10 @@ async function main() {
|
|
|
2305
2542
|
function renderSettings() {
|
|
2306
2543
|
const providerKeys = Object.keys(sources)
|
|
2307
2544
|
const telemetryRowIdx = providerKeys.length
|
|
2545
|
+
const updateRowIdx = providerKeys.length + 1
|
|
2308
2546
|
const EL = '\x1b[K'
|
|
2309
2547
|
const lines = []
|
|
2548
|
+
const cursorLineByRow = {}
|
|
2310
2549
|
|
|
2311
2550
|
lines.push('')
|
|
2312
2551
|
lines.push(` ${chalk.bold('⚙ Settings')} ${chalk.dim('— free-coding-models v' + LOCAL_VERSION)}`)
|
|
@@ -2349,6 +2588,7 @@ async function main() {
|
|
|
2349
2588
|
const bullet = isCursor ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
|
|
2350
2589
|
|
|
2351
2590
|
const row = `${bullet}[ ${enabledBadge} ] ${providerName} ${keyDisplay.padEnd(30)} ${testBadge} ${rateSummary}`
|
|
2591
|
+
cursorLineByRow[i] = lines.length
|
|
2352
2592
|
lines.push(isCursor ? chalk.bgRgb(30, 30, 60)(row) : row)
|
|
2353
2593
|
}
|
|
2354
2594
|
|
|
@@ -2363,6 +2603,11 @@ async function main() {
|
|
|
2363
2603
|
lines.push(chalk.dim(` 1) Create a ${selectedMeta.label || selectedSource.name} account: ${selectedMeta.signupUrl || 'signup link missing'}`))
|
|
2364
2604
|
lines.push(chalk.dim(` 2) ${selectedMeta.signupHint || 'Generate an API key and paste it with Enter on this row'}`))
|
|
2365
2605
|
lines.push(chalk.dim(` 3) Press ${chalk.yellow('T')} to test your key. Status: ${setupStatus}`))
|
|
2606
|
+
if (selectedProviderKey === 'cloudflare') {
|
|
2607
|
+
const hasAccountId = Boolean((process.env.CLOUDFLARE_ACCOUNT_ID || '').trim())
|
|
2608
|
+
const accountIdStatus = hasAccountId ? chalk.green('CLOUDFLARE_ACCOUNT_ID detected ✅') : chalk.yellow('Set CLOUDFLARE_ACCOUNT_ID ⚠')
|
|
2609
|
+
lines.push(chalk.dim(` 4) Export ${chalk.yellow('CLOUDFLARE_ACCOUNT_ID')} in your shell. Status: ${accountIdStatus}`))
|
|
2610
|
+
}
|
|
2366
2611
|
lines.push('')
|
|
2367
2612
|
}
|
|
2368
2613
|
|
|
@@ -2379,19 +2624,55 @@ async function main() {
|
|
|
2379
2624
|
? chalk.dim('[Config]')
|
|
2380
2625
|
: chalk.yellow('[Env override]')
|
|
2381
2626
|
const telemetryRow = `${telemetryRowBullet}${chalk.bold('Anonymous usage analytics').padEnd(44)} ${telemetryStatus} ${telemetrySource}`
|
|
2627
|
+
cursorLineByRow[telemetryRowIdx] = lines.length
|
|
2382
2628
|
lines.push(telemetryCursor ? chalk.bgRgb(30, 30, 60)(telemetryRow) : telemetryRow)
|
|
2383
2629
|
|
|
2630
|
+
lines.push('')
|
|
2631
|
+
lines.push(` ${chalk.bold('🛠 Maintenance')}`)
|
|
2632
|
+
lines.push(` ${chalk.dim(' ' + '─'.repeat(112))}`)
|
|
2633
|
+
lines.push('')
|
|
2634
|
+
|
|
2635
|
+
const updateCursor = state.settingsCursor === updateRowIdx
|
|
2636
|
+
const updateBullet = updateCursor ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
|
|
2637
|
+
const updateState = state.settingsUpdateState
|
|
2638
|
+
const latestFound = state.settingsUpdateLatestVersion
|
|
2639
|
+
const updateActionLabel = updateState === 'available' && latestFound
|
|
2640
|
+
? `Install update (v${latestFound})`
|
|
2641
|
+
: 'Check for updates manually'
|
|
2642
|
+
let updateStatus = chalk.dim('Press Enter or U to check npm registry')
|
|
2643
|
+
if (updateState === 'checking') updateStatus = chalk.yellow('Checking npm registry…')
|
|
2644
|
+
if (updateState === 'available' && latestFound) updateStatus = chalk.greenBright(`Update available: v${latestFound} (Enter to install)`)
|
|
2645
|
+
if (updateState === 'up-to-date') updateStatus = chalk.green('Already on latest version')
|
|
2646
|
+
if (updateState === 'error') updateStatus = chalk.red('Check failed (press U to retry)')
|
|
2647
|
+
if (updateState === 'installing') updateStatus = chalk.cyan('Installing update…')
|
|
2648
|
+
const updateRow = `${updateBullet}${chalk.bold(updateActionLabel).padEnd(44)} ${updateStatus}`
|
|
2649
|
+
cursorLineByRow[updateRowIdx] = lines.length
|
|
2650
|
+
lines.push(updateCursor ? chalk.bgRgb(30, 30, 60)(updateRow) : updateRow)
|
|
2651
|
+
if (updateState === 'error' && state.settingsUpdateError) {
|
|
2652
|
+
lines.push(chalk.red(` ${state.settingsUpdateError}`))
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2384
2655
|
lines.push('')
|
|
2385
2656
|
if (state.settingsEditMode) {
|
|
2386
2657
|
lines.push(chalk.dim(' Type API key • Enter Save • Esc Cancel'))
|
|
2387
2658
|
} else {
|
|
2388
|
-
lines.push(chalk.dim(' ↑↓ Navigate • Enter Edit key / Toggle analytics • Space Toggle enabled • T Test key • Esc Close'))
|
|
2659
|
+
lines.push(chalk.dim(' ↑↓ Navigate • Enter Edit key / Toggle analytics / Check-or-Install update • Space Toggle enabled • T Test key • U Check updates • Esc Close'))
|
|
2389
2660
|
}
|
|
2390
2661
|
lines.push('')
|
|
2391
2662
|
|
|
2392
|
-
|
|
2393
|
-
const
|
|
2394
|
-
|
|
2663
|
+
// 📖 Keep selected Settings row visible on small terminals by scrolling the overlay viewport.
|
|
2664
|
+
const targetLine = cursorLineByRow[state.settingsCursor] ?? 0
|
|
2665
|
+
state.settingsScrollOffset = keepOverlayTargetVisible(
|
|
2666
|
+
state.settingsScrollOffset,
|
|
2667
|
+
targetLine,
|
|
2668
|
+
lines.length,
|
|
2669
|
+
state.terminalRows
|
|
2670
|
+
)
|
|
2671
|
+
const { visible, offset } = sliceOverlayLines(lines, state.settingsScrollOffset, state.terminalRows)
|
|
2672
|
+
state.settingsScrollOffset = offset
|
|
2673
|
+
|
|
2674
|
+
const tintedLines = tintOverlayLines(visible, SETTINGS_OVERLAY_BG)
|
|
2675
|
+
const cleared = tintedLines.map(l => l + EL)
|
|
2395
2676
|
return cleared.join('\n')
|
|
2396
2677
|
}
|
|
2397
2678
|
|
|
@@ -2402,8 +2683,9 @@ async function main() {
|
|
|
2402
2683
|
const EL = '\x1b[K'
|
|
2403
2684
|
const lines = []
|
|
2404
2685
|
lines.push('')
|
|
2405
|
-
lines.push(` ${chalk.bold('❓ Keyboard Shortcuts')} ${chalk.dim('—
|
|
2686
|
+
lines.push(` ${chalk.bold('❓ Keyboard Shortcuts')} ${chalk.dim('— ↑↓ / PgUp / PgDn / Home / End scroll • K or Esc close')}`)
|
|
2406
2687
|
lines.push('')
|
|
2688
|
+
lines.push(` ${chalk.bold('Main TUI')}`)
|
|
2407
2689
|
lines.push(` ${chalk.bold('Navigation')}`)
|
|
2408
2690
|
lines.push(` ${chalk.yellow('↑↓')} Navigate rows`)
|
|
2409
2691
|
lines.push(` ${chalk.yellow('Enter')} Select model and launch`)
|
|
@@ -2421,13 +2703,37 @@ async function main() {
|
|
|
2421
2703
|
lines.push(` ${chalk.yellow('W')} Decrease ping interval (faster)`)
|
|
2422
2704
|
lines.push(` ${chalk.yellow('X')} Increase ping interval (slower)`)
|
|
2423
2705
|
lines.push(` ${chalk.yellow('Z')} Cycle launch mode ${chalk.dim('(OpenCode CLI → OpenCode Desktop → OpenClaw)')}`)
|
|
2424
|
-
lines.push(` ${chalk.yellow('
|
|
2706
|
+
lines.push(` ${chalk.yellow('F')} Toggle favorite on selected row ${chalk.dim('(⭐ pinned at top, persisted)')}`)
|
|
2707
|
+
lines.push(` ${chalk.yellow('P')} Open settings ${chalk.dim('(manage API keys, provider toggles, analytics, manual update)')}`)
|
|
2425
2708
|
lines.push(` ${chalk.yellow('K')} / ${chalk.yellow('Esc')} Show/hide this help`)
|
|
2426
2709
|
lines.push(` ${chalk.yellow('Ctrl+C')} Exit`)
|
|
2427
2710
|
lines.push('')
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2711
|
+
lines.push(` ${chalk.bold('Settings (P)')}`)
|
|
2712
|
+
lines.push(` ${chalk.yellow('↑↓')} Navigate rows`)
|
|
2713
|
+
lines.push(` ${chalk.yellow('PgUp/PgDn')} Jump by page`)
|
|
2714
|
+
lines.push(` ${chalk.yellow('Home/End')} Jump first/last row`)
|
|
2715
|
+
lines.push(` ${chalk.yellow('Enter')} Edit key / toggle analytics / check-install update`)
|
|
2716
|
+
lines.push(` ${chalk.yellow('Space')} Toggle provider enable/disable`)
|
|
2717
|
+
lines.push(` ${chalk.yellow('T')} Test selected provider key`)
|
|
2718
|
+
lines.push(` ${chalk.yellow('U')} Check updates manually`)
|
|
2719
|
+
lines.push(` ${chalk.yellow('Esc')} Close settings`)
|
|
2720
|
+
lines.push('')
|
|
2721
|
+
lines.push(` ${chalk.bold('CLI Flags')}`)
|
|
2722
|
+
lines.push(` ${chalk.dim('Usage: free-coding-models [options]')}`)
|
|
2723
|
+
lines.push(` ${chalk.cyan('free-coding-models --opencode')} ${chalk.dim('OpenCode CLI mode')}`)
|
|
2724
|
+
lines.push(` ${chalk.cyan('free-coding-models --opencode-desktop')} ${chalk.dim('OpenCode Desktop mode')}`)
|
|
2725
|
+
lines.push(` ${chalk.cyan('free-coding-models --openclaw')} ${chalk.dim('OpenClaw mode')}`)
|
|
2726
|
+
lines.push(` ${chalk.cyan('free-coding-models --best')} ${chalk.dim('Only top tiers (A+, S, S+)')}`)
|
|
2727
|
+
lines.push(` ${chalk.cyan('free-coding-models --fiable')} ${chalk.dim('10s reliability analysis')}`)
|
|
2728
|
+
lines.push(` ${chalk.cyan('free-coding-models --tier S|A|B|C')} ${chalk.dim('Filter by tier letter')}`)
|
|
2729
|
+
lines.push(` ${chalk.cyan('free-coding-models --no-telemetry')} ${chalk.dim('Disable telemetry for this run')}`)
|
|
2730
|
+
lines.push(` ${chalk.dim('Flags can be combined: --openclaw --tier S')}`)
|
|
2731
|
+
lines.push('')
|
|
2732
|
+
// 📖 Help overlay can be longer than viewport, so keep a dedicated scroll offset.
|
|
2733
|
+
const { visible, offset } = sliceOverlayLines(lines, state.helpScrollOffset, state.terminalRows)
|
|
2734
|
+
state.helpScrollOffset = offset
|
|
2735
|
+
const tintedLines = tintOverlayLines(visible, HELP_OVERLAY_BG)
|
|
2736
|
+
const cleared = tintedLines.map(l => l + EL)
|
|
2431
2737
|
return cleared.join('\n')
|
|
2432
2738
|
}
|
|
2433
2739
|
|
|
@@ -2448,11 +2754,47 @@ async function main() {
|
|
|
2448
2754
|
state.settingsTestResults[providerKey] = code === '200' ? 'ok' : 'fail'
|
|
2449
2755
|
}
|
|
2450
2756
|
|
|
2757
|
+
// 📖 Manual update checker from settings; keeps status visible in maintenance row.
|
|
2758
|
+
async function checkUpdatesFromSettings() {
|
|
2759
|
+
if (state.settingsUpdateState === 'checking' || state.settingsUpdateState === 'installing') return
|
|
2760
|
+
state.settingsUpdateState = 'checking'
|
|
2761
|
+
state.settingsUpdateError = null
|
|
2762
|
+
const { latestVersion, error } = await checkForUpdateDetailed()
|
|
2763
|
+
if (error) {
|
|
2764
|
+
state.settingsUpdateState = 'error'
|
|
2765
|
+
state.settingsUpdateLatestVersion = null
|
|
2766
|
+
state.settingsUpdateError = error
|
|
2767
|
+
return
|
|
2768
|
+
}
|
|
2769
|
+
if (latestVersion) {
|
|
2770
|
+
state.settingsUpdateState = 'available'
|
|
2771
|
+
state.settingsUpdateLatestVersion = latestVersion
|
|
2772
|
+
state.settingsUpdateError = null
|
|
2773
|
+
return
|
|
2774
|
+
}
|
|
2775
|
+
state.settingsUpdateState = 'up-to-date'
|
|
2776
|
+
state.settingsUpdateLatestVersion = null
|
|
2777
|
+
state.settingsUpdateError = null
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
// 📖 Leaves TUI cleanly, then runs npm global update command.
|
|
2781
|
+
function launchUpdateFromSettings(latestVersion) {
|
|
2782
|
+
if (!latestVersion) return
|
|
2783
|
+
state.settingsUpdateState = 'installing'
|
|
2784
|
+
clearInterval(ticker)
|
|
2785
|
+
clearTimeout(state.pingIntervalObj)
|
|
2786
|
+
process.stdin.removeListener('keypress', onKeyPress)
|
|
2787
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
2788
|
+
process.stdin.pause()
|
|
2789
|
+
process.stdout.write(ALT_LEAVE)
|
|
2790
|
+
runUpdate(latestVersion)
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2451
2793
|
// Apply CLI --tier filter if provided
|
|
2452
2794
|
if (cliArgs.tierFilter) {
|
|
2453
2795
|
const allowed = TIER_LETTER_MAP[cliArgs.tierFilter]
|
|
2454
2796
|
state.results.forEach(r => {
|
|
2455
|
-
r.hidden = !allowed.includes(r.tier)
|
|
2797
|
+
r.hidden = r.isFavorite ? false : !allowed.includes(r.tier)
|
|
2456
2798
|
})
|
|
2457
2799
|
}
|
|
2458
2800
|
|
|
@@ -2466,9 +2808,20 @@ async function main() {
|
|
|
2466
2808
|
const onKeyPress = async (str, key) => {
|
|
2467
2809
|
if (!key) return
|
|
2468
2810
|
|
|
2469
|
-
// 📖 Help overlay:
|
|
2470
|
-
if (state.helpVisible
|
|
2471
|
-
state.
|
|
2811
|
+
// 📖 Help overlay: full keyboard navigation + key swallowing while overlay is open.
|
|
2812
|
+
if (state.helpVisible) {
|
|
2813
|
+
const pageStep = Math.max(1, (state.terminalRows || 1) - 2)
|
|
2814
|
+
if (key.name === 'escape' || key.name === 'k') {
|
|
2815
|
+
state.helpVisible = false
|
|
2816
|
+
return
|
|
2817
|
+
}
|
|
2818
|
+
if (key.name === 'up') { state.helpScrollOffset = Math.max(0, state.helpScrollOffset - 1); return }
|
|
2819
|
+
if (key.name === 'down') { state.helpScrollOffset += 1; return }
|
|
2820
|
+
if (key.name === 'pageup') { state.helpScrollOffset = Math.max(0, state.helpScrollOffset - pageStep); return }
|
|
2821
|
+
if (key.name === 'pagedown') { state.helpScrollOffset += pageStep; return }
|
|
2822
|
+
if (key.name === 'home') { state.helpScrollOffset = 0; return }
|
|
2823
|
+
if (key.name === 'end') { state.helpScrollOffset = Number.MAX_SAFE_INTEGER; return }
|
|
2824
|
+
if (key.ctrl && key.name === 'c') { exit(0); return }
|
|
2472
2825
|
return
|
|
2473
2826
|
}
|
|
2474
2827
|
|
|
@@ -2476,6 +2829,7 @@ async function main() {
|
|
|
2476
2829
|
if (state.settingsOpen) {
|
|
2477
2830
|
const providerKeys = Object.keys(sources)
|
|
2478
2831
|
const telemetryRowIdx = providerKeys.length
|
|
2832
|
+
const updateRowIdx = providerKeys.length + 1
|
|
2479
2833
|
|
|
2480
2834
|
// 📖 Edit mode: capture typed characters for the API key
|
|
2481
2835
|
if (state.settingsEditMode) {
|
|
@@ -2518,6 +2872,11 @@ async function main() {
|
|
|
2518
2872
|
// 📖 Re-index results
|
|
2519
2873
|
results.forEach((r, i) => { r.idx = i + 1 })
|
|
2520
2874
|
state.results = results
|
|
2875
|
+
syncFavoriteFlags(state.results, state.config)
|
|
2876
|
+
applyTierFilter()
|
|
2877
|
+
const visible = state.results.filter(r => !r.hidden)
|
|
2878
|
+
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
2879
|
+
if (state.cursor >= state.visibleSorted.length) state.cursor = Math.max(0, state.visibleSorted.length - 1)
|
|
2521
2880
|
adjustScrollOffset(state)
|
|
2522
2881
|
// 📖 Re-ping all models that were 'noauth' (got 401 without key) but now have a key
|
|
2523
2882
|
// 📖 This makes the TUI react immediately when a user adds an API key in settings
|
|
@@ -2537,11 +2896,33 @@ async function main() {
|
|
|
2537
2896
|
return
|
|
2538
2897
|
}
|
|
2539
2898
|
|
|
2540
|
-
if (key.name === 'down' && state.settingsCursor <
|
|
2899
|
+
if (key.name === 'down' && state.settingsCursor < updateRowIdx) {
|
|
2541
2900
|
state.settingsCursor++
|
|
2542
2901
|
return
|
|
2543
2902
|
}
|
|
2544
2903
|
|
|
2904
|
+
if (key.name === 'pageup') {
|
|
2905
|
+
const pageStep = Math.max(1, (state.terminalRows || 1) - 2)
|
|
2906
|
+
state.settingsCursor = Math.max(0, state.settingsCursor - pageStep)
|
|
2907
|
+
return
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
if (key.name === 'pagedown') {
|
|
2911
|
+
const pageStep = Math.max(1, (state.terminalRows || 1) - 2)
|
|
2912
|
+
state.settingsCursor = Math.min(updateRowIdx, state.settingsCursor + pageStep)
|
|
2913
|
+
return
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
if (key.name === 'home') {
|
|
2917
|
+
state.settingsCursor = 0
|
|
2918
|
+
return
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
if (key.name === 'end') {
|
|
2922
|
+
state.settingsCursor = updateRowIdx
|
|
2923
|
+
return
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2545
2926
|
if (key.name === 'return') {
|
|
2546
2927
|
if (state.settingsCursor === telemetryRowIdx) {
|
|
2547
2928
|
ensureTelemetryConfig(state.config)
|
|
@@ -2550,6 +2931,14 @@ async function main() {
|
|
|
2550
2931
|
saveConfig(state.config)
|
|
2551
2932
|
return
|
|
2552
2933
|
}
|
|
2934
|
+
if (state.settingsCursor === updateRowIdx) {
|
|
2935
|
+
if (state.settingsUpdateState === 'available' && state.settingsUpdateLatestVersion) {
|
|
2936
|
+
launchUpdateFromSettings(state.settingsUpdateLatestVersion)
|
|
2937
|
+
return
|
|
2938
|
+
}
|
|
2939
|
+
checkUpdatesFromSettings()
|
|
2940
|
+
return
|
|
2941
|
+
}
|
|
2553
2942
|
|
|
2554
2943
|
// 📖 Enter edit mode for the selected provider's key
|
|
2555
2944
|
const pk = providerKeys[state.settingsCursor]
|
|
@@ -2566,6 +2955,7 @@ async function main() {
|
|
|
2566
2955
|
saveConfig(state.config)
|
|
2567
2956
|
return
|
|
2568
2957
|
}
|
|
2958
|
+
if (state.settingsCursor === updateRowIdx) return
|
|
2569
2959
|
|
|
2570
2960
|
// 📖 Toggle enabled/disabled for selected provider
|
|
2571
2961
|
const pk = providerKeys[state.settingsCursor]
|
|
@@ -2577,7 +2967,7 @@ async function main() {
|
|
|
2577
2967
|
}
|
|
2578
2968
|
|
|
2579
2969
|
if (key.name === 't') {
|
|
2580
|
-
if (state.settingsCursor === telemetryRowIdx) return
|
|
2970
|
+
if (state.settingsCursor === telemetryRowIdx || state.settingsCursor === updateRowIdx) return
|
|
2581
2971
|
|
|
2582
2972
|
// 📖 Test the selected provider's key (fires a real ping)
|
|
2583
2973
|
const pk = providerKeys[state.settingsCursor]
|
|
@@ -2585,6 +2975,11 @@ async function main() {
|
|
|
2585
2975
|
return
|
|
2586
2976
|
}
|
|
2587
2977
|
|
|
2978
|
+
if (key.name === 'u') {
|
|
2979
|
+
checkUpdatesFromSettings()
|
|
2980
|
+
return
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2588
2983
|
if (key.ctrl && key.name === 'c') { exit(0); return }
|
|
2589
2984
|
return // 📖 Swallow all other keys while settings is open
|
|
2590
2985
|
}
|
|
@@ -2595,6 +2990,7 @@ async function main() {
|
|
|
2595
2990
|
state.settingsCursor = 0
|
|
2596
2991
|
state.settingsEditMode = false
|
|
2597
2992
|
state.settingsEditBuffer = ''
|
|
2993
|
+
state.settingsScrollOffset = 0
|
|
2598
2994
|
return
|
|
2599
2995
|
}
|
|
2600
2996
|
|
|
@@ -2617,12 +3013,38 @@ async function main() {
|
|
|
2617
3013
|
}
|
|
2618
3014
|
// 📖 Recompute visible sorted list and reset cursor to top to avoid stale index
|
|
2619
3015
|
const visible = state.results.filter(r => !r.hidden)
|
|
2620
|
-
state.visibleSorted =
|
|
3016
|
+
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
2621
3017
|
state.cursor = 0
|
|
2622
3018
|
state.scrollOffset = 0
|
|
2623
3019
|
return
|
|
2624
3020
|
}
|
|
2625
3021
|
|
|
3022
|
+
// 📖 F key: toggle favorite on the currently selected row and persist to config.
|
|
3023
|
+
if (key.name === 'f') {
|
|
3024
|
+
const selected = state.visibleSorted[state.cursor]
|
|
3025
|
+
if (!selected) return
|
|
3026
|
+
const wasFavorite = selected.isFavorite
|
|
3027
|
+
toggleFavoriteModel(state.config, selected.providerKey, selected.modelId)
|
|
3028
|
+
syncFavoriteFlags(state.results, state.config)
|
|
3029
|
+
applyTierFilter()
|
|
3030
|
+
const visible = state.results.filter(r => !r.hidden)
|
|
3031
|
+
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
3032
|
+
|
|
3033
|
+
// 📖 UX rule: when unpinning a favorite, jump back to the top of the list.
|
|
3034
|
+
if (wasFavorite) {
|
|
3035
|
+
state.cursor = 0
|
|
3036
|
+
state.scrollOffset = 0
|
|
3037
|
+
return
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
const selectedKey = toFavoriteKey(selected.providerKey, selected.modelId)
|
|
3041
|
+
const newCursor = state.visibleSorted.findIndex(r => toFavoriteKey(r.providerKey, r.modelId) === selectedKey)
|
|
3042
|
+
if (newCursor >= 0) state.cursor = newCursor
|
|
3043
|
+
else if (state.cursor >= state.visibleSorted.length) state.cursor = Math.max(0, state.visibleSorted.length - 1)
|
|
3044
|
+
adjustScrollOffset(state)
|
|
3045
|
+
return
|
|
3046
|
+
}
|
|
3047
|
+
|
|
2626
3048
|
// 📖 Interval adjustment keys: W=decrease (faster), X=increase (slower)
|
|
2627
3049
|
// 📖 Minimum 1s, maximum 60s
|
|
2628
3050
|
if (key.name === 'w') {
|
|
@@ -2637,7 +3059,7 @@ async function main() {
|
|
|
2637
3059
|
applyTierFilter()
|
|
2638
3060
|
// 📖 Recompute visible sorted list and reset cursor to avoid stale index into new filtered set
|
|
2639
3061
|
const visible = state.results.filter(r => !r.hidden)
|
|
2640
|
-
state.visibleSorted =
|
|
3062
|
+
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
2641
3063
|
state.cursor = 0
|
|
2642
3064
|
state.scrollOffset = 0
|
|
2643
3065
|
return
|
|
@@ -2649,7 +3071,7 @@ async function main() {
|
|
|
2649
3071
|
applyTierFilter()
|
|
2650
3072
|
// 📖 Recompute visible sorted list and reset cursor to avoid stale index into new filtered set
|
|
2651
3073
|
const visible = state.results.filter(r => !r.hidden)
|
|
2652
|
-
state.visibleSorted =
|
|
3074
|
+
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
2653
3075
|
state.cursor = 0
|
|
2654
3076
|
state.scrollOffset = 0
|
|
2655
3077
|
return
|
|
@@ -2658,6 +3080,7 @@ async function main() {
|
|
|
2658
3080
|
// 📖 Help overlay key: K = toggle help overlay
|
|
2659
3081
|
if (key.name === 'k') {
|
|
2660
3082
|
state.helpVisible = !state.helpVisible
|
|
3083
|
+
if (state.helpVisible) state.helpScrollOffset = 0
|
|
2661
3084
|
return
|
|
2662
3085
|
}
|
|
2663
3086
|
|
|
@@ -2676,18 +3099,20 @@ async function main() {
|
|
|
2676
3099
|
}
|
|
2677
3100
|
|
|
2678
3101
|
if (key.name === 'up') {
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
3102
|
+
// 📖 Main list wrap navigation: top -> bottom on Up.
|
|
3103
|
+
const count = state.visibleSorted.length
|
|
3104
|
+
if (count === 0) return
|
|
3105
|
+
state.cursor = state.cursor > 0 ? state.cursor - 1 : count - 1
|
|
3106
|
+
adjustScrollOffset(state)
|
|
2683
3107
|
return
|
|
2684
3108
|
}
|
|
2685
3109
|
|
|
2686
3110
|
if (key.name === 'down') {
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
3111
|
+
// 📖 Main list wrap navigation: bottom -> top on Down.
|
|
3112
|
+
const count = state.visibleSorted.length
|
|
3113
|
+
if (count === 0) return
|
|
3114
|
+
state.cursor = state.cursor < count - 1 ? state.cursor + 1 : 0
|
|
3115
|
+
adjustScrollOffset(state)
|
|
2691
3116
|
return
|
|
2692
3117
|
}
|
|
2693
3118
|
|
|
@@ -2759,7 +3184,7 @@ async function main() {
|
|
|
2759
3184
|
// 📖 Cache visible+sorted models each frame so Enter handler always matches the display
|
|
2760
3185
|
if (!state.settingsOpen) {
|
|
2761
3186
|
const visible = state.results.filter(r => !r.hidden)
|
|
2762
|
-
state.visibleSorted =
|
|
3187
|
+
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
2763
3188
|
}
|
|
2764
3189
|
const content = state.settingsOpen
|
|
2765
3190
|
? renderSettings()
|
|
@@ -2771,7 +3196,7 @@ async function main() {
|
|
|
2771
3196
|
|
|
2772
3197
|
// 📖 Populate visibleSorted before the first frame so Enter works immediately
|
|
2773
3198
|
const initialVisible = state.results.filter(r => !r.hidden)
|
|
2774
|
-
state.visibleSorted =
|
|
3199
|
+
state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection)
|
|
2775
3200
|
|
|
2776
3201
|
process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, tierFilterMode, state.scrollOffset, state.terminalRows, originFilterMode))
|
|
2777
3202
|
|
package/lib/config.js
CHANGED
|
@@ -24,7 +24,11 @@
|
|
|
24
24
|
* "codestral": "csk-xxx",
|
|
25
25
|
* "hyperbolic": "eyJ...",
|
|
26
26
|
* "scaleway": "scw-xxx",
|
|
27
|
-
* "googleai": "AIza..."
|
|
27
|
+
* "googleai": "AIza...",
|
|
28
|
+
* "siliconflow":"sk-xxx",
|
|
29
|
+
* "together": "together-xxx",
|
|
30
|
+
* "cloudflare": "cf-xxx",
|
|
31
|
+
* "perplexity": "pplx-xxx"
|
|
28
32
|
* },
|
|
29
33
|
* "providers": {
|
|
30
34
|
* "nvidia": { "enabled": true },
|
|
@@ -39,7 +43,14 @@
|
|
|
39
43
|
* "codestral": { "enabled": true },
|
|
40
44
|
* "hyperbolic": { "enabled": true },
|
|
41
45
|
* "scaleway": { "enabled": true },
|
|
42
|
-
* "googleai": { "enabled": true }
|
|
46
|
+
* "googleai": { "enabled": true },
|
|
47
|
+
* "siliconflow":{ "enabled": true },
|
|
48
|
+
* "together": { "enabled": true },
|
|
49
|
+
* "cloudflare": { "enabled": true },
|
|
50
|
+
* "perplexity": { "enabled": true }
|
|
51
|
+
* },
|
|
52
|
+
* "favorites": [
|
|
53
|
+
* "nvidia/deepseek-ai/deepseek-v3.2"
|
|
43
54
|
* },
|
|
44
55
|
* "telemetry": {
|
|
45
56
|
* "enabled": true,
|
|
@@ -56,6 +67,7 @@
|
|
|
56
67
|
* → loadConfig() — Read ~/.free-coding-models.json; auto-migrate old plain-text config if needed
|
|
57
68
|
* → saveConfig(config) — Write config to ~/.free-coding-models.json with 0o600 permissions
|
|
58
69
|
* → getApiKey(config, providerKey) — Get effective API key (env var override > config > null)
|
|
70
|
+
* → isProviderEnabled(config, providerKey) — Check if provider is enabled (defaults true)
|
|
59
71
|
*
|
|
60
72
|
* @exports loadConfig, saveConfig, getApiKey
|
|
61
73
|
* @exports CONFIG_PATH — path to the JSON config file
|
|
@@ -90,6 +102,10 @@ const ENV_VARS = {
|
|
|
90
102
|
hyperbolic: 'HYPERBOLIC_API_KEY',
|
|
91
103
|
scaleway: 'SCALEWAY_API_KEY',
|
|
92
104
|
googleai: 'GOOGLE_API_KEY',
|
|
105
|
+
siliconflow:'SILICONFLOW_API_KEY',
|
|
106
|
+
together: 'TOGETHER_API_KEY',
|
|
107
|
+
cloudflare: ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_API_KEY'],
|
|
108
|
+
perplexity: ['PERPLEXITY_API_KEY', 'PPLX_API_KEY'],
|
|
93
109
|
}
|
|
94
110
|
|
|
95
111
|
/**
|
|
@@ -103,7 +119,7 @@ const ENV_VARS = {
|
|
|
103
119
|
* 📖 The migration reads the old file as a plain nvidia API key and writes
|
|
104
120
|
* a proper JSON config. The old file is NOT deleted (safety first).
|
|
105
121
|
*
|
|
106
|
-
* @returns {{ apiKeys: Record<string,string>, providers: Record<string,{enabled:boolean}>, telemetry: { enabled: boolean | null, consentVersion: number, anonymousId: string | null } }}
|
|
122
|
+
* @returns {{ apiKeys: Record<string,string>, providers: Record<string,{enabled:boolean}>, favorites: string[], telemetry: { enabled: boolean | null, consentVersion: number, anonymousId: string | null } }}
|
|
107
123
|
*/
|
|
108
124
|
export function loadConfig() {
|
|
109
125
|
// 📖 Try new JSON config first
|
|
@@ -114,6 +130,9 @@ export function loadConfig() {
|
|
|
114
130
|
// 📖 Ensure the shape is always complete — fill missing sections with defaults
|
|
115
131
|
if (!parsed.apiKeys) parsed.apiKeys = {}
|
|
116
132
|
if (!parsed.providers) parsed.providers = {}
|
|
133
|
+
// 📖 Favorites: list of "providerKey/modelId" pinned rows.
|
|
134
|
+
if (!Array.isArray(parsed.favorites)) parsed.favorites = []
|
|
135
|
+
parsed.favorites = parsed.favorites.filter((fav) => typeof fav === 'string' && fav.trim().length > 0)
|
|
117
136
|
if (!parsed.telemetry || typeof parsed.telemetry !== 'object') parsed.telemetry = { enabled: null, consentVersion: 0, anonymousId: null }
|
|
118
137
|
if (typeof parsed.telemetry.enabled !== 'boolean') parsed.telemetry.enabled = null
|
|
119
138
|
if (typeof parsed.telemetry.consentVersion !== 'number') parsed.telemetry.consentVersion = 0
|
|
@@ -150,7 +169,7 @@ export function loadConfig() {
|
|
|
150
169
|
* 📖 Uses mode 0o600 so the file is only readable by the owning user (API keys!).
|
|
151
170
|
* 📖 Pretty-prints JSON for human readability.
|
|
152
171
|
*
|
|
153
|
-
* @param {{ apiKeys: Record<string,string>, providers: Record<string,{enabled:boolean}>, telemetry?: { enabled?: boolean | null, consentVersion?: number, anonymousId?: string | null } }} config
|
|
172
|
+
* @param {{ apiKeys: Record<string,string>, providers: Record<string,{enabled:boolean}>, favorites?: string[], telemetry?: { enabled?: boolean | null, consentVersion?: number, anonymousId?: string | null } }} config
|
|
154
173
|
*/
|
|
155
174
|
export function saveConfig(config) {
|
|
156
175
|
try {
|
|
@@ -208,6 +227,8 @@ function _emptyConfig() {
|
|
|
208
227
|
return {
|
|
209
228
|
apiKeys: {},
|
|
210
229
|
providers: {},
|
|
230
|
+
// 📖 Pinned favorites rendered at top of the table ("providerKey/modelId").
|
|
231
|
+
favorites: [],
|
|
211
232
|
// 📖 Telemetry consent is explicit. null = not decided yet.
|
|
212
233
|
telemetry: {
|
|
213
234
|
enabled: null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "free-coding-models",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.66",
|
|
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
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
* 📖 Secondary: https://swe-rebench.com (independent evals, scores are lower)
|
|
28
28
|
* 📖 Leaderboard tracker: https://www.marc0.dev/en/leaderboard
|
|
29
29
|
*
|
|
30
|
-
* @exports nvidiaNim, groq, cerebras, sambanova, openrouter, huggingface, replicate, deepinfra, fireworks, codestral, hyperbolic, scaleway, googleai — model arrays per provider
|
|
31
|
-
* @exports sources — map of { nvidia, groq, cerebras, sambanova, openrouter, huggingface, replicate, deepinfra, fireworks, codestral, hyperbolic, scaleway, googleai } each with { name, url, models }
|
|
30
|
+
* @exports nvidiaNim, groq, cerebras, sambanova, openrouter, huggingface, replicate, deepinfra, fireworks, codestral, hyperbolic, scaleway, googleai, siliconflow, together, cloudflare, perplexity — model arrays per provider
|
|
31
|
+
* @exports sources — map of { nvidia, groq, cerebras, sambanova, openrouter, huggingface, replicate, deepinfra, fireworks, codestral, hyperbolic, scaleway, googleai, siliconflow, together, cloudflare, perplexity } each with { name, url, models }
|
|
32
32
|
* @exports MODELS — flat array of [modelId, label, tier, sweScore, ctx, providerKey]
|
|
33
33
|
*
|
|
34
34
|
* 📖 MODELS now includes providerKey as 6th element so ping() knows which
|
|
@@ -230,6 +230,54 @@ export const googleai = [
|
|
|
230
230
|
['gemma-3-4b-it', 'Gemma 3 4B', 'C', '10.0%', '128k'],
|
|
231
231
|
]
|
|
232
232
|
|
|
233
|
+
// 📖 SiliconFlow source - https://cloud.siliconflow.cn
|
|
234
|
+
// 📖 OpenAI-compatible endpoint: https://api.siliconflow.com/v1/chat/completions
|
|
235
|
+
// 📖 Free model quotas vary by model and can change over time.
|
|
236
|
+
export const siliconflow = [
|
|
237
|
+
['Qwen/Qwen3-Coder-480B-A35B-Instruct', 'Qwen3 Coder 480B', 'S+', '70.6%', '256k'],
|
|
238
|
+
['deepseek-ai/DeepSeek-V3.2', 'DeepSeek V3.2', 'S+', '73.1%', '128k'],
|
|
239
|
+
['Qwen/Qwen3-235B-A22B', 'Qwen3 235B', 'S+', '70.0%', '128k'],
|
|
240
|
+
['deepseek-ai/DeepSeek-R1', 'DeepSeek R1', 'S', '61.0%', '128k'],
|
|
241
|
+
['Qwen/Qwen3-Coder-30B-A3B-Instruct', 'Qwen3 Coder 30B', 'A+', '55.0%', '32k'],
|
|
242
|
+
['Qwen/Qwen2.5-Coder-32B-Instruct', 'Qwen2.5 Coder 32B', 'A', '46.0%', '32k'],
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
// 📖 Together AI source - https://api.together.ai
|
|
246
|
+
// 📖 OpenAI-compatible endpoint: https://api.together.xyz/v1/chat/completions
|
|
247
|
+
// 📖 Credits/promotions vary by account and region; verify current quota in console.
|
|
248
|
+
export const together = [
|
|
249
|
+
['moonshotai/Kimi-K2.5', 'Kimi K2.5', 'S+', '76.8%', '128k'],
|
|
250
|
+
['Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8', 'Qwen3 Coder 480B', 'S+', '70.6%', '256k'],
|
|
251
|
+
['deepseek-ai/DeepSeek-V3.1', 'DeepSeek V3.1', 'S', '62.0%', '128k'],
|
|
252
|
+
['deepseek-ai/DeepSeek-R1', 'DeepSeek R1', 'S', '61.0%', '128k'],
|
|
253
|
+
['openai/gpt-oss-120b', 'GPT OSS 120B', 'S', '60.0%', '128k'],
|
|
254
|
+
['openai/gpt-oss-20b', 'GPT OSS 20B', 'A', '42.0%', '128k'],
|
|
255
|
+
['meta-llama/Llama-3.3-70B-Instruct-Turbo', 'Llama 3.3 70B', 'A-', '39.5%', '128k'],
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
// 📖 Cloudflare Workers AI source - https://developers.cloudflare.com/workers-ai
|
|
259
|
+
// 📖 OpenAI-compatible endpoint requires account id:
|
|
260
|
+
// 📖 https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1/chat/completions
|
|
261
|
+
// 📖 Free plan includes daily neuron quota and provider-level request limits.
|
|
262
|
+
export const cloudflare = [
|
|
263
|
+
['@cf/openai/gpt-oss-120b', 'GPT OSS 120B', 'S', '60.0%', '128k'],
|
|
264
|
+
['@cf/qwen/qwen2.5-coder-32b-instruct', 'Qwen2.5 Coder 32B', 'A', '46.0%', '32k'],
|
|
265
|
+
['@cf/deepseek-ai/deepseek-r1-distill-qwen-32b', 'R1 Distill 32B', 'A', '43.9%', '128k'],
|
|
266
|
+
['@cf/openai/gpt-oss-20b', 'GPT OSS 20B', 'A', '42.0%', '128k'],
|
|
267
|
+
['@cf/meta/llama-3.3-70b-instruct-fp8-fast', 'Llama 3.3 70B', 'A-', '39.5%', '128k'],
|
|
268
|
+
['@cf/meta/llama-3.1-8b-instruct', 'Llama 3.1 8B', 'B', '28.8%', '128k'],
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
// 📖 Perplexity source - https://docs.perplexity.ai
|
|
272
|
+
// 📖 Chat Completions endpoint: https://api.perplexity.ai/chat/completions
|
|
273
|
+
// 📖 Sonar models focus on search/reasoning and have tiered API rate limits.
|
|
274
|
+
export const perplexity = [
|
|
275
|
+
['sonar-reasoning-pro', 'Sonar Reasoning Pro', 'A+', '50.0%', '128k'],
|
|
276
|
+
['sonar-reasoning', 'Sonar Reasoning', 'A', '45.0%', '128k'],
|
|
277
|
+
['sonar-pro', 'Sonar Pro', 'B+', '32.0%', '128k'],
|
|
278
|
+
['sonar', 'Sonar', 'B', '25.0%', '128k'],
|
|
279
|
+
]
|
|
280
|
+
|
|
233
281
|
// 📖 All sources combined - used by the main script
|
|
234
282
|
// 📖 Each source has: name (display), url (API endpoint), models (array of model tuples)
|
|
235
283
|
export const sources = {
|
|
@@ -298,6 +346,26 @@ export const sources = {
|
|
|
298
346
|
url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',
|
|
299
347
|
models: googleai,
|
|
300
348
|
},
|
|
349
|
+
siliconflow: {
|
|
350
|
+
name: 'SiliconFlow',
|
|
351
|
+
url: 'https://api.siliconflow.com/v1/chat/completions',
|
|
352
|
+
models: siliconflow,
|
|
353
|
+
},
|
|
354
|
+
together: {
|
|
355
|
+
name: 'Together AI',
|
|
356
|
+
url: 'https://api.together.xyz/v1/chat/completions',
|
|
357
|
+
models: together,
|
|
358
|
+
},
|
|
359
|
+
cloudflare: {
|
|
360
|
+
name: 'Cloudflare AI',
|
|
361
|
+
url: 'https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1/chat/completions',
|
|
362
|
+
models: cloudflare,
|
|
363
|
+
},
|
|
364
|
+
perplexity: {
|
|
365
|
+
name: 'Perplexity',
|
|
366
|
+
url: 'https://api.perplexity.ai/chat/completions',
|
|
367
|
+
models: perplexity,
|
|
368
|
+
},
|
|
301
369
|
}
|
|
302
370
|
|
|
303
371
|
// 📖 Flatten all models from all sources — each entry includes providerKey as 6th element
|