free-coding-models 0.5.57 → 0.5.59
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 +86 -0
- package/changelog/v0.5.58.md +42 -0
- package/changelog/v0.5.59.md +52 -0
- package/package.json +4 -4
- package/src/core/ping.js +7 -2
- package/src/core/probe-cache.js +515 -0
- package/src/core/provider-quota-fetchers.js +254 -2
- package/src/core/router-daemon.js +81 -4
- package/src/core/utils.js +24 -1
- package/src/tui/app.js +132 -4
- package/src/tui/cli-help.js +3 -0
- package/src/tui/key-handler.js +19 -0
- package/src/tui/render-table.js +50 -2
- package/src/tui/tui-state.js +11 -0
- package/web/dist/assets/{index-BoJ4r2gC.js → index-C_ZdUGrS.js} +2 -2
- package/web/dist/assets/{index-BkK1gdJN.css → index-wI9xrm0w.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/src/components/router/RouterView.jsx +34 -0
- package/web/src/components/router/RouterView.module.css +53 -0
|
@@ -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
|
+
}
|