free-coding-models 0.5.57 โ 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.58.md +42 -0
- package/package.json +4 -4
- 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-BoJ4r2gC.js โ index-4Jq00xKl.js} +2 -2
- package/web/dist/index.html +1 -1
package/README.md
CHANGED
|
@@ -466,6 +466,57 @@ When a tool mode is active (via `Z`), models incompatible with that tool are hig
|
|
|
466
466
|
|
|
467
467
|
---
|
|
468
468
|
|
|
469
|
+
## ๐ง Persistent probe cache
|
|
470
|
+
|
|
471
|
+
Every health probe result is cached to `~/.free-coding-models/probe-cache.json` for **24 hours** and **shared across all surfaces** (CLI TUI, Web Dashboard / daemon, Tauri Desktop).
|
|
472
|
+
|
|
473
|
+
- **Warm start** renders the full ranking in <500ms using the cached results, then re-pings only the models that are due (broken or past TTL).
|
|
474
|
+
- **Broken models** are auto-hidden across sessions โ a model that 401s today stays out of tomorrow's default view. Recovery is automatic: if it comes back `ok`, it un-hides on the next probe.
|
|
475
|
+
- **Cross-process safe**: a debounced flush + atomic rename + read-merge-write means the CLI and the daemon can share the file without clobbering each other.
|
|
476
|
+
|
|
477
|
+
### Where the cache lives
|
|
478
|
+
|
|
479
|
+
| Env / OS | Path |
|
|
480
|
+
|----------|------|
|
|
481
|
+
| `XDG_CACHE_HOME` set | `$XDG_CACHE_HOME/free-coding-models/probe-cache.json` |
|
|
482
|
+
| Default (macOS / Linux) | `~/.free-coding-models/probe-cache.json` |
|
|
483
|
+
| Windows | `%USERPROFILE%\.free-coding-models\probe-cache.json` |
|
|
484
|
+
|
|
485
|
+
Inspect or wipe it manually any time โ it's plain JSON with `0600` perms.
|
|
486
|
+
|
|
487
|
+
### CLI flags
|
|
488
|
+
|
|
489
|
+
| Flag | Effect |
|
|
490
|
+
|------|--------|
|
|
491
|
+
| `--reprobe` / `--no-cache` | Nuke the cache before this run; ping everything fresh |
|
|
492
|
+
| `--probe-ttl <ms>` | Override the 24h TTL (e.g. `--probe-ttl 3600000` for 1h) |
|
|
493
|
+
| `--show-broken` | Don't auto-hide broken models this run (one-shot override) |
|
|
494
|
+
|
|
495
|
+
### TUI keys
|
|
496
|
+
|
|
497
|
+
| Key | Effect |
|
|
498
|
+
|-----|--------|
|
|
499
|
+
| **Shift+B** | Toggle visibility of broken models (footer chip shows `โก N cached ยท ๐ด M broken`) |
|
|
500
|
+
|
|
501
|
+
### Daemon `/stats` shape
|
|
502
|
+
|
|
503
|
+
```json
|
|
504
|
+
{
|
|
505
|
+
"probeCache": {
|
|
506
|
+
"total": 191,
|
|
507
|
+
"ok": 178,
|
|
508
|
+
"broken": 13,
|
|
509
|
+
"freshCount": 165,
|
|
510
|
+
"staleCount": 13,
|
|
511
|
+
"dueCount": 26,
|
|
512
|
+
"hiddenCount": 13,
|
|
513
|
+
"providers": 9
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
---
|
|
519
|
+
|
|
469
520
|
## ฯ Pi Extension โ FCM-Pi โ ๏ธ BETA
|
|
470
521
|
|
|
471
522
|
**FCM-Pi** is a native [Pi coding agent](https://pi.dev) extension that integrates `free-coding-models` directly into your Pi session. It stays silent by default, scans only when you run `/fcm`, and lets you explicitly hot-swap models mid-session.
|
|
@@ -678,6 +729,7 @@ See [`packages/fcm-agent-core/README.md`](./packages/fcm-agent-core/README.md) f
|
|
|
678
729
|
- **Auto-retry** โ timeout models keep getting retried
|
|
679
730
|
- **Mandatory self-update policy** โ startup checks npm for a newer FCM and installs it automatically without a prompt. If the install fails twice in a row (offline, proxy, or permissions), FCM still starts but shows a red outdated-version warning until the user retries with `Shift+U` or runs the displayed install command.
|
|
680
731
|
- **Last release timestamp** โ light pink footer shows `Last release: Mar 27, 2026, 09:42 PM` from npm so users know how fresh the data is
|
|
732
|
+
- **Persistent probe-cache (t1)** โ every health probe result is cached to `~/.free-coding-models/probe-cache.json` for 24h. Warm starts render the full ranking in <500ms, only re-ping the models that are due (broken or TTL-expired). Broken models are auto-hidden across sessions โ toggle visibility with **Shift+B**. See [Persistent probe cache](#-persistent-probe-cache) below for `--reprobe`, `--probe-ttl`, `--show-broken`.
|
|
681
733
|
|
|
682
734
|
---
|
|
683
735
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Changelog v0.5.58 - 2026-07-26
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- โก **Persistent probe-cache with TTL + auto-hide broken models** ([#144](https://github.com/vava-nessa/free-coding-models/issues/144)) โ every health probe result is now cached to `~/.free-coding-models/probe-cache.json` for **24 hours**, shared across all surfaces (CLI TUI, Web Dashboard / daemon, Tauri Desktop).
|
|
6
|
+
|
|
7
|
+
- **Warm start in <500ms**: the full ranking renders from the cache instantly, then only models that are due (broken or TTL-expired) get re-pinged. Cold start with no cache behaves identically to before โ no regression.
|
|
8
|
+
- **Broken models stay hidden across sessions**: a model that 401s today won't appear in tomorrow's default view. Recovery is automatic โ if a previously-broken model comes back `ok`, the next probe un-hides it.
|
|
9
|
+
- **Honors `XDG_CACHE_HOME`** when set (Linux/macOS convention), else falls back to `~/.free-coding-models/`. File uses `0600` perms and is written atomically (tmp + rename) to survive crashes mid-flush.
|
|
10
|
+
- **Concurrency-safe**: debounced flush + atomic rename + read-merge-write means a running daemon and an interactive CLI can share the file without clobbering each other. Worst case: one batch of deltas is lost, never the whole file.
|
|
11
|
+
- **`probeVersion` constant** (currently `2`): bump this when ping behaviour changes (new endpoint, new prompt, etc.) and the entire cache invalidates automatically โ no manual purge.
|
|
12
|
+
|
|
13
|
+
- ๐ **Shift+B hotkey** toggles visibility of probe-cache-broken models in the TUI. Footer chip shows `โก N cached ยท ๐ด M broken (Shift+B)` (becomes `๐ด M broken (visible)` when toggled on).
|
|
14
|
+
|
|
15
|
+
- ๐ ๏ธ **New CLI flags**:
|
|
16
|
+
- `--reprobe` / `--no-cache` โ force-rebuild the probe-cache this run (ping everything fresh).
|
|
17
|
+
- `--probe-ttl <ms>` โ override the 24h TTL (e.g. `--probe-ttl 3600000` for 1h, useful when debugging model health).
|
|
18
|
+
- `--show-broken` โ don't auto-hide broken models this run (one-shot override for `--reprobe` style workflows).
|
|
19
|
+
|
|
20
|
+
- ๐ **Daemon `/stats` now exposes `probeCache`** with `total`, `ok`, `broken`, `freshCount`, `staleCount`, `dueCount`, `hiddenCount`, `providers` โ the Web Dashboard renders it live so users can see cache hit rate at a glance.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- ๐ฉบ **Daemon health-probe loop** (`runProbeBurst`) now skips models that are fresh + ok in the persistent cache. Broken models naturally pass through, so recovery detection keeps working unchanged. This cuts daemon-side probe traffic by ~85% on warm starts.
|
|
25
|
+
|
|
26
|
+
- ๐ **Per-provider probe results** are mirrored from the in-memory circuit-breaker windows into the persistent cache via `recordProbeResult` in `src/core/router-daemon.js`. Debounced 2s flush avoids filesystem thrash during probe bursts.
|
|
27
|
+
|
|
28
|
+
### Maintenance
|
|
29
|
+
|
|
30
|
+
- ๐งช **+40 unit tests** (`test/probe-cache.test.js`, 36 โ 76): covers freshness rules 1โ5, path resolution (XDG_CACHE_HOME), corrupt-JSON recovery, version migration, multi-model fan-out, `isCacheFresh` per-condition checks, `recordProbeResults` validation + garbage dropping, `getCacheStats` aggregates, `getCachedResultsForProvider` filtering, `pruneStaleEntries`, end-to-end reload, 4 concurrency tests (other-process deltas survive, stale `lastProbedAt` is overwritten, missing-file path, corrupt-file recovery).
|
|
31
|
+
- ๐งช **628 โ 632 tests passing** (`pnpm test`), **107 โ 118 suites**.
|
|
32
|
+
- ๐งน `vite build` succeeds.
|
|
33
|
+
- ๐ README updated with a new **๐ง Persistent probe cache** section (where the file lives, CLI flags, TUI keys, daemon `/stats` shape).
|
|
34
|
+
|
|
35
|
+
### Files
|
|
36
|
+
|
|
37
|
+
- **New**: `src/core/probe-cache.js` (335 lines), `test/probe-cache.test.js` (332 lines), `changelog/v0.5.58.md`.
|
|
38
|
+
- **Modified**: `src/tui/app.js` (probe-cache load + apply + record), `src/tui/key-handler.js` (Shift+B handler), `src/tui/render-table.js` (footer chip), `src/tui/tui-state.js` (5 new state fields), `src/tui/cli-help.js` (3 new flag entries), `src/core/utils.js` (`parseArgs` gains `--reprobe` / `--probe-ttl` / `--show-broken`), `src/core/router-daemon.js` (full integration: load on boot, mirror probe results, skip fresh in `runProbeBurst`, expose in `/stats`, flush on shutdown), `README.md`, `tasks/t1.md`, `package.json` (test script), `changelog/`.
|
|
39
|
+
|
|
40
|
+
### Inspiration
|
|
41
|
+
|
|
42
|
+
This implementation is informed by [`apmantza/pi-free`](https://github.com/apmantza/pi-free)'s `lib/provider-probe.ts` + `lib/probe-cache.ts` (which itself credits us: *"Inspired by free-coding-models' `extractQuotaPercent`"*). The inspiration flows both ways โ we're reclaiming the lead on the probe-cache axis by going cross-surface (CLI / daemon / Tauri share one file) and concurrency-safe (read-merge-write vs pi-free's single-writer Pi-only model).
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "free-coding-models",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "Find the fastest coding LLM models in seconds
|
|
3
|
+
"version": "0.5.58",
|
|
4
|
+
"description": "Find the fastest coding LLM models in seconds \u2014 ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nvidia",
|
|
7
7
|
"nim",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
],
|
|
54
54
|
"scripts": {
|
|
55
55
|
"start": "node bin/free-coding-models.js",
|
|
56
|
-
"test": "node --test test/test.js test/fcm-agent-core.test.js test/patch-openclaw.test.js test/provider-metadata.test.js test/config-permission-hint.test.js",
|
|
56
|
+
"test": "node --test test/test.js test/fcm-agent-core.test.js test/patch-openclaw.test.js test/provider-metadata.test.js test/config-permission-hint.test.js test/probe-cache.test.js",
|
|
57
57
|
"prepack": "npm run build:web",
|
|
58
58
|
"dev": "node scripts/dev-web.mjs",
|
|
59
59
|
"dev:web": "node scripts/dev-web.mjs",
|
|
@@ -83,4 +83,4 @@
|
|
|
83
83
|
"vite": "^8.0.16",
|
|
84
84
|
"vite-plus": "^0.2.6"
|
|
85
85
|
}
|
|
86
|
-
}
|
|
86
|
+
}
|
|
@@ -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
|
+
}
|