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/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
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file models-dev-fetcher.js
|
|
3
|
+
* @description Fetcher + TTL cache for the models.dev community catalog, with retry/backoff.
|
|
4
|
+
* Used by src/core/models-dev-index.js to enrich `sources.js` with live metadata
|
|
5
|
+
* (context window, max output tokens, reasoning/vision/thinking support).
|
|
6
|
+
*
|
|
7
|
+
* @details
|
|
8
|
+
* 📖 Why this exists:
|
|
9
|
+
* 📖 - `sources.js` is curated (which models to ping, SWE-bench tier) but its `ctx` field
|
|
10
|
+
* 📖 drifts — vendors expand windows (128k → 256k → 1M) and we don't catch up.
|
|
11
|
+
* 📖 - models.dev is a community-maintained registry of LLM metadata (context, max
|
|
12
|
+
* 📖 tokens, reasoning/vision flags, thinking levels) that we can use as an overlay.
|
|
13
|
+
* 📖 - The fetch happens lazily and non-blocking — if the network is down or the
|
|
14
|
+
* 📖 catalog is unreachable, the TUI keeps rendering with sources.js values.
|
|
15
|
+
*
|
|
16
|
+
* 📖 Behaviour:
|
|
17
|
+
* 📖 - 3 retries with 250 ms backoff, 8s per-request timeout.
|
|
18
|
+
* 📖 - 5-min in-process cache (TTL).
|
|
19
|
+
* 📖 - Sets a custom User-Agent so the maintainers can identify us.
|
|
20
|
+
* 📖 - Returns null on failure (no throw). Callers MUST handle null gracefully.
|
|
21
|
+
*
|
|
22
|
+
* 📖 The fetched JSON shape (from models.dev):
|
|
23
|
+
* 📖 {
|
|
24
|
+
* 📖 "<providerKey>": {
|
|
25
|
+
* 📖 "id": "deepseek",
|
|
26
|
+
* 📖 "name": "DeepSeek",
|
|
27
|
+
* 📖 "models": {
|
|
28
|
+
* 📖 "<modelId>": {
|
|
29
|
+
* 📖 "id": "deepseek-chat",
|
|
30
|
+
* 📖 "name": "DeepSeek Chat",
|
|
31
|
+
* 📖 "context": 128000,
|
|
32
|
+
* 📖 "maxTokens": 8192,
|
|
33
|
+
* 📖 "reasoning": false,
|
|
34
|
+
* 📖 "vision": false,
|
|
35
|
+
* 📖 "thinking": false,
|
|
36
|
+
* 📖 "tool_call": true,
|
|
37
|
+
* 📖 ...
|
|
38
|
+
* 📖 }
|
|
39
|
+
* 📖 }
|
|
40
|
+
* 📖 }
|
|
41
|
+
* 📖 }
|
|
42
|
+
*
|
|
43
|
+
* @functions
|
|
44
|
+
* → fetchModelsDevCatalog({ force }?) — Returns the parsed catalog (or null on failure)
|
|
45
|
+
* → getModelsDevCacheStats() — { hits, misses, lastFetchAt, lastError }
|
|
46
|
+
* → clearModelsDevCache() — Reset the in-process cache (for tests + drift script)
|
|
47
|
+
* → _resetModelsDevCacheForTests() — Alias used by tests (no behaviour difference)
|
|
48
|
+
*
|
|
49
|
+
* @exports fetchModelsDevCatalog, getModelsDevCacheStats, clearModelsDevCache,
|
|
50
|
+
* MODELS_DEV_URL, DEFAULT_FETCH_TIMEOUT_MS, MODELS_DEV_CACHE_TTL_MS
|
|
51
|
+
*
|
|
52
|
+
* @see src/core/models-dev-index.js — uses this fetcher to build the lookup index
|
|
53
|
+
* @see src/core/models-drift.js — drift detection against sources.js
|
|
54
|
+
* @see https://models.dev — community catalog source
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
const MODELS_DEV_URL = 'https://models.dev/models.json'
|
|
58
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 8_000
|
|
59
|
+
const MODELS_DEV_CACHE_TTL_MS = 5 * 60 * 1000 // 📖 5 minutes
|
|
60
|
+
const MODELS_DEV_RETRIES = 3
|
|
61
|
+
const MODELS_DEV_RETRY_DELAY_MS = 250
|
|
62
|
+
const USER_AGENT = 'free-coding-models (+https://github.com/vava-nessa/free-coding-models)'
|
|
63
|
+
|
|
64
|
+
// 📖 Re-export as ES exports so the test file (and other consumers) can import them.
|
|
65
|
+
// 📖 We re-declare them as named exports here to keep the file self-documenting.
|
|
66
|
+
export {
|
|
67
|
+
MODELS_DEV_URL,
|
|
68
|
+
DEFAULT_FETCH_TIMEOUT_MS,
|
|
69
|
+
MODELS_DEV_CACHE_TTL_MS,
|
|
70
|
+
MODELS_DEV_RETRIES,
|
|
71
|
+
MODELS_DEV_RETRY_DELAY_MS,
|
|
72
|
+
USER_AGENT,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Module-level state ──────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/** 📖 Cached entry: { expiresAt, promise, resolvedAt, error, hits, misses } */
|
|
78
|
+
let _cache = null
|
|
79
|
+
|
|
80
|
+
/** 📖 Simple sleep helper (used between retries). */
|
|
81
|
+
function sleep(ms) {
|
|
82
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 📖 Fetch (or return the cached) models.dev catalog. Returns the parsed JSON
|
|
89
|
+
* 📖 object on success, or null on failure (after exhausting all retries).
|
|
90
|
+
* 📖 Multiple concurrent calls share the same in-flight promise (no thundering herd).
|
|
91
|
+
*
|
|
92
|
+
* @param {object} [opts]
|
|
93
|
+
* @param {boolean} [opts.force=false] — Bypass the cache and re-fetch
|
|
94
|
+
* @param {number} [opts.timeoutMs=8000] — Per-request timeout
|
|
95
|
+
* @param {number} [opts.retries=3] — Number of attempts before giving up
|
|
96
|
+
* @param {number} [opts.retryDelayMs=250] — Backoff between attempts
|
|
97
|
+
* @param {boolean} [opts.silent=true] — Suppress console.warn on fetch failure
|
|
98
|
+
* @returns {Promise<object|null>}
|
|
99
|
+
*/
|
|
100
|
+
export async function fetchModelsDevCatalog({
|
|
101
|
+
force = false,
|
|
102
|
+
timeoutMs = DEFAULT_FETCH_TIMEOUT_MS,
|
|
103
|
+
retries = MODELS_DEV_RETRIES,
|
|
104
|
+
retryDelayMs = MODELS_DEV_RETRY_DELAY_MS,
|
|
105
|
+
silent = true,
|
|
106
|
+
} = {}) {
|
|
107
|
+
const now = Date.now()
|
|
108
|
+
if (!force && _cache && _cache.expiresAt > now && _cache.promise) {
|
|
109
|
+
_cache.hits++
|
|
110
|
+
return _cache.promise
|
|
111
|
+
}
|
|
112
|
+
if (_cache) _cache.misses++
|
|
113
|
+
|
|
114
|
+
const promise = (async () => {
|
|
115
|
+
let lastError = null
|
|
116
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
117
|
+
try {
|
|
118
|
+
const ctrl = new AbortController()
|
|
119
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
|
|
120
|
+
try {
|
|
121
|
+
const res = await fetch(MODELS_DEV_URL, {
|
|
122
|
+
headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' },
|
|
123
|
+
signal: ctrl.signal,
|
|
124
|
+
})
|
|
125
|
+
if (!res.ok) {
|
|
126
|
+
lastError = new Error(`HTTP ${res.status} ${res.statusText}`.trim())
|
|
127
|
+
} else {
|
|
128
|
+
const data = await res.json()
|
|
129
|
+
if (data && typeof data === 'object') {
|
|
130
|
+
return data
|
|
131
|
+
}
|
|
132
|
+
lastError = new Error('models.dev returned non-object payload')
|
|
133
|
+
}
|
|
134
|
+
} finally {
|
|
135
|
+
clearTimeout(timer)
|
|
136
|
+
}
|
|
137
|
+
} catch (err) {
|
|
138
|
+
lastError = err
|
|
139
|
+
}
|
|
140
|
+
if (attempt < retries) {
|
|
141
|
+
await sleep(retryDelayMs)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!silent) {
|
|
145
|
+
console.warn(`[models.dev] fetch failed after ${retries} attempts: ${lastError?.message ?? 'unknown error'}`)
|
|
146
|
+
}
|
|
147
|
+
if (_cache) {
|
|
148
|
+
_cache.error = lastError?.message ?? 'unknown'
|
|
149
|
+
_cache.resolvedAt = Date.now()
|
|
150
|
+
}
|
|
151
|
+
return null
|
|
152
|
+
})()
|
|
153
|
+
|
|
154
|
+
_cache = {
|
|
155
|
+
expiresAt: Date.now() + MODELS_DEV_CACHE_TTL_MS,
|
|
156
|
+
promise,
|
|
157
|
+
resolvedAt: null,
|
|
158
|
+
error: null,
|
|
159
|
+
hits: _cache?.hits ?? 0,
|
|
160
|
+
misses: _cache?.misses ?? 0,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 📖 Wrap the promise so we can capture resolvedAt + error metadata on success too
|
|
164
|
+
const wrapped = promise.then(data => {
|
|
165
|
+
if (_cache) {
|
|
166
|
+
_cache.resolvedAt = Date.now()
|
|
167
|
+
_cache.error = null
|
|
168
|
+
}
|
|
169
|
+
return data
|
|
170
|
+
})
|
|
171
|
+
return wrapped
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 📖 Stats for the in-process cache. Used by the TUI footer chip and the
|
|
176
|
+
* 📖 /health endpoint of the daemon.
|
|
177
|
+
*
|
|
178
|
+
* @returns {{
|
|
179
|
+
* hits: number,
|
|
180
|
+
* misses: number,
|
|
181
|
+
* lastFetchAt: number|null, // ms timestamp of last completed fetch
|
|
182
|
+
* lastError: string|null,
|
|
183
|
+
* cached: boolean // whether the current entry is still within TTL
|
|
184
|
+
* }}
|
|
185
|
+
*/
|
|
186
|
+
export function getModelsDevCacheStats() {
|
|
187
|
+
if (!_cache) {
|
|
188
|
+
return { hits: 0, misses: 0, lastFetchAt: null, lastError: null, cached: false }
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
hits: _cache.hits,
|
|
192
|
+
misses: _cache.misses,
|
|
193
|
+
lastFetchAt: _cache.resolvedAt,
|
|
194
|
+
lastError: _cache.error,
|
|
195
|
+
cached: _cache.expiresAt > Date.now(),
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 📖 Clear the in-process cache. The next call to fetchModelsDevCatalog will
|
|
201
|
+
* 📖 re-fetch. Used by tests + the drift script (to get a fresh view).
|
|
202
|
+
*/
|
|
203
|
+
export function clearModelsDevCache() {
|
|
204
|
+
_cache = null
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 📖 Test-only alias. Same behaviour as clearModelsDevCache. */
|
|
208
|
+
export function _resetModelsDevCacheForTests() {
|
|
209
|
+
clearModelsDevCache()
|
|
210
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file models-dev-index.js
|
|
3
|
+
* @description Build a normalized lookup index over the models.dev catalog.
|
|
4
|
+
* Used to enrich `sources.js` models with live metadata (context window,
|
|
5
|
+
* max output tokens, reasoning/vision/thinking support).
|
|
6
|
+
*
|
|
7
|
+
* @details
|
|
8
|
+
* 📖 Why this exists:
|
|
9
|
+
* 📖 - models.dev returns a nested object: `{ <providerKey>: { models: { <id>: {...} } } }`.
|
|
10
|
+
* 📖 - sources.js uses a flat array with display labels. We need a fast, normalized
|
|
11
|
+
* 📖 way to look up a model by either its `provider/modelId` or by display label.
|
|
12
|
+
* 📖 - Lookup order: exact id → provider-prefixed id → display label substring.
|
|
13
|
+
* 📖 - Substring matches are a last resort — they can produce false positives
|
|
14
|
+
* 📖 (e.g. "deepseek-chat" substring of "deepseek-chat-2"). We log them via
|
|
15
|
+
* 📖 the `metaSource` field so callers can surface the uncertainty.
|
|
16
|
+
*
|
|
17
|
+
* 📖 Provider aliases: the catalog may list "together" while sources.js uses
|
|
18
|
+
* 📖 "togetherai". We provide a small alias map to bridge the two. New aliases
|
|
19
|
+
* 📖 can be added without touching the lookup logic.
|
|
20
|
+
*
|
|
21
|
+
* 📖 Cross-surface: pure logic, used by the TUI, Web Dashboard, and drift detector.
|
|
22
|
+
*
|
|
23
|
+
* @functions
|
|
24
|
+
* → buildModelIndex(catalog) — Returns { byId, byProviderModel, byLabel }
|
|
25
|
+
* → lookupModelDevMeta(modelId, label, index?) — Returns the normalized entry or null
|
|
26
|
+
* → normalizeModelDevEntry(rawEntry) — Shape-validate a raw models.dev model
|
|
27
|
+
* → PROVIDER_ALIASES — Map of canonical provider keys
|
|
28
|
+
*
|
|
29
|
+
* @exports buildModelIndex, lookupModelDevMeta, normalizeModelDevEntry, PROVIDER_ALIASES
|
|
30
|
+
*
|
|
31
|
+
* @see src/core/models-dev-fetcher.js — provides the catalog
|
|
32
|
+
* @see src/core/models-drift.js — uses this for drift detection
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
// ─── Provider alias map ──────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 📖 Map of source provider keys (used by sources.js) to models.dev provider keys
|
|
39
|
+
* 📖 (used by their catalog). The lookup falls back to the source key if no alias
|
|
40
|
+
* 📖 matches, so adding a new provider is a no-op until a divergence shows up.
|
|
41
|
+
*/
|
|
42
|
+
export const PROVIDER_ALIASES = {
|
|
43
|
+
// sources.js → models.dev
|
|
44
|
+
nvidiaNim: 'nvidia',
|
|
45
|
+
nvidia: 'nvidia',
|
|
46
|
+
openrouter: 'openrouter',
|
|
47
|
+
groq: 'groq',
|
|
48
|
+
cerebras: 'cerebras',
|
|
49
|
+
github: 'github-models',
|
|
50
|
+
mistral: 'mistral',
|
|
51
|
+
cloudflare: 'cloudflare-workers-ai',
|
|
52
|
+
opencode: 'opencode',
|
|
53
|
+
scaleway: 'scaleway',
|
|
54
|
+
google: 'google',
|
|
55
|
+
zai: 'zai',
|
|
56
|
+
zaiOrg: 'zai',
|
|
57
|
+
kilocode: 'kilocode',
|
|
58
|
+
kilocodeAi: 'kilo',
|
|
59
|
+
deepseek: 'deepseek',
|
|
60
|
+
qwen: 'qwen',
|
|
61
|
+
together: 'togetherai',
|
|
62
|
+
novita: 'novita-ai',
|
|
63
|
+
ollama: 'ollama',
|
|
64
|
+
openhands: 'openhands',
|
|
65
|
+
xai: 'xai',
|
|
66
|
+
cohere: 'cohere',
|
|
67
|
+
perplexity: 'perplexity',
|
|
68
|
+
anthropic: 'anthropic',
|
|
69
|
+
openai: 'openai',
|
|
70
|
+
meta: 'meta',
|
|
71
|
+
moonshotai: 'moonshotai',
|
|
72
|
+
zhipu: 'zhipu',
|
|
73
|
+
stepfun: 'stepfun',
|
|
74
|
+
bytedance: 'bytedance',
|
|
75
|
+
stockmark: 'stockmark',
|
|
76
|
+
minimax: 'minimax',
|
|
77
|
+
minimaxai: 'minimax',
|
|
78
|
+
poolside: 'poolside',
|
|
79
|
+
cohereCommand: 'cohere',
|
|
80
|
+
llm7: 'llm7',
|
|
81
|
+
routeway: 'routeway',
|
|
82
|
+
dashscope: 'dashscope',
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function aliasesFor(providerKey) {
|
|
86
|
+
if (!providerKey) return []
|
|
87
|
+
const lower = String(providerKey).toLowerCase()
|
|
88
|
+
const aliased = PROVIDER_ALIASES[providerKey] ?? PROVIDER_ALIASES[lower]
|
|
89
|
+
if (aliased && aliased !== providerKey) return [providerKey, aliased]
|
|
90
|
+
return [providerKey]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Shape normalisation ─────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 📖 Convert a raw models.dev model entry into the normalized shape our merger
|
|
97
|
+
* 📖 consumes. Defensive: returns null if the entry is unusable, so callers can
|
|
98
|
+
* 📖 skip it without try/catch.
|
|
99
|
+
*
|
|
100
|
+
* 📖 Normalized shape:
|
|
101
|
+
* 📖 {
|
|
102
|
+
* 📖 id: string,
|
|
103
|
+
* 📖 name: string,
|
|
104
|
+
* 📖 contextWindow: number|null,
|
|
105
|
+
* 📖 maxOutputTokens: number|null,
|
|
106
|
+
* 📖 reasoning: boolean,
|
|
107
|
+
* 📖 vision: boolean,
|
|
108
|
+
* 📖 thinking: boolean,
|
|
109
|
+
* 📖 toolCall: boolean,
|
|
110
|
+
* 📖 provider: string, // original provider key
|
|
111
|
+
* 📖 raw: object // original entry (for debug / future fields)
|
|
112
|
+
* 📖 }
|
|
113
|
+
*
|
|
114
|
+
* @param {object} rawEntry
|
|
115
|
+
* @returns {object|null}
|
|
116
|
+
*/
|
|
117
|
+
export function normalizeModelDevEntry(rawEntry) {
|
|
118
|
+
if (!rawEntry || typeof rawEntry !== 'object') return null
|
|
119
|
+
const id = rawEntry.id
|
|
120
|
+
if (typeof id !== 'string' || !id) return null
|
|
121
|
+
const numOrNull = (v) => (typeof v === 'number' && Number.isFinite(v)) ? v : null
|
|
122
|
+
const boolOrFalse = (v) => v === true
|
|
123
|
+
|
|
124
|
+
// 📖 Context + max tokens come from `limit` (flat) or top-level (nested)
|
|
125
|
+
const limit = rawEntry.limit && typeof rawEntry.limit === 'object' ? rawEntry.limit : {}
|
|
126
|
+
const contextWindow = numOrNull(limit.context ?? rawEntry.context ?? rawEntry.contextWindow)
|
|
127
|
+
const maxOutputTokens = numOrNull(limit.output ?? rawEntry.max_tokens ?? rawEntry.maxTokens)
|
|
128
|
+
|
|
129
|
+
// 📖 Vision is a modality flag (flat) or a top-level boolean (nested)
|
|
130
|
+
const modalities = rawEntry.modalities && typeof rawEntry.modalities === 'object' ? rawEntry.modalities : {}
|
|
131
|
+
const visionFromModalities = Array.isArray(modalities.input)
|
|
132
|
+
? modalities.input.some(m => typeof m === 'string' && (m.toLowerCase().includes('image') || m.toLowerCase().includes('video')))
|
|
133
|
+
: false
|
|
134
|
+
const vision = boolOrFalse(rawEntry.vision) || visionFromModalities
|
|
135
|
+
|
|
136
|
+
// 📖 Provider is the id prefix (flat) or a top-level field (nested)
|
|
137
|
+
const slashIdx = id.indexOf('/')
|
|
138
|
+
const providerFromId = slashIdx !== -1 ? id.slice(0, slashIdx) : ''
|
|
139
|
+
const provider = typeof rawEntry.provider === 'string' && rawEntry.provider
|
|
140
|
+
? rawEntry.provider
|
|
141
|
+
: providerFromId
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
id,
|
|
145
|
+
name: typeof rawEntry.name === 'string' ? rawEntry.name : id,
|
|
146
|
+
contextWindow,
|
|
147
|
+
maxOutputTokens,
|
|
148
|
+
reasoning: boolOrFalse(rawEntry.reasoning),
|
|
149
|
+
vision,
|
|
150
|
+
thinking: boolOrFalse(rawEntry.thinking),
|
|
151
|
+
toolCall: boolOrFalse(rawEntry.tool_call ?? rawEntry.toolCall),
|
|
152
|
+
provider,
|
|
153
|
+
raw: rawEntry,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ─── Index builder ───────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 📖 Build a normalized index over the models.dev catalog. Returns three maps:
|
|
161
|
+
* 📖 - byId: "deepseek/deepseek-chat" → normalized entry
|
|
162
|
+
* 📖 - byProviderModel: "deepseek/deepseek-chat" → normalized entry (same key, exposed
|
|
163
|
+
* 📖 separately so the merger can pick whichever it prefers)
|
|
164
|
+
* 📖 - byLabel: "DeepSeek Chat" → normalized entry[] (substring fallback)
|
|
165
|
+
*
|
|
166
|
+
* @param {object} catalog — The raw models.dev catalog
|
|
167
|
+
* @returns {{
|
|
168
|
+
* byId: Map<string, object>,
|
|
169
|
+
* byProviderModel: Map<string, object>,
|
|
170
|
+
* byLabel: Map<string, object[]>,
|
|
171
|
+
* total: number,
|
|
172
|
+
* providers: string[]
|
|
173
|
+
* }}
|
|
174
|
+
*/
|
|
175
|
+
export function buildModelIndex(catalog) {
|
|
176
|
+
const byId = new Map()
|
|
177
|
+
const byProviderModel = new Map()
|
|
178
|
+
const byLabel = new Map()
|
|
179
|
+
const providers = []
|
|
180
|
+
let total = 0
|
|
181
|
+
|
|
182
|
+
if (!catalog || typeof catalog !== 'object') {
|
|
183
|
+
return { byId, byProviderModel, byLabel, total: 0, providers: [] }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 📖 Detect format: flat catalogs have keys that look like model ids
|
|
187
|
+
// 📖 ("provider/model-name") while nested catalogs have keys that look like
|
|
188
|
+
// 📖 provider ids with a `models` sub-object.
|
|
189
|
+
const entries = Object.entries(catalog)
|
|
190
|
+
const isFlat = entries.length > 0 && entries.every(([k, v]) => {
|
|
191
|
+
if (!v || typeof v !== 'object') return false
|
|
192
|
+
if (k.includes('/')) return typeof v.limit === 'object' || typeof v.id === 'string'
|
|
193
|
+
return false
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
if (isFlat) {
|
|
197
|
+
// 📖 Flat format: each top-level key is "<provider>/<modelId>"
|
|
198
|
+
for (const [idKey, rawEntry] of entries) {
|
|
199
|
+
if (!rawEntry || typeof rawEntry !== 'object') continue
|
|
200
|
+
const norm = normalizeModelDevEntry(rawEntry)
|
|
201
|
+
if (!norm) continue
|
|
202
|
+
const providerKey = norm.provider || idKey.slice(0, idKey.indexOf('/'))
|
|
203
|
+
if (!providers.includes(providerKey)) providers.push(providerKey)
|
|
204
|
+
if (!byId.has(idKey)) byId.set(idKey, norm)
|
|
205
|
+
if (!byProviderModel.has(idKey)) byProviderModel.set(idKey, norm)
|
|
206
|
+
const label = norm.name || idKey
|
|
207
|
+
const arr = byLabel.get(label) ?? []
|
|
208
|
+
arr.push(norm)
|
|
209
|
+
byLabel.set(label, arr)
|
|
210
|
+
total++
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
// 📖 Nested format: { <provider>: { id, name, models: {...} } }
|
|
214
|
+
for (const [providerKey, providerBucket] of entries) {
|
|
215
|
+
if (!providerBucket || typeof providerBucket !== 'object') continue
|
|
216
|
+
const models = providerBucket.models
|
|
217
|
+
if (!models || typeof models !== 'object') continue
|
|
218
|
+
providers.push(providerKey)
|
|
219
|
+
for (const [modelId, rawEntry] of Object.entries(models)) {
|
|
220
|
+
const norm = normalizeModelDevEntry(rawEntry)
|
|
221
|
+
if (!norm) continue
|
|
222
|
+
norm.provider = providerKey
|
|
223
|
+
const idKey = `${providerKey}/${modelId}`
|
|
224
|
+
if (!byId.has(idKey)) byId.set(idKey, norm)
|
|
225
|
+
if (!byProviderModel.has(idKey)) byProviderModel.set(idKey, norm)
|
|
226
|
+
const label = norm.name || modelId
|
|
227
|
+
const arr = byLabel.get(label) ?? []
|
|
228
|
+
arr.push(norm)
|
|
229
|
+
byLabel.set(label, arr)
|
|
230
|
+
total++
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { byId, byProviderModel, byLabel, total, providers }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── Lookup ──────────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 📖 Look up a model in the index. Tries in order:
|
|
241
|
+
* 📖 1. Exact "provider/modelId" match (O(1))
|
|
242
|
+
* 📖 2. Aliased provider variants (e.g. "nvidiaNim" → "nvidia")
|
|
243
|
+
* 📖 3. Display-label substring match (last resort, may produce false positives)
|
|
244
|
+
*
|
|
245
|
+
* 📖 Returns a small wrapper so callers know HOW the match was made:
|
|
246
|
+
* 📖 { entry, matchKind: 'exact'|'alias'|'substring' }
|
|
247
|
+
*
|
|
248
|
+
* @param {string} modelId
|
|
249
|
+
* @param {string} [label] — display label from sources.js (helps the substring fallback)
|
|
250
|
+
* @param {object} [index] — Optional pre-built index (defaults to building one)
|
|
251
|
+
* @param {object} [catalog] — Optional catalog (used when index is omitted)
|
|
252
|
+
* @returns {{ entry: object, matchKind: string }|null}
|
|
253
|
+
*/
|
|
254
|
+
export function lookupModelDevMeta(modelId, label, index, catalog) {
|
|
255
|
+
if (!modelId || typeof modelId !== 'string') return null
|
|
256
|
+
let idx = index
|
|
257
|
+
if (!idx) {
|
|
258
|
+
if (!catalog) return null
|
|
259
|
+
idx = buildModelIndex(catalog)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Rule 1: exact match — supports both "provider/modelId" and bare "modelId"
|
|
263
|
+
if (idx.byProviderModel.has(modelId)) {
|
|
264
|
+
return { entry: idx.byProviderModel.get(modelId), matchKind: 'exact' }
|
|
265
|
+
}
|
|
266
|
+
// 📖 Try the bare model id in case the catalog's provider is implicit
|
|
267
|
+
if (!modelId.includes('/') && idx.byId.has(modelId)) {
|
|
268
|
+
return { entry: idx.byId.get(modelId), matchKind: 'exact' }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Rule 2: provider alias variants. Strip the provider prefix from the input
|
|
272
|
+
// and try each alias.
|
|
273
|
+
if (modelId.includes('/')) {
|
|
274
|
+
const slash = modelId.indexOf('/')
|
|
275
|
+
const providerKey = modelId.slice(0, slash)
|
|
276
|
+
const rest = modelId.slice(slash + 1)
|
|
277
|
+
for (const alias of aliasesFor(providerKey)) {
|
|
278
|
+
const altKey = `${alias}/${rest}`
|
|
279
|
+
if (idx.byProviderModel.has(altKey)) {
|
|
280
|
+
return { entry: idx.byProviderModel.get(altKey), matchKind: 'alias' }
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// 📖 Try the bare rest under any provider (last-ditch exact)
|
|
284
|
+
for (const providerKey2 of idx.providers ?? []) {
|
|
285
|
+
const altKey2 = `${providerKey2}/${rest}`
|
|
286
|
+
if (idx.byProviderModel.has(altKey2)) {
|
|
287
|
+
return { entry: idx.byProviderModel.get(altKey2), matchKind: 'alias' }
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Rule 3: display-label substring fallback
|
|
293
|
+
if (label && typeof label === 'string') {
|
|
294
|
+
const labelLower = label.toLowerCase()
|
|
295
|
+
// 📖 Prefer entries whose name matches the label exactly first
|
|
296
|
+
if (idx.byLabel.has(label)) {
|
|
297
|
+
const arr = idx.byLabel.get(label)
|
|
298
|
+
if (arr.length > 0) return { entry: arr[0], matchKind: 'substring' }
|
|
299
|
+
}
|
|
300
|
+
// 📖 Then scan for a substring match (lowercase)
|
|
301
|
+
for (const [key, arr] of idx.byLabel.entries()) {
|
|
302
|
+
if (!arr || arr.length === 0) continue
|
|
303
|
+
const keyLower = key.toLowerCase()
|
|
304
|
+
if (keyLower.includes(labelLower) || labelLower.includes(keyLower)) {
|
|
305
|
+
return { entry: arr[0], matchKind: 'substring' }
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return null
|
|
311
|
+
}
|