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.
@@ -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
+ }