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.
- package/CHANGELOG.md +40 -0
- package/README.md +112 -1134
- package/bin/free-coding-models.js +34 -188
- package/package.json +2 -3
- package/src/cli-help.js +0 -18
- package/src/config.js +17 -351
- package/src/endpoint-installer.js +26 -64
- package/src/favorites.js +0 -14
- package/src/key-handler.js +74 -641
- package/src/legacy-proxy-cleanup.js +432 -0
- package/src/openclaw.js +69 -108
- package/src/opencode-config.js +48 -0
- package/src/opencode.js +6 -248
- package/src/overlays.js +26 -550
- package/src/product-flags.js +14 -0
- package/src/render-helpers.js +2 -34
- package/src/render-table.js +14 -33
- package/src/testfcm.js +90 -43
- package/src/token-usage-reader.js +9 -38
- package/src/tool-launchers.js +235 -409
- package/src/tool-metadata.js +0 -7
- package/src/utils.js +8 -77
- package/bin/fcm-proxy-daemon.js +0 -242
- package/src/account-manager.js +0 -634
- package/src/anthropic-translator.js +0 -440
- package/src/daemon-manager.js +0 -527
- package/src/error-classifier.js +0 -154
- package/src/log-reader.js +0 -195
- package/src/opencode-sync.js +0 -200
- package/src/proxy-server.js +0 -1477
- package/src/proxy-sync.js +0 -565
- package/src/proxy-topology.js +0 -85
- package/src/request-transformer.js +0 -180
- package/src/responses-translator.js +0 -423
- package/src/token-stats.js +0 -320
package/src/token-stats.js
DELETED
|
@@ -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
|
-
}
|