free-coding-models 0.3.6 → 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.
@@ -0,0 +1,234 @@
1
+ /**
2
+ * @file src/proxy-foreground.js
3
+ * @description Foreground proxy mode — starts the FCM Proxy V2 in the current terminal
4
+ * with a live dashboard showing status, accounts, and incoming requests.
5
+ *
6
+ * 📖 This is the `--proxy` flag handler. Unlike the daemon, it runs in the foreground
7
+ * with a live-updating terminal UI that shows proxy health and request activity.
8
+ * Perfect for debugging, dev testing (no .git check), and monitoring.
9
+ *
10
+ * @functions
11
+ * → startForegroundProxy(config, chalk) — main entry point, starts proxy + dashboard
12
+ *
13
+ * @exports startForegroundProxy
14
+ *
15
+ * @see src/proxy-server.js — ProxyServer implementation
16
+ * @see src/proxy-topology.js — topology builder
17
+ * @see bin/fcm-proxy-daemon.js — headless daemon equivalent
18
+ */
19
+
20
+ import { loadConfig, getProxySettings } from './config.js'
21
+ import { ProxyServer } from './proxy-server.js'
22
+ import { buildProxyTopologyFromConfig, buildMergedModelsForDaemon } from './proxy-topology.js'
23
+ import { sources } from '../sources.js'
24
+ import { syncProxyToTool, resolveProxySyncToolMode } from './proxy-sync.js'
25
+ import { buildMergedModels } from './model-merger.js'
26
+ import { createHash, randomBytes } from 'node:crypto'
27
+
28
+ // 📖 Default foreground proxy port — same as daemon
29
+ const DEFAULT_PORT = 18045
30
+
31
+ /**
32
+ * 📖 Start the proxy in foreground mode with a live terminal dashboard.
33
+ * 📖 No .git check, no daemon install — just starts the proxy and shows activity.
34
+ *
35
+ * @param {object} config — loaded FCM config
36
+ * @param {object} chalk — chalk instance for terminal colors
37
+ */
38
+ export async function startForegroundProxy(config, chalk) {
39
+ const proxySettings = getProxySettings(config)
40
+ const port = proxySettings.preferredPort || DEFAULT_PORT
41
+
42
+ // 📖 Ensure a stable token exists — generate one if missing (dev-friendly)
43
+ let token = proxySettings.stableToken
44
+ if (!token) {
45
+ token = 'fcm_' + randomBytes(16).toString('hex')
46
+ console.log(chalk.yellow(' ⚠ No stableToken in config — generated a temporary one for this session'))
47
+ }
48
+
49
+ console.log()
50
+ console.log(chalk.bold(' 📡 FCM Proxy V2 — Foreground Mode'))
51
+ console.log(chalk.dim(' ─────────────────────────────────────────────'))
52
+ console.log()
53
+
54
+ // 📖 Build topology
55
+ console.log(chalk.dim(' Building merged model catalog...'))
56
+ let mergedModels
57
+ try {
58
+ mergedModels = await buildMergedModelsForDaemon()
59
+ } catch (err) {
60
+ console.error(chalk.red(` ✗ Failed to build model catalog: ${err.message}`))
61
+ process.exit(1)
62
+ }
63
+
64
+ const topology = buildProxyTopologyFromConfig(config, mergedModels, sources)
65
+ const { accounts, proxyModels, anthropicRouting } = topology
66
+
67
+ if (accounts.length === 0) {
68
+ console.error(chalk.red(' ✗ No API keys configured — no accounts to serve.'))
69
+ console.error(chalk.dim(' Add keys via the TUI first (run free-coding-models without --proxy)'))
70
+ process.exit(1)
71
+ }
72
+
73
+ // 📖 Start proxy server
74
+ const proxy = new ProxyServer({
75
+ port,
76
+ accounts,
77
+ proxyApiKey: token,
78
+ anthropicRouting,
79
+ })
80
+
81
+ let listeningPort
82
+ try {
83
+ const result = await proxy.start()
84
+ listeningPort = result.port
85
+ } catch (err) {
86
+ if (err.code === 'EADDRINUSE') {
87
+ console.error(chalk.red(` ✗ Port ${port} already in use.`))
88
+ console.error(chalk.dim(' Another FCM proxy or process may be running on that port.'))
89
+ process.exit(2)
90
+ }
91
+ console.error(chalk.red(` ✗ Failed to start proxy: ${err.message}`))
92
+ process.exit(1)
93
+ }
94
+
95
+ const modelCount = Object.keys(proxyModels).length
96
+
97
+ // 📖 Sync env file for claude-code if it's a syncable tool
98
+ try {
99
+ const baseUrl = `http://127.0.0.1:${listeningPort}/v1`
100
+ const proxyInfo = { baseUrl, token }
101
+ syncProxyToTool('claude-code', proxyInfo, mergedModels)
102
+ } catch { /* best effort */ }
103
+
104
+ // 📖 Dashboard header
105
+ console.log(chalk.green(' ✓ Proxy running'))
106
+ console.log()
107
+ console.log(chalk.bold(' Status'))
108
+ console.log(chalk.dim(' ─────────────────────────────────────────────'))
109
+ console.log(` ${chalk.cyan('Endpoint')} http://127.0.0.1:${listeningPort}`)
110
+ console.log(` ${chalk.cyan('Token')} ${token.slice(0, 12)}...${token.slice(-4)}`)
111
+ console.log(` ${chalk.cyan('Accounts')} ${accounts.length}`)
112
+ console.log(` ${chalk.cyan('Models')} ${modelCount}`)
113
+ console.log()
114
+
115
+ // 📖 Show provider breakdown
116
+ const byProvider = {}
117
+ for (const acct of accounts) {
118
+ byProvider[acct.providerKey] = (byProvider[acct.providerKey] || 0) + 1
119
+ }
120
+ console.log(chalk.bold(' Providers'))
121
+ console.log(chalk.dim(' ─────────────────────────────────────────────'))
122
+ for (const [provider, count] of Object.entries(byProvider).sort((a, b) => b[1] - a[1])) {
123
+ console.log(` ${chalk.cyan(provider.padEnd(20))} ${count} account${count > 1 ? 's' : ''}`)
124
+ }
125
+ console.log()
126
+
127
+ // 📖 Claude Code quick-start hint
128
+ console.log(chalk.bold(' Quick Start'))
129
+ console.log(chalk.dim(' ─────────────────────────────────────────────'))
130
+ console.log(chalk.dim(' Claude Code:'))
131
+ console.log(` ${chalk.cyan(`ANTHROPIC_BASE_URL=http://127.0.0.1:${listeningPort} ANTHROPIC_API_KEY=${token} claude`)}`)
132
+ console.log()
133
+ console.log(chalk.dim(' curl test:'))
134
+ console.log(` ${chalk.cyan(`curl -s -H "x-api-key: ${token}" http://127.0.0.1:${listeningPort}/v1/models | head`)}`)
135
+ console.log()
136
+
137
+ console.log(chalk.bold(' Live Requests'))
138
+ console.log(chalk.dim(' ─────────────────────────────────────────────'))
139
+ console.log(chalk.dim(' Waiting for incoming requests... (Ctrl+C to stop)'))
140
+ console.log()
141
+
142
+ // 📖 Monkey-patch tokenStats.record to intercept and display live requests
143
+ const originalRecord = proxy._tokenStats.record.bind(proxy._tokenStats)
144
+ let requestCount = 0
145
+ proxy._tokenStats.record = (entry) => {
146
+ originalRecord(entry)
147
+ requestCount++
148
+
149
+ const now = new Date().toLocaleTimeString()
150
+ const status = entry.success ? chalk.green(`${entry.statusCode}`) : chalk.red(`${entry.statusCode}`)
151
+ const latency = entry.latencyMs ? chalk.dim(`${entry.latencyMs}ms`) : ''
152
+ const tokens = (entry.promptTokens + entry.completionTokens) > 0
153
+ ? chalk.dim(`${entry.promptTokens}+${entry.completionTokens}tok`)
154
+ : ''
155
+ const reqType = entry.requestType || 'unknown'
156
+ const model = entry.requestedModelId || entry.modelId || '?'
157
+ const provider = entry.providerKey || '?'
158
+ const switched = entry.switched ? chalk.yellow(' ↻') : ''
159
+
160
+ console.log(
161
+ ` ${chalk.dim(now)} ${status} ${chalk.cyan(reqType.padEnd(20))} ` +
162
+ `${chalk.white(model)} → ${chalk.dim(provider)}${switched} ${latency} ${tokens}`
163
+ )
164
+ }
165
+
166
+ // 📖 Also intercept errors on the _handleRequest level to show auth failures etc.
167
+ const originalHandleRequest = proxy._handleRequest.bind(proxy)
168
+ proxy._handleRequest = (req, res) => {
169
+ const origEnd = res.end.bind(res)
170
+ const method = req.method
171
+ const url = req.url
172
+ let logged = false
173
+
174
+ res.end = function (...args) {
175
+ if (!logged && res.statusCode >= 400) {
176
+ logged = true
177
+ const now = new Date().toLocaleTimeString()
178
+ const status = chalk.red(`${res.statusCode}`)
179
+ const ua = req.headers['user-agent'] || ''
180
+ // 📖 Try to detect the client tool from user-agent
181
+ const tool = detectClientTool(ua, req.headers)
182
+ const toolLabel = tool ? chalk.magenta(` [${tool}]`) : ''
183
+ console.log(
184
+ ` ${chalk.dim(now)} ${status} ${chalk.cyan(`${method} ${url}`.padEnd(20))} ` +
185
+ `${chalk.dim('rejected')}${toolLabel}`
186
+ )
187
+ // 📖 Debug: show auth headers on 401 to help diagnose auth issues
188
+ if (res.statusCode === 401) {
189
+ const authHeader = req.headers.authorization ? `Bearer ${req.headers.authorization.slice(0, 20)}...` : 'none'
190
+ const xApiKeyHeader = req.headers['x-api-key'] ? `${req.headers['x-api-key'].slice(0, 20)}...` : 'none'
191
+ console.log(chalk.dim(` auth: ${authHeader} | x-api-key: ${xApiKeyHeader}`))
192
+ }
193
+ }
194
+ return origEnd(...args)
195
+ }
196
+
197
+ return originalHandleRequest(req, res)
198
+ }
199
+
200
+ // 📖 Graceful shutdown
201
+ const shutdown = async (signal) => {
202
+ console.log()
203
+ console.log(chalk.dim(` Received ${signal} — shutting down...`))
204
+ try { await proxy.stop() } catch { /* best effort */ }
205
+ console.log(chalk.green(` ✓ Proxy stopped. ${requestCount} request${requestCount !== 1 ? 's' : ''} served this session.`))
206
+ console.log()
207
+ process.exit(0)
208
+ }
209
+
210
+ process.on('SIGINT', () => shutdown('SIGINT'))
211
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
212
+ }
213
+
214
+ /**
215
+ * 📖 Detect which client tool sent the request based on User-Agent or custom headers.
216
+ * 📖 Claude Code, Codex, OpenCode etc. each have distinctive UA patterns.
217
+ */
218
+ function detectClientTool(ua, headers) {
219
+ if (!ua && !headers) return null
220
+ const uaLower = (ua || '').toLowerCase()
221
+
222
+ if (uaLower.includes('claude') || uaLower.includes('anthropic')) return 'Claude Code'
223
+ if (headers?.['anthropic-version'] || headers?.['x-api-key']) return 'Anthropic SDK'
224
+ if (uaLower.includes('codex')) return 'Codex'
225
+ if (uaLower.includes('opencode')) return 'OpenCode'
226
+ if (uaLower.includes('cursor')) return 'Cursor'
227
+ if (uaLower.includes('aider')) return 'Aider'
228
+ if (uaLower.includes('goose')) return 'Goose'
229
+ if (uaLower.includes('openclaw')) return 'OpenClaw'
230
+ if (uaLower.includes('node-fetch') || uaLower.includes('undici')) return 'Node.js'
231
+ if (uaLower.includes('python')) return 'Python'
232
+ if (uaLower.includes('curl')) return 'curl'
233
+ return null
234
+ }
@@ -175,20 +175,42 @@ function resolveAnthropicMappedModel(modelId, anthropicRouting) {
175
175
  return fallbackModel
176
176
  }
177
177
 
178
- function parseProxyAuthorizationHeader(authorization, expectedToken) {
178
+ // 📖 Accepts both standard Bearer auth and Anthropic SDK x-api-key header
179
+ // 📖 Claude Code sends credentials via x-api-key, not Authorization: Bearer
180
+ function parseProxyAuthorizationHeader(authorization, expectedToken, xApiKey = null) {
179
181
  if (!expectedToken) return { authorized: true, modelHint: null }
180
- if (typeof authorization !== 'string' || !authorization.startsWith('Bearer ')) {
181
- return { authorized: false, modelHint: null }
182
+
183
+ // 📖 Check standard Bearer auth first
184
+ if (typeof authorization === 'string' && authorization.startsWith('Bearer ')) {
185
+ const rawToken = authorization.slice('Bearer '.length).trim()
186
+ if (rawToken === expectedToken) return { authorized: true, modelHint: null }
187
+ if (rawToken.startsWith(`${expectedToken}:`)) {
188
+ const modelHint = normalizeRequestedModel(rawToken.slice(expectedToken.length + 1))
189
+ return modelHint ? { authorized: true, modelHint } : { authorized: false, modelHint: null }
190
+ }
191
+ }
192
+
193
+ // 📖 Fallback: Anthropic SDK x-api-key header
194
+ if (typeof xApiKey === 'string' && xApiKey.trim()) {
195
+ const trimmed = xApiKey.trim()
196
+ if (trimmed === expectedToken) return { authorized: true, modelHint: null }
197
+ if (trimmed.startsWith(`${expectedToken}:`)) {
198
+ const modelHint = normalizeRequestedModel(trimmed.slice(expectedToken.length + 1))
199
+ return modelHint ? { authorized: true, modelHint } : { authorized: false, modelHint: null }
200
+ }
182
201
  }
183
202
 
184
- const rawToken = authorization.slice('Bearer '.length).trim()
185
- if (rawToken === expectedToken) return { authorized: true, modelHint: null }
186
- if (!rawToken.startsWith(`${expectedToken}:`)) return { authorized: false, modelHint: null }
203
+ // 📖 Accept real Anthropic API keys (sk-ant-*) — Claude Code uses its own stored key
204
+ // 📖 even when ANTHROPIC_BASE_URL is overridden to point at the proxy.
205
+ // 📖 The proxy is bound to 127.0.0.1 only, so accepting these keys is safe.
206
+ const candidateToken = (typeof authorization === 'string' && authorization.startsWith('Bearer '))
207
+ ? authorization.slice('Bearer '.length).trim()
208
+ : (typeof xApiKey === 'string' ? xApiKey.trim() : '')
209
+ if (candidateToken.startsWith('sk-ant-')) {
210
+ return { authorized: true, modelHint: null }
211
+ }
187
212
 
188
- const modelHint = normalizeRequestedModel(rawToken.slice(expectedToken.length + 1))
189
- return modelHint
190
- ? { authorized: true, modelHint }
191
- : { authorized: false, modelHint: null }
213
+ return { authorized: false, modelHint: null }
192
214
  }
193
215
 
194
216
  // ─── ProxyServer ─────────────────────────────────────────────────────────────
@@ -211,7 +233,7 @@ export class ProxyServer {
211
233
  constructor({
212
234
  port = 0,
213
235
  accounts = [],
214
- retries = 3,
236
+ retries = 8,
215
237
  proxyApiKey = null,
216
238
  anthropicRouting = null,
217
239
  accountManagerOpts = {},
@@ -284,7 +306,7 @@ export class ProxyServer {
284
306
  }
285
307
 
286
308
  _getAuthContext(req) {
287
- return parseProxyAuthorizationHeader(req.headers.authorization, this._proxyApiKey)
309
+ return parseProxyAuthorizationHeader(req.headers.authorization, this._proxyApiKey, req.headers['x-api-key'])
288
310
  }
289
311
 
290
312
  _isAuthorized(req) {
@@ -312,6 +334,13 @@ export class ProxyServer {
312
334
  }
313
335
  }
314
336
 
337
+ // 📖 Last resort: when the requested model is a Claude virtual model and no routing resolved,
338
+ // 📖 fall back to the first available account's model (free-claude-code behavior)
339
+ if (!requestedModel || classifyClaudeVirtualModel(requestedModel) || requestedModel.toLowerCase().startsWith('claude-')) {
340
+ const firstModel = this._accounts[0]?.modelId
341
+ if (firstModel) return firstModel
342
+ }
343
+
315
344
  return requestedModel
316
345
  }
317
346
 
package/src/proxy-sync.js CHANGED
@@ -32,13 +32,33 @@ import { getToolMeta } from './tool-metadata.js'
32
32
  // 📖 Provider ID used for all proxy entries — replaces per-provider fcm-{providerKey} IDs
33
33
  const PROXY_PROVIDER_ID = 'fcm-proxy'
34
34
 
35
+ // 📖 Ensures the env file is sourced in the user's shell profile (.zshrc, .bashrc, .bash_profile)
36
+ // 📖 Only adds the source line if not already present — idempotent
37
+ function ensureShellSourceLine(envFilePath) {
38
+ const home = homedir()
39
+ const candidates = ['.zshrc', '.bashrc', '.bash_profile']
40
+ let profilePath = null
41
+ for (const c of candidates) {
42
+ const p = join(home, c)
43
+ if (existsSync(p)) { profilePath = p; break }
44
+ }
45
+ if (!profilePath) return // 📖 No shell profile found — skip silently
46
+
47
+ try {
48
+ const content = readFileSync(profilePath, 'utf8')
49
+ if (content.includes(envFilePath)) return // 📖 Already sourced
50
+ const sourceLine = `\n# 📖 FCM Proxy — Claude Code env vars\n[ -f "${envFilePath}" ] && source "${envFilePath}"\n`
51
+ writeFileSync(profilePath, content + sourceLine)
52
+ } catch { /* best effort */ }
53
+ }
54
+
35
55
  // 📖 Tools that support proxy sync (have base URL + API key config)
36
56
  // 📖 Gemini is excluded — it only stores a model name, no URL/key fields.
37
57
  // 📖 Claude proxy integration is
38
58
  // 📖 runtime-only now, with fake Claude ids handled by the proxy itself.
39
59
  export const PROXY_SYNCABLE_TOOLS = [
40
60
  'opencode', 'opencode-desktop', 'openclaw', 'crush', 'goose', 'pi',
41
- 'aider', 'amp', 'qwen', 'codex', 'openhands',
61
+ 'aider', 'amp', 'qwen', 'codex', 'openhands', 'claude-code',
42
62
  ]
43
63
 
44
64
  const PROXY_SYNCABLE_CANONICAL = new Set(PROXY_SYNCABLE_TOOLS.map(tool => tool === 'opencode-desktop' ? 'opencode' : tool))
@@ -343,7 +363,7 @@ function syncEnvTool(proxyInfo, mergedModels, toolMode) {
343
363
  // 📖 Claude Code: Anthropic-specific env vars
344
364
  if (toolMode === 'claude-code') {
345
365
  const proxyBase = proxyInfo.baseUrl.replace(/\/v1$/, '')
346
- envLines.push(`export ANTHROPIC_AUTH_TOKEN="${proxyInfo.token}"`)
366
+ envLines.push(`export ANTHROPIC_API_KEY="${proxyInfo.token}"`)
347
367
  envLines.push(`export ANTHROPIC_BASE_URL="${proxyBase}"`)
348
368
  }
349
369
 
@@ -352,6 +372,12 @@ function syncEnvTool(proxyInfo, mergedModels, toolMode) {
352
372
  try { copyFileSync(envFilePath, envFilePath + '.bak') } catch { /* best effort */ }
353
373
  }
354
374
  writeFileSync(envFilePath, envLines.join('\n') + '\n')
375
+
376
+ // 📖 Auto-source the env file in the shell profile so Claude Code picks it up
377
+ if (toolMode === 'claude-code') {
378
+ ensureShellSourceLine(envFilePath)
379
+ }
380
+
355
381
  return { path: envFilePath, modelCount: models.length }
356
382
  }
357
383
 
@@ -12,7 +12,7 @@
12
12
  * - Hotkey-aware header lettering so highlighted letters always match live sort/filter keys
13
13
  * - Emoji-aware padding via padEndDisplay for aligned verdict/status cells
14
14
  * - Viewport clipping with above/below indicators
15
- * - Smart badges (mode, tier filter, origin filter, profile)
15
+ * - Smart badges (mode, tier filter, origin filter)
16
16
  * - Footer J badge: green "Proxy On" / red "Proxy Off" indicator with direct overlay access
17
17
  * - Install-endpoints shortcut surfaced directly in the footer hints
18
18
  * - Full-width red outdated-version banner when a newer npm release is known
@@ -93,7 +93,7 @@ export function setActiveProxy(proxyInstance) {
93
93
  }
94
94
 
95
95
  // ─── renderTable: mode param controls footer hint text (opencode vs openclaw) ─────────
96
- export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, proxyEnabled = false, startupLatestVersion = null, versionAlertsEnabled = true, disableWidthsWarning = false) {
96
+ export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, proxyEnabled = false, startupLatestVersion = null, versionAlertsEnabled = true, disableWidthsWarning = false) {
97
97
  // 📖 Filter out hidden models for display
98
98
  const visibleResults = results.filter(r => !r.hidden)
99
99
 
@@ -172,11 +172,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
172
172
  }
173
173
  }
174
174
 
175
- // 📖 Profile badge — shown when a named profile is active (Shift+P to cycle, Shift+S to save)
176
- let profileBadge = ''
177
- if (activeProfile) {
178
- profileBadge = chalk.bold.rgb(200, 150, 255)(` [📋 ${activeProfile}]`)
179
- }
175
+
180
176
 
181
177
  // 📖 Column widths (generous spacing with margins)
182
178
  const W_RANK = 6
@@ -230,7 +226,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
230
226
  const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection)
231
227
 
232
228
  const lines = [
233
- ` ${chalk.cyanBright.bold(`🚀 free-coding-models v${LOCAL_VERSION}`)}${modeBadge}${pingControlBadge}${tierBadge}${originBadge}${profileBadge}${chalk.reset('')} ` +
229
+ ` ${chalk.cyanBright.bold(`🚀 free-coding-models v${LOCAL_VERSION}`)}${modeBadge}${pingControlBadge}${tierBadge}${originBadge}${chalk.reset('')} ` +
234
230
  chalk.dim('📦 ') + chalk.cyanBright.bold(`${completedPings}/${totalVisible}`) + chalk.dim(' ') +
235
231
  chalk.greenBright(`✅ ${up}`) + chalk.dim(' up ') +
236
232
  chalk.yellow(`⏳ ${timeout}`) + chalk.dim(' timeout ') +
@@ -590,12 +586,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
590
586
  lines.push(chalk.dim(` ... ${sorted.length - vp.endIdx} more below ...`))
591
587
  }
592
588
 
593
- // 📖 Profile save inline prompt — shown when Shift+S is pressed, replaces spacer line
594
- if (profileSaveMode) {
595
- lines.push(chalk.bgRgb(40, 20, 60)(` 📋 Save profile as: ${chalk.cyanBright(profileSaveBuffer + '▏')} ${chalk.dim('Enter save • Esc cancel')}`))
596
- } else {
597
- lines.push('')
598
- }
589
+ lines.push('')
599
590
  // 📖 Footer hints keep only navigation and secondary actions now that the
600
591
  // 📖 active tool target is already visible in the header badge.
601
592
  const hotkey = (keyLabel, text) => chalk.yellow(keyLabel) + chalk.dim(text)
@@ -624,11 +615,9 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
624
615
  chalk.dim(` • `) +
625
616
  hotkey('K', ' Help')
626
617
  )
627
- // 📖 Line 2: profiles, install flow, recommend, proxy shortcut, feedback, and extended hints.
618
+ // 📖 Line 2: install flow, recommend, proxy shortcut, feedback, and extended hints.
628
619
  lines.push(
629
620
  chalk.dim(` `) +
630
- hotkey('⇧P', ' Cycle profile') + chalk.dim(` • `) +
631
- hotkey('⇧S', ' Save profile') + chalk.dim(` • `) +
632
621
  hotkey('Y', ' Install endpoints') + chalk.dim(` • `) +
633
622
  hotkey('Q', ' Smart Recommend') + chalk.dim(` • `) +
634
623
  hotkey('I', ' Feedback, bugs & requests')
@@ -26,28 +26,70 @@
26
26
  * @see src/render-table.js
27
27
  */
28
28
 
29
+ import { readFileSync, existsSync } from 'node:fs'
30
+ import { join } from 'node:path'
31
+ import { homedir } from 'node:os'
29
32
  import { loadRecentLogs } from './log-reader.js'
30
33
 
34
+ const DEFAULT_DATA_DIR = join(homedir(), '.free-coding-models')
35
+ const STATS_FILE = join(DEFAULT_DATA_DIR, 'token-stats.json')
36
+
31
37
  // 📖 buildProviderModelTokenKey keeps provider-scoped totals isolated even when
32
38
  // 📖 multiple Origins expose the same model ID.
33
39
  export function buildProviderModelTokenKey(providerKey, modelId) {
34
40
  return `${providerKey}::${modelId}`
35
41
  }
36
42
 
37
- // 📖 loadTokenUsageByProviderModel reads the full bounded log history available
38
- // 📖 through log-reader and sums tokens per exact provider+model pair.
39
- export function loadTokenUsageByProviderModel({ logFile, limit = 50_000 } = {}) {
40
- const rows = loadRecentLogs({ logFile, limit })
43
+ // 📖 loadTokenUsageByProviderModel prioritizes token-stats.json for accurate
44
+ // 📖 historical totals. If missing, it falls back to parsing the bounded log history.
45
+ export function loadTokenUsageByProviderModel({ logFile, statsFile = STATS_FILE, limit = 50_000 } = {}) {
46
+ // 📖 If a custom logFile is provided (Test Mode), ONLY use that file.
47
+ if (logFile) {
48
+ const testTotals = {}
49
+ const rows = loadRecentLogs({ logFile, limit })
50
+ for (const row of rows) {
51
+ const key = buildProviderModelTokenKey(row.provider, row.model)
52
+ testTotals[key] = (testTotals[key] || 0) + (Number(row.tokens) || 0)
53
+ }
54
+ return testTotals
55
+ }
56
+
41
57
  const totals = {}
42
58
 
43
- for (const row of rows) {
44
- const providerKey = typeof row.provider === 'string' ? row.provider : 'unknown'
45
- const modelId = typeof row.model === 'string' ? row.model : 'unknown'
46
- const tokens = Number(row.tokens) || 0
47
- if (tokens <= 0) continue
59
+ // 📖 Phase 1: Try to load from the aggregated stats file (canonical source for totals)
60
+ try {
61
+ if (existsSync(statsFile)) {
62
+ const stats = JSON.parse(readFileSync(statsFile, 'utf8'))
63
+ // 📖 Aggregate byAccount entries (which use providerKey/slug/keyIdx as ID)
64
+ // 📖 into providerKey::modelId buckets.
65
+ if (stats.byAccount && typeof stats.byAccount === 'object') {
66
+ for (const [accountId, acct] of Object.entries(stats.byAccount)) {
67
+ const tokens = Number(acct.tokens) || 0
68
+ if (tokens <= 0) continue
69
+
70
+ // 📖 Extract providerKey and modelId from accountId (provider/model/index)
71
+ const parts = accountId.split('/')
72
+ if (parts.length >= 2) {
73
+ const providerKey = parts[0]
74
+ const modelId = parts[1]
75
+ const key = buildProviderModelTokenKey(providerKey, modelId)
76
+ totals[key] = (totals[key] || 0) + tokens
77
+ }
78
+ }
79
+ }
80
+ }
81
+ } catch (err) {
82
+ // 📖 Silently fall back to log parsing if stats file is corrupt or unreadable
83
+ }
48
84
 
49
- const key = buildProviderModelTokenKey(providerKey, modelId)
50
- totals[key] = (totals[key] || 0) + tokens
85
+ // 📖 Phase 2: Supplement with recent log entries if totals are still empty
86
+ // 📖 (e.g. fresh install or token-stats.json deleted)
87
+ if (Object.keys(totals).length === 0) {
88
+ const rows = loadRecentLogs({ limit })
89
+ for (const row of rows) {
90
+ const key = buildProviderModelTokenKey(row.provider, row.model)
91
+ totals[key] = (totals[key] || 0) + (Number(row.tokens) || 0)
92
+ }
51
93
  }
52
94
 
53
95
  return totals
package/src/utils.js CHANGED
@@ -403,27 +403,40 @@ export function parseArgs(argv) {
403
403
  let apiKey = null
404
404
  const flags = []
405
405
 
406
- // 📖 Determine which arg indices are consumed by --tier and --profile so we skip them
406
+ // 📖 Determine which arg indices are consumed by --tier so we skip them
407
407
  const tierIdx = args.findIndex(a => a.toLowerCase() === '--tier')
408
408
  const tierValueIdx = (tierIdx !== -1 && args[tierIdx + 1] && !args[tierIdx + 1].startsWith('--'))
409
409
  ? tierIdx + 1
410
410
  : -1
411
411
 
412
- const profileIdx = args.findIndex(a => a.toLowerCase() === '--profile')
413
- const profileValueIdx = (profileIdx !== -1 && args[profileIdx + 1] && !args[profileIdx + 1].startsWith('--'))
414
- ? profileIdx + 1
412
+ // New value flags
413
+ const sortIdx = args.findIndex(a => a.toLowerCase() === '--sort')
414
+ const sortValueIdx = (sortIdx !== -1 && args[sortIdx + 1] && !args[sortIdx + 1].startsWith('--'))
415
+ ? sortIdx + 1
416
+ : -1
417
+
418
+ const originIdx = args.findIndex(a => a.toLowerCase() === '--origin')
419
+ const originValueIdx = (originIdx !== -1 && args[originIdx + 1] && !args[originIdx + 1].startsWith('--'))
420
+ ? originIdx + 1
421
+ : -1
422
+
423
+ const pingIntervalIdx = args.findIndex(a => a.toLowerCase() === '--ping-interval')
424
+ const pingIntervalValueIdx = (pingIntervalIdx !== -1 && args[pingIntervalIdx + 1] && !args[pingIntervalIdx + 1].startsWith('--'))
425
+ ? pingIntervalIdx + 1
415
426
  : -1
416
427
 
417
428
  // 📖 Set of arg indices that are values for flags (not API keys)
418
429
  const skipIndices = new Set()
419
430
  if (tierValueIdx !== -1) skipIndices.add(tierValueIdx)
420
- if (profileValueIdx !== -1) skipIndices.add(profileValueIdx)
431
+ if (sortValueIdx !== -1) skipIndices.add(sortValueIdx)
432
+ if (originValueIdx !== -1) skipIndices.add(originValueIdx)
433
+ if (pingIntervalValueIdx !== -1) skipIndices.add(pingIntervalValueIdx)
421
434
 
422
435
  for (const [i, arg] of args.entries()) {
423
436
  if (arg.startsWith('--') || arg === '-h') {
424
437
  flags.push(arg.toLowerCase())
425
438
  } else if (skipIndices.has(i)) {
426
- // 📖 Skip — this is a value for --tier or --profile, not an API key
439
+ // 📖 Skip — this is a value for --tier, not an API key
427
440
  } else if (!apiKey) {
428
441
  apiKey = arg
429
442
  }
@@ -446,12 +459,25 @@ export function parseArgs(argv) {
446
459
  const piMode = flags.includes('--pi')
447
460
  const noTelemetry = flags.includes('--no-telemetry')
448
461
  const cleanProxyMode = flags.includes('--clean-proxy') || flags.includes('--proxy-clean')
462
+ const proxyForegroundMode = flags.includes('--proxy')
449
463
  const jsonMode = flags.includes('--json')
450
464
  const helpMode = flags.includes('--help') || flags.includes('-h')
465
+ const premiumMode = flags.includes('--premium')
466
+
467
+ // New boolean flags
468
+ const sortDesc = flags.includes('--desc')
469
+ const sortAscFlag = flags.includes('--asc')
470
+ const hideUnconfigured = flags.includes('--hide-unconfigured')
471
+ const showUnconfigured = flags.includes('--show-unconfigured')
472
+ const disableWidthsWarning = flags.includes('--disable-widths-warning')
451
473
 
452
474
  let tierFilter = tierValueIdx !== -1 ? args[tierValueIdx].toUpperCase() : null
475
+ let sortColumn = sortValueIdx !== -1 ? args[sortValueIdx].toLowerCase() : null
476
+ let originFilter = originValueIdx !== -1 ? args[originValueIdx] : null
477
+ let pingInterval = pingIntervalValueIdx !== -1 ? parseInt(args[pingIntervalValueIdx], 10) : null
478
+ let sortDirection = sortDesc ? 'desc' : (sortAscFlag ? 'asc' : null)
453
479
 
454
- const profileName = profileValueIdx !== -1 ? args[profileValueIdx] : null
480
+ // 📖 Profile system removed - API keys now persist permanently across all sessions
455
481
 
456
482
  // 📖 --recommend — launch directly into Smart Recommend mode (Q key equivalent)
457
483
  const recommendMode = flags.includes('--recommend')
@@ -478,8 +504,17 @@ export function parseArgs(argv) {
478
504
  jsonMode,
479
505
  helpMode,
480
506
  tierFilter,
481
- profileName,
482
- recommendMode
507
+ sortColumn,
508
+ sortDirection,
509
+ originFilter,
510
+ pingInterval,
511
+ hideUnconfigured,
512
+ showUnconfigured,
513
+ disableWidthsWarning,
514
+ premiumMode,
515
+ // 📖 Profile system removed - API keys now persist permanently across all sessions
516
+ recommendMode,
517
+ proxyForegroundMode,
483
518
  }
484
519
  }
485
520