free-coding-models 0.3.5 → 0.3.6

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/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ---
4
4
 
5
+ ## 0.3.6
6
+
7
+ ### Added
8
+ - **AI `/testfcm` workflow**: Added a repo-local PTY runner, workflow doc, slash-command prompts, and artifact/report directories so an agent can drive the real TUI, launch a tool, send `hi`, and write a Markdown bug report with evidence.
9
+ - **Mock tool verification path**: Added a tiny fake `crush` binary plus `test:fcm:mock` so maintainers can validate the TUI → launcher → prompt plumbing even when a real coding tool is not installed locally.
10
+
11
+ ### Fixed
12
+ - **`--json` startup crash**: JSON mode now reuses the same provider-aware ping function as the TUI without crashing on `pingModel is not a function`.
13
+ - **Managed endpoint installs no longer resurrect stale disk entries**: install/refresh saves now replace the tracked `endpointInstalls` snapshot so old provider-tool records from another config state do not leak back into the current catalog set.
14
+ - **Favorites persistence is now much harder to break**: favorite toggles now reload the latest disk config before saving, keep the active profile snapshot in sync, and use atomic config writes so pinned rows no longer disappear after unrelated saves or updates.
15
+ - **API key saves no longer clobber the rest of the config**: editing one provider now persists only that provider against the latest on-disk snapshot, preserves rotated extra keys, and stops stale config writes from wiping other saved keys.
16
+ - **Configured Only no longer hides favorites**: starred rows now stay visible and pinned at the top even when the provider has no currently configured key.
17
+
5
18
  ## 0.3.5
6
19
 
7
20
  ### Fixed
package/README.md CHANGED
@@ -84,6 +84,7 @@ By Vanessa Depraute
84
84
  - **📊 Token usage tracking** — The proxy logs prompt+completion token usage per exact provider/model pair, and the TUI surfaces that history in the `Used` column and the request log overlay.
85
85
  - **📜 Request Log Overlay** — Press `X` to inspect recent proxied requests and token usage for exact provider/model pairs.
86
86
  - **📋 Changelog Overlay** — Press `N` to browse all versions in an index, then `Enter` to view details for any version with full scroll support
87
+ - **🧪 AI end-to-end workflow** — Run the repo-local `/testfcm` flow to drive the TUI in a PTY, launch one tool, send `hi`, and generate a Markdown bug report plus raw artifacts under `task/`
87
88
  - **🛠 MODEL_NOT_FOUND Rotation** — If a specific provider returns a 404 for a model, the TUI intelligently rotates through other available providers for the same model.
88
89
  - **🔄 Auto-retry** — Timeout models keep getting retried, nothing is ever "given up on"
89
90
  - **🎮 Interactive selection** — Navigate with arrow keys directly in the table, press Enter to act
@@ -235,6 +236,26 @@ free-coding-models --opencode --best
235
236
  free-coding-models --tier S --json
