free-coding-models 0.5.56 → 0.5.58

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,515 @@
1
+ /**
2
+ * @file probe-cache.js
3
+ * @description Persistent cache for health-probe results, shared across CLI, daemon, and Tauri.
4
+ *
5
+ * @details
6
+ * 📖 Why this exists:
7
+ * 📖 - Today every `pnpm start` re-pings all ~238 models from scratch (10-30s cold start).
8
+ * 📖 - A model returning `ok` < 24h ago is almost certainly still healthy — skip the ping.
9
+ * 📖 - A model returning `broken` should always be re-probed (allows recovery detection)
10
+ * 📖 AND hidden from the default view until it recovers.
11
+ *
12
+ * 📖 Freshness rules (in order):
13
+ * 📖 1. No entry → due for probe
14
+ * 📖 2. probeVersion mismatch → due for probe (silently re-probed, entry overwritten)
15
+ * 📖 3. status === 'broken' → always due (allows recovery detection)
16
+ * 📖 4. now - lastProbedAt >= ttlMs → due for probe
17
+ * 📖 5. otherwise → fresh, skip
18
+ *
19
+ * 📖 File location:
20
+ * 📖 - $XDG_CACHE_HOME/free-coding-models/probe-cache.json if XDG_CACHE_HOME is set
21
+ * 📖 - ~/.free-coding-models/probe-cache.json otherwise
22
+ *
23
+ * 📖 All surface modes (CLI TUI, Web Dashboard / daemon, Tauri Desktop) read/write the
24
+ * 📖 same file. Concurrency between daemon and CLI is handled via read-merge-write on
25
+ * 📖 every flush (see flushCache) plus the atomic tmp + rename helper from shared-helpers.
26
+ *
27
+ * @functions
28
+ * → getProbeCachePath() — Resolves the cache file path
29
+ * → loadCache({ path, now }?) — Reads + migrates the JSON file
30
+ * → flushCache({ path, cache, now }?) — Atomic write of the cache
31
+ * → clearCache({ path }?) — Nukes the file (for --reprobe)
32
+ * → getModelsDueForProbe(providerKey, modelIds, opts?) → string[] of IDs needing ping
33
+ * → isCacheFresh(providerKey, modelId, opts?) → boolean freshness check
34
+ * → recordProbeResults(providerKey, results, opts?) → mutates in-memory cache
35
+ * → getCacheStats(opts?) → { total, ok, broken, freshCount, staleCount, ... }
36
+ * → getCachedResultsForProvider(providerKey, opts?) → array of synthesized results
37
+ *
38
+ * @exports getProbeCachePath, loadCache, flushCache, clearCache,
39
+ * getModelsDueForProbe, isCacheFresh, recordProbeResults,
40
+ * getCacheStats, getCachedResultsForProvider,
41
+ * DEFAULT_PROBE_TTL_MS, CURRENT_PROBE_VERSION
42
+ *
43
+ * @see src/core/ping-loop.js — the integration point (skips fresh entries)
44
+ * @see src/core/cache.js — older per-session ping cache (5 min TTL, distinct concern)
45
+ * @see src/core/shared-helpers.js — atomicWriteJson (used by flushCache)
46
+ */
47
+
48
+ import fs from 'node:fs'
49
+ import os from 'node:os'
50
+ import path from 'node:path'
51
+ import { atomicWriteJson } from './shared-helpers.js'
52
+
53
+ // ─── Constants ────────────────────────────────────────────────────────────────
54
+
55
+ /** 📖 Default probe-result freshness window — 24h. Override via opts.ttlMs. */
56
+ export const DEFAULT_PROBE_TTL_MS = 24 * 60 * 60 * 1000
57
+
58
+ /**
59
+ * 📖 Bump this number whenever ping behaviour changes (new endpoint, different prompt,
60
+ * 📖 new provider factory from t7, etc.). On load, any entry whose probeVersion differs
61
+ * 📖 is treated as due-for-probe and silently overwritten — no manual purge needed.
62
+ */
63
+ export const CURRENT_PROBE_VERSION = 2
64
+
65
+ /** 📖 Cache file basename. Lives in a directory alongside other FCM state files. */
66
+ const CACHE_FILENAME = 'probe-cache.json'
67
+ const CACHE_DIRNAME = 'free-coding-models'
68
+
69
+ // ─── Module-level state ──────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * 📖 In-memory mirror of the on-disk cache. Loaded lazily on first call to
73
+ * 📖 any function that needs it (loadCache / getModelsDueForProbe / etc.).
74
+ * 📖 Mutations via recordProbeResults() update this object; flushCache() writes it.
75
+ */
76
+ let _cache = null
77
+ let _cacheLoadedFrom = null // path we last loaded from (for write-back)
78
+
79
+ // ─── Path resolution ──────────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * 📖 Resolves where the probe-cache JSON lives.
83
+ * 📖 Honours XDG_CACHE_HOME when set (Linux/macOS convention), else falls back to ~/.free-coding-models.
84
+ *
85
+ * @returns {string} Absolute path to the cache file (may not exist yet).
86
+ */
87
+ export function getProbeCachePath() {
88
+ const xdg = process.env.XDG_CACHE_HOME
89
+ const baseDir = xdg && xdg.trim()
90
+ ? path.join(xdg, CACHE_DIRNAME)
91
+ : path.join(os.homedir(), `.${CACHE_DIRNAME}`)
92
+ return path.join(baseDir, CACHE_FILENAME)
93
+ }
94
+
95
+ // ─── Low-level load / flush / clear ───────────────────────────────────────────
96
+
97
+ /**
98
+ * 📖 Empty cache shape — used as the default when no file exists or it cannot be parsed.
99
+ * @returns {{ version: number, providers: Record<string, { models: Record<string, ProbeEntry> }> }}
100
+ */
101
+ function emptyCache() {
102
+ return { version: CURRENT_PROBE_VERSION, providers: {} }
103
+ }
104
+
105
+ /**
106
+ * 📖 Read the cache JSON from disk. Returns an empty cache on any I/O or parse error.
107
+ * 📖 Also normalises older versions by setting version = CURRENT_PROBE_VERSION so the
108
+ * 📖 freshness check picks them up as due (the per-entry probeVersion mismatch handles it).
109
+ *
110
+ * @param {object} [opts]
111
+ * @param {string} [opts.path] — Override the cache file path (mainly for tests).
112
+ * @returns {object} The loaded cache object.
113
+ */
114
+ export function loadCache({ path: cachePath } = {}) {
115
+ const target = cachePath ?? getProbeCachePath()
116
+
117
+ let raw
118
+ try {
119
+ raw = fs.readFileSync(target, 'utf-8')
120
+ } catch (err) {
121
+ if (err && err.code === 'ENOENT') return emptyCache()
122
+ // 📖 Any other read error — start fresh rather than crash.
123
+ return emptyCache()
124
+ }
125
+
126
+ let parsed
127
+ try {
128
+ parsed = JSON.parse(raw)
129
+ } catch {
130
+ // 📖 Corrupt JSON — start fresh (atomic write means we should never see this,
131
+ // 📖 but if the file is hand-edited or partially written by an older buggy version
132
+ // 📖 we recover gracefully).
133
+ return emptyCache()
134
+ }
135
+
136
+ // 📖 Structural validation — missing fields get filled with defaults.
137
+ if (!parsed || typeof parsed !== 'object') return emptyCache()
138
+ if (typeof parsed.version !== 'number') parsed.version = CURRENT_PROBE_VERSION
139
+ if (!parsed.providers || typeof parsed.providers !== 'object') parsed.providers = {}
140
+ for (const provider of Object.values(parsed.providers)) {
141
+ if (!provider || typeof provider !== 'object') continue
142
+ if (!provider.models || typeof provider.models !== 'object') provider.models = {}
143
+ }
144
+
145
+ _cache = parsed
146
+ _cacheLoadedFrom = target
147
+ return parsed
148
+ }
149
+
150
+ /**
151
+ * 📖 Merge two cache objects: incoming wins on key collision, but absent fields
152
+ * 📖 from incoming do NOT delete fields from base. Used by flushCache to merge
153
+ * 📖 our in-memory mirror with whatever the on-disk file now contains (covers
154
+ * 📖 the daemon + CLI running concurrently case).
155
+ */
156
+ function mergeCache(base, incoming) {
157
+ if (!base || typeof base !== 'object') return incoming
158
+ if (!incoming || typeof incoming !== 'object') return base
159
+ const out = { ...incoming, providers: { ...(incoming.providers || {}) } }
160
+ for (const [providerKey, providerBucket] of Object.entries(base.providers || {})) {
161
+ const incomingBucket = out.providers[providerKey] || { models: {} }
162
+ out.providers[providerKey] = {
163
+ models: { ...(providerBucket?.models || {}), ...(incomingBucket.models || {}) },
164
+ }
165
+ }
166
+ return out
167
+ }
168
+
169
+ /**
170
+ * 📖 Persist the in-memory cache to disk atomically (tmp + rename) with a
171
+ * 📖 read-merge-write pass so concurrent daemons and CLIs don't clobber each
172
+ * 📖 other. Worst case: one batch of deltas is merged twice (idempotent), or
173
+ * 📖 a stale `lastProbedAt` survives briefly (acceptable per t1 risk register).
174
+ *
175
+ * @param {object} [opts]
176
+ * @param {string} [opts.path] — Override the cache file path.
177
+ * @param {object} [opts.cache] — Override the in-memory cache (defaults to module state).
178
+ * @returns {boolean} true on success, false on any I/O error.
179
+ */
180
+ export function flushCache({ path: cachePath, cache } = {}) {
181
+ const target = cachePath ?? _cacheLoadedFrom ?? getProbeCachePath()
182
+ const localData = cache ?? _cache ?? emptyCache()
183
+
184
+ // 📖 Read whatever is on disk RIGHT NOW (may have been written by another process
185
+ // 📖 since we last loaded), and merge our deltas over the top.
186
+ let onDisk = null
187
+ try {
188
+ const raw = fs.readFileSync(target, 'utf-8')
189
+ onDisk = JSON.parse(raw)
190
+ if (!onDisk || typeof onDisk !== 'object') onDisk = null
191
+ } catch {
192
+ onDisk = null
193
+ }
194
+
195
+ const merged = onDisk ? mergeCache(onDisk, localData) : localData
196
+
197
+ try {
198
+ atomicWriteJson(target, merged, 0o600)
199
+ _cacheLoadedFrom = target
200
+ _cache = merged
201
+ return true
202
+ } catch {
203
+ return false
204
+ }
205
+ }
206
+
207
+ /**
208
+ * 📖 Delete the cache file from disk. Used by `--reprobe` / `--no-cache` flags.
209
+ * 📖 Also clears the in-memory mirror so the next call reloads from scratch.
210
+ *
211
+ * @param {object} [opts]
212
+ * @param {string} [opts.path] — Override the cache file path.
213
+ * @returns {boolean} true if the file was deleted (or didn't exist), false on error.
214
+ */
215
+ export function clearCache({ path: cachePath } = {}) {
216
+ const target = cachePath ?? getProbeCachePath()
217
+ _cache = null
218
+ _cacheLoadedFrom = null
219
+ try {
220
+ fs.unlinkSync(target)
221
+ return true
222
+ } catch (err) {
223
+ if (err && err.code === 'ENOENT') return true
224
+ return false
225
+ }
226
+ }
227
+
228
+ // ─── Module-state accessor ───────────────────────────────────────────────────
229
+
230
+ /**
231
+ * 📖 Get the current in-memory cache, loading from disk if not yet loaded.
232
+ * 📖 Pure-isolated: callers can pass `opts.cache` to avoid touching module state
233
+ * 📖 (used by all freshness / stats functions for testability).
234
+ */
235
+ function getCache(opts) {
236
+ if (opts && Object.prototype.hasOwnProperty.call(opts, 'cache')) return opts.cache
237
+ if (_cache) return _cache
238
+ return loadCache()
239
+ }
240
+
241
+ // ─── Freshness rules ─────────────────────────────────────────────────────────
242
+
243
+ /**
244
+ * 📖 Decide which model IDs are due for a (re-)probe, given the current cache.
245
+ * 📖 See file header for the 5 rules.
246
+ *
247
+ * @param {string} providerKey
248
+ * @param {string[]} modelIds
249
+ * @param {object} [opts]
250
+ * @param {number} [opts.ttlMs=86400000]
251
+ * @param {number} [opts.now=Date.now()]
252
+ * @param {object} [opts.cache] — Injected cache (skips module state + disk)
253
+ * @param {number} [opts.probeVersion=2]
254
+ * @returns {string[]} Subset of `modelIds` that need probing this cycle.
255
+ */
256
+ export function getModelsDueForProbe(providerKey, modelIds, opts = {}) {
257
+ const ttlMs = opts.ttlMs ?? DEFAULT_PROBE_TTL_MS
258
+ const now = opts.now ?? Date.now()
259
+ const probeVersion = opts.probeVersion ?? CURRENT_PROBE_VERSION
260
+ const cache = getCache(opts)
261
+ const providerBucket = cache?.providers?.[providerKey]
262
+ const models = providerBucket?.models ?? {}
263
+
264
+ const due = []
265
+ for (const id of modelIds) {
266
+ const entry = models[id]
267
+ // Rule 1: no entry → due
268
+ if (!entry) { due.push(id); continue }
269
+ // Rule 2: version mismatch → due (silently overwritten on next record)
270
+ if (typeof entry.probeVersion !== 'number' || entry.probeVersion !== probeVersion) {
271
+ due.push(id); continue
272
+ }
273
+ // Rule 3: broken → always due (recovery detection)
274
+ if (entry.status === 'broken') { due.push(id); continue }
275
+ // Rule 4: TTL expired → due
276
+ if (typeof entry.lastProbedAt !== 'number' || now - entry.lastProbedAt >= ttlMs) {
277
+ due.push(id); continue
278
+ }
279
+ // Rule 5: fresh + ok → skip
280
+ }
281
+ return due
282
+ }
283
+
284
+ /**
285
+ * 📖 Single-model freshness check. Returns true only when ALL conditions hold:
286
+ * 📖 - entry exists
287
+ * 📖 - probeVersion matches CURRENT_PROBE_VERSION
288
+ * 📖 - status !== 'broken'
289
+ * 📖 - now - lastProbedAt < ttlMs
290
+ *
291
+ * @param {string} providerKey
292
+ * @param {string} modelId
293
+ * @param {object} [opts]
294
+ * @returns {boolean}
295
+ */
296
+ export function isCacheFresh(providerKey, modelId, opts = {}) {
297
+ const ttlMs = opts.ttlMs ?? DEFAULT_PROBE_TTL_MS
298
+ const now = opts.now ?? Date.now()
299
+ const probeVersion = opts.probeVersion ?? CURRENT_PROBE_VERSION
300
+ const cache = getCache(opts)
301
+ const entry = cache?.providers?.[providerKey]?.models?.[modelId]
302
+ if (!entry) return false
303
+ if (typeof entry.probeVersion !== 'number' || entry.probeVersion !== probeVersion) return false
304
+ if (entry.status === 'broken') return false
305
+ if (typeof entry.lastProbedAt !== 'number') return false
306
+ return now - entry.lastProbedAt < ttlMs
307
+ }
308
+
309
+ // ─── Write path ───────────────────────────────────────────────────────────────
310
+
311
+ /**
312
+ * 📖 Validate + normalise a single probe result into the on-disk shape.
313
+ * 📖 Throws on garbage input — callers should catch and drop.
314
+ */
315
+ function normaliseResult(r) {
316
+ if (!r || typeof r !== 'object') throw new Error('probe result must be an object')
317
+ if (typeof r.modelId !== 'string' || !r.modelId) throw new Error('modelId required')
318
+ if (r.status !== 'ok' && r.status !== 'broken') throw new Error(`status must be 'ok' or 'broken'`)
319
+ return {
320
+ modelId: r.modelId,
321
+ status: r.status,
322
+ latencyMs: typeof r.latencyMs === 'number' && Number.isFinite(r.latencyMs) ? r.latencyMs : null,
323
+ lastError: typeof r.lastError === 'string' ? r.lastError : null,
324
+ }
325
+ }
326
+
327
+ /**
328
+ * 📖 Persist a batch of probe results into the in-memory cache (and schedule a flush).
329
+ * 📖 Per-result validation: bad entries are dropped, good ones are written.
330
+ * 📖 The module-level cache is mutated in place; flushCache() is a separate call.
331
+ *
332
+ * @param {string} providerKey
333
+ * @param {Array<{ modelId: string, status: 'ok'|'broken', latencyMs?: number, lastError?: string }>} results
334
+ * @param {object} [opts]
335
+ * @param {number} [opts.now=Date.now()]
336
+ * @param {object} [opts.cache] — Optional explicit cache to mutate (skips module state).
337
+ * @returns {{ written: number, dropped: number }} Counts for telemetry.
338
+ */
339
+ export function recordProbeResults(providerKey, results, opts = {}) {
340
+ const now = opts.now ?? Date.now()
341
+ const cache = (opts && Object.prototype.hasOwnProperty.call(opts, 'cache')) ? opts.cache : getCache(opts)
342
+
343
+ if (!cache.providers[providerKey]) {
344
+ cache.providers[providerKey] = { models: {} }
345
+ }
346
+ const bucket = cache.providers[providerKey].models
347
+
348
+ let written = 0
349
+ let dropped = 0
350
+ for (const raw of results || []) {
351
+ try {
352
+ const r = normaliseResult(raw)
353
+ bucket[r.modelId] = {
354
+ status: r.status,
355
+ lastProbedAt: now,
356
+ latencyMs: r.latencyMs,
357
+ lastError: r.lastError,
358
+ probeVersion: CURRENT_PROBE_VERSION,
359
+ }
360
+ written++
361
+ } catch {
362
+ dropped++
363
+ }
364
+ }
365
+
366
+ // 📖 Mark cache as dirty if we're touching module state. flushCache() picks it up later.
367
+ if (!opts || !Object.prototype.hasOwnProperty.call(opts, 'cache')) {
368
+ _cache = cache
369
+ }
370
+
371
+ return { written, dropped }
372
+ }
373
+
374
+ // ─── Stats ────────────────────────────────────────────────────────────────────
375
+
376
+ /**
377
+ * 📖 Aggregate stats over the current cache, useful for the TUI footer chip and
378
+ * 📖 the daemon's /health endpoint.
379
+ *
380
+ * @param {object} [opts]
381
+ * @param {number} [opts.ttlMs=86400000]
382
+ * @param {number} [opts.now=Date.now()]
383
+ * @param {object} [opts.cache]
384
+ * @returns {{
385
+ * total: number,
386
+ * ok: number,
387
+ * broken: number,
388
+ * freshCount: number, // ok + within TTL
389
+ * staleCount: number, // ok but past TTL (would be due for probe under rules 1/4)
390
+ * dueCount: number, // models that would be re-probed right now (broken + stale)
391
+ * hiddenCount: number, // == broken
392
+ * providers: number,
393
+ * }}
394
+ */
395
+ export function getCacheStats(opts = {}) {
396
+ const ttlMs = opts.ttlMs ?? DEFAULT_PROBE_TTL_MS
397
+ const now = opts.now ?? Date.now()
398
+ const cache = getCache(opts)
399
+ const probeVersion = opts.probeVersion ?? CURRENT_PROBE_VERSION
400
+
401
+ let total = 0, ok = 0, broken = 0, freshCount = 0, staleCount = 0
402
+ for (const providerBucket of Object.values(cache.providers ?? {})) {
403
+ for (const entry of Object.values(providerBucket?.models ?? {})) {
404
+ if (!entry) continue
405
+ total++
406
+ const isFresh = entry.status === 'ok'
407
+ && typeof entry.probeVersion === 'number'
408
+ && entry.probeVersion === probeVersion
409
+ && typeof entry.lastProbedAt === 'number'
410
+ && now - entry.lastProbedAt < ttlMs
411
+ if (entry.status === 'ok') ok++
412
+ else if (entry.status === 'broken') broken++
413
+ if (isFresh) freshCount++
414
+ else if (entry.status === 'ok') staleCount++
415
+ }
416
+ }
417
+
418
+ return {
419
+ total,
420
+ ok,
421
+ broken,
422
+ freshCount,
423
+ staleCount,
424
+ dueCount: broken + staleCount,
425
+ hiddenCount: broken,
426
+ providers: Object.keys(cache.providers ?? {}).length,
427
+ }
428
+ }
429
+
430
+ // ─── Synthesised results for the TUI ──────────────────────────────────────────
431
+
432
+ /**
433
+ * 📖 Convert a provider's cache entries into the shape ping-loop emits so the TUI
434
+ * 📖 can render them instantly on warm start. Only returns FRESH entries (rule 5);
435
+ * 📖 broken/stale entries are intentionally excluded so the live ping will refresh them.
436
+ *
437
+ * 📖 Returned shape mirrors what `src/core/ping.js` builds for a successful ping,
438
+ * 📖 so the downstream renderer / ranker don't need to know whether data is cached
439
+ * 📖 or live. Minimum fields used by `render-table.js`:
440
+ * 📖 modelId, providerKey, status ('up'|'down'), avg, p95, jitter, stability,
441
+ * 📖 uptime, verdict, lastProbedAt, source ('cache'|'live')
442
+ *
443
+ * @param {string} providerKey
444
+ * @param {object} [opts]
445
+ * @param {number} [opts.ttlMs=86400000]
446
+ * @param {number} [opts.now=Date.now()]
447
+ * @param {object} [opts.cache]
448
+ * @returns {Array<object>} Synthesised result objects (empty array if no fresh entries).
449
+ */
450
+ export function getCachedResultsForProvider(providerKey, opts = {}) {
451
+ const ttlMs = opts.ttlMs ?? DEFAULT_PROBE_TTL_MS
452
+ const now = opts.now ?? Date.now()
453
+ const cache = getCache(opts)
454
+ const probeVersion = opts.probeVersion ?? CURRENT_PROBE_VERSION
455
+ const bucket = cache?.providers?.[providerKey]?.models ?? {}
456
+
457
+ const out = []
458
+ for (const [modelId, entry] of Object.entries(bucket)) {
459
+ if (!entry || entry.status !== 'ok') continue
460
+ if (typeof entry.probeVersion !== 'number' || entry.probeVersion !== probeVersion) continue
461
+ if (typeof entry.lastProbedAt !== 'number') continue
462
+ if (now - entry.lastProbedAt >= ttlMs) continue
463
+
464
+ const latency = typeof entry.latencyMs === 'number' ? entry.latencyMs : 0
465
+ out.push({
466
+ modelId,
467
+ providerKey,
468
+ status: 'up',
469
+ avg: latency,
470
+ p95: latency,
471
+ jitter: 0,
472
+ stability: 100,
473
+ uptime: 100,
474
+ verdict: 'Cached',
475
+ httpCode: '200',
476
+ lastProbedAt: entry.lastProbedAt,
477
+ source: 'cache',
478
+ latencyMs: latency,
479
+ })
480
+ }
481
+ return out
482
+ }
483
+
484
+ // ─── Pruning ──────────────────────────────────────────────────────────────────
485
+
486
+ /**
487
+ * 📖 Drop entries whose modelId is no longer present in the live catalog.
488
+ * 📖 Called once per boot from ping-loop to keep the cache file bounded as the
489
+ * 📖 catalog evolves (providers add/remove models over time).
490
+ *
491
+ * @param {string} providerKey
492
+ * @param {Set<string> | string[]} liveModelIds
493
+ * @param {object} [opts]
494
+ * @param {object} [opts.cache]
495
+ * @returns {number} Number of entries pruned.
496
+ */
497
+ export function pruneStaleEntries(providerKey, liveModelIds, opts = {}) {
498
+ const cache = getCache(opts)
499
+ const bucket = cache?.providers?.[providerKey]?.models
500
+ if (!bucket) return 0
501
+
502
+ const live = liveModelIds instanceof Set ? liveModelIds : new Set(liveModelIds)
503
+ let pruned = 0
504
+ for (const id of Object.keys(bucket)) {
505
+ if (!live.has(id)) {
506
+ delete bucket[id]
507
+ pruned++
508
+ }
509
+ }
510
+
511
+ if (!opts || !Object.prototype.hasOwnProperty.call(opts, 'cache')) {
512
+ _cache = cache
513
+ }
514
+ return pruned
515
+ }
@@ -54,6 +54,15 @@ import { sendUsageTelemetry } from './telemetry.js'
54
54
  import { TIER_ORDER } from './utils.js'
55
55
  import { atomicWriteJson, safeJsonParse, sleep, maskApiKey, isRouteableProvider } from './shared-helpers.js'
56
56
  import { normalizeRequestBody } from './schema-normalizer.js'
57
+ import {
58
+ loadCache as loadProbeCache,
59
+ flushCache as flushProbeCache,
60
+ recordProbeResults as recordProbeCacheResults,
61
+ getCacheStats as getProbeCacheStats,
62
+ getModelsDueForProbe,
63
+ isCacheFresh as isProbeCacheFresh,
64
+ pruneStaleEntries as pruneProbeCacheStaleEntries,
65
+ } from './probe-cache.js'
57
66
 
58
67
  export const ROUTER_DEFAULT_PORT = 19280
59
68
  export const ROUTER_MAX_PORT = 19289
@@ -870,6 +879,12 @@ class RouterRuntime {
870
879
  this.webGlobalBenchmarkRunning = false
871
880
  this.webGlobalBenchmarkTotal = 0
872
881
  this.webGlobalBenchmarkCompleted = 0
882
+ // 📖 Probe-cache (t1): load the persistent probe-cache on boot so the daemon
883
+ // 📖 can skip fresh healthy models during its health-probe loop and write
884
+ // 📖 results back to the same file the CLI TUI uses. See src/core/probe-cache.js.
885
+ this.probeCache = loadProbeCache()
886
+ this.probeCacheDirty = false
887
+ this.probeCacheFlushTimer = null
873
888
  this.refreshRouteState()
874
889
  }
875
890
 
@@ -1023,6 +1038,39 @@ class RouterRuntime {
1023
1038
  latency_ms: result.latencyMs ?? null,
1024
1039
  circuit_state: this.circuit.get(key)?.state || 'UNKNOWN',
1025
1040
  })
1041
+
1042
+ // 📖 Probe-cache (t1): mirror the result into the persistent cross-session
1043
+ // 📖 cache so the CLI TUI can skip fresh healthy models and auto-hide broken
1044
+ // 📖 ones. key is `provider/modelId` — split on the first slash.
1045
+ const slashIdx = key.indexOf('/')
1046
+ if (slashIdx > 0) {
1047
+ const providerKey = key.slice(0, slashIdx)
1048
+ const modelId = key.slice(slashIdx + 1)
1049
+ recordProbeCacheResults(providerKey, [{
1050
+ modelId,
1051
+ status: result.ok ? 'ok' : 'broken',
1052
+ latencyMs: result.latencyMs ?? null,
1053
+ lastError: result.ok ? null : (result.code != null ? String(result.code) : 'error'),
1054
+ }])
1055
+ this.probeCacheDirty = true
1056
+ this.scheduleProbeCacheFlush()
1057
+ }
1058
+ }
1059
+
1060
+ /**
1061
+ * 📖 scheduleProbeCacheFlush — debounced write to disk so we don't thrash the
1062
+ * 📖 filesystem when a probe burst records dozens of results at once.
1063
+ */
1064
+ scheduleProbeCacheFlush() {
1065
+ if (this.probeCacheFlushTimer) return
1066
+ this.probeCacheFlushTimer = setTimeout(() => {
1067
+ this.probeCacheFlushTimer = null
1068
+ if (this.probeCacheDirty) {
1069
+ flushProbeCache()
1070
+ this.probeCacheDirty = false
1071
+ }
1072
+ }, 2000)
1073
+ if (typeof this.probeCacheFlushTimer.unref === 'function') this.probeCacheFlushTimer.unref()
1026
1074
  }
1027
1075
 
1028
1076
  markAuthError(key, detail = 'authentication failed') {
@@ -1435,6 +1483,10 @@ class RouterRuntime {
1435
1483
  configPath: CONFIG_PATH,
1436
1484
  tokenStatsPath: ROUTER_TOKENS_PATH,
1437
1485
  logPath: ROUTER_LOG_PATH,
1486
+ // 📖 Probe-cache (t1): live aggregates from the persistent probe-cache.
1487
+ // 📖 Surfaced so the Web Dashboard + CLI can show cache hit rate + how many
1488
+ // 📖 broken models are currently hidden. Refreshed every /stats call.
1489
+ probeCache: getProbeCacheStats(),
1438
1490
  }
1439
1491
  }
1440
1492
 
@@ -1529,7 +1581,14 @@ class RouterRuntime {
1529
1581
  if (!set) return
1530
1582
  const candidates = this.scoreCandidates(set)
1531
1583
  .filter((candidate) => candidate.catalog?.routeable && !candidate.circuit?.stale)
1532
- await Promise.allSettled(candidates.map((candidate) => this.probeCandidate(candidate, {
1584
+ // 📖 Probe-cache (t1): skip models that are still fresh + ok in the persistent
1585
+ // 📖 cache. Broken models naturally pass through (isProbeCacheFresh returns false
1586
+ // 📖 for them), so recovery detection keeps working unchanged.
1587
+ const filtered = candidates.filter((c) => {
1588
+ if (!c.catalog) return true
1589
+ return !isProbeCacheFresh(c.catalog.providerKey, c.catalog.modelId)
1590
+ })
1591
+ await Promise.allSettled(filtered.map((candidate) => this.probeCandidate(candidate, {
1533
1592
  eco: this.routerConfig().probeMode === 'eco',
1534
1593
  })))
1535
1594
  }
@@ -3101,12 +3160,14 @@ class RouterRuntime {
3101
3160
  if (this.probeTimer) clearInterval(this.probeTimer)
3102
3161
  if (this.configReloadTimer) clearInterval(this.configReloadTimer)
3103
3162
  if (this.tokenFlushTimer) clearInterval(this.tokenFlushTimer)
3163
+ if (this.probeCacheFlushTimer) clearInterval(this.probeCacheFlushTimer)
3104
3164
  for (const timeout of this.probeTimeouts) clearTimeout(timeout)
3105
3165
  const started = Date.now()
3106
3166
  while (this.inFlight > 0 && Date.now() - started < 30000) {
3107
3167
  await sleep(100)
3108
3168
  }
3109
3169
  this.tokenTracker.flush({ force: true })
3170
+ flushProbeCache() // 📖 t1: persist any pending probe-cache deltas before exit
3110
3171
  try { this.server?.close() } catch {}
3111
3172
  try { unlinkSync(ROUTER_PID_PATH) } catch {}
3112
3173
  try { unlinkSync(ROUTER_PORT_PATH) } catch {}
package/src/core/utils.js CHANGED
@@ -452,11 +452,16 @@ export function findBestModel(results) {
452
452
  // --daemon-status, --no-telemetry, --json, --help/-h (case-insensitive)
453
453
  // --playground / playground subcommand (open the in-TUI chat playground)
454
454
  // - Value flag: --tier <letter> (the next non-flag arg is the tier value)
455
+ // - Probe-cache flags (t1):
456
+ // --reprobe / --no-cache (boolean) — force-rebuild the probe cache this run
457
+ // --probe-ttl <ms> (value) — override the 24h default TTL
458
+ // --show-broken (boolean) — don't auto-hide broken models (one-shot)
455
459
  //
456
460
  // Returns:
457
461
  // { apiKey, bestMode, fiableMode, openCodeMode, openCodeDesktopMode, openCodeWebMode, openClawMode,
458
462
  // aiderMode, crushMode, gooseMode, qwenMode, openHandsMode, ampMode,
459
- // piMode, jcodeMode, copilotMode, forgecodeMode, zcodeMode, noTelemetry, jsonMode, helpMode, tierFilter }
463
+ // piMode, jcodeMode, copilotMode, forgecodeMode, zcodeMode, noTelemetry, jsonMode, helpMode, tierFilter,
464
+ // reprobeMode, probeTtlMs, showBrokenMode }
460
465
  //
461
466
  // 📖 Note: apiKey may be null here — the main CLI falls back to env vars and saved config.
462
467
  export function parseArgs(argv) {
@@ -486,6 +491,12 @@ export function parseArgs(argv) {
486
491
  ? pingIntervalIdx + 1
487
492
  : -1
488
493
 
494
+ // 📖 --probe-ttl <ms> — override the 24h probe-cache TTL (power users / debugging)
495
+ const probeTtlIdx = args.findIndex(a => a.toLowerCase() === '--probe-ttl')
496
+ const probeTtlValueIdx = (probeTtlIdx !== -1 && args[probeTtlIdx + 1] && !args[probeTtlIdx + 1].startsWith('--'))
497
+ ? probeTtlIdx + 1
498
+ : -1
499
+
489
500
  // 📖 --sync-set [name] — auto-discover and live-probe models into a named router set
490
501
  const syncSetIdx = args.findIndex(a => a.toLowerCase() === '--sync-set')
491
502
  const syncSetValueIdx = (syncSetIdx !== -1 && args[syncSetIdx + 1] && !args[syncSetIdx + 1].startsWith('--'))
@@ -499,6 +510,7 @@ export function parseArgs(argv) {
499
510
  if (originValueIdx !== -1) skipIndices.add(originValueIdx)
500
511
  if (pingIntervalValueIdx !== -1) skipIndices.add(pingIntervalValueIdx)
501
512
  if (syncSetValueIdx !== -1) skipIndices.add(syncSetValueIdx)
513
+ if (probeTtlValueIdx !== -1) skipIndices.add(probeTtlValueIdx)
502
514
 
503
515
  for (const [i, arg] of args.entries()) {
504
516
  if (arg.startsWith('--') || arg === '-h') {
@@ -574,6 +586,13 @@ export function parseArgs(argv) {
574
586
  // 📖 --recommend — launch directly into Smart Recommend mode (Q key equivalent)
575
587
  const recommendMode = flags.includes('--recommend')
576
588
 
589
+ // 📖 Probe-cache flags (t1): --reprobe / --no-cache force a fresh probe pass;
590
+ // 📖 --probe-ttl overrides the 24h default; --show-broken un-hides broken models for this run.
591
+ const reprobeMode = flags.includes('--reprobe') || flags.includes('--no-cache')
592
+ const showBrokenMode = flags.includes('--show-broken')
593
+ const probeTtlRaw = probeTtlValueIdx !== -1 ? args[probeTtlValueIdx] : null
594
+ const probeTtlMs = probeTtlRaw !== null ? parseInt(probeTtlRaw, 10) : null
595
+
577
596
  return {
578
597
  apiKey,
579
598
  bestMode,
@@ -621,6 +640,10 @@ export function parseArgs(argv) {
621
640
  devMode,
622
641
  syncSetMode,
623
642
  syncSetName,
643
+ // 📖 Probe-cache flags (t1) — see src/core/probe-cache.js
644
+ reprobeMode,
645
+ probeTtlMs: Number.isFinite(probeTtlMs) && probeTtlMs > 0 ? probeTtlMs : null,
646
+ showBrokenMode,
624
647
  }
625
648
  }
626
649