dsh-all-usage 1.1.1 → 1.1.3

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/pricing.js ADDED
@@ -0,0 +1,908 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ const PRICING_SCHEMA_VERSION = 1
4
+ const COST_SCHEMA_VERSION = 1
5
+ const MODEL_CATALOG_URL = 'https://models.dev/api.json'
6
+ const DEFAULT_SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000
7
+ const MAX_CATALOG_BYTES = 24 * 1024 * 1024
8
+ const MAX_PRICE_ENTRIES = 10000
9
+ // Hard candidate ceiling before the sorting-heavy selection: a pathological
10
+ // upstream catalog is rejected instead of consuming unbounded memory.
11
+ // Hard candidate ceiling before any normalization-heavy sort: a pathological
12
+ // upstream catalog is rejected instead of consuming unbounded memory.
13
+ const MAX_CATALOG_CANDIDATES = 30000
14
+ const MAX_OVERRIDES = 500
15
+ const MAX_MAPPINGS = 500
16
+ const MAX_CONTEXT_TIERS = 32
17
+ const RATE_KEYS = ['input', 'output', 'cacheRead', 'cacheWrite']
18
+ const INPUT_SEMANTICS = ['legacy', 'total', 'fresh']
19
+ const COST_STATUSES = ['priced', 'unpriced', 'ambiguous', 'unsupported']
20
+ const COST_FIELDS = ['input', 'output', 'cacheRead', 'cacheWrite', 'baseTotal', 'total']
21
+ const COST_ACCUMULATOR = Symbol('costAccumulator')
22
+ const COST_PARTS_CACHE = new WeakMap()
23
+ const OFFICIAL_PROVIDER_RULES = [
24
+ { providers: ['openai'], prefixes: ['gpt-', 'o1', 'o3', 'o4', 'o5'] },
25
+ { providers: ['anthropic'], prefixes: ['claude-'] },
26
+ { providers: ['google'], prefixes: ['gemini-', 'gemma-'] },
27
+ { providers: ['xai'], prefixes: ['grok-'] },
28
+ { providers: ['deepseek'], prefixes: ['deepseek-'] },
29
+ { providers: ['moonshotai', 'moonshot'], prefixes: ['kimi-', 'moonshot-'] },
30
+ { providers: ['qwen', 'alibaba'], prefixes: ['qwen'] },
31
+ { providers: ['zai', 'zhipuai', 'zhipu'], prefixes: ['glm-', 'chatglm-'] },
32
+ { providers: ['minimax'], prefixes: ['minimax-'] },
33
+ { providers: ['mistral'], prefixes: ['mistral-', 'mixtral-'] },
34
+ { providers: ['meta'], prefixes: ['llama-', 'meta-llama'] },
35
+ { providers: ['cohere'], prefixes: ['command-'] },
36
+ { providers: ['ai21'], prefixes: ['jamba-'] },
37
+ { providers: ['baidu'], prefixes: ['ernie-'] },
38
+ ]
39
+ const MAX_DECIMAL_DIGITS = 40
40
+ const MAX_DECIMAL_EXPONENT = 24
41
+
42
+ function isRecord(value) {
43
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
44
+ }
45
+
46
+ function finiteNumber(value) {
47
+ if (typeof value === 'number' && Number.isFinite(value)) return value
48
+ if (typeof value === 'string' && value.trim() !== '') {
49
+ const parsed = Number(value)
50
+ if (Number.isFinite(parsed)) return parsed
51
+ }
52
+ return null
53
+ }
54
+
55
+ function decimalParts(value) {
56
+ const raw = typeof value === 'number' ? String(value) : typeof value === 'string' ? value.trim().toLowerCase() : ''
57
+ const match = raw.match(/^(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/)
58
+ if (!match) return null
59
+ let digits = (match[1] || '') + (match[2] || '')
60
+ const exponent = match[3] ? Number(match[3]) : 0
61
+ if (!Number.isSafeInteger(exponent) || Math.abs(exponent) > MAX_DECIMAL_EXPONENT) return null
62
+ let scale = (match[2] || '').length - exponent
63
+ digits = digits.replace(/^0+(?=\d)/, '')
64
+ if (scale < 0) {
65
+ digits += '0'.repeat(-scale)
66
+ scale = 0
67
+ }
68
+ if (scale > digits.length) digits = '0'.repeat(scale - digits.length + 1) + digits
69
+ if (digits.length > MAX_DECIMAL_DIGITS) return null
70
+ while (scale > 0 && digits.length > 1 && digits.endsWith('0')) {
71
+ digits = digits.slice(0, -1)
72
+ scale -= 1
73
+ }
74
+ digits = digits.replace(/^0+(?=\d)/, '')
75
+ return { digits: BigInt(digits || '0'), scale }
76
+ }
77
+
78
+ function decimalText(value) {
79
+ const parts = decimalParts(value)
80
+ if (parts === null) return null
81
+ if (parts.digits === 0n) return '0'
82
+ const raw = parts.digits.toString()
83
+ if (parts.scale === 0) return raw
84
+ const padded = raw.padStart(parts.scale + 1, '0')
85
+ const split = padded.length - parts.scale
86
+ return padded.slice(0, split) + '.' + padded.slice(split)
87
+ }
88
+
89
+ function normalizedDecimalParts(parts) {
90
+ let digits = parts && typeof parts.digits === 'bigint' ? parts.digits : 0n
91
+ let scale = parts && Number.isSafeInteger(parts.scale) && parts.scale >= 0 ? parts.scale : 0
92
+ if (digits < 0n) digits = 0n
93
+ while (scale > 0 && digits !== 0n && digits % 10n === 0n) {
94
+ digits /= 10n
95
+ scale -= 1
96
+ }
97
+ return { digits, scale }
98
+ }
99
+
100
+ function decimalAccumulatorText(value) {
101
+ const parts = normalizedDecimalParts(value)
102
+ if (parts.digits === 0n) return '0'
103
+ const raw = parts.digits.toString()
104
+ if (parts.scale === 0) return raw
105
+ const padded = raw.padStart(parts.scale + 1, '0')
106
+ const split = padded.length - parts.scale
107
+ return padded.slice(0, split) + '.' + padded.slice(split)
108
+ }
109
+
110
+ function isCostAccumulator(value) {
111
+ return value !== null && typeof value === 'object' && value[COST_ACCUMULATOR] === true
112
+ }
113
+
114
+ function createCostAccumulator(currency = 'USD') {
115
+ const result = { currency, input: { digits: 0n, scale: 0 }, output: { digits: 0n, scale: 0 }, cacheRead: { digits: 0n, scale: 0 }, cacheWrite: { digits: 0n, scale: 0 }, baseTotal: { digits: 0n, scale: 0 }, total: { digits: 0n, scale: 0 }, pricedCalls: 0, unpricedCalls: 0, ambiguousCalls: 0, unsupportedCalls: 0 }
116
+ Object.defineProperty(result, COST_ACCUMULATOR, { value: true })
117
+ return result
118
+ }
119
+
120
+ function costAccumulatorParts(cost) {
121
+ if (isCostAccumulator(cost)) return cost
122
+ if (cost !== null && typeof cost === 'object') {
123
+ const cached = COST_PARTS_CACHE.get(cost)
124
+ if (cached !== undefined) return cached
125
+ }
126
+ const value = isRecord(cost) ? cost : {}
127
+ const status = COST_STATUSES.includes(value.status) ? value.status : 'unpriced'
128
+ const breakdown = isRecord(value.breakdown) ? value.breakdown : {}
129
+ const result = {
130
+ currency: typeof value.currency === 'string' && value.currency !== '' ? value.currency : 'USD',
131
+ status,
132
+ input: decimalParts(breakdown.input) || { digits: 0n, scale: 0 },
133
+ output: decimalParts(breakdown.output) || { digits: 0n, scale: 0 },
134
+ cacheRead: decimalParts(breakdown.cacheRead) || { digits: 0n, scale: 0 },
135
+ cacheWrite: decimalParts(breakdown.cacheWrite) || { digits: 0n, scale: 0 },
136
+ baseTotal: decimalParts(value.baseTotal) || { digits: 0n, scale: 0 },
137
+ total: decimalParts(value.total) || { digits: 0n, scale: 0 },
138
+ }
139
+ if (cost !== null && typeof cost === 'object') COST_PARTS_CACHE.set(cost, result)
140
+ return result
141
+ }
142
+
143
+ function addDecimalAccumulator(target, field, value, direction) {
144
+ const left = normalizedDecimalParts(target[field])
145
+ const right = normalizedDecimalParts(value)
146
+ const scale = Math.max(left.scale, right.scale)
147
+ let digits = left.digits * 10n ** BigInt(scale - left.scale) + BigInt(direction) * right.digits * 10n ** BigInt(scale - right.scale)
148
+ if (digits < 0n) digits = 0n
149
+ target[field] = normalizedDecimalParts({ digits, scale })
150
+ }
151
+
152
+ function addCostAccumulator(target, cost, direction = 1) {
153
+ if (!isCostAccumulator(target)) return target
154
+ if (isCostAccumulator(cost)) {
155
+ for (const field of COST_FIELDS) addDecimalAccumulator(target, field, cost[field], direction)
156
+ target.pricedCalls = Math.max(0, target.pricedCalls + direction * cost.pricedCalls)
157
+ target.unpricedCalls = Math.max(0, target.unpricedCalls + direction * cost.unpricedCalls)
158
+ target.ambiguousCalls = Math.max(0, target.ambiguousCalls + direction * cost.ambiguousCalls)
159
+ target.unsupportedCalls = Math.max(0, target.unsupportedCalls + direction * cost.unsupportedCalls)
160
+ return target
161
+ }
162
+ const value = costAccumulatorParts(cost)
163
+ if (value.status === 'priced') {
164
+ for (const field of COST_FIELDS) addDecimalAccumulator(target, field, value[field], direction)
165
+ target.pricedCalls = Math.max(0, target.pricedCalls + direction)
166
+ } else if (value.status === 'ambiguous') target.ambiguousCalls = Math.max(0, target.ambiguousCalls + direction)
167
+ else if (value.status === 'unsupported') target.unsupportedCalls = Math.max(0, target.unsupportedCalls + direction)
168
+ else target.unpricedCalls = Math.max(0, target.unpricedCalls + direction)
169
+ return target
170
+ }
171
+
172
+ function decimalAdd(left, right) {
173
+ const a = decimalParts(left) || { digits: 0n, scale: 0 }
174
+ const b = decimalParts(right) || { digits: 0n, scale: 0 }
175
+ const scale = Math.max(a.scale, b.scale)
176
+ const value = a.digits * 10n ** BigInt(scale - a.scale) + b.digits * 10n ** BigInt(scale - b.scale)
177
+ return decimalText(value.toString() + (scale > 0 ? 'e-' + scale : '')) || '0'
178
+ }
179
+
180
+ function decimalMultiply(left, right) {
181
+ const a = decimalParts(left) || { digits: 0n, scale: 0 }
182
+ const b = decimalParts(right) || { digits: 0n, scale: 0 }
183
+ return decimalText((a.digits * b.digits).toString() + ((a.scale + b.scale) > 0 ? 'e-' + (a.scale + b.scale) : '')) || '0'
184
+ }
185
+
186
+ function decimalSubtract(left, right) {
187
+ const a = decimalParts(left) || { digits: 0n, scale: 0 }
188
+ const b = decimalParts(right) || { digits: 0n, scale: 0 }
189
+ const scale = Math.max(a.scale, b.scale)
190
+ const value = a.digits * 10n ** BigInt(scale - a.scale) - b.digits * 10n ** BigInt(scale - b.scale)
191
+ if (value <= 0n) return '0'
192
+ return decimalText(value.toString() + (scale > 0 ? 'e-' + scale : '')) || '0'
193
+ }
194
+
195
+ function decimalGreaterThanZero(value) {
196
+ const parts = decimalParts(value)
197
+ return parts !== null && parts.digits > 0n
198
+ }
199
+
200
+ function nonNegativeDecimal(value, fallback = '0') {
201
+ const parsed = decimalText(value)
202
+ if (parsed === null || !decimalParts(parsed) || decimalParts(parsed).digits < 0n) return fallback
203
+ return parsed
204
+ }
205
+
206
+ function normalizeModelId(value) {
207
+ if (typeof value !== 'string') return ''
208
+ let normalized = value.trim()
209
+ const slash = normalized.lastIndexOf('/')
210
+ if (slash >= 0) normalized = normalized.slice(slash + 1)
211
+ normalized = normalized.split(':', 1)[0].trim().replace(/@/g, '-').toLowerCase()
212
+ normalized = normalized.replace(/\[1m\]$/i, '').trim()
213
+ return normalized
214
+ }
215
+
216
+ function pushUnique(list, value) {
217
+ if (typeof value === 'string' && value !== '' && !list.includes(value)) list.push(value)
218
+ }
219
+
220
+ function stripKnownNamespace(value) {
221
+ const claude = value.lastIndexOf('claude-')
222
+ if (claude > 0) return value.slice(claude)
223
+ for (const marker of ['openai.', 'anthropic.', 'google.', 'moonshot.', 'moonshotai.', 'bedrock.', 'global.']) {
224
+ if (value.startsWith(marker)) return value.slice(marker.length)
225
+ }
226
+ return null
227
+ }
228
+
229
+ function stripClaudeDesktopPrefix(value) {
230
+ const markers = ['abab', 'ark-code', 'arctic', 'astron', 'codex', 'command-r', 'deepseek', 'doubao', 'ernie', 'gemini', 'gemma', 'glm', 'gpt', 'grok', 'hermes', 'hy3', 'hunyuan', 'jamba', 'kimi', 'lfm', 'llama', 'longcat', 'mercury', 'mimo', 'minimax', 'mistral', 'mixtral', 'moonshot', 'nemotron', 'nova-', 'openai', 'qianfan', 'qwen', 'seed-', 'solar', 'stepfun']
231
+ if (!value.startsWith('claude-')) return null
232
+ const rest = value.slice('claude-'.length)
233
+ return markers.some((marker) => rest.startsWith(marker)) ? rest : null
234
+ }
235
+
236
+ function stripBedrockSuffix(value) {
237
+ const match = value.match(/^(.+)-v(\d+)$/)
238
+ return match ? match[1] : null
239
+ }
240
+
241
+ function stripDateSuffix(value) {
242
+ let match = value.match(/^(.+)-(\d{4}-\d{2}-\d{2})$/)
243
+ if (match) return match[1]
244
+ match = value.match(/^(.+)-(\d{8})$/)
245
+ if (match) return match[1]
246
+ match = value.match(/^(.+)-(\d{6})$/)
247
+ if (!match) return null
248
+ const month = Number(match[2].slice(2, 4))
249
+ const day = Number(match[2].slice(4, 6))
250
+ return month >= 1 && month <= 12 && day >= 1 && day <= 31 ? match[1] : null
251
+ }
252
+
253
+ function stripReasoningSuffix(value) {
254
+ for (const suffix of ['-minimal', '-low', '-medium', '-high', '-xhigh']) {
255
+ if (value.endsWith(suffix) && value.length > suffix.length) return value.slice(0, -suffix.length)
256
+ }
257
+ return null
258
+ }
259
+
260
+ function shouldTryPrefix(value) {
261
+ const dashCount = (value.match(/-/g) || []).length
262
+ if (value.startsWith('claude-')) return dashCount >= 3
263
+ if (['o1', 'o3', 'o4', 'o5'].some((prefix) => value.startsWith(prefix))) return dashCount >= 1
264
+ return ['gpt-', 'gemini-', 'deepseek-', 'qwen-', 'glm-', 'kimi-', 'minimax-'].some((prefix) => value.startsWith(prefix)) && dashCount >= 2
265
+ }
266
+
267
+ function modelPricingCandidates(value) {
268
+ const cleaned = normalizeModelId(value)
269
+ if (cleaned === '') return []
270
+ const candidates = []
271
+ const queue = [cleaned]
272
+ while (queue.length > 0) {
273
+ const candidate = queue.pop()
274
+ if (candidates.includes(candidate)) continue
275
+ pushUnique(candidates, candidate)
276
+ for (const next of [stripKnownNamespace(candidate), stripClaudeDesktopPrefix(candidate), stripBedrockSuffix(candidate), stripDateSuffix(candidate), stripReasoningSuffix(candidate)]) {
277
+ if (next !== null) queue.push(next)
278
+ }
279
+ if (candidate.startsWith('claude-') && candidate.includes('.')) queue.push(candidate.replace(/\./g, '-'))
280
+ }
281
+ return candidates
282
+ }
283
+
284
+ function normalizeProvider(value) {
285
+ return typeof value === 'string' ? value.trim().toLowerCase() : ''
286
+ }
287
+
288
+ function officialProviderIds(modelId) {
289
+ const providers = new Set()
290
+ for (const candidate of modelPricingCandidates(modelId)) {
291
+ for (const rule of OFFICIAL_PROVIDER_RULES) if (rule.prefixes.some((prefix) => candidate.startsWith(prefix))) for (const provider of rule.providers) providers.add(provider)
292
+ }
293
+ return providers
294
+ }
295
+
296
+ function identityModels(identity) {
297
+ const value = isRecord(identity) ? identity : {}
298
+ const result = []
299
+ for (const candidate of [value.actualModel, value.requestedModel, value.model, value.label]) {
300
+ const normalized = normalizeModelId(candidate)
301
+ if (normalized !== '' && !result.includes(normalized)) result.push(normalized)
302
+ }
303
+ return result
304
+ }
305
+
306
+ function identityKeyOf(identity) {
307
+ if (!isRecord(identity)) return ''
308
+ const key = typeof identity.identityKey === 'string' && identity.identityKey.trim() !== '' ? identity.identityKey : identity.usageIdentityKey
309
+ return typeof key === 'string' ? key.trim() : ''
310
+ }
311
+
312
+ function priceEntryKey(entry) {
313
+ return normalizeProvider(entry.providerId) + '\0' + normalizeModelId(entry.modelId)
314
+ }
315
+
316
+ function decimalRate(raw, keys, fallback = undefined) {
317
+ let value
318
+ for (const key of keys) {
319
+ if (raw && raw[key] !== undefined) {
320
+ value = raw[key]
321
+ break
322
+ }
323
+ }
324
+ if (value === undefined || value === null || value === '') value = fallback
325
+ return decimalText(value)
326
+ }
327
+
328
+ function normalizeContextTier(raw, fallbackRates, legacySize = null) {
329
+ if (!isRecord(raw)) return null
330
+ const descriptor = isRecord(raw.tier) ? raw.tier : raw
331
+ const type = legacySize === null ? (typeof descriptor.type === 'string' ? descriptor.type.trim().toLowerCase() : '') : 'context'
332
+ const sizeValue = legacySize === null ? descriptor.size : legacySize
333
+ const size = finiteNumber(sizeValue)
334
+ if (type !== 'context' || size === null || !Number.isSafeInteger(size) || size <= 0 || size > 1000000000) return null
335
+ const input = decimalRate(raw, ['input', 'inputPerMillion'], fallbackRates.input)
336
+ const output = decimalRate(raw, ['output', 'outputPerMillion'], fallbackRates.output)
337
+ const cacheRead = decimalRate(raw, ['cacheRead', 'cache_read', 'cacheReadPerMillion'], fallbackRates.cacheRead)
338
+ const cacheWrite = decimalRate(raw, ['cacheWrite', 'cache_write', 'cacheCreation', 'cacheWritePerMillion'], fallbackRates.cacheWrite)
339
+ if (input === null || output === null || cacheRead === null || cacheWrite === null) return null
340
+ if (decimalParts(input).digits < 0n || decimalParts(output).digits < 0n || decimalParts(cacheRead).digits < 0n || decimalParts(cacheWrite).digits < 0n) return null
341
+ return { type: 'context', size, input, output, cacheRead, cacheWrite }
342
+ }
343
+
344
+ function normalizePriceEntry(raw, sourceDefault = 'models.dev') {
345
+ if (!isRecord(raw)) return null
346
+ const modelId = normalizeModelId(raw.modelId || raw.id)
347
+ if (modelId === '') return null
348
+ const input = decimalRate(raw, ['input', 'inputPerMillion'])
349
+ const output = decimalRate(raw, ['output', 'outputPerMillion'])
350
+ if (input === null || output === null || decimalParts(input).digits < 0n || decimalParts(output).digits < 0n) return null
351
+ const cacheRead = decimalRate(raw, ['cacheRead', 'cache_read', 'cacheReadPerMillion'], '0')
352
+ const cacheWrite = decimalRate(raw, ['cacheWrite', 'cache_write', 'cacheCreation', 'cacheWritePerMillion'], '0')
353
+ if (cacheRead === null || cacheWrite === null || decimalParts(cacheRead).digits < 0n || decimalParts(cacheWrite).digits < 0n) return null
354
+ const fallbackRates = { input, output, cacheRead, cacheWrite }
355
+ const tiers = []
356
+ let tieredInvalid = false
357
+ const hasTierData = raw.tiers !== undefined || raw.context_over_200k !== undefined
358
+ if (raw.tiers !== undefined) {
359
+ if (!Array.isArray(raw.tiers)) tieredInvalid = true
360
+ else {
361
+ if (raw.tiers.length > MAX_CONTEXT_TIERS) tieredInvalid = true
362
+ for (const rawTier of raw.tiers.slice(0, MAX_CONTEXT_TIERS)) {
363
+ const tier = normalizeContextTier(rawTier, fallbackRates)
364
+ if (tier === null || tiers.some((existing) => existing.size === tier.size)) tieredInvalid = true
365
+ else tiers.push(tier)
366
+ }
367
+ }
368
+ }
369
+ if (raw.context_over_200k !== undefined && raw.tiers === undefined) {
370
+ const tier = normalizeContextTier(raw.context_over_200k, fallbackRates, 200000)
371
+ if (tier === null) tieredInvalid = true
372
+ else tiers.push(tier)
373
+ }
374
+ tiers.sort((left, right) => left.size - right.size)
375
+ const tiered = raw.tiered !== undefined ? raw.tiered === true : hasTierData || tiers.length > 0
376
+ if (!tiered) {
377
+ tiers.length = 0
378
+ tieredInvalid = false
379
+ } else if (tiers.length === 0) tieredInvalid = true
380
+ return {
381
+ providerId: typeof raw.providerId === 'string' ? raw.providerId.trim().toLowerCase() : '',
382
+ providerName: typeof raw.providerName === 'string' ? raw.providerName.trim().slice(0, 200) : '',
383
+ modelId,
384
+ displayName: typeof raw.displayName === 'string' && raw.displayName.trim() !== '' ? raw.displayName.trim().slice(0, 200) : modelId,
385
+ currency: typeof raw.currency === 'string' && raw.currency.trim() !== '' ? raw.currency.trim().toUpperCase() : 'USD',
386
+ input,
387
+ output,
388
+ cacheRead,
389
+ cacheWrite,
390
+ source: raw.source === 'manual' ? 'manual' : sourceDefault,
391
+ tiered,
392
+ ...(tiered ? { tiers, tieredInvalid } : {}),
393
+ reasoningRateAvailable: raw.reasoningRateAvailable === true || raw.reasoning !== undefined,
394
+ fetchedAt: Number.isFinite(raw.fetchedAt) ? raw.fetchedAt : 0,
395
+ }
396
+ }
397
+
398
+ function parseModelsDevCatalog(raw, fetchedAt = Date.now()) {
399
+ if (!isRecord(raw)) return { ok: false, error: 'catalog-not-object' }
400
+ const entries = []
401
+ for (const [providerKey, provider] of Object.entries(raw)) {
402
+ if (!isRecord(provider) || !isRecord(provider.models)) continue
403
+ const providerId = typeof provider.id === 'string' && provider.id.trim() !== '' ? provider.id.trim() : providerKey
404
+ const providerName = typeof provider.name === 'string' ? provider.name : providerId
405
+ for (const [modelKey, model] of Object.entries(provider.models)) {
406
+ if (!isRecord(model)) continue
407
+ const entry = normalizePriceEntry({
408
+ providerId,
409
+ providerName,
410
+ modelId: typeof model.id === 'string' ? model.id : modelKey,
411
+ displayName: model.name,
412
+ currency: 'USD',
413
+ input: model.cost && model.cost.input,
414
+ output: model.cost && model.cost.output,
415
+ cacheRead: model.cost && model.cost.cache_read,
416
+ cacheWrite: model.cost && model.cost.cache_write,
417
+ tiers: model.cost && model.cost.tiers,
418
+ context_over_200k: model.cost && model.cost.context_over_200k,
419
+ tiered: model.cost && model.cost.tiers !== undefined || model.cost && model.cost.context_over_200k !== undefined,
420
+ reasoningRateAvailable: model.cost && model.cost.reasoning !== undefined,
421
+ source: 'models.dev',
422
+ fetchedAt,
423
+ })
424
+ if (entry === null || entry.currency !== 'USD') continue
425
+ // Duplicate normalized keys are NOT resolved here: they are kept and
426
+ // resolved deterministically after the stable sort below.
427
+ entries.push(entry)
428
+ if (entries.length > MAX_CATALOG_CANDIDATES) return { ok: false, error: 'catalog-exceeds-candidate-limit', candidateCount: entries.length }
429
+ }
430
+ }
431
+ if (entries.length === 0) return { ok: false, error: 'catalog-has-no-priced-models' }
432
+ // Sort by the stable key first: upstream object order must not decide which
433
+ // models survive a large catalog or which conflicting duplicate is kept.
434
+ // Code-unit comparison: default-locale localeCompare can report ties for
435
+ // distinct strings (e.g. composed/decomposed accents), which would let
436
+ // upstream enumeration order decide the surviving entry.
437
+ entries.sort((left, right) => { const ka = priceEntryKey(left); const kb = priceEntryKey(right); return ka < kb ? -1 : ka > kb ? 1 : 0 })
438
+ // Deterministic duplicate selection: within one normalized key, compare only
439
+ // the small conflict group by their full content (fetchedAt excluded), so
440
+ // ordering swaps cannot change the surviving price.
441
+ const stableText = (entry) => {
442
+ const copy = Object.assign({}, entry)
443
+ delete copy.fetchedAt
444
+ if (typeof copy.providerId === 'string') copy.providerId = copy.providerId.trim().toLowerCase()
445
+ return JSON.stringify(copy)
446
+ }
447
+ const ordered = []
448
+ {
449
+ let index = 0
450
+ while (index < entries.length) {
451
+ const key = priceEntryKey(entries[index])
452
+ let end = index + 1
453
+ while (end < entries.length && priceEntryKey(entries[end]) === key) end += 1
454
+ let group = end - index > 1 ? entries.slice(index, end).sort((left, right) => { const la = stableText(left); const lb = stableText(right); return la < lb ? -1 : la > lb ? 1 : 0 }) : entries.slice(index, end)
455
+ ordered.push(group[0])
456
+ index = end
457
+ }
458
+ }
459
+ ordered.length = Math.min(ordered.length, MAX_PRICE_ENTRIES)
460
+ // The fetchedAt stamp differs on every fetch; exclude it from the content
461
+ // hash so identical catalog contents hash identically across syncs.
462
+ const canonical = JSON.stringify(ordered.map((entry) => {
463
+ if (entry.fetchedAt === undefined) return entry
464
+ const { fetchedAt, ...stable } = entry
465
+ return stable
466
+ }))
467
+ return {
468
+ ok: true,
469
+ catalog: {
470
+ schemaVersion: PRICING_SCHEMA_VERSION,
471
+ sourceUrl: MODEL_CATALOG_URL,
472
+ fetchedAt,
473
+ catalogHash: createHash('sha256').update(canonical).digest('hex'),
474
+ entries: ordered,
475
+ },
476
+ }
477
+ }
478
+
479
+ function normalizePricingState(raw) {
480
+ const value = isRecord(raw) ? raw : {}
481
+ const sync = isRecord(value.sync) ? value.sync : {}
482
+ const source = isRecord(value.source) ? value.source : {}
483
+ const normalizeEntries = (items, sourceDefault, limit) => (Array.isArray(items) ? items.slice(0, limit) : []).map((item) => normalizePriceEntry(item, sourceDefault)).filter((item) => item !== null)
484
+ const catalogEntries = normalizeEntries(value.catalogEntries || value.entries, 'models.dev', MAX_PRICE_ENTRIES)
485
+ const overrides = normalizeEntries(value.overrides, 'manual', MAX_OVERRIDES).map((entry) => ({ ...entry, source: 'manual' }))
486
+ const mappings = (Array.isArray(value.mappings) ? value.mappings : []).slice(0, MAX_MAPPINGS).map((mapping) => {
487
+ if (!isRecord(mapping)) return null
488
+ const provider = typeof mapping.provider === 'string' ? mapping.provider.trim() : ''
489
+ const model = normalizeModelId(mapping.model || mapping.modelId)
490
+ const catalogProviderId = typeof mapping.catalogProviderId === 'string' ? mapping.catalogProviderId.trim() : ''
491
+ const catalogModelId = normalizeModelId(mapping.catalogModelId || mapping.catalogModel)
492
+ const identityKey = (typeof mapping.identityKey === 'string' && mapping.identityKey.trim() !== '' ? mapping.identityKey : mapping.usageIdentityKey)
493
+ const normalizedIdentityKey = typeof identityKey === 'string' ? identityKey.trim().slice(0, 1024) : ''
494
+ if (provider === '' && model === '' && catalogProviderId === '' && catalogModelId === '' && normalizedIdentityKey === '') return null
495
+ return {
496
+ identityKey: normalizedIdentityKey,
497
+ provider,
498
+ model,
499
+ catalogProviderId,
500
+ catalogModelId,
501
+ inputTokenSemantics: INPUT_SEMANTICS.includes(mapping.inputTokenSemantics) ? mapping.inputTokenSemantics : 'fresh',
502
+ multiplier: decimalText(mapping.multiplier === undefined ? '1' : mapping.multiplier) || '1',
503
+ }
504
+ }).filter((mapping) => mapping !== null)
505
+ const providerAliases = {}
506
+ if (isRecord(value.providerAliases)) {
507
+ for (const [from, to] of Object.entries(value.providerAliases).slice(0, MAX_MAPPINGS)) {
508
+ if (typeof from === 'string' && typeof to === 'string' && from.trim() !== '' && to.trim() !== '') providerAliases[from.trim()] = to.trim()
509
+ }
510
+ }
511
+ const normalized = {
512
+ schemaVersion: PRICING_SCHEMA_VERSION,
513
+ source: {
514
+ url: typeof source.url === 'string' && source.url !== '' ? source.url : MODEL_CATALOG_URL,
515
+ fetchedAt: Number.isFinite(source.fetchedAt) ? source.fetchedAt : 0,
516
+ catalogHash: typeof source.catalogHash === 'string' ? source.catalogHash.slice(0, 128) : '',
517
+ lastError: typeof source.lastError === 'string' ? source.lastError.slice(0, 500) : '',
518
+ },
519
+ sync: {
520
+ autoEnabled: sync.autoEnabled === true,
521
+ intervalMs: Number.isFinite(sync.intervalMs) && sync.intervalMs >= 60 * 60 * 1000 ? sync.intervalMs : DEFAULT_SYNC_INTERVAL_MS,
522
+ lastAttemptAt: Number.isFinite(sync.lastAttemptAt) ? sync.lastAttemptAt : 0,
523
+ lastSuccessAt: Number.isFinite(sync.lastSuccessAt) ? sync.lastSuccessAt : 0,
524
+ lastError: typeof sync.lastError === 'string' ? sync.lastError.slice(0, 500) : '',
525
+ },
526
+ catalogEntries,
527
+ overrides,
528
+ mappings,
529
+ providerAliases,
530
+ }
531
+ const entries = normalized.overrides.concat(normalized.catalogEntries)
532
+ const exactIndex = new Map()
533
+ for (const entry of entries) {
534
+ const list = exactIndex.get(entry.modelId) || []
535
+ list.push(entry)
536
+ exactIndex.set(entry.modelId, list)
537
+ }
538
+ Object.defineProperty(normalized, '_entries', { value: entries, enumerable: false })
539
+ Object.defineProperty(normalized, '_exactIndex', { value: exactIndex, enumerable: false })
540
+ Object.defineProperty(normalized, '_normalized', { value: true, enumerable: false })
541
+ return normalized
542
+ }
543
+
544
+ function createEmptyPricingState() {
545
+ return normalizePricingState({})
546
+ }
547
+
548
+ function serializePricingState(state) {
549
+ const normalized = normalizePricingState(state)
550
+ return {
551
+ schemaVersion: normalized.schemaVersion,
552
+ source: { ...normalized.source },
553
+ sync: { ...normalized.sync },
554
+ catalogEntries: normalized.catalogEntries.map((entry) => ({ ...entry })),
555
+ overrides: normalized.overrides.map((entry) => ({ ...entry })),
556
+ mappings: normalized.mappings.map((mapping) => ({ ...mapping })),
557
+ providerAliases: { ...normalized.providerAliases },
558
+ }
559
+ }
560
+
561
+ function sameRates(left, right) {
562
+ if (left.input !== right.input || left.output !== right.output || left.cacheRead !== right.cacheRead || left.cacheWrite !== right.cacheWrite || left.currency !== right.currency || left.tiered !== right.tiered || left.tieredInvalid !== right.tieredInvalid) return false
563
+ const leftTiers = Array.isArray(left.tiers) ? left.tiers : []
564
+ const rightTiers = Array.isArray(right.tiers) ? right.tiers : []
565
+ if (leftTiers.length !== rightTiers.length) return false
566
+ return leftTiers.every((leftTier, index) => {
567
+ const rightTier = rightTiers[index]
568
+ return rightTier && leftTier.type === rightTier.type && leftTier.size === rightTier.size && leftTier.input === rightTier.input && leftTier.output === rightTier.output && leftTier.cacheRead === rightTier.cacheRead && leftTier.cacheWrite === rightTier.cacheWrite
569
+ })
570
+ }
571
+
572
+ function mappingMatches(mapping, identity) {
573
+ if (mapping.identityKey !== '') {
574
+ if (mapping.identityKey !== identityKeyOf(identity)) return false
575
+ return mapping.provider === '' || (isRecord(identity) && typeof identity.provider === 'string' && identity.provider.trim().toLowerCase() === mapping.provider.toLowerCase())
576
+ }
577
+ if (mapping.provider !== '' && (!isRecord(identity) || typeof identity.provider !== 'string' || identity.provider.trim().toLowerCase() !== mapping.provider.toLowerCase())) return false
578
+ const models = identityModels(identity)
579
+ return mapping.model !== '' && models.includes(mapping.model)
580
+ }
581
+
582
+ function findMappedEntry(mapping, state) {
583
+ if (mapping.catalogProviderId !== '' && mapping.catalogModelId !== '') {
584
+ return state.overrides.find((entry) => normalizeProvider(entry.providerId) === normalizeProvider(mapping.catalogProviderId) && entry.modelId === mapping.catalogModelId) || state.catalogEntries.find((entry) => normalizeProvider(entry.providerId) === normalizeProvider(mapping.catalogProviderId) && entry.modelId === mapping.catalogModelId) || null
585
+ }
586
+ if (mapping.catalogModelId !== '') {
587
+ const matches = state.overrides.concat(state.catalogEntries).filter((entry) => entry.modelId === mapping.catalogModelId)
588
+ return matches.length === 1 ? matches[0] : null
589
+ }
590
+ return null
591
+ }
592
+
593
+ function resolvePricing(identity, rawState) {
594
+ const state = rawState && rawState._normalized === true ? rawState : normalizePricingState(rawState)
595
+ const identityKey = identityKeyOf(identity)
596
+ const exactMapped = identityKey === '' ? undefined : state.mappings.find((mapping) => mapping.identityKey !== '' && mapping.identityKey === identityKey && mappingMatches(mapping, identity))
597
+ const mapped = exactMapped || state.mappings.find((mapping) => mapping.identityKey === '' && mappingMatches(mapping, identity))
598
+ const mappedEntry = mapped === undefined ? null : findMappedEntry(mapped, state)
599
+ if (mapped !== undefined && mappedEntry === null && (mapped.catalogModelId !== '' || mapped.catalogProviderId !== '')) {
600
+ return { status: 'unpriced', reason: 'mapping-target-not-found', pricingModel: mapped.catalogModelId || null, providerId: mapped.catalogProviderId || null }
601
+ }
602
+ const models = mappedEntry === null ? identityModels(identity) : [mappedEntry.modelId]
603
+ const pool = state._entries || state.overrides.concat(state.catalogEntries)
604
+ const exactIndex = state._exactIndex || new Map()
605
+ const officialProviders = new Set(models.flatMap((model) => Array.from(officialProviderIds(model))))
606
+ const choose = (entries, model, candidate, exact) => entries.filter((entry) => {
607
+ if (mappedEntry !== null && entry !== mappedEntry) return false
608
+ if (entry.currency !== 'USD') return false
609
+ if (entry.source === 'manual') return true
610
+ return officialProviders.has(normalizeProvider(entry.providerId))
611
+ }).map((entry) => ({ entry, model, candidate, exact }))
612
+ for (const model of models) {
613
+ const manualMatches = []
614
+ const officialMatches = []
615
+ for (const [index, id] of modelPricingCandidates(model).entries()) {
616
+ const exactMatches = choose(exactIndex.get(id) || [], model, id, true)
617
+ manualMatches.push(...exactMatches.filter((match) => match.entry.source === 'manual').map((match) => ({ ...match, rank: index * 2 })))
618
+ officialMatches.push(...exactMatches.filter((match) => match.entry.source !== 'manual').map((match) => ({ ...match, rank: index * 2 })))
619
+ if (shouldTryPrefix(id)) {
620
+ const prefixMatches = choose(pool.filter((entry) => entry.modelId.startsWith(id + '-')), model, id, false)
621
+ manualMatches.push(...prefixMatches.filter((match) => match.entry.source === 'manual').map((match) => ({ ...match, rank: index * 2 + 1 })))
622
+ officialMatches.push(...prefixMatches.filter((match) => match.entry.source !== 'manual').map((match) => ({ ...match, rank: index * 2 + 1 })))
623
+ }
624
+ }
625
+ const matches = manualMatches.length > 0 ? manualMatches : officialMatches
626
+ if (matches.length === 0) continue
627
+ matches.sort((a, b) => a.rank - b.rank || a.entry.modelId.length - b.entry.modelId.length)
628
+ const best = matches[0]
629
+ const sameRank = matches.filter((match) => match.rank === best.rank)
630
+ const unique = []
631
+ for (const match of sameRank) if (!unique.some((entry) => sameRates(entry, match.entry))) unique.push(match.entry)
632
+ if (unique.length > 1) return { status: 'ambiguous', reason: 'multiple-official-prices', pricingModel: best.entry.modelId, providerId: null, candidates: unique.map((entry) => ({ providerId: entry.providerId, modelId: entry.modelId })) }
633
+ const entry = unique[0] || best.entry
634
+ const tiered = entry.tiered === true
635
+ const tiers = Array.isArray(entry.tiers) ? entry.tiers.map((tier) => ({ ...tier })) : []
636
+ const tieredSupported = !tiered || (entry.tieredInvalid !== true && tiers.length > 0)
637
+ return {
638
+ status: tieredSupported ? 'priced' : 'unsupported',
639
+ reason: tieredSupported ? '' : 'tiered-pricing-not-modeled',
640
+ pricingModel: entry.modelId,
641
+ providerId: entry.providerId || null,
642
+ providerName: entry.providerName || null,
643
+ displayName: entry.displayName,
644
+ currency: entry.currency,
645
+ rates: { input: entry.input, output: entry.output, cacheRead: entry.cacheRead, cacheWrite: entry.cacheWrite },
646
+ source: entry.source,
647
+ tiered,
648
+ ...(tiered ? { tiers, tieredInvalid: entry.tieredInvalid === true } : {}),
649
+ reasoningRateAvailable: entry.reasoningRateAvailable,
650
+ inputTokenSemantics: mapped && INPUT_SEMANTICS.includes(mapped.inputTokenSemantics) ? mapped.inputTokenSemantics : 'fresh',
651
+ multiplier: mapped ? mapped.multiplier : '1',
652
+ }
653
+ }
654
+ const model = models[0] || null
655
+ if (officialProviders.size === 0) return { status: 'unsupported', reason: 'official-provider-unknown', pricingModel: model, providerId: null }
656
+ return { status: 'unpriced', reason: 'official-price-not-found', pricingModel: model, providerId: null }
657
+ }
658
+
659
+ function tokenCount(value) {
660
+ const parsed = finiteNumber(value)
661
+ if (parsed === null || parsed <= 0) return 0n
662
+ return BigInt(Math.trunc(parsed))
663
+ }
664
+
665
+ function costPerMillion(tokens, rate) {
666
+ const tokenValue = tokenCount(tokens)
667
+ const rateParts = decimalParts(rate) || { digits: 0n, scale: 0 }
668
+ const numerator = tokenValue * rateParts.digits
669
+ const scale = rateParts.scale + 6
670
+ return decimalText(numerator.toString() + (scale > 0 ? 'e-' + scale : '')) || '0'
671
+ }
672
+
673
+ function contextTokenCount(input, cacheRead, cacheWrite, inputSemantics) {
674
+ const values = inputSemantics === 'total' ? [input] : inputSemantics === 'legacy' ? [input, cacheWrite] : [input, cacheRead, cacheWrite]
675
+ let total = 0
676
+ for (const value of values) {
677
+ const tokens = Math.max(0, Math.trunc(value))
678
+ if (!Number.isFinite(tokens) || total > Number.MAX_SAFE_INTEGER - tokens) return Number.MAX_SAFE_INTEGER
679
+ total += tokens
680
+ }
681
+ return total
682
+ }
683
+
684
+ function validContextTier(tier, previousSize = 0) {
685
+ if (!isRecord(tier) || tier.type !== 'context' || !Number.isSafeInteger(tier.size) || tier.size <= previousSize || tier.size > 1000000000) return false
686
+ return RATE_KEYS.every((key) => decimalText(tier[key]) !== null)
687
+ }
688
+
689
+ function validContextTierSchedule(tiers) {
690
+ if (!Array.isArray(tiers) || tiers.length === 0) return false
691
+ let previousSize = 0
692
+ for (const tier of tiers) {
693
+ if (!validContextTier(tier, previousSize)) return false
694
+ previousSize = tier.size
695
+ }
696
+ return true
697
+ }
698
+
699
+ function selectContextTier(tiers, contextTokens) {
700
+ let selected = null
701
+ for (const tier of tiers) {
702
+ // models.dev defines size as the point where the next band starts.
703
+ if (contextTokens > tier.size) selected = tier
704
+ else break
705
+ }
706
+ return selected
707
+ }
708
+
709
+ function calculateCost(values, resolved) {
710
+ const input = finiteNumber(values && values.input) || 0
711
+ const output = finiteNumber(values && values.output) || 0
712
+ const cacheRead = finiteNumber(values && values.cacheRead) || 0
713
+ const cacheWrite = finiteNumber(values && values.cacheWrite) || 0
714
+ const inputSemantics = INPUT_SEMANTICS.includes(resolved && resolved.inputTokenSemantics) ? resolved.inputTokenSemantics : 'fresh'
715
+ const billableInput = inputSemantics === 'total' ? Math.max(0, input - cacheRead - cacheWrite) : inputSemantics === 'legacy' ? Math.max(0, input - cacheRead) : input
716
+ const billableOutput = output
717
+ const tiered = resolved && resolved.tiered === true
718
+ const tiers = tiered && Array.isArray(resolved.tiers) ? resolved.tiers : []
719
+ const tierScheduleValid = !tiered || validContextTierSchedule(tiers)
720
+ const base = {
721
+ schemaVersion: COST_SCHEMA_VERSION,
722
+ pricingMode: 'official-model',
723
+ status: tierScheduleValid && resolved && COST_STATUSES.includes(resolved.status) ? resolved.status : tiered ? 'unsupported' : 'unpriced',
724
+ currency: resolved && resolved.currency ? resolved.currency : 'USD',
725
+ source: resolved && resolved.source ? resolved.source : 'none',
726
+ pricingModel: resolved && resolved.pricingModel ? resolved.pricingModel : null,
727
+ providerId: resolved && resolved.providerId ? resolved.providerId : null,
728
+ inputTokenSemantics: inputSemantics,
729
+ multiplier: decimalText(resolved && resolved.multiplier !== undefined ? resolved.multiplier : '1') || '1',
730
+ billableInputTokens: Math.trunc(billableInput),
731
+ billableOutputTokens: Math.trunc(billableOutput),
732
+ rates: resolved && resolved.rates ? { ...resolved.rates } : { input: '0', output: '0', cacheRead: '0', cacheWrite: '0' },
733
+ breakdown: { input: '0', output: '0', cacheRead: '0', cacheWrite: '0' },
734
+ baseTotal: '0',
735
+ total: '0',
736
+ reason: tiered && !tierScheduleValid ? 'tiered-pricing-not-modeled' : resolved && typeof resolved.reason === 'string' ? resolved.reason : 'model-not-found',
737
+ tiered,
738
+ reasoningRateAvailable: resolved && resolved.reasoningRateAvailable === true,
739
+ }
740
+ if (base.status !== 'priced') return base
741
+ if (tiered) {
742
+ const contextTokens = contextTokenCount(input, cacheRead, cacheWrite, inputSemantics)
743
+ const selectedTier = selectContextTier(tiers, contextTokens)
744
+ const selectedRates = selectedTier || base.rates
745
+ base.rates = { input: selectedRates.input, output: selectedRates.output, cacheRead: selectedRates.cacheRead, cacheWrite: selectedRates.cacheWrite }
746
+ base.contextTokens = contextTokens
747
+ base.selectedTier = { type: 'context', size: selectedTier ? selectedTier.size : 0 }
748
+ }
749
+ base.breakdown.input = costPerMillion(base.billableInputTokens, base.rates.input)
750
+ base.breakdown.output = costPerMillion(base.billableOutputTokens, base.rates.output)
751
+ base.breakdown.cacheRead = costPerMillion(cacheRead, base.rates.cacheRead)
752
+ base.breakdown.cacheWrite = costPerMillion(cacheWrite, base.rates.cacheWrite)
753
+ base.baseTotal = decimalAdd(decimalAdd(base.breakdown.input, base.breakdown.output), decimalAdd(base.breakdown.cacheRead, base.breakdown.cacheWrite))
754
+ base.total = decimalMultiply(base.baseTotal, base.multiplier)
755
+ return base
756
+ }
757
+
758
+ function emptyCostAggregate(currency = 'USD') {
759
+ return { currency, input: '0', output: '0', cacheRead: '0', cacheWrite: '0', baseTotal: '0', total: '0', pricedCalls: 0, unpricedCalls: 0, ambiguousCalls: 0, unsupportedCalls: 0 }
760
+ }
761
+
762
+ function addCostAggregate(target, cost) {
763
+ if (isCostAccumulator(target)) return addCostAccumulator(target, cost)
764
+ const value = isRecord(cost) ? cost : {}
765
+ const status = COST_STATUSES.includes(value.status) ? value.status : 'unpriced'
766
+ if (status === 'priced') {
767
+ target.input = decimalAdd(target.input, value.breakdown && value.breakdown.input)
768
+ target.output = decimalAdd(target.output, value.breakdown && value.breakdown.output)
769
+ target.cacheRead = decimalAdd(target.cacheRead, value.breakdown && value.breakdown.cacheRead)
770
+ target.cacheWrite = decimalAdd(target.cacheWrite, value.breakdown && value.breakdown.cacheWrite)
771
+ target.baseTotal = decimalAdd(target.baseTotal, value.baseTotal)
772
+ target.total = decimalAdd(target.total, value.total)
773
+ target.pricedCalls += 1
774
+ } else if (status === 'ambiguous') target.ambiguousCalls += 1
775
+ else if (status === 'unsupported') target.unsupportedCalls += 1
776
+ else target.unpricedCalls += 1
777
+ return target
778
+ }
779
+
780
+ function serializeCostAggregate(cost) {
781
+ const value = cost || emptyCostAggregate()
782
+ const accumulator = isCostAccumulator(value)
783
+ const text = (field) => accumulator ? decimalAccumulatorText(value[field]) : decimalText(value[field]) || '0'
784
+ return {
785
+ currency: typeof value.currency === 'string' && value.currency !== '' ? value.currency : 'USD',
786
+ input: text('input'),
787
+ output: text('output'),
788
+ cacheRead: text('cacheRead'),
789
+ cacheWrite: text('cacheWrite'),
790
+ baseTotal: text('baseTotal'),
791
+ total: text('total'),
792
+ pricedCalls: Number.isFinite(value.pricedCalls) ? value.pricedCalls : 0,
793
+ unpricedCalls: Number.isFinite(value.unpricedCalls) ? value.unpricedCalls : 0,
794
+ ambiguousCalls: Number.isFinite(value.ambiguousCalls) ? value.ambiguousCalls : 0,
795
+ unsupportedCalls: Number.isFinite(value.unsupportedCalls) ? value.unsupportedCalls : 0,
796
+ }
797
+ }
798
+
799
+ function normalizeCostSnapshot(raw) {
800
+ if (!isRecord(raw) || !COST_STATUSES.includes(raw.status)) return null
801
+ const rates = isRecord(raw.rates) ? raw.rates : {}
802
+ const breakdown = isRecord(raw.breakdown) ? raw.breakdown : {}
803
+ const fields = ['input', 'output', 'cacheRead', 'cacheWrite']
804
+ const normalizedRates = {}
805
+ const normalizedBreakdown = {}
806
+ for (const field of fields) {
807
+ const rate = decimalText(rates[field])
808
+ const cost = decimalText(breakdown[field])
809
+ if (rate === null || cost === null) return null
810
+ normalizedRates[field] = rate
811
+ normalizedBreakdown[field] = cost
812
+ }
813
+ const baseTotal = decimalText(raw.baseTotal)
814
+ const total = decimalText(raw.total)
815
+ const multiplier = decimalText(raw.multiplier)
816
+ if (baseTotal === null || total === null || multiplier === null) return null
817
+ let contextTokens = null
818
+ if (raw.contextTokens !== undefined) {
819
+ const parsed = finiteNumber(raw.contextTokens)
820
+ if (parsed === null || parsed < 0 || !Number.isSafeInteger(parsed)) return null
821
+ contextTokens = parsed
822
+ }
823
+ let selectedTier = null
824
+ if (raw.selectedTier !== undefined && raw.selectedTier !== null) {
825
+ if (!isRecord(raw.selectedTier) || raw.selectedTier.type !== 'context' || !Number.isSafeInteger(raw.selectedTier.size) || raw.selectedTier.size < 0 || raw.selectedTier.size > 1000000000) return null
826
+ selectedTier = { type: 'context', size: raw.selectedTier.size }
827
+ }
828
+ return {
829
+ schemaVersion: COST_SCHEMA_VERSION,
830
+ pricingMode: raw.pricingMode === 'official-model' ? 'official-model' : 'legacy-provider-aware',
831
+ status: raw.status,
832
+ currency: typeof raw.currency === 'string' ? raw.currency : 'USD',
833
+ source: typeof raw.source === 'string' ? raw.source : 'none',
834
+ pricingModel: typeof raw.pricingModel === 'string' ? raw.pricingModel : null,
835
+ providerId: typeof raw.providerId === 'string' ? raw.providerId : null,
836
+ inputTokenSemantics: INPUT_SEMANTICS.includes(raw.inputTokenSemantics) ? raw.inputTokenSemantics : 'fresh',
837
+ multiplier,
838
+ billableInputTokens: Number.isFinite(raw.billableInputTokens) ? Math.max(0, Math.trunc(raw.billableInputTokens)) : 0,
839
+ billableOutputTokens: Number.isFinite(raw.billableOutputTokens) ? Math.max(0, Math.trunc(raw.billableOutputTokens)) : 0,
840
+ ...(contextTokens === null ? {} : { contextTokens }),
841
+ ...(selectedTier === null ? {} : { selectedTier }),
842
+ rates: normalizedRates,
843
+ breakdown: normalizedBreakdown,
844
+ baseTotal,
845
+ total,
846
+ reason: typeof raw.reason === 'string' ? raw.reason.slice(0, 200) : '',
847
+ tiered: raw.tiered === true,
848
+ reasoningRateAvailable: raw.reasoningRateAvailable === true,
849
+ }
850
+ }
851
+
852
+ async function fetchModelsDevCatalog(fetchImpl = globalThis.fetch, options = {}) {
853
+ if (typeof fetchImpl !== 'function') return { ok: false, error: 'fetch-unavailable' }
854
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 15000
855
+ const url = typeof options.url === 'string' && options.url !== '' ? options.url : MODEL_CATALOG_URL
856
+ let controller = null
857
+ let timer = null
858
+ try {
859
+ if (typeof AbortController === 'function') {
860
+ controller = new AbortController()
861
+ timer = setTimeout(() => controller.abort(), timeoutMs)
862
+ }
863
+ const response = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: controller ? controller.signal : undefined })
864
+ if (!response || response.ok !== true) return { ok: false, error: 'http-' + String(response && response.status || 0) }
865
+ const length = response.headers && typeof response.headers.get === 'function' ? Number(response.headers.get('content-length')) : 0
866
+ if (Number.isFinite(length) && length > MAX_CATALOG_BYTES) return { ok: false, error: 'catalog-too-large' }
867
+ const text = await response.text()
868
+ if (new TextEncoder().encode(text).length > MAX_CATALOG_BYTES) return { ok: false, error: 'catalog-too-large' }
869
+ let parsed
870
+ try { parsed = JSON.parse(text.replace(/^\uFEFF/, '')) } catch (err) { return { ok: false, error: 'catalog-invalid-json' } }
871
+ return parseModelsDevCatalog(parsed, Date.now())
872
+ } catch (err) {
873
+ return { ok: false, error: 'catalog-fetch-failed' }
874
+ } finally {
875
+ if (timer !== null) clearTimeout(timer)
876
+ }
877
+ }
878
+
879
+ export {
880
+ COST_SCHEMA_VERSION,
881
+ COST_STATUSES,
882
+ DEFAULT_SYNC_INTERVAL_MS,
883
+ INPUT_SEMANTICS,
884
+ MODEL_CATALOG_URL,
885
+ PRICING_SCHEMA_VERSION,
886
+ RATE_KEYS,
887
+ addCostAggregate,
888
+ addCostAccumulator,
889
+ calculateCost,
890
+ createCostAccumulator,
891
+ createEmptyPricingState,
892
+ decimalAdd,
893
+ decimalMultiply,
894
+ decimalSubtract,
895
+ decimalText,
896
+ emptyCostAggregate,
897
+ fetchModelsDevCatalog,
898
+ isCostAccumulator,
899
+ modelPricingCandidates,
900
+ officialProviderIds,
901
+ normalizeCostSnapshot,
902
+ normalizeModelId,
903
+ normalizePricingState,
904
+ parseModelsDevCatalog,
905
+ resolvePricing,
906
+ serializeCostAggregate,
907
+ serializePricingState,
908
+ }