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