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.
- package/README.md +6 -17
- package/bin/free-coding-models.js +297 -4754
- package/package.json +2 -2
- package/src/analysis.js +197 -0
- package/src/constants.js +116 -0
- package/src/favorites.js +98 -0
- package/src/key-handler.js +1005 -0
- package/src/openclaw.js +131 -0
- package/src/opencode.js +952 -0
- package/src/overlays.js +840 -0
- package/src/ping.js +186 -0
- package/src/provider-metadata.js +218 -0
- package/src/quota-capabilities.js +112 -0
- package/src/render-helpers.js +239 -0
- package/src/render-table.js +567 -0
- package/src/setup.js +105 -0
- package/src/telemetry.js +382 -0
- package/src/tier-colors.js +37 -0
- package/{lib → src}/token-stats.js +71 -3
- package/src/token-usage-reader.js +63 -0
- package/src/updater.js +237 -0
- package/{lib → src}/usage-reader.js +63 -21
- package/lib/quota-capabilities.js +0 -79
- /package/{lib → src}/account-manager.js +0 -0
- /package/{lib → src}/config.js +0 -0
- /package/{lib → src}/error-classifier.js +0 -0
- /package/{lib → src}/log-reader.js +0 -0
- /package/{lib → src}/model-merger.js +0 -0
- /package/{lib → src}/opencode-sync.js +0 -0
- /package/{lib → src}/provider-quota-fetchers.js +0 -0
- /package/{lib → src}/proxy-server.js +0 -0
- /package/{lib → src}/request-transformer.js +0 -0
- /package/{lib → src}/utils.js +0 -0
package/src/telemetry.js
ADDED
|
@@ -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)
|
|
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
|
+
}
|