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 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-111-76b900?logo=nvidia" alt="models count">
6
- <img src="https://img.shields.io/badge/providers-13-blue" alt="providers count">
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 13 providers in real-time — pick the best one for OpenCode, OpenClaw, or any AI coding assistant</sub>
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** — 111 models from NVIDIA NIM, Groq, Cerebras, SambaNova, OpenRouter, Hugging Face Inference, Replicate, DeepInfra, Fireworks AI, Codestral, Hyperbolic, Scaleway, and Google AI all free to use
51
- - **⚙️ Settings screen** — Press `P` to manage provider API keys, enable/disable providers, and test keys live
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 thirteen 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.
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 111 across 13 providers)
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 13 providers):
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 thirteen — skip any provider by pressing Enter. At least one key is required.
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
- **111 coding models** across 13 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.
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 (6 models)
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 (3 models)
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 analytics row
649
- - **Enter** — Edit API key inline, or toggle analytics on analytics row
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 per provider, enable/disable, test 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
- async function checkForUpdate() {
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
- } catch {}
426
- return null
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 = sortResults(visibleResults, sortColumn, sortDirection)
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
- const name = r.label.slice(0, W_MODEL).padEnd(W_MODEL)
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
- const maxOffset = Math.max(0, total - maxSlots)
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
- const cleared = lines.map(l => l + EL)
2393
- const remaining = state.terminalRows > 0 ? Math.max(0, state.terminalRows - cleared.length) : 0
2394
- for (let i = 0; i < remaining; i++) cleared.push(EL)
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('— press K or Esc to close')}`)
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('P')} Open settings ${chalk.dim('(manage API keys, provider toggles, analytics toggle)')}`)
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
- const cleared = lines.map(l => l + EL)
2429
- const remaining = state.terminalRows > 0 ? Math.max(0, state.terminalRows - cleared.length) : 0
2430
- for (let i = 0; i < remaining; i++) cleared.push(EL)
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: Esc or K closes it handle before everything else so Esc isn't swallowed elsewhere
2470
- if (state.helpVisible && (key.name === 'escape' || key.name === 'k')) {
2471
- state.helpVisible = false
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 < telemetryRowIdx) {
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 = sortResults(visible, state.sortColumn, state.sortDirection)
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 = sortResults(visible, state.sortColumn, state.sortDirection)
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 = sortResults(visible, state.sortColumn, state.sortDirection)
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
- if (state.cursor > 0) {
2680
- state.cursor--
2681
- adjustScrollOffset(state)
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
- if (state.cursor < state.visibleSorted.length - 1) {
2688
- state.cursor++
2689
- adjustScrollOffset(state)
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 = sortResults(visible, state.sortColumn, state.sortDirection)
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 = sortResults(initialVisible, state.sortColumn, state.sortDirection)
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.64",
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