free-coding-models 0.1.81 → 0.1.83
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 -40
- package/bin/free-coding-models.js +692 -69
- package/lib/account-manager.js +600 -0
- package/lib/config.js +122 -0
- package/lib/error-classifier.js +154 -0
- package/lib/log-reader.js +174 -0
- package/lib/model-merger.js +78 -0
- package/lib/opencode-sync.js +159 -0
- package/lib/provider-quota-fetchers.js +319 -0
- package/lib/proxy-server.js +543 -0
- package/lib/quota-capabilities.js +79 -0
- package/lib/request-transformer.js +180 -0
- package/lib/token-stats.js +242 -0
- package/lib/usage-reader.js +203 -0
- package/lib/utils.js +55 -0
- package/package.json +1 -1
- package/sources.js +4 -3
package/lib/config.js
CHANGED
|
@@ -85,6 +85,9 @@
|
|
|
85
85
|
* → loadConfig() — Read ~/.free-coding-models.json; auto-migrate old plain-text config if needed
|
|
86
86
|
* → saveConfig(config) — Write config to ~/.free-coding-models.json with 0o600 permissions
|
|
87
87
|
* → getApiKey(config, providerKey) — Get effective API key (env var override > config > null)
|
|
88
|
+
* → addApiKey(config, providerKey, key) — Append a key (string→array); ignores empty/duplicate
|
|
89
|
+
* → removeApiKey(config, providerKey, index?) — Remove key at index (or last); collapses array-of-1 to string; deletes when empty
|
|
90
|
+
* → listApiKeys(config, providerKey) — Return all keys for a provider as normalized array
|
|
88
91
|
* → isProviderEnabled(config, providerKey) — Check if provider is enabled (defaults true)
|
|
89
92
|
* → saveAsProfile(config, name) — Snapshot current apiKeys/providers/favorites/settings into a named profile
|
|
90
93
|
* → loadProfile(config, name) — Apply a named profile's values onto the live config
|
|
@@ -95,6 +98,7 @@
|
|
|
95
98
|
* → _emptyProfileSettings() — Default TUI settings for a profile
|
|
96
99
|
*
|
|
97
100
|
* @exports loadConfig, saveConfig, getApiKey, isProviderEnabled
|
|
101
|
+
* @exports addApiKey, removeApiKey, listApiKeys — multi-key management helpers
|
|
98
102
|
* @exports saveAsProfile, loadProfile, listProfiles, deleteProfile
|
|
99
103
|
* @exports getActiveProfileName, setActiveProfile
|
|
100
104
|
* @exports CONFIG_PATH — path to the JSON config file
|
|
@@ -239,6 +243,124 @@ export function getApiKey(config, providerKey) {
|
|
|
239
243
|
return null
|
|
240
244
|
}
|
|
241
245
|
|
|
246
|
+
/**
|
|
247
|
+
* addApiKey: Append a new API key for a provider.
|
|
248
|
+
*
|
|
249
|
+
* - If the provider has no key yet, sets it as a plain string.
|
|
250
|
+
* - If the provider already has one string key, converts to array [existing, new].
|
|
251
|
+
* - If the provider already has an array, pushes the new key.
|
|
252
|
+
* - Ignores empty/whitespace keys.
|
|
253
|
+
* - Ignores exact duplicates (same string already present).
|
|
254
|
+
*
|
|
255
|
+
* @param {object} config — Live config object (will be mutated)
|
|
256
|
+
* @param {string} providerKey — Provider identifier (e.g. 'groq')
|
|
257
|
+
* @param {string} key — New API key to add
|
|
258
|
+
* @returns {boolean} true if added, false if ignored (empty or duplicate)
|
|
259
|
+
*/
|
|
260
|
+
export function addApiKey(config, providerKey, key) {
|
|
261
|
+
const trimmed = typeof key === 'string' ? key.trim() : ''
|
|
262
|
+
if (!trimmed) return false
|
|
263
|
+
if (!config.apiKeys) config.apiKeys = {}
|
|
264
|
+
const current = config.apiKeys[providerKey]
|
|
265
|
+
if (!current) {
|
|
266
|
+
config.apiKeys[providerKey] = trimmed
|
|
267
|
+
return true
|
|
268
|
+
}
|
|
269
|
+
if (typeof current === 'string') {
|
|
270
|
+
if (current === trimmed) return false // duplicate
|
|
271
|
+
config.apiKeys[providerKey] = [current, trimmed]
|
|
272
|
+
return true
|
|
273
|
+
}
|
|
274
|
+
if (Array.isArray(current)) {
|
|
275
|
+
if (current.includes(trimmed)) return false // duplicate
|
|
276
|
+
current.push(trimmed)
|
|
277
|
+
return true
|
|
278
|
+
}
|
|
279
|
+
// unknown shape — replace
|
|
280
|
+
config.apiKeys[providerKey] = trimmed
|
|
281
|
+
return true
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* removeApiKey: Remove an API key for a provider by index, or remove the last one.
|
|
286
|
+
*
|
|
287
|
+
* - Removes the key at `index` if provided, else removes the last key.
|
|
288
|
+
* - If only one key remains after removal, collapses array to string.
|
|
289
|
+
* - If the last key is removed, deletes the provider entry entirely.
|
|
290
|
+
*
|
|
291
|
+
* @param {object} config — Live config object (will be mutated)
|
|
292
|
+
* @param {string} providerKey — Provider identifier (e.g. 'groq')
|
|
293
|
+
* @param {number} [index] — 0-based index to remove; omit to remove last
|
|
294
|
+
* @returns {boolean} true if a key was removed, false if nothing to remove
|
|
295
|
+
*/
|
|
296
|
+
export function removeApiKey(config, providerKey, index) {
|
|
297
|
+
if (!config.apiKeys) return false
|
|
298
|
+
const current = config.apiKeys[providerKey]
|
|
299
|
+
if (!current) return false
|
|
300
|
+
|
|
301
|
+
if (typeof current === 'string') {
|
|
302
|
+
// Only one key — remove it
|
|
303
|
+
delete config.apiKeys[providerKey]
|
|
304
|
+
return true
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (Array.isArray(current)) {
|
|
308
|
+
const idx = (index !== undefined && index >= 0 && index < current.length) ? index : current.length - 1
|
|
309
|
+
current.splice(idx, 1)
|
|
310
|
+
if (current.length === 0) {
|
|
311
|
+
delete config.apiKeys[providerKey]
|
|
312
|
+
} else if (current.length === 1) {
|
|
313
|
+
config.apiKeys[providerKey] = current[0] // collapse array-of-1 to string
|
|
314
|
+
}
|
|
315
|
+
return true
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return false
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* listApiKeys: Return all configured API keys for a provider as a normalized array.
|
|
323
|
+
* Empty when no key is configured.
|
|
324
|
+
*
|
|
325
|
+
* @param {object} config
|
|
326
|
+
* @param {string} providerKey
|
|
327
|
+
* @returns {string[]}
|
|
328
|
+
*/
|
|
329
|
+
export function listApiKeys(config, providerKey) {
|
|
330
|
+
return resolveApiKeys(config, providerKey)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Resolve all API keys for a provider as an array.
|
|
335
|
+
* Handles: string → [string], string[] → string[], missing → []
|
|
336
|
+
* Filters empty strings. Falls back to envVarName if no config key.
|
|
337
|
+
*/
|
|
338
|
+
export function resolveApiKeys(config, providerKey, envVarName) {
|
|
339
|
+
const raw = config?.apiKeys?.[providerKey]
|
|
340
|
+
let keys = []
|
|
341
|
+
if (Array.isArray(raw)) {
|
|
342
|
+
keys = raw
|
|
343
|
+
} else if (typeof raw === 'string' && raw.length > 0) {
|
|
344
|
+
keys = [raw]
|
|
345
|
+
} else if (envVarName && process.env[envVarName]) {
|
|
346
|
+
keys = [process.env[envVarName]]
|
|
347
|
+
}
|
|
348
|
+
return keys.filter(k => typeof k === 'string' && k.length > 0)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Normalize config for disk persistence.
|
|
353
|
+
* Single-element arrays collapse to string. Multi-element arrays stay.
|
|
354
|
+
*/
|
|
355
|
+
export function normalizeApiKeyConfig(config) {
|
|
356
|
+
if (!config?.apiKeys) return
|
|
357
|
+
for (const [key, val] of Object.entries(config.apiKeys)) {
|
|
358
|
+
if (Array.isArray(val) && val.length === 1) {
|
|
359
|
+
config.apiKeys[key] = val[0]
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
242
364
|
/**
|
|
243
365
|
* 📖 isProviderEnabled: Check if a provider is enabled in config.
|
|
244
366
|
*
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error types:
|
|
3
|
+
* - QUOTA_EXHAUSTED: Skip account until quota resets
|
|
4
|
+
* - RATE_LIMITED: Backoff, try another account
|
|
5
|
+
* - MODEL_CAPACITY: Server overloaded, retry after delay
|
|
6
|
+
* - SERVER_ERROR: Backoff, count toward circuit breaker
|
|
7
|
+
* - AUTH_ERROR: Disable account permanently
|
|
8
|
+
* - NETWORK_ERROR: Connection failure, try another
|
|
9
|
+
* - MODEL_NOT_FOUND: Provider does not have/serve this model; skip and try next account
|
|
10
|
+
* - UNKNOWN: Generic, no retry
|
|
11
|
+
*/
|
|
12
|
+
export const ErrorType = {
|
|
13
|
+
QUOTA_EXHAUSTED: 'QUOTA_EXHAUSTED',
|
|
14
|
+
RATE_LIMITED: 'RATE_LIMITED',
|
|
15
|
+
MODEL_CAPACITY: 'MODEL_CAPACITY',
|
|
16
|
+
SERVER_ERROR: 'SERVER_ERROR',
|
|
17
|
+
AUTH_ERROR: 'AUTH_ERROR',
|
|
18
|
+
NETWORK_ERROR: 'NETWORK_ERROR',
|
|
19
|
+
MODEL_NOT_FOUND: 'MODEL_NOT_FOUND',
|
|
20
|
+
UNKNOWN: 'UNKNOWN',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const QUOTA_KEYWORDS = ['quota', 'limit exceeded', 'billing', 'insufficient_quota', 'exceeded your']
|
|
24
|
+
const CAPACITY_KEYWORDS = ['overloaded', 'capacity', 'busy', 'unavailable']
|
|
25
|
+
/**
|
|
26
|
+
* Keywords that indicate a provider-level 404/410 means the model is not
|
|
27
|
+
* available on *this account/provider*, not a generic routing 404.
|
|
28
|
+
* These trigger rotation to the next provider rather than forwarding the error.
|
|
29
|
+
*/
|
|
30
|
+
const MODEL_NOT_FOUND_KEYWORDS = [
|
|
31
|
+
'model not found',
|
|
32
|
+
'inaccessible',
|
|
33
|
+
'not deployed',
|
|
34
|
+
'model is not available',
|
|
35
|
+
'model unavailable',
|
|
36
|
+
'no such model',
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Classify the confidence level for a 429 response.
|
|
41
|
+
*
|
|
42
|
+
* Returns:
|
|
43
|
+
* - 'quota_exhaustion_likely' — body contains keywords indicating the account's quota is depleted
|
|
44
|
+
* - 'generic_rate_limit' — plain rate-limit with no quota-specific signal (or non-429 status)
|
|
45
|
+
*
|
|
46
|
+
* @param {number} statusCode
|
|
47
|
+
* @param {string} body
|
|
48
|
+
* @param {Object} headers
|
|
49
|
+
* @returns {'quota_exhaustion_likely'|'generic_rate_limit'}
|
|
50
|
+
*/
|
|
51
|
+
export function rateLimitConfidence(statusCode, body, headers) {
|
|
52
|
+
if (statusCode !== 429) return 'generic_rate_limit'
|
|
53
|
+
const bodyLower = (body || '').toLowerCase()
|
|
54
|
+
const isQuota = QUOTA_KEYWORDS.some(kw => bodyLower.includes(kw))
|
|
55
|
+
return isQuota ? 'quota_exhaustion_likely' : 'generic_rate_limit'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Classify an HTTP error response.
|
|
60
|
+
* @param {number} statusCode - 0 for network errors
|
|
61
|
+
* @param {string} body - Response body text or error message
|
|
62
|
+
* @param {Object} headers - Response headers (lowercased keys)
|
|
63
|
+
* @returns {{ type: string, retryAfterSec: number|null, shouldRetry: boolean, skipAccount: boolean, rateLimitConfidence?: string }}
|
|
64
|
+
*/
|
|
65
|
+
export function classifyError(statusCode, body, headers) {
|
|
66
|
+
const bodyLower = (body || '').toLowerCase()
|
|
67
|
+
const retryAfter = headers?.['retry-after']
|
|
68
|
+
const retryAfterSec = retryAfter ? parseInt(retryAfter, 10) || null : null
|
|
69
|
+
|
|
70
|
+
// Network/connection errors
|
|
71
|
+
if (statusCode === 0 || statusCode === undefined) {
|
|
72
|
+
return { type: ErrorType.NETWORK_ERROR, retryAfterSec: 5, shouldRetry: true, skipAccount: false }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (statusCode === 401 || statusCode === 403) {
|
|
76
|
+
return { type: ErrorType.AUTH_ERROR, retryAfterSec: null, shouldRetry: false, skipAccount: true }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Provider-level 404/410: model not found / inaccessible / not deployed on this account.
|
|
80
|
+
// These are NOT generic routing 404s — they mean this specific provider doesn't serve
|
|
81
|
+
// the requested model. Rotate to the next account rather than forwarding the error.
|
|
82
|
+
if (statusCode === 404 || statusCode === 410) {
|
|
83
|
+
const isModelNotFound = MODEL_NOT_FOUND_KEYWORDS.some(kw => bodyLower.includes(kw))
|
|
84
|
+
if (isModelNotFound) {
|
|
85
|
+
return { type: ErrorType.MODEL_NOT_FOUND, retryAfterSec: null, shouldRetry: true, skipAccount: true }
|
|
86
|
+
}
|
|
87
|
+
// Generic 404 (wrong URL, endpoint not found, etc.) — not retryable
|
|
88
|
+
return { type: ErrorType.UNKNOWN, retryAfterSec: null, shouldRetry: false, skipAccount: false }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (statusCode === 429) {
|
|
92
|
+
const isQuota = QUOTA_KEYWORDS.some(kw => bodyLower.includes(kw))
|
|
93
|
+
const confidence = isQuota ? 'quota_exhaustion_likely' : 'generic_rate_limit'
|
|
94
|
+
if (isQuota) {
|
|
95
|
+
return { type: ErrorType.QUOTA_EXHAUSTED, retryAfterSec, shouldRetry: true, skipAccount: true, rateLimitConfidence: confidence }
|
|
96
|
+
}
|
|
97
|
+
return { type: ErrorType.RATE_LIMITED, retryAfterSec, shouldRetry: true, skipAccount: false, rateLimitConfidence: confidence }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (statusCode === 503 || statusCode === 502) {
|
|
101
|
+
return { type: ErrorType.MODEL_CAPACITY, retryAfterSec: retryAfterSec || 5, shouldRetry: true, skipAccount: false }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (statusCode >= 500) {
|
|
105
|
+
return { type: ErrorType.SERVER_ERROR, retryAfterSec: retryAfterSec || 10, shouldRetry: true, skipAccount: false }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { type: ErrorType.UNKNOWN, retryAfterSec: null, shouldRetry: false, skipAccount: false }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Circuit breaker: CLOSED → OPEN (after threshold failures) → HALF_OPEN (after cooldown) → CLOSED (on success) or → OPEN (on failure)
|
|
113
|
+
*/
|
|
114
|
+
export class CircuitBreaker {
|
|
115
|
+
constructor({ threshold = 5, cooldownMs = 60000 } = {}) {
|
|
116
|
+
this.threshold = threshold
|
|
117
|
+
this.cooldownMs = cooldownMs
|
|
118
|
+
this.consecutiveFailures = 0
|
|
119
|
+
this.openedAt = null
|
|
120
|
+
this.state = 'CLOSED'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
recordFailure() {
|
|
124
|
+
this.consecutiveFailures++
|
|
125
|
+
if (this.consecutiveFailures >= this.threshold || this.state === 'HALF_OPEN') {
|
|
126
|
+
this.state = 'OPEN'
|
|
127
|
+
this.openedAt = Date.now()
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
recordSuccess() {
|
|
132
|
+
this.consecutiveFailures = 0
|
|
133
|
+
this.state = 'CLOSED'
|
|
134
|
+
this.openedAt = null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
isOpen() {
|
|
138
|
+
if (this.state === 'CLOSED') return false
|
|
139
|
+
if (this.state === 'OPEN' && Date.now() - this.openedAt >= this.cooldownMs) {
|
|
140
|
+
this.state = 'HALF_OPEN'
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
if (this.state === 'HALF_OPEN') return false
|
|
144
|
+
return true
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
isHalfOpen() { return this.state === 'HALF_OPEN' }
|
|
148
|
+
|
|
149
|
+
reset() {
|
|
150
|
+
this.consecutiveFailures = 0
|
|
151
|
+
this.state = 'CLOSED'
|
|
152
|
+
this.openedAt = null
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file lib/log-reader.js
|
|
3
|
+
* @description Pure functions to load recent request-log entries from
|
|
4
|
+
* ~/.free-coding-models/request-log.jsonl, newest-first, bounded by a
|
|
5
|
+
* configurable row limit.
|
|
6
|
+
*
|
|
7
|
+
* Design principles:
|
|
8
|
+
* - Bounded reads only — never slurp the entire log for every TUI repaint.
|
|
9
|
+
* - Tolerates malformed / partially-written JSONL lines by skipping them.
|
|
10
|
+
* - No shared mutable state (pure functions, injectable file path for tests).
|
|
11
|
+
* - No new npm dependencies — uses only Node.js built-ins.
|
|
12
|
+
*
|
|
13
|
+
* Default path:
|
|
14
|
+
* ~/.free-coding-models/request-log.jsonl
|
|
15
|
+
*
|
|
16
|
+
* Row object shape returned from loadRecentLogs():
|
|
17
|
+
* {
|
|
18
|
+
* time: string // ISO timestamp string (from entry.timestamp)
|
|
19
|
+
* requestType: string // e.g. "chat.completions"
|
|
20
|
+
* model: string // e.g. "llama-3.3-70b-instruct"
|
|
21
|
+
* provider: string // e.g. "nvidia"
|
|
22
|
+
* status: string // e.g. "200" | "429" | "error"
|
|
23
|
+
* tokens: number // promptTokens + completionTokens (0 if unknown)
|
|
24
|
+
* latency: number // ms (0 if unknown)
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* @exports loadRecentLogs
|
|
28
|
+
* @exports parseLogLine
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { existsSync, statSync, openSync, readSync, closeSync } from 'node:fs'
|
|
32
|
+
import { join } from 'node:path'
|
|
33
|
+
import { homedir } from 'node:os'
|
|
34
|
+
|
|
35
|
+
const DEFAULT_LOG_FILE = join(homedir(), '.free-coding-models', 'request-log.jsonl')
|
|
36
|
+
|
|
37
|
+
/** Maximum bytes to read from the tail of the file to avoid OOM on large logs. */
|
|
38
|
+
const MAX_READ_BYTES = 128 * 1024 // 128 KB
|
|
39
|
+
|
|
40
|
+
function normalizeTimestamp(raw) {
|
|
41
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
|
42
|
+
return new Date(raw).toISOString()
|
|
43
|
+
}
|
|
44
|
+
if (typeof raw === 'string') {
|
|
45
|
+
const numeric = Number(raw)
|
|
46
|
+
if (Number.isFinite(numeric)) return new Date(numeric).toISOString()
|
|
47
|
+
const parsed = new Date(raw)
|
|
48
|
+
if (!Number.isNaN(parsed.getTime())) return parsed.toISOString()
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function inferProvider(entry) {
|
|
54
|
+
if (entry.providerKey || entry.provider) {
|
|
55
|
+
return String(entry.providerKey ?? entry.provider)
|
|
56
|
+
}
|
|
57
|
+
if (typeof entry.accountId === 'string' && entry.accountId.includes('/')) {
|
|
58
|
+
return entry.accountId.split('/')[0] || 'unknown'
|
|
59
|
+
}
|
|
60
|
+
return 'unknown'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function inferStatus(entry) {
|
|
64
|
+
if (entry.statusCode !== undefined || entry.status !== undefined) {
|
|
65
|
+
return String(entry.statusCode ?? entry.status)
|
|
66
|
+
}
|
|
67
|
+
if (typeof entry.success === 'boolean') {
|
|
68
|
+
return entry.success ? '200' : 'error'
|
|
69
|
+
}
|
|
70
|
+
return 'unknown'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function inferRequestType(entry) {
|
|
74
|
+
if (entry.requestType !== undefined || entry.type !== undefined) {
|
|
75
|
+
return String(entry.requestType ?? entry.type)
|
|
76
|
+
}
|
|
77
|
+
if (typeof entry.url === 'string') {
|
|
78
|
+
if (entry.url.includes('/chat/completions')) return 'chat.completions'
|
|
79
|
+
if (entry.url.includes('/models')) return 'models'
|
|
80
|
+
}
|
|
81
|
+
return 'chat.completions'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Parse a single JSONL line into a normalised log row object.
|
|
86
|
+
*
|
|
87
|
+
* Returns `null` for any line that is blank, not valid JSON, or missing
|
|
88
|
+
* the required `timestamp` field.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} line - A single text line from the JSONL file.
|
|
91
|
+
* @returns {{ time: string, requestType: string, model: string, provider: string, status: string, tokens: number, latency: number } | null}
|
|
92
|
+
*/
|
|
93
|
+
export function parseLogLine(line) {
|
|
94
|
+
const trimmed = line.trim()
|
|
95
|
+
if (!trimmed) return null
|
|
96
|
+
let entry
|
|
97
|
+
try {
|
|
98
|
+
entry = JSON.parse(trimmed)
|
|
99
|
+
} catch {
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
if (!entry || typeof entry !== 'object') return null
|
|
103
|
+
if (!entry.timestamp) return null
|
|
104
|
+
|
|
105
|
+
const normalizedTime = normalizeTimestamp(entry.timestamp)
|
|
106
|
+
if (!normalizedTime) return null
|
|
107
|
+
|
|
108
|
+
const model = String(entry.modelId ?? entry.model ?? 'unknown')
|
|
109
|
+
const provider = inferProvider(entry)
|
|
110
|
+
const status = inferStatus(entry)
|
|
111
|
+
const requestType = inferRequestType(entry)
|
|
112
|
+
const tokens = (Number(entry.usage?.prompt_tokens ?? entry.promptTokens ?? 0) +
|
|
113
|
+
Number(entry.usage?.completion_tokens ?? entry.completionTokens ?? 0)) || 0
|
|
114
|
+
const latency = Number(entry.latencyMs ?? entry.latency ?? 0) || 0
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
time: normalizedTime,
|
|
118
|
+
requestType,
|
|
119
|
+
model,
|
|
120
|
+
provider,
|
|
121
|
+
status,
|
|
122
|
+
tokens,
|
|
123
|
+
latency,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Load the N most-recent log entries from the JSONL file, newest-first.
|
|
129
|
+
*
|
|
130
|
+
* Only reads up to MAX_READ_BYTES from the end of the file to avoid
|
|
131
|
+
* loading the entire log history. Malformed lines are silently skipped.
|
|
132
|
+
*
|
|
133
|
+
* @param {object} [opts]
|
|
134
|
+
* @param {string} [opts.logFile] - Path to request-log.jsonl (injectable for tests)
|
|
135
|
+
* @param {number} [opts.limit] - Maximum rows to return (default 200)
|
|
136
|
+
* @returns {Array<{ time: string, requestType: string, model: string, provider: string, status: string, tokens: number, latency: number }>}
|
|
137
|
+
*/
|
|
138
|
+
export function loadRecentLogs({ logFile = DEFAULT_LOG_FILE, limit = 200 } = {}) {
|
|
139
|
+
try {
|
|
140
|
+
if (!existsSync(logFile)) return []
|
|
141
|
+
|
|
142
|
+
const fileSize = statSync(logFile).size
|
|
143
|
+
if (fileSize === 0) return []
|
|
144
|
+
|
|
145
|
+
// 📖 Read only the tail of the file (bounded by MAX_READ_BYTES) to avoid
|
|
146
|
+
// 📖 reading multi-megabyte logs on every TUI repaint.
|
|
147
|
+
const readBytes = Math.min(fileSize, MAX_READ_BYTES)
|
|
148
|
+
const fileOffset = fileSize - readBytes
|
|
149
|
+
|
|
150
|
+
const buf = Buffer.allocUnsafe(readBytes)
|
|
151
|
+
const fd = openSync(logFile, 'r')
|
|
152
|
+
try {
|
|
153
|
+
readSync(fd, buf, 0, readBytes, fileOffset)
|
|
154
|
+
} finally {
|
|
155
|
+
closeSync(fd)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const text = buf.toString('utf8')
|
|
159
|
+
|
|
160
|
+
// 📖 Split on newlines; if we started mid-line (fileOffset > 0), drop
|
|
161
|
+
// 📖 the first (potentially incomplete) line to avoid corrupt JSON.
|
|
162
|
+
const rawLines = text.split('\n')
|
|
163
|
+
const lines = fileOffset > 0 ? rawLines.slice(1) : rawLines
|
|
164
|
+
|
|
165
|
+
const rows = []
|
|
166
|
+
for (let i = lines.length - 1; i >= 0 && rows.length < limit; i--) {
|
|
167
|
+
const row = parseLogLine(lines[i])
|
|
168
|
+
if (row) rows.push(row)
|
|
169
|
+
}
|
|
170
|
+
return rows
|
|
171
|
+
} catch {
|
|
172
|
+
return []
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const TIER_RANK = { 'S+': 0, 'S': 1, 'A+': 2, 'A': 3, 'A-': 4, 'B+': 5, 'B': 6, 'C': 7 }
|
|
2
|
+
|
|
3
|
+
function parseCtxK(ctx) {
|
|
4
|
+
if (!ctx) return 0
|
|
5
|
+
const s = ctx.toLowerCase()
|
|
6
|
+
if (s.endsWith('m')) return parseFloat(s) * 1000
|
|
7
|
+
return parseFloat(s) || 0
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function parseSwePercent(swe) {
|
|
11
|
+
return parseFloat(swe) || 0
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generate a unique slug from a label.
|
|
16
|
+
* "DeepSeek V3.2" → "deepseek-v3-2"
|
|
17
|
+
* Appends suffix if collision detected.
|
|
18
|
+
*/
|
|
19
|
+
function slugify(label, existingSlugs) {
|
|
20
|
+
let base = label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
|
21
|
+
let slug = base
|
|
22
|
+
let i = 2
|
|
23
|
+
while (existingSlugs.has(slug)) {
|
|
24
|
+
slug = `${base}-${i++}`
|
|
25
|
+
}
|
|
26
|
+
existingSlugs.add(slug)
|
|
27
|
+
return slug
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build merged model list from flat MODELS array.
|
|
32
|
+
* Groups by display label. Each merged entry contains all providers.
|
|
33
|
+
*
|
|
34
|
+
* @param {Array} models - Flat array of [modelId, label, tier, sweScore, ctx, providerKey]
|
|
35
|
+
* @returns {Array<MergedModel>}
|
|
36
|
+
*
|
|
37
|
+
* MergedModel: {
|
|
38
|
+
* slug: string, // unique URL-safe identifier
|
|
39
|
+
* label: string, // display name
|
|
40
|
+
* tier: string, // best tier across providers
|
|
41
|
+
* sweScore: string, // highest SWE score
|
|
42
|
+
* ctx: string, // largest context window
|
|
43
|
+
* providerCount: number,
|
|
44
|
+
* providers: Array<{ modelId: string, providerKey: string, tier: string }>
|
|
45
|
+
* }
|
|
46
|
+
*/
|
|
47
|
+
export function buildMergedModels(models) {
|
|
48
|
+
const groups = new Map()
|
|
49
|
+
|
|
50
|
+
for (const [modelId, label, tier, sweScore, ctx, providerKey] of models) {
|
|
51
|
+
if (!groups.has(label)) {
|
|
52
|
+
groups.set(label, { label, tier, sweScore, ctx, providers: [] })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const group = groups.get(label)
|
|
56
|
+
group.providers.push({ modelId, providerKey, tier })
|
|
57
|
+
|
|
58
|
+
// Keep best tier
|
|
59
|
+
if ((TIER_RANK[tier] ?? 99) < (TIER_RANK[group.tier] ?? 99)) {
|
|
60
|
+
group.tier = tier
|
|
61
|
+
}
|
|
62
|
+
// Keep highest SWE score
|
|
63
|
+
if (parseSwePercent(sweScore) > parseSwePercent(group.sweScore)) {
|
|
64
|
+
group.sweScore = sweScore
|
|
65
|
+
}
|
|
66
|
+
// Keep largest context
|
|
67
|
+
if (parseCtxK(ctx) > parseCtxK(group.ctx)) {
|
|
68
|
+
group.ctx = ctx
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const existingSlugs = new Set()
|
|
73
|
+
return Array.from(groups.values()).map(g => ({
|
|
74
|
+
...g,
|
|
75
|
+
slug: slugify(g.label, existingSlugs),
|
|
76
|
+
providerCount: g.providers.length,
|
|
77
|
+
}))
|
|
78
|
+
}
|