free-coding-models 0.1.83 → 0.1.84

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,382 @@
1
+ /**
2
+ * @file telemetry.js
3
+ * @description Anonymous usage telemetry and Discord feedback webhooks.
4
+ * Extracted from bin/free-coding-models.js to keep the main entry point lean.
5
+ *
6
+ * @details
7
+ * All telemetry is strictly opt-in-by-default, fire-and-forget, and anonymous:
8
+ * - A stable `anonymousId` (UUID prefixed with "anon_") is generated once and stored
9
+ * in ~/.free-coding-models.json. No personal data is ever collected.
10
+ * - PostHog is used for product analytics (app_start events, mode, platform).
11
+ * - Discord webhooks carry anonymous feature requests (J key) and bug reports (I key).
12
+ * - `isTelemetryEnabled()` checks: CLI flag → env var → default (enabled).
13
+ * - `telemetryDebug()` writes to stderr only when FREE_CODING_MODELS_TELEMETRY_DEBUG=1.
14
+ * - `sendUsageTelemetry()` has a hard 1.2 s timeout so it never blocks startup.
15
+ *
16
+ * ⚙️ Configuration (env vars, all optional):
17
+ * - FREE_CODING_MODELS_TELEMETRY=0|false|off — disable telemetry globally
18
+ * - FREE_CODING_MODELS_TELEMETRY_DEBUG=1 — print debug traces to stderr
19
+ * - FREE_CODING_MODELS_POSTHOG_KEY — override the PostHog project key
20
+ * - FREE_CODING_MODELS_POSTHOG_HOST — override the PostHog host
21
+ * - POSTHOG_PROJECT_API_KEY / POSTHOG_HOST — standard PostHog env vars (fallback)
22
+ *
23
+ * @functions
24
+ * → parseTelemetryEnv(value) — Convert env string to boolean or null
25
+ * → isTelemetryDebugEnabled() — Check debug flag from env
26
+ * → telemetryDebug(message, meta) — Conditional debug trace to stderr
27
+ * → ensureTelemetryConfig(config) — Ensure telemetry shape in config object
28
+ * → getTelemetryDistinctId(config) — Get/create stable anonymous ID
29
+ * → getTelemetrySystem() — Convert platform to human label
30
+ * → getTelemetryTerminal() — Infer terminal family from env hints
31
+ * → isTelemetryEnabled(config, cliArgs) — Resolve effective enabled state
32
+ * → sendUsageTelemetry(config, cliArgs, payload)— Fire-and-forget PostHog ping
33
+ * → sendFeatureRequest(message) — Post anonymous feature request to Discord
34
+ * → sendBugReport(message) — Post anonymous bug report to Discord
35
+ *
36
+ * @exports
37
+ * parseTelemetryEnv, isTelemetryDebugEnabled, telemetryDebug,
38
+ * ensureTelemetryConfig, getTelemetryDistinctId,
39
+ * getTelemetrySystem, getTelemetryTerminal,
40
+ * isTelemetryEnabled, sendUsageTelemetry,
41
+ * sendFeatureRequest, sendBugReport
42
+ *
43
+ * @see src/config.js — saveConfig is imported here to persist the generated anonymousId
44
+ * @see bin/free-coding-models.js — calls sendUsageTelemetry on startup and on key events
45
+ */
46
+
47
+ import { randomUUID } from 'crypto'
48
+ import { createRequire } from 'module'
49
+ import { saveConfig } from './config.js'
50
+
51
+ const require = createRequire(import.meta.url)
52
+ const pkg = require('../package.json')
53
+ const LOCAL_VERSION = pkg.version
54
+
55
+ // 📖 PostHog capture endpoint and defaults.
56
+ // 📖 These are public ingest tokens — safe to publish in open-source code.
57
+ const TELEMETRY_TIMEOUT = 1_200
58
+ const POSTHOG_CAPTURE_PATH = '/i/v0/e/'
59
+ const POSTHOG_DEFAULT_HOST = 'https://eu.i.posthog.com'
60
+ const POSTHOG_PROJECT_KEY_DEFAULT = 'phc_5P1n8HaLof6nHM0tKJYt4bV5pj2XPb272fLVigwf1YQ'
61
+ const POSTHOG_HOST_DEFAULT = 'https://eu.i.posthog.com'
62
+
63
+ // 📖 Discord feature request webhook configuration (anonymous feedback system).
64
+ const DISCORD_WEBHOOK_URL = 'https://discord.com/api/webhooks/1476709155992764427/hmnHNtpducvi5LClhv8DynENjUmmg9q8HI1Bx1lNix56UHqrqZf55rW95LGvNJ2W4j7D'
65
+ const DISCORD_BOT_NAME = 'TUI - Feature Requests'
66
+ const DISCORD_EMBED_COLOR = 0x39FF14 // Vert fluo (RGB: 57, 255, 20)
67
+
68
+ // 📖 Discord bug report webhook configuration (anonymous bug reports).
69
+ const DISCORD_BUG_WEBHOOK_URL = 'https://discord.com/api/webhooks/1476715954409963743/5cOLf7U_891f1jwxRBLIp2RIP9xYhr4rWtOhipzKKwVdFVl1Bj89X_fB6I_uGXZiGT9E'
70
+ const DISCORD_BUG_BOT_NAME = 'TUI Bug Report'
71
+ const DISCORD_BUG_EMBED_COLOR = 0xFF5733 // Rouge (RGB: 255, 87, 51)
72
+
73
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
74
+
75
+ /**
76
+ * 📖 parseTelemetryEnv: Convert env var strings into booleans.
77
+ * 📖 Returns true/false when value is recognized, otherwise null.
78
+ * @param {unknown} value
79
+ * @returns {boolean|null}
80
+ */
81
+ export function parseTelemetryEnv(value) {
82
+ if (typeof value !== 'string') return null
83
+ const normalized = value.trim().toLowerCase()
84
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
85
+ if (['0', 'false', 'no', 'off'].includes(normalized)) return false
86
+ return null
87
+ }
88
+
89
+ /**
90
+ * 📖 Optional debug switch for telemetry troubleshooting (disabled by default).
91
+ * @returns {boolean}
92
+ */
93
+ export function isTelemetryDebugEnabled() {
94
+ return parseTelemetryEnv(process.env.FREE_CODING_MODELS_TELEMETRY_DEBUG) === true
95
+ }
96
+
97
+ /**
98
+ * 📖 Writes telemetry debug traces to stderr only when explicitly enabled.
99
+ * @param {string} message
100
+ * @param {unknown} [meta]
101
+ */
102
+ export function telemetryDebug(message, meta = null) {
103
+ if (!isTelemetryDebugEnabled()) return
104
+ const prefix = '[telemetry-debug]'
105
+ if (meta === null) {
106
+ process.stderr.write(`${prefix} ${message}\n`)
107
+ return
108
+ }
109
+ try {
110
+ process.stderr.write(`${prefix} ${message} ${JSON.stringify(meta)}\n`)
111
+ } catch {
112
+ process.stderr.write(`${prefix} ${message}\n`)
113
+ }
114
+ }
115
+
116
+ /**
117
+ * 📖 Ensure telemetry config shape exists even on old config files.
118
+ * @param {Record<string, unknown>} config
119
+ */
120
+ export function ensureTelemetryConfig(config) {
121
+ if (!config.telemetry || typeof config.telemetry !== 'object') {
122
+ config.telemetry = { enabled: true, anonymousId: null }
123
+ }
124
+ // 📖 Only default enabled when unset; do not override a user's explicit opt-out
125
+ if (typeof config.telemetry.enabled !== 'boolean') {
126
+ config.telemetry.enabled = true
127
+ }
128
+ if (typeof config.telemetry.anonymousId !== 'string' || !config.telemetry.anonymousId.trim()) {
129
+ config.telemetry.anonymousId = null
130
+ }
131
+ }
132
+
133
+ /**
134
+ * 📖 Create or reuse a persistent anonymous distinct_id for PostHog.
135
+ * 📖 Stored locally in config so one user is stable over time without personal data.
136
+ * @param {Record<string, unknown>} config
137
+ * @returns {string}
138
+ */
139
+ export function getTelemetryDistinctId(config) {
140
+ ensureTelemetryConfig(config)
141
+ if (config.telemetry.anonymousId) return config.telemetry.anonymousId
142
+
143
+ config.telemetry.anonymousId = `anon_${randomUUID()}`
144
+ saveConfig(config)
145
+ return config.telemetry.anonymousId
146
+ }
147
+
148
+ /**
149
+ * 📖 Convert Node platform to human-readable system name for analytics segmentation.
150
+ * @returns {string}
151
+ */
152
+ export function getTelemetrySystem() {
153
+ if (process.platform === 'darwin') return 'macOS'
154
+ if (process.platform === 'win32') return 'Windows'
155
+ if (process.platform === 'linux') return 'Linux'
156
+ return process.platform
157
+ }
158
+
159
+ /**
160
+ * 📖 Infer terminal family from environment hints for coarse usage segmentation.
161
+ * 📖 Never sends full env dumps; only a normalized terminal label is emitted.
162
+ * @returns {string}
163
+ */
164
+ export function getTelemetryTerminal() {
165
+ const termProgramRaw = (process.env.TERM_PROGRAM || '').trim()
166
+ const termProgram = termProgramRaw.toLowerCase()
167
+ const term = (process.env.TERM || '').toLowerCase()
168
+
169
+ if (termProgram === 'apple_terminal') return 'Terminal.app'
170
+ if (termProgram === 'iterm.app') return 'iTerm2'
171
+ if (termProgram === 'warpterminal' || process.env.WARP_IS_LOCAL_SHELL_SESSION) return 'Warp'
172
+ if (process.env.WT_SESSION) return 'Windows Terminal'
173
+ if (process.env.KITTY_WINDOW_ID || term.includes('kitty')) return 'kitty'
174
+ if (process.env.GHOSTTY_RESOURCES_DIR || term.includes('ghostty')) return 'Ghostty'
175
+ if (process.env.WEZTERM_PANE || termProgram === 'wezterm') return 'WezTerm'
176
+ if (process.env.KONSOLE_VERSION || termProgram === 'konsole') return 'Konsole'
177
+ if (process.env.GNOME_TERMINAL_SCREEN || termProgram === 'gnome-terminal') return 'GNOME Terminal'
178
+ if (process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm') return 'JetBrains Terminal'
179
+ if (process.env.TABBY_CONFIG_DIRECTORY || termProgram === 'tabby') return 'Tabby'
180
+ if (termProgram === 'vscode' || process.env.VSCODE_GIT_IPC_HANDLE) return 'VS Code Terminal'
181
+ if (process.env.ALACRITTY_SOCKET || term.includes('alacritty') || termProgram === 'alacritty') return 'Alacritty'
182
+ if (term.includes('foot') || termProgram === 'foot') return 'foot'
183
+ if (termProgram === 'hyper' || process.env.HYPER) return 'Hyper'
184
+ if (process.env.TMUX) return 'tmux'
185
+ if (process.env.STY) return 'screen'
186
+ // 📖 Generic fallback for many terminals exposing TERM_PROGRAM (e.g., Rio, Contour, etc.).
187
+ if (termProgramRaw) return termProgramRaw
188
+ if (term) return term
189
+
190
+ return 'unknown'
191
+ }
192
+
193
+ /**
194
+ * 📖 Resolve telemetry effective state with clear precedence:
195
+ * 📖 CLI flag > env var > enabled by default (forced for all users).
196
+ * @param {Record<string, unknown>} config
197
+ * @param {{ noTelemetry?: boolean }} cliArgs
198
+ * @returns {boolean}
199
+ */
200
+ export function isTelemetryEnabled(config, cliArgs) {
201
+ if (cliArgs.noTelemetry) return false
202
+ const envTelemetry = parseTelemetryEnv(process.env.FREE_CODING_MODELS_TELEMETRY)
203
+ if (envTelemetry !== null) return envTelemetry
204
+ ensureTelemetryConfig(config)
205
+ return true
206
+ }
207
+
208
+ /**
209
+ * 📖 Fire-and-forget analytics ping: never blocks UX, never throws.
210
+ * @param {Record<string, unknown>} config
211
+ * @param {{ noTelemetry?: boolean }} cliArgs
212
+ * @param {{ event?: string, version?: string, mode?: string, ts?: string }} payload
213
+ */
214
+ export async function sendUsageTelemetry(config, cliArgs, payload) {
215
+ if (!isTelemetryEnabled(config, cliArgs)) {
216
+ telemetryDebug('skip: telemetry disabled', {
217
+ cliNoTelemetry: cliArgs.noTelemetry === true,
218
+ envTelemetry: process.env.FREE_CODING_MODELS_TELEMETRY || null,
219
+ configEnabled: config?.telemetry?.enabled ?? null,
220
+ })
221
+ return
222
+ }
223
+
224
+ const apiKey = (
225
+ process.env.FREE_CODING_MODELS_POSTHOG_KEY ||
226
+ process.env.POSTHOG_PROJECT_API_KEY ||
227
+ POSTHOG_PROJECT_KEY_DEFAULT ||
228
+ ''
229
+ ).trim()
230
+ if (!apiKey) {
231
+ telemetryDebug('skip: missing api key')
232
+ return
233
+ }
234
+
235
+ const host = (
236
+ process.env.FREE_CODING_MODELS_POSTHOG_HOST ||
237
+ process.env.POSTHOG_HOST ||
238
+ POSTHOG_HOST_DEFAULT ||
239
+ POSTHOG_DEFAULT_HOST
240
+ ).trim().replace(/\/+$/, '')
241
+ if (!host) {
242
+ telemetryDebug('skip: missing host')
243
+ return
244
+ }
245
+
246
+ try {
247
+ const endpoint = `${host}${POSTHOG_CAPTURE_PATH}`
248
+ const distinctId = getTelemetryDistinctId(config)
249
+ const timestamp = typeof payload?.ts === 'string' ? payload.ts : new Date().toISOString()
250
+ const signal = (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function')
251
+ ? AbortSignal.timeout(TELEMETRY_TIMEOUT)
252
+ : undefined
253
+
254
+ const posthogBody = {
255
+ api_key: apiKey,
256
+ event: payload?.event || 'app_start',
257
+ distinct_id: distinctId,
258
+ timestamp,
259
+ properties: {
260
+ $process_person_profile: false,
261
+ source: 'cli',
262
+ app: 'free-coding-models',
263
+ version: payload?.version || LOCAL_VERSION,
264
+ app_version: payload?.version || LOCAL_VERSION,
265
+ mode: payload?.mode || 'opencode',
266
+ system: getTelemetrySystem(),
267
+ terminal: getTelemetryTerminal(),
268
+ },
269
+ }
270
+
271
+ await fetch(endpoint, {
272
+ method: 'POST',
273
+ headers: { 'content-type': 'application/json' },
274
+ body: JSON.stringify(posthogBody),
275
+ signal,
276
+ })
277
+ telemetryDebug('sent', {
278
+ event: posthogBody.event,
279
+ endpoint,
280
+ mode: posthogBody.properties.mode,
281
+ system: posthogBody.properties.system,
282
+ terminal: posthogBody.properties.terminal,
283
+ })
284
+ } catch {
285
+ // 📖 Ignore failures silently: analytics must never break the CLI.
286
+ telemetryDebug('error: send failed')
287
+ }
288
+ }
289
+
290
+ /**
291
+ * 📖 sendFeatureRequest: Send anonymous feature request to Discord via webhook.
292
+ * 📖 Called when user presses J key, types message, and presses Enter.
293
+ * 📖 Returns success/error status for UI feedback.
294
+ * @param {string} message
295
+ * @returns {Promise<{ success: boolean, error: string|null }>}
296
+ */
297
+ export async function sendFeatureRequest(message) {
298
+ try {
299
+ // 📖 Collect anonymous telemetry for context (no personal data)
300
+ const system = getTelemetrySystem()
301
+ const terminal = getTelemetryTerminal()
302
+ const nodeVersion = process.version
303
+ const arch = process.arch
304
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'Unknown'
305
+
306
+ // 📖 Build Discord embed with rich metadata in footer (compact format)
307
+ const embed = {
308
+ description: message,
309
+ color: DISCORD_EMBED_COLOR,
310
+ timestamp: new Date().toISOString(),
311
+ footer: {
312
+ text: `v${LOCAL_VERSION} • ${system} • ${terminal} • ${nodeVersion} • ${arch} • ${timezone}`
313
+ }
314
+ }
315
+
316
+ const response = await fetch(DISCORD_WEBHOOK_URL, {
317
+ method: 'POST',
318
+ headers: { 'content-type': 'application/json' },
319
+ body: JSON.stringify({
320
+ username: DISCORD_BOT_NAME,
321
+ embeds: [embed]
322
+ }),
323
+ signal: AbortSignal.timeout(10000) // 📖 10s timeout for webhook
324
+ })
325
+
326
+ if (!response.ok) {
327
+ throw new Error(`HTTP ${response.status}`)
328
+ }
329
+
330
+ return { success: true, error: null }
331
+ } catch (error) {
332
+ const message = error instanceof Error ? error.message : 'Unknown error'
333
+ return { success: false, error: message }
334
+ }
335
+ }
336
+
337
+ /**
338
+ * 📖 sendBugReport: Send anonymous bug report to Discord via webhook.
339
+ * 📖 Called when user presses I key, types message, and presses Enter.
340
+ * 📖 Returns success/error status for UI feedback.
341
+ * @param {string} message
342
+ * @returns {Promise<{ success: boolean, error: string|null }>}
343
+ */
344
+ export async function sendBugReport(message) {
345
+ try {
346
+ // 📖 Collect anonymous telemetry for context (no personal data)
347
+ const system = getTelemetrySystem()
348
+ const terminal = getTelemetryTerminal()
349
+ const nodeVersion = process.version
350
+ const arch = process.arch
351
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'Unknown'
352
+
353
+ // 📖 Build Discord embed with rich metadata in footer (compact format)
354
+ const embed = {
355
+ description: message,
356
+ color: DISCORD_BUG_EMBED_COLOR,
357
+ timestamp: new Date().toISOString(),
358
+ footer: {
359
+ text: `v${LOCAL_VERSION} • ${system} • ${terminal} • ${nodeVersion} • ${arch} • ${timezone}`
360
+ }
361
+ }
362
+
363
+ const response = await fetch(DISCORD_BUG_WEBHOOK_URL, {
364
+ method: 'POST',
365
+ headers: { 'content-type': 'application/json' },
366
+ body: JSON.stringify({
367
+ username: DISCORD_BUG_BOT_NAME,
368
+ embeds: [embed]
369
+ }),
370
+ signal: AbortSignal.timeout(10000) // 📖 10s timeout for webhook
371
+ })
372
+
373
+ if (!response.ok) {
374
+ throw new Error(`HTTP ${response.status}`)
375
+ }
376
+
377
+ return { success: true, error: null }
378
+ } catch (error) {
379
+ const message = error instanceof Error ? error.message : 'Unknown error'
380
+ return { success: false, error: message }
381
+ }
382
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @file tier-colors.js
3
+ * @description Chalk colour functions for each tier level, extracted from bin/free-coding-models.js.
4
+ *
5
+ * @details
6
+ * The tier system maps model quality tiers (S+, S, A+, A, A-, B+, B, C) to a
7
+ * green → yellow → orange → red gradient. Keeping these colour definitions in their
8
+ * own module allows the renderer, overlays, and any future CLI tools to share a
9
+ * single, consistent visual language without depending on the whole TUI entry point.
10
+ *
11
+ * The gradient is deliberately designed so that the higher the tier the more
12
+ * "neon" and attention-grabbing the colour, while lower tiers fade toward dark red.
13
+ * `chalk.rgb()` is used for fine-grained control — terminal 256-colour and truecolour
14
+ * modes both support this; on terminals that don't, chalk gracefully degrades.
15
+ *
16
+ * @exports
17
+ * TIER_COLOR — object mapping tier string → chalk colouring function
18
+ *
19
+ * @see src/constants.js — TIER_CYCLE ordering that drives the T-key filter
20
+ * @see bin/free-coding-models.js — renderTable() uses TIER_COLOR per row
21
+ */
22
+
23
+ import chalk from 'chalk'
24
+
25
+ // 📖 Tier colors: green gradient (best) → yellow → orange → red (worst).
26
+ // 📖 Uses chalk.rgb() for fine-grained color control across 8 tier levels.
27
+ // 📖 Each entry is a function (t) => styled string so it can be applied to any text.
28
+ export const TIER_COLOR = {
29
+ 'S+': t => chalk.bold.rgb(0, 255, 80)(t), // 🟢 bright neon green — elite
30
+ 'S': t => chalk.bold.rgb(80, 220, 0)(t), // 🟢 green — excellent
31
+ 'A+': t => chalk.bold.rgb(170, 210, 0)(t), // 🟡 yellow-green — great
32
+ 'A': t => chalk.bold.rgb(240, 190, 0)(t), // 🟡 yellow — good
33
+ 'A-': t => chalk.bold.rgb(255, 130, 0)(t), // 🟠 amber — decent
34
+ 'B+': t => chalk.bold.rgb(255, 70, 0)(t), // 🟠 orange-red — average
35
+ 'B': t => chalk.bold.rgb(210, 20, 0)(t), // 🔴 red — below avg
36
+ 'C': t => chalk.bold.rgb(140, 0, 0)(t), // 🔴 dark red — lightweight
37
+ }
@@ -6,6 +6,14 @@
6
6
  * an in-memory ring buffer of the 100 most-recent requests, and an
7
7
  * append-only JSONL log file for detailed history.
8
8
  *
9
+ * Quota snapshots are intentionally stored at two granularities:
10
+ * - byProviderModel: precise UI data for a specific Origin + model pair
11
+ * - byProvider: fallback when a provider exposes account-level remaining quota
12
+ *
13
+ * We still keep the legacy byModel aggregate for backward compatibility and
14
+ * debugging, but the TUI no longer trusts it for display because the same
15
+ * model ID can exist under multiple Origins with different limits.
16
+ *
9
17
  * Storage locations:
10
18
  * ~/.free-coding-models/token-stats.json — aggregated stats (auto-saved every 10 records)
11
19
  * ~/.free-coding-models/request-log.jsonl — timestamped per-request log (pruned after 30 days)
@@ -46,7 +54,7 @@ export class TokenStats {
46
54
  byModel: {},
47
55
  hourly: {},
48
56
  daily: {},
49
- quotaSnapshots: { byAccount: {}, byModel: {}, byProvider: {} },
57
+ quotaSnapshots: { byAccount: {}, byModel: {}, byProvider: {}, byProviderModel: {} },
50
58
  }
51
59
  this._ringBuffer = []
52
60
  this._recordsSinceLastSave = 0
@@ -64,11 +72,12 @@ export class TokenStats {
64
72
  } catch { /* start fresh */ }
65
73
  // Ensure quotaSnapshots always exists (backward compat for old files)
66
74
  if (!this._stats.quotaSnapshots || typeof this._stats.quotaSnapshots !== 'object') {
67
- this._stats.quotaSnapshots = { byAccount: {}, byModel: {}, byProvider: {} }
75
+ this._stats.quotaSnapshots = { byAccount: {}, byModel: {}, byProvider: {}, byProviderModel: {} }
68
76
  }
69
77
  if (!this._stats.quotaSnapshots.byAccount) this._stats.quotaSnapshots.byAccount = {}
70
78
  if (!this._stats.quotaSnapshots.byModel) this._stats.quotaSnapshots.byModel = {}
71
79
  if (!this._stats.quotaSnapshots.byProvider) this._stats.quotaSnapshots.byProvider = {}
80
+ if (!this._stats.quotaSnapshots.byProviderModel) this._stats.quotaSnapshots.byProviderModel = {}
72
81
  }
73
82
 
74
83
  _pruneOldLogs() {
@@ -178,6 +187,7 @@ export class TokenStats {
178
187
  * @param {{ quotaPercent: number, providerKey?: string, modelId?: string, updatedAt?: string }} opts
179
188
  */
180
189
  updateQuotaSnapshot(accountId, { quotaPercent, providerKey, modelId, updatedAt } = {}) {
190
+ const previousSnap = this._stats.quotaSnapshots.byAccount[accountId]
181
191
  const snap = {
182
192
  quotaPercent,
183
193
  updatedAt: updatedAt || new Date().toISOString(),
@@ -187,9 +197,20 @@ export class TokenStats {
187
197
 
188
198
  this._stats.quotaSnapshots.byAccount[accountId] = snap
189
199
 
200
+ // 📖 Recompute the old aggregate buckets first when an account switches model/provider.
201
+ if (previousSnap?.modelId !== undefined) {
202
+ this._recomputeModelQuota(previousSnap.modelId)
203
+ }
204
+ if (previousSnap?.providerKey !== undefined && previousSnap?.modelId !== undefined) {
205
+ this._recomputeProviderModelQuota(previousSnap.providerKey, previousSnap.modelId)
206
+ }
207
+
190
208
  if (modelId !== undefined) {
191
209
  this._recomputeModelQuota(modelId)
192
210
  }
211
+ if (providerKey !== undefined && modelId !== undefined) {
212
+ this._recomputeProviderModelQuota(providerKey, modelId)
213
+ }
193
214
 
194
215
  if (providerKey !== undefined) {
195
216
  this._stats.quotaSnapshots.byProvider[providerKey] = {
@@ -202,6 +223,17 @@ export class TokenStats {
202
223
  this.save()
203
224
  }
204
225
 
226
+ /**
227
+ * Build a stable key for provider-scoped model quota snapshots.
228
+ *
229
+ * @param {string} providerKey
230
+ * @param {string} modelId
231
+ * @returns {string}
232
+ */
233
+ _providerModelKey(providerKey, modelId) {
234
+ return `${providerKey}::${modelId}`
235
+ }
236
+
205
237
  /**
206
238
  * Recompute the per-model quota snapshot by averaging all account snapshots
207
239
  * that share the given modelId.
@@ -212,7 +244,10 @@ export class TokenStats {
212
244
  const accountSnaps = Object.values(this._stats.quotaSnapshots.byAccount)
213
245
  .filter(s => s.modelId === modelId)
214
246
 
215
- if (accountSnaps.length === 0) return
247
+ if (accountSnaps.length === 0) {
248
+ delete this._stats.quotaSnapshots.byModel[modelId]
249
+ return
250
+ }
216
251
 
217
252
  const avgPercent = Math.round(
218
253
  accountSnaps.reduce((sum, s) => sum + s.quotaPercent, 0) / accountSnaps.length
@@ -228,6 +263,39 @@ export class TokenStats {
228
263
  }
229
264
  }
230
265
 
266
+ /**
267
+ * Recompute the provider-scoped quota snapshot for one Origin + model pair.
268
+ * This is the canonical source used by the TUI Usage column.
269
+ *
270
+ * @param {string} providerKey
271
+ * @param {string} modelId
272
+ */
273
+ _recomputeProviderModelQuota(providerKey, modelId) {
274
+ const accountSnaps = Object.values(this._stats.quotaSnapshots.byAccount)
275
+ .filter((s) => s.providerKey === providerKey && s.modelId === modelId)
276
+
277
+ const key = this._providerModelKey(providerKey, modelId)
278
+ if (accountSnaps.length === 0) {
279
+ delete this._stats.quotaSnapshots.byProviderModel[key]
280
+ return
281
+ }
282
+
283
+ const avgPercent = Math.round(
284
+ accountSnaps.reduce((sum, s) => sum + s.quotaPercent, 0) / accountSnaps.length
285
+ )
286
+ const latestUpdatedAt = accountSnaps.reduce(
287
+ (latest, s) => (s.updatedAt > latest ? s.updatedAt : latest),
288
+ accountSnaps[0].updatedAt
289
+ )
290
+
291
+ this._stats.quotaSnapshots.byProviderModel[key] = {
292
+ quotaPercent: avgPercent,
293
+ updatedAt: latestUpdatedAt,
294
+ providerKey,
295
+ modelId,
296
+ }
297
+ }
298
+
231
299
  /**
232
300
  * Return a summary snapshot including the 10 most-recent requests.
233
301
  *
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @file token-usage-reader.js
3
+ * @description Reads historical token usage from request-log.jsonl and aggregates it by exact provider + model pair.
4
+ *
5
+ * @details
6
+ * The TUI already shows live latency and quota state, but that does not tell
7
+ * you how much you've actually consumed on a given Origin. This module reads
8
+ * the persistent JSONL request log once at startup and builds a compact
9
+ * `provider::model -> totalTokens` map for table display.
10
+ *
11
+ * Why this exists:
12
+ * - `token-stats.json` keeps convenience aggregates, but not the exact
13
+ * provider+model sum needed for the new table column.
14
+ * - `request-log.jsonl` is the source of truth because every proxied request
15
+ * records prompt and completion token counts with provider context.
16
+ * - Startup-only parsing keeps runtime overhead negligible during TUI redraws.
17
+ *
18
+ * @functions
19
+ * → `buildProviderModelTokenKey` — creates a stable aggregation key
20
+ * → `loadTokenUsageByProviderModel` — reads request-log.jsonl and returns total tokens by provider+model
21
+ * → `formatTokenTotalCompact` — renders totals as integer K / M strings for narrow columns
22
+ *
23
+ * @exports buildProviderModelTokenKey, loadTokenUsageByProviderModel, formatTokenTotalCompact
24
+ *
25
+ * @see src/log-reader.js
26
+ * @see src/render-table.js
27
+ */
28
+
29
+ import { loadRecentLogs } from './log-reader.js'
30
+
31
+ // 📖 buildProviderModelTokenKey keeps provider-scoped totals isolated even when
32
+ // 📖 multiple Origins expose the same model ID.
33
+ export function buildProviderModelTokenKey(providerKey, modelId) {
34
+ return `${providerKey}::${modelId}`
35
+ }
36
+
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 })
41
+ const totals = {}
42
+
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
48
+
49
+ const key = buildProviderModelTokenKey(providerKey, modelId)
50
+ totals[key] = (totals[key] || 0) + tokens
51
+ }
52
+
53
+ return totals
54
+ }
55
+
56
+ // 📖 formatTokenTotalCompact keeps the new column narrow and scannable:
57
+ // 📖 0-999 => raw integer, 1k-999k => Nk, 1m+ => NM, no decimals.
58
+ export function formatTokenTotalCompact(totalTokens) {
59
+ const safeTotal = Number(totalTokens) || 0
60
+ if (safeTotal >= 1_000_000) return `${Math.floor(safeTotal / 1_000_000)}M`
61
+ if (safeTotal >= 1_000) return `${Math.floor(safeTotal / 1_000)}k`
62
+ return String(Math.floor(safeTotal))
63
+ }