free-coding-models 0.3.9 → 0.3.12

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.
@@ -1,320 +0,0 @@
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, requestedModelId?: string, switched?: boolean, switchReason?: string, switchedFromProviderKey?: string, switchedFromModelId?: string }} 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
- requestedModelId,
117
- switched = false,
118
- switchReason,
119
- switchedFromProviderKey,
120
- switchedFromModelId,
121
- } = entry
122
- const totalTokens = promptTokens + completionTokens
123
- const now = new Date()
124
- const hourKey = now.toISOString().slice(0, 13)
125
- const dayKey = now.toISOString().slice(0, 10)
126
-
127
- // By account
128
- const acct = this._stats.byAccount[accountId] ||= { requests: 0, tokens: 0, errors: 0 }
129
- acct.requests++
130
- acct.tokens += totalTokens
131
- if (!success) acct.errors++
132
-
133
- // By model
134
- const model = this._stats.byModel[modelId] ||= { requests: 0, tokens: 0 }
135
- model.requests++
136
- model.tokens += totalTokens
137
-
138
- // Hourly
139
- this._stats.hourly[hourKey] ||= { requests: 0, tokens: 0 }
140
- this._stats.hourly[hourKey].requests++
141
- this._stats.hourly[hourKey].tokens += totalTokens
142
-
143
- // Daily
144
- this._stats.daily[dayKey] ||= { requests: 0, tokens: 0 }
145
- this._stats.daily[dayKey].requests++
146
- this._stats.daily[dayKey].tokens += totalTokens
147
-
148
- // Ring buffer (newest at end)
149
- this._ringBuffer.push({ ...entry, timestamp: now.toISOString() })
150
- if (this._ringBuffer.length > MAX_RING_BUFFER) this._ringBuffer.shift()
151
-
152
- // JSONL log
153
- try {
154
- const logEntry = {
155
- timestamp: now.toISOString(),
156
- accountId,
157
- modelId,
158
- providerKey,
159
- statusCode,
160
- requestType,
161
- promptTokens,
162
- completionTokens,
163
- latencyMs,
164
- success,
165
- ...(typeof requestedModelId === 'string' && requestedModelId.length > 0 && { requestedModelId }),
166
- ...(switched === true && { switched: true }),
167
- ...(typeof switchReason === 'string' && switchReason.length > 0 && { switchReason }),
168
- ...(typeof switchedFromProviderKey === 'string' && switchedFromProviderKey.length > 0 && { switchedFromProviderKey }),
169
- ...(typeof switchedFromModelId === 'string' && switchedFromModelId.length > 0 && { switchedFromModelId }),
170
- }
171
- appendFileSync(this._logFile, JSON.stringify(logEntry) + '\n')
172
- } catch { /* ignore */ }
173
-
174
- // Auto-save every 10 records
175
- this._recordsSinceLastSave++
176
- if (this._recordsSinceLastSave >= 10) this.save()
177
- }
178
-
179
- save() {
180
- try {
181
- mkdirSync(this._dataDir, { recursive: true })
182
- writeFileSync(this._statsFile, JSON.stringify(this._stats, null, 2))
183
- this._recordsSinceLastSave = 0
184
- } catch { /* ignore */ }
185
- }
186
-
187
- /**
188
- * Persist a quota snapshot for a single account.
189
- * Also recomputes the per-model aggregate quota if modelId is provided.
190
- * Tracks latest provider-level quota snapshot when providerKey is provided.
191
- *
192
- * Quota snapshots are lightweight (not per-request) and are written to
193
- * token-stats.json immediately so the TUI can read them without waiting
194
- * for the next 10-record auto-save cycle.
195
- *
196
- * @param {string} accountId
197
- * @param {{ quotaPercent: number, providerKey?: string, modelId?: string, updatedAt?: string }} opts
198
- */
199
- updateQuotaSnapshot(accountId, { quotaPercent, providerKey, modelId, updatedAt } = {}) {
200
- const previousSnap = this._stats.quotaSnapshots.byAccount[accountId]
201
- const snap = {
202
- quotaPercent,
203
- updatedAt: updatedAt || new Date().toISOString(),
204
- }
205
- if (providerKey !== undefined) snap.providerKey = providerKey
206
- if (modelId !== undefined) snap.modelId = modelId
207
-
208
- this._stats.quotaSnapshots.byAccount[accountId] = snap
209
-
210
- // 📖 Recompute the old aggregate buckets first when an account switches model/provider.
211
- if (previousSnap?.modelId !== undefined) {
212
- this._recomputeModelQuota(previousSnap.modelId)
213
- }
214
- if (previousSnap?.providerKey !== undefined && previousSnap?.modelId !== undefined) {
215
- this._recomputeProviderModelQuota(previousSnap.providerKey, previousSnap.modelId)
216
- }
217
-
218
- if (modelId !== undefined) {
219
- this._recomputeModelQuota(modelId)
220
- }
221
- if (providerKey !== undefined && modelId !== undefined) {
222
- this._recomputeProviderModelQuota(providerKey, modelId)
223
- }
224
-
225
- if (providerKey !== undefined) {
226
- this._stats.quotaSnapshots.byProvider[providerKey] = {
227
- quotaPercent,
228
- updatedAt: snap.updatedAt,
229
- }
230
- }
231
-
232
- // Persist immediately (quota data must be fresh for TUI reads)
233
- this.save()
234
- }
235
-
236
- /**
237
- * Build a stable key for provider-scoped model quota snapshots.
238
- *
239
- * @param {string} providerKey
240
- * @param {string} modelId
241
- * @returns {string}
242
- */
243
- _providerModelKey(providerKey, modelId) {
244
- return `${providerKey}::${modelId}`
245
- }
246
-
247
- /**
248
- * Recompute the per-model quota snapshot by averaging all account snapshots
249
- * that share the given modelId.
250
- *
251
- * @param {string} modelId
252
- */
253
- _recomputeModelQuota(modelId) {
254
- const accountSnaps = Object.values(this._stats.quotaSnapshots.byAccount)
255
- .filter(s => s.modelId === modelId)
256
-
257
- if (accountSnaps.length === 0) {
258
- delete this._stats.quotaSnapshots.byModel[modelId]
259
- return
260
- }
261
-
262
- const avgPercent = Math.round(
263
- accountSnaps.reduce((sum, s) => sum + s.quotaPercent, 0) / accountSnaps.length
264
- )
265
- const latestUpdatedAt = accountSnaps.reduce(
266
- (latest, s) => (s.updatedAt > latest ? s.updatedAt : latest),
267
- accountSnaps[0].updatedAt
268
- )
269
-
270
- this._stats.quotaSnapshots.byModel[modelId] = {
271
- quotaPercent: avgPercent,
272
- updatedAt: latestUpdatedAt,
273
- }
274
- }
275
-
276
- /**
277
- * Recompute the provider-scoped quota snapshot for one Origin + model pair.
278
- * This is the canonical source used by the TUI Usage column.
279
- *
280
- * @param {string} providerKey
281
- * @param {string} modelId
282
- */
283
- _recomputeProviderModelQuota(providerKey, modelId) {
284
- const accountSnaps = Object.values(this._stats.quotaSnapshots.byAccount)
285
- .filter((s) => s.providerKey === providerKey && s.modelId === modelId)
286
-
287
- const key = this._providerModelKey(providerKey, modelId)
288
- if (accountSnaps.length === 0) {
289
- delete this._stats.quotaSnapshots.byProviderModel[key]
290
- return
291
- }
292
-
293
- const avgPercent = Math.round(
294
- accountSnaps.reduce((sum, s) => sum + s.quotaPercent, 0) / accountSnaps.length
295
- )
296
- const latestUpdatedAt = accountSnaps.reduce(
297
- (latest, s) => (s.updatedAt > latest ? s.updatedAt : latest),
298
- accountSnaps[0].updatedAt
299
- )
300
-
301
- this._stats.quotaSnapshots.byProviderModel[key] = {
302
- quotaPercent: avgPercent,
303
- updatedAt: latestUpdatedAt,
304
- providerKey,
305
- modelId,
306
- }
307
- }
308
-
309
- /**
310
- * Return a summary snapshot including the 10 most-recent requests.
311
- *
312
- * @returns {{ byAccount: object, byModel: object, hourly: object, daily: object, recentRequests: object[] }}
313
- */
314
- getSummary() {
315
- return {
316
- ...this._stats,
317
- recentRequests: this._ringBuffer.slice(-10),
318
- }
319
- }
320
- }