free-coding-models 0.5.60 → 0.5.62
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 +2 -0
- package/bin/free-coding-models.js +19 -0
- package/changelog/v0.5.61.md +65 -0
- package/changelog/v0.5.62.md +65 -0
- package/package.json +8 -2
- package/scripts/check-drift.mjs +160 -0
- package/scripts/update-benchmarks.mjs +239 -0
- 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/utils.js +14 -0
- package/src/data/benchmarks.json +302 -0
- package/src/tui/app.js +86 -0
- package/src/tui/cli-help.js +2 -0
- package/src/tui/render-table.js +38 -2
- package/web/dist/assets/{index-CQQJkofy.js → index-DBaz4DiK.js} +2 -2
- package/web/dist/index.html +1 -1
- package/web/server.js +39 -0
|
@@ -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
|
+
}
|
package/src/core/model-merger.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseCtxToK, parseSweToNum } from './utils.js'
|
|
2
|
+
import { lookupExtendedBenchmark, mergeExtendedBenchmark, getCatalogStats as getExtendedBenchStats } from './extended-benchmarks.js'
|
|
2
3
|
|
|
3
4
|
const TIER_RANK = { 'S+': 0, 'S': 1, 'A+': 2, 'A': 3, 'A-': 4, 'B+': 5, 'B': 6, 'C': 7 }
|
|
4
5
|
|
|
@@ -67,3 +68,157 @@ export function buildMergedModels(models) {
|
|
|
67
68
|
providerCount: g.providers.length,
|
|
68
69
|
}))
|
|
69
70
|
}
|
|
71
|
+
|
|
72
|
+
// ─── Extended-benchmark overlay (t4) ─────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 📖 Overlay the extended-benchmark catalog (Coding/Math/Agentic/Reasoning/MMLU-Pro/
|
|
76
|
+
* 📖 GPQA/HLE + reasoning/vision flags) onto every merged model. The catalog is
|
|
77
|
+
* 📖 a static JSON in `src/data/benchmarks.json`, looked up via prefix-index for
|
|
78
|
+
* 📖 O(key length) cost. We try the group's primary modelId first, then any of its
|
|
79
|
+
* 📖 provider variants as a fallback.
|
|
80
|
+
*
|
|
81
|
+
* 📖 Returns a NEW array of merged models — does not mutate the input.
|
|
82
|
+
*
|
|
83
|
+
* @param {Array} mergedModels — Output of buildMergedModels
|
|
84
|
+
* @returns {Array} The same models with `extendedBench` + `metaSourceExt` set
|
|
85
|
+
*/
|
|
86
|
+
export function overlayExtendedBenchmarks(mergedModels) {
|
|
87
|
+
if (!Array.isArray(mergedModels)) return mergedModels
|
|
88
|
+
return mergedModels.map(m => {
|
|
89
|
+
// 📖 Try the primary modelId first, then provider variants
|
|
90
|
+
let entry = null
|
|
91
|
+
if (m.providers && m.providers.length > 0) {
|
|
92
|
+
for (const p of m.providers) {
|
|
93
|
+
entry = lookupExtendedBenchmark(p.modelId)
|
|
94
|
+
if (entry) break
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return mergeExtendedBenchmark(m, entry)
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── models.dev overlay (t5) ──────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 📖 Async: enrich every merged model with live metadata from models.dev
|
|
105
|
+
* 📖 (context window, max output tokens, reasoning/vision/thinking flags). This
|
|
106
|
+
* 📖 runs in the background — the TUI renders with sources.js values first, then
|
|
107
|
+
* 📖 re-renders once the fetch resolves. If the fetch fails, the overlay is a
|
|
108
|
+
* 📖 no-op and `metaSource` stays at 'sources.js'.
|
|
109
|
+
*
|
|
110
|
+
* 📖 `metaSource` reflects which layer currently holds the values:
|
|
111
|
+
* 📖 - 'sources.js' — curated only (no live data, or fetch failed)
|
|
112
|
+
* 📖 - 'models.dev' — live values replaced the curated ones
|
|
113
|
+
* 📖 - 'sources.js+md' — live overlay + curated (no overrides applied)
|
|
114
|
+
*
|
|
115
|
+
* @param {Array} mergedModels — Output of buildMergedModels (or overlayExtendedBenchmarks)
|
|
116
|
+
* @param {object} [opts]
|
|
117
|
+
* @param {Function} [opts.fetchCatalog] — Injected fetcher (defaults to fetchModelsDevCatalog)
|
|
118
|
+
* @param {Function} [opts.buildIndex] — Injected indexer (defaults to buildModelIndex)
|
|
119
|
+
* @param {Function} [opts.lookup] — Injected lookup (defaults to lookupModelDevMeta)
|
|
120
|
+
* @param {boolean} [opts.mutate=false] — Mutate input in place
|
|
121
|
+
* @returns {Promise<Array>} The same models with `modelsDevMeta` + `metaSource` set
|
|
122
|
+
*/
|
|
123
|
+
export async function overlayModelsDevMetadata(mergedModels, opts = {}) {
|
|
124
|
+
if (!Array.isArray(mergedModels)) return mergedModels
|
|
125
|
+
|
|
126
|
+
// 📖 Resolve dependencies (with lazy import to avoid a hard dep when t5 is unused)
|
|
127
|
+
const fetchCatalog = opts.fetchCatalog
|
|
128
|
+
?? (await import('./models-dev-fetcher.js')).fetchModelsDevCatalog
|
|
129
|
+
const buildIndex = opts.buildIndex
|
|
130
|
+
?? (await import('./models-dev-index.js')).buildModelIndex
|
|
131
|
+
const lookup = opts.lookup
|
|
132
|
+
?? (await import('./models-dev-index.js')).lookupModelDevMeta
|
|
133
|
+
|
|
134
|
+
let catalog = null
|
|
135
|
+
try {
|
|
136
|
+
catalog = await fetchCatalog({ silent: true })
|
|
137
|
+
} catch {
|
|
138
|
+
catalog = null
|
|
139
|
+
}
|
|
140
|
+
if (!catalog || typeof catalog !== 'object') {
|
|
141
|
+
// 📖 Fetch failed — leave models untouched, mark provenance
|
|
142
|
+
return mergedModels.map(m => ({ ...m, modelsDevMeta: null, metaSource: 'sources.js' }))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const index = buildIndex(catalog)
|
|
146
|
+
const touchedAt = Date.now()
|
|
147
|
+
|
|
148
|
+
for (let i = 0; i < mergedModels.length; i++) {
|
|
149
|
+
const m = mergedModels[i]
|
|
150
|
+
let bestMatch = null
|
|
151
|
+
if (m.providers && m.providers.length > 0) {
|
|
152
|
+
// 📖 Try the full "<providerKey>/<modelId>" key first (most common case),
|
|
153
|
+
// 📖 then the bare modelId (handles the modelId-already-includes-prefix case).
|
|
154
|
+
for (const p of m.providers) {
|
|
155
|
+
const fullKey = p.providerKey ? `${p.providerKey}/${p.modelId}` : p.modelId
|
|
156
|
+
const r1 = lookup(fullKey, m.label, index)
|
|
157
|
+
if (r1) { bestMatch = r1; break }
|
|
158
|
+
const r2 = lookup(p.modelId, m.label, index)
|
|
159
|
+
if (r2) { bestMatch = r2; break }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (!bestMatch && m.slug) bestMatch = lookup(m.slug, m.label, index)
|
|
163
|
+
if (!bestMatch) {
|
|
164
|
+
if (opts.mutate) {
|
|
165
|
+
Object.assign(m, { modelsDevMeta: null, metaSource: 'sources.js' })
|
|
166
|
+
mergedModels[i] = m
|
|
167
|
+
} else {
|
|
168
|
+
mergedModels[i] = { ...m, modelsDevMeta: null, metaSource: 'sources.js' }
|
|
169
|
+
}
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
const norm = bestMatch.entry
|
|
173
|
+
const liveCtx = typeof norm.contextWindow === 'number' ? formatCtxFromNum(norm.contextWindow) : null
|
|
174
|
+
const overlay = {
|
|
175
|
+
contextWindow: liveCtx ?? m.ctx,
|
|
176
|
+
contextWindowNum: norm.contextWindow ?? null,
|
|
177
|
+
maxOutputTokens: norm.maxOutputTokens ?? null,
|
|
178
|
+
reasoning: norm.reasoning === true,
|
|
179
|
+
vision: norm.vision === true,
|
|
180
|
+
thinking: norm.thinking === true,
|
|
181
|
+
toolCall: norm.toolCall === true,
|
|
182
|
+
matchKind: bestMatch.matchKind,
|
|
183
|
+
lastFetchedAt: touchedAt,
|
|
184
|
+
}
|
|
185
|
+
if (opts.mutate) {
|
|
186
|
+
Object.assign(m, { modelsDevMeta: overlay, metaSource: 'models.dev' })
|
|
187
|
+
mergedModels[i] = m
|
|
188
|
+
} else {
|
|
189
|
+
mergedModels[i] = { ...m, modelsDevMeta: overlay, metaSource: 'models.dev' }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return mergedModels
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 📖 formatCtxFromNum: Convert a raw token count (e.g. 128000) into a compact string
|
|
196
|
+
// 📖 matching the sources.js convention ("128k", "1M"). Used by overlayModelsDevMetadata.
|
|
197
|
+
function formatCtxFromNum(n) {
|
|
198
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return null
|
|
199
|
+
if (n >= 1_000_000) {
|
|
200
|
+
const m = n / 1_000_000
|
|
201
|
+
return (Number.isInteger(m) ? m.toString() : m.toFixed(1)) + 'M'
|
|
202
|
+
}
|
|
203
|
+
if (n >= 1000) {
|
|
204
|
+
const k = n / 1000
|
|
205
|
+
return (Number.isInteger(k) ? k.toString() : k.toFixed(0)) + 'k'
|
|
206
|
+
}
|
|
207
|
+
return n.toString()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── Stats helper ─────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 📖 Aggregate stats about all enrichment layers — used by the TUI footer chip,
|
|
214
|
+
* 📖 the web dashboard /stats endpoint, and the drift report.
|
|
215
|
+
*
|
|
216
|
+
* @returns {{
|
|
217
|
+
* extendedBench: { total: number, lastUpdated: string, source: string, byField: object }
|
|
218
|
+
* }}
|
|
219
|
+
*/
|
|
220
|
+
export function getEnrichmentStats() {
|
|
221
|
+
return {
|
|
222
|
+
extendedBench: getExtendedBenchStats(),
|
|
223
|
+
}
|
|
224
|
+
}
|