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.
@@ -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 {}