@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.
- package/README.md +21 -0
- package/README.ru.md +21 -0
- package/README.zh.md +21 -0
- package/lib/adapter-registry.js +1 -0
- package/lib/capability-prober.js +339 -0
- package/lib/client.js +318 -9
- package/lib/http.js +206 -0
- package/lib/inventory.js +1 -0
- package/lib/models.js +114 -0
- package/lib/ollama-manager.js +330 -0
- package/lib/pricing-database.js +150 -17
- package/lib/provider-service.js +43 -0
- package/lib/reconcile.js +9 -2
- package/lib/scheduler.js +7 -0
- package/lib/synchronizer-prober.js +117 -35
- package/lib/synchronizer.js +92 -1
- package/package.json +1 -1
package/lib/pricing-database.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
+
}
|
package/lib/provider-service.js
CHANGED
|
@@ -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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|