236
237
  ```
237
238
 
239
+ ### AI E2E workflow (`/testfcm`)
240
+
241
+ For repo-level validation, this project now ships a repeatable AI-driven manual test flow:
242
+
243
+ - Preferred: `pnpm test:fcm -- --tool crush`
244
+ - Fallback when `pnpm` is unavailable: `npm run test:fcm -- --tool crush`
245
+ - Mock plumbing check: `pnpm test:fcm:mock`
246
+
247
+ What it does:
248
+
249
+ 1. Copies your current `~/.free-coding-models.json` into an isolated HOME
250
+ 2. Runs a `--json` preflight to catch obvious startup regressions
251
+ 3. Starts the real TUI in a PTY via the system `expect` command
252
+ 4. Presses `Enter` like a user to launch the chosen tool
253
+ 5. Sends `hi`
254
+ 6. Captures the response, `request-log.jsonl`, daemon logs, and generated tool config
255
+ 7. Writes a Markdown report to `task/reports/` and raw artifacts to `task/artifacts/`
256
+
257
+ The command workflow is documented in [task/TESTFCM-WORKFLOW.md](task/TESTFCM-WORKFLOW.md). Project-local slash commands are also included at [.claude/commands/testfcm.md](.claude/commands/testfcm.md) and [.crush/commands/testfcm.md](.crush/commands/testfcm.md).
258
+
238
259
  ### Choosing the target tool
239
260
 
240
261
  Running `free-coding-models` with no launcher flag starts in **OpenCode CLI** mode.
@@ -318,7 +339,8 @@ Press **`P`** to open the Settings screen at any time:
318
339
 
319
340
  Manual update is in the same Settings screen (`P`) under **Maintenance** (Enter to check, Enter again to install when an update is available).
320
341
  When a newer npm release is known, the main footer also adds a full-width red warning line with the manual recovery command `npm install -g free-coding-models@latest`.
321
- Favorites are also persisted in the same config file and survive restarts.
342
+ Favorites are also persisted in the same config file and survive restarts, app relaunches, and package updates.
343
+ Favorite rows stay pinned at the top and remain visible even when `Configured Only` mode is enabled.
322
344
  The main table now starts in `Configured Only` mode, so if nothing is set up yet you can press `P` and add your first API key immediately.
323
345
 
324
346
  ### Environment variable overrides
@@ -1073,6 +1095,7 @@ Profiles let you save and restore different TUI configurations — useful if you
1073
1095
  **Managing profiles:**
1074
1096
  - Open Settings (**P** key) — scroll down to the **Profiles** section
1075
1097
  - **Enter** on a profile row to load it
1098
+ - While a profile is active, edits to favorites and API keys update that active profile immediately
1076
1099
  - **Backspace** on a profile row to delete it
1077
1100
 
1078
1101
  Profiles are stored inside `~/.free-coding-models.json` under the `profiles` key.
@@ -99,7 +99,7 @@ import { homedir } from 'os'
99
99
  import { join, dirname } from 'path'
100
100
  import { MODELS, sources } from '../sources.js'
101
101
  import { getAvg, getVerdict, getUptime, getP95, getJitter, getStabilityScore, sortResults, filterByTier, findBestModel, parseArgs, TIER_ORDER, VERDICT_ORDER, TIER_LETTER_MAP, scoreModelForTask, getTopRecommendations, TASK_TYPES, PRIORITY_TYPES, CONTEXT_BUDGETS, formatCtxWindow, labelFromId, getProxyStatusInfo, formatResultsAsJSON } from '../src/utils.js'
102
- import { loadConfig, saveConfig, getApiKey, getProxySettings, resolveApiKeys, addApiKey, removeApiKey, isProviderEnabled, saveAsProfile, loadProfile, listProfiles, deleteProfile, getActiveProfileName, setActiveProfile, _emptyProfileSettings } from '../src/config.js'
102
+ import { loadConfig, saveConfig, getApiKey, getProxySettings, resolveApiKeys, addApiKey, removeApiKey, isProviderEnabled, saveAsProfile, loadProfile, listProfiles, deleteProfile, getActiveProfileName, setActiveProfile, _emptyProfileSettings, persistApiKeysForProvider } from '../src/config.js'
103
103
  import { buildMergedModels } from '../src/model-merger.js'
104
104
  import { ProxyServer } from '../src/proxy-server.js'
105
105
  import { loadOpenCodeConfig, saveOpenCodeConfig, syncToOpenCode, restoreOpenCodeBackup, cleanupOpenCodeProxyConfig } from '../src/opencode-sync.js'
@@ -307,7 +307,10 @@ async function main() {
307
307
  console.error(chalk.red(` Unknown profile "${cliArgs.profileName}". Available: ${listProfiles(config).join(', ') || '(none)'}`))
308
308
  process.exit(1)
309
309
  }
310
- saveConfig(config)
310
+ saveConfig(config, {
311
+ replaceApiKeys: true,
312
+ replaceFavorites: true,
313
+ })
311
314
  }
312
315
 
313
316
  // 📖 Check if any provider has a key — if not, run the first-time setup wizard
@@ -674,6 +677,52 @@ hideUnconfiguredModels: startupProfileSettings?.hideUnconfiguredModels === true
674
677
  }
675
678
  }
676
679
 
680
+ // 📖 Define pingModel before JSON mode so `--json` can reuse the same provider-aware
681
+ // 📖 ping path as the interactive TUI without waiting for the PTY/render loop setup.
682
+ pingModel = async (r) => {
683
+ state.pendingPings += 1
684
+ r.isPinging = true
685
+
686
+ try {
687
+ const providerApiKey = getApiKey(state.config, r.providerKey) ?? null
688
+ const providerUrl = sources[r.providerKey]?.url ?? sources.nvidia.url
689
+ let { code, ms, quotaPercent } = await ping(providerApiKey, r.modelId, r.providerKey, providerUrl)
690
+
691
+ if ((quotaPercent === null || quotaPercent === undefined) && providerApiKey) {
692
+ const providerQuota = await getProviderQuotaPercentCached(r.providerKey, providerApiKey)
693
+ if (typeof providerQuota === 'number' && Number.isFinite(providerQuota)) {
694
+ quotaPercent = providerQuota
695
+ }
696
+ }
697
+
698
+ r.pings.push({ ms, code })
699
+
700
+ if (code === '200') {
701
+ r.status = 'up'
702
+ } else if (code === '000') {
703
+ r.status = 'timeout'
704
+ } else if (code === '401' || code === '403') {
705
+ r.status = providerApiKey ? 'auth_error' : 'noauth'
706
+ r.httpCode = code
707
+ } else {
708
+ r.status = 'down'
709
+ r.httpCode = code
710
+ }
711
+
712
+ if (typeof quotaPercent === 'number' && Number.isFinite(quotaPercent)) {
713
+ r.usagePercent = quotaPercent
714
+ for (const sibling of state.results) {
715
+ if (sibling.providerKey === r.providerKey && (sibling.usagePercent === undefined || sibling.usagePercent === null)) {
716
+ sibling.usagePercent = quotaPercent
717
+ }
718
+ }
719
+ }
720
+ } finally {
721
+ r.isPinging = false
722
+ state.pendingPings = Math.max(0, state.pendingPings - 1)
723
+ }
724
+ }
725
+
677
726
  // 📖 JSON output mode: skip TUI, output results as JSON after initial pings
678
727
  if (cliArgs.jsonMode) {
679
728
  console.log(chalk.cyan(' ⚡ Pinging models for JSON output...'))
@@ -745,16 +794,16 @@ hideUnconfiguredModels: startupProfileSettings?.hideUnconfiguredModels === true
745
794
  const activeTier = TIER_CYCLE[state.tierFilterMode]
746
795
  const activeOrigin = ORIGIN_CYCLE[state.originFilterMode]
747
796
  state.results.forEach(r => {
797
+ // 📖 Favorites stay visible and pinned regardless of configured-only, tier, or provider filters.
798
+ if (r.isFavorite) {
799
+ r.hidden = false
800
+ return
801
+ }
748
802
  const unconfiguredHide = state.hideUnconfiguredModels && !getApiKey(state.config, r.providerKey)
749
803
  if (unconfiguredHide) {
750
804
  r.hidden = true
751
805
  return
752
806
  }
753
- // 📖 Favorites stay visible regardless of tier/origin filters.
754
- if (r.isFavorite) {
755
- r.hidden = false
756
- return
757
- }
758
807
  // 📖 Apply both tier and origin filters — model is hidden if it fails either
759
808
  const tierHide = activeTier !== null && r.tier !== activeTier
760
809
  const originHide = activeOrigin !== null && r.providerKey !== activeOrigin
@@ -826,6 +875,7 @@ hideUnconfiguredModels: startupProfileSettings?.hideUnconfiguredModels === true
826
875
  resolveApiKeys,
827
876
  addApiKey,
828
877
  removeApiKey,
878
+ persistApiKeysForProvider,
829
879
  isProviderEnabled,
830
880
  listProfiles,
831
881
  loadProfile,
@@ -954,60 +1004,6 @@ hideUnconfiguredModels: startupProfileSettings?.hideUnconfiguredModels === true
954
1004
 
955
1005
  // ── Continuous ping loop — ping all models every N seconds forever ──────────
956
1006
 
957
- // 📖 Single ping function that updates result
958
- // 📖 Uses per-provider API key and URL from sources.js
959
- // 📖 If no API key is configured, pings without auth — a 401 still tells us latency + server is up
960
- pingModel = async (r) => {
961
- state.pendingPings += 1
962
- r.isPinging = true
963
-
964
- try {
965
- const providerApiKey = getApiKey(state.config, r.providerKey) ?? null
966
- const providerUrl = sources[r.providerKey]?.url ?? sources.nvidia.url
967
- let { code, ms, quotaPercent } = await ping(providerApiKey, r.modelId, r.providerKey, providerUrl)
968
-
969
- if ((quotaPercent === null || quotaPercent === undefined) && providerApiKey) {
970
- const providerQuota = await getProviderQuotaPercentCached(r.providerKey, providerApiKey)
971
- if (typeof providerQuota === 'number' && Number.isFinite(providerQuota)) {
972
- quotaPercent = providerQuota
973
- }
974
- }
975
-
976
- // 📖 Store ping result as object with ms and code
977
- // 📖 ms = actual response time (even for errors like 429)
978
- // 📖 code = HTTP status code ('200', '429', '500', '000' for timeout)
979
- r.pings.push({ ms, code })
980
-
981
- // 📖 Update status based on latest ping
982
- if (code === '200') {
983
- r.status = 'up'
984
- } else if (code === '000') {
985
- r.status = 'timeout'
986
- } else if (code === '401' || code === '403') {
987
- // 📖 Distinguish "no key configured" from "configured key rejected" so the
988
- // 📖 Health column stays honest when Configured Only mode is enabled.
989
- r.status = providerApiKey ? 'auth_error' : 'noauth'
990
- r.httpCode = code
991
- } else {
992
- r.status = 'down'
993
- r.httpCode = code
994
- }
995
-
996
- if (typeof quotaPercent === 'number' && Number.isFinite(quotaPercent)) {
997
- r.usagePercent = quotaPercent
998
- // Provider-level fallback: apply latest known quota to sibling rows on same provider.
999
- for (const sibling of state.results) {
1000
- if (sibling.providerKey === r.providerKey && (sibling.usagePercent === undefined || sibling.usagePercent === null)) {
1001
- sibling.usagePercent = quotaPercent
1002
- }
1003
- }
1004
- }
1005
- } finally {
1006
- r.isPinging = false
1007
- state.pendingPings = Math.max(0, state.pendingPings - 1)
1008
- }
1009
- }
1010
-
1011
1007
  // 📖 Initial ping of all models
1012
1008
  const initialPing = Promise.all(state.results.map(r => pingModel(r)))
1013
1009
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
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",
@@ -51,7 +51,9 @@
51
51
  ],
52
52
  "scripts": {
53
53
  "start": "node bin/free-coding-models.js",
54
- "test": "node --test test/test.js"
54
+ "test": "node --test test/test.js",
55
+ "test:fcm": "node scripts/testfcm-runner.mjs",
56
+ "test:fcm:mock": "node scripts/testfcm-runner.mjs --tool crush --tool-bin-dir test/fixtures/mock-bin"
55
57
  },
56
58
  "dependencies": {
57
59
  "chalk": "^5.4.1"