free-coding-models 0.2.17 → 0.3.1

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.
@@ -78,7 +78,11 @@
78
78
  * - --best: Show only top-tier models (A+, S, S+)
79
79
  * - --fiable: Analyze 10s and output the most reliable model
80
80
  * - --json: Output results as JSON (for scripting/automation)
81
+ * - --recommend: Open Smart Recommend immediately on startup
82
+ * - --profile <name>: Load a saved config profile before entering the TUI
83
+ * - --clean-proxy / --proxy-clean: Remove persisted fcm-proxy config from OpenCode
81
84
  * - --no-telemetry: Disable anonymous usage analytics for this run
85
+ * - --help / -h: Print the full CLI help and exit
82
86
  * - --tier S/A/B/C: Filter models by tier letter (S=S+/S, A=A+/A/A-, B=B+/B, C=C)
83
87
  *
84
88
  * @see {@link https://build.nvidia.com} NVIDIA API key generation
@@ -88,6 +92,7 @@
88
92
 
89
93
  import chalk from 'chalk'
90
94
  import { createRequire } from 'module'
95
+ import { fileURLToPath } from 'url'
91
96
  import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'fs'
92
97
  import { randomUUID } from 'crypto'
93
98
  import { homedir } from 'os'
@@ -98,6 +103,7 @@ import { loadConfig, saveConfig, getApiKey, getProxySettings, resolveApiKeys, ad
98
103
  import { buildMergedModels } from '../src/model-merger.js'
99
104
  import { ProxyServer } from '../src/proxy-server.js'
100
105
  import { loadOpenCodeConfig, saveOpenCodeConfig, syncToOpenCode, restoreOpenCodeBackup, cleanupOpenCodeProxyConfig } from '../src/opencode-sync.js'
106
+ import { syncProxyToTool, cleanupToolConfig, PROXY_SYNCABLE_TOOLS } from '../src/proxy-sync.js'
101
107
  import { usageForRow as _usageForRow } from '../src/usage-reader.js'
102
108
  import { loadRecentLogs } from '../src/log-reader.js'
103
109
  import { buildProviderModelTokenKey, loadTokenUsageByProviderModel } from '../src/token-usage-reader.js'
@@ -108,7 +114,7 @@ import { TIER_COLOR } from '../src/tier-colors.js'
108
114
  import { resolveCloudflareUrl, buildPingRequest, ping, extractQuotaPercent, getProviderQuotaPercentCached, usagePlaceholderForProvider } from '../src/ping.js'
109
115
  import { runFiableMode, filterByTierOrExit, fetchOpenRouterFreeModels } from '../src/analysis.js'
110
116
  import { PROVIDER_METADATA, ENV_VAR_NAMES, isWindows, isMac } from '../src/provider-metadata.js'
111
- import { parseTelemetryEnv, isTelemetryDebugEnabled, telemetryDebug, ensureTelemetryConfig, getTelemetryDistinctId, getTelemetrySystem, getTelemetryTerminal, isTelemetryEnabled, sendUsageTelemetry, sendFeatureRequest, sendBugReport } from '../src/telemetry.js'
117
+ import { parseTelemetryEnv, isTelemetryDebugEnabled, telemetryDebug, ensureTelemetryConfig, getTelemetryDistinctId, getTelemetrySystem, getTelemetryTerminal, isTelemetryEnabled, sendUsageTelemetry, sendBugReport } from '../src/telemetry.js'
112
118
  import { ensureFavoritesConfig, toFavoriteKey, syncFavoriteFlags, toggleFavoriteModel } from '../src/favorites.js'
113
119
  import { checkForUpdateDetailed, checkForUpdate, runUpdate, promptUpdateNotification } from '../src/updater.js'
114
120
  import { promptApiKey } from '../src/setup.js'
@@ -123,6 +129,7 @@ import { startExternalTool } from '../src/tool-launchers.js'
123
129
  import { getConfiguredInstallableProviders, installProviderEndpoints, refreshInstalledEndpoints, getInstallTargetModes, getProviderCatalogModels, CONNECTION_MODES } from '../src/endpoint-installer.js'
124
130
  import { loadCache, saveCache, clearCache, getCacheAge } from '../src/cache.js'
125
131
  import { checkConfigSecurity } from '../src/security.js'
132
+ import { buildCliHelpText } from '../src/cli-help.js'
126
133
 
127
134
  // 📖 mergedModels: cross-provider grouped model list (one entry per label, N providers each)
128
135
  // 📖 mergedModelByLabel: fast lookup map from display label → merged model entry
@@ -140,7 +147,7 @@ const readline = require('readline')
140
147
  const pkg = require('../package.json')
141
148
  const LOCAL_VERSION = pkg.version
142
149
 
143
- // 📖 sendFeatureRequest, sendBugReport → imported from ../src/telemetry.js
150
+ // 📖 sendBugReport → imported from ../src/telemetry.js
144
151
 
145
152
  // 📖 parseTelemetryEnv, isTelemetryDebugEnabled, telemetryDebug, ensureTelemetryConfig → imported from ../src/telemetry.js
146
153
 
@@ -172,6 +179,13 @@ const LOCAL_VERSION = pkg.version
172
179
  async function main() {
173
180
  const cliArgs = parseArgs(process.argv)
174
181
 
182
+ if (cliArgs.helpMode) {
183
+ console.log()
184
+ console.log(buildCliHelpText({ chalk, title: 'free-coding-models' }))
185
+ console.log()
186
+ process.exit(0)
187
+ }
188
+
175
189
  // Validate --tier early, before entering alternate screen
176
190
  if (cliArgs.tierFilter && !TIER_LETTER_MAP[cliArgs.tierFilter]) {
177
191
  console.error(chalk.red(` Unknown tier "${cliArgs.tierFilter}". Valid tiers: S, A, B, C`))
@@ -199,6 +213,80 @@ async function main() {
199
213
  process.exit(0)
200
214
  }
201
215
 
216
+ // 📖 CLI subcommand: free-coding-models daemon <action>
217
+ const daemonSubcmd = process.argv[2] === 'daemon' ? (process.argv[3] || 'status') : null
218
+ if (daemonSubcmd) {
219
+ const dm = await import('../src/daemon-manager.js')
220
+ if (daemonSubcmd === 'status') {
221
+ const s = await dm.getDaemonStatus()
222
+ console.log()
223
+ if (s.status === 'running') {
224
+ console.log(chalk.greenBright(` 📡 FCM Proxy V2: Running`))
225
+ console.log(chalk.dim(` PID: ${s.info.pid} • Port: ${s.info.port} • Accounts: ${s.info.accountCount} • Version: ${s.info.version}`))
226
+ console.log(chalk.dim(` Started: ${s.info.startedAt}`))
227
+ } else if (s.status === 'stopped') {
228
+ console.log(chalk.yellow(` 📡 FCM Proxy V2: Stopped (service installed but not running)`))
229
+ } else if (s.status === 'stale') {
230
+ console.log(chalk.red(` 📡 FCM Proxy V2: Stale (crashed — PID ${s.info?.pid} no longer alive)`))
231
+ } else if (s.status === 'unhealthy') {
232
+ console.log(chalk.red(` 📡 FCM Proxy V2: Unhealthy (PID alive but health check failed)`))
233
+ } else {
234
+ console.log(chalk.dim(` 📡 FCM Proxy V2: Not installed`))
235
+ console.log(chalk.dim(` Install via: free-coding-models daemon install`))
236
+ }
237
+ console.log()
238
+ process.exit(0)
239
+ }
240
+ if (daemonSubcmd === 'install') {
241
+ const result = dm.installDaemon()
242
+ console.log()
243
+ if (result.success) {
244
+ console.log(chalk.greenBright(' ✅ FCM Proxy V2 background service installed and started!'))
245
+ console.log(chalk.dim(' The proxy will now run automatically at login.'))
246
+ } else {
247
+ console.log(chalk.red(` ❌ Install failed: ${result.error}`))
248
+ }
249
+ console.log()
250
+ process.exit(result.success ? 0 : 1)
251
+ }
252
+ if (daemonSubcmd === 'uninstall') {
253
+ const result = dm.uninstallDaemon()
254
+ console.log()
255
+ if (result.success) {
256
+ console.log(chalk.greenBright(' ✅ FCM Proxy V2 background service uninstalled.'))
257
+ } else {
258
+ console.log(chalk.red(` ❌ Uninstall failed: ${result.error}`))
259
+ }
260
+ console.log()
261
+ process.exit(result.success ? 0 : 1)
262
+ }
263
+ if (daemonSubcmd === 'restart') {
264
+ const result = dm.restartDaemon()
265
+ console.log()
266
+ if (result.success) {
267
+ console.log(chalk.greenBright(' ✅ FCM Proxy V2 service restarted.'))
268
+ } else {
269
+ console.log(chalk.red(` ❌ Restart failed: ${result.error}`))
270
+ }
271
+ console.log()
272
+ process.exit(result.success ? 0 : 1)
273
+ }
274
+ if (daemonSubcmd === 'logs') {
275
+ const logPath = dm.getDaemonLogPath()
276
+ console.log(chalk.dim(` Log file: ${logPath}`))
277
+ try {
278
+ const { execSync } = await import('child_process')
279
+ execSync(`tail -50 "${logPath}"`, { stdio: 'inherit' })
280
+ } catch {
281
+ console.log(chalk.dim(' (no logs yet)'))
282
+ }
283
+ process.exit(0)
284
+ }
285
+ console.log(chalk.red(` Unknown command: ${daemonSubcmd}`))
286
+ console.log(chalk.dim(' Usage: free-coding-models daemon [status|install|uninstall|restart|logs]'))
287
+ process.exit(1)
288
+ }
289
+
202
290
  // 📖 If --profile <name> was passed, load that profile into the live config
203
291
  let startupProfileSettings = null
204
292
  if (cliArgs.profileName) {
@@ -261,28 +349,48 @@ async function main() {
261
349
  ts: new Date().toISOString(),
262
350
  })
263
351
 
264
- // 📖 Check for updates in the background (non-blocking, non-forced)
265
- // 📖 The old auto-update on startup caused infinite loops, so we've moved to:
266
- // 📖 1. Optional prompt when a new version is available (user chooses to update or not)
267
- // 📖 2. Display "OUTDATED" in TUI footer if update check fails repeatedly
352
+ // 📖 Auto-update detection: check npm registry for new versions at startup.
353
+ // 📖 If a new version is available, show an interactive prompt (Update / Changelogs / Skip).
354
+ // 📖 Dev mode (git checkout) skips auto-update to avoid infinite relaunch loops.
268
355
  let latestVersion = null
269
- let isOutdated = false
356
+ const isDevMode = existsSync(join(dirname(fileURLToPath(import.meta.url)), '..', '.git'))
270
357
  try {
271
358
  latestVersion = await checkForUpdate()
272
- // 📖 Track update check failures - if it fails 3+ times, mark as outdated
273
- if (!latestVersion && config.settings?.updateCheckFailures >= 3) {
274
- isOutdated = true
359
+ // 📖 Reset failure counter on successful check
360
+ if (config.settings?.updateCheckFailures) {
361
+ config.settings.updateCheckFailures = 0
362
+ saveConfig(config)
275
363
  }
276
364
  } catch (err) {
277
- // 📖 Silently fail - don't block the app if npm registry is unreachable
278
- // 📖 But track the failure for outdated detection
279
365
  const failures = (config.settings?.updateCheckFailures || 0) + 1
280
366
  if (!config.settings) config.settings = {}
281
367
  config.settings.updateCheckFailures = Math.min(failures, 3)
282
- if (failures >= 3) isOutdated = true
283
368
  saveConfig(config)
284
369
  }
285
370
 
371
+ // 📖 Show interactive update prompt if a new version is available (skip in dev mode)
372
+ if (latestVersion && !isDevMode) {
373
+ const choice = await promptUpdateNotification(latestVersion)
374
+ if (choice === 'update') {
375
+ runUpdate(latestVersion)
376
+ return // 📖 runUpdate relaunches the process — this line is a safety guard
377
+ } else if (choice === 'changelogs') {
378
+ const { execSync: _exec } = await import('child_process')
379
+ const url = 'https://github.com/vava-nessa/free-coding-models/releases'
380
+ try {
381
+ if (process.platform === 'darwin') _exec(`open ${url}`)
382
+ else if (process.platform === 'linux') _exec(`xdg-open ${url}`)
383
+ else console.log(chalk.dim(` 📋 ${url}`))
384
+ } catch { console.log(chalk.dim(` 📋 ${url}`)) }
385
+ // 📖 After opening changelogs, re-prompt so user can still update or continue
386
+ const choice2 = await promptUpdateNotification(latestVersion)
387
+ if (choice2 === 'update') {
388
+ runUpdate(latestVersion)
389
+ return
390
+ }
391
+ }
392
+ }
393
+
286
394
  // 📖 Dynamic OpenRouter free model discovery — fetch live free models from API
287
395
  // 📖 Replaces static openrouter entries in MODELS with fresh data.
288
396
  // 📖 Fallback: if fetch fails, the static list from sources.js stays intact + warning shown.
@@ -371,8 +479,8 @@ async function main() {
371
479
  lastPingTime: now, // 📖 Track when last ping cycle started
372
480
  lastUserActivityAt: now, // 📖 Any keypress refreshes this timer; inactivity can force slow mode.
373
481
  resumeSpeedOnActivity: false, // 📖 Set after idle slowdown so the next activity restarts a 60s speed burst.
374
- latestVersion, // 📖 Latest npm version available (null if none or check failed)
375
- isOutdated, // 📖 Set to true if update check failed 3+ times (show red "OUTDATED" footer)
482
+ startupLatestVersion: latestVersion, // 📖 Startup auto-check result reused by the footer banner after "skip update".
483
+ versionAlertsEnabled: !isDevMode, // 📖 Dev checkouts should not tell contributors to upgrade the global npm package.
376
484
  mode, // 📖 'opencode' or 'openclaw' — controls Enter action
377
485
  tierFilterMode: 0, // 📖 Index into TIER_CYCLE (0=All, 1=S+, 2=S, ...)
378
486
  originFilterMode: 0, // 📖 Index into ORIGIN_CYCLE (0=All, then providers)
@@ -382,6 +490,7 @@ async function main() {
382
490
  terminalCols: process.stdout.columns || 80, // 📖 Current terminal width
383
491
  widthWarningStartedAt: (process.stdout.columns || 80) < 166 ? now : null, // 📖 Start the narrow-terminal countdown immediately when booting in a small viewport.
384
492
  widthWarningDismissed: false, // 📖 Esc hides the narrow-terminal warning early for the current narrow-width session.
493
+ widthWarningShowCount: 0, // 📖 Counter for how many times the narrow-terminal warning has been shown (max 2 per session).
385
494
  // 📖 Settings screen state (P key opens it)
386
495
  settingsOpen: false, // 📖 Whether settings overlay is active
387
496
  settingsCursor: 0, // 📖 Which provider row is selected in settings
@@ -396,6 +505,13 @@ async function main() {
396
505
  settingsUpdateError: null, // 📖 Last update-check error message for maintenance row
397
506
  settingsProxyPortEditMode: false, // 📖 Whether Settings is editing the preferred proxy port field.
398
507
  settingsProxyPortBuffer: '', // 📖 Inline input buffer for the preferred proxy port (0 = auto).
508
+ daemonStatus: 'not-installed', // 📖 Background daemon status: 'running'|'stopped'|'stale'|'unhealthy'|'not-installed'
509
+ daemonInfo: null, // 📖 daemon.json contents when daemon is running
510
+ // 📖 Proxy & Daemon overlay state (opened from Settings)
511
+ proxyDaemonOpen: false, // 📖 Whether the dedicated Proxy & Daemon overlay is active
512
+ proxyDaemonCursor: 0, // 📖 Selected row in the proxy/daemon overlay
513
+ proxyDaemonScrollOffset: 0, // 📖 Vertical scroll offset for the proxy/daemon overlay
514
+ proxyDaemonMessage: null, // 📖 Feedback message { type: 'success'|'warning'|'error', msg: string, ts: number }
399
515
  config, // 📖 Live reference to the config object (updated on save)
400
516
  visibleSorted: [], // 📖 Cached visible+sorted models — shared between render loop and key handlers
401
517
  helpVisible: false, // 📖 Whether the help overlay (K key) is active
@@ -429,14 +545,9 @@ async function main() {
429
545
  activeProfile: getActiveProfileName(config), // 📖 Currently loaded profile name (or null)
430
546
  profileSaveMode: false, // 📖 Whether the inline "Save profile" name input is active
431
547
  profileSaveBuffer: '', // 📖 Typed characters for the profile name being saved
432
- // 📖 Feature Request state (J key opens it)
433
- featureRequestOpen: false, // 📖 Whether the feature request overlay is active
434
- featureRequestBuffer: '', // 📖 Typed characters for the feature request message
435
- featureRequestStatus: 'idle', // 📖 'idle'|'sending'|'success'|'error' — webhook send status
436
- featureRequestError: null, // 📖 Last webhook error message
437
- // 📖 Bug Report state (I key opens it)
438
- bugReportOpen: false, // 📖 Whether the bug report overlay is active
439
- bugReportBuffer: '', // 📖 Typed characters for the bug report message
548
+ // 📖 Feedback state (J/I keys open it)
549
+ feedbackOpen: false, // 📖 Whether the feedback overlay is active
550
+ bugReportBuffer: '', // 📖 Typed characters for the feedback message
440
551
  bugReportStatus: 'idle', // 📖 'idle'|'sending'|'success'|'error' — webhook send status
441
552
  bugReportError: null, // 📖 Last webhook error message
442
553
  // 📖 OpenCode sync status (S key in settings)
@@ -468,6 +579,7 @@ async function main() {
468
579
  if (prevCols >= 166 || state.widthWarningDismissed) {
469
580
  state.widthWarningStartedAt = Date.now()
470
581
  state.widthWarningDismissed = false
582
+ state.widthWarningShowCount++ // 📖 Increment counter when showing the warning again
471
583
  } else if (!state.widthWarningStartedAt) {
472
584
  state.widthWarningStartedAt = Date.now()
473
585
  }
@@ -525,11 +637,9 @@ async function main() {
525
637
  }
526
638
  }
527
639
 
528
- // 📖 Auto-start proxy on launch if OpenCode config already has an fcm-proxy provider.
640
+ // 📖 Auto-start proxy on launch when proxy auto-sync is enabled for the current tool.
529
641
  // 📖 Fire-and-forget: does not block UI startup. state.proxyStartupStatus is updated async.
530
- if (mode === 'opencode' || mode === 'opencode-desktop') {
531
- void autoStartProxyIfSynced(config, state)
532
- }
642
+ void autoStartProxyIfSynced(config, state)
533
643
 
534
644
  // 📖 Load cache if available (for faster startup with cached ping results)
535
645
  const cached = loadCache()
@@ -726,7 +836,7 @@ async function main() {
726
836
  ENV_VAR_NAMES,
727
837
  ensureProxyRunning,
728
838
  syncToOpenCode,
729
- cleanupOpenCodeProxyConfig,
839
+ cleanupToolConfig,
730
840
  restoreOpenCodeBackup,
731
841
  checkForUpdateDetailed,
732
842
  runUpdate,
@@ -740,7 +850,6 @@ async function main() {
740
850
  getToolModeOrder,
741
851
  startRecommendAnalysis: overlays.startRecommendAnalysis,
742
852
  stopRecommendAnalysis: overlays.stopRecommendAnalysis,
743
- sendFeatureRequest,
744
853
  sendBugReport,
745
854
  stopUi,
746
855
  ping,
@@ -788,27 +897,27 @@ async function main() {
788
897
  refreshAutoPingMode()
789
898
  state.frame++
790
899
  // 📖 Cache visible+sorted models each frame so Enter handler always matches the display
791
- if (!state.settingsOpen && !state.installEndpointsOpen && !state.recommendOpen && !state.featureRequestOpen && !state.bugReportOpen && !state.changelogOpen) {
900
+ if (!state.settingsOpen && !state.installEndpointsOpen && !state.recommendOpen && !state.feedbackOpen && !state.changelogOpen && !state.proxyDaemonOpen) {
792
901
  const visible = state.results.filter(r => !r.hidden)
793
902
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
794
903
  }
795
904
  const content = state.settingsOpen
796
905
  ? overlays.renderSettings()
906
+ : state.proxyDaemonOpen
907
+ ? overlays.renderProxyDaemon()
797
908
  : state.installEndpointsOpen
798
909
  ? overlays.renderInstallEndpoints()
799
910
  : state.recommendOpen
800
911
  ? overlays.renderRecommend()
801
- : state.featureRequestOpen
802
- ? overlays.renderFeatureRequest()
803
- : state.bugReportOpen
804
- ? overlays.renderBugReport()
805
- : state.helpVisible
912
+ : state.feedbackOpen
913
+ ? overlays.renderFeedback()
914
+ : state.helpVisible
806
915
  ? overlays.renderHelp()
807
916
  : state.logVisible
808
917
  ? overlays.renderLog()
809
918
  : state.changelogOpen
810
919
  ? overlays.renderChangelog()
811
- : 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, state.isOutdated, state.latestVersion)
920
+ : 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)
812
921
  process.stdout.write(ALT_HOME + content)
813
922
  }, Math.round(1000 / FPS))
814
923
 
@@ -816,7 +925,7 @@ async function main() {
816
925
  const initialVisible = state.results.filter(r => !r.hidden)
817
926
  state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection)
818
927
 
819
- 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, state.isOutdated, state.latestVersion))
928
+ 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))
820
929
 
821
930
  // 📖 If --recommend was passed, auto-open the Smart Recommend overlay on start
822
931
  if (cliArgs.recommendMode) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.2.17",
3
+ "version": "0.3.1",
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",
@@ -36,7 +36,8 @@
36
36
  "type": "module",
37
37
  "main": "bin/free-coding-models.js",
38
38
  "bin": {
39
- "free-coding-models": "./bin/free-coding-models.js"
39
+ "free-coding-models": "./bin/free-coding-models.js",
40
+ "fcm-proxy-daemon": "./bin/fcm-proxy-daemon.js"
40
41
  },
41
42
  "files": [
42
43
  "bin/",
@@ -39,6 +39,12 @@ const UNKNOWN_COOLDOWN_BASE_MS = 15 * 60 * 1000 // 15m initial cooldown
39
39
  const UNKNOWN_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1000 // 6h max cooldown
40
40
  const KNOWN_FALLBACK_COOLDOWN_MS = 5 * 60 * 1000 // 5m fallback for known providers
41
41
 
42
+ // 📖 Generic consecutive-failure cooldown constants.
43
+ // When an account accumulates FAILURE_COOLDOWN_THRESHOLD consecutive non-429 failures,
44
+ // it enters a graduated cooldown (30s → 60s → 120s) so the proxy routes around it.
45
+ const FAILURE_COOLDOWN_THRESHOLD = 3
46
+ const FAILURE_COOLDOWN_STEPS_MS = [30_000, 60_000, 120_000]
47
+
42
48
  // ─── Internal: per-account health state ──────────────────────────────────────
43
49
 
44
50
  class AccountHealth {
@@ -85,6 +91,15 @@ class AccountHealth {
85
91
  cooldownUntilMs: 0,
86
92
  probeInFlight: false,
87
93
  }
94
+
95
+ /**
96
+ * 📖 Generic consecutive-failure tracking for non-429 errors.
97
+ * When consecutiveFailures >= FAILURE_COOLDOWN_THRESHOLD, the account enters
98
+ * a graduated cooldown to avoid wasting requests on a failing endpoint.
99
+ */
100
+ this.consecutiveFailures = 0
101
+ this.failureCooldownUntilMs = 0
102
+ this.failureCooldownLevel = 0
88
103
  }
89
104
 
90
105
  /**
@@ -265,6 +280,9 @@ export class AccountManager {
265
280
  const retryAfterTs = this._retryAfterMap.get(acct.id)
266
281
  if (retryAfterTs && Date.now() < retryAfterTs) return false
267
282
 
283
+ // 📖 Generic failure cooldown — blocks account after consecutive non-429 failures
284
+ if (health.failureCooldownUntilMs > 0 && Date.now() < health.failureCooldownUntilMs) return false
285
+
268
286
  // Only exclude when quota is known to be nearly exhausted.
269
287
  // When quotaSignal is 'unknown' (null quotaPercent), we remain available.
270
288
  if (health.quotaSignal === 'known' && health.quotaPercent !== null && health.quotaPercent <= 5) return false
@@ -455,6 +473,17 @@ export class AccountManager {
455
473
  if (classifiedError?.retryAfterSec) {
456
474
  this._retryAfterMap.set(accountId, Date.now() + classifiedError.retryAfterSec * 1000)
457
475
  }
476
+
477
+ // 📖 Generic consecutive-failure cooldown: when an account hits FAILURE_COOLDOWN_THRESHOLD
478
+ // consecutive non-429 failures, put it in graduated cooldown (30s → 60s → 120s)
479
+ // so the proxy routes around it instead of wasting requests.
480
+ health.consecutiveFailures++
481
+ if (health.consecutiveFailures >= FAILURE_COOLDOWN_THRESHOLD) {
482
+ const stepIdx = Math.min(health.failureCooldownLevel, FAILURE_COOLDOWN_STEPS_MS.length - 1)
483
+ health.failureCooldownUntilMs = Date.now() + FAILURE_COOLDOWN_STEPS_MS[stepIdx]
484
+ health.failureCooldownLevel++
485
+ health.consecutiveFailures = 0
486
+ }
458
487
  }
459
488
 
460
489
  /**
@@ -532,6 +561,11 @@ export class AccountManager {
532
561
 
533
562
  // Clear retry-after from the retryAfterMap (no longer cooling down)
534
563
  this._retryAfterMap.delete(accountId)
564
+
565
+ // 📖 Reset generic failure cooldown state on success
566
+ health.consecutiveFailures = 0
567
+ health.failureCooldownUntilMs = 0
568
+ health.failureCooldownLevel = 0
535
569
  }
536
570
 
537
571
  /**