free-coding-models 0.5.59 → 0.5.61
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 +32 -0
- package/bin/free-coding-models.js +31 -0
- package/changelog/v0.5.60.md +54 -0
- package/changelog/v0.5.61.md +65 -0
- package/package.json +6 -2
- package/src/core/extended-benchmarks.js +421 -0
- package/src/core/model-merger.js +155 -0
- package/src/core/models-dev-fetcher.js +210 -0
- package/src/core/models-dev-index.js +311 -0
- package/src/core/models-drift.js +296 -0
- package/src/core/router-daemon.js +96 -0
- package/src/core/runtime-telemetry.js +541 -0
- package/src/core/utils.js +29 -0
- package/src/data/benchmarks.json +302 -0
- package/src/tui/app.js +103 -0
- package/src/tui/cli-help.js +2 -0
- package/src/tui/key-handler.js +80 -0
- package/src/tui/render-table.js +38 -2
- package/src/tui/tui-state.js +9 -0
- package/web/dist/assets/{index-C_ZdUGrS.js → index-4IyXp-vf.js} +2 -2
- package/web/dist/index.html +1 -1
- package/web/server.js +39 -0
- package/web/src/components/router/RouterView.jsx +34 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file models-drift.js
|
|
3
|
+
* @description Drift detection between `sources.js` (our curated catalog) and the live
|
|
4
|
+
* models.dev community catalog. Compares context window, max output tokens,
|
|
5
|
+
* and capability flags (reasoning / vision / thinking). Reports mismatches
|
|
6
|
+
* but never auto-rewrites sources.js — a human approves the edit.
|
|
7
|
+
*
|
|
8
|
+
* @details
|
|
9
|
+
* 📖 Why this exists:
|
|
10
|
+
* 📖 - sources.js is curated but `ctx` drifts constantly (128k → 256k → 1M).
|
|
11
|
+
* 📖 - We don't want to silently overwrite curated values; we want to surface
|
|
12
|
+
* 📖 the drift so a human can review it.
|
|
13
|
+
* 📖 - Used by:
|
|
14
|
+
* 📖 1. `scripts/check-drift.mjs` (CLI: `free-coding-models --check-drift`)
|
|
15
|
+
* 📖 2. `.github/workflows/check-drift.yml` (weekly CI — opens an issue)
|
|
16
|
+
* 📖 3. The TUI footer chip + /health endpoint (read-only)
|
|
17
|
+
*
|
|
18
|
+
* 📖 What "drift" means here:
|
|
19
|
+
* 📖 - ctx: sources.js says "128k", models.dev says 256000 → UPDATE
|
|
20
|
+
* 📖 - maxTokens: sources.js has no value, models.dev says 65536 → ADD
|
|
21
|
+
* 📖 - reasoning/vision flags: sources.js is null, models.dev has the flag → ADD
|
|
22
|
+
* 📖 - exact match: ✓ (no action)
|
|
23
|
+
*
|
|
24
|
+
* 📖 A "field mismatch" entry has:
|
|
25
|
+
* 📖 { modelId, field, sourcesJsValue, modelsDevValue, action: 'update'|'add'|'remove' }
|
|
26
|
+
*
|
|
27
|
+
* @functions
|
|
28
|
+
* → detectDrift(models, catalog, opts?) — Returns a list of field mismatches
|
|
29
|
+
* → summarizeDrift(mismatches) — { total, byField, byModel, modelsAffected }
|
|
30
|
+
* → formatDriftReport(mismatches, opts?) — Pretty-print a human-readable report
|
|
31
|
+
* → parseCtxToNum(ctx) — "128k" / "1m" → number
|
|
32
|
+
* → DRIFT_FIELDS — The list of fields we check
|
|
33
|
+
*
|
|
34
|
+
* @exports detectDrift, summarizeDrift, formatDriftReport, parseCtxToNum, DRIFT_FIELDS
|
|
35
|
+
*
|
|
36
|
+
* @see src/core/models-dev-fetcher.js — provides the live catalog
|
|
37
|
+
* @see src/core/models-dev-index.js — provides the lookup helpers
|
|
38
|
+
* @see scripts/check-drift.mjs — CLI consumer
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { buildModelIndex, lookupModelDevMeta, normalizeModelDevEntry } from './models-dev-index.js'
|
|
42
|
+
|
|
43
|
+
/** 📖 Fields we check for drift. Each has a sources.js column + a models.dev mapping. */
|
|
44
|
+
export const DRIFT_FIELDS = ['ctx', 'maxTokens', 'reasoning', 'vision', 'thinking']
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 📖 Convert a sources.js ctx string ("128k", "1m", "262144") to a number.
|
|
48
|
+
* 📖 Mirrors parseCtxToK from utils.js but returns raw tokens (not thousands).
|
|
49
|
+
* 📖 Returns null if the string is empty, "-", or unparseable.
|
|
50
|
+
*/
|
|
51
|
+
export function parseCtxToNum(ctx) {
|
|
52
|
+
if (ctx == null) return null
|
|
53
|
+
const s = String(ctx).trim()
|
|
54
|
+
if (!s || s === '-' || s === '—') return null
|
|
55
|
+
// Pure number
|
|
56
|
+
if (/^\d+$/.test(s)) return parseInt(s, 10)
|
|
57
|
+
// Suffix
|
|
58
|
+
const m = s.match(/^(\d+(?:\.\d+)?)\s*([km])$/i)
|
|
59
|
+
if (m) {
|
|
60
|
+
const n = parseFloat(m[1])
|
|
61
|
+
const unit = m[2].toLowerCase()
|
|
62
|
+
if (unit === 'k') return Math.round(n * 1000)
|
|
63
|
+
if (unit === 'm') return Math.round(n * 1_000_000)
|
|
64
|
+
}
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Comparison helpers ──────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
function contextMatch(sourcesJsCtx, devContextWindow) {
|
|
71
|
+
const a = parseCtxToNum(sourcesJsCtx)
|
|
72
|
+
const b = (typeof devContextWindow === 'number' && Number.isFinite(devContextWindow)) ? devContextWindow : null
|
|
73
|
+
if (a == null && b == null) return { status: 'both-null' }
|
|
74
|
+
if (a == null && b != null) return { status: 'add', sourcesJs: null, dev: b }
|
|
75
|
+
if (a != null && b == null) return { status: 'dev-missing', sourcesJs: a, dev: null }
|
|
76
|
+
// 📖 Allow 5% tolerance for vendor-specific rounding
|
|
77
|
+
if (Math.abs(a - b) <= Math.max(a, b) * 0.05) return { status: 'match', sourcesJs: a, dev: b }
|
|
78
|
+
return { status: 'drift', sourcesJs: a, dev: b }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function flagMatch(sourcesJsFlag, devFlag) {
|
|
82
|
+
// 📖 sources.js stores capability flags as booleans (true/false) or null/undefined
|
|
83
|
+
const a = sourcesJsFlag === true
|
|
84
|
+
const b = devFlag === true
|
|
85
|
+
if (!a && !b) return { status: 'both-off' }
|
|
86
|
+
if (!a && b) return { status: 'add', sourcesJs: false, dev: true }
|
|
87
|
+
if (a && !b) return { status: 'drift', sourcesJs: true, dev: false }
|
|
88
|
+
return { status: 'match' }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function numMatch(sourcesJsNum, devNum) {
|
|
92
|
+
const a = (typeof sourcesJsNum === 'number' && Number.isFinite(sourcesJsNum)) ? sourcesJsNum : null
|
|
93
|
+
const b = (typeof devNum === 'number' && Number.isFinite(devNum)) ? devNum : null
|
|
94
|
+
if (a == null && b == null) return { status: 'both-null' }
|
|
95
|
+
if (a == null && b != null) return { status: 'add', sourcesJs: null, dev: b }
|
|
96
|
+
if (a != null && b == null) return { status: 'dev-missing', sourcesJs: a, dev: null }
|
|
97
|
+
if (a === b) return { status: 'match', sourcesJs: a, dev: b }
|
|
98
|
+
return { status: 'drift', sourcesJs: a, dev: b }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Main detection ──────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 📖 Run drift detection over the sources.js model list against the live catalog.
|
|
105
|
+
* 📖 Returns a list of per-field mismatches. Use `summarizeDrift` + `formatDriftReport`
|
|
106
|
+
* 📖 to render the result.
|
|
107
|
+
*
|
|
108
|
+
* 📖 Each model in `models` should be a sources.js tuple:
|
|
109
|
+
* 📖 [modelId, label, tier, sweScore, ctx, providerKey, ...]
|
|
110
|
+
* 📖 or an object with the same fields.
|
|
111
|
+
*
|
|
112
|
+
* @param {Array} models — sources.js MODELS array
|
|
113
|
+
* @param {object} catalog — parsed models.dev catalog (or null = no drift)
|
|
114
|
+
* @param {object} [opts]
|
|
115
|
+
* @param {object} [opts.index] — Pre-built index (skips the build)
|
|
116
|
+
* @param {number} [opts.threshold=0] — Min mismatches to report; 0 = all
|
|
117
|
+
* @returns {Array<{
|
|
118
|
+
* modelId: string, label: string, field: string,
|
|
119
|
+
* sourcesJsValue: any, modelsDevValue: any,
|
|
120
|
+
* action: 'update'|'add'|'drift', matchKind: 'exact'|'alias'|'substring'|'none'
|
|
121
|
+
* }>}
|
|
122
|
+
*/
|
|
123
|
+
export function detectDrift(models, catalog, opts = {}) {
|
|
124
|
+
if (!Array.isArray(models) || models.length === 0) return []
|
|
125
|
+
if (!catalog || typeof catalog !== 'object') return []
|
|
126
|
+
|
|
127
|
+
const index = opts.index ?? buildModelIndex(catalog)
|
|
128
|
+
const threshold = opts.threshold ?? 0
|
|
129
|
+
const out = []
|
|
130
|
+
|
|
131
|
+
for (const entry of models) {
|
|
132
|
+
// 📖 Accept both tuple form and object form
|
|
133
|
+
let modelId, label, ctx, reasoning, vision, thinking, maxTokens
|
|
134
|
+
if (Array.isArray(entry)) {
|
|
135
|
+
[modelId, label, , , ctx] = entry
|
|
136
|
+
// 📖 The 6th+ elements of the sources.js tuple are providerKey + metadata;
|
|
137
|
+
// 📖 for drift we only need the first 5.
|
|
138
|
+
} else if (entry && typeof entry === 'object') {
|
|
139
|
+
({ modelId, label, ctx, reasoning, vision, thinking, maxTokens } = entry)
|
|
140
|
+
} else {
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
if (!modelId) continue
|
|
144
|
+
|
|
145
|
+
const match = lookupModelDevMeta(modelId, label, index)
|
|
146
|
+
if (!match) continue
|
|
147
|
+
const norm = match.entry
|
|
148
|
+
const matchKind = match.matchKind
|
|
149
|
+
|
|
150
|
+
// 📖 Skip substring matches for ctx/metadata to avoid false positives.
|
|
151
|
+
// 📖 Only exact + alias matches contribute to drift.
|
|
152
|
+
if (matchKind === 'substring') continue
|
|
153
|
+
|
|
154
|
+
// ctx
|
|
155
|
+
const ctxCmp = contextMatch(ctx, norm.contextWindow)
|
|
156
|
+
if (ctxCmp.status === 'drift' || ctxCmp.status === 'add') {
|
|
157
|
+
out.push({
|
|
158
|
+
modelId, label, field: 'ctx',
|
|
159
|
+
sourcesJsValue: ctxCmp.sourcesJs,
|
|
160
|
+
modelsDevValue: ctxCmp.dev,
|
|
161
|
+
action: ctxCmp.status === 'add' ? 'add' : 'update',
|
|
162
|
+
matchKind,
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
// maxTokens
|
|
166
|
+
const maxCmp = numMatch(maxTokens, norm.maxOutputTokens)
|
|
167
|
+
if (maxCmp.status === 'drift' || maxCmp.status === 'add') {
|
|
168
|
+
out.push({
|
|
169
|
+
modelId, label, field: 'maxTokens',
|
|
170
|
+
sourcesJsValue: maxCmp.sourcesJs,
|
|
171
|
+
modelsDevValue: maxCmp.dev,
|
|
172
|
+
action: maxCmp.status === 'add' ? 'add' : 'update',
|
|
173
|
+
matchKind,
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
// reasoning
|
|
177
|
+
const rCmp = flagMatch(reasoning, norm.reasoning)
|
|
178
|
+
if (rCmp.status === 'drift' || rCmp.status === 'add') {
|
|
179
|
+
out.push({
|
|
180
|
+
modelId, label, field: 'reasoning',
|
|
181
|
+
sourcesJsValue: rCmp.sourcesJs,
|
|
182
|
+
modelsDevValue: rCmp.dev,
|
|
183
|
+
action: rCmp.status === 'add' ? 'add' : 'update',
|
|
184
|
+
matchKind,
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
// vision
|
|
188
|
+
const vCmp = flagMatch(vision, norm.vision)
|
|
189
|
+
if (vCmp.status === 'drift' || vCmp.status === 'add') {
|
|
190
|
+
out.push({
|
|
191
|
+
modelId, label, field: 'vision',
|
|
192
|
+
sourcesJsValue: vCmp.sourcesJs,
|
|
193
|
+
modelsDevValue: vCmp.dev,
|
|
194
|
+
action: vCmp.status === 'add' ? 'add' : 'update',
|
|
195
|
+
matchKind,
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
// thinking
|
|
199
|
+
const tCmp = flagMatch(thinking, norm.thinking)
|
|
200
|
+
if (tCmp.status === 'drift' || tCmp.status === 'add') {
|
|
201
|
+
out.push({
|
|
202
|
+
modelId, label, field: 'thinking',
|
|
203
|
+
sourcesJsValue: tCmp.sourcesJs,
|
|
204
|
+
modelsDevValue: tCmp.dev,
|
|
205
|
+
action: tCmp.status === 'add' ? 'add' : 'update',
|
|
206
|
+
matchKind,
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (threshold > 0 && out.length < threshold) {
|
|
212
|
+
return []
|
|
213
|
+
}
|
|
214
|
+
return out
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ─── Summary + report ────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 📖 Aggregate stats over a drift result list. Used by the TUI footer chip and
|
|
221
|
+
* 📖 the /health endpoint.
|
|
222
|
+
*
|
|
223
|
+
* @param {Array} mismatches — Output of detectDrift
|
|
224
|
+
* @returns {{
|
|
225
|
+
* total: number,
|
|
226
|
+
* byField: Record<string, number>,
|
|
227
|
+
* byAction: Record<string, number>,
|
|
228
|
+
* modelsAffected: string[]
|
|
229
|
+
* }}
|
|
230
|
+
*/
|
|
231
|
+
export function summarizeDrift(mismatches) {
|
|
232
|
+
const byField = Object.fromEntries(DRIFT_FIELDS.map(f => [f, 0]))
|
|
233
|
+
const byAction = { update: 0, add: 0, drift: 0 }
|
|
234
|
+
const modelsSet = new Set()
|
|
235
|
+
for (const m of mismatches || []) {
|
|
236
|
+
byField[m.field] = (byField[m.field] ?? 0) + 1
|
|
237
|
+
byAction[m.action] = (byAction[m.action] ?? 0) + 1
|
|
238
|
+
modelsSet.add(m.modelId)
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
total: (mismatches || []).length,
|
|
242
|
+
byField,
|
|
243
|
+
byAction,
|
|
244
|
+
modelsAffected: Array.from(modelsSet).sort(),
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 📖 Format a drift report for human reading. Used by the CLI script and the
|
|
250
|
+
* 📖 GitHub Actions workflow (which posts the report to an issue).
|
|
251
|
+
*
|
|
252
|
+
* @param {Array} mismatches — Output of detectDrift
|
|
253
|
+
* @param {object} [opts]
|
|
254
|
+
* @param {boolean} [opts.useColor=true] — ANSI-color the output
|
|
255
|
+
* @returns {string} Multi-line report
|
|
256
|
+
*/
|
|
257
|
+
export function formatDriftReport(mismatches, opts = {}) {
|
|
258
|
+
const useColor = opts.useColor !== false && process.stdout?.isTTY === true
|
|
259
|
+
const RED = useColor ? '\x1b[31m' : ''
|
|
260
|
+
const YEL = useColor ? '\x1b[33m' : ''
|
|
261
|
+
const GRN = useColor ? '\x1b[32m' : ''
|
|
262
|
+
const DIM = useColor ? '\x1b[2m' : ''
|
|
263
|
+
const RST = useColor ? '\x1b[0m' : ''
|
|
264
|
+
|
|
265
|
+
const summary = summarizeDrift(mismatches)
|
|
266
|
+
if (summary.total === 0) {
|
|
267
|
+
return `${GRN}✓ No catalog drift detected${RST} ${DIM}(all models match models.dev)${RST}`
|
|
268
|
+
}
|
|
269
|
+
const lines = []
|
|
270
|
+
lines.push(`${YEL}⚠️ Catalog drift detected (${summary.total} mismatches across ${summary.modelsAffected.length} models)${RST}`)
|
|
271
|
+
lines.push('')
|
|
272
|
+
|
|
273
|
+
// 📖 Group by model for readability
|
|
274
|
+
const byModel = new Map()
|
|
275
|
+
for (const m of mismatches) {
|
|
276
|
+
const list = byModel.get(m.modelId) ?? []
|
|
277
|
+
list.push(m)
|
|
278
|
+
byModel.set(m.modelId, list)
|
|
279
|
+
}
|
|
280
|
+
for (const [modelId, list] of byModel) {
|
|
281
|
+
const label = list[0].label || modelId
|
|
282
|
+
lines.push(` ${label} ${DIM}(${modelId})${RST}`)
|
|
283
|
+
for (const m of list) {
|
|
284
|
+
const arrow = m.action === 'add' ? '← ADD' : '← UPDATE'
|
|
285
|
+
const color = m.action === 'add' ? GRN : RED
|
|
286
|
+
const sj = m.sourcesJsValue === null || m.sourcesJsValue === undefined ? '—' : String(m.sourcesJsValue)
|
|
287
|
+
const dv = m.modelsDevValue === null || m.modelsDevValue === undefined ? '—' : String(m.modelsDevValue)
|
|
288
|
+
lines.push(` ${DIM}${m.field.padEnd(11)}${RST} sources.js=${sj} models.dev=${dv} ${color}${arrow}${RST}`)
|
|
289
|
+
}
|
|
290
|
+
lines.push('')
|
|
291
|
+
}
|
|
292
|
+
return lines.join('\n')
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Re-export normalizeModelDevEntry for convenience (drift callers often need it)
|
|
296
|
+
export { normalizeModelDevEntry }
|
|
@@ -68,6 +68,17 @@ import {
|
|
|
68
68
|
getAllQuotas as getAllPassiveQuotas,
|
|
69
69
|
STALENESS_MS as PASSIVE_QUOTA_STALENESS_MS,
|
|
70
70
|
} from './provider-quota-fetchers.js'
|
|
71
|
+
import {
|
|
72
|
+
recordModelCall as recordRuntimeModelCall,
|
|
73
|
+
getAllModelTelemetry as getAllRuntimeTelemetry,
|
|
74
|
+
getRealWorldScore as getRuntimeRealWorldScore,
|
|
75
|
+
getCacheStats as getRuntimeCacheStats,
|
|
76
|
+
loadRuntimeTelemetry,
|
|
77
|
+
flushRuntimeTelemetry as flushRuntimeTelemetryStore,
|
|
78
|
+
clearRuntimeTelemetry as clearRuntimeTelemetryStore,
|
|
79
|
+
pruneStaleEntries as pruneRuntimeTelemetry,
|
|
80
|
+
DEFAULT_MIN_CALLS_FOR_SCORE as RUNTIME_MIN_CALLS,
|
|
81
|
+
} from './runtime-telemetry.js'
|
|
71
82
|
|
|
72
83
|
export const ROUTER_DEFAULT_PORT = 19280
|
|
73
84
|
export const ROUTER_MAX_PORT = 19289
|
|
@@ -895,9 +906,50 @@ class RouterRuntime {
|
|
|
895
906
|
this.probeCache = loadProbeCache()
|
|
896
907
|
this.probeCacheDirty = false
|
|
897
908
|
this.probeCacheFlushTimer = null
|
|
909
|
+
// 📖 Runtime telemetry (t3): load the persistent per-model metrics file on boot.
|
|
910
|
+
// 📖 Prune models that haven't been seen in 30 days to keep file size bounded.
|
|
911
|
+
loadRuntimeTelemetry()
|
|
912
|
+
pruneRuntimeTelemetry(30 * 24 * 60 * 60 * 1000)
|
|
913
|
+
this.runtimeTelemetryDirty = false
|
|
914
|
+
this.runtimeTelemetryFlushTimer = null
|
|
898
915
|
this.refreshRouteState()
|
|
899
916
|
}
|
|
900
917
|
|
|
918
|
+
/**
|
|
919
|
+
* 📖 scheduleRuntimeTelemetryFlush — debounced write so we don't thrash the
|
|
920
|
+
* 📖 filesystem when many requests succeed in quick succession.
|
|
921
|
+
*/
|
|
922
|
+
scheduleRuntimeTelemetryFlush() {
|
|
923
|
+
if (this.runtimeTelemetryFlushTimer) return
|
|
924
|
+
this.runtimeTelemetryFlushTimer = setTimeout(() => {
|
|
925
|
+
this.runtimeTelemetryFlushTimer = null
|
|
926
|
+
if (this.runtimeTelemetryDirty) {
|
|
927
|
+
flushRuntimeTelemetryStore()
|
|
928
|
+
this.runtimeTelemetryDirty = false
|
|
929
|
+
}
|
|
930
|
+
}, 5000)
|
|
931
|
+
if (typeof this.runtimeTelemetryFlushTimer.unref === 'function') this.runtimeTelemetryFlushTimer.unref()
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* 📖 recordRuntimeCall — central helper invoked from both success and failure
|
|
936
|
+
* 📖 paths in the reverse proxy. Wraps recordModelCall with the call-shape
|
|
937
|
+
* 📖 translation so every routed outcome (success or fail) feeds the telemetry.
|
|
938
|
+
*/
|
|
939
|
+
recordRuntimeCall({ providerKey, modelId, success, latencyMs, usage, error }) {
|
|
940
|
+
if (!providerKey || !modelId) return
|
|
941
|
+
recordRuntimeModelCall(providerKey, modelId, {
|
|
942
|
+
success: !!success,
|
|
943
|
+
latencyMs: typeof latencyMs === 'number' && Number.isFinite(latencyMs) ? latencyMs : 0,
|
|
944
|
+
promptTokens: usage?.prompt_tokens,
|
|
945
|
+
completionTokens: usage?.completion_tokens,
|
|
946
|
+
stopReason: success ? 'stop' : null,
|
|
947
|
+
error: success ? null : (error || 'unknown'),
|
|
948
|
+
})
|
|
949
|
+
this.runtimeTelemetryDirty = true
|
|
950
|
+
this.scheduleRuntimeTelemetryFlush()
|
|
951
|
+
}
|
|
952
|
+
|
|
901
953
|
buildModelCatalog() {
|
|
902
954
|
const catalog = new Map()
|
|
903
955
|
for (const [providerKey, source] of Object.entries(sources)) {
|
|
@@ -1503,6 +1555,15 @@ class RouterRuntime {
|
|
|
1503
1555
|
// 📖 fetcher fallback). Stale entries (older than PASSIVE_QUOTA_STALENESS_MS)
|
|
1504
1556
|
// 📖 are excluded so the consumer only sees fresh data.
|
|
1505
1557
|
quota: Object.fromEntries(getAllPassiveQuotas()),
|
|
1558
|
+
// 📖 Runtime telemetry (t3): aggregate counters + every model's derived
|
|
1559
|
+
// 📖 snapshot (successRate, avgLatencyMs, avgTokensPerSecond, recentCalls).
|
|
1560
|
+
// 📖 Surfaced via /stats/runtime (separate endpoint) and here as a digest
|
|
1561
|
+
// 📖 so dashboards can show '1,420 calls tracked across 12 models' without
|
|
1562
|
+
// 📖 an extra round-trip.
|
|
1563
|
+
runtimeTelemetry: {
|
|
1564
|
+
stats: getRuntimeCacheStats(),
|
|
1565
|
+
models: getAllRuntimeTelemetry(),
|
|
1566
|
+
},
|
|
1506
1567
|
}
|
|
1507
1568
|
}
|
|
1508
1569
|
|
|
@@ -2087,6 +2148,15 @@ class RouterRuntime {
|
|
|
2087
2148
|
this.markSuccess(key, latencyMs)
|
|
2088
2149
|
const usage = extractUsage(parsed.value)
|
|
2089
2150
|
this.tokenTracker.record(candidate.provider, candidate.model, usage)
|
|
2151
|
+
// 📖 Runtime telemetry (t3): track every successful routed request so the
|
|
2152
|
+
// 📖 real-world score can rank models by what *actually* works.
|
|
2153
|
+
this.recordRuntimeCall({
|
|
2154
|
+
providerKey: candidate.provider,
|
|
2155
|
+
modelId: candidate.model,
|
|
2156
|
+
success: true,
|
|
2157
|
+
latencyMs,
|
|
2158
|
+
usage,
|
|
2159
|
+
})
|
|
2090
2160
|
this.totalRequestsRouted += 1
|
|
2091
2161
|
// 📖 Fire app_router_use telemetry once per 10 routed requests
|
|
2092
2162
|
if (this.totalRequestsRouted % 10 === 0) {
|
|
@@ -2122,12 +2192,20 @@ class RouterRuntime {
|
|
|
2122
2192
|
if (AUTH_STATUS_CODES.has(response.status)) {
|
|
2123
2193
|
this.markAuthError(key, `HTTP ${response.status}`)
|
|
2124
2194
|
this.addRequestLog({ request_id: requestId, model: key, status: response.status, latency_ms: latencyMs, tokens: 0, failover: attemptIndex > 0, error: 'auth_error' })
|
|
2195
|
+
this.recordRuntimeCall({
|
|
2196
|
+
providerKey: candidate.provider, modelId: candidate.model,
|
|
2197
|
+
success: false, latencyMs, error: `auth_${response.status}`,
|
|
2198
|
+
})
|
|
2125
2199
|
return { done: false, failoverToNext: true, reason: `auth_${response.status}`, authFailure: true }
|
|
2126
2200
|
}
|
|
2127
2201
|
|
|
2128
2202
|
if (RETRYABLE_STATUS_CODES.has(response.status)) {
|
|
2129
2203
|
this.markFailure(key, `HTTP ${response.status}`, response.status, upstreamMeta)
|
|
2130
2204
|
this.addRequestLog({ request_id: requestId, model: key, status: response.status, latency_ms: latencyMs, tokens: 0, failover: attemptIndex > 0, error: `http_${response.status}` })
|
|
2205
|
+
this.recordRuntimeCall({
|
|
2206
|
+
providerKey: candidate.provider, modelId: candidate.model,
|
|
2207
|
+
success: false, latencyMs, error: `http_${response.status}`,
|
|
2208
|
+
})
|
|
2131
2209
|
return { done: false, failoverToNext: true, reason: `http_${response.status}` }
|
|
2132
2210
|
}
|
|
2133
2211
|
|
|
@@ -2137,6 +2215,10 @@ class RouterRuntime {
|
|
|
2137
2215
|
this.recordRouterError(`http_${response.status}`, requestId, { model: key, status: response.status, body: text })
|
|
2138
2216
|
this.markFailure(key, `HTTP ${response.status}`)
|
|
2139
2217
|
this.addRequestLog({ request_id: requestId, model: key, status: response.status, latency_ms: latencyMs, tokens: 0, failover: attemptIndex > 0, error: `http_${response.status}` })
|
|
2218
|
+
this.recordRuntimeCall({
|
|
2219
|
+
providerKey: candidate.provider, modelId: candidate.model,
|
|
2220
|
+
success: false, latencyMs, error: `http_${response.status}`,
|
|
2221
|
+
})
|
|
2140
2222
|
return { done: false, failoverToNext: true, reason: `http_${response.status}` }
|
|
2141
2223
|
}
|
|
2142
2224
|
|
|
@@ -2696,6 +2778,18 @@ class RouterRuntime {
|
|
|
2696
2778
|
sendJson(res, 200, this.statsPayload(), { 'x-request-id': requestId })
|
|
2697
2779
|
return
|
|
2698
2780
|
}
|
|
2781
|
+
// 📖 Runtime telemetry (t3): /stats/runtime returns every model's derived
|
|
2782
|
+
// 📖 snapshot (successRate, avgLatencyMs, avgTokensPerSecond, recentCalls).
|
|
2783
|
+
// 📖 Useful for the Web Dashboard's runtime cards + the upcoming Shift+W
|
|
2784
|
+
// 📖 'Runtime Report' screen in the TUI.
|
|
2785
|
+
if (req.method === 'GET' && url.pathname === '/stats/runtime') {
|
|
2786
|
+
sendJson(res, 200, {
|
|
2787
|
+
ok: true,
|
|
2788
|
+
stats: getRuntimeCacheStats(),
|
|
2789
|
+
models: getAllRuntimeTelemetry(),
|
|
2790
|
+
}, { 'x-request-id': requestId })
|
|
2791
|
+
return
|
|
2792
|
+
}
|
|
2699
2793
|
if (req.method === 'GET' && url.pathname === '/stats/tokens') {
|
|
2700
2794
|
sendJson(res, 200, this.tokenTracker.summary(), { 'x-request-id': requestId })
|
|
2701
2795
|
return
|
|
@@ -3177,6 +3271,7 @@ class RouterRuntime {
|
|
|
3177
3271
|
if (this.configReloadTimer) clearInterval(this.configReloadTimer)
|
|
3178
3272
|
if (this.tokenFlushTimer) clearInterval(this.tokenFlushTimer)
|
|
3179
3273
|
if (this.probeCacheFlushTimer) clearInterval(this.probeCacheFlushTimer)
|
|
3274
|
+
if (this.runtimeTelemetryFlushTimer) clearTimeout(this.runtimeTelemetryFlushTimer)
|
|
3180
3275
|
for (const timeout of this.probeTimeouts) clearTimeout(timeout)
|
|
3181
3276
|
const started = Date.now()
|
|
3182
3277
|
while (this.inFlight > 0 && Date.now() - started < 30000) {
|
|
@@ -3184,6 +3279,7 @@ class RouterRuntime {
|
|
|
3184
3279
|
}
|
|
3185
3280
|
this.tokenTracker.flush({ force: true })
|
|
3186
3281
|
flushProbeCache() // 📖 t1: persist any pending probe-cache deltas before exit
|
|
3282
|
+
if (this.runtimeTelemetryDirty) flushRuntimeTelemetryStore() // 📖 t3
|
|
3187
3283
|
try { this.server?.close() } catch {}
|
|
3188
3284
|
try { unlinkSync(ROUTER_PID_PATH) } catch {}
|
|
3189
3285
|
try { unlinkSync(ROUTER_PORT_PATH) } catch {}
|