free-coding-models 0.3.9 → 0.3.11

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,27 @@
2
2
 
3
3
  ---
4
4
 
5
+ ## 0.3.11
6
+
7
+ ### Fixed
8
+ - Added early 404 response when a requested model has no registered accounts, ensuring clear error handling.
9
+
10
+ ### Removed
11
+ - **Profile system**: Entire profile system removed to ensure API keys persist permanently across all sessions. No more profile switching causing API key loss.
12
+
13
+ ### Added
14
+ - **`--proxy` foreground mode**: New `--proxy` flag starts FCM Proxy V2 in the current terminal with a live dashboard showing status, accounts, provider breakdown, and real-time request log. No daemon install needed — works from dev checkout too.
15
+
16
+ ### Fixed
17
+ - **Claude Code proxy auth**: Proxy now accepts `x-api-key` header (used by Anthropic SDK / Claude Code) in addition to `Authorization: Bearer`
18
+ - **Claude model fallback**: When `anthropicRouting` is all null, Claude model names now fall back to the first available account instead of returning "Model not found"
19
+ - **Proxy sync for Claude Code**: Added `claude-code` to `PROXY_SYNCABLE_TOOLS` so the env file is properly written/updated by proxy sync
20
+ - **Correct env var name**: Claude Code env file now exports `ANTHROPIC_API_KEY` (SDK standard) instead of `ANTHROPIC_AUTH_TOKEN`
21
+ - **Auto-source shell profile**: Claude Code env file is now automatically sourced in `.zshrc` / `.bashrc` / `.bash_profile`
22
+ - **Removed deleted profile functions from tests**: Cleaned up test imports after profile system removal from config.js
23
+
24
+ ---
25
+
5
26
  ## 0.3.9
6
27
 
7
28
  ### Improved
@@ -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, persistApiKeysForProvider } from '../src/config.js'
102
+ import { loadConfig, saveConfig, getApiKey, getProxySettings, resolveApiKeys, addApiKey, removeApiKey, isProviderEnabled, 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'
@@ -126,6 +126,7 @@ import { createOverlayRenderers } from '../src/overlays.js'
126
126
  import { createKeyHandler } from '../src/key-handler.js'
127
127
  import { getToolModeOrder, getToolMeta } from '../src/tool-metadata.js'
128
128
  import { startExternalTool } from '../src/tool-launchers.js'
129
+ import { startForegroundProxy } from '../src/proxy-foreground.js'
129
130
  import { getConfiguredInstallableProviders, installProviderEndpoints, refreshInstalledEndpoints, getInstallTargetModes, getProviderCatalogModels, CONNECTION_MODES } from '../src/endpoint-installer.js'
130
131
  import { loadCache, saveCache, clearCache, getCacheAge } from '../src/cache.js'
131
132
  import { checkConfigSecurity } from '../src/security.js'
@@ -229,6 +230,12 @@ async function main() {
229
230
  process.exit(0)
230
231
  }
231
232
 
233
+ // 📖 Foreground proxy mode — starts the proxy in the current terminal with live dashboard
234
+ if (cliArgs.proxyForegroundMode) {
235
+ await startForegroundProxy(config, chalk)
236
+ return // 📖 startForegroundProxy keeps the process alive via signal handlers
237
+ }
238
+
232
239
  // 📖 CLI subcommand: free-coding-models daemon <action>
233
240
  const daemonSubcmd = process.argv[2] === 'daemon' ? (process.argv[3] || 'status') : null
234
241
  if (daemonSubcmd) {
@@ -315,19 +322,7 @@ async function main() {
315
322
  process.exit(1)
316
323
  }
317
324
 
318
- // 📖 If --profile <name> was passed, load that profile into the live config
319
- let startupProfileSettings = null
320
- if (cliArgs.profileName) {
321
- startupProfileSettings = loadProfile(config, cliArgs.profileName)
322
- if (!startupProfileSettings) {
323
- console.error(chalk.red(` Unknown profile "${cliArgs.profileName}". Available: ${listProfiles(config).join(', ') || '(none)'}`))
324
- process.exit(1)
325
- }
326
- saveConfig(config, {
327
- replaceApiKeys: true,
328
- replaceFavorites: true,
329
- })
330
- }
325
+ // 📖 Profile system removed - API keys now persist permanently across all sessions
331
326
 
332
327
  // 📖 Check if any provider has a key — if not, run the first-time setup wizard
333
328
  const hasAnyKey = Object.keys(sources).some(pk => !!getApiKey(config, pk))
@@ -494,15 +489,15 @@ async function main() {
494
489
  return 'normal'
495
490
  }
496
491
 
497
- // 📖 tierFilter: current tier filter letter (null = all, 'S' = S+/S, 'A' = A+/A/A-, etc.)
492
+ // 📖 tierFilter: current tier filter letter (null = all, 'S' = S+/S, 'A' = A+/A/A-, etc.)
498
493
  const state = {
499
494
  results,
500
495
  pendingPings: 0,
501
496
  frame: 0,
502
497
  cursor: 0,
503
498
  selectedModel: null,
504
- sortColumn: startupProfileSettings?.sortColumn ?? config.settings?.sortColumn ?? 'avg',
505
- sortDirection: (startupProfileSettings?.sortAsc ?? config.settings?.sortAsc ?? true) ? 'asc' : 'desc',
499
+ sortColumn: config.settings?.sortColumn ?? 'avg',
500
+ sortDirection: (config.settings?.sortAsc ?? true) ? 'asc' : 'desc',
506
501
  pingInterval: PING_MODE_INTERVALS.speed, // 📖 Effective live interval derived from the active ping mode.
507
502
  pingMode: 'speed', // 📖 Current ping mode: speed | normal | slow | forced.
508
503
  pingModeSource: 'startup', // 📖 Why this mode is active: startup | manual | auto | idle | activity.
@@ -516,7 +511,7 @@ async function main() {
516
511
  tierFilterMode: 0, // 📖 Index into TIER_CYCLE (0=All, 1=S+, 2=S, ...)
517
512
  originFilterMode: 0, // 📖 Index into ORIGIN_CYCLE (0=All, then providers)
518
513
  premiumMode: cliArgs.premiumMode, // 📖 Special elite-only mode: S/S+ only, Health UP only, Perfect/Normal/Slow verdict only.
519
- hideUnconfiguredModels: startupProfileSettings?.hideUnconfiguredModels === true || config.settings?.hideUnconfiguredModels === true, // 📖 Hide providers with no configured API key when true.
514
+ hideUnconfiguredModels: config.settings?.hideUnconfiguredModels === true, // 📖 Hide providers with no configured API key when true.
520
515
  disableWidthsWarning: config.settings?.disableWidthsWarning ?? false, // 📖 Disable widths warning toggle (default off)
521
516
  scrollOffset: 0, // 📖 First visible model index in viewport
522
517
  terminalRows: process.stdout.rows || 24, // 📖 Current terminal height
@@ -574,10 +569,6 @@ async function main() {
574
569
  recommendAnalysisTimer: null, // 📖 setInterval handle for the 10s analysis phase
575
570
  recommendPingTimer: null, // 📖 setInterval handle for 2 pings/sec during analysis
576
571
  recommendedKeys: new Set(), // 📖 Set of "providerKey/modelId" for recommended models (shown in main table)
577
- // 📖 Config Profiles state
578
- activeProfile: getActiveProfileName(config), // 📖 Currently loaded profile name (or null)
579
- profileSaveMode: false, // 📖 Whether the inline "Save profile" name input is active
580
- profileSaveBuffer: '', // 📖 Typed characters for the profile name being saved
581
572
  // 📖 Feedback state (J/I keys open it)
582
573
  feedbackOpen: false, // 📖 Whether the feedback overlay is active
583
574
  bugReportBuffer: '', // 📖 Typed characters for the feedback message
@@ -798,6 +789,9 @@ async function main() {
798
789
 
799
790
  // 📖 Enter alternate screen — animation runs here, zero scrollback pollution
800
791
  process.stdout.write(ALT_ENTER)
792
+ if (process.stdout.isTTY) {
793
+ process.stdout.flush && process.stdout.flush()
794
+ }
801
795
 
802
796
  // 📖 Ensure we always leave alt screen cleanly (Ctrl+C, crash, normal exit)
803
797
  const exit = (code = 0) => {
@@ -806,6 +800,9 @@ async function main() {
806
800
  clearInterval(ticker)
807
801
  clearTimeout(state.pingIntervalObj)
808
802
  process.stdout.write(ALT_LEAVE)
803
+ if (process.stdout.isTTY) {
804
+ process.stdout.flush && process.stdout.flush()
805
+ }
809
806
  process.exit(code)
810
807
  }
811
808
  process.on('SIGINT', () => exit(0))
@@ -813,7 +810,7 @@ async function main() {
813
810
 
814
811
  // 📖 originFilterMode: index into ORIGIN_CYCLE, 0=All, then each provider key in order
815
812
  const ORIGIN_CYCLE = [null, ...Object.keys(sources)]
816
- const resolvedTierFilter = startupProfileSettings?.tierFilter ?? config.settings?.tierFilter
813
+ const resolvedTierFilter = config.settings?.tierFilter
817
814
  state.tierFilterMode = resolvedTierFilter ? Math.max(0, TIER_CYCLE.indexOf(resolvedTierFilter)) : 0
818
815
  const resolvedOriginFilter = config.settings?.originFilter
819
816
  state.originFilterMode = resolvedOriginFilter ? Math.max(0, ORIGIN_CYCLE.indexOf(resolvedOriginFilter)) : 0
@@ -865,6 +862,9 @@ async function main() {
865
862
  if (process.stdin.isTTY && resetRawMode) process.stdin.setRawMode(false)
866
863
  process.stdin.pause()
867
864
  process.stdout.write(ALT_LEAVE)
865
+ if (process.stdout.isTTY) {
866
+ process.stdout.flush && process.stdout.flush()
867
+ }
868
868
  }
869
869
 
870
870
  const overlays = createOverlayRenderers(state, {
@@ -877,7 +877,6 @@ async function main() {
877
877
  getProxySettings,
878
878
  resolveApiKeys,
879
879
  isProviderEnabled,
880
- listProfiles,
881
880
  TIER_CYCLE,
882
881
  SETTINGS_OVERLAY_BG,
883
882
  HELP_OVERLAY_BG,
@@ -919,11 +918,6 @@ async function main() {
919
918
  removeApiKey,
920
919
  persistApiKeysForProvider,
921
920
  isProviderEnabled,
922
- listProfiles,
923
- loadProfile,
924
- deleteProfile,
925
- saveAsProfile,
926
- setActiveProfile,
927
921
  saveConfig,
928
922
  getConfiguredInstallableProviders,
929
923
  getInstallTargetModes,
@@ -1022,15 +1016,21 @@ async function main() {
1022
1016
  ? overlays.renderLog()
1023
1017
  : state.changelogOpen
1024
1018
  ? overlays.renderChangelog()
1025
- : 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.widthWarningShowCount, state.settingsUpdateState, state.settingsUpdateLatestVersion, getProxySettings(state.config).enabled === true, state.startupLatestVersion, state.versionAlertsEnabled)
1019
+ : 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.proxyStartupStatus, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels, state.widthWarningStartedAt, state.widthWarningDismissed, state.widthWarningShowCount, state.settingsUpdateState, state.settingsUpdateLatestVersion, getProxySettings(state.config).enabled === true, state.startupLatestVersion, state.versionAlertsEnabled)
1026
1020
  process.stdout.write(ALT_HOME + content)
1021
+ if (process.stdout.isTTY) {
1022
+ process.stdout.flush && process.stdout.flush()
1023
+ }
1027
1024
  }, Math.round(1000 / FPS))
1028
1025
 
1029
1026
  // 📖 Populate visibleSorted before the first frame so Enter works immediately
1030
1027
  const initialVisible = state.results.filter(r => !r.hidden)
1031
1028
  state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection)
1032
1029
 
1033
- 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.widthWarningShowCount, state.settingsUpdateState, state.settingsUpdateLatestVersion, getProxySettings(state.config).enabled === true, state.startupLatestVersion, state.versionAlertsEnabled))
1030
+ 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.proxyStartupStatus, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels, state.widthWarningStartedAt, state.widthWarningDismissed, state.widthWarningShowCount, state.settingsUpdateState, state.settingsUpdateLatestVersion, getProxySettings(state.config).enabled === true, state.startupLatestVersion, state.versionAlertsEnabled))
1031
+ if (process.stdout.isTTY) {
1032
+ process.stdout.flush && process.stdout.flush()
1033
+ }
1034
1034
 
1035
1035
  // 📖 If --recommend was passed, auto-open the Smart Recommend overlay on start
1036
1036
  if (cliArgs.recommendMode) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
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/cli-help.js CHANGED
@@ -37,7 +37,7 @@ const ANALYSIS_FLAGS = [
37
37
  ]
38
38
 
39
39
  const CONFIG_FLAGS = [
40
- { flag: '--profile <name>', description: 'Load a saved config profile before startup' },
40
+ { flag: '--proxy', description: 'Start FCM Proxy V2 in foreground with live dashboard (no daemon)' },
41
41
  { flag: '--no-telemetry', description: 'Disable anonymous telemetry for this run' },
42
42
  { flag: '--clean-proxy, --proxy-clean', description: 'Remove persisted fcm-proxy config from OpenCode' },
43
43
  { flag: '--help, -h', description: 'Print this help and exit' },
package/src/config.js CHANGED
@@ -59,7 +59,7 @@
59
59
  * "consentVersion": 1,
60
60
  * "anonymousId": "anon_550e8400-e29b-41d4-a716-446655440000"
61
61
  * },
62
- * "activeProfile": "work",
62
+
63
63
  * "profiles": {
64
64
  * "work": { "apiKeys": {...}, "providers": {...}, "favorites": [...], "settings": {...} },
65
65
  * "personal": { "apiKeys": {...}, "providers": {...}, "favorites": [...], "settings": {...} },
@@ -70,15 +70,7 @@
70
70
  * ]
71
71
  * }
72
72
  *
73
- * 📖 Profiles store a snapshot of the user's configuration. Each profile contains:
74
- * - apiKeys: API keys per provider (can differ between work/personal setups)
75
- * - providers: enabled/disabled state per provider
76
- * - favorites: list of pinned favorite models
77
- * - settings: extra TUI preferences (tierFilter, sortColumn, sortAsc, pingInterval, hideUnconfiguredModels, preferredToolMode, proxy)
78
- *
79
- * 📖 When a profile is loaded via --profile <name> or Shift+P, the main config's
80
- * apiKeys/providers/favorites are replaced with the profile's values. The profile
81
- * data itself stays in the profiles section — it's a named snapshot, not a fork.
73
+
82
74
  *
83
75
  * 📖 Migration: On first run, if the old plain-text ~/.free-coding-models exists
84
76
  * and the new JSON file does not, the old key is auto-migrated as the nvidia key.
@@ -95,21 +87,14 @@
95
87
  * → buildPersistedConfig(incomingConfig, diskConfig, options?) — Merge a live snapshot with the latest disk state safely
96
88
  * → replaceConfigContents(targetConfig, nextConfig) — Refresh an in-memory config object from a normalized snapshot
97
89
  * → persistApiKeysForProvider(config, providerKey) — Persist one provider's API keys without clobbering the rest of the file
98
- * → saveAsProfile(config, name) — Snapshot current apiKeys/providers/favorites/settings into a named profile
99
- * → loadProfile(config, name) — Apply a named profile's values onto the live config
100
- * → listProfiles(config) — Return array of profile names
101
- * → deleteProfile(config, name) — Remove a named profile
102
- * → getActiveProfileName(config) — Get the currently active profile name (or null)
103
- * → setActiveProfile(config, name) — Set which profile is active (null to clear)
104
- * → _emptyProfileSettings() — Default TUI settings for a profile
90
+
105
91
  * → getProxySettings(config) — Return normalized proxy settings from config
106
92
  * → setClaudeProxyModelRouting(config, modelId) — Mirror free-claude-code MODEL/MODEL_* routing onto one selected FCM model
107
93
  * → normalizeEndpointInstalls(endpointInstalls) — Keep tracked endpoint installs stable across app versions
108
94
  *
109
95
  * @exports loadConfig, saveConfig, validateConfigFile, getApiKey, isProviderEnabled
110
96
  * @exports addApiKey, removeApiKey, listApiKeys — multi-key management helpers
111
- * @exports saveAsProfile, loadProfile, listProfiles, deleteProfile
112
- * @exports getActiveProfileName, setActiveProfile, getProxySettings, setClaudeProxyModelRouting, normalizeEndpointInstalls
97
+ * @exports getProxySettings, setClaudeProxyModelRouting, normalizeEndpointInstalls
113
98
  * @exports buildPersistedConfig, replaceConfigContents, persistApiKeysForProvider
114
99
  * @exports CONFIG_PATH — path to the JSON config file
115
100
  *
@@ -254,20 +239,7 @@ function normalizeProfileSettings(settings) {
254
239
  }
255
240
  }
256
241
 
257
- function normalizeProfilesSection(profiles) {
258
- if (!isPlainObject(profiles)) return {}
259
- const normalized = {}
260
- for (const [profileName, profile] of Object.entries(profiles)) {
261
- if (!isPlainObject(profile)) continue
262
- normalized[profileName] = {
263
- apiKeys: normalizeApiKeysSection(profile.apiKeys),
264
- providers: normalizeProvidersSection(profile.providers),
265
- favorites: normalizeFavoriteList(profile.favorites),
266
- settings: normalizeProfileSettings(profile.settings),
267
- }
268
- }
269
- return normalized
270
- }
242
+
271
243
 
272
244
  function normalizeConfigShape(config) {
273
245
  const safeConfig = isPlainObject(config) ? config : {}
@@ -278,10 +250,8 @@ function normalizeConfigShape(config) {
278
250
  favorites: normalizeFavoriteList(safeConfig.favorites),
279
251
  telemetry: normalizeTelemetrySection(safeConfig.telemetry),
280
252
  endpointInstalls: normalizeEndpointInstalls(safeConfig.endpointInstalls),
281
- activeProfile: typeof safeConfig.activeProfile === 'string' && safeConfig.activeProfile.trim()
282
- ? safeConfig.activeProfile.trim()
283
- : null,
284
- profiles: normalizeProfilesSection(safeConfig.profiles),
253
+
254
+
285
255
  }
286
256
  }
287
257
 
@@ -320,48 +290,13 @@ function mergeEndpointInstalls(diskEndpointInstalls, incomingEndpointInstalls) {
320
290
  }
321
291
 
322
292
  function mergeProfiles(diskProfiles, incomingProfiles, options = {}) {
323
- const replaceProfileNames = new Set((options.replaceProfileNames || []).filter((name) => typeof name === 'string' && name.length > 0))
324
- const removedProfileNames = new Set((options.removedProfileNames || []).filter((name) => typeof name === 'string' && name.length > 0))
325
- const normalizedDiskProfiles = normalizeProfilesSection(diskProfiles)
326
- const normalizedIncomingProfiles = normalizeProfilesSection(incomingProfiles)
327
- const mergedProfiles = {}
328
- const profileNames = new Set([
329
- ...Object.keys(normalizedDiskProfiles),
330
- ...Object.keys(normalizedIncomingProfiles),
331
- ])
332
-
333
- for (const profileName of profileNames) {
334
- if (removedProfileNames.has(profileName)) continue
335
-
336
- const diskProfile = normalizedDiskProfiles[profileName]
337
- const incomingProfile = normalizedIncomingProfiles[profileName]
338
- if (!incomingProfile) {
339
- if (diskProfile) mergedProfiles[profileName] = cloneConfigValue(diskProfile)
340
- continue
341
- }
342
-
343
- if (!diskProfile || replaceProfileNames.has(profileName)) {
344
- mergedProfiles[profileName] = cloneConfigValue(incomingProfile)
345
- continue
346
- }
347
-
348
- mergedProfiles[profileName] = {
349
- apiKeys: { ...diskProfile.apiKeys, ...incomingProfile.apiKeys },
350
- providers: { ...diskProfile.providers, ...incomingProfile.providers },
351
- favorites: mergeOrderedUniqueStrings(incomingProfile.favorites, diskProfile.favorites),
352
- settings: normalizeProfileSettings({
353
- ...diskProfile.settings,
354
- ...incomingProfile.settings,
355
- }),
356
- }
357
- }
358
-
359
- return mergedProfiles
293
+ // 📖 Profile system removed - return empty object
294
+ return {}
360
295
  }
361
296
 
362
297
  /**
363
298
  * 📖 buildPersistedConfig merges the latest disk snapshot with the in-memory config so
364
- * 📖 stale writers do not accidentally wipe secrets, favorites, or profiles they did not touch.
299
+ * 📖 stale writers do not accidentally wipe secrets or favorites they did not touch.
365
300
  *
366
301
  * @param {object} incomingConfig
367
302
  * @param {object} [diskConfig=_emptyConfig()]
@@ -389,15 +324,8 @@ export function buildPersistedConfig(incomingConfig, diskConfig = _emptyConfig()
389
324
  endpointInstalls: options.replaceEndpointInstalls === true
390
325
  ? cloneConfigValue(normalizedIncoming.endpointInstalls)
391
326
  : mergeEndpointInstalls(normalizedDisk.endpointInstalls, normalizedIncoming.endpointInstalls),
392
- activeProfile: normalizedIncoming.activeProfile,
393
- profiles: mergeProfiles(normalizedDisk.profiles, normalizedIncoming.profiles, {
394
- replaceProfileNames: options.replaceProfileNames,
395
- removedProfileNames: options.removedProfileNames,
396
- }),
397
- }
327
+ // 📖 Profile system removed - always null
398
328
 
399
- if (merged.activeProfile && !merged.profiles[merged.activeProfile]) {
400
- merged.activeProfile = null
401
329
  }
402
330
 
403
331
  return normalizeConfigShape(merged)
@@ -435,32 +363,12 @@ export function persistApiKeysForProvider(config, providerKey) {
435
363
  const latestConfig = readStoredConfigSnapshot()
436
364
  const normalizedProviderValue = normalizeApiKeyValue(config?.apiKeys?.[providerKey])
437
365
 
438
- latestConfig.activeProfile = typeof config?.activeProfile === 'string' && config.activeProfile.trim()
439
- ? config.activeProfile.trim()
440
- : null
441
-
442
366
  if (normalizedProviderValue === null) delete latestConfig.apiKeys[providerKey]
443
367
  else latestConfig.apiKeys[providerKey] = cloneConfigValue(normalizedProviderValue)
444
368
 
445
- if (latestConfig.activeProfile) {
446
- if (!latestConfig.profiles[latestConfig.activeProfile]) {
447
- latestConfig.profiles[latestConfig.activeProfile] = config?.profiles?.[latestConfig.activeProfile]
448
- ? cloneConfigValue(config.profiles[latestConfig.activeProfile])
449
- : {
450
- apiKeys: {},
451
- providers: {},
452
- favorites: [],
453
- settings: _emptyProfileSettings(),
454
- }
455
- }
456
-
457
- if (normalizedProviderValue === null) delete latestConfig.profiles[latestConfig.activeProfile].apiKeys[providerKey]
458
- else latestConfig.profiles[latestConfig.activeProfile].apiKeys[providerKey] = cloneConfigValue(normalizedProviderValue)
459
- }
460
-
461
369
  const saveResult = saveConfig(latestConfig, {
462
370
  replaceApiKeys: true,
463
- replaceProfileNames: latestConfig.activeProfile ? [latestConfig.activeProfile] : [],
371
+ replaceProfileNames: [],
464
372
  })
465
373
 
466
374
  if (saveResult.success) replaceConfigContents(config, latestConfig)
@@ -924,13 +832,10 @@ export function isProviderEnabled(config, providerKey) {
924
832
  return providerConfig.enabled !== false
925
833
  }
926
834
 
927
- // ─── Config Profiles ──────────────────────────────────────────────────────────
835
+
928
836
 
929
837
  /**
930
- * 📖 _emptyProfileSettings: Default TUI settings stored in a profile.
931
- *
932
- * 📖 These settings are saved/restored when switching profiles so each profile
933
- * can have different sort, filter, and ping preferences.
838
+ * 📖 _emptyProfileSettings: Default TUI settings.
934
839
  *
935
840
  * @returns {{ tierFilter: string|null, sortColumn: string, sortAsc: boolean, pingInterval: number, hideUnconfiguredModels: boolean, preferredToolMode: string }}
936
841
  */
@@ -1086,142 +991,15 @@ export function normalizeEndpointInstalls(endpointInstalls) {
1086
991
  .filter(Boolean)
1087
992
  }
1088
993
 
1089
- /**
1090
- * 📖 saveAsProfile: Snapshot the current config state into a named profile.
1091
- *
1092
- * 📖 Takes the current apiKeys, providers, favorites, plus explicit TUI settings
1093
- * and stores them under config.profiles[name]. Does NOT change activeProfile —
1094
- * call setActiveProfile() separately if you want to switch to this profile.
1095
- *
1096
- * 📖 If a profile with the same name exists, it's overwritten.
1097
- *
1098
- * @param {object} config — Live config object (will be mutated)
1099
- * @param {string} name — Profile name (e.g. 'work', 'personal', 'fast')
1100
- * @param {object} [settings] — TUI settings to save (tierFilter, sortColumn, etc.)
1101
- * @returns {object} The config object (for chaining)
1102
- */
1103
- export function saveAsProfile(config, name, settings = null) {
1104
- if (!config.profiles || typeof config.profiles !== 'object') config.profiles = {}
1105
- config.profiles[name] = {
1106
- apiKeys: JSON.parse(JSON.stringify(config.apiKeys || {})),
1107
- providers: JSON.parse(JSON.stringify(config.providers || {})),
1108
- favorites: [...(config.favorites || [])],
1109
- settings: settings ? { ..._emptyProfileSettings(), ...settings } : _emptyProfileSettings(),
1110
- }
1111
- return config
1112
- }
1113
-
1114
- /**
1115
- * 📖 loadProfile: Apply a named profile's values onto the live config.
1116
- *
1117
- * 📖 Replaces config.apiKeys, config.providers, config.favorites with the
1118
- * profile's stored values. Also sets config.activeProfile to the loaded name.
1119
- *
1120
- * 📖 Returns the profile's TUI settings so the caller (main CLI) can apply them
1121
- * to the live state object (sortColumn, tierFilter, etc.).
1122
- *
1123
- * 📖 If the profile doesn't exist, returns null (caller should show an error).
1124
- *
1125
- * @param {object} config — Live config object (will be mutated)
1126
- * @param {string} name — Profile name to load
1127
- * @returns {{ tierFilter: string|null, sortColumn: string, sortAsc: boolean, pingInterval: number }|null}
1128
- * The profile's TUI settings, or null if profile not found
1129
- */
1130
- export function loadProfile(config, name) {
1131
- const profile = config?.profiles?.[name]
1132
- if (!profile) return null
1133
- const nextSettings = profile.settings ? { ..._emptyProfileSettings(), ...profile.settings, proxy: normalizeProxySettings(profile.settings.proxy) } : _emptyProfileSettings()
1134
-
1135
- // 📖 Deep-copy the profile data into the live config (don't share references)
1136
- // 📖 IMPORTANT: MERGE apiKeys instead of replacing to preserve keys not in profile
1137
- // 📖 Profile keys take priority over existing keys (allows profile-specific overrides)
1138
- const profileApiKeys = profile.apiKeys || {}
1139
- const mergedApiKeys = { ...config.apiKeys || {}, ...profileApiKeys }
1140
- config.apiKeys = JSON.parse(JSON.stringify(mergedApiKeys))
1141
-
1142
- // 📖 For providers, favorites: replace with profile values (these are profile-specific settings)
1143
- config.providers = JSON.parse(JSON.stringify(profile.providers || {}))
1144
- config.favorites = [...(profile.favorites || [])]
1145
- config.settings = nextSettings
1146
- config.activeProfile = name
1147
-
1148
- return nextSettings
1149
- }
1150
-
1151
- /**
1152
- * 📖 listProfiles: Get all saved profile names.
1153
- *
1154
- * @param {object} config
1155
- * @returns {string[]} Array of profile names, sorted alphabetically
1156
- */
1157
- export function listProfiles(config) {
1158
- if (!config?.profiles || typeof config.profiles !== 'object') return []
1159
- return Object.keys(config.profiles).sort()
1160
- }
1161
-
1162
- /**
1163
- * 📖 deleteProfile: Remove a named profile from the config.
1164
- *
1165
- * 📖 If the deleted profile is the active one, clears activeProfile.
1166
- *
1167
- * @param {object} config — Live config object (will be mutated)
1168
- * @param {string} name — Profile name to delete
1169
- * @returns {boolean} True if the profile existed and was deleted
1170
- */
1171
- export function deleteProfile(config, name) {
1172
- if (!config?.profiles?.[name]) return false
1173
- delete config.profiles[name]
1174
- if (config.activeProfile === name) config.activeProfile = null
1175
- return true
1176
- }
1177
-
1178
- /**
1179
- * 📖 getActiveProfileName: Get the currently active profile name.
1180
- *
1181
- * @param {object} config
1182
- * @returns {string|null} Profile name, or null if no profile is active
1183
- */
1184
- export function getActiveProfileName(config) {
1185
- return config?.activeProfile || null
1186
- }
1187
-
1188
- /**
1189
- * 📖 setActiveProfile: Set which profile is active (or null to clear).
1190
- *
1191
- * 📖 This just stores the name — it does NOT load the profile's data.
1192
- * Call loadProfile() first to actually apply the profile's values.
1193
- *
1194
- * @param {object} config — Live config object (will be mutated)
1195
- * @param {string|null} name — Profile name, or null to clear
1196
- */
1197
- export function setActiveProfile(config, name) {
1198
- config.activeProfile = name || null
1199
- }
994
+ // 📖 Profile system removed - API keys now persist permanently across all sessions
1200
995
 
1201
996
  // 📖 Internal helper: create a blank config with the right shape
1202
997
  function _emptyConfig() {
1203
998
  return {
1204
999
  apiKeys: {},
1205
- providers: {},
1206
- // 📖 Global TUI preferences that should persist even without a named profile.
1207
- settings: {
1208
- hideUnconfiguredModels: true,
1209
- proxy: normalizeProxySettings(),
1210
- disableWidthsWarning: false, // 📖 Disable widths warning toggle (default off)
1211
- },
1212
- // 📖 Pinned favorites rendered at top of the table ("providerKey/modelId").
1213
1000
  favorites: [],
1214
- // 📖 Telemetry consent is explicit. null = not decided yet.
1215
- telemetry: {
1216
- enabled: null,
1217
- consentVersion: 0,
1218
- anonymousId: null,
1219
- },
1220
- // 📖 Tracked `Y` installs — used to refresh external tool catalogs automatically.
1221
- endpointInstalls: [],
1222
- // 📖 Active profile name — null means no profile is loaded (using raw config).
1223
- activeProfile: null,
1224
- // 📖 Named profiles: each is a snapshot of apiKeys + providers + favorites + settings.
1225
- profiles: {},
1001
+ proxySettings: { enabled: false, routing: {} },
1002
+ endpointInstalls: {},
1003
+ settings: _emptyProfileSettings(),
1226
1004
  }
1227
1005
  }
@@ -72,8 +72,11 @@ export function classifyError(statusCode, body, headers) {
72
72
  return { type: ErrorType.NETWORK_ERROR, retryAfterSec: 5, shouldRetry: true, skipAccount: false }
73
73
  }
74
74
 
75
+ // 📖 401/403 from upstream providers: skip this account and try the next one.
76
+ // 📖 A provider may reject a valid key for specific models (e.g. nvidia 403 on gpt-oss-120b)
77
+ // 📖 while another provider with the same proxyModelId may accept it.
75
78
  if (statusCode === 401 || statusCode === 403) {
76
- return { type: ErrorType.AUTH_ERROR, retryAfterSec: null, shouldRetry: false, skipAccount: true }
79
+ return { type: ErrorType.AUTH_ERROR, retryAfterSec: null, shouldRetry: true, skipAccount: true }
77
80
  }
78
81
 
79
82
  // Provider-level 404/410: model not found / inaccessible / not deployed on this account.
package/src/favorites.js CHANGED
@@ -85,34 +85,20 @@ export function syncFavoriteFlags(results, config) {
85
85
  */
86
86
  export function toggleFavoriteModel(config, providerKey, modelId) {
87
87
  const latestConfig = loadConfig()
88
- latestConfig.activeProfile = typeof config?.activeProfile === 'string' && config.activeProfile.trim()
89
- ? config.activeProfile.trim()
90
- : latestConfig.activeProfile
91
88
  ensureFavoritesConfig(latestConfig)
92
- if (latestConfig.activeProfile && !latestConfig.profiles?.[latestConfig.activeProfile] && config?.profiles?.[latestConfig.activeProfile]) {
93
- latestConfig.profiles[latestConfig.activeProfile] = JSON.parse(JSON.stringify(config.profiles[latestConfig.activeProfile]))
94
- }
95
89
  const favoriteKey = toFavoriteKey(providerKey, modelId)
96
90
  const existingIndex = latestConfig.favorites.indexOf(favoriteKey)
97
91
  if (existingIndex >= 0) {
98
92
  latestConfig.favorites.splice(existingIndex, 1)
99
- if (latestConfig.activeProfile && latestConfig.profiles?.[latestConfig.activeProfile]) {
100
- latestConfig.profiles[latestConfig.activeProfile].favorites = [...latestConfig.favorites]
101
- }
102
93
  const saveResult = saveConfig(latestConfig, {
103
94
  replaceFavorites: true,
104
- replaceProfileNames: latestConfig.activeProfile ? [latestConfig.activeProfile] : [],
105
95
  })
106
96
  if (saveResult.success) replaceConfigContents(config, latestConfig)
107
97
  return false
108
98
  }
109
99
  latestConfig.favorites.push(favoriteKey)
110
- if (latestConfig.activeProfile && latestConfig.profiles?.[latestConfig.activeProfile]) {
111
- latestConfig.profiles[latestConfig.activeProfile].favorites = [...latestConfig.favorites]
112
- }
113
100
  const saveResult = saveConfig(latestConfig, {
114
101
  replaceFavorites: true,
115
- replaceProfileNames: latestConfig.activeProfile ? [latestConfig.activeProfile] : [],
116
102
  })
117
103
  if (saveResult.success) replaceConfigContents(config, latestConfig)
118
104
  return true