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.
- package/README.md +52 -0
- package/changelog/v0.5.57.md +20 -0
- package/changelog/v0.5.58.md +42 -0
- package/package.json +4 -4
- package/patch-openclaw.js +33 -2
- package/src/core/probe-cache.js +515 -0
- package/src/core/router-daemon.js +62 -1
- package/src/core/utils.js +24 -1
- package/src/tui/app.js +127 -3
- package/src/tui/cli-help.js +3 -0
- package/src/tui/key-handler.js +19 -0
- package/src/tui/render-table.js +24 -2
- package/src/tui/tui-state.js +11 -0
- package/web/dist/assets/{index-MocddN_W.js → index-4Jq00xKl.js} +2 -2
- package/web/dist/index.html +1 -1
|
@@ -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
|
-
|
|
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
|
|