free-coding-models 0.1.82 → 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 +53 -51
- package/bin/free-coding-models.js +429 -4276
- package/package.json +2 -2
- package/sources.js +3 -2
- package/src/account-manager.js +600 -0
- package/src/analysis.js +197 -0
- package/{lib → src}/config.js +122 -0
- package/src/constants.js +116 -0
- package/src/error-classifier.js +154 -0
- package/src/favorites.js +98 -0
- package/src/key-handler.js +1005 -0
- package/src/log-reader.js +174 -0
- package/src/model-merger.js +78 -0
- package/src/openclaw.js +131 -0
- package/src/opencode-sync.js +159 -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/provider-quota-fetchers.js +319 -0
- package/src/proxy-server.js +543 -0
- package/src/quota-capabilities.js +112 -0
- package/src/render-helpers.js +239 -0
- package/src/render-table.js +567 -0
- package/src/request-transformer.js +180 -0
- package/src/setup.js +105 -0
- package/src/telemetry.js +382 -0
- package/src/tier-colors.js +37 -0
- package/src/token-stats.js +310 -0
- package/src/token-usage-reader.js +63 -0
- package/src/updater.js +237 -0
- package/src/usage-reader.js +245 -0
- package/{lib → src}/utils.js +55 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file lib/token-stats.js
|
|
3
|
+
* @description Persistent token usage tracking for the multi-account proxy.
|
|
4
|
+
*
|
|
5
|
+
* Records per-account and per-model token usage, hourly/daily aggregates,
|
|
6
|
+
* an in-memory ring buffer of the 100 most-recent requests, and an
|
|
7
|
+
* append-only JSONL log file for detailed history.
|
|
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
|
+
*
|
|
17
|
+
* Storage locations:
|
|
18
|
+
* ~/.free-coding-models/token-stats.json — aggregated stats (auto-saved every 10 records)
|
|
19
|
+
* ~/.free-coding-models/request-log.jsonl — timestamped per-request log (pruned after 30 days)
|
|
20
|
+
*
|
|
21
|
+
* @exports TokenStats
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
import { homedir } from 'node:os'
|
|
27
|
+
|
|
28
|
+
const DEFAULT_DATA_DIR = join(homedir(), '.free-coding-models')
|
|
29
|
+
const MAX_RING_BUFFER = 100
|
|
30
|
+
const RETENTION_DAYS = 30
|
|
31
|
+
|
|
32
|
+
function timestampToMillis(value) {
|
|
33
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
34
|
+
if (typeof value === 'string') {
|
|
35
|
+
const numeric = Number(value)
|
|
36
|
+
if (Number.isFinite(numeric)) return numeric
|
|
37
|
+
const parsed = Date.parse(value)
|
|
38
|
+
if (!Number.isNaN(parsed)) return parsed
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class TokenStats {
|
|
44
|
+
/**
|
|
45
|
+
* @param {{ dataDir?: string }} [opts]
|
|
46
|
+
* dataDir — override the default ~/.free-coding-models directory (used in tests)
|
|
47
|
+
*/
|
|
48
|
+
constructor({ dataDir } = {}) {
|
|
49
|
+
this._dataDir = dataDir || DEFAULT_DATA_DIR
|
|
50
|
+
this._statsFile = join(this._dataDir, 'token-stats.json')
|
|
51
|
+
this._logFile = join(this._dataDir, 'request-log.jsonl')
|
|
52
|
+
this._stats = {
|
|
53
|
+
byAccount: {},
|
|
54
|
+
byModel: {},
|
|
55
|
+
hourly: {},
|
|
56
|
+
daily: {},
|
|
57
|
+
quotaSnapshots: { byAccount: {}, byModel: {}, byProvider: {}, byProviderModel: {} },
|
|
58
|
+
}
|
|
59
|
+
this._ringBuffer = []
|
|
60
|
+
this._recordsSinceLastSave = 0
|
|
61
|
+
this._load()
|
|
62
|
+
setImmediate(() => this._pruneOldLogs())
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_load() {
|
|
66
|
+
try {
|
|
67
|
+
mkdirSync(this._dataDir, { recursive: true })
|
|
68
|
+
if (existsSync(this._statsFile)) {
|
|
69
|
+
const loaded = JSON.parse(readFileSync(this._statsFile, 'utf8'))
|
|
70
|
+
this._stats = loaded
|
|
71
|
+
}
|
|
72
|
+
} catch { /* start fresh */ }
|
|
73
|
+
// Ensure quotaSnapshots always exists (backward compat for old files)
|
|
74
|
+
if (!this._stats.quotaSnapshots || typeof this._stats.quotaSnapshots !== 'object') {
|
|
75
|
+
this._stats.quotaSnapshots = { byAccount: {}, byModel: {}, byProvider: {}, byProviderModel: {} }
|
|
76
|
+
}
|
|
77
|
+
if (!this._stats.quotaSnapshots.byAccount) this._stats.quotaSnapshots.byAccount = {}
|
|
78
|
+
if (!this._stats.quotaSnapshots.byModel) this._stats.quotaSnapshots.byModel = {}
|
|
79
|
+
if (!this._stats.quotaSnapshots.byProvider) this._stats.quotaSnapshots.byProvider = {}
|
|
80
|
+
if (!this._stats.quotaSnapshots.byProviderModel) this._stats.quotaSnapshots.byProviderModel = {}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_pruneOldLogs() {
|
|
84
|
+
try {
|
|
85
|
+
if (!existsSync(this._logFile)) return
|
|
86
|
+
const cutoff = Date.now() - RETENTION_DAYS * 86400000
|
|
87
|
+
const lines = readFileSync(this._logFile, 'utf8').split('\n').filter(Boolean)
|
|
88
|
+
const kept = lines.filter(line => {
|
|
89
|
+
try {
|
|
90
|
+
const millis = timestampToMillis(JSON.parse(line).timestamp)
|
|
91
|
+
return millis !== null && millis >= cutoff
|
|
92
|
+
} catch {
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
writeFileSync(this._logFile, kept.join('\n') + (kept.length ? '\n' : ''))
|
|
97
|
+
} catch { /* ignore */ }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Record a single request's token usage.
|
|
102
|
+
*
|
|
103
|
+
* @param {{ accountId: string, modelId: string, providerKey?: string, statusCode?: number|string, requestType?: string, promptTokens?: number, completionTokens?: number, latencyMs?: number, success?: boolean }} entry
|
|
104
|
+
*/
|
|
105
|
+
record(entry) {
|
|
106
|
+
const {
|
|
107
|
+
accountId,
|
|
108
|
+
modelId,
|
|
109
|
+
providerKey = 'unknown',
|
|
110
|
+
statusCode = 200,
|
|
111
|
+
requestType = 'chat.completions',
|
|
112
|
+
promptTokens = 0,
|
|
113
|
+
completionTokens = 0,
|
|
114
|
+
latencyMs = 0,
|
|
115
|
+
success = true,
|
|
116
|
+
} = entry
|
|
117
|
+
const totalTokens = promptTokens + completionTokens
|
|
118
|
+
const now = new Date()
|
|
119
|
+
const hourKey = now.toISOString().slice(0, 13)
|
|
120
|
+
const dayKey = now.toISOString().slice(0, 10)
|
|
121
|
+
|
|
122
|
+
// By account
|
|
123
|
+
const acct = this._stats.byAccount[accountId] ||= { requests: 0, tokens: 0, errors: 0 }
|
|
124
|
+
acct.requests++
|
|
125
|
+
acct.tokens += totalTokens
|
|
126
|
+
if (!success) acct.errors++
|
|
127
|
+
|
|
128
|
+
// By model
|
|
129
|
+
const model = this._stats.byModel[modelId] ||= { requests: 0, tokens: 0 }
|
|
130
|
+
model.requests++
|
|
131
|
+
model.tokens += totalTokens
|
|
132
|
+
|
|
133
|
+
// Hourly
|
|
134
|
+
this._stats.hourly[hourKey] ||= { requests: 0, tokens: 0 }
|
|
135
|
+
this._stats.hourly[hourKey].requests++
|
|
136
|
+
this._stats.hourly[hourKey].tokens += totalTokens
|
|
137
|
+
|
|
138
|
+
// Daily
|
|
139
|
+
this._stats.daily[dayKey] ||= { requests: 0, tokens: 0 }
|
|
140
|
+
this._stats.daily[dayKey].requests++
|
|
141
|
+
this._stats.daily[dayKey].tokens += totalTokens
|
|
142
|
+
|
|
143
|
+
// Ring buffer (newest at end)
|
|
144
|
+
this._ringBuffer.push({ ...entry, timestamp: now.toISOString() })
|
|
145
|
+
if (this._ringBuffer.length > MAX_RING_BUFFER) this._ringBuffer.shift()
|
|
146
|
+
|
|
147
|
+
// JSONL log
|
|
148
|
+
try {
|
|
149
|
+
const logEntry = {
|
|
150
|
+
timestamp: now.toISOString(),
|
|
151
|
+
accountId,
|
|
152
|
+
modelId,
|
|
153
|
+
providerKey,
|
|
154
|
+
statusCode,
|
|
155
|
+
requestType,
|
|
156
|
+
promptTokens,
|
|
157
|
+
completionTokens,
|
|
158
|
+
latencyMs,
|
|
159
|
+
success,
|
|
160
|
+
}
|
|
161
|
+
appendFileSync(this._logFile, JSON.stringify(logEntry) + '\n')
|
|
162
|
+
} catch { /* ignore */ }
|
|
163
|
+
|
|
164
|
+
// Auto-save every 10 records
|
|
165
|
+
this._recordsSinceLastSave++
|
|
166
|
+
if (this._recordsSinceLastSave >= 10) this.save()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
save() {
|
|
170
|
+
try {
|
|
171
|
+
mkdirSync(this._dataDir, { recursive: true })
|
|
172
|
+
writeFileSync(this._statsFile, JSON.stringify(this._stats, null, 2))
|
|
173
|
+
this._recordsSinceLastSave = 0
|
|
174
|
+
} catch { /* ignore */ }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Persist a quota snapshot for a single account.
|
|
179
|
+
* Also recomputes the per-model aggregate quota if modelId is provided.
|
|
180
|
+
* Tracks latest provider-level quota snapshot when providerKey is provided.
|
|
181
|
+
*
|
|
182
|
+
* Quota snapshots are lightweight (not per-request) and are written to
|
|
183
|
+
* token-stats.json immediately so the TUI can read them without waiting
|
|
184
|
+
* for the next 10-record auto-save cycle.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} accountId
|
|
187
|
+
* @param {{ quotaPercent: number, providerKey?: string, modelId?: string, updatedAt?: string }} opts
|
|
188
|
+
*/
|
|
189
|
+
updateQuotaSnapshot(accountId, { quotaPercent, providerKey, modelId, updatedAt } = {}) {
|
|
190
|
+
const previousSnap = this._stats.quotaSnapshots.byAccount[accountId]
|
|
191
|
+
const snap = {
|
|
192
|
+
quotaPercent,
|
|
193
|
+
updatedAt: updatedAt || new Date().toISOString(),
|
|
194
|
+
}
|
|
195
|
+
if (providerKey !== undefined) snap.providerKey = providerKey
|
|
196
|
+
if (modelId !== undefined) snap.modelId = modelId
|
|
197
|
+
|
|
198
|
+
this._stats.quotaSnapshots.byAccount[accountId] = snap
|
|
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
|
+
|
|
208
|
+
if (modelId !== undefined) {
|
|
209
|
+
this._recomputeModelQuota(modelId)
|
|
210
|
+
}
|
|
211
|
+
if (providerKey !== undefined && modelId !== undefined) {
|
|
212
|
+
this._recomputeProviderModelQuota(providerKey, modelId)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (providerKey !== undefined) {
|
|
216
|
+
this._stats.quotaSnapshots.byProvider[providerKey] = {
|
|
217
|
+
quotaPercent,
|
|
218
|
+
updatedAt: snap.updatedAt,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Persist immediately (quota data must be fresh for TUI reads)
|
|
223
|
+
this.save()
|
|
224
|
+
}
|
|
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
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Recompute the per-model quota snapshot by averaging all account snapshots
|
|
239
|
+
* that share the given modelId.
|
|
240
|
+
*
|
|
241
|
+
* @param {string} modelId
|
|
242
|
+
*/
|
|
243
|
+
_recomputeModelQuota(modelId) {
|
|
244
|
+
const accountSnaps = Object.values(this._stats.quotaSnapshots.byAccount)
|
|
245
|
+
.filter(s => s.modelId === modelId)
|
|
246
|
+
|
|
247
|
+
if (accountSnaps.length === 0) {
|
|
248
|
+
delete this._stats.quotaSnapshots.byModel[modelId]
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const avgPercent = Math.round(
|
|
253
|
+
accountSnaps.reduce((sum, s) => sum + s.quotaPercent, 0) / accountSnaps.length
|
|
254
|
+
)
|
|
255
|
+
const latestUpdatedAt = accountSnaps.reduce(
|
|
256
|
+
(latest, s) => (s.updatedAt > latest ? s.updatedAt : latest),
|
|
257
|
+
accountSnaps[0].updatedAt
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
this._stats.quotaSnapshots.byModel[modelId] = {
|
|
261
|
+
quotaPercent: avgPercent,
|
|
262
|
+
updatedAt: latestUpdatedAt,
|
|
263
|
+
}
|
|
264
|
+
}
|
|
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
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Return a summary snapshot including the 10 most-recent requests.
|
|
301
|
+
*
|
|
302
|
+
* @returns {{ byAccount: object, byModel: object, hourly: object, daily: object, recentRequests: object[] }}
|
|
303
|
+
*/
|
|
304
|
+
getSummary() {
|
|
305
|
+
return {
|
|
306
|
+
...this._stats,
|
|
307
|
+
recentRequests: this._ringBuffer.slice(-10),
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
@@ -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
|
+
}
|
package/src/updater.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file updater.js
|
|
3
|
+
* @description Update detection and installation helpers, extracted from bin/free-coding-models.js.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* This module handles all npm version-check and auto-update logic:
|
|
7
|
+
*
|
|
8
|
+
* - `checkForUpdateDetailed()` — hits the npm registry to compare the published version
|
|
9
|
+
* against the locally installed one. Returns `{ latestVersion, error }` so callers
|
|
10
|
+
* can surface meaningful status text in the Settings overlay.
|
|
11
|
+
*
|
|
12
|
+
* - `checkForUpdate()` — thin backward-compatible wrapper used at startup for the
|
|
13
|
+
* auto-update guard. Returns `latestVersion` (string) or `null`.
|
|
14
|
+
*
|
|
15
|
+
* - `runUpdate(latestVersion)` — runs `npm i -g free-coding-models@<version> --prefer-online`,
|
|
16
|
+
* retrying with `sudo` on EACCES/EPERM. On success, relaunches the process with the
|
|
17
|
+
* same argv. On failure, prints manual instructions and exits with code 1.
|
|
18
|
+
* Uses `require('child_process').execSync` inline because ESM dynamic import is async
|
|
19
|
+
* but `execSync` must block to give `stdio: 'inherit'` feedback in the terminal.
|
|
20
|
+
*
|
|
21
|
+
* - `promptUpdateNotification(latestVersion)` — renders a small centered interactive menu
|
|
22
|
+
* that lets the user choose: Update Now / Read Changelogs / Continue without update.
|
|
23
|
+
* Uses raw mode readline keypress events (same pattern as the main TUI).
|
|
24
|
+
* This function is called BEFORE the alt-screen is entered, so it writes to the
|
|
25
|
+
* normal terminal buffer.
|
|
26
|
+
*
|
|
27
|
+
* ⚙️ Notes:
|
|
28
|
+
* - `LOCAL_VERSION` is resolved from package.json via `createRequire` so this module
|
|
29
|
+
* can be imported independently from the bin entry point.
|
|
30
|
+
* - The auto-update flow in `main()` skips update if `isDevMode` is detected (presence of
|
|
31
|
+
* a `.git` directory next to the package root) to avoid an infinite update loop in dev.
|
|
32
|
+
*
|
|
33
|
+
* @functions
|
|
34
|
+
* → checkForUpdateDetailed() — Fetch npm latest with explicit error info
|
|
35
|
+
* → checkForUpdate() — Startup wrapper, returns version string or null
|
|
36
|
+
* → runUpdate(latestVersion) — Install new version via npm global + relaunch
|
|
37
|
+
* → promptUpdateNotification(version) — Interactive pre-TUI update menu
|
|
38
|
+
*
|
|
39
|
+
* @exports
|
|
40
|
+
* checkForUpdateDetailed, checkForUpdate, runUpdate, promptUpdateNotification
|
|
41
|
+
*
|
|
42
|
+
* @see bin/free-coding-models.js — calls checkForUpdate() at startup and runUpdate() on confirm
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import chalk from 'chalk'
|
|
46
|
+
import { createRequire } from 'module'
|
|
47
|
+
|
|
48
|
+
const require = createRequire(import.meta.url)
|
|
49
|
+
const readline = require('readline')
|
|
50
|
+
const pkg = require('../package.json')
|
|
51
|
+
const LOCAL_VERSION = pkg.version
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 📖 checkForUpdateDetailed: Fetch npm latest version with explicit error details.
|
|
55
|
+
* 📖 Used by settings manual-check flow to display meaningful status in the UI.
|
|
56
|
+
* @returns {Promise<{ latestVersion: string|null, error: string|null }>}
|
|
57
|
+
*/
|
|
58
|
+
export async function checkForUpdateDetailed() {
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch('https://registry.npmjs.org/free-coding-models/latest', { signal: AbortSignal.timeout(5000) })
|
|
61
|
+
if (!res.ok) return { latestVersion: null, error: `HTTP ${res.status}` }
|
|
62
|
+
const data = await res.json()
|
|
63
|
+
if (data.version && data.version !== LOCAL_VERSION) return { latestVersion: data.version, error: null }
|
|
64
|
+
return { latestVersion: null, error: null }
|
|
65
|
+
} catch (error) {
|
|
66
|
+
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
67
|
+
return { latestVersion: null, error: message }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 📖 checkForUpdate: Backward-compatible wrapper for startup update prompt.
|
|
73
|
+
* @returns {Promise<string|null>}
|
|
74
|
+
*/
|
|
75
|
+
export async function checkForUpdate() {
|
|
76
|
+
const { latestVersion } = await checkForUpdateDetailed()
|
|
77
|
+
return latestVersion
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 📖 runUpdate: Run npm global install to update to latestVersion.
|
|
82
|
+
* 📖 Retries with sudo on permission errors.
|
|
83
|
+
* 📖 Relaunches the process on success, exits with code 1 on failure.
|
|
84
|
+
* @param {string} latestVersion
|
|
85
|
+
*/
|
|
86
|
+
export function runUpdate(latestVersion) {
|
|
87
|
+
const { execSync } = require('child_process')
|
|
88
|
+
console.log()
|
|
89
|
+
console.log(chalk.bold.cyan(' ⬆ Updating free-coding-models to v' + latestVersion + '...'))
|
|
90
|
+
console.log()
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
// 📖 Force install from npm registry (ignore local cache)
|
|
94
|
+
// 📖 Use --prefer-online to ensure we get the latest published version
|
|
95
|
+
execSync(`npm i -g free-coding-models@${latestVersion} --prefer-online`, { stdio: 'inherit' })
|
|
96
|
+
console.log()
|
|
97
|
+
console.log(chalk.green(' ✅ Update complete! Version ' + latestVersion + ' installed.'))
|
|
98
|
+
console.log()
|
|
99
|
+
console.log(chalk.dim(' 🔄 Restarting with new version...'))
|
|
100
|
+
console.log()
|
|
101
|
+
|
|
102
|
+
// 📖 Relaunch automatically with the same arguments
|
|
103
|
+
const args = process.argv.slice(2)
|
|
104
|
+
execSync(`node ${process.argv[1]} ${args.join(' ')}`, { stdio: 'inherit' })
|
|
105
|
+
process.exit(0)
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.log()
|
|
108
|
+
// 📖 Check if error is permission-related (EACCES or EPERM)
|
|
109
|
+
const isPermissionError = err.code === 'EACCES' || err.code === 'EPERM' ||
|
|
110
|
+
(err.stderr && (err.stderr.includes('EACCES') || err.stderr.includes('permission') ||
|
|
111
|
+
err.stderr.includes('EACCES'))) ||
|
|
112
|
+
(err.message && (err.message.includes('EACCES') || err.message.includes('permission')))
|
|
113
|
+
|
|
114
|
+
if (isPermissionError) {
|
|
115
|
+
console.log(chalk.yellow(' ⚠️ Permission denied. Retrying with sudo...'))
|
|
116
|
+
console.log()
|
|
117
|
+
try {
|
|
118
|
+
execSync(`sudo npm i -g free-coding-models@${latestVersion} --prefer-online`, { stdio: 'inherit' })
|
|
119
|
+
console.log()
|
|
120
|
+
console.log(chalk.green(' ✅ Update complete with sudo! Version ' + latestVersion + ' installed.'))
|
|
121
|
+
console.log()
|
|
122
|
+
console.log(chalk.dim(' 🔄 Restarting with new version...'))
|
|
123
|
+
console.log()
|
|
124
|
+
|
|
125
|
+
// 📖 Relaunch automatically with the same arguments
|
|
126
|
+
const args = process.argv.slice(2)
|
|
127
|
+
execSync(`node ${process.argv[1]} ${args.join(' ')}`, { stdio: 'inherit' })
|
|
128
|
+
process.exit(0)
|
|
129
|
+
} catch (sudoErr) {
|
|
130
|
+
console.log()
|
|
131
|
+
console.log(chalk.red(' ✖ Update failed even with sudo. Try manually:'))
|
|
132
|
+
console.log(chalk.dim(' sudo npm i -g free-coding-models@' + latestVersion))
|
|
133
|
+
console.log()
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
console.log(chalk.red(' ✖ Update failed. Try manually: npm i -g free-coding-models@' + latestVersion))
|
|
137
|
+
console.log()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
process.exit(1)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 📖 promptUpdateNotification: Show a centered interactive menu when a new version is available.
|
|
145
|
+
* 📖 Returns 'update', 'changelogs', or null (continue without update).
|
|
146
|
+
* 📖 Called BEFORE entering the alt-screen so it renders in the normal terminal buffer.
|
|
147
|
+
* @param {string|null} latestVersion
|
|
148
|
+
* @returns {Promise<'update'|'changelogs'|null>}
|
|
149
|
+
*/
|
|
150
|
+
export async function promptUpdateNotification(latestVersion) {
|
|
151
|
+
if (!latestVersion) return null
|
|
152
|
+
|
|
153
|
+
return new Promise((resolve) => {
|
|
154
|
+
let selected = 0
|
|
155
|
+
const options = [
|
|
156
|
+
{
|
|
157
|
+
label: 'Update now',
|
|
158
|
+
icon: '⬆',
|
|
159
|
+
description: `Update free-coding-models to v${latestVersion}`,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
label: 'Read Changelogs',
|
|
163
|
+
icon: '📋',
|
|
164
|
+
description: 'Open GitHub changelog',
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
label: 'Continue without update',
|
|
168
|
+
icon: '▶',
|
|
169
|
+
description: 'Use current version',
|
|
170
|
+
},
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
// 📖 Centered render function
|
|
174
|
+
const render = () => {
|
|
175
|
+
process.stdout.write('\x1b[2J\x1b[H') // clear screen + cursor home
|
|
176
|
+
|
|
177
|
+
// 📖 Calculate centering
|
|
178
|
+
const terminalWidth = process.stdout.columns || 80
|
|
179
|
+
const maxWidth = Math.min(terminalWidth - 4, 70)
|
|
180
|
+
const centerPad = ' '.repeat(Math.max(0, Math.floor((terminalWidth - maxWidth) / 2)))
|
|
181
|
+
|
|
182
|
+
console.log()
|
|
183
|
+
console.log(centerPad + chalk.bold.red(' ⚠ UPDATE AVAILABLE'))
|
|
184
|
+
console.log(centerPad + chalk.red(` Version ${latestVersion} is ready to install`))
|
|
185
|
+
console.log()
|
|
186
|
+
console.log(centerPad + chalk.bold(' ⚡ Free Coding Models') + chalk.dim(` v${LOCAL_VERSION}`))
|
|
187
|
+
console.log()
|
|
188
|
+
|
|
189
|
+
for (let i = 0; i < options.length; i++) {
|
|
190
|
+
const isSelected = i === selected
|
|
191
|
+
const bullet = isSelected ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
|
|
192
|
+
const label = isSelected
|
|
193
|
+
? chalk.bold.white(options[i].icon + ' ' + options[i].label)
|
|
194
|
+
: chalk.dim(options[i].icon + ' ' + options[i].label)
|
|
195
|
+
|
|
196
|
+
console.log(centerPad + bullet + label)
|
|
197
|
+
console.log(centerPad + chalk.dim(' ' + options[i].description))
|
|
198
|
+
console.log()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.log(centerPad + chalk.dim(' ↑↓ Navigate • Enter Select • Ctrl+C Continue'))
|
|
202
|
+
console.log()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
render()
|
|
206
|
+
|
|
207
|
+
readline.emitKeypressEvents(process.stdin)
|
|
208
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true)
|
|
209
|
+
|
|
210
|
+
const onKey = (_str, key) => {
|
|
211
|
+
if (!key) return
|
|
212
|
+
if (key.ctrl && key.name === 'c') {
|
|
213
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
214
|
+
process.stdin.removeListener('keypress', onKey)
|
|
215
|
+
resolve(null) // Continue without update
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
if (key.name === 'up' && selected > 0) {
|
|
219
|
+
selected--
|
|
220
|
+
render()
|
|
221
|
+
} else if (key.name === 'down' && selected < options.length - 1) {
|
|
222
|
+
selected++
|
|
223
|
+
render()
|
|
224
|
+
} else if (key.name === 'return') {
|
|
225
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
226
|
+
process.stdin.removeListener('keypress', onKey)
|
|
227
|
+
process.stdin.pause()
|
|
228
|
+
|
|
229
|
+
if (selected === 0) resolve('update')
|
|
230
|
+
else if (selected === 1) resolve('changelogs')
|
|
231
|
+
else resolve(null) // Continue without update
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
process.stdin.on('keypress', onKey)
|
|
236
|
+
})
|
|
237
|
+
}
|