dsh-all-usage 1.1.2 → 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/CHANGELOG.md +66 -0
- package/README.md +193 -19
- package/fixtures/usage-events.json +172 -0
- package/lib/aggregation.js +1002 -0
- package/lib/balance.js +112 -0
- package/lib/client.js +1 -2906
- package/lib/http.js +305 -0
- package/lib/index.js +2 -2119
- package/lib/ledger.js +464 -0
- package/lib/plugin.js +276 -0
- package/lib/pricing-runtime.js +282 -0
- package/lib/pricing.js +299 -36
- package/lib/session-sync.js +589 -0
- package/lib/usage-core.js +127 -0
- package/package.json +28 -3
- package/scripts/replay-fixture.mjs +155 -0
package/lib/pricing.js
CHANGED
|
@@ -6,11 +6,20 @@ const MODEL_CATALOG_URL = 'https://models.dev/api.json'
|
|
|
6
6
|
const DEFAULT_SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000
|
|
7
7
|
const MAX_CATALOG_BYTES = 24 * 1024 * 1024
|
|
8
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
|
|
9
14
|
const MAX_OVERRIDES = 500
|
|
10
15
|
const MAX_MAPPINGS = 500
|
|
16
|
+
const MAX_CONTEXT_TIERS = 32
|
|
11
17
|
const RATE_KEYS = ['input', 'output', 'cacheRead', 'cacheWrite']
|
|
12
18
|
const INPUT_SEMANTICS = ['legacy', 'total', 'fresh']
|
|
13
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()
|
|
14
23
|
const OFFICIAL_PROVIDER_RULES = [
|
|
15
24
|
{ providers: ['openai'], prefixes: ['gpt-', 'o1', 'o3', 'o4', 'o5'] },
|
|
16
25
|
{ providers: ['anthropic'], prefixes: ['claude-'] },
|
|
@@ -77,6 +86,89 @@ function decimalText(value) {
|
|
|
77
86
|
return padded.slice(0, split) + '.' + padded.slice(split)
|
|
78
87
|
}
|
|
79
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
|
+
|
|
80
172
|
function decimalAdd(left, right) {
|
|
81
173
|
const a = decimalParts(left) || { digits: 0n, scale: 0 }
|
|
82
174
|
const b = decimalParts(right) || { digits: 0n, scale: 0 }
|
|
@@ -212,30 +304,81 @@ function identityModels(identity) {
|
|
|
212
304
|
}
|
|
213
305
|
|
|
214
306
|
function identityKeyOf(identity) {
|
|
215
|
-
|
|
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() : ''
|
|
216
310
|
}
|
|
217
311
|
|
|
218
312
|
function priceEntryKey(entry) {
|
|
219
313
|
return normalizeProvider(entry.providerId) + '\0' + normalizeModelId(entry.modelId)
|
|
220
314
|
}
|
|
221
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
|
+
|
|
222
344
|
function normalizePriceEntry(raw, sourceDefault = 'models.dev') {
|
|
223
345
|
if (!isRecord(raw)) return null
|
|
224
346
|
const modelId = normalizeModelId(raw.modelId || raw.id)
|
|
225
347
|
if (modelId === '') return null
|
|
226
|
-
const input =
|
|
227
|
-
const output =
|
|
348
|
+
const input = decimalRate(raw, ['input', 'inputPerMillion'])
|
|
349
|
+
const output = decimalRate(raw, ['output', 'outputPerMillion'])
|
|
228
350
|
if (input === null || output === null || decimalParts(input).digits < 0n || decimalParts(output).digits < 0n) return null
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
if (value === undefined || value === null || value === '') return '0'
|
|
232
|
-
return decimalText(value)
|
|
233
|
-
}
|
|
234
|
-
const cacheRead = optionalDecimal(raw.cacheRead, raw.cacheReadPerMillion)
|
|
235
|
-
const cacheWrite = optionalDecimal(raw.cacheWrite !== undefined ? raw.cacheWrite : raw.cacheCreation, raw.cacheWritePerMillion)
|
|
351
|
+
const cacheRead = decimalRate(raw, ['cacheRead', 'cache_read', 'cacheReadPerMillion'], '0')
|
|
352
|
+
const cacheWrite = decimalRate(raw, ['cacheWrite', 'cache_write', 'cacheCreation', 'cacheWritePerMillion'], '0')
|
|
236
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
|
|
237
380
|
return {
|
|
238
|
-
providerId: typeof raw.providerId === 'string' ? raw.providerId.trim() : '',
|
|
381
|
+
providerId: typeof raw.providerId === 'string' ? raw.providerId.trim().toLowerCase() : '',
|
|
239
382
|
providerName: typeof raw.providerName === 'string' ? raw.providerName.trim().slice(0, 200) : '',
|
|
240
383
|
modelId,
|
|
241
384
|
displayName: typeof raw.displayName === 'string' && raw.displayName.trim() !== '' ? raw.displayName.trim().slice(0, 200) : modelId,
|
|
@@ -245,7 +388,8 @@ function normalizePriceEntry(raw, sourceDefault = 'models.dev') {
|
|
|
245
388
|
cacheRead,
|
|
246
389
|
cacheWrite,
|
|
247
390
|
source: raw.source === 'manual' ? 'manual' : sourceDefault,
|
|
248
|
-
tiered
|
|
391
|
+
tiered,
|
|
392
|
+
...(tiered ? { tiers, tieredInvalid } : {}),
|
|
249
393
|
reasoningRateAvailable: raw.reasoningRateAvailable === true || raw.reasoning !== undefined,
|
|
250
394
|
fetchedAt: Number.isFinite(raw.fetchedAt) ? raw.fetchedAt : 0,
|
|
251
395
|
}
|
|
@@ -254,7 +398,6 @@ function normalizePriceEntry(raw, sourceDefault = 'models.dev') {
|
|
|
254
398
|
function parseModelsDevCatalog(raw, fetchedAt = Date.now()) {
|
|
255
399
|
if (!isRecord(raw)) return { ok: false, error: 'catalog-not-object' }
|
|
256
400
|
const entries = []
|
|
257
|
-
const seen = new Set()
|
|
258
401
|
for (const [providerKey, provider] of Object.entries(raw)) {
|
|
259
402
|
if (!isRecord(provider) || !isRecord(provider.models)) continue
|
|
260
403
|
const providerId = typeof provider.id === 'string' && provider.id.trim() !== '' ? provider.id.trim() : providerKey
|
|
@@ -271,22 +414,56 @@ function parseModelsDevCatalog(raw, fetchedAt = Date.now()) {
|
|
|
271
414
|
output: model.cost && model.cost.output,
|
|
272
415
|
cacheRead: model.cost && model.cost.cache_read,
|
|
273
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,
|
|
274
419
|
tiered: model.cost && model.cost.tiers !== undefined || model.cost && model.cost.context_over_200k !== undefined,
|
|
275
420
|
reasoningRateAvailable: model.cost && model.cost.reasoning !== undefined,
|
|
276
421
|
source: 'models.dev',
|
|
277
422
|
fetchedAt,
|
|
278
423
|
})
|
|
279
424
|
if (entry === null || entry.currency !== 'USD') continue
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
seen.add(key)
|
|
425
|
+
// Duplicate normalized keys are NOT resolved here: they are kept and
|
|
426
|
+
// resolved deterministically after the stable sort below.
|
|
283
427
|
entries.push(entry)
|
|
284
|
-
if (entries.length
|
|
428
|
+
if (entries.length > MAX_CATALOG_CANDIDATES) return { ok: false, error: 'catalog-exceeds-candidate-limit', candidateCount: entries.length }
|
|
285
429
|
}
|
|
286
|
-
if (entries.length >= MAX_PRICE_ENTRIES) break
|
|
287
430
|
}
|
|
288
431
|
if (entries.length === 0) return { ok: false, error: 'catalog-has-no-priced-models' }
|
|
289
|
-
|
|
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
|
+
}))
|
|
290
467
|
return {
|
|
291
468
|
ok: true,
|
|
292
469
|
catalog: {
|
|
@@ -294,7 +471,7 @@ function parseModelsDevCatalog(raw, fetchedAt = Date.now()) {
|
|
|
294
471
|
sourceUrl: MODEL_CATALOG_URL,
|
|
295
472
|
fetchedAt,
|
|
296
473
|
catalogHash: createHash('sha256').update(canonical).digest('hex'),
|
|
297
|
-
entries,
|
|
474
|
+
entries: ordered,
|
|
298
475
|
},
|
|
299
476
|
}
|
|
300
477
|
}
|
|
@@ -312,9 +489,11 @@ function normalizePricingState(raw) {
|
|
|
312
489
|
const model = normalizeModelId(mapping.model || mapping.modelId)
|
|
313
490
|
const catalogProviderId = typeof mapping.catalogProviderId === 'string' ? mapping.catalogProviderId.trim() : ''
|
|
314
491
|
const catalogModelId = normalizeModelId(mapping.catalogModelId || mapping.catalogModel)
|
|
315
|
-
|
|
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
|
|
316
495
|
return {
|
|
317
|
-
identityKey:
|
|
496
|
+
identityKey: normalizedIdentityKey,
|
|
318
497
|
provider,
|
|
319
498
|
model,
|
|
320
499
|
catalogProviderId,
|
|
@@ -380,10 +559,22 @@ function serializePricingState(state) {
|
|
|
380
559
|
}
|
|
381
560
|
|
|
382
561
|
function sameRates(left, right) {
|
|
383
|
-
|
|
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
|
+
})
|
|
384
570
|
}
|
|
385
571
|
|
|
386
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
|
|
387
578
|
const models = identityModels(identity)
|
|
388
579
|
return mapping.model !== '' && models.includes(mapping.model)
|
|
389
580
|
}
|
|
@@ -401,7 +592,9 @@ function findMappedEntry(mapping, state) {
|
|
|
401
592
|
|
|
402
593
|
function resolvePricing(identity, rawState) {
|
|
403
594
|
const state = rawState && rawState._normalized === true ? rawState : normalizePricingState(rawState)
|
|
404
|
-
const
|
|
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))
|
|
405
598
|
const mappedEntry = mapped === undefined ? null : findMappedEntry(mapped, state)
|
|
406
599
|
if (mapped !== undefined && mappedEntry === null && (mapped.catalogModelId !== '' || mapped.catalogProviderId !== '')) {
|
|
407
600
|
return { status: 'unpriced', reason: 'mapping-target-not-found', pricingModel: mapped.catalogModelId || null, providerId: mapped.catalogProviderId || null }
|
|
@@ -438,9 +631,12 @@ function resolvePricing(identity, rawState) {
|
|
|
438
631
|
for (const match of sameRank) if (!unique.some((entry) => sameRates(entry, match.entry))) unique.push(match.entry)
|
|
439
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 })) }
|
|
440
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)
|
|
441
637
|
return {
|
|
442
|
-
status: 'priced',
|
|
443
|
-
reason: '',
|
|
638
|
+
status: tieredSupported ? 'priced' : 'unsupported',
|
|
639
|
+
reason: tieredSupported ? '' : 'tiered-pricing-not-modeled',
|
|
444
640
|
pricingModel: entry.modelId,
|
|
445
641
|
providerId: entry.providerId || null,
|
|
446
642
|
providerName: entry.providerName || null,
|
|
@@ -448,7 +644,8 @@ function resolvePricing(identity, rawState) {
|
|
|
448
644
|
currency: entry.currency,
|
|
449
645
|
rates: { input: entry.input, output: entry.output, cacheRead: entry.cacheRead, cacheWrite: entry.cacheWrite },
|
|
450
646
|
source: entry.source,
|
|
451
|
-
tiered
|
|
647
|
+
tiered,
|
|
648
|
+
...(tiered ? { tiers, tieredInvalid: entry.tieredInvalid === true } : {}),
|
|
452
649
|
reasoningRateAvailable: entry.reasoningRateAvailable,
|
|
453
650
|
inputTokenSemantics: mapped && INPUT_SEMANTICS.includes(mapped.inputTokenSemantics) ? mapped.inputTokenSemantics : 'fresh',
|
|
454
651
|
multiplier: mapped ? mapped.multiplier : '1',
|
|
@@ -473,6 +670,42 @@ function costPerMillion(tokens, rate) {
|
|
|
473
670
|
return decimalText(numerator.toString() + (scale > 0 ? 'e-' + scale : '')) || '0'
|
|
474
671
|
}
|
|
475
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
|
+
|
|
476
709
|
function calculateCost(values, resolved) {
|
|
477
710
|
const input = finiteNumber(values && values.input) || 0
|
|
478
711
|
const output = finiteNumber(values && values.output) || 0
|
|
@@ -481,10 +714,13 @@ function calculateCost(values, resolved) {
|
|
|
481
714
|
const inputSemantics = INPUT_SEMANTICS.includes(resolved && resolved.inputTokenSemantics) ? resolved.inputTokenSemantics : 'fresh'
|
|
482
715
|
const billableInput = inputSemantics === 'total' ? Math.max(0, input - cacheRead - cacheWrite) : inputSemantics === 'legacy' ? Math.max(0, input - cacheRead) : input
|
|
483
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)
|
|
484
720
|
const base = {
|
|
485
721
|
schemaVersion: COST_SCHEMA_VERSION,
|
|
486
722
|
pricingMode: 'official-model',
|
|
487
|
-
status: resolved && COST_STATUSES.includes(resolved.status) ? resolved.status : 'unpriced',
|
|
723
|
+
status: tierScheduleValid && resolved && COST_STATUSES.includes(resolved.status) ? resolved.status : tiered ? 'unsupported' : 'unpriced',
|
|
488
724
|
currency: resolved && resolved.currency ? resolved.currency : 'USD',
|
|
489
725
|
source: resolved && resolved.source ? resolved.source : 'none',
|
|
490
726
|
pricingModel: resolved && resolved.pricingModel ? resolved.pricingModel : null,
|
|
@@ -497,11 +733,19 @@ function calculateCost(values, resolved) {
|
|
|
497
733
|
breakdown: { input: '0', output: '0', cacheRead: '0', cacheWrite: '0' },
|
|
498
734
|
baseTotal: '0',
|
|
499
735
|
total: '0',
|
|
500
|
-
reason: resolved && typeof resolved.reason === 'string' ? resolved.reason : 'model-not-found',
|
|
501
|
-
tiered
|
|
736
|
+
reason: tiered && !tierScheduleValid ? 'tiered-pricing-not-modeled' : resolved && typeof resolved.reason === 'string' ? resolved.reason : 'model-not-found',
|
|
737
|
+
tiered,
|
|
502
738
|
reasoningRateAvailable: resolved && resolved.reasoningRateAvailable === true,
|
|
503
739
|
}
|
|
504
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
|
+
}
|
|
505
749
|
base.breakdown.input = costPerMillion(base.billableInputTokens, base.rates.input)
|
|
506
750
|
base.breakdown.output = costPerMillion(base.billableOutputTokens, base.rates.output)
|
|
507
751
|
base.breakdown.cacheRead = costPerMillion(cacheRead, base.rates.cacheRead)
|
|
@@ -516,6 +760,7 @@ function emptyCostAggregate(currency = 'USD') {
|
|
|
516
760
|
}
|
|
517
761
|
|
|
518
762
|
function addCostAggregate(target, cost) {
|
|
763
|
+
if (isCostAccumulator(target)) return addCostAccumulator(target, cost)
|
|
519
764
|
const value = isRecord(cost) ? cost : {}
|
|
520
765
|
const status = COST_STATUSES.includes(value.status) ? value.status : 'unpriced'
|
|
521
766
|
if (status === 'priced') {
|
|
@@ -534,14 +779,16 @@ function addCostAggregate(target, cost) {
|
|
|
534
779
|
|
|
535
780
|
function serializeCostAggregate(cost) {
|
|
536
781
|
const value = cost || emptyCostAggregate()
|
|
782
|
+
const accumulator = isCostAccumulator(value)
|
|
783
|
+
const text = (field) => accumulator ? decimalAccumulatorText(value[field]) : decimalText(value[field]) || '0'
|
|
537
784
|
return {
|
|
538
785
|
currency: typeof value.currency === 'string' && value.currency !== '' ? value.currency : 'USD',
|
|
539
|
-
input:
|
|
540
|
-
output:
|
|
541
|
-
cacheRead:
|
|
542
|
-
cacheWrite:
|
|
543
|
-
baseTotal:
|
|
544
|
-
total:
|
|
786
|
+
input: text('input'),
|
|
787
|
+
output: text('output'),
|
|
788
|
+
cacheRead: text('cacheRead'),
|
|
789
|
+
cacheWrite: text('cacheWrite'),
|
|
790
|
+
baseTotal: text('baseTotal'),
|
|
791
|
+
total: text('total'),
|
|
545
792
|
pricedCalls: Number.isFinite(value.pricedCalls) ? value.pricedCalls : 0,
|
|
546
793
|
unpricedCalls: Number.isFinite(value.unpricedCalls) ? value.unpricedCalls : 0,
|
|
547
794
|
ambiguousCalls: Number.isFinite(value.ambiguousCalls) ? value.ambiguousCalls : 0,
|
|
@@ -567,6 +814,17 @@ function normalizeCostSnapshot(raw) {
|
|
|
567
814
|
const total = decimalText(raw.total)
|
|
568
815
|
const multiplier = decimalText(raw.multiplier)
|
|
569
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
|
+
}
|
|
570
828
|
return {
|
|
571
829
|
schemaVersion: COST_SCHEMA_VERSION,
|
|
572
830
|
pricingMode: raw.pricingMode === 'official-model' ? 'official-model' : 'legacy-provider-aware',
|
|
@@ -579,6 +837,8 @@ function normalizeCostSnapshot(raw) {
|
|
|
579
837
|
multiplier,
|
|
580
838
|
billableInputTokens: Number.isFinite(raw.billableInputTokens) ? Math.max(0, Math.trunc(raw.billableInputTokens)) : 0,
|
|
581
839
|
billableOutputTokens: Number.isFinite(raw.billableOutputTokens) ? Math.max(0, Math.trunc(raw.billableOutputTokens)) : 0,
|
|
840
|
+
...(contextTokens === null ? {} : { contextTokens }),
|
|
841
|
+
...(selectedTier === null ? {} : { selectedTier }),
|
|
582
842
|
rates: normalizedRates,
|
|
583
843
|
breakdown: normalizedBreakdown,
|
|
584
844
|
baseTotal,
|
|
@@ -625,7 +885,9 @@ export {
|
|
|
625
885
|
PRICING_SCHEMA_VERSION,
|
|
626
886
|
RATE_KEYS,
|
|
627
887
|
addCostAggregate,
|
|
888
|
+
addCostAccumulator,
|
|
628
889
|
calculateCost,
|
|
890
|
+
createCostAccumulator,
|
|
629
891
|
createEmptyPricingState,
|
|
630
892
|
decimalAdd,
|
|
631
893
|
decimalMultiply,
|
|
@@ -633,6 +895,7 @@ export {
|
|
|
633
895
|
decimalText,
|
|
634
896
|
emptyCostAggregate,
|
|
635
897
|
fetchModelsDevCatalog,
|
|
898
|
+
isCostAccumulator,
|
|
636
899
|
modelPricingCandidates,
|
|
637
900
|
officialProviderIds,
|
|
638
901
|
normalizeCostSnapshot,
|