free-coding-models 0.2.0 β†’ 0.2.2

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
@@ -84,6 +84,7 @@
84
84
  - **πŸ’» OpenCode integration** β€” Auto-detects NIM setup, sets model as default, launches OpenCode
85
85
  - **🦞 OpenClaw integration** β€” Sets selected model as default provider in `~/.openclaw/openclaw.json`
86
86
  - **🧰 Public tool launchers** β€” `Enter` can auto-configure and launch `OpenCode CLI`, `OpenCode Desktop`, `OpenClaw`, `Crush`, and `Goose`
87
+ - **πŸ”Œ Install Endpoints flow** β€” Press `Y` to install one configured provider directly into `OpenCode CLI`, `OpenCode Desktop`, `OpenClaw`, `Crush`, or `Goose`, either with the full provider catalog or a curated subset of models
87
88
  - **πŸ“ Feature Request (J key)** β€” Send anonymous feedback directly to the project team
88
89
  - **πŸ› Bug Report (I key)** β€” Send anonymous bug reports directly to the project team
89
90
  - **🎨 Clean output** β€” Zero scrollback pollution, interface stays open until Ctrl+C
@@ -446,7 +447,7 @@ The main table displays one row per model with the following columns:
446
447
  | Column | Sort key | Description |
447
448
  |--------|----------|-------------|
448
449
  | **Rank** | `R` | Position based on current sort order (medals for top 3: πŸ₯‡πŸ₯ˆπŸ₯‰) |
449
- | **Tier** | `Y` | SWE-bench tier (S+, S, A+, A, A-, B+, B, C) |
450
+ | **Tier** | β€” | SWE-bench tier (S+, S, A+, A, A-, B+, B, C) |
450
451
  | **SWE%** | `S` | SWE-bench Verified score β€” industry-standard for coding |
451
452
  | **CTX** | `C` | Context window size (e.g. `128k`) |
452
453
  | **Model** | `M` | Model display name (favorites show ⭐ prefix) |
@@ -844,7 +845,7 @@ This script:
844
845
  **Keyboard shortcuts (main TUI):**
845
846
  - **↑↓** β€” Navigate models
846
847
  - **Enter** β€” Select model and launch the current target tool from the header badge
847
- - **R/Y/S/C/M/O/L/A/H/V/B/U/G** β€” Sort by Rank/Tier/SWE/Ctx/Model/Provider/Latest/Avg/Health/Verdict/Stability/Up%/Usage
848
+ - **R/S/C/M/O/L/A/H/V/B/U/G** β€” Sort by Rank/SWE/Ctx/Model/Provider/Latest/Avg/Health/Verdict/Stability/Up%/Usage
848
849
  - **F** β€” Toggle favorite on selected model (⭐ in Model column, pinned at top)
849
850
  - **T** β€” Cycle tier filter (All β†’ S+ β†’ S β†’ A+ β†’ A β†’ A- β†’ B+ β†’ B β†’ C β†’ All)
850
851
  - **D** β€” Cycle provider filter (All β†’ NIM β†’ Groq β†’ ...)
@@ -852,6 +853,7 @@ This script:
852
853
  - **Z** β€” Cycle target tool (OpenCode CLI β†’ OpenCode Desktop β†’ OpenClaw β†’ Crush β†’ Goose)
853
854
  - **X** β€” Toggle request logs (recent proxied request/token usage logs)
854
855
  - **P** β€” Open Settings (manage API keys, toggles, updates, profiles)
856
+ - **Y** β€” Open Install Endpoints (`provider β†’ tool β†’ all models` or `selected models only`, no proxy)
855
857
  - **Shift+P** β€” Cycle through saved profiles (switches live TUI settings)
856
858
  - **Shift+S** β€” Save current TUI settings as a named profile (inline prompt)
857
859
  - **Q** β€” Open Smart Recommend overlay (find the best model for your task)
@@ -862,6 +864,21 @@ This script:
862
864
 
863
865
  Pressing **K** now shows a full in-app reference: main hotkeys, settings hotkeys, and CLI flags with usage examples.
864
866
 
867
+ ### πŸ”Œ Install Endpoints (`Y`)
868
+
869
+ `Y` opens a dedicated install flow for configured providers. The flow is:
870
+
871
+ 1. Pick one provider that already has an API key in Settings
872
+ 2. Pick the target tool: `OpenCode CLI`, `OpenCode Desktop`, `OpenClaw`, `Crush`, or `Goose`
873
+ 3. Choose either `Install all models` or `Install selected models only`
874
+
875
+ Important behavior:
876
+
877
+ - Installs are written directly into the target tool config as FCM-managed entries, without going through `fcm-proxy`
878
+ - `Install all models` is the recommended path because FCM can refresh that catalog automatically on later launches when the provider model list changes
879
+ - `Install selected models only` is useful when you want a smaller curated picker inside the target tool
880
+ - `OpenCode CLI` and `OpenCode Desktop` share the same `opencode.json`, so the managed provider appears in both
881
+
865
882
  **Keyboard shortcuts (Settings screen β€” `P` key):**
866
883
  - **↑↓** β€” Navigate providers, maintenance row, and profile rows
867
884
  - **Enter** β€” Edit API key inline, check/install update, or load a profile
@@ -20,10 +20,11 @@
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, provider toggles, and manual updates
23
+ * - Settings screen (P key) to manage API keys, provider toggles, manual updates, and provider-key diagnostics
24
+ * - Install Endpoints flow (Y key) to push provider catalogs into OpenCode, OpenClaw, Crush, and Goose
24
25
  * - Favorites system: toggle with F, pin rows to top, persist between sessions
25
26
  * - Uptime percentage tracking (successful pings / total pings)
26
- * - Sortable columns (R/Y/O/M/L/A/S/N/H/V/B/U keys)
27
+ * - Sortable columns (R/O/M/L/A/S/C/H/V/B/U/G keys)
27
28
  * - Tier filtering via T key (cycles S+β†’Sβ†’A+β†’Aβ†’A-β†’B+β†’Bβ†’Cβ†’All)
28
29
  *
29
30
  * β†’ Functions:
@@ -118,6 +119,7 @@ import { createOverlayRenderers } from '../src/overlays.js'
118
119
  import { createKeyHandler } from '../src/key-handler.js'
119
120
  import { getToolModeOrder } from '../src/tool-metadata.js'
120
121
  import { startExternalTool } from '../src/tool-launchers.js'
122
+ import { getConfiguredInstallableProviders, installProviderEndpoints, refreshInstalledEndpoints, getInstallTargetModes, getProviderCatalogModels } from '../src/endpoint-installer.js'
121
123
 
122
124
  // πŸ“– mergedModels: cross-provider grouped model list (one entry per label, N providers each)
123
125
  // πŸ“– mergedModelByLabel: fast lookup map from display label β†’ merged model entry
@@ -216,9 +218,11 @@ async function main() {
216
218
  // πŸ“– Backward-compat: keep apiKey var for startOpenClaw() which still needs it
217
219
  let apiKey = getApiKey(config, 'nvidia')
218
220
 
219
- // πŸ“– Default mode: OpenCode CLI.
220
- // πŸ“– Additional external tools can now be selected via dedicated flags.
221
- let mode = 'opencode'
221
+ // πŸ“– Default mode: use the last persisted launcher choice when valid,
222
+ // πŸ“– otherwise fall back to OpenCode CLI.
223
+ let mode = getToolModeOrder().includes(config.settings?.preferredToolMode)
224
+ ? config.settings.preferredToolMode
225
+ : 'opencode'
222
226
  const requestedMode = getToolModeOrder().find((toolMode) => {
223
227
  const flagByMode = {
224
228
  opencode: cliArgs.openCodeMode,
@@ -308,6 +312,10 @@ async function main() {
308
312
  console.log(chalk.yellow(' OpenRouter: using cached model list (live fetch failed)'))
309
313
  }
310
314
 
315
+ // πŸ“– Re-sync tracked external-tool catalogs after the live provider catalog has settled.
316
+ // πŸ“– This keeps prior `Y` installs aligned with the current FCM model list.
317
+ refreshInstalledEndpoints(config)
318
+
311
319
  // πŸ“– Build results from MODELS β€” only include enabled providers
312
320
  // πŸ“– Each result gets providerKey so ping() knows which URL + API key to use
313
321
 
@@ -391,7 +399,8 @@ async function main() {
391
399
  settingsAddKeyMode: false, // πŸ“– Whether we're in add-key mode (append a new key to provider)
392
400
  settingsEditBuffer: '', // πŸ“– Typed characters for the API key being edited
393
401
  settingsErrorMsg: null, // πŸ“– Temporary error message to display in settings
394
- settingsTestResults: {}, // πŸ“– { providerKey: 'pending'|'ok'|'fail'|null }
402
+ settingsTestResults: {}, // πŸ“– { providerKey: 'pending'|'ok'|'auth_error'|'rate_limited'|'no_callable_model'|'fail'|'missing_key'|null }
403
+ settingsTestDetails: {}, // πŸ“– Long-form diagnostics shown under Setup Instructions after a Settings key test.
395
404
  settingsUpdateState: 'idle', // πŸ“– 'idle'|'checking'|'available'|'up-to-date'|'error'|'installing'
396
405
  settingsUpdateLatestVersion: null, // πŸ“– Latest npm version discovered from manual check
397
406
  settingsUpdateError: null, // πŸ“– Last update-check error message for maintenance row
@@ -402,6 +411,17 @@ async function main() {
402
411
  helpVisible: false, // πŸ“– Whether the help overlay (K key) is active
403
412
  settingsScrollOffset: 0, // πŸ“– Vertical scroll offset for Settings overlay viewport
404
413
  helpScrollOffset: 0, // πŸ“– Vertical scroll offset for Help overlay viewport
414
+ // πŸ“– Install Endpoints overlay state (Y key opens it)
415
+ installEndpointsOpen: false, // πŸ“– Whether the install-endpoints overlay is active
416
+ installEndpointsPhase: 'providers', // πŸ“– providers | tools | scope | models | result
417
+ installEndpointsCursor: 0, // πŸ“– Selected row within the current install phase
418
+ installEndpointsScrollOffset: 0, // πŸ“– Vertical scroll offset for the install overlay viewport
419
+ installEndpointsProviderKey: null, // πŸ“– Selected provider for endpoint installation
420
+ installEndpointsToolMode: null, // πŸ“– Selected target tool mode
421
+ installEndpointsScope: null, // πŸ“– all | selected
422
+ installEndpointsSelectedModelIds: new Set(), // πŸ“– Multi-select buffer for the selected-models phase
423
+ installEndpointsErrorMsg: null, // πŸ“– Temporary validation/error message inside the install flow
424
+ installEndpointsResult: null, // πŸ“– Final install result shown in the result phase
405
425
  // πŸ“– Smart Recommend overlay state (Q key opens it)
406
426
  recommendOpen: false, // πŸ“– Whether the recommend overlay is active
407
427
  recommendPhase: 'questionnaire', // πŸ“– 'questionnaire'|'analyzing'|'results' β€” current phase
@@ -593,7 +613,10 @@ async function main() {
593
613
  toFavoriteKey,
594
614
  getTopRecommendations,
595
615
  adjustScrollOffset,
596
- getPingModel: () => pingModel
616
+ getPingModel: () => pingModel,
617
+ getConfiguredInstallableProviders,
618
+ getInstallTargetModes,
619
+ getProviderCatalogModels,
597
620
  })
598
621
 
599
622
  onKeyPress = createKeyHandler({
@@ -614,6 +637,10 @@ async function main() {
614
637
  saveAsProfile,
615
638
  setActiveProfile,
616
639
  saveConfig,
640
+ getConfiguredInstallableProviders,
641
+ getInstallTargetModes,
642
+ getProviderCatalogModels,
643
+ installProviderEndpoints,
617
644
  syncFavoriteFlags,
618
645
  toggleFavoriteModel,
619
646
  sortResultsWithPinnedFavorites,
@@ -687,12 +714,14 @@ async function main() {
687
714
  refreshAutoPingMode()
688
715
  state.frame++
689
716
  // πŸ“– Cache visible+sorted models each frame so Enter handler always matches the display
690
- if (!state.settingsOpen && !state.recommendOpen && !state.featureRequestOpen && !state.bugReportOpen) {
717
+ if (!state.settingsOpen && !state.installEndpointsOpen && !state.recommendOpen && !state.featureRequestOpen && !state.bugReportOpen) {
691
718
  const visible = state.results.filter(r => !r.hidden)
692
719
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
693
720
  }
694
721
  const content = state.settingsOpen
695
722
  ? overlays.renderSettings()
723
+ : state.installEndpointsOpen
724
+ ? overlays.renderInstallEndpoints()
696
725
  : state.recommendOpen
697
726
  ? overlays.renderRecommend()
698
727
  : state.featureRequestOpen
@@ -703,7 +732,7 @@ async function main() {
703
732
  ? overlays.renderHelp()
704
733
  : state.logVisible
705
734
  ? overlays.renderLog()
706
- : renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.terminalCols, state.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels, state.widthWarningStartedAt, state.widthWarningDismissed)
735
+ : renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.terminalCols, state.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels, state.widthWarningStartedAt, state.widthWarningDismissed, state.settingsUpdateState, state.settingsUpdateLatestVersion, getProxySettings(state.config).enabled === true)
707
736
  process.stdout.write(ALT_HOME + content)
708
737
  }, Math.round(1000 / FPS))
709
738
 
@@ -711,7 +740,7 @@ async function main() {
711
740
  const initialVisible = state.results.filter(r => !r.hidden)
712
741
  state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection)
713
742
 
714
- process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.terminalCols, state.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels, state.widthWarningStartedAt, state.widthWarningDismissed))
743
+ process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.terminalCols, state.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels, state.widthWarningStartedAt, state.widthWarningDismissed, state.settingsUpdateState, state.settingsUpdateLatestVersion, getProxySettings(state.config).enabled === true))
715
744
 
716
745
  // πŸ“– If --recommend was passed, auto-open the Smart Recommend overlay on start
717
746
  if (cliArgs.recommendMode) {
@@ -756,10 +785,10 @@ async function main() {
756
785
  r.status = 'up'
757
786
  } else if (code === '000') {
758
787
  r.status = 'timeout'
759
- } else if (code === '401') {
760
- // πŸ“– 401 = server is reachable but no API key set (or wrong key)
761
- // πŸ“– Treated as 'noauth' β€” server is UP, latency is real, just needs a key
762
- r.status = 'noauth'
788
+ } else if (code === '401' || code === '403') {
789
+ // πŸ“– Distinguish "no key configured" from "configured key rejected" so the
790
+ // πŸ“– Health column stays honest when Configured Only mode is enabled.
791
+ r.status = providerApiKey ? 'auth_error' : 'noauth'
763
792
  r.httpCode = code
764
793
  } else {
765
794
  r.status = 'down'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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/src/config.js CHANGED
@@ -64,14 +64,17 @@
64
64
  * "work": { "apiKeys": {...}, "providers": {...}, "favorites": [...], "settings": {...} },
65
65
  * "personal": { "apiKeys": {...}, "providers": {...}, "favorites": [...], "settings": {...} },
66
66
  * "fast": { "apiKeys": {...}, "providers": {...}, "favorites": [...], "settings": {...} }
67
- * }
67
+ * },
68
+ * "endpointInstalls": [
69
+ * { "providerKey": "nvidia", "toolMode": "opencode", "scope": "all", "modelIds": [], "lastSyncedAt": "2026-03-09T10:00:00.000Z" }
70
+ * ]
68
71
  * }
69
72
  *
70
73
  * πŸ“– Profiles store a snapshot of the user's configuration. Each profile contains:
71
74
  * - apiKeys: API keys per provider (can differ between work/personal setups)
72
75
  * - providers: enabled/disabled state per provider
73
76
  * - favorites: list of pinned favorite models
74
- * - settings: extra TUI preferences (tierFilter, sortColumn, sortAsc, pingInterval, hideUnconfiguredModels, proxy)
77
+ * - settings: extra TUI preferences (tierFilter, sortColumn, sortAsc, pingInterval, hideUnconfiguredModels, preferredToolMode, proxy)
75
78
  *
76
79
  * πŸ“– When a profile is loaded via --profile <name> or Shift+P, the main config's
77
80
  * apiKeys/providers/favorites are replaced with the profile's values. The profile
@@ -97,11 +100,12 @@
97
100
  * β†’ setActiveProfile(config, name) β€” Set which profile is active (null to clear)
98
101
  * β†’ _emptyProfileSettings() β€” Default TUI settings for a profile
99
102
  * β†’ getProxySettings(config) β€” Return normalized proxy settings from config
103
+ * β†’ normalizeEndpointInstalls(endpointInstalls) β€” Keep tracked endpoint installs stable across app versions
100
104
  *
101
105
  * @exports loadConfig, saveConfig, getApiKey, isProviderEnabled
102
106
  * @exports addApiKey, removeApiKey, listApiKeys β€” multi-key management helpers
103
107
  * @exports saveAsProfile, loadProfile, listProfiles, deleteProfile
104
- * @exports getActiveProfileName, setActiveProfile, getProxySettings
108
+ * @exports getActiveProfileName, setActiveProfile, getProxySettings, normalizeEndpointInstalls
105
109
  * @exports CONFIG_PATH β€” path to the JSON config file
106
110
  *
107
111
  * @see bin/free-coding-models.js β€” main CLI that uses these functions
@@ -175,6 +179,7 @@ export function loadConfig() {
175
179
  if (typeof parsed.telemetry.enabled !== 'boolean') parsed.telemetry.enabled = null
176
180
  if (typeof parsed.telemetry.consentVersion !== 'number') parsed.telemetry.consentVersion = 0
177
181
  if (typeof parsed.telemetry.anonymousId !== 'string' || !parsed.telemetry.anonymousId.trim()) parsed.telemetry.anonymousId = null
182
+ parsed.endpointInstalls = normalizeEndpointInstalls(parsed.endpointInstalls)
178
183
  // πŸ“– Ensure profiles section exists (added in profile system)
179
184
  if (!parsed.profiles || typeof parsed.profiles !== 'object') parsed.profiles = {}
180
185
  for (const profile of Object.values(parsed.profiles)) {
@@ -395,7 +400,7 @@ export function isProviderEnabled(config, providerKey) {
395
400
  * πŸ“– These settings are saved/restored when switching profiles so each profile
396
401
  * can have different sort, filter, and ping preferences.
397
402
  *
398
- * @returns {{ tierFilter: string|null, sortColumn: string, sortAsc: boolean, pingInterval: number, hideUnconfiguredModels: boolean }}
403
+ * @returns {{ tierFilter: string|null, sortColumn: string, sortAsc: boolean, pingInterval: number, hideUnconfiguredModels: boolean, preferredToolMode: string }}
399
404
  */
400
405
  export function _emptyProfileSettings() {
401
406
  return {
@@ -404,6 +409,7 @@ export function _emptyProfileSettings() {
404
409
  sortAsc: true, // πŸ“– true = ascending (fastest first for latency)
405
410
  pingInterval: 10000, // πŸ“– default ms between pings in the steady "normal" mode
406
411
  hideUnconfiguredModels: true, // πŸ“– true = default to providers that are actually configured
412
+ preferredToolMode: 'opencode', // πŸ“– remember the last Z-selected launcher across app restarts
407
413
  proxy: normalizeProxySettings(),
408
414
  }
409
415
  }
@@ -438,6 +444,39 @@ export function getProxySettings(config) {
438
444
  return normalizeProxySettings(config?.settings?.proxy)
439
445
  }
440
446
 
447
+ /**
448
+ * πŸ“– normalizeEndpointInstalls keeps the endpoint-install tracking list safe to replay.
449
+ *
450
+ * πŸ“– Each entry represents one managed catalog install performed through the `Y` flow:
451
+ * - `providerKey`: FCM provider identifier (`nvidia`, `groq`, ...)
452
+ * - `toolMode`: canonical tool id (`opencode`, `openclaw`, `crush`, `goose`)
453
+ * - `scope`: `all` or `selected`
454
+ * - `modelIds`: only used when `scope === 'selected'`
455
+ * - `lastSyncedAt`: informational timestamp updated on successful refresh
456
+ *
457
+ * @param {unknown} endpointInstalls
458
+ * @returns {{ providerKey: string, toolMode: string, scope: 'all'|'selected', modelIds: string[], lastSyncedAt: string | null }[]}
459
+ */
460
+ export function normalizeEndpointInstalls(endpointInstalls) {
461
+ if (!Array.isArray(endpointInstalls)) return []
462
+ return endpointInstalls
463
+ .map((entry) => {
464
+ if (!entry || typeof entry !== 'object') return null
465
+ const providerKey = typeof entry.providerKey === 'string' ? entry.providerKey.trim() : ''
466
+ const toolMode = typeof entry.toolMode === 'string' ? entry.toolMode.trim() : ''
467
+ if (!providerKey || !toolMode) return null
468
+ const scope = entry.scope === 'selected' ? 'selected' : 'all'
469
+ const modelIds = Array.isArray(entry.modelIds)
470
+ ? [...new Set(entry.modelIds.filter((modelId) => typeof modelId === 'string' && modelId.trim().length > 0))]
471
+ : []
472
+ const lastSyncedAt = typeof entry.lastSyncedAt === 'string' && entry.lastSyncedAt.trim().length > 0
473
+ ? entry.lastSyncedAt
474
+ : null
475
+ return { providerKey, toolMode, scope, modelIds, lastSyncedAt }
476
+ })
477
+ .filter(Boolean)
478
+ }
479
+
441
480
  /**
442
481
  * πŸ“– saveAsProfile: Snapshot the current config state into a named profile.
443
482
  *
@@ -562,6 +601,8 @@ function _emptyConfig() {
562
601
  consentVersion: 0,
563
602
  anonymousId: null,
564
603
  },
604
+ // πŸ“– Tracked `Y` installs β€” used to refresh external tool catalogs automatically.
605
+ endpointInstalls: [],
565
606
  // πŸ“– Active profile name β€” null means no profile is loaded (using raw config).
566
607
  activeProfile: null,
567
608
  // πŸ“– Named profiles: each is a snapshot of apiKeys + providers + favorites + settings.