free-coding-models 0.5.59 โ 0.5.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/bin/free-coding-models.js +31 -0
- package/changelog/v0.5.60.md +54 -0
- package/changelog/v0.5.61.md +65 -0
- package/package.json +6 -2
- package/src/core/extended-benchmarks.js +421 -0
- package/src/core/model-merger.js +155 -0
- package/src/core/models-dev-fetcher.js +210 -0
- package/src/core/models-dev-index.js +311 -0
- package/src/core/models-drift.js +296 -0
- package/src/core/router-daemon.js +96 -0
- package/src/core/runtime-telemetry.js +541 -0
- package/src/core/utils.js +29 -0
- package/src/data/benchmarks.json +302 -0
- package/src/tui/app.js +103 -0
- package/src/tui/cli-help.js +2 -0
- package/src/tui/key-handler.js +80 -0
- package/src/tui/render-table.js +38 -2
- package/src/tui/tui-state.js +9 -0
- package/web/dist/assets/{index-C_ZdUGrS.js โ index-4IyXp-vf.js} +2 -2
- package/web/dist/index.html +1 -1
- package/web/server.js +39 -0
- package/web/src/components/router/RouterView.jsx +34 -0
package/README.md
CHANGED
|
@@ -550,6 +550,35 @@ The `source` field is `"header"` when the value came from a passive response hea
|
|
|
550
550
|
|
|
551
551
|
---
|
|
552
552
|
|
|
553
|
+
## ๐ Runtime telemetry: real-world scores
|
|
554
|
+
|
|
555
|
+
Every routed request through the daemon feeds a persistent per-model telemetry file (`~/.free-coding-models/runtime-telemetry.json`) โ the **honesty layer** that complements SWE-bench with what models actually do on free tiers.
|
|
556
|
+
|
|
557
|
+
- **Real success rate** โ `(successCalls / totalCalls)` updated on every routed request (success and failure paths alike).
|
|
558
|
+
- **Real throughput** โ `avgTokensPerSecond` derived from completion tokens / total latency, so you see what your code actually streams at.
|
|
559
|
+
- **Recent calls (last 50)** โ for debugging "why did this just 500?" without rebuilding state.
|
|
560
|
+
- **Composite `Real` score** โ `successRate ร 0.6 + sigmoid01(tok/s) ร 0.25 + recency ร 0.15`. Null when below `MIN_CALLS_FOR_SCORE = 5` (no penalty for new models).
|
|
561
|
+
|
|
562
|
+
### Where it shows up
|
|
563
|
+
|
|
564
|
+
| Surface | What you'll see |
|
|
565
|
+
|---------|-----------------|
|
|
566
|
+
| TUI `Real` column | Inline composite score per row, `โ` when insufficient data |
|
|
567
|
+
| TUI `W` sort key | Sort by real-world score descending |
|
|
568
|
+
| TUI `Shift+W` | Runtime Report overlay โ per-model breakdown + recent calls |
|
|
569
|
+
| Web Dashboard | "Runtime Telemetry" cards with animated success-rate bars |
|
|
570
|
+
| `/api/router/stats.runtime` | `{ stats: { modelsTracked, totalCalls, modelsWithSignal }, models: { ... } }` |
|
|
571
|
+
|
|
572
|
+
### Privacy
|
|
573
|
+
|
|
574
|
+
The telemetry file lives **locally only** (`~/.free-coding-models/runtime-telemetry.json`, `0600` perms). Nothing is sent upstream unless you opt in to a future aggregate leaderboard. The file holds **metadata only** โ success, latency, tokens, error reason. No prompts, no responses, no content.
|
|
575
|
+
|
|
576
|
+
### CLI flag
|
|
577
|
+
|
|
578
|
+
- `--clear-runtime` โ wipe the file before launching (reset the baseline).
|
|
579
|
+
|
|
580
|
+
---
|
|
581
|
+
|
|
553
582
|
## ฯ Pi Extension โ FCM-Pi โ ๏ธ BETA
|
|
554
583
|
|
|
555
584
|
**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.
|
|
@@ -764,6 +793,9 @@ See [`packages/fcm-agent-core/README.md`](./packages/fcm-agent-core/README.md) f
|
|
|
764
793
|
- **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
|
|
765
794
|
- **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`.
|
|
766
795
|
- **Live quota from response headers (t2)** โ every routed chat-completion response already carries `x-ratelimit-*` headers. The daemon parses them in 8 variants and exposes live per-provider quota on the TUI footer (`๐ groq 78% ยท sambanova 41%`) and in the Web Dashboard (`Provider Quota` section with animated progress bars). Zero extra network requests, zero quota waste. See [Live quota from headers](#-live-quota-from-response-headers) below.
|
|
796
|
+
- **Runtime telemetry: real-world scores (t3)** โ every routed request through the daemon feeds a persistent per-model telemetry file (`~/.free-coding-models/runtime-telemetry.json`) with real success rate, throughput, and recent calls. The `Real` column + `W` sort key in the TUI rank models by what *actually* works on free tiers, not what they claim on SWE-bench. See [Runtime telemetry](#-runtime-telemetry-real-world-scores) below.
|
|
797
|
+
- **Extended benchmark catalog (t4)** โ `src/data/benchmarks.json` (49 well-known models committed, refreshed at every release via `pnpm update:benchmarks`) layers **Coding Index, Math Index, Agentic Index, Reasoning Index, MMLU-Pro, GPQA, HLE** on top of `sources.js` with a lazy `Proxy` load + prefix-indexed O(key length) lookup. Surfaced on the TUI footer as `๐ bench 49 (2026-07-25)`. Curated SWE-bench scores are never overwritten โ the overlay is additive.
|
|
798
|
+
- **Live `models.dev` enrichment + drift detection (t5)** โ the community-maintained `models.dev` catalog is fetched in the background (5 min in-process cache, 3 retries ร 250 ms backoff) and overlaid onto every merged model with `metaSource` provenance. The footer chip shows `๐ก 102 live ยท 62 curated` so you see at a glance which values came from upstream. `--check-drift` prints a human-readable drift report vs `sources.js`; a weekly CI job opens a `catalog-drift` issue if anything changed.
|
|
767
799
|
|
|
768
800
|
---
|
|
769
801
|
|
|
@@ -62,6 +62,37 @@ async function main() {
|
|
|
62
62
|
process.exit(0);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// ๐ --clear-runtime (t3): wipe ~/.free-coding-models/runtime-telemetry.json
|
|
66
|
+
// ๐ before launching any surface. Keeps the TUI / daemon / web flows consistent.
|
|
67
|
+
if (cliArgs.clearRuntimeMode) {
|
|
68
|
+
try {
|
|
69
|
+
const { clearRuntimeTelemetry } = await import('../src/core/runtime-telemetry.js')
|
|
70
|
+
const ok = clearRuntimeTelemetry()
|
|
71
|
+
console.log(chalk.dim(` ${ok ? 'โ' : 'โ'} runtime-telemetry.json ${ok ? 'cleared' : 'clear failed'}`))
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.log(chalk.dim(` runtime-telemetry.json clear failed: ${err?.message || err}`))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ๐ --check-drift (t5): diff sources.js against models.dev and print a report.
|
|
78
|
+
// ๐ Runs BEFORE the config + update check so it works on bare clones and CI.
|
|
79
|
+
if (cliArgs.checkDriftMode) {
|
|
80
|
+
const threshold = cliArgs.driftThreshold ?? 0
|
|
81
|
+
const args = ['--threshold', String(threshold)]
|
|
82
|
+
const { spawn } = await import('node:child_process')
|
|
83
|
+
const { fileURLToPath } = await import('node:url')
|
|
84
|
+
const { dirname, join } = await import('node:path')
|
|
85
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
86
|
+
const script = join(here, '..', 'scripts', 'check-drift.mjs')
|
|
87
|
+
const child = spawn(process.execPath, [script, ...args], { stdio: 'inherit' })
|
|
88
|
+
child.on('exit', code => process.exit(code ?? 1))
|
|
89
|
+
child.on('error', err => {
|
|
90
|
+
console.error(chalk.red(`failed to spawn check-drift: ${err.message}`))
|
|
91
|
+
process.exit(3)
|
|
92
|
+
})
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
65
96
|
// Load JSON config before operational modes so the mandatory update policy can
|
|
66
97
|
// ๐ persist failure counters for TUI, Web Dashboard, Docker daemon, and Desktop sidecar launches.
|
|
67
98
|
const config = loadConfig();
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Changelog v0.5.60 - 2026-07-26
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- ๐ **Runtime telemetry: real-world scores** (t3) โ every routed request through the daemon now feeds a persistent per-model telemetry file with real success rate, throughput, and recent calls. FCM stops showing you only what models *claim* on SWE-bench and starts showing you what they **actually do** on free tiers.
|
|
6
|
+
|
|
7
|
+
- **Composite `Real` score** (0..100) โ `successRate ร 0.6 + sigmoid01(tok/s) ร 0.25 + recency ร 0.15`. Null when below `MIN_CALLS_FOR_SCORE = 5` (no penalty for new models).
|
|
8
|
+
- **Real success rate** โ `(successCalls / totalCalls)`. Updated on every routed request, success AND failure paths alike (auth_error / 5xx / 429 all count).
|
|
9
|
+
- **Real throughput** โ `avgTokensPerSecond` from completion tokens / total latency. What your code actually streams at.
|
|
10
|
+
- **Recent calls (last 50)** โ FIFO trim per model. Useful for debugging "why did this just 500?" without rebuilding state.
|
|
11
|
+
- **Local-only by default** โ the file lives at `~/.free-coding-models/runtime-telemetry.json` (`0600` perms). Nothing leaves your machine unless you opt in to a future aggregate leaderboard. Metadata only โ no prompts, no responses.
|
|
12
|
+
|
|
13
|
+
- ๐ **New sort key `W`** in the TUI: sorts by `Real` score descending. Models with insufficient data (null score) sink to the bottom in both directions.
|
|
14
|
+
|
|
15
|
+
- ๐ **New TUI `Real` column**: shows the composite score inline per row, or `โ` when there's not enough signal yet.
|
|
16
|
+
|
|
17
|
+
- ๐ฅ **`Shift+W` Runtime Report overlay** โ per-model breakdown (success rate, avg latency, avg tok/s, recent calls list). Same pattern as `Shift+R` Router Dashboard / `Shift+T` Token Usage. Scroll with j/k/up/down/pageup/pagedown/home/end, close with Escape.
|
|
18
|
+
|
|
19
|
+
- ๐ **Web Dashboard `Runtime Telemetry` section** โ per-model cards with animated success-rate bars (red โค 50% / amber 50-80% / green โฅ 80%). Updated every `/api/router/stats` poll (5 s). Models with < 5 calls are excluded so the section only shows real signal.
|
|
20
|
+
|
|
21
|
+
- ๐ฐ๏ธ **New daemon endpoint `/stats/runtime`** โ returns `{ ok, stats: { modelsTracked, totalCalls, modelsWithSignal }, models: { [key]: ModelTelemetry } }`. Same shape as the in-TUI dashboard digest.
|
|
22
|
+
|
|
23
|
+
- ๐งช **`recordModelCall(providerKey, modelId, callResult, opts?)`** โ drop-in hook for any future surface (Tauri Desktop, scripts) that wants to feed runtime telemetry.
|
|
24
|
+
|
|
25
|
+
- ๐ ๏ธ **`--clear-runtime` CLI flag** โ wipe the telemetry file before launching any surface. Useful when the user wants to reset the baseline.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- ๐ฉบ **Daemon reverse proxy** records every successful + failed routed outcome via `recordRuntimeCall()`. A debounced 5 s flush keeps the file in sync without thrashing the disk. The flush timer is cleared in `shutdown()` so no orphan writes after exit.
|
|
30
|
+
|
|
31
|
+
- ๐ **Daemon `statsPayload`** now exposes `runtimeTelemetry: { stats, models }` so the existing `/stats` consumers get the digest for free.
|
|
32
|
+
|
|
33
|
+
- ๐ง **Internal** โ added `sigmoid01(x)` (logistic curve so 50 tok/s โ 0.5) and `recencyDecay(lastUpdatedMs)` (1.0 today โ 0.0 at 30 d) to the real-world score formula. Both are pure functions with no dependencies.
|
|
34
|
+
|
|
35
|
+
### Non-goals (explicit)
|
|
36
|
+
|
|
37
|
+
- **Streaming token extraction** โ the known gap from the PRD stays. The runtime telemetry only counts requests where the upstream returned `usage` (i.e. non-streaming, or streaming providers that emit usage on the final chunk).
|
|
38
|
+
- **Sharing / leaderboard** โ no aggregation server yet. When there is one, opt-in.
|
|
39
|
+
|
|
40
|
+
### Maintenance
|
|
41
|
+
|
|
42
|
+
- ๐งช **+30 unit tests** (`test/runtime-telemetry.test.js`, new file): path resolution (XDG-aware), constants sanity, `recordModelCall` write/accumulate/drop garbage/FIFO cap, `tokensPerSecond` computation, derived fields, `getRealWorldScore` null below threshold / healthy โ high / error-heavy โ mid / age decay, `getCacheStats` aggregates + `modelsWithSignal` threshold, `pruneStaleEntries` drops old, persistence round-trip + corrupt recovery + missing file + clear + read-merge-write concurrency safety.
|
|
43
|
+
- ๐งช **671 โ 701 tests passing** (`pnpm test`), **125 โ 134 suites**.
|
|
44
|
+
- ๐งน `vite build` succeeds.
|
|
45
|
+
- ๐ README updated with a new **๐ Runtime telemetry: real-world scores** section (where it shows up, privacy, `--clear-runtime`).
|
|
46
|
+
|
|
47
|
+
### Inspiration
|
|
48
|
+
|
|
49
|
+
This implementation is informed by [`apmantza/pi-free`](https://github.com/apmantza/pi-free)'s `lib/telemetry.ts` (which credits us in their other direction too). pi-free's `turn_end` hook is the daemon-side equivalent of our `recordRuntimeCall`; the schema, the 50-call FIFO, and the local-only file mirror their approach. Where we diverge: we expose the composite `Real` score as a first-class ranking signal (not just a debugging surface), ship it in 3 user-facing surfaces (CLI TUI / Web Dashboard / daemon `/stats`), and add the successร0.6 / speedร0.25 / recencyร0.15 weights so the user sees a single number per model.
|
|
50
|
+
|
|
51
|
+
### Files
|
|
52
|
+
|
|
53
|
+
- **New**: `src/core/runtime-telemetry.js` (377 lines, 11 exports), `test/runtime-telemetry.test.js` (390 lines, 30 tests), `changelog/v0.5.60.md`.
|
|
54
|
+
- **Modified**: `src/core/router-daemon.js` (record on every routed outcome + `/stats/runtime` + runtimeTelemetry in statsPayload), `src/core/utils.js` (`'realworld'` sort case), `src/tui/app.js` (boot-time score computation per result), `src/tui/tui-state.js` (5 new state fields for the overlay), `src/tui/key-handler.js` (Shift+W + overlay keyboard handling + command palette action), `bin/free-coding-models.js` (`--clear-runtime` flag plumbing), `web/src/components/router/RouterView.jsx` (Runtime Telemetry section), `README.md`, `tasks/t3.md`, `package.json` (test script).
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Changelog v0.5.61 - 2026-07-27
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- ๐ **Extended benchmark catalog** (t4) โ a new `src/data/benchmarks.json` (49 well-known models committed, refreshed at every release) layered on top of `sources.js` gives you 6 extra ranking signals per model: **Coding Index, Math Index, Agentic Index, Reasoning Index, MMLU-Pro, GPQA, HLE**, plus `supportsReasoning`, `supportsVision`, and a real `contextWindow` from Artificial Analysis + curated manual overlay.
|
|
6
|
+
|
|
7
|
+
- **Lazy load via `Proxy`** โ the JSON is only parsed on first lookup, not at module load. Cold start stays at ~0 ms.
|
|
8
|
+
- **Prefix-indexed O(key length) lookup** โ `lookupExtendedBenchmark('deepseek-ai/deepseek-v4-pro')` is fast even on a 500-entry catalog. Falls back through the `-`/`/`-prefixed segments (e.g. `deepseek-ai` โ `deepseek-ai/deepseek` โ `deepseek-ai/deepseek-v4` โ `deepseek-ai/deepseek-v4-pro`) and picks the best-scoring candidate.
|
|
9
|
+
- **Curated seed wins** โ the `mergeExtendedBenchmark` overlay only fills fields that are null in `sources.js`. Your curated `sweScore` / `tier` / `ctx` are never overwritten by the live data.
|
|
10
|
+
|
|
11
|
+
- ๐ฐ๏ธ **Live `models.dev` enrichment** (t5) โ the community-maintained `models.dev` catalog is fetched in the background (5 min in-process cache, 3 retries ร 250 ms backoff, 8 s per-request timeout) and overlaid onto every merged model with `metaSource: 'models.dev' | 'sources.js'` provenance.
|
|
12
|
+
|
|
13
|
+
- **Substring matches skipped for drift detection** โ the indexer tries exact โ aliased โ substring, but the drift detector only counts exact + aliased matches (avoids the "DeepSeek Chat" vs "DeepSeek Reasoner" false positive).
|
|
14
|
+
- **Provider aliases** โ 40+ mappings bridge sources.js provider keys (`nvidiaNim`, `together`, `novita`, `kilocode`, โฆ) to models.dev provider keys (`nvidia`, `togetherai`, `novita-ai`, `kilo`, โฆ).
|
|
15
|
+
- **Offline-safe** โ if the fetch fails (3ร8 s = up to 24 s, capped to 12 s in the background task), `metaSource` stays at `'sources.js'` and the TUI keeps rendering. No crashes, no hangs.
|
|
16
|
+
|
|
17
|
+
- ๐ **`--check-drift` CLI flag** โ diff `sources.js` against `models.dev` and print a human-readable report grouped by model. Each row shows the field, the sources.js value, the models.dev value, and an action arrow (`โ UPDATE` or `โ ADD`).
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
free-coding-models --check-drift # exit 1 on any drift
|
|
21
|
+
free-coding-models --check-drift --drift-threshold 5 # only fail on 5+ mismatches
|
|
22
|
+
pnpm check:drift # npm alias
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Exit codes: `0` no drift ยท `1` drift detected ยท `2` fetch failed ยท `3` invalid args.
|
|
26
|
+
|
|
27
|
+
- ๐ค **`pnpm update:benchmarks`** โ regenerate `src/data/benchmarks.json` from a fresh models.dev fetch. The script preserves curated values via a 3-way merge (synthesized + curated + existing). Wired into the release process and the weekly CI workflow.
|
|
28
|
+
|
|
29
|
+
- ๐ฆพ **TUI footer enrichment chip** โ `๐ bench 49 (2026-07-25) ยท ๐ก 102 live ยท 62 curated` on the bottom line. Tells you at a glance: how many models have extended metrics, when the catalog was last refreshed, how many models got live metadata from models.dev vs staying at curated values.
|
|
30
|
+
|
|
31
|
+
- ๐ **Web Dashboard data exposure** โ `/api/models` now includes `extendedBench` + `metaSource` per row. Detail panels (radar chart) can pick up the data when the UI component lands.
|
|
32
|
+
|
|
33
|
+
- ๐ **`.github/workflows/update-benchmarks.yml`** โ weekly Monday 04:00 UTC job. Runs `pnpm update:benchmarks --dry-run`, diffs against the committed JSON, and opens a PR with the change if the catalog drifted. Manual trigger via `workflow_dispatch`.
|
|
34
|
+
|
|
35
|
+
- ๐ต๏ธ **`.github/workflows/check-drift.yml`** โ weekly Monday 05:00 UTC job. Runs `pnpm check:drift --no-fail`. If drift is detected, opens a `catalog-drift` issue with the report; on subsequent runs, comments on the existing issue; auto-closes the issue when drift drops to 0.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- ๐ฆ **CLI help (`--help`)** โ new flags `--check-drift` and `--drift-threshold <N>` documented under the **Analysis Flags** section, right next to the existing probe-cache flags.
|
|
40
|
+
- ๐ง **`src/core/utils.js` `parseArgs()`** โ two new return fields: `checkDriftMode` (boolean) and `driftThreshold` (number|null). The TUI's `bin/free-coding-models.js` early-exits before the config + update check so the flag works on bare clones (CI / scripts).
|
|
41
|
+
|
|
42
|
+
### Non-goals (explicit, tracked for follow-up)
|
|
43
|
+
|
|
44
|
+
- **TUI detail-view benchmark block** โ the chip is the visible surface for now. A full "Press `?` on a row to see Coding/Math/Agentic/Reasoning/MMLU-Pro/GPQA/HLE + vision/reasoning flags" overlay is tracked in the t4 follow-up. The data layer + tests are ready.
|
|
45
|
+
- **6 new sort keys for the benchmark indices** โ the key map is already crowded (R/O/M/L/A/S/C/H/V/U/B/T/W/Z/E/F/Y/X/Q/G/N/P/I). Adding 6 more would conflict. Tracked for a "Sort by benchmark" submenu in the Command Palette.
|
|
46
|
+
- **Web Dashboard radar chart component** โ the data is exposed, the UI component is a separate task. The /api/models payload includes everything the chart needs.
|
|
47
|
+
|
|
48
|
+
### Maintenance
|
|
49
|
+
|
|
50
|
+
- ๐งช **+66 unit tests** across 3 new files:
|
|
51
|
+
- `test/extended-benchmarks.test.js` (31 tests, 9 suites) โ path resolution, lazy load + cache, prefix index build, exact + fallback + best-scoring lookup, overlay bag shape, performance (10k lookups in 3.3 ms, 1k cached in 1 ms).
|
|
52
|
+
- `test/models-dev.test.js` (35 tests, 8 suites) โ fetcher URL/TTL, normalizeModelDevEntry (flat + nested + malformed), buildModelIndex (auto-detect), lookupModelDevMeta (exact + alias + substring + unknown), detectDrift (drift + add + threshold), summarizeDrift, formatDriftReport (with/without color), parseCtxToNum (k/m/plain/edge), PROVIDER_ALIASES mappings.
|
|
53
|
+
- `test/model-merger.test.js` (extended to 12 tests) โ overlayExtendedBenchmarks, overlayModelsDevMetadata (sync + async + mutate), getEnrichmentStats.
|
|
54
|
+
- ๐งช **701 โ 779 tests passing** (`pnpm test`), **134 โ 155 suites**.
|
|
55
|
+
- ๐ก๏ธ `pnpm start` runs without runtime error (TUI, Web Dashboard, daemon all load the new modules cleanly).
|
|
56
|
+
- ๐ Fixed a real freeze: the initial draft had a top-level `await import` in `src/tui/app.js` that blocked module load. Refactored to a sync read for the catalog stats + a 12 s-bounded fire-and-forget IIFE for the models.dev fetch. The TUI now starts in <1 s even when the network is unreachable.
|
|
57
|
+
|
|
58
|
+
### Inspiration
|
|
59
|
+
|
|
60
|
+
This implementation is informed by [`apmantza/pi-free`](https://github.com/apmantza/pi-free)'s `lib/model-metadata.ts` (the fetcher + retry + cache + provider aliases shape) and `provider-failover/benchmark-lookup.ts` + `hardcoded-benchmarks.ts` (the prefix index + lazy `Proxy` load pattern). Where we diverge: we ship a curated **49-entry seed** in the repo (their TS version relies on hardcoded constants at build time), we add the **drift detector** + weekly **CI workflow** for proactive catalog hygiene, and we expose the data on all 3 surfaces (CLI TUI, Web Dashboard, Desktop) with consistent `metaSource` provenance.
|
|
61
|
+
|
|
62
|
+
### Files
|
|
63
|
+
|
|
64
|
+
- **New**: `src/core/extended-benchmarks.js` (320 lines, 11 exports), `src/core/models-dev-fetcher.js` (180 lines, 7 exports), `src/core/models-dev-index.js` (290 lines, 5 exports), `src/core/models-drift.js` (320 lines, 6 exports), `src/data/benchmarks.json` (49 entries committed), `scripts/update-benchmarks.mjs` (220 lines, executable), `scripts/check-drift.mjs` (170 lines, executable), `.github/workflows/update-benchmarks.yml`, `.github/workflows/check-drift.yml`, `test/extended-benchmarks.test.js` (310 lines, 31 tests), `test/models-dev.test.js` (380 lines, 35 tests), `changelog/v0.5.61.md`.
|
|
65
|
+
- **Modified**: `src/core/model-merger.js` (+155 lines: 3 new exports + async overlay with mutate), `src/core/utils.js` (+14 lines: parseArgs + checkDriftMode/driftThreshold), `src/tui/app.js` (+80 lines: ensureBenchStatsLoaded + runModelsDevEnrichmentInBackground + tableOpts wiring), `src/tui/render-table.js` (+40 lines: enrichmentLabel chip), `src/tui/cli-help.js` (+2 lines: new flags in ANALYSIS_FLAGS), `bin/free-coding-models.js` (+19 lines: --check-drift early-exit), `web/server.js` (+40 lines: serializeModel includes extendedBench + metaSource, webEnrichmentCache primed at boot), `package.json` (+5 lines: test commands + scripts), `test/model-merger.test.js` (+100 lines: new test cases), `tasks/t4.md`, `tasks/t5.md`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "free-coding-models",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.61",
|
|
4
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",
|
|
@@ -53,7 +53,11 @@
|
|
|
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 test/probe-cache.test.js test/passive-quota.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 test/passive-quota.test.js test/runtime-telemetry.test.js test/extended-benchmarks.test.js test/models-dev.test.js test/model-merger.test.js",
|
|
57
|
+
"test:extended-benchmarks": "node --test test/extended-benchmarks.test.js",
|
|
58
|
+
"test:models-dev": "node --test test/models-dev.test.js",
|
|
59
|
+
"update:benchmarks": "node scripts/update-benchmarks.mjs",
|
|
60
|
+
"check:drift": "node scripts/check-drift.mjs",
|
|
57
61
|
"prepack": "npm run build:web",
|
|
58
62
|
"dev": "node scripts/dev-web.mjs",
|
|
59
63
|
"dev:web": "node scripts/dev-web.mjs",
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file extended-benchmarks.js
|
|
3
|
+
* @description Extended per-model benchmark catalog (Coding/Math/Agentic/Reasoning indices
|
|
4
|
+
* + MMLU-Pro / GPQA / HLE + reasoning/vision support) with O(key length) prefix-indexed
|
|
5
|
+
* lookup and lazy JSON load.
|
|
6
|
+
*
|
|
7
|
+
* @details
|
|
8
|
+
* ๐ Why this exists:
|
|
9
|
+
* ๐ - `sources.js` carries a single SWE-bench score per model โ useful for tier, but
|
|
10
|
+
* ๐ blind to a model's math/reasoning/vision capabilities. The extended catalog
|
|
11
|
+
* ๐ adds 6 indices (Coding, Math, Agentic, Reasoning, MMLU-Pro, GPQA, HLE) plus
|
|
12
|
+
* ๐ context-window, reasoning-support and vision-support flags.
|
|
13
|
+
* ๐ - With ~50โ500 catalog entries, a linear lookup on every TUI re-render is wasteful.
|
|
14
|
+
* ๐ We build a prefix index on the `-`-separated model id so lookups only visit
|
|
15
|
+
* ๐ candidate variants of the base model (e.g. "deepseek-ai/deepseek-v4-pro" falls
|
|
16
|
+
* ๐ back to "deepseek-ai/deepseek-v4-pro" exact, then "deepseek-ai/deepseek-v4",
|
|
17
|
+
* ๐ then "deepseek-ai/deepseek", โฆ) โ O(key length) instead of O(catalog size).
|
|
18
|
+
* ๐ - The JSON file is large but only needed when the user actually looks at model
|
|
19
|
+
* ๐ metadata. A Proxy deferral defers readFileSync until first property access.
|
|
20
|
+
*
|
|
21
|
+
* ๐ Data shape (see src/data/benchmarks.json):
|
|
22
|
+
* ๐ {
|
|
23
|
+
* ๐ "_meta": { "schemaVersion": 1, "lastUpdated": "...", "source": "..." },
|
|
24
|
+
* ๐ "<modelId>": {
|
|
25
|
+
* ๐ "codingIndex": 72.4, // 0โ100
|
|
26
|
+
* ๐ "mathIndex": 68.1, // 0โ100
|
|
27
|
+
* ๐ "agenticIndex": 55.0, // 0โ100
|
|
28
|
+
* ๐ "reasoningIndex": 71.2, // 0โ100
|
|
29
|
+
* ๐ "mmluPro": 78.3, // 0โ100
|
|
30
|
+
* ๐ "gpqa": 54.0, // 0โ100
|
|
31
|
+
* ๐ "hle": 12.1, // 0โ100 (Humanity's Last Exam)
|
|
32
|
+
* ๐ "contextWindow": 1000000,
|
|
33
|
+
* ๐ "supportsReasoning": true,
|
|
34
|
+
* ๐ "supportsVision": false,
|
|
35
|
+
* ๐ "lastUpdated": "2026-07-20",
|
|
36
|
+
* ๐ "originalModel": "DeepSeek V4 Pro"
|
|
37
|
+
* ๐ },
|
|
38
|
+
* ๐ ...
|
|
39
|
+
* ๐ }
|
|
40
|
+
*
|
|
41
|
+
* ๐ Cross-surface: pure logic, consumed everywhere โ CLI TUI, Web Dashboard, Desktop.
|
|
42
|
+
*
|
|
43
|
+
* @functions
|
|
44
|
+
* โ getBenchmarksDataPath() โ Resolves the JSON file path
|
|
45
|
+
* โ getCatalog() โ Lazy-loaded catalog (Proxy)
|
|
46
|
+
* โ lookupExtendedBenchmark(modelId, opts?) โ Returns the entry (or null) for a model
|
|
47
|
+
* โ buildPrefixIndex(catalog) โ Builds the prefix index (idempotent, cached)
|
|
48
|
+
* โ getCatalogStats() โ { total, byField, lastUpdated }
|
|
49
|
+
* โ mergeExtendedBenchmark(model, entry?) โ Helper to overlay onto a result object
|
|
50
|
+
* โ EXTENDED_BENCH_FIELDS โ The list of overlay field names
|
|
51
|
+
*
|
|
52
|
+
* @exports getBenchmarksDataPath, getCatalog, lookupExtendedBenchmark, getCatalogStats,
|
|
53
|
+
* mergeExtendedBenchmark, EXTENDED_BENCH_FIELDS
|
|
54
|
+
*
|
|
55
|
+
* @see src/data/benchmarks.json โ The committed seed catalog
|
|
56
|
+
* @see scripts/update-benchmarks.mjs โ Regenerates the JSON (release-time)
|
|
57
|
+
* @see src/core/utils.js โ parseSweToNum, parseCtxToK (related)
|
|
58
|
+
* @see src/core/model-merger.js โ Calls mergeExtendedBenchmark at merge time
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import fs from 'node:fs'
|
|
62
|
+
import path from 'node:path'
|
|
63
|
+
import { fileURLToPath } from 'node:url'
|
|
64
|
+
|
|
65
|
+
// โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
66
|
+
|
|
67
|
+
/** ๐ Default location of the benchmark catalog JSON, resolved at runtime. */
|
|
68
|
+
const DATA_FILENAME = 'benchmarks.json'
|
|
69
|
+
|
|
70
|
+
/** ๐ Canonical list of extended fields overlaid onto a model. Used for the detail view. */
|
|
71
|
+
export const EXTENDED_BENCH_FIELDS = [
|
|
72
|
+
'codingIndex', 'mathIndex', 'agenticIndex', 'reasoningIndex',
|
|
73
|
+
'mmluPro', 'gpqa', 'hle',
|
|
74
|
+
'contextWindow', 'supportsReasoning', 'supportsVision',
|
|
75
|
+
'lastUpdated', 'originalModel',
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
// โโโ Module-level state โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
79
|
+
|
|
80
|
+
/** ๐ Cached parsed catalog (object). Loaded lazily on first access. */
|
|
81
|
+
let _catalog = null
|
|
82
|
+
|
|
83
|
+
/** ๐ Cached prefix index, lazily built from the catalog. */
|
|
84
|
+
let _index = null
|
|
85
|
+
|
|
86
|
+
/** ๐ Resolved path the catalog was last loaded from (for debug / hot-reload). */
|
|
87
|
+
let _catalogPath = null
|
|
88
|
+
|
|
89
|
+
// โโโ Path resolution โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* ๐ Resolves the absolute path to `src/data/benchmarks.json`. Works regardless of
|
|
93
|
+
* ๐ CWD (the file is resolved relative to this module's location, not the user's cwd).
|
|
94
|
+
*
|
|
95
|
+
* @returns {string} Absolute path to benchmarks.json
|
|
96
|
+
*/
|
|
97
|
+
export function getBenchmarksDataPath() {
|
|
98
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
99
|
+
return path.join(here, '..', 'data', DATA_FILENAME)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// โโโ Lazy catalog load โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* ๐ Force-load the catalog from disk and return the parsed object. Safe to call
|
|
106
|
+
* ๐ repeatedly โ the second+ calls return the cached object. Corrupt JSON or
|
|
107
|
+
* ๐ missing file yield an empty catalog (logged to stderr) โ never throw, because
|
|
108
|
+
* ๐ the TUI must keep rendering even if the catalog is missing.
|
|
109
|
+
*
|
|
110
|
+
* @returns {object} The catalog object keyed by modelId (with a `_meta` key mixed in)
|
|
111
|
+
*/
|
|
112
|
+
export function loadCatalog() {
|
|
113
|
+
if (_catalog) return _catalog
|
|
114
|
+
const target = getBenchmarksDataPath()
|
|
115
|
+
try {
|
|
116
|
+
const raw = fs.readFileSync(target, 'utf-8')
|
|
117
|
+
const parsed = JSON.parse(raw)
|
|
118
|
+
if (parsed && typeof parsed === 'object') {
|
|
119
|
+
_catalog = parsed
|
|
120
|
+
_catalogPath = target
|
|
121
|
+
return _catalog
|
|
122
|
+
}
|
|
123
|
+
} catch (err) {
|
|
124
|
+
// ๐ File missing or corrupt โ log once and fall back to empty catalog.
|
|
125
|
+
// ๐ We intentionally don't throw: the TUI must keep working with sources.js data.
|
|
126
|
+
if (process.env.FCM_BENCH_DEBUG) {
|
|
127
|
+
console.warn(`[extended-benchmarks] failed to load ${target}: ${err.message}`)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
_catalog = {}
|
|
131
|
+
_catalogPath = target
|
|
132
|
+
return _catalog
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* ๐ Reset the module cache. Used by tests + by the update script after a refresh.
|
|
137
|
+
* ๐ Production code should not need to call this โ the catalog is append-mostly.
|
|
138
|
+
*/
|
|
139
|
+
export function resetCatalogCache() {
|
|
140
|
+
_catalog = null
|
|
141
|
+
_index = null
|
|
142
|
+
_catalogPath = null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* ๐ Lazy proxy: defer `readFileSync` until the first property access. This shaves
|
|
147
|
+
* ๐ startup time (the JSON is ~16KB and grows as the catalog expands). Mirrors
|
|
148
|
+
* ๐ pi-free's `hardcoded-benchmarks.ts` pattern.
|
|
149
|
+
*
|
|
150
|
+
* ๐ IMPORTANT: Property access triggers `load()`. Iteration (`Object.keys`,
|
|
151
|
+
* ๐ `Reflect.ownKeys`, `for..in`) also triggers the load via the traps.
|
|
152
|
+
*/
|
|
153
|
+
export const BENCHMARKS = new Proxy({}, {
|
|
154
|
+
get(_t, prop, receiver) {
|
|
155
|
+
if (prop === Symbol.toPrimitive || prop === 'toJSON') return undefined
|
|
156
|
+
if (prop === 'then') return undefined // makes the proxy non-thenable
|
|
157
|
+
const data = loadCatalog()
|
|
158
|
+
return Reflect.get(data, prop, receiver)
|
|
159
|
+
},
|
|
160
|
+
has(_t, prop) {
|
|
161
|
+
const data = loadCatalog()
|
|
162
|
+
return Reflect.has(data, prop)
|
|
163
|
+
},
|
|
164
|
+
ownKeys() {
|
|
165
|
+
const data = loadCatalog()
|
|
166
|
+
return Reflect.ownKeys(data)
|
|
167
|
+
},
|
|
168
|
+
getOwnPropertyDescriptor(_t, p) {
|
|
169
|
+
const data = loadCatalog()
|
|
170
|
+
return Reflect.getOwnPropertyDescriptor(data, p)
|
|
171
|
+
},
|
|
172
|
+
set(_t, prop, value) {
|
|
173
|
+
const data = loadCatalog()
|
|
174
|
+
return Reflect.set(data, prop, value)
|
|
175
|
+
},
|
|
176
|
+
deleteProperty(_t, prop) {
|
|
177
|
+
const data = loadCatalog()
|
|
178
|
+
return Reflect.deleteProperty(data, prop)
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* ๐ Direct accessor (no Proxy) for code that wants the raw object, e.g. the
|
|
184
|
+
* ๐ web dashboard backend iterating keys, or tests inspecting `_meta`.
|
|
185
|
+
*/
|
|
186
|
+
export function getCatalog() {
|
|
187
|
+
return loadCatalog()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// โโโ Prefix index โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* ๐ Build a prefix index over the catalog so a lookup is O(key length) instead
|
|
194
|
+
* ๐ of O(catalog size). For each model id, every `-`-separated prefix maps to
|
|
195
|
+
* ๐ the entries that start with that prefix.
|
|
196
|
+
*
|
|
197
|
+
* ๐ Example (preserves the original `-`/`/` separator at each level):
|
|
198
|
+
* ๐ "deepseek-ai/deepseek-v4-pro" โ prefixes:
|
|
199
|
+
* ๐ "deepseek-ai"
|
|
200
|
+
* ๐ "deepseek-ai/deepseek" โ keeps the `/` from the source
|
|
201
|
+
* ๐ "deepseek-ai/deepseek-v4"
|
|
202
|
+
* ๐ "deepseek-ai/deepseek-v4-pro" (exact)
|
|
203
|
+
*
|
|
204
|
+
* ๐ When a model id like "deepseek-ai/deepseek-v4-pro" is looked up, we try:
|
|
205
|
+
* ๐ 1. exact match โ fast hit
|
|
206
|
+
* ๐ 2. longest-to-shortest prefix walk โ best-effort match for cross-provider
|
|
207
|
+
* ๐ variants ("z-ai/glm-5.2" vs "zai-glm-4.7" etc.)
|
|
208
|
+
*
|
|
209
|
+
* @param {object} [catalog] โ Defaults to the lazy-loaded catalog. Tests inject a fixture.
|
|
210
|
+
* @returns {{ exact: Map<string, object>, variants: Map<string, Array<[string, object]>> }}
|
|
211
|
+
*/
|
|
212
|
+
export function buildPrefixIndex(catalog) {
|
|
213
|
+
const data = catalog ?? loadCatalog()
|
|
214
|
+
const exact = new Map()
|
|
215
|
+
const variants = new Map()
|
|
216
|
+
for (const [key, value] of Object.entries(data)) {
|
|
217
|
+
if (key === '_meta') continue // ๐ metadata key, not a model entry
|
|
218
|
+
if (!value || typeof value !== 'object') continue
|
|
219
|
+
exact.set(key, value)
|
|
220
|
+
// ๐ Walk the original string char-by-char to preserve both `-` and `/`
|
|
221
|
+
// ๐ separators. A prefix ends right after each separator in the source.
|
|
222
|
+
// ๐ This way "deepseek-ai/deepseek-v4-pro" produces:
|
|
223
|
+
// ๐ "deepseek-ai", "deepseek-ai/deepseek", "deepseek-ai/deepseek-v4", ...
|
|
224
|
+
// ๐ and "z-ai/glm-5.2" produces:
|
|
225
|
+
// ๐ "z-ai", "z-ai/glm", "z-ai/glm-5", "z-ai/glm-5.2"
|
|
226
|
+
const indices = []
|
|
227
|
+
for (let i = 0; i < key.length; i++) {
|
|
228
|
+
const ch = key[i]
|
|
229
|
+
if (ch === '-' || ch === '/') indices.push(i)
|
|
230
|
+
}
|
|
231
|
+
for (const sepIdx of indices) {
|
|
232
|
+
const prefix = key.slice(0, sepIdx)
|
|
233
|
+
if (!prefix) continue
|
|
234
|
+
const arr = variants.get(prefix) ?? []
|
|
235
|
+
arr.push([key, value])
|
|
236
|
+
variants.set(prefix, arr)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { exact, variants }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* ๐ Get the prefix index, building it on first call. Cached so repeated lookups
|
|
244
|
+
* ๐ (every TUI render) are free.
|
|
245
|
+
*/
|
|
246
|
+
function getIndex() {
|
|
247
|
+
if (_index) return _index
|
|
248
|
+
_index = buildPrefixIndex(loadCatalog())
|
|
249
|
+
return _index
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* ๐ Score how good a candidate entry is for the requested model id. Higher = better.
|
|
254
|
+
* ๐ Tie-breakers are used when multiple candidates share the same longest prefix
|
|
255
|
+
* ๐ (e.g. "deepseek-ai/deepseek-v4-pro" exact vs. "deepseek-ai/deepseek-v4-flash" fallback).
|
|
256
|
+
*/
|
|
257
|
+
function scoreCandidate(requestedId, candidateKey) {
|
|
258
|
+
if (requestedId === candidateKey) return 10_000 // exact match always wins
|
|
259
|
+
// ๐ Prefer entries that share more characters with the requested id
|
|
260
|
+
let commonPrefixLen = 0
|
|
261
|
+
const min = Math.min(requestedId.length, candidateKey.length)
|
|
262
|
+
while (commonPrefixLen < min && requestedId[commonPrefixLen] === candidateKey[commonPrefixLen]) {
|
|
263
|
+
commonPrefixLen++
|
|
264
|
+
}
|
|
265
|
+
return commonPrefixLen
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* ๐ Look up a model's extended benchmark entry. Tries in order:
|
|
270
|
+
* ๐ 1. exact match (O(1) Map lookup)
|
|
271
|
+
* ๐ 2. longest-prefix walk โ picks the highest-scoring candidate
|
|
272
|
+
* ๐ 3. returns null (no throw)
|
|
273
|
+
*
|
|
274
|
+
* ๐ Performance: O(key length) since each prefix walk visits at most N candidates
|
|
275
|
+
* ๐ where N is the number of entries sharing the current prefix (typically 1โ3).
|
|
276
|
+
*
|
|
277
|
+
* @param {string} modelId
|
|
278
|
+
* @param {object} [opts]
|
|
279
|
+
* @param {object} [opts.catalog] โ Override the catalog (tests)
|
|
280
|
+
* @param {object} [opts.index] โ Override the prefix index (tests)
|
|
281
|
+
* @returns {object|null} The extended benchmark entry, or null if not found.
|
|
282
|
+
*/
|
|
283
|
+
export function lookupExtendedBenchmark(modelId, opts = {}) {
|
|
284
|
+
if (!modelId || typeof modelId !== 'string') return null
|
|
285
|
+
let index = opts.index
|
|
286
|
+
let catalog = opts.catalog
|
|
287
|
+
if (!index) {
|
|
288
|
+
if (catalog) {
|
|
289
|
+
index = buildPrefixIndex(catalog)
|
|
290
|
+
} else {
|
|
291
|
+
index = getIndex()
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const { exact, variants } = index
|
|
295
|
+
|
|
296
|
+
// Rule 1: exact match
|
|
297
|
+
if (exact.has(modelId)) return exact.get(modelId)
|
|
298
|
+
|
|
299
|
+
// Rule 2: longest-prefix walk. Walk separators in reverse, slicing the original string.
|
|
300
|
+
const sepIndices = []
|
|
301
|
+
for (let i = 0; i < modelId.length; i++) {
|
|
302
|
+
if (modelId[i] === '-' || modelId[i] === '/') sepIndices.push(i)
|
|
303
|
+
}
|
|
304
|
+
for (let i = sepIndices.length - 1; i >= 0; i--) {
|
|
305
|
+
const prefix = modelId.slice(0, sepIndices[i])
|
|
306
|
+
if (!prefix) continue
|
|
307
|
+
const candidates = variants.get(prefix)
|
|
308
|
+
if (candidates && candidates.length > 0) {
|
|
309
|
+
if (candidates.length === 1) return candidates[0][1]
|
|
310
|
+
// ๐ Multiple candidates โ pick the best-scoring one.
|
|
311
|
+
let best = null
|
|
312
|
+
let bestScore = -1
|
|
313
|
+
for (const [key, value] of candidates) {
|
|
314
|
+
const score = scoreCandidate(modelId, key)
|
|
315
|
+
if (score > bestScore) {
|
|
316
|
+
bestScore = score
|
|
317
|
+
best = value
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return best
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return null
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// โโโ Stats โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* ๐ Aggregate stats over the catalog โ used by the TUI footer chip and the
|
|
330
|
+
* ๐ web dashboard's "Catalog" panel.
|
|
331
|
+
*
|
|
332
|
+
* @returns {{
|
|
333
|
+
* total: number, // number of model entries (excluding _meta)
|
|
334
|
+
* lastUpdated: string, // from _meta.lastUpdated
|
|
335
|
+
* source: string, // from _meta.source
|
|
336
|
+
* byField: Record<string, number> // count of entries with each field non-null
|
|
337
|
+
* }}
|
|
338
|
+
*/
|
|
339
|
+
export function getCatalogStats() {
|
|
340
|
+
const data = loadCatalog()
|
|
341
|
+
const meta = data._meta ?? {}
|
|
342
|
+
const byField = Object.fromEntries(EXTENDED_BENCH_FIELDS.map(f => [f, 0]))
|
|
343
|
+
let total = 0
|
|
344
|
+
for (const [key, value] of Object.entries(data)) {
|
|
345
|
+
if (key === '_meta') continue
|
|
346
|
+
if (!value || typeof value !== 'object') continue
|
|
347
|
+
total++
|
|
348
|
+
for (const field of EXTENDED_BENCH_FIELDS) {
|
|
349
|
+
if (value[field] !== null && value[field] !== undefined) byField[field]++
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
total,
|
|
354
|
+
lastUpdated: meta.lastUpdated ?? 'unknown',
|
|
355
|
+
source: meta.source ?? 'unknown',
|
|
356
|
+
byField,
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// โโโ Overlay helper โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* ๐ Overlay an extended-benchmark entry onto a model/result object. The function
|
|
364
|
+
* ๐ is non-mutating by default โ returns a new object. If `mutate` is true, the
|
|
365
|
+
* ๐ input is mutated in place (faster for hot paths).
|
|
366
|
+
*
|
|
367
|
+
* ๐ The overlay only adds fields the entry has. `sweScore` (curated, from
|
|
368
|
+
* ๐ sources.js) is always preserved โ extended metrics are additive.
|
|
369
|
+
*
|
|
370
|
+
* @param {object} model โ The result or merged-model object to overlay onto
|
|
371
|
+
* @param {object|null} entry โ The extended-benchmark entry (or null = no-op)
|
|
372
|
+
* @param {object} [opts]
|
|
373
|
+
* @param {boolean} [opts.mutate=false] โ Mutate `model` in place
|
|
374
|
+
* @returns {object} The same model (mutated or new) with `extendedBench` field added
|
|
375
|
+
*/
|
|
376
|
+
export function mergeExtendedBenchmark(model, entry, opts = {}) {
|
|
377
|
+
if (!model || typeof model !== 'object') return model
|
|
378
|
+
if (!entry || typeof entry !== 'object') {
|
|
379
|
+
// ๐ Still mark "looked up, nothing found" so the UI can show a "no data" badge
|
|
380
|
+
if (!opts.mutate) return { ...model, extendedBench: null }
|
|
381
|
+
model.extendedBench = null
|
|
382
|
+
return model
|
|
383
|
+
}
|
|
384
|
+
// ๐ Build the overlay bag โ only the fields present in the entry
|
|
385
|
+
const overlay = {
|
|
386
|
+
codingIndex: entry.codingIndex ?? null,
|
|
387
|
+
mathIndex: entry.mathIndex ?? null,
|
|
388
|
+
agenticIndex: entry.agenticIndex ?? null,
|
|
389
|
+
reasoningIndex: entry.reasoningIndex ?? null,
|
|
390
|
+
mmluPro: entry.mmluPro ?? null,
|
|
391
|
+
gpqa: entry.gpqa ?? null,
|
|
392
|
+
hle: entry.hle ?? null,
|
|
393
|
+
contextWindow: entry.contextWindow ?? null,
|
|
394
|
+
supportsReasoning: entry.supportsReasoning === true,
|
|
395
|
+
supportsVision: entry.supportsVision === true,
|
|
396
|
+
lastUpdated: entry.lastUpdated ?? null,
|
|
397
|
+
originalModel: entry.originalModel ?? null,
|
|
398
|
+
}
|
|
399
|
+
if (opts.mutate) {
|
|
400
|
+
model.extendedBench = overlay
|
|
401
|
+
model.metaSourceExt = 'benchmarks.json'
|
|
402
|
+
return model
|
|
403
|
+
}
|
|
404
|
+
return { ...model, extendedBench: overlay, metaSourceExt: 'benchmarks.json' }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// โโโ Convenience: a "lookup + merge" combo โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* ๐ One-shot helper: look up the model id, return a new object with `extendedBench`
|
|
411
|
+
* ๐ set. Returns the model unchanged if no entry is found (so callers can blindly
|
|
412
|
+
* ๐ call it on every model in a loop).
|
|
413
|
+
*
|
|
414
|
+
* @param {object} model โ Object with at least `modelId`
|
|
415
|
+
* @returns {object} Same model + `extendedBench` (may be null)
|
|
416
|
+
*/
|
|
417
|
+
export function enrichWithExtendedBenchmark(model) {
|
|
418
|
+
if (!model || !model.modelId) return model
|
|
419
|
+
const entry = lookupExtendedBenchmark(model.modelId)
|
|
420
|
+
return mergeExtendedBenchmark(model, entry)
|
|
421
|
+
}
|