free-coding-models 0.5.59 → 0.5.60
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 +30 -0
- package/bin/free-coding-models.js +12 -0
- package/changelog/v0.5.60.md +54 -0
- package/package.json +2 -2
- package/src/core/router-daemon.js +96 -0
- package/src/core/runtime-telemetry.js +541 -0
- package/src/core/utils.js +15 -0
- package/src/tui/app.js +17 -0
- package/src/tui/key-handler.js +80 -0
- package/src/tui/tui-state.js +9 -0
- package/web/dist/assets/{index-C_ZdUGrS.js → index-CQQJkofy.js} +2 -2
- package/web/dist/index.html +1 -1
- package/web/src/components/router/RouterView.jsx +34 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file runtime-telemetry.js
|
|
3
|
+
* @description Per-model runtime metrics captured by the daemon on every routed request.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* 📖 Why this exists:
|
|
7
|
+
* 📖 SWE-bench / tier scores are static and self-reported by providers — a model
|
|
8
|
+
* 📖 can advertise 82% on SWE-bench and still fail 50% of real requests through a
|
|
9
|
+
* 📖 flaky free-tier gateway. We track what *actually* happens on the wire:
|
|
10
|
+
* 📖 - real success rate (success vs error calls)
|
|
11
|
+
* 📖 - real avg tokens/second (throughput under our real traffic)
|
|
12
|
+
* 📖 - recent calls (last 50, capped) for debugging
|
|
13
|
+
*
|
|
14
|
+
* 📖 Distinct from src/core/telemetry.js (which is product analytics sent
|
|
15
|
+
* 📖 upstream to PostHog). This file is local-only by default and never leaves
|
|
16
|
+
* 📖 the user's machine.
|
|
17
|
+
*
|
|
18
|
+
* 📖 Persistence: ~/.free-coding-models/runtime-telemetry.json (separate from
|
|
19
|
+
* 📖 the probe-cache at probe-cache.json). Atomic write via shared-helpers.
|
|
20
|
+
*
|
|
21
|
+
* 📖 Derived fields (computed on read, never persisted):
|
|
22
|
+
* 📖 avgLatencyMs = totalLatencyMs / totalCalls
|
|
23
|
+
* 📖 avgTokensPerSecond = totalCompletionTokens / (totalLatencyMs / 1000)
|
|
24
|
+
* 📖 successRate = successCalls / totalCalls
|
|
25
|
+
*
|
|
26
|
+
* @functions
|
|
27
|
+
* → getRuntimeTelemetryPath() — Resolves the JSON file path
|
|
28
|
+
* → loadRuntimeTelemetry({ path, now }?) — Reads + validates the file
|
|
29
|
+
* → flushRuntimeTelemetry({ path, cache, now }?) — Atomic write
|
|
30
|
+
* → clearRuntimeTelemetry({ path }?) — Nuke the file (for --clear-runtime)
|
|
31
|
+
* → recordModelCall(providerKey, modelId, callResult, opts?) → mutates in-memory cache
|
|
32
|
+
* → getModelTelemetry(providerKey, modelId, opts?) → ModelTelemetry | null
|
|
33
|
+
* → getAllModelTelemetry(opts?) → Record<key, ModelTelemetry>
|
|
34
|
+
* → getRealWorldScore(providerKey, modelId, opts?) → 0..100 | null (null below MIN_CALLS)
|
|
35
|
+
* → getCacheStats(opts?) → aggregate counts for footer
|
|
36
|
+
* → pruneStaleEntries(maxAgeMs, opts?) → drop models not seen recently
|
|
37
|
+
*
|
|
38
|
+
* @exports getRuntimeTelemetryPath, loadRuntimeTelemetry, flushRuntimeTelemetry,
|
|
39
|
+
* clearRuntimeTelemetry, recordModelCall, getModelTelemetry,
|
|
40
|
+
* getAllModelTelemetry, getRealWorldScore, getCacheStats,
|
|
41
|
+
* pruneStaleEntries, DEFAULT_MIN_CALLS_FOR_SCORE, DEFAULT_REAL_WORLD_WEIGHTS,
|
|
42
|
+
* MAX_RECENT_CALLS
|
|
43
|
+
*
|
|
44
|
+
* @see src/core/telemetry.js — product analytics (separate concern)
|
|
45
|
+
* @see src/core/probe-cache.js — persistent probe-cache (t1, separate file)
|
|
46
|
+
* @see src/core/shared-helpers.js — atomicWriteJson (used by flushRuntimeTelemetry)
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import fs from 'node:fs'
|
|
50
|
+
import os from 'node:os'
|
|
51
|
+
import path from 'node:path'
|
|
52
|
+
import { atomicWriteJson } from './shared-helpers.js'
|
|
53
|
+
|
|
54
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/** 📖 Below this many routed calls, getRealWorldScore returns null (not enough signal). */
|
|
57
|
+
export const DEFAULT_MIN_CALLS_FOR_SCORE = 5
|
|
58
|
+
|
|
59
|
+
/** 📖 recentCalls FIFO cap. Worst-case size: 238 models × 50 × ~200B ≈ 2.4 MB. */
|
|
60
|
+
export const MAX_RECENT_CALLS = 50
|
|
61
|
+
|
|
62
|
+
/** 📖 Composite-score weights. Tune with vava before shipping wider. */
|
|
63
|
+
export const DEFAULT_REAL_WORLD_WEIGHTS = Object.freeze({
|
|
64
|
+
success: 0.60,
|
|
65
|
+
speed: 0.25,
|
|
66
|
+
recency: 0.15,
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/** 📖 Filename lives in the same dir as the probe-cache for discoverability. */
|
|
70
|
+
const TELEMETRY_FILENAME = 'runtime-telemetry.json'
|
|
71
|
+
const STATE_DIRNAME = 'free-coding-models'
|
|
72
|
+
|
|
73
|
+
// ─── Module-level state ──────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 📖 In-memory mirror of the on-disk file. Loaded lazily by the first function
|
|
77
|
+
* 📖 that needs it (recordModelCall / getModelTelemetry / etc.).
|
|
78
|
+
*/
|
|
79
|
+
let _cache = null
|
|
80
|
+
let _cacheLoadedFrom = null
|
|
81
|
+
|
|
82
|
+
// ─── Path resolution ──────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 📖 Resolves where the runtime-telemetry JSON lives. Honours XDG_CACHE_HOME
|
|
86
|
+
* 📖 when set, else falls back to ~/.free-coding-models/. The schema is local-only
|
|
87
|
+
* 📖 so we put it under ~/.cache (the same dir as the probe-cache) — not under
|
|
88
|
+
* 📖 ~/.config which is reserved for shipped config the user expects to back up.
|
|
89
|
+
*
|
|
90
|
+
* @returns {string} Absolute path to the file (may not exist yet).
|
|
91
|
+
*/
|
|
92
|
+
export function getRuntimeTelemetryPath() {
|
|
93
|
+
const xdg = process.env.XDG_CACHE_HOME
|
|
94
|
+
const baseDir = xdg && xdg.trim()
|
|
95
|
+
? path.join(xdg, STATE_DIRNAME)
|
|
96
|
+
: path.join(os.homedir(), `.${STATE_DIRNAME}`)
|
|
97
|
+
return path.join(baseDir, TELEMETRY_FILENAME)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Low-level load / flush / clear ──────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
function emptyCache() {
|
|
103
|
+
return {
|
|
104
|
+
version: 1,
|
|
105
|
+
models: {},
|
|
106
|
+
lastUpdated: 0,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 📖 Read the runtime-telemetry JSON from disk. Returns an empty cache on any
|
|
112
|
+
* 📖 I/O or parse error — never crashes the daemon on a corrupt file.
|
|
113
|
+
*
|
|
114
|
+
* @param {object} [opts]
|
|
115
|
+
* @param {string} [opts.path] — Override the file path (mainly for tests).
|
|
116
|
+
* @returns {object} The loaded cache.
|
|
117
|
+
*/
|
|
118
|
+
export function loadRuntimeTelemetry({ path: telemetryPath } = {}) {
|
|
119
|
+
const target = telemetryPath ?? getRuntimeTelemetryPath()
|
|
120
|
+
let raw
|
|
121
|
+
try {
|
|
122
|
+
raw = fs.readFileSync(target, 'utf-8')
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (err && err.code === 'ENOENT') return emptyCache()
|
|
125
|
+
return emptyCache()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let parsed
|
|
129
|
+
try {
|
|
130
|
+
parsed = JSON.parse(raw)
|
|
131
|
+
} catch {
|
|
132
|
+
return emptyCache()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!parsed || typeof parsed !== 'object') return emptyCache()
|
|
136
|
+
if (typeof parsed.version !== 'number') parsed.version = 1
|
|
137
|
+
if (!parsed.models || typeof parsed.models !== 'object') parsed.models = {}
|
|
138
|
+
if (typeof parsed.lastUpdated !== 'number') parsed.lastUpdated = 0
|
|
139
|
+
|
|
140
|
+
// 📖 Walk every model entry and normalise.
|
|
141
|
+
for (const [key, entry] of Object.entries(parsed.models)) {
|
|
142
|
+
if (!entry || typeof entry !== 'object') {
|
|
143
|
+
delete parsed.models[key]
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
entry.totalCalls = Number(entry.totalCalls) || 0
|
|
147
|
+
entry.successCalls = Number(entry.successCalls) || 0
|
|
148
|
+
entry.errorCalls = Number(entry.errorCalls) || 0
|
|
149
|
+
entry.totalTokens = Number(entry.totalTokens) || 0
|
|
150
|
+
entry.totalPromptTokens = Number(entry.totalPromptTokens) || 0
|
|
151
|
+
entry.totalCompletionTokens = Number(entry.totalCompletionTokens) || 0
|
|
152
|
+
entry.totalLatencyMs = Number(entry.totalLatencyMs) || 0
|
|
153
|
+
entry.totalCost = Number(entry.totalCost) || 0
|
|
154
|
+
if (!Array.isArray(entry.recentCalls)) entry.recentCalls = []
|
|
155
|
+
if (typeof entry.lastUpdated !== 'number') entry.lastUpdated = 0
|
|
156
|
+
// 📖 Backfill derived: split totalCalls into success+error if they're 0.
|
|
157
|
+
if (entry.totalCalls > 0 && entry.successCalls === 0 && entry.errorCalls === 0) {
|
|
158
|
+
// 📖 Unknown break-down — treat all as success to preserve the count signal.
|
|
159
|
+
entry.successCalls = entry.totalCalls
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
_cache = parsed
|
|
164
|
+
_cacheLoadedFrom = target
|
|
165
|
+
return parsed
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 📖 Persist the in-memory cache to disk atomically (tmp + rename). Uses the
|
|
170
|
+
* 📖 same read-merge-write pattern as probe-cache.flushCache so the daemon +
|
|
171
|
+
* 📖 CLI can share the file safely across processes.
|
|
172
|
+
*
|
|
173
|
+
* @param {object} [opts]
|
|
174
|
+
* @param {string} [opts.path]
|
|
175
|
+
* @param {object} [opts.cache]
|
|
176
|
+
* @returns {boolean} true on success, false on I/O error.
|
|
177
|
+
*/
|
|
178
|
+
export function flushRuntimeTelemetry({ path: telemetryPath, cache } = {}) {
|
|
179
|
+
const target = telemetryPath ?? _cacheLoadedFrom ?? getRuntimeTelemetryPath()
|
|
180
|
+
const localData = cache ?? _cache ?? emptyCache()
|
|
181
|
+
localData.lastUpdated = Date.now()
|
|
182
|
+
|
|
183
|
+
let onDisk = null
|
|
184
|
+
try {
|
|
185
|
+
const raw = fs.readFileSync(target, 'utf-8')
|
|
186
|
+
onDisk = JSON.parse(raw)
|
|
187
|
+
if (!onDisk || typeof onDisk !== 'object') onDisk = null
|
|
188
|
+
} catch {
|
|
189
|
+
onDisk = null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 📖 Merge: per-model, our deltas win on key collision.
|
|
193
|
+
const merged = onDisk && typeof onDisk === 'object' ? onDisk : { version: 1, models: {}, lastUpdated: 0 }
|
|
194
|
+
if (!merged.models || typeof merged.models !== 'object') merged.models = {}
|
|
195
|
+
for (const [key, entry] of Object.entries(localData.models)) {
|
|
196
|
+
const base = merged.models[key]
|
|
197
|
+
if (base && typeof base === 'object') {
|
|
198
|
+
// 📖 Merge counters + append recent calls (caller-side already FIFO'd).
|
|
199
|
+
merged.models[key] = {
|
|
200
|
+
...base,
|
|
201
|
+
...entry,
|
|
202
|
+
recentCalls: entry.recentCalls, // 📖 entry already has the full new FIFO list
|
|
203
|
+
lastUpdated: Math.max(base.lastUpdated || 0, entry.lastUpdated || 0),
|
|
204
|
+
}
|
|
205
|
+
} else {
|
|
206
|
+
merged.models[key] = entry
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
merged.lastUpdated = Date.now()
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
atomicWriteJson(target, merged, 0o600)
|
|
213
|
+
_cacheLoadedFrom = target
|
|
214
|
+
_cache = merged
|
|
215
|
+
return true
|
|
216
|
+
} catch {
|
|
217
|
+
return false
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 📖 Delete the runtime-telemetry file. Used by `--clear-runtime` and tests.
|
|
223
|
+
*
|
|
224
|
+
* @param {object} [opts]
|
|
225
|
+
* @param {string} [opts.path]
|
|
226
|
+
* @returns {boolean}
|
|
227
|
+
*/
|
|
228
|
+
export function clearRuntimeTelemetry({ path: telemetryPath } = {}) {
|
|
229
|
+
const target = telemetryPath ?? getRuntimeTelemetryPath()
|
|
230
|
+
_cache = null
|
|
231
|
+
_cacheLoadedFrom = null
|
|
232
|
+
try {
|
|
233
|
+
fs.unlinkSync(target)
|
|
234
|
+
return true
|
|
235
|
+
} catch (err) {
|
|
236
|
+
if (err && err.code === 'ENOENT') return true
|
|
237
|
+
return false
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ─── Module-state accessor ───────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
function getCache(opts) {
|
|
244
|
+
if (opts && Object.prototype.hasOwnProperty.call(opts, 'cache')) return opts.cache
|
|
245
|
+
if (_cache) return _cache
|
|
246
|
+
return loadRuntimeTelemetry()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ─── Write path ───────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 📖 Validate + normalise a single callResult into a recordCall shape.
|
|
253
|
+
* 📖 Throws on garbage so recordModelCall can drop it cleanly.
|
|
254
|
+
*/
|
|
255
|
+
function normaliseCallResult(r) {
|
|
256
|
+
if (!r || typeof r !== 'object') throw new Error('callResult must be an object')
|
|
257
|
+
const out = {
|
|
258
|
+
success: r.success === true,
|
|
259
|
+
latencyMs: typeof r.latencyMs === 'number' && Number.isFinite(r.latencyMs) ? r.latencyMs : 0,
|
|
260
|
+
promptTokens: typeof r.promptTokens === 'number' && Number.isFinite(r.promptTokens) ? Math.max(0, Math.floor(r.promptTokens)) : 0,
|
|
261
|
+
completionTokens: typeof r.completionTokens === 'number' && Number.isFinite(r.completionTokens) ? Math.max(0, Math.floor(r.completionTokens)) : 0,
|
|
262
|
+
stopReason: typeof r.stopReason === 'string' ? r.stopReason : null,
|
|
263
|
+
error: r.success === true ? null : (typeof r.error === 'string' ? r.error : 'unknown'),
|
|
264
|
+
}
|
|
265
|
+
out.totalTokens = out.promptTokens + out.completionTokens
|
|
266
|
+
return out
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 📖 recordModelCall: append one routed request's outcome to the per-model store.
|
|
271
|
+
* 📖 Updates the in-memory cache (caller schedules a flush). The recentCalls FIFO
|
|
272
|
+
* 📖 is trimmed to MAX_RECENT_CALLS to bound the file size.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} providerKey
|
|
275
|
+
* @param {string} modelId
|
|
276
|
+
* @param {{ success: boolean, latencyMs?: number, promptTokens?: number, completionTokens?: number, stopReason?: string, error?: string }} callResult
|
|
277
|
+
* @param {object} [opts]
|
|
278
|
+
* @param {number} [opts.now=Date.now()]
|
|
279
|
+
* @param {object} [opts.cache] — Optional explicit cache to mutate (skips module state).
|
|
280
|
+
* @returns {{ written: boolean, error?: string }}
|
|
281
|
+
*/
|
|
282
|
+
export function recordModelCall(providerKey, modelId, callResult, opts = {}) {
|
|
283
|
+
if (!providerKey || typeof providerKey !== 'string') return { written: false, error: 'invalid providerKey' }
|
|
284
|
+
if (!modelId || typeof modelId !== 'string') return { written: false, error: 'invalid modelId' }
|
|
285
|
+
const now = opts.now ?? Date.now()
|
|
286
|
+
const cache = (opts && Object.prototype.hasOwnProperty.call(opts, 'cache')) ? opts.cache : getCache(opts)
|
|
287
|
+
const key = `${providerKey}/${modelId}`
|
|
288
|
+
|
|
289
|
+
let r
|
|
290
|
+
try {
|
|
291
|
+
r = normaliseCallResult(callResult)
|
|
292
|
+
} catch (err) {
|
|
293
|
+
return { written: false, error: err?.message || 'invalid callResult' }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let entry = cache.models[key]
|
|
297
|
+
if (!entry) {
|
|
298
|
+
entry = {
|
|
299
|
+
providerKey,
|
|
300
|
+
modelId,
|
|
301
|
+
totalCalls: 0,
|
|
302
|
+
successCalls: 0,
|
|
303
|
+
errorCalls: 0,
|
|
304
|
+
totalTokens: 0,
|
|
305
|
+
totalPromptTokens: 0,
|
|
306
|
+
totalCompletionTokens: 0,
|
|
307
|
+
totalLatencyMs: 0,
|
|
308
|
+
totalCost: 0,
|
|
309
|
+
recentCalls: [],
|
|
310
|
+
lastUpdated: 0,
|
|
311
|
+
}
|
|
312
|
+
cache.models[key] = entry
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
entry.totalCalls += 1
|
|
316
|
+
if (r.success) entry.successCalls += 1
|
|
317
|
+
else entry.errorCalls += 1
|
|
318
|
+
entry.totalTokens += r.totalTokens
|
|
319
|
+
entry.totalPromptTokens += r.promptTokens
|
|
320
|
+
entry.totalCompletionTokens += r.completionTokens
|
|
321
|
+
entry.totalLatencyMs += r.latencyMs
|
|
322
|
+
entry.lastUpdated = now
|
|
323
|
+
|
|
324
|
+
// 📖 FIFO trim — push to the front so recentCalls[0] is the newest.
|
|
325
|
+
const call = {
|
|
326
|
+
timestamp: now,
|
|
327
|
+
provider: providerKey,
|
|
328
|
+
model: modelId,
|
|
329
|
+
success: r.success,
|
|
330
|
+
latencyMs: r.latencyMs,
|
|
331
|
+
promptTokens: r.promptTokens,
|
|
332
|
+
completionTokens: r.completionTokens,
|
|
333
|
+
totalTokens: r.totalTokens,
|
|
334
|
+
tokensPerSecond: r.latencyMs > 0 ? r.completionTokens / (r.latencyMs / 1000) : 0,
|
|
335
|
+
stopReason: r.stopReason,
|
|
336
|
+
error: r.error,
|
|
337
|
+
}
|
|
338
|
+
entry.recentCalls.unshift(call)
|
|
339
|
+
if (entry.recentCalls.length > MAX_RECENT_CALLS) {
|
|
340
|
+
entry.recentCalls.length = MAX_RECENT_CALLS // 📖 truncate to cap
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (!opts || !Object.prototype.hasOwnProperty.call(opts, 'cache')) {
|
|
344
|
+
_cache = cache
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return { written: true }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ─── Read path ────────────────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* 📖 Derived metrics computed on read (never persisted).
|
|
354
|
+
* @typedef {object} ModelTelemetry
|
|
355
|
+
* @property {string} providerKey
|
|
356
|
+
* @property {string} modelId
|
|
357
|
+
* @property {number} totalCalls
|
|
358
|
+
* @property {number} successCalls
|
|
359
|
+
* @property {number} errorCalls
|
|
360
|
+
* @property {number} successRate 0..1
|
|
361
|
+
* @property {number} totalTokens
|
|
362
|
+
* @property {number} totalPromptTokens
|
|
363
|
+
* @property {number} totalCompletionTokens
|
|
364
|
+
* @property {number} totalLatencyMs
|
|
365
|
+
* @property {number} avgLatencyMs 0 if totalCalls===0
|
|
366
|
+
* @property {number} avgTokensPerSecond 0 if totalLatencyMs===0
|
|
367
|
+
* @property {Array} recentCalls most-recent first, capped at MAX_RECENT_CALLS
|
|
368
|
+
* @property {number} lastUpdated ms epoch
|
|
369
|
+
*/
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* 📖 getModelTelemetry: read the per-model telemetry snapshot + derived metrics.
|
|
373
|
+
*
|
|
374
|
+
* @param {string} providerKey
|
|
375
|
+
* @param {string} modelId
|
|
376
|
+
* @param {object} [opts]
|
|
377
|
+
* @returns {ModelTelemetry | null}
|
|
378
|
+
*/
|
|
379
|
+
export function getModelTelemetry(providerKey, modelId, opts = {}) {
|
|
380
|
+
if (!providerKey || !modelId) return null
|
|
381
|
+
const cache = getCache(opts)
|
|
382
|
+
const key = `${providerKey}/${modelId}`
|
|
383
|
+
const entry = cache.models[key]
|
|
384
|
+
if (!entry) return null
|
|
385
|
+
return deriveMetrics(entry)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* 📖 Same as getModelTelemetry but for every model at once.
|
|
390
|
+
* 📖 Returns an object keyed by `${providerKey}/${modelId}` for cheap lookup.
|
|
391
|
+
*
|
|
392
|
+
* @param {object} [opts]
|
|
393
|
+
* @returns {Record<string, ModelTelemetry>}
|
|
394
|
+
*/
|
|
395
|
+
export function getAllModelTelemetry(opts = {}) {
|
|
396
|
+
const cache = getCache(opts)
|
|
397
|
+
const out = {}
|
|
398
|
+
for (const [key, entry] of Object.entries(cache.models)) {
|
|
399
|
+
if (!entry) continue
|
|
400
|
+
out[key] = deriveMetrics(entry)
|
|
401
|
+
}
|
|
402
|
+
return out
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* 📖 Internal: derive successRate / avgLatencyMs / avgTokensPerSecond from raw counters.
|
|
407
|
+
*/
|
|
408
|
+
function deriveMetrics(entry) {
|
|
409
|
+
const totalCalls = entry.totalCalls || 0
|
|
410
|
+
const totalLatencyMs = entry.totalLatencyMs || 0
|
|
411
|
+
const totalCompletionTokens = entry.totalCompletionTokens || 0
|
|
412
|
+
return {
|
|
413
|
+
providerKey: entry.providerKey,
|
|
414
|
+
modelId: entry.modelId,
|
|
415
|
+
totalCalls,
|
|
416
|
+
successCalls: entry.successCalls || 0,
|
|
417
|
+
errorCalls: entry.errorCalls || 0,
|
|
418
|
+
successRate: totalCalls > 0 ? (entry.successCalls || 0) / totalCalls : 0,
|
|
419
|
+
totalTokens: entry.totalTokens || 0,
|
|
420
|
+
totalPromptTokens: entry.totalPromptTokens || 0,
|
|
421
|
+
totalCompletionTokens,
|
|
422
|
+
totalLatencyMs,
|
|
423
|
+
avgLatencyMs: totalCalls > 0 ? totalLatencyMs / totalCalls : 0,
|
|
424
|
+
avgTokensPerSecond: totalLatencyMs > 0 ? totalCompletionTokens / (totalLatencyMs / 1000) : 0,
|
|
425
|
+
recentCalls: Array.isArray(entry.recentCalls) ? entry.recentCalls : [],
|
|
426
|
+
lastUpdated: entry.lastUpdated || 0,
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ─── Real-world score (the ranking signal) ───────────────────────────────────
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* 📖 sigmoid01: squashes (0, +inf) into (0, 1) so 50 tok/s = 0.5, 200 tok/s ≈ 0.95.
|
|
434
|
+
* 📖 Tuned so that the typical free-tier sweet spot (30-80 tok/s) maps to 0.4-0.7.
|
|
435
|
+
*/
|
|
436
|
+
function sigmoid01(x, midpoint = 50) {
|
|
437
|
+
// 📖 Logistic curve shifted so midpoint -> 0.5.
|
|
438
|
+
const k = 0.04 // 📖 steepness — 50 -> 0.5, 100 -> ~0.88, 200 -> ~0.99
|
|
439
|
+
return 1 / (1 + Math.exp(-k * (x - midpoint)))
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* 📖 recencyDecay: 1.0 today, ~0.5 after 7 days, ~0.0 after 30 days.
|
|
444
|
+
*/
|
|
445
|
+
function recencyDecay(lastUpdatedMs, now = Date.now()) {
|
|
446
|
+
if (!lastUpdatedMs) return 0
|
|
447
|
+
const ageDays = (now - lastUpdatedMs) / (24 * 60 * 60 * 1000)
|
|
448
|
+
if (ageDays < 0) return 1
|
|
449
|
+
if (ageDays >= 30) return 0
|
|
450
|
+
return Math.max(0, 1 - ageDays / 30)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* 📖 getRealWorldScore: composite 0..100 score for ranking, or null when below
|
|
455
|
+
* 📖 the minimum-call threshold (so brand-new models don't get punished).
|
|
456
|
+
*
|
|
457
|
+
* 📖 Formula: successRate * 0.60 + speedScore * 0.25 + recencyBonus * 0.15
|
|
458
|
+
* 📖 - successRate: 0..1 (straightforward)
|
|
459
|
+
* 📖 - speedScore: sigmoid01(avgTokensPerSecond) maps 50 tok/s -> 0.5
|
|
460
|
+
* 📖 - recencyBonus: recencyDecay(lastUpdatedMs) -> 1.0 today, ~0 after 30d
|
|
461
|
+
*
|
|
462
|
+
* @param {string} providerKey
|
|
463
|
+
* @param {string} modelId
|
|
464
|
+
* @param {object} [opts]
|
|
465
|
+
* @param {number} [opts.minCalls=DEFAULT_MIN_CALLS_FOR_SCORE]
|
|
466
|
+
* @param {object} [opts.weights=DEFAULT_REAL_WORLD_WEIGHTS]
|
|
467
|
+
* @returns {number | null}
|
|
468
|
+
*/
|
|
469
|
+
export function getRealWorldScore(providerKey, modelId, opts = {}) {
|
|
470
|
+
const minCalls = opts.minCalls ?? DEFAULT_MIN_CALLS_FOR_SCORE
|
|
471
|
+
const weights = opts.weights ?? DEFAULT_REAL_WORLD_WEIGHTS
|
|
472
|
+
const m = getModelTelemetry(providerKey, modelId, opts)
|
|
473
|
+
if (!m || m.totalCalls < minCalls) return null
|
|
474
|
+
const now = opts.now ?? Date.now()
|
|
475
|
+
const successRate = m.successRate
|
|
476
|
+
const speedScore = sigmoid01(m.avgTokensPerSecond)
|
|
477
|
+
const recencyBonus = recencyDecay(m.lastUpdated, now)
|
|
478
|
+
const score = (successRate * weights.success) + (speedScore * weights.speed) + (recencyBonus * weights.recency)
|
|
479
|
+
return Math.round(Math.max(0, Math.min(1, score)) * 100)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ─── Stats + pruning ──────────────────────────────────────────────────────────
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* 📖 getCacheStats: aggregate counts for the TUI footer + /stats endpoint.
|
|
486
|
+
*
|
|
487
|
+
* @param {object} [opts]
|
|
488
|
+
* @returns {{
|
|
489
|
+
* modelsTracked: number,
|
|
490
|
+
* totalCalls: number,
|
|
491
|
+
* successCalls: number,
|
|
492
|
+
* errorCalls: number,
|
|
493
|
+
* modelsWithSignal: number, // totalCalls >= DEFAULT_MIN_CALLS_FOR_SCORE
|
|
494
|
+
* }}
|
|
495
|
+
*/
|
|
496
|
+
export function getCacheStats(opts = {}) {
|
|
497
|
+
const cache = getCache(opts)
|
|
498
|
+
const minCalls = opts.minCalls ?? DEFAULT_MIN_CALLS_FOR_SCORE
|
|
499
|
+
let totalCalls = 0, successCalls = 0, errorCalls = 0, modelsWithSignal = 0
|
|
500
|
+
for (const entry of Object.values(cache.models)) {
|
|
501
|
+
if (!entry) continue
|
|
502
|
+
totalCalls += entry.totalCalls || 0
|
|
503
|
+
successCalls += entry.successCalls || 0
|
|
504
|
+
errorCalls += entry.errorCalls || 0
|
|
505
|
+
if ((entry.totalCalls || 0) >= minCalls) modelsWithSignal++
|
|
506
|
+
}
|
|
507
|
+
return {
|
|
508
|
+
modelsTracked: Object.keys(cache.models).length,
|
|
509
|
+
totalCalls,
|
|
510
|
+
successCalls,
|
|
511
|
+
errorCalls,
|
|
512
|
+
modelsWithSignal,
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* 📖 pruneStaleEntries: drop entries not updated within maxAgeMs. Called on
|
|
518
|
+
* 📖 daemon boot to keep the file bounded as the catalog evolves.
|
|
519
|
+
*
|
|
520
|
+
* @param {number} maxAgeMs
|
|
521
|
+
* @param {object} [opts]
|
|
522
|
+
* @param {object} [opts.cache]
|
|
523
|
+
* @param {number} [opts.now=Date.now()]
|
|
524
|
+
* @returns {number} Number of entries pruned.
|
|
525
|
+
*/
|
|
526
|
+
export function pruneStaleEntries(maxAgeMs, opts = {}) {
|
|
527
|
+
const cache = getCache(opts)
|
|
528
|
+
const now = opts.now ?? Date.now()
|
|
529
|
+
let pruned = 0
|
|
530
|
+
for (const [key, entry] of Object.entries(cache.models)) {
|
|
531
|
+
if (!entry) continue
|
|
532
|
+
if (!entry.lastUpdated || now - entry.lastUpdated > maxAgeMs) {
|
|
533
|
+
delete cache.models[key]
|
|
534
|
+
pruned++
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (!opts || !Object.prototype.hasOwnProperty.call(opts, 'cache')) {
|
|
538
|
+
_cache = cache
|
|
539
|
+
}
|
|
540
|
+
return pruned
|
|
541
|
+
}
|
package/src/core/utils.js
CHANGED
|
@@ -380,6 +380,15 @@ export const sortResults = (results, sortColumn, sortDirection, { benchmarkResul
|
|
|
380
380
|
// 📖 via JS stable sort preserving original order when values are equal
|
|
381
381
|
cmp = (a.usagePercent ?? 0) - (b.usagePercent ?? 0)
|
|
382
382
|
break
|
|
383
|
+
case 'realworld':
|
|
384
|
+
// 📖 Sort by real-world score (t3) — composite of success rate, throughput,
|
|
385
|
+
// 📖 and recency. Models with insufficient data (r.realWorldScore === null)
|
|
386
|
+
// 📖 sort to the bottom in BOTH directions (treat null as -Infinity for asc,
|
|
387
|
+
// 📖 so high-to-low puts the score-havers first regardless of null).
|
|
388
|
+
const aRW = typeof a.realWorldScore === 'number' ? a.realWorldScore : -Infinity
|
|
389
|
+
const bRW = typeof b.realWorldScore === 'number' ? b.realWorldScore : -Infinity
|
|
390
|
+
cmp = aRW - bRW
|
|
391
|
+
break
|
|
383
392
|
}
|
|
384
393
|
|
|
385
394
|
// 📖 Flip comparison for descending order
|
|
@@ -586,6 +595,10 @@ export function parseArgs(argv) {
|
|
|
586
595
|
// 📖 --recommend — launch directly into Smart Recommend mode (Q key equivalent)
|
|
587
596
|
const recommendMode = flags.includes('--recommend')
|
|
588
597
|
|
|
598
|
+
// 📖 --clear-runtime — wipe ~/.free-coding-models/runtime-telemetry.json (t3).
|
|
599
|
+
// 📖 Useful when the user wants to reset the real-world-score baseline.
|
|
600
|
+
const clearRuntimeMode = flags.includes('--clear-runtime')
|
|
601
|
+
|
|
589
602
|
// 📖 Probe-cache flags (t1): --reprobe / --no-cache force a fresh probe pass;
|
|
590
603
|
// 📖 --probe-ttl overrides the 24h default; --show-broken un-hides broken models for this run.
|
|
591
604
|
const reprobeMode = flags.includes('--reprobe') || flags.includes('--no-cache')
|
|
@@ -644,6 +657,8 @@ export function parseArgs(argv) {
|
|
|
644
657
|
reprobeMode,
|
|
645
658
|
probeTtlMs: Number.isFinite(probeTtlMs) && probeTtlMs > 0 ? probeTtlMs : null,
|
|
646
659
|
showBrokenMode,
|
|
660
|
+
// 📖 Runtime telemetry flag (t3) — see src/core/runtime-telemetry.js
|
|
661
|
+
clearRuntimeMode,
|
|
647
662
|
}
|
|
648
663
|
}
|
|
649
664
|
|
package/src/tui/app.js
CHANGED
|
@@ -507,6 +507,23 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
|
|
|
507
507
|
state.probeCacheMisses = probeCacheMisses
|
|
508
508
|
state.probeCacheBrokenHidden = state.results.filter(r => r.cachedBroken && r.hidden).length
|
|
509
509
|
|
|
510
|
+
// 📖 Runtime telemetry (t3): load the per-model metrics file + compute the
|
|
511
|
+
// 📖 real-world score for each result so the table can sort/display it.
|
|
512
|
+
// 📖 The file may not exist yet (no daemon traffic) — that's fine, every
|
|
513
|
+
// 📖 r.realWorldScore stays null and we fall back to SWE-bench.
|
|
514
|
+
try {
|
|
515
|
+
const { loadRuntimeTelemetry, getRealWorldScore } = await import('../core/runtime-telemetry.js')
|
|
516
|
+
loadRuntimeTelemetry() // 📖 populate module-level cache
|
|
517
|
+
for (const r of state.results) {
|
|
518
|
+
const score = getRealWorldScore(r.providerKey, r.modelId)
|
|
519
|
+
r.realWorldScore = score // null when below MIN_CALLS_FOR_SCORE
|
|
520
|
+
}
|
|
521
|
+
state.runtimeTelemetryLoaded = true
|
|
522
|
+
} catch (err) {
|
|
523
|
+
state.runtimeTelemetryLoaded = false
|
|
524
|
+
state.runtimeTelemetryError = err?.message || String(err)
|
|
525
|
+
}
|
|
526
|
+
|
|
510
527
|
// 📖 Define pingModel before JSON mode so `--json` can reuse the same provider-aware
|
|
511
528
|
// 📖 ping path as the interactive TUI without waiting for the PTY/render loop setup.
|
|
512
529
|
pingModel = async (r) => {
|