@goodandready/dsh-model-sync 0.5.15 → 0.5.17

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/lib/models.js CHANGED
@@ -1,13 +1,23 @@
1
1
  import { lookupReferencePricing } from './pricing-database.js'
2
+ import { lookupReferenceLimits, lookupReferenceReasoning, lookupReferenceCompat } from './specs-database.js'
2
3
  const FIELD_ALIASES = Object.freeze({
3
4
  contextWindow: [
4
5
  'contextWindow', 'context_window', 'contextLength', 'context_length',
5
6
  'maxContextTokens', 'max_context_tokens', 'maxContextLength', 'max_context_length',
6
7
  'inputTokenLimit', 'input_token_limit',
8
+ 'top_provider.context_length', 'topProvider.contextLength',
7
9
  ],
8
10
  maxTokens: [
9
11
  'maxTokens', 'max_tokens', 'max_output_tokens', 'maxOutputTokens',
10
12
  'outputTokenLimit', 'output_token_limit',
13
+ 'max_completion_tokens', 'maxCompletionTokens',
14
+ 'top_provider.max_completion_tokens', 'topProvider.maxCompletionTokens',
15
+ ],
16
+ maxOutputTokens: [
17
+ 'maxOutputTokens', 'max_output_tokens', 'maxTokens', 'max_tokens',
18
+ 'outputTokenLimit', 'output_token_limit',
19
+ 'max_completion_tokens', 'maxCompletionTokens',
20
+ 'top_provider.max_completion_tokens', 'topProvider.maxCompletionTokens',
11
21
  ],
12
22
  })
13
23
 
@@ -60,8 +70,14 @@ function numericValue(value, { allowZero = false } = {}) {
60
70
 
61
71
  function firstNumber(row, fields, options) {
62
72
  for (const field of fields) {
63
- const value = numericValue(row?.[field], options)
64
- if (value !== undefined) return value
73
+ let value
74
+ if (field.includes('.')) {
75
+ value = field.split('.').reduce((acc, k) => (acc && typeof acc === 'object') ? acc[k] : undefined, row)
76
+ } else {
77
+ value = row?.[field]
78
+ }
79
+ const val = numericValue(value, options)
80
+ if (val !== undefined) return val
65
81
  }
66
82
  return undefined
67
83
  }
@@ -184,6 +200,13 @@ function normalizeCompat(provider, row, id) {
184
200
  source.supportsDeveloperRole = defaultSupportsDeveloperRole
185
201
  }
186
202
 
203
+ const refCompat = lookupReferenceCompat(provider, id)
204
+ if (refCompat) {
205
+ for (const [k, v] of Object.entries(refCompat)) {
206
+ if (source[k] === undefined) source[k] = v
207
+ }
208
+ }
209
+
187
210
  return Object.keys(source).length > 0 ? source : undefined
188
211
  }
189
212
 
@@ -298,6 +321,164 @@ function normalizeDate(val) {
298
321
  return undefined
299
322
  }
300
323
 
324
+ function normalizeReasoningConfig(provider, row, id, capabilities) {
325
+ const source = row?.reasoningConfig && typeof row.reasoningConfig === 'object' ? { ...row.reasoningConfig } : {}
326
+ const ref = lookupReferenceReasoning(provider, id)
327
+ if (ref) {
328
+ for (const [k, v] of Object.entries(ref)) {
329
+ if (source[k] === undefined) source[k] = v
330
+ }
331
+ }
332
+ const isReasoning = capabilities?.reasoning === true ||
333
+ Boolean(source.supportsReasoning) ||
334
+ /(^|[_-])(o1|o3|r1|qwq|thinking)($|[_-])/i.test(id) ||
335
+ id.toLowerCase().includes('reasoner') ||
336
+ id.toLowerCase().includes('-r1') ||
337
+ id.toLowerCase().includes('thinking')
338
+
339
+ if (!isReasoning && Object.keys(source).length === 0) {
340
+ return undefined
341
+ }
342
+
343
+ source.supportsReasoning = true
344
+ if (!source.paramFormat) {
345
+ const idLower = id.toLowerCase()
346
+ const provLower = String(provider || '').toLowerCase()
347
+ if (provLower === 'openai' || /(^|[_-])(o1|o3)($|[_-])/i.test(idLower)) {
348
+ source.paramFormat = 'effort'
349
+ source.defaultEffort = source.defaultEffort || 'medium'
350
+ source.supportedEfforts = source.supportedEfforts || ['low', 'medium', 'high']
351
+ } else if (provLower === 'anthropic' || idLower.includes('claude-3-7') || idLower.includes('claude-3.7')) {
352
+ source.paramFormat = 'budget_tokens'
353
+ source.defaultBudget = source.defaultBudget || 4096
354
+ source.maxBudget = source.maxBudget || 64000
355
+ } else if (provLower === 'deepseek' && (idLower.includes('reasoner') || idLower.includes('r1'))) {
356
+ source.paramFormat = 'boolean'
357
+ } else if (idLower.includes('thinking') || idLower.includes('r1') || idLower.includes('qwq')) {
358
+ source.paramFormat = 'budget_tokens'
359
+ source.defaultBudget = source.defaultBudget || 4096
360
+ source.maxBudget = source.maxBudget || 32768
361
+ } else {
362
+ source.paramFormat = 'boolean'
363
+ }
364
+ }
365
+ return source
366
+ }
367
+
368
+ export function deriveModelTags(model) {
369
+ if (!model || typeof model !== "object") return []
370
+ const tags = new Set(Array.isArray(model.tags) ? model.tags.filter((t) => typeof t === "string" && t.trim()).map((t) => t.trim().toLowerCase()) : [])
371
+ const idLower = String(model.id || "").toLowerCase()
372
+ const nameLower = String(model.name || "").toLowerCase()
373
+ const provLower = String(model.provider || "").toLowerCase()
374
+ const capabilities = model.capabilities || {}
375
+
376
+ // 1. coding
377
+ if (
378
+ capabilities.code === true ||
379
+ idLower.includes("coder") ||
380
+ idLower.includes("codestral") ||
381
+ idLower.includes("starcoder") ||
382
+ idLower.includes("deepseek-coder") ||
383
+ idLower.includes("claude-3-7-sonnet") ||
384
+ idLower.includes("claude-3-5-sonnet") ||
385
+ idLower.includes("kimi-k2.7-code") ||
386
+ idLower.includes("qwen-2.5-coder") ||
387
+ idLower.includes("qwen2.5-coder") ||
388
+ (/(^|[_-])(code|dev|prog)($|[_-])/i.test(idLower)) ||
389
+ (/(^|[_-])(code|coder)($|[_-])/i.test(nameLower))
390
+ ) {
391
+ tags.add("coding")
392
+ }
393
+
394
+ // 2. reasoning
395
+ if (
396
+ model.reasoningConfig?.supportsReasoning === true ||
397
+ capabilities.reasoning === true ||
398
+ /(^|[_-])(o1|o3|r1|qwq)($|[_-])/i.test(idLower) ||
399
+ idLower.includes("reasoner") ||
400
+ idLower.includes("deepseek-r1") ||
401
+ idLower.includes("thinking") ||
402
+ idLower.includes("-r1")
403
+ ) {
404
+ tags.add("reasoning")
405
+ }
406
+
407
+ // 3. fast
408
+ if (
409
+ provLower === "groq" ||
410
+ provLower === "cerebras" ||
411
+ idLower.includes("flash") ||
412
+ idLower.includes("mini") ||
413
+ idLower.includes("instant") ||
414
+ idLower.includes("haiku") ||
415
+ idLower.includes("small") ||
416
+ idLower.includes("8b") ||
417
+ idLower.includes("3b") ||
418
+ idLower.includes("1b") ||
419
+ idLower.includes("turbo") ||
420
+ idLower.includes("lite") ||
421
+ idLower.includes("nano")
422
+ ) {
423
+ tags.add("fast")
424
+ }
425
+
426
+ // 4. vision
427
+ if (
428
+ capabilities.vision === true ||
429
+ (Array.isArray(model.input) && model.input.includes("image")) ||
430
+ idLower.includes("vision") ||
431
+ idLower.includes("vl") ||
432
+ idLower.includes("pixtral") ||
433
+ idLower.includes("gemini") ||
434
+ idLower.includes("claude-3") ||
435
+ idLower.includes("4o")
436
+ ) {
437
+ tags.add("vision")
438
+ }
439
+
440
+ // 5. long-context (>= 128k tokens)
441
+ if (typeof model.contextWindow === "number" && model.contextWindow >= 128000) {
442
+ tags.add("long-context")
443
+ }
444
+
445
+ return [...tags].sort()
446
+ }
447
+
448
+ export function isCuratedModel(model) {
449
+ if (!model || typeof model !== "object") return false
450
+ if (model.lifecycle?.deprecated) return false
451
+ const id = String(model.id || "").toLowerCase()
452
+ if (/-(202\d{5}|202\d-\d{2}-\d{2}|\d{4})$/.test(id) && !id.includes("claude-3-7") && !id.includes("claude-3-5")) {
453
+ return false
454
+ }
455
+ if (id.includes("-0314") || id.includes("-0613") || id.includes("-1106") || id.includes("-0125")) {
456
+ return false
457
+ }
458
+
459
+ const curatedIds = [
460
+ "claude-3-7-sonnet", "claude-3-5-sonnet", "claude-3-5-haiku",
461
+ "gpt-4o", "gpt-4o-mini", "o1", "o3-mini",
462
+ "deepseek-chat", "deepseek-reasoner", "deepseek-v3", "deepseek-r1",
463
+ "gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash",
464
+ "llama-3.3-70b-versatile", "llama-3.1-8b-instant", "llama-3.1-70b-versatile",
465
+ "qwen-2.5-coder-32b", "qwen-2.5-72b", "qwq-32b",
466
+ "mistral-large", "codestral", "pixtral-large",
467
+ "kimi-k2.7-code"
468
+ ]
469
+
470
+ for (const c of curatedIds) {
471
+ if (id === c || id.endsWith("/" + c) || id.includes(c)) return true
472
+ }
473
+
474
+ const tags = model.tags || deriveModelTags(model)
475
+ if (tags.includes("coding") && tags.includes("reasoning") && tags.includes("long-context")) {
476
+ return true
477
+ }
478
+
479
+ return false
480
+ }
481
+
301
482
  export function normalizeModel(provider, row) {
302
483
  if (typeof row === 'string') {
303
484
  row = { id: row }
@@ -306,11 +487,15 @@ export function normalizeModel(provider, row) {
306
487
  const effectiveProvider = (typeof provider === 'string' && provider.trim())
307
488
  ? provider.trim()
308
489
  : (typeof row.provider === 'string' ? row.provider.trim() : '')
309
- const id = String(row.id ?? row.slug ?? row.model ?? '').trim()
490
+ const id = String(row.id ?? row.slug ?? row.model ?? (typeof row.name === 'string' && row.name.startsWith('models/') ? row.name.replace(/^models\//, '') : '')).trim()
310
491
  if (!id) return null
311
492
  const name = String(row.name ?? row.displayName ?? row.display_name ?? id).trim() || id
312
- const contextWindow = firstNumber(row, FIELD_ALIASES.contextWindow)
313
- const maxTokens = firstNumber(row, FIELD_ALIASES.maxTokens)
493
+ const rawContextWindow = firstNumber(row, FIELD_ALIASES.contextWindow)
494
+ const rawMaxOutputTokens = firstNumber(row, FIELD_ALIASES.maxOutputTokens)
495
+ const refLimits = lookupReferenceLimits(effectiveProvider || provider, id)
496
+ const contextWindow = rawContextWindow ?? refLimits?.contextWindow
497
+ const maxOutputTokens = rawMaxOutputTokens ?? refLimits?.maxOutputTokens
498
+ const maxTokens = maxOutputTokens ?? firstNumber(row, FIELD_ALIASES.maxTokens)
314
499
  const capabilities = normalizeCapabilities(row)
315
500
  const pricing = normalizePricing(row) ?? lookupReferencePricing(effectiveProvider || provider, id)
316
501
  const tags = normalizeTags(row)
@@ -318,6 +503,7 @@ export function normalizeModel(provider, row) {
318
503
  const lifecycle = normalizeLifecycle(row)
319
504
  const input = normalizeInput(row)
320
505
  const compat = normalizeCompat(effectiveProvider, row, id)
506
+ const reasoningConfig = normalizeReasoningConfig(effectiveProvider || provider, row, id, capabilities)
321
507
  const firstSeenAt = normalizeDate(row.firstSeenAt)
322
508
  const createdAt = normalizeDate(row.createdAt ?? row.created)
323
509
  return {
@@ -327,11 +513,13 @@ export function normalizeModel(provider, row) {
327
513
  ...(firstSeenAt === undefined ? {} : { firstSeenAt }),
328
514
  ...(createdAt === undefined ? {} : { createdAt }),
329
515
  ...(contextWindow === undefined ? {} : { contextWindow }),
516
+ ...(maxOutputTokens === undefined ? {} : { maxOutputTokens }),
330
517
  ...(maxTokens === undefined ? {} : { maxTokens }),
331
518
  ...(row.description ? { description: String(row.description) } : {}),
332
519
  ...(input === undefined ? {} : { input }),
333
520
  ...(compat === undefined ? {} : { compat }),
334
521
  ...(capabilities === undefined ? {} : { capabilities }),
522
+ ...(reasoningConfig === undefined ? {} : { reasoningConfig }),
335
523
  ...(pricing === undefined ? {} : { pricing }),
336
524
  ...(tags === undefined ? {} : { tags }),
337
525
  ...(aliases.length === 0 ? {} : { aliases }),
@@ -410,6 +598,7 @@ export const CLINEPASS_MODELS = Object.freeze([
410
598
  id: 'cline-pass/deepseek-v4-flash',
411
599
  name: 'DeepSeek V4 Flash',
412
600
  contextWindow: 200000,
601
+ maxOutputTokens: 8192,
413
602
  maxTokens: 8192,
414
603
  capabilities: { vision: true, code: true, reasoning: true },
415
604
  input: ['text', 'image'],
@@ -418,6 +607,7 @@ export const CLINEPASS_MODELS = Object.freeze([
418
607
  id: 'cline-pass/deepseek-v4-pro',
419
608
  name: 'DeepSeek V4 Pro',
420
609
  contextWindow: 200000,
610
+ maxOutputTokens: 8192,
421
611
  maxTokens: 8192,
422
612
  capabilities: { vision: true, code: true, reasoning: true },
423
613
  input: ['text', 'image'],
@@ -426,6 +616,7 @@ export const CLINEPASS_MODELS = Object.freeze([
426
616
  id: 'cline-pass/glm-5.2',
427
617
  name: 'GLM 5.2',
428
618
  contextWindow: 200000,
619
+ maxOutputTokens: 8192,
429
620
  maxTokens: 8192,
430
621
  capabilities: { code: true },
431
622
  input: ['text'],
@@ -434,6 +625,7 @@ export const CLINEPASS_MODELS = Object.freeze([
434
625
  id: 'cline-pass/kimi-k3',
435
626
  name: 'Kimi K3',
436
627
  contextWindow: 200000,
628
+ maxOutputTokens: 8192,
437
629
  maxTokens: 8192,
438
630
  capabilities: { reasoning: true },
439
631
  input: ['text'],
@@ -442,6 +634,7 @@ export const CLINEPASS_MODELS = Object.freeze([
442
634
  id: 'cline-pass/kimi-k2.7-code',
443
635
  name: 'Kimi K2.7 Code',
444
636
  contextWindow: 200000,
637
+ maxOutputTokens: 8192,
445
638
  maxTokens: 8192,
446
639
  capabilities: { code: true, tools: true },
447
640
  input: ['text'],
@@ -450,6 +643,7 @@ export const CLINEPASS_MODELS = Object.freeze([
450
643
  id: 'cline-pass/kimi-k2.6',
451
644
  name: 'Kimi K2.6',
452
645
  contextWindow: 200000,
646
+ maxOutputTokens: 8192,
453
647
  maxTokens: 8192,
454
648
  capabilities: { code: true },
455
649
  input: ['text'],
@@ -458,6 +652,7 @@ export const CLINEPASS_MODELS = Object.freeze([
458
652
  id: 'cline-pass/qwen3.7-max',
459
653
  name: 'Qwen 3.7 Max',
460
654
  contextWindow: 200000,
655
+ maxOutputTokens: 8192,
461
656
  maxTokens: 8192,
462
657
  capabilities: { vision: true },
463
658
  input: ['text', 'image'],
@@ -466,6 +661,7 @@ export const CLINEPASS_MODELS = Object.freeze([
466
661
  id: 'cline-pass/qwen3.7-plus',
467
662
  name: 'Qwen 3.7 Plus',
468
663
  contextWindow: 200000,
664
+ maxOutputTokens: 8192,
469
665
  maxTokens: 8192,
470
666
  capabilities: { vision: true },
471
667
  input: ['text', 'image'],
@@ -474,6 +670,7 @@ export const CLINEPASS_MODELS = Object.freeze([
474
670
  id: 'cline-pass/minimax-m3',
475
671
  name: 'MiniMax M3',
476
672
  contextWindow: 200000,
673
+ maxOutputTokens: 8192,
477
674
  maxTokens: 8192,
478
675
  capabilities: { reasoning: true },
479
676
  input: ['text'],
@@ -482,6 +679,7 @@ export const CLINEPASS_MODELS = Object.freeze([
482
679
  id: 'cline-pass/mimo-v2.5',
483
680
  name: 'MiMo V2.5',
484
681
  contextWindow: 200000,
682
+ maxOutputTokens: 8192,
485
683
  maxTokens: 8192,
486
684
  capabilities: { code: true },
487
685
  input: ['text'],
@@ -490,6 +688,7 @@ export const CLINEPASS_MODELS = Object.freeze([
490
688
  id: 'cline-pass/mimo-v2.5-pro',
491
689
  name: 'MiMo V2.5 Pro',
492
690
  contextWindow: 200000,
691
+ maxOutputTokens: 8192,
493
692
  maxTokens: 8192,
494
693
  capabilities: { code: true, reasoning: true },
495
694
  input: ['text'],
@@ -546,7 +745,8 @@ export function parseClinePassModels(includedInput) {
546
745
  id: `cline-pass/${idPart}`,
547
746
  name,
548
747
  contextWindow: 200000,
549
- maxTokens: 8192,
748
+ maxOutputTokens: 8192,
749
+ maxTokens: 8192,
550
750
  capabilities: {},
551
751
  input: ['text'],
552
752
  })
@@ -1,6 +1,13 @@
1
- function cleanTokenPrice(val) {
2
- if (typeof val !== 'number' || !Number.isFinite(val)) return 0
3
- return Math.round((val / 1000000) * 1e12) / 1e12
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ function cleanTokenPrice(val, { isPerToken = false } = {}) {
5
+ const num = typeof val === 'string' ? Number(val.trim()) : val
6
+ if (typeof num !== 'number' || !Number.isFinite(num) || num < 0) return 0
7
+ if (isPerToken) {
8
+ return Math.round(num * 1e12) / 1e12
9
+ }
10
+ return Math.round((num / 1000000) * 1e12) / 1e12
4
11
  }
5
12
 
6
13
  function perMillion(inputUSD, outputUSD, extra = {}) {
@@ -21,23 +28,23 @@ function perMillion(inputUSD, outputUSD, extra = {}) {
21
28
 
22
29
  export const REFERENCE_PRICING = Object.freeze({
23
30
  openai: {
24
- 'gpt-4o': perMillion(2.5, 10),
25
- 'gpt-4o-mini': perMillion(0.15, 0.6),
31
+ 'gpt-4o': perMillion(2.5, 10, { cacheReadUSD: 1.25 }),
32
+ 'gpt-4o-mini': perMillion(0.15, 0.6, { cacheReadUSD: 0.075 }),
26
33
  'gpt-4-turbo': perMillion(10, 30),
27
34
  'gpt-4': perMillion(30, 60),
28
35
  'gpt-3.5-turbo': perMillion(0.5, 1.5),
29
- 'o1': perMillion(15, 60),
36
+ 'o1': perMillion(15, 60, { cacheReadUSD: 7.5 }),
30
37
  'o1-preview': perMillion(15, 60),
31
- 'o1-mini': perMillion(1.1, 4.4),
32
- 'o3-mini': perMillion(1.1, 4.4),
38
+ 'o1-mini': perMillion(1.1, 4.4, { cacheReadUSD: 0.55 }),
39
+ 'o3-mini': perMillion(1.1, 4.4, { cacheReadUSD: 0.55 }),
33
40
  'chatgpt-4o-latest': perMillion(5, 15),
34
41
  },
35
42
  anthropic: {
36
- 'claude-3-7-sonnet': perMillion(3, 15),
37
- 'claude-3-5-sonnet': perMillion(3, 15),
38
- 'claude-3-5-haiku': perMillion(0.8, 4),
39
- 'claude-3-opus': perMillion(15, 75),
40
- 'claude-3-haiku': perMillion(0.25, 1.25),
43
+ 'claude-3-7-sonnet': perMillion(3, 15, { cacheReadUSD: 0.3, cacheWriteUSD: 3.75 }),
44
+ 'claude-3-5-sonnet': perMillion(3, 15, { cacheReadUSD: 0.3, cacheWriteUSD: 3.75 }),
45
+ 'claude-3-5-haiku': perMillion(0.8, 4, { cacheReadUSD: 0.08, cacheWriteUSD: 1 }),
46
+ 'claude-3-opus': perMillion(15, 75, { cacheReadUSD: 1.5, cacheWriteUSD: 18.75 }),
47
+ 'claude-3-haiku': perMillion(0.25, 1.25, { cacheReadUSD: 0.025, cacheWriteUSD: 0.3 }),
41
48
  },
42
49
  groq: {
43
50
  'llama-3.3-70b-versatile': perMillion(0.59, 0.79),
@@ -83,6 +90,22 @@ export const REFERENCE_PRICING = Object.freeze({
83
90
  },
84
91
  })
85
92
 
93
+ // Dynamic in-memory pricing store populated from OpenRouter / LiteLLM / disk cache
94
+ const DYNAMIC_PRICING = new Map()
95
+
96
+ export function getDynamicPricingMap() {
97
+ return DYNAMIC_PRICING
98
+ }
99
+
100
+ export function clearDynamicPricing() {
101
+ DYNAMIC_PRICING.clear()
102
+ }
103
+
104
+ export function setDynamicPricing(key, pricing) {
105
+ if (!key || typeof key !== 'string' || !pricing) return
106
+ DYNAMIC_PRICING.set(key.toLowerCase().trim(), Object.freeze({ ...pricing }))
107
+ }
108
+
86
109
  function cleanId(id) {
87
110
  if (typeof id !== 'string') return ''
88
111
  return id
@@ -100,16 +123,23 @@ export function lookupReferencePricing(provider, modelId) {
100
123
  const rawId = modelId.toLowerCase().trim()
101
124
  const cleaned = cleanId(rawId)
102
125
 
103
- // 1. Direct check in provider table (exact match or date-cleaned match)
126
+ // 1. Dynamic pricing cache check (provider:modelId or rawId or cleaned)
127
+ if (DYNAMIC_PRICING.size > 0) {
128
+ const dynScoped = provKey ? DYNAMIC_PRICING.get(`${provKey}:${rawId}`) || DYNAMIC_PRICING.get(`${provKey}:${cleaned}`) : undefined
129
+ if (dynScoped) return { ...dynScoped }
130
+ const dynRaw = DYNAMIC_PRICING.get(rawId) || DYNAMIC_PRICING.get(cleaned)
131
+ if (dynRaw) return { ...dynRaw }
132
+ }
133
+
134
+ // 2. Direct check in provider table (exact match or date-cleaned match)
104
135
  const table = REFERENCE_PRICING[provKey]
105
136
  if (table) {
106
137
  if (table[rawId]) return { ...table[rawId] }
107
138
  if (table[cleaned]) return { ...table[cleaned] }
108
- // If the provider has a dedicated pricing table, do not search other providers' tables
109
139
  return undefined
110
140
  }
111
141
 
112
- // 2. Vendor prefix check (e.g. "openai/gpt-4o" or "anthropic/claude-3-5-sonnet")
142
+ // 3. Vendor prefix check (e.g. "openai/gpt-4o" or "anthropic/claude-3-5-sonnet")
113
143
  const slashIdx = rawId.indexOf('/')
114
144
  if (slashIdx > 0) {
115
145
  const subProv = rawId.slice(0, slashIdx)
@@ -122,10 +152,113 @@ export function lookupReferencePricing(provider, modelId) {
122
152
  }
123
153
  }
124
154
 
125
- // 3. Fallback search across tables ONLY for unknown/aggregator providers without a dedicated table
155
+ // 4. Fallback search across tables ONLY for unknown/aggregator providers without a dedicated table
126
156
  for (const [p, t] of Object.entries(REFERENCE_PRICING)) {
127
157
  if (t[cleaned]) return { ...t[cleaned] }
128
158
  }
129
159
 
130
160
  return undefined
131
161
  }
162
+
163
+ export function parseOpenRouterPricing(modelsData) {
164
+ if (!Array.isArray(modelsData)) return []
165
+ const results = []
166
+ for (const item of modelsData) {
167
+ if (!item?.id || !item?.pricing) continue
168
+ const id = String(item.id).trim().toLowerCase()
169
+ const p = item.pricing
170
+ const inputPerToken = cleanTokenPrice(p.prompt, { isPerToken: true })
171
+ const outputPerToken = cleanTokenPrice(p.completion, { isPerToken: true })
172
+ const cacheReadPerToken = p.input_cache_read !== undefined || p.prompt_cached !== undefined
173
+ ? cleanTokenPrice(p.input_cache_read ?? p.prompt_cached, { isPerToken: true })
174
+ : undefined
175
+ const cacheWritePerToken = p.input_cache_write !== undefined
176
+ ? cleanTokenPrice(p.input_cache_write, { isPerToken: true })
177
+ : undefined
178
+
179
+ if (inputPerToken > 0 || outputPerToken > 0) {
180
+ const entry = {
181
+ inputPerToken,
182
+ outputPerToken,
183
+ ...(cacheReadPerToken !== undefined ? { cacheReadPerToken } : {}),
184
+ ...(cacheWritePerToken !== undefined ? { cacheWritePerToken } : {}),
185
+ currency: 'USD',
186
+ source: 'openrouter',
187
+ }
188
+ results.push({ id, entry })
189
+ }
190
+ }
191
+ return results
192
+ }
193
+
194
+ export async function fetchPricingUpdates({
195
+ timeoutMs = 15000,
196
+ fetchFn = globalThis.fetch,
197
+ cachePath,
198
+ logger,
199
+ } = {}) {
200
+ let updatedCount = 0
201
+ const controller = new AbortController()
202
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
203
+
204
+ try {
205
+ const res = await fetchFn('https://openrouter.ai/api/v1/models', {
206
+ signal: controller.signal,
207
+ headers: { 'Accept': 'application/json' },
208
+ })
209
+ if (res.ok) {
210
+ const json = await res.json()
211
+ const entries = parseOpenRouterPricing(json?.data)
212
+ for (const { id, entry } of entries) {
213
+ DYNAMIC_PRICING.set(id, Object.freeze(entry))
214
+ const clean = cleanId(id)
215
+ if (clean && clean !== id) {
216
+ DYNAMIC_PRICING.set(clean, Object.freeze(entry))
217
+ }
218
+ // Also index under vendor-prefixed subId
219
+ const slashIdx = id.indexOf('/')
220
+ if (slashIdx > 0) {
221
+ const subId = id.slice(slashIdx + 1)
222
+ DYNAMIC_PRICING.set(subId, Object.freeze(entry))
223
+ const subClean = cleanId(subId)
224
+ if (subClean && subClean !== subId) {
225
+ DYNAMIC_PRICING.set(subClean, Object.freeze(entry))
226
+ }
227
+ }
228
+ updatedCount++
229
+ }
230
+
231
+ if (cachePath && updatedCount > 0) {
232
+ try {
233
+ const serialized = JSON.stringify(Object.fromEntries(DYNAMIC_PRICING), null, 2)
234
+ fs.writeFileSync(cachePath, serialized, 'utf-8')
235
+ } catch (writeErr) {
236
+ logger?.warn?.(`[dsh-model-sync] failed to write pricing cache: ${writeErr?.message}`)
237
+ }
238
+ }
239
+
240
+ return { updated: updatedCount, source: 'openrouter', timestamp: Date.now() }
241
+ }
242
+ } catch (err) {
243
+ logger?.warn?.(`[dsh-model-sync] live pricing fetch failed: ${err?.message}, falling back to cache`)
244
+ } finally {
245
+ clearTimeout(timer)
246
+ }
247
+
248
+ // Fallback: try loading cached pricing from disk
249
+ if (cachePath && fs.existsSync(cachePath)) {
250
+ try {
251
+ const data = fs.readFileSync(cachePath, 'utf-8')
252
+ const parsed = JSON.parse(data)
253
+ for (const [k, v] of Object.entries(parsed)) {
254
+ DYNAMIC_PRICING.set(k.toLowerCase().trim(), Object.freeze(v))
255
+ updatedCount++
256
+ }
257
+ return { updated: updatedCount, source: 'cache', timestamp: Date.now() }
258
+ } catch (readErr) {
259
+ logger?.warn?.(`[dsh-model-sync] failed to read pricing cache: ${readErr?.message}`)
260
+ }
261
+ }
262
+
263
+ return { updated: 0, source: 'reference', timestamp: Date.now() }
264
+ }
@@ -1,4 +1,7 @@
1
1
  import { listApiKeyProviders } from './inventory.js'
2
+ import { lookupReferenceLimits, lookupReferenceReasoning, clampOutputTokens, adaptReasoningPayload } from './specs-database.js'
3
+ import { deriveModelTags, isCuratedModel } from './models.js'
4
+ import { lookupReferencePricing } from './pricing-database.js'
2
5
 
3
6
  export function createProviderService(ctx) {
4
7
  const readSettings = () => {
@@ -20,10 +23,80 @@ export function createProviderService(ctx) {
20
23
 
21
24
  const listProviders = () => listApiKeyProviders(readLlm(), readSettings())
22
25
 
26
+ const getProvider = (provider) => listProviders().find((row) => row.provider === provider) ?? null
27
+
28
+ const getLimits = (provider, modelId) => {
29
+ const p = getProvider(provider)
30
+ const catalog = Array.isArray(p?.models) ? p.models : []
31
+ const found = catalog.find((m) => (m?.id === modelId || m?.name === modelId))
32
+ if (found && (found.contextWindow !== undefined || found.maxOutputTokens !== undefined || found.maxTokens !== undefined)) {
33
+ return {
34
+ contextWindow: found.contextWindow,
35
+ maxOutputTokens: found.maxOutputTokens ?? found.maxTokens,
36
+ }
37
+ }
38
+ return lookupReferenceLimits(provider, modelId) ?? null
39
+ }
40
+
41
+ const clampCompletionTokens = (provider, modelId, requestedTokens) => {
42
+ const limits = getLimits(provider, modelId)
43
+ return clampOutputTokens(limits?.maxOutputTokens, requestedTokens)
44
+ }
45
+
46
+ const getReasoningConfig = (provider, modelId) => {
47
+ const p = getProvider(provider)
48
+ const catalog = Array.isArray(p?.models) ? p.models : []
49
+ const found = catalog.find((m) => (m?.id === modelId || m?.name === modelId))
50
+ if (found?.reasoningConfig) {
51
+ return found.reasoningConfig
52
+ }
53
+ return lookupReferenceReasoning(provider, modelId) ?? null
54
+ }
55
+
56
+ const adaptReasoning = (provider, modelId, options = {}) => {
57
+ const p = getProvider(provider)
58
+ const catalog = Array.isArray(p?.models) ? p.models : []
59
+ const found = catalog.find((m) => (m?.id === modelId || m?.name === modelId)) || {
60
+ provider,
61
+ id: modelId,
62
+ reasoningConfig: getReasoningConfig(provider, modelId),
63
+ }
64
+ return adaptReasoningPayload(found, options)
65
+ }
66
+
67
+ const getPricing = (provider, modelId) => {
68
+ const p = getProvider(provider)
69
+ const catalog = Array.isArray(p?.models) ? p.models : []
70
+ const found = catalog.find((m) => (m?.id === modelId || m?.name === modelId))
71
+ if (found?.pricing) return found.pricing
72
+ return lookupReferencePricing(provider, modelId) ?? null
73
+ }
74
+
75
+ const getTags = (provider, modelId) => {
76
+ const p = getProvider(provider)
77
+ const catalog = Array.isArray(p?.models) ? p.models : []
78
+ const found = catalog.find((m) => (m?.id === modelId || m?.name === modelId))
79
+ if (found?.tags && found.tags.length > 0) return found.tags
80
+ if (found) return deriveModelTags(found)
81
+ return deriveModelTags({ provider, id: modelId })
82
+ }
83
+
84
+ const isCurated = (provider, modelId) => {
85
+ const p = getProvider(provider)
86
+ const catalog = Array.isArray(p?.models) ? p.models : []
87
+ const found = catalog.find((m) => (m?.id === modelId || m?.name === modelId))
88
+ return isCuratedModel(found || { provider, id: modelId })
89
+ }
90
+
23
91
  return Object.freeze({
24
92
  listProviders,
25
- getProvider(provider) {
26
- return listProviders().find((row) => row.provider === provider) ?? null
27
- },
93
+ getProvider,
94
+ getLimits,
95
+ clampCompletionTokens,
96
+ getReasoningConfig,
97
+ adaptReasoningPayload: adaptReasoning,
98
+ getPricing,
99
+ getTags,
100
+ isCurated,
28
101
  })
29
102
  }
package/lib/reconcile.js CHANGED
@@ -1,4 +1,4 @@
1
- import { diffModels, normalizeModels, stableStringify } from './models.js'
1
+ import { diffModels, normalizeModels, stableStringify, deriveModelTags } from './models.js'
2
2
 
3
3
  function sameModel(left, right) {
4
4
  return stableStringify(left) === stableStringify(right)
@@ -30,8 +30,13 @@ export function reconcileModels(provider, current, discovered, {
30
30
  ...(row.compat || fresh.compat ? {
31
31
  compat: { ...(fresh.compat ?? {}), ...(row.compat ?? {}) },
32
32
  } : {}),
33
+ ...(row.reasoningConfig || fresh.reasoningConfig ? {
34
+ reasoningConfig: { ...(fresh.reasoningConfig ?? {}), ...(row.reasoningConfig ?? {}) },
35
+ } : {}),
33
36
  ...(fresh.input?.length ? { input: fresh.input } : row.input ? { input: row.input } : {}),
34
37
  }
38
+ const derivedTags = deriveModelTags(merged)
39
+ if (derivedTags.length > 0) merged.tags = derivedTags
35
40
  next.push(merged)
36
41
  } else if (renamedBefore.has(row.id)) {
37
42
  // An explicit provider alias is safe evidence that the old id was renamed.
@@ -42,7 +47,12 @@ export function reconcileModels(provider, current, discovered, {
42
47
  }
43
48
  const known = new Set(before.map((row) => row.id))
44
49
  for (const row of advertised) {
45
- if (!known.has(row.id)) next.push(row)
50
+ if (!known.has(row.id)) {
51
+ const copy = { ...row }
52
+ const derivedTags = deriveModelTags(copy)
53
+ if (derivedTags.length > 0) copy.tags = derivedTags
54
+ next.push(copy)
55
+ }
46
56
  }
47
57
  const diff = diffModels(before, next)
48
58
  return {