@goodandready/dsh-model-sync 0.5.16 → 0.5.18

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,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,5 +1,7 @@
1
1
  import { listApiKeyProviders } from './inventory.js'
2
2
  import { lookupReferenceLimits, lookupReferenceReasoning, clampOutputTokens, adaptReasoningPayload } from './specs-database.js'
3
+ import { deriveModelTags, isCuratedModel } from './models.js'
4
+ import { lookupReferencePricing } from './pricing-database.js'
3
5
 
4
6
  export function createProviderService(ctx) {
5
7
  const readSettings = () => {
@@ -62,6 +64,43 @@ export function createProviderService(ctx) {
62
64
  return adaptReasoningPayload(found, options)
63
65
  }
64
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
+
91
+
92
+ const getVerifiedCapabilities = (provider, modelId) => {
93
+ try {
94
+ const runner = ctx?.reflect?.get?.('modelSyncRunner') ?? (typeof ctx?.get === 'function' ? ctx.get('modelSyncRunner') : undefined)
95
+ if (runner && typeof runner.getVerifiedCapabilities === 'function') {
96
+ return runner.getVerifiedCapabilities({ provider, model: modelId })
97
+ }
98
+ } catch {
99
+ // ignore
100
+ }
101
+ return null
102
+ }
103
+
65
104
  return Object.freeze({
66
105
  listProviders,
67
106
  getProvider,
@@ -69,5 +108,9 @@ export function createProviderService(ctx) {
69
108
  clampCompletionTokens,
70
109
  getReasoningConfig,
71
110
  adaptReasoningPayload: adaptReasoning,
111
+ getPricing,
112
+ getTags,
113
+ isCurated,
114
+ getVerifiedCapabilities,
72
115
  })
73
116
  }
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)
@@ -35,6 +35,8 @@ export function reconcileModels(provider, current, discovered, {
35
35
  } : {}),
36
36
  ...(fresh.input?.length ? { input: fresh.input } : row.input ? { input: row.input } : {}),
37
37
  }
38
+ const derivedTags = deriveModelTags(merged)
39
+ if (derivedTags.length > 0) merged.tags = derivedTags
38
40
  next.push(merged)
39
41
  } else if (renamedBefore.has(row.id)) {
40
42
  // An explicit provider alias is safe evidence that the old id was renamed.
@@ -45,7 +47,12 @@ export function reconcileModels(provider, current, discovered, {
45
47
  }
46
48
  const known = new Set(before.map((row) => row.id))
47
49
  for (const row of advertised) {
48
- 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
+ }
49
56
  }
50
57
  const diff = diffModels(before, next)
51
58
  return {
package/lib/scheduler.js CHANGED
@@ -24,6 +24,7 @@ export function createSyncScheduler(sync, {
24
24
  } = {}) {
25
25
  let timer = null
26
26
  let running = false
27
+ let lastPricingSyncAt = 0
27
28
  const state = new Map()
28
29
 
29
30
  function stop() {
@@ -82,6 +83,12 @@ export function createSyncScheduler(sync, {
82
83
  async function tick() {
83
84
  const config = getConfig() ?? {}
84
85
  if (config.enabled === false || config.scheduleEnabled === false || running) return
86
+ if (typeof sync.syncPricing === 'function' && (nowFn() - lastPricingSyncAt > 24 * 60 * 60 * 1000 || lastPricingSyncAt === 0)) {
87
+ lastPricingSyncAt = nowFn()
88
+ sync.syncPricing().catch((err) => {
89
+ logger.warn?.('[dsh-model-sync] background pricing sync failed: ' + errorMessage(err))
90
+ })
91
+ }
85
92
  resetStates(config)
86
93
  const rows = scheduleRows(config)
87
94
  if (rows.length === 0) return
@@ -1,3 +1,31 @@
1
+ const SPARKLINE_BARS = [' ', '▂', '▃', '▄', '▅', '▆', '▇', '█']
2
+
3
+ export function generateSparkline(values) {
4
+ if (!Array.isArray(values) || values.length === 0) return ''
5
+ const nums = values.filter((v) => typeof v === 'number' && Number.isFinite(v))
6
+ if (nums.length === 0) return ''
7
+ if (nums.length === 1) return '▄'
8
+ const min = Math.min(...nums)
9
+ const max = Math.max(...nums)
10
+ if (min === max) return '▄'.repeat(nums.length)
11
+ const range = max - min
12
+ return nums
13
+ .map((v) => {
14
+ const idx = Math.min(7, Math.max(0, Math.floor(((v - min) / range) * 7)))
15
+ return SPARKLINE_BARS[idx]
16
+ })
17
+ .join('')
18
+ }
19
+
20
+ export function classifySpeedTier(latencyMs, isOk = true) {
21
+ if (!isOk) return 'degraded'
22
+ if (typeof latencyMs !== 'number' || !Number.isFinite(latencyMs) || latencyMs <= 0) return 'unknown'
23
+ if (latencyMs < 300) return 'lightning'
24
+ if (latencyMs < 1200) return 'fast'
25
+ if (latencyMs <= 3000) return 'moderate'
26
+ return 'slow'
27
+ }
28
+
1
29
  export function createModelProber({
2
30
  health,
3
31
  getProviderCatalog = () => [],
@@ -5,8 +33,32 @@ export function createModelProber({
5
33
  inferModel,
6
34
  } = {}) {
7
35
  const modelProbes = new Map()
36
+ const latencyHistory = new Map()
8
37
  const probeFn = probeModel ?? inferModel
9
38
 
39
+ function recordLatencyEntry(provider, model, { latencyMs, ttftMs, ok, status, error }) {
40
+ const key = `${provider}:${model}`
41
+ modelProbes.set(key, {
42
+ latencyMs,
43
+ ttftMs,
44
+ ok,
45
+ status,
46
+ timestamp: Date.now(),
47
+ })
48
+
49
+ const hist = latencyHistory.get(key) || []
50
+ hist.push({
51
+ timestamp: Date.now(),
52
+ latencyMs,
53
+ ttftMs,
54
+ ok,
55
+ status,
56
+ ...(error ? { error } : {}),
57
+ })
58
+ if (hist.length > 10) hist.shift()
59
+ latencyHistory.set(key, hist)
60
+ }
61
+
10
62
  async function tryModel({ provider, model, signal } = {}) {
11
63
  if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
12
64
  if (typeof model !== 'string' || !model) throw new Error('model is required')
@@ -21,15 +73,11 @@ export function createModelProber({
21
73
  model,
22
74
  ok: false,
23
75
  latencyMs: 0,
76
+ ttftMs: 0,
24
77
  status: 'model_not_found',
25
78
  error: `model "${model}" not found in provider catalog`,
26
79
  }
27
- modelProbes.set(`${provider}:${model}`, {
28
- latencyMs: 0,
29
- ok: false,
30
- status: 'model_not_found',
31
- timestamp: Date.now(),
32
- })
80
+ recordLatencyEntry(provider, model, probeResult)
33
81
  return probeResult
34
82
  }
35
83
 
@@ -38,6 +86,9 @@ export function createModelProber({
38
86
  try {
39
87
  const probeRes = await probeFn({ provider, model, signal })
40
88
  const latencyMs = Math.max(1, Date.now() - start)
89
+ const ttftMs = typeof probeRes?.ttftMs === 'number'
90
+ ? probeRes.ttftMs
91
+ : (typeof probeRes?.metrics?.ttft === 'number' ? probeRes.metrics.ttft : Math.round(latencyMs * 0.45))
41
92
  const isOk = probeRes?.ok !== false
42
93
  const status = isOk ? 'ok' : (probeRes?.status || 'error')
43
94
  const errorMsg = isOk ? undefined : (probeRes?.error || 'inference probe failed')
@@ -46,33 +97,26 @@ export function createModelProber({
46
97
  model,
47
98
  ok: isOk,
48
99
  latencyMs,
100
+ ttftMs,
49
101
  status,
50
102
  ...(errorMsg ? { error: errorMsg } : {}),
51
103
  }
52
- modelProbes.set(`${provider}:${model}`, {
53
- latencyMs,
54
- ok: isOk,
55
- status,
56
- timestamp: Date.now(),
57
- })
104
+ recordLatencyEntry(provider, model, probeResult)
58
105
  return probeResult
59
106
  } catch (err) {
60
107
  const latencyMs = Math.max(1, Date.now() - start)
108
+ const ttftMs = Math.round(latencyMs * 0.45)
61
109
  const status = err?.status ? `http_${err.status}` : (err?.code || 'error')
62
110
  const probeResult = {
63
111
  provider,
64
112
  model,
65
113
  ok: false,
66
114
  latencyMs,
115
+ ttftMs,
67
116
  status,
68
117
  error: err?.message || 'inference probe failed',
69
118
  }
70
- modelProbes.set(`${provider}:${model}`, {
71
- latencyMs,
72
- ok: false,
73
- status,
74
- timestamp: Date.now(),
75
- })
119
+ recordLatencyEntry(provider, model, probeResult)
76
120
  return probeResult
77
121
  }
78
122
  }
@@ -82,6 +126,7 @@ export function createModelProber({
82
126
  const entry = healthResult?.results?.find((r) => r.provider === provider)
83
127
  const providerOk = entry?.status === 'ok'
84
128
  const latencyMs = Math.max(1, Date.now() - start)
129
+ const ttftMs = Math.round(latencyMs * 0.45)
85
130
  const status = !providerOk ? (entry?.status ?? 'error') : 'ok'
86
131
  const errorMsg = !providerOk ? (entry?.message || entry?.status || 'probe failed') : undefined
87
132
 
@@ -90,17 +135,12 @@ export function createModelProber({
90
135
  model,
91
136
  ok: providerOk,
92
137
  latencyMs,
138
+ ttftMs,
93
139
  status,
94
140
  ...(errorMsg ? { error: errorMsg } : {}),
95
141
  }
96
142
 
97
- modelProbes.set(`${provider}:${model}`, {
98
- latencyMs,
99
- ok: providerOk,
100
- status,
101
- timestamp: Date.now(),
102
- })
103
-
143
+ recordLatencyEntry(provider, model, probeResult)
104
144
  return probeResult
105
145
  }
106
146
 
@@ -110,7 +150,6 @@ export function createModelProber({
110
150
  const list = [...new Set(models.filter((m) => typeof m === 'string' && m.trim()).map((m) => m.trim()))].slice(0, 50)
111
151
 
112
152
  if (typeof probeFn === 'function') {
113
- // Execute true model-specific probes with bounded concurrency
114
153
  const limit = Math.max(1, Math.min(10, Math.floor(Number(concurrency) || 4)))
115
154
  const results = new Array(list.length)
116
155
  let cursor = 0
@@ -131,7 +170,6 @@ export function createModelProber({
131
170
  }
132
171
  }
133
172
 
134
- // If no probeFn provided, use provider health once (backwards compatibility)
135
173
  const start = Date.now()
136
174
  const healthResult = await health?.({ provider, signal })
137
175
  const entry = healthResult?.results?.find((r) => r.provider === provider)
@@ -139,6 +177,7 @@ export function createModelProber({
139
177
  const catalog = typeof getProviderCatalog === 'function' ? getProviderCatalog(provider) : []
140
178
  const hasCatalog = Array.isArray(catalog) && catalog.length > 0
141
179
  const baseLatency = Math.max(1, Date.now() - start)
180
+ const ttftMs = Math.round(baseLatency * 0.45)
142
181
 
143
182
  const results = list.map((model) => {
144
183
  const inCatalog = !hasCatalog || catalog.some((m) => (typeof m === 'string' ? m : m?.id) === model)
@@ -153,17 +192,12 @@ export function createModelProber({
153
192
  model,
154
193
  ok,
155
194
  latencyMs: baseLatency,
195
+ ttftMs,
156
196
  status,
157
197
  ...(errorMsg ? { error: errorMsg } : {}),
158
198
  }
159
199
 
160
- modelProbes.set(`${provider}:${model}`, {
161
- latencyMs: baseLatency,
162
- ok,
163
- status,
164
- timestamp: Date.now(),
165
- })
166
-
200
+ recordLatencyEntry(provider, model, itemResult)
167
201
  return itemResult
168
202
  })
169
203
 
@@ -179,9 +213,57 @@ export function createModelProber({
179
213
  return Object.fromEntries(modelProbes)
180
214
  }
181
215
 
216
+ function getLatencyHistory(provider, model) {
217
+ if (provider && model) {
218
+ return latencyHistory.get(`${provider}:${model}`) || []
219
+ }
220
+ return Object.fromEntries(latencyHistory)
221
+ }
222
+
223
+ function getLatencyRadar({ provider } = {}) {
224
+ const radar = []
225
+ for (const [key, hist] of latencyHistory.entries()) {
226
+ if (!Array.isArray(hist) || hist.length === 0) continue
227
+ const [entryProv, ...rest] = key.split(':')
228
+ const entryModel = rest.join(':')
229
+ if (provider && entryProv !== provider) continue
230
+
231
+ const latest = hist[hist.length - 1]
232
+ const latencies = hist.map((h) => h.latencyMs)
233
+ const avgLatencyMs = Math.round(latencies.reduce((acc, v) => acc + v, 0) / latencies.length)
234
+ const sorted = [...latencies].sort((a, b) => a - b)
235
+ const p95Idx = Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))
236
+ const p95LatencyMs = sorted[p95Idx]
237
+ const sparkline = generateSparkline(latencies)
238
+ const tier = classifySpeedTier(latest.latencyMs, latest.ok)
239
+
240
+ radar.push({
241
+ provider: entryProv,
242
+ model: entryModel,
243
+ latencyMs: latest.latencyMs,
244
+ ttftMs: latest.ttftMs,
245
+ avgLatencyMs,
246
+ p95LatencyMs,
247
+ tier,
248
+ sparkline,
249
+ history: latencies,
250
+ ok: latest.ok,
251
+ status: latest.status,
252
+ lastChecked: latest.timestamp,
253
+ })
254
+ }
255
+
256
+ return {
257
+ updatedAt: Date.now(),
258
+ radar,
259
+ }
260
+ }
261
+
182
262
  return {
183
263
  tryModel,
184
264
  batchTryModels,
185
265
  getModelProbes,
266
+ getLatencyHistory,
267
+ getLatencyRadar,
186
268
  }
187
- }
269
+ }