dsh-all-usage 1.1.2 → 1.1.4

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 CHANGED
@@ -1,22 +1,36 @@
1
1
  import { createHash } from 'node:crypto'
2
2
 
3
3
  const PRICING_SCHEMA_VERSION = 1
4
- const COST_SCHEMA_VERSION = 1
4
+ const COST_SCHEMA_VERSION = 2
5
5
  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
17
+ const MAX_TEMPORAL_RULES = 16
18
+ const MAX_TEMPORAL_WINDOWS = 16
19
+ const MAX_TEMPORAL_POLICIES = 8
20
+ const TEMPORAL_TIMEZONE = 'UTC'
21
+ const TEMPORAL_TIME_SOURCES = ['request-context', 'usage-event', 'legacy-unknown']
11
22
  const RATE_KEYS = ['input', 'output', 'cacheRead', 'cacheWrite']
12
23
  const INPUT_SEMANTICS = ['legacy', 'total', 'fresh']
13
24
  const COST_STATUSES = ['priced', 'unpriced', 'ambiguous', 'unsupported']
25
+ const COST_FIELDS = ['input', 'output', 'cacheRead', 'cacheWrite', 'baseTotal', 'total']
26
+ const COST_ACCUMULATOR = Symbol('costAccumulator')
27
+ const COST_PARTS_CACHE = new WeakMap()
14
28
  const OFFICIAL_PROVIDER_RULES = [
15
29
  { providers: ['openai'], prefixes: ['gpt-', 'o1', 'o3', 'o4', 'o5'] },
16
30
  { providers: ['anthropic'], prefixes: ['claude-'] },
17
31
  { providers: ['google'], prefixes: ['gemini-', 'gemma-'] },
18
32
  { providers: ['xai'], prefixes: ['grok-'] },
19
- { providers: ['deepseek'], prefixes: ['deepseek-'] },
33
+ { providers: ['deepseek', 'deepseek-official'], prefixes: ['deepseek-'] },
20
34
  { providers: ['moonshotai', 'moonshot'], prefixes: ['kimi-', 'moonshot-'] },
21
35
  { providers: ['qwen', 'alibaba'], prefixes: ['qwen'] },
22
36
  { providers: ['zai', 'zhipuai', 'zhipu'], prefixes: ['glm-', 'chatglm-'] },
@@ -77,6 +91,89 @@ function decimalText(value) {
77
91
  return padded.slice(0, split) + '.' + padded.slice(split)
78
92
  }
79
93
 
94
+ function normalizedDecimalParts(parts) {
95
+ let digits = parts && typeof parts.digits === 'bigint' ? parts.digits : 0n
96
+ let scale = parts && Number.isSafeInteger(parts.scale) && parts.scale >= 0 ? parts.scale : 0
97
+ if (digits < 0n) digits = 0n
98
+ while (scale > 0 && digits !== 0n && digits % 10n === 0n) {
99
+ digits /= 10n
100
+ scale -= 1
101
+ }
102
+ return { digits, scale }
103
+ }
104
+
105
+ function decimalAccumulatorText(value) {
106
+ const parts = normalizedDecimalParts(value)
107
+ if (parts.digits === 0n) return '0'
108
+ const raw = parts.digits.toString()
109
+ if (parts.scale === 0) return raw
110
+ const padded = raw.padStart(parts.scale + 1, '0')
111
+ const split = padded.length - parts.scale
112
+ return padded.slice(0, split) + '.' + padded.slice(split)
113
+ }
114
+
115
+ function isCostAccumulator(value) {
116
+ return value !== null && typeof value === 'object' && value[COST_ACCUMULATOR] === true
117
+ }
118
+
119
+ function createCostAccumulator(currency = 'USD') {
120
+ 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 }
121
+ Object.defineProperty(result, COST_ACCUMULATOR, { value: true })
122
+ return result
123
+ }
124
+
125
+ function costAccumulatorParts(cost) {
126
+ if (isCostAccumulator(cost)) return cost
127
+ if (cost !== null && typeof cost === 'object') {
128
+ const cached = COST_PARTS_CACHE.get(cost)
129
+ if (cached !== undefined) return cached
130
+ }
131
+ const value = isRecord(cost) ? cost : {}
132
+ const status = COST_STATUSES.includes(value.status) ? value.status : 'unpriced'
133
+ const breakdown = isRecord(value.breakdown) ? value.breakdown : {}
134
+ const result = {
135
+ currency: typeof value.currency === 'string' && value.currency !== '' ? value.currency : 'USD',
136
+ status,
137
+ input: decimalParts(breakdown.input) || { digits: 0n, scale: 0 },
138
+ output: decimalParts(breakdown.output) || { digits: 0n, scale: 0 },
139
+ cacheRead: decimalParts(breakdown.cacheRead) || { digits: 0n, scale: 0 },
140
+ cacheWrite: decimalParts(breakdown.cacheWrite) || { digits: 0n, scale: 0 },
141
+ baseTotal: decimalParts(value.baseTotal) || { digits: 0n, scale: 0 },
142
+ total: decimalParts(value.total) || { digits: 0n, scale: 0 },
143
+ }
144
+ if (cost !== null && typeof cost === 'object') COST_PARTS_CACHE.set(cost, result)
145
+ return result
146
+ }
147
+
148
+ function addDecimalAccumulator(target, field, value, direction) {
149
+ const left = normalizedDecimalParts(target[field])
150
+ const right = normalizedDecimalParts(value)
151
+ const scale = Math.max(left.scale, right.scale)
152
+ let digits = left.digits * 10n ** BigInt(scale - left.scale) + BigInt(direction) * right.digits * 10n ** BigInt(scale - right.scale)
153
+ if (digits < 0n) digits = 0n
154
+ target[field] = normalizedDecimalParts({ digits, scale })
155
+ }
156
+
157
+ function addCostAccumulator(target, cost, direction = 1) {
158
+ if (!isCostAccumulator(target)) return target
159
+ if (isCostAccumulator(cost)) {
160
+ for (const field of COST_FIELDS) addDecimalAccumulator(target, field, cost[field], direction)
161
+ target.pricedCalls = Math.max(0, target.pricedCalls + direction * cost.pricedCalls)
162
+ target.unpricedCalls = Math.max(0, target.unpricedCalls + direction * cost.unpricedCalls)
163
+ target.ambiguousCalls = Math.max(0, target.ambiguousCalls + direction * cost.ambiguousCalls)
164
+ target.unsupportedCalls = Math.max(0, target.unsupportedCalls + direction * cost.unsupportedCalls)
165
+ return target
166
+ }
167
+ const value = costAccumulatorParts(cost)
168
+ if (value.status === 'priced') {
169
+ for (const field of COST_FIELDS) addDecimalAccumulator(target, field, value[field], direction)
170
+ target.pricedCalls = Math.max(0, target.pricedCalls + direction)
171
+ } else if (value.status === 'ambiguous') target.ambiguousCalls = Math.max(0, target.ambiguousCalls + direction)
172
+ else if (value.status === 'unsupported') target.unsupportedCalls = Math.max(0, target.unsupportedCalls + direction)
173
+ else target.unpricedCalls = Math.max(0, target.unpricedCalls + direction)
174
+ return target
175
+ }
176
+
80
177
  function decimalAdd(left, right) {
81
178
  const a = decimalParts(left) || { digits: 0n, scale: 0 }
82
179
  const b = decimalParts(right) || { digits: 0n, scale: 0 }
@@ -212,30 +309,308 @@ function identityModels(identity) {
212
309
  }
213
310
 
214
311
  function identityKeyOf(identity) {
215
- return isRecord(identity) && typeof identity.identityKey === 'string' ? identity.identityKey : ''
312
+ if (!isRecord(identity)) return ''
313
+ const key = typeof identity.identityKey === 'string' && identity.identityKey.trim() !== '' ? identity.identityKey : identity.usageIdentityKey
314
+ return typeof key === 'string' ? key.trim() : ''
216
315
  }
217
316
 
218
317
  function priceEntryKey(entry) {
219
318
  return normalizeProvider(entry.providerId) + '\0' + normalizeModelId(entry.modelId)
220
319
  }
221
320
 
321
+ function decimalRate(raw, keys, fallback = undefined) {
322
+ let value
323
+ for (const key of keys) {
324
+ if (raw && raw[key] !== undefined) {
325
+ value = raw[key]
326
+ break
327
+ }
328
+ }
329
+ if (value === undefined || value === null || value === '') value = fallback
330
+ return decimalText(value)
331
+ }
332
+
333
+ function temporalRatesOf(raw, fallback) {
334
+ const rates = isRecord(raw) ? raw : {}
335
+ const input = decimalRate(rates, ['input', 'inputPerMillion'], fallback && fallback.input)
336
+ const output = decimalRate(rates, ['output', 'outputPerMillion'], fallback && fallback.output)
337
+ const cacheRead = decimalRate(rates, ['cacheRead', 'cache_read', 'cacheReadPerMillion'], fallback && fallback.cacheRead)
338
+ const cacheWrite = decimalRate(rates, ['cacheWrite', 'cache_write', 'cacheCreation', 'cacheWritePerMillion'], fallback && fallback.cacheWrite)
339
+ if (input === null || output === null || cacheRead === null || cacheWrite === null) return null
340
+ const parts = [decimalParts(input), decimalParts(output), decimalParts(cacheRead), decimalParts(cacheWrite)]
341
+ if (parts.some((entry) => entry === null || entry.digits < 0n)) return null
342
+ return { input, output, cacheRead, cacheWrite }
343
+ }
344
+
345
+ function normalizeTemporalWindows(raw) {
346
+ if (!Array.isArray(raw)) return null
347
+ const windows = []
348
+ for (const candidate of raw.slice(0, MAX_TEMPORAL_WINDOWS)) {
349
+ if (!isRecord(candidate)) return null
350
+ const startMinute = finiteNumber(candidate.startMinute)
351
+ const endMinute = finiteNumber(candidate.endMinute)
352
+ if (startMinute === null || endMinute === null || !Number.isInteger(startMinute) || !Number.isInteger(endMinute) || startMinute < 0 || startMinute >= 1440 || endMinute <= startMinute || endMinute > 1440) return null
353
+ windows.push({ startMinute, endMinute })
354
+ }
355
+ if (windows.length === 0) return null
356
+ // Half-open [start, end) windows that overlap would make the band ambiguous;
357
+ // deterministic order plus adjacency-only constraint keeps selection stable.
358
+ windows.sort((left, right) => left.startMinute - right.startMinute || left.endMinute - right.endMinute)
359
+ for (let index = 1; index < windows.length; index += 1) {
360
+ if (windows[index].startMinute < windows[index - 1].endMinute) return null
361
+ }
362
+ return windows
363
+ }
364
+
365
+ /** One versioned temporal policy: effective-windowed, UTC-band rules. */
366
+ function normalizeTemporalPolicy(raw, entryDefaultRates = undefined) {
367
+ if (!isRecord(raw)) return null
368
+ const policyId = typeof raw.policyId === 'string' && raw.policyId.trim() !== '' ? raw.policyId.trim().slice(0, 128) : ''
369
+ if (policyId === '') return null
370
+ const sourceUrl = typeof raw.sourceUrl === 'string' && raw.sourceUrl.trim() !== '' ? raw.sourceUrl.trim().slice(0, 512) : ''
371
+ const timezone = typeof raw.timezone === 'string' && raw.timezone.trim() !== '' ? raw.timezone.trim().toUpperCase() : TEMPORAL_TIMEZONE
372
+ // DeepSeek peak/off-peak windows are defined in UTC; any other timezone is
373
+ // rejected outright so band selection can never depend on the viewer's clock.
374
+ if (timezone !== TEMPORAL_TIMEZONE) return null
375
+ const effectiveFrom = raw.effectiveFrom === undefined || raw.effectiveFrom === null ? 0 : finiteNumber(raw.effectiveFrom)
376
+ const effectiveUntil = raw.effectiveUntil === undefined || raw.effectiveUntil === null ? null : finiteNumber(raw.effectiveUntil)
377
+ if (effectiveFrom === null || !Number.isSafeInteger(effectiveFrom) || effectiveFrom < 0) return null
378
+ if (effectiveUntil !== null && (!Number.isSafeInteger(effectiveUntil) || effectiveUntil <= effectiveFrom)) return null
379
+ let defaultPlan = null
380
+ if (raw.defaultPlan !== undefined && raw.defaultPlan !== null) {
381
+ if (!isRecord(raw.defaultPlan)) return null
382
+ const rates = temporalRatesOf(raw.defaultPlan.rates || raw.defaultPlan, entryDefaultRates)
383
+ if (rates === null) return null
384
+ defaultPlan = { id: typeof raw.defaultPlan.id === 'string' && raw.defaultPlan.id.trim() !== '' ? raw.defaultPlan.id.trim().slice(0, 64) : 'default', rates }
385
+ }
386
+ if (raw.rules !== undefined && !Array.isArray(raw.rules)) return null
387
+ if (!Array.isArray(raw.rules)) return null
388
+ if (raw.rules.length > MAX_TEMPORAL_RULES) return null
389
+ const rules = []
390
+ for (const rawRule of raw.rules) {
391
+ if (!isRecord(rawRule)) return null
392
+ const id = typeof rawRule.id === 'string' && rawRule.id.trim() !== '' ? rawRule.id.trim().slice(0, 64) : ''
393
+ if (id === '') return null
394
+ const weekdays = Array.from(new Set((Array.isArray(rawRule.weekdays) ? rawRule.weekdays : []).map(Number)))
395
+ if (weekdays.length === 0 || weekdays.some((day) => !Number.isInteger(day) || day < 0 || day > 6)) return null
396
+ weekdays.sort((left, right) => left - right)
397
+ const windows = normalizeTemporalWindows(rawRule.windows)
398
+ if (windows === null) return null
399
+ const rates = temporalRatesOf(rawRule.rates || rawRule, defaultPlan ? defaultPlan.rates : entryDefaultRates)
400
+ if (rates === null) return null
401
+ rules.push({ id, weekdays, windows, rates })
402
+ }
403
+ if (rules.length === 0) return null
404
+ // Rules must never both claim the same weekday minute: a JSON array order
405
+ // that decides a price is nondeterministic and must be rejected instead.
406
+ const dayWindows = new Map()
407
+ for (const rule of rules) {
408
+ for (const day of rule.weekdays) {
409
+ let list = dayWindows.get(day)
410
+ if (list === undefined) { list = []; dayWindows.set(day, list) }
411
+ for (const window of rule.windows) list.push(window)
412
+ }
413
+ }
414
+ for (const list of dayWindows.values()) {
415
+ list.sort((left, right) => left.startMinute - right.startMinute || left.endMinute - right.endMinute)
416
+ for (let index = 1; index < list.length; index += 1) {
417
+ if (list[index].startMinute < list[index - 1].endMinute) return null
418
+ }
419
+ }
420
+ return { policyId, sourceUrl, timezone, effectiveFrom, effectiveUntil, defaultPlan, rules }
421
+ }
422
+
423
+ /** Temporal pricing plan: an ordered, non-overlapping archive of policies. */
424
+ function normalizeTemporalPricing(raw, entryDefaultRates = undefined) {
425
+ if (!isRecord(raw)) return null
426
+ const rawPolicies = Array.isArray(raw.policies) ? raw.policies : (typeof raw.policyId === 'string' ? [raw] : null)
427
+ if (rawPolicies === null || rawPolicies.length === 0 || rawPolicies.length > MAX_TEMPORAL_POLICIES) return null
428
+ const policies = []
429
+ for (const rawPolicy of rawPolicies) {
430
+ const policy = normalizeTemporalPolicy(rawPolicy, entryDefaultRates)
431
+ if (policy === null) return null
432
+ policies.push(policy)
433
+ }
434
+ policies.sort((left, right) => left.effectiveFrom - right.effectiveFrom || (left.policyId < right.policyId ? -1 : left.policyId > right.policyId ? 1 : 0))
435
+ // A policy whose window never ends must be the last one; successive windows
436
+ // may leave a gap (which fails closed) but must never overlap.
437
+ for (let index = 1; index < policies.length; index += 1) {
438
+ const previous = policies[index - 1]
439
+ if (previous.effectiveUntil === null || policies[index].effectiveFrom < previous.effectiveUntil) return null
440
+ }
441
+ return { policies }
442
+ }
443
+
444
+ const DEEPSEEK_TEMPORAL_POLICY_ID = 'deepseek-v4-2026-08-pricing'
445
+ const DEEPSEEK_TEMPORAL_POLICY_URL = 'https://api-docs.deepseek.com/quick_start/pricing/'
446
+ // DeepSeek announced peak/off-peak pricing effective 2026-08-16T16:00:00Z; the
447
+ // V4-Flash-Vision-Exp model itself became available on 2026-08-21. Before those
448
+ // instants there is no verifiable band archive, so usage fails closed instead
449
+ // of inheriting today's V4 rates.
450
+ const DEEPSEEK_TEMPORAL_EFFECTIVE_FROM = Date.UTC(2026, 7, 16, 16, 0, 0)
451
+ const DEEPSEEK_VISION_EFFECTIVE_FROM = Date.UTC(2026, 7, 21, 0, 0, 0)
452
+ // Built-in first-party profiles: each model carries its own explicit off-peak
453
+ // (inherited from the live catalog entry) and peak rates; the peak rates are
454
+ // stored as data, never derived as an automatic 'half price' rule.
455
+ const DEEPSEEK_TEMPORAL_MODELS = new Map([
456
+ ['deepseek-v4-flash', { input: '0.44', output: '1.32', cacheRead: '0.014', cacheWrite: '0', effectiveFrom: DEEPSEEK_TEMPORAL_EFFECTIVE_FROM }],
457
+ ['deepseek-v4-flash-vision-exp', { input: '0.44', output: '1.32', cacheRead: '0.014', cacheWrite: '0', effectiveFrom: DEEPSEEK_VISION_EFFECTIVE_FROM }],
458
+ ['deepseek-v4-pro', { input: '1.32', output: '3.96', cacheRead: '0.044', cacheWrite: '0', effectiveFrom: DEEPSEEK_TEMPORAL_EFFECTIVE_FROM }],
459
+ ])
460
+
461
+ function builtinTemporalProfileFor(modelId) {
462
+ const spec = DEEPSEEK_TEMPORAL_MODELS.get(normalizeModelId(modelId))
463
+ if (spec === undefined) return null
464
+ return {
465
+ policies: [{
466
+ policyId: DEEPSEEK_TEMPORAL_POLICY_ID,
467
+ sourceUrl: DEEPSEEK_TEMPORAL_POLICY_URL,
468
+ timezone: TEMPORAL_TIMEZONE,
469
+ effectiveFrom: spec.effectiveFrom,
470
+ effectiveUntil: null,
471
+ defaultPlan: null,
472
+ rules: [{ id: 'peak', weekdays: [1, 2, 3, 4, 5], windows: [{ startMinute: 60, endMinute: 240 }, { startMinute: 360, endMinute: 600 }], rates: { input: spec.input, output: spec.output, cacheRead: spec.cacheRead, cacheWrite: spec.cacheWrite } }],
473
+ }],
474
+ }
475
+ }
476
+
477
+ /** Policy covering an instant inside a profile's ordered policy archive. */
478
+ function temporalPolicyFor(profile, atMs) {
479
+ const policies = isRecord(profile) ? profile.policies : null
480
+ if (!Array.isArray(policies) || policies.length === 0 || !Number.isFinite(atMs)) return null
481
+ const at = Math.trunc(atMs)
482
+ for (const policy of policies) {
483
+ if (at < policy.effectiveFrom) continue
484
+ if (policy.effectiveUntil !== null && at >= policy.effectiveUntil) continue
485
+ return policy
486
+ }
487
+ return null
488
+ }
489
+
490
+ /** UTC half-open band: weekday in rule set AND minute within [start, end). */
491
+ function temporalBand(profile, atMs) {
492
+ if (!Number.isFinite(atMs) || atMs < 0) return null
493
+ const policy = temporalPolicyFor(profile, atMs)
494
+ if (policy === null) return null
495
+ const matched = matchingTemporalRule(policy, atMs)
496
+ return matched === null ? 'off-peak' : 'peak'
497
+ }
498
+
499
+ function matchingTemporalRule(policy, atMs) {
500
+ const date = new Date(Math.trunc(atMs))
501
+ const weekday = date.getUTCDay()
502
+ const minute = date.getUTCHours() * 60 + date.getUTCMinutes()
503
+ for (const rule of (policy && Array.isArray(policy.rules) ? policy.rules : [])) {
504
+ if (!(Array.isArray(rule.weekdays) ? rule.weekdays : []).includes(weekday)) continue
505
+ if ((Array.isArray(rule.windows) ? rule.windows : []).some((window) => minute >= window.startMinute && minute < window.endMinute)) return rule
506
+ }
507
+ return null
508
+ }
509
+
510
+ /**
511
+ * Deterministic hash of one policy: the policy content only, never the live
512
+ * catalog rates. A snapshot's policy hash is therefore stable across catalog
513
+ * refreshes, which keeps historical costs auditable (see the reconciliation
514
+ * rules); changing the policy itself yields a new hash.
515
+ */
516
+ function temporalPolicyHash(policy) {
517
+ const stable = JSON.stringify([
518
+ policy.policyId,
519
+ policy.timezone,
520
+ policy.effectiveFrom,
521
+ policy.effectiveUntil,
522
+ policy.defaultPlan,
523
+ policy.rules,
524
+ ])
525
+ return createHash('sha256').update(stable).digest('hex')
526
+ }
527
+
528
+ /**
529
+ * Resolve one usage instant against a resolved pricing entry: which policy
530
+ * covers it, which band applies and which rates that band carries. Returns the
531
+ * stable signature that both calculateCost() and the snapshot-reuse check
532
+ * consume.
533
+ */
534
+ function temporalPlanFor(resolved, pricingAtMs) {
535
+ if (isRecord(resolved) && resolved.temporalConfigInvalid === true) {
536
+ return { status: 'config-invalid', band: null, ruleRates: null, policyId: null, policyHash: null, timezone: null, exemptReason: 'temporal-config-invalid' }
537
+ }
538
+ const profile = isRecord(resolved) && resolved.temporalProfile ? resolved.temporalProfile : null
539
+ if (profile === null) {
540
+ return { status: 'none', band: null, ruleRates: null, policyId: null, policyHash: null, timezone: null, exemptReason: 'no-temporal-profile' }
541
+ }
542
+ const route = resolved.temporalRoute === 'official' || resolved.temporalRoute === 'mapped' ? resolved.temporalRoute : 'other'
543
+ if (route === 'other') return { status: 'other-route', band: null, ruleRates: null, policyId: null, policyHash: null, timezone: null, exemptReason: 'route-not-official' }
544
+ if (!Number.isFinite(pricingAtMs) || pricingAtMs < 0) return { status: 'time-missing', band: null, ruleRates: null, policyId: null, policyHash: null, timezone: null, exemptReason: null }
545
+ const at = Math.trunc(pricingAtMs)
546
+ const policy = temporalPolicyFor(profile, at)
547
+ if (policy === null) return { status: 'history-gap', band: null, ruleRates: null, policyId: null, policyHash: null, timezone: null, exemptReason: null }
548
+ const common = { policyId: policy.policyId || null, policyHash: policy.policyHash || null, timezone: policy.timezone === TEMPORAL_TIMEZONE ? TEMPORAL_TIMEZONE : null }
549
+ const rule = matchingTemporalRule(policy, at)
550
+ if (rule !== null) return { status: 'applied', band: 'peak', ruleRates: rule.rates, exemptReason: null, ...common }
551
+ return { status: 'applied', band: 'off-peak', ruleRates: policy.defaultPlan ? policy.defaultPlan.rates : null, exemptReason: null, ...common }
552
+ }
553
+
554
+ function normalizeContextTier(raw, fallbackRates, legacySize = null) {
555
+ if (!isRecord(raw)) return null
556
+ const descriptor = isRecord(raw.tier) ? raw.tier : raw
557
+ const type = legacySize === null ? (typeof descriptor.type === 'string' ? descriptor.type.trim().toLowerCase() : '') : 'context'
558
+ const sizeValue = legacySize === null ? descriptor.size : legacySize
559
+ const size = finiteNumber(sizeValue)
560
+ if (type !== 'context' || size === null || !Number.isSafeInteger(size) || size <= 0 || size > 1000000000) return null
561
+ const input = decimalRate(raw, ['input', 'inputPerMillion'], fallbackRates.input)
562
+ const output = decimalRate(raw, ['output', 'outputPerMillion'], fallbackRates.output)
563
+ const cacheRead = decimalRate(raw, ['cacheRead', 'cache_read', 'cacheReadPerMillion'], fallbackRates.cacheRead)
564
+ const cacheWrite = decimalRate(raw, ['cacheWrite', 'cache_write', 'cacheCreation', 'cacheWritePerMillion'], fallbackRates.cacheWrite)
565
+ if (input === null || output === null || cacheRead === null || cacheWrite === null) return null
566
+ if (decimalParts(input).digits < 0n || decimalParts(output).digits < 0n || decimalParts(cacheRead).digits < 0n || decimalParts(cacheWrite).digits < 0n) return null
567
+ return { type: 'context', size, input, output, cacheRead, cacheWrite }
568
+ }
569
+
222
570
  function normalizePriceEntry(raw, sourceDefault = 'models.dev') {
223
571
  if (!isRecord(raw)) return null
224
572
  const modelId = normalizeModelId(raw.modelId || raw.id)
225
573
  if (modelId === '') return null
226
- const input = decimalText(raw.input !== undefined ? raw.input : raw.inputPerMillion)
227
- const output = decimalText(raw.output !== undefined ? raw.output : raw.outputPerMillion)
574
+ const input = decimalRate(raw, ['input', 'inputPerMillion'])
575
+ const output = decimalRate(raw, ['output', 'outputPerMillion'])
228
576
  if (input === null || output === null || decimalParts(input).digits < 0n || decimalParts(output).digits < 0n) return null
229
- const optionalDecimal = (primary, secondary) => {
230
- const value = primary !== undefined ? primary : secondary
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)
577
+ const cacheRead = decimalRate(raw, ['cacheRead', 'cache_read', 'cacheReadPerMillion'], '0')
578
+ const cacheWrite = decimalRate(raw, ['cacheWrite', 'cache_write', 'cacheCreation', 'cacheWritePerMillion'], '0')
236
579
  if (cacheRead === null || cacheWrite === null || decimalParts(cacheRead).digits < 0n || decimalParts(cacheWrite).digits < 0n) return null
580
+ const fallbackRates = { input, output, cacheRead, cacheWrite }
581
+ const tiers = []
582
+ let tieredInvalid = false
583
+ const hasTierData = raw.tiers !== undefined || raw.context_over_200k !== undefined
584
+ if (raw.tiers !== undefined) {
585
+ if (!Array.isArray(raw.tiers)) tieredInvalid = true
586
+ else {
587
+ if (raw.tiers.length > MAX_CONTEXT_TIERS) tieredInvalid = true
588
+ for (const rawTier of raw.tiers.slice(0, MAX_CONTEXT_TIERS)) {
589
+ const tier = normalizeContextTier(rawTier, fallbackRates)
590
+ if (tier === null || tiers.some((existing) => existing.size === tier.size)) tieredInvalid = true
591
+ else tiers.push(tier)
592
+ }
593
+ }
594
+ }
595
+ if (raw.context_over_200k !== undefined && raw.tiers === undefined) {
596
+ const tier = normalizeContextTier(raw.context_over_200k, fallbackRates, 200000)
597
+ if (tier === null) tieredInvalid = true
598
+ else tiers.push(tier)
599
+ }
600
+ tiers.sort((left, right) => left.size - right.size)
601
+ const tiered = raw.tiered !== undefined ? raw.tiered === true : hasTierData || tiers.length > 0
602
+ if (!tiered) {
603
+ tiers.length = 0
604
+ tieredInvalid = false
605
+ } else if (tiers.length === 0) tieredInvalid = true
606
+ const hasTemporalConfig = raw.temporalPricing !== undefined && raw.temporalPricing !== null
607
+ const temporalPricing = hasTemporalConfig ? normalizeTemporalPricing(raw.temporalPricing, fallbackRates) || undefined : undefined
608
+ // An explicit invalid config keeps its sentinel across the normalize →
609
+ // serialize → normalize cycle, so a restart cannot silently re-enable the
610
+ // built-in profile after a rejected temporalPricing edit.
611
+ const temporalPricingInvalid = hasTemporalConfig && temporalPricing === undefined ? true : raw.temporalPricingInvalid === true && temporalPricing === undefined
237
612
  return {
238
- providerId: typeof raw.providerId === 'string' ? raw.providerId.trim() : '',
613
+ providerId: typeof raw.providerId === 'string' ? raw.providerId.trim().toLowerCase() : '',
239
614
  providerName: typeof raw.providerName === 'string' ? raw.providerName.trim().slice(0, 200) : '',
240
615
  modelId,
241
616
  displayName: typeof raw.displayName === 'string' && raw.displayName.trim() !== '' ? raw.displayName.trim().slice(0, 200) : modelId,
@@ -245,16 +620,18 @@ function normalizePriceEntry(raw, sourceDefault = 'models.dev') {
245
620
  cacheRead,
246
621
  cacheWrite,
247
622
  source: raw.source === 'manual' ? 'manual' : sourceDefault,
248
- tiered: raw.tiered === true || raw.tiers !== undefined,
623
+ tiered,
624
+ ...(tiered ? { tiers, tieredInvalid } : {}),
249
625
  reasoningRateAvailable: raw.reasoningRateAvailable === true || raw.reasoning !== undefined,
250
626
  fetchedAt: Number.isFinite(raw.fetchedAt) ? raw.fetchedAt : 0,
627
+ ...(temporalPricing === undefined ? {} : { temporalPricing }),
628
+ ...(temporalPricingInvalid === true ? { temporalPricingInvalid: true } : {}),
251
629
  }
252
630
  }
253
631
 
254
632
  function parseModelsDevCatalog(raw, fetchedAt = Date.now()) {
255
633
  if (!isRecord(raw)) return { ok: false, error: 'catalog-not-object' }
256
634
  const entries = []
257
- const seen = new Set()
258
635
  for (const [providerKey, provider] of Object.entries(raw)) {
259
636
  if (!isRecord(provider) || !isRecord(provider.models)) continue
260
637
  const providerId = typeof provider.id === 'string' && provider.id.trim() !== '' ? provider.id.trim() : providerKey
@@ -271,22 +648,56 @@ function parseModelsDevCatalog(raw, fetchedAt = Date.now()) {
271
648
  output: model.cost && model.cost.output,
272
649
  cacheRead: model.cost && model.cost.cache_read,
273
650
  cacheWrite: model.cost && model.cost.cache_write,
651
+ tiers: model.cost && model.cost.tiers,
652
+ context_over_200k: model.cost && model.cost.context_over_200k,
274
653
  tiered: model.cost && model.cost.tiers !== undefined || model.cost && model.cost.context_over_200k !== undefined,
275
654
  reasoningRateAvailable: model.cost && model.cost.reasoning !== undefined,
276
655
  source: 'models.dev',
277
656
  fetchedAt,
278
657
  })
279
658
  if (entry === null || entry.currency !== 'USD') continue
280
- const key = priceEntryKey(entry)
281
- if (seen.has(key)) continue
282
- seen.add(key)
659
+ // Duplicate normalized keys are NOT resolved here: they are kept and
660
+ // resolved deterministically after the stable sort below.
283
661
  entries.push(entry)
284
- if (entries.length >= MAX_PRICE_ENTRIES) break
662
+ if (entries.length > MAX_CATALOG_CANDIDATES) return { ok: false, error: 'catalog-exceeds-candidate-limit', candidateCount: entries.length }
285
663
  }
286
- if (entries.length >= MAX_PRICE_ENTRIES) break
287
664
  }
288
665
  if (entries.length === 0) return { ok: false, error: 'catalog-has-no-priced-models' }
289
- const canonical = JSON.stringify(entries)
666
+ // Sort by the stable key first: upstream object order must not decide which
667
+ // models survive a large catalog or which conflicting duplicate is kept.
668
+ // Code-unit comparison: default-locale localeCompare can report ties for
669
+ // distinct strings (e.g. composed/decomposed accents), which would let
670
+ // upstream enumeration order decide the surviving entry.
671
+ entries.sort((left, right) => { const ka = priceEntryKey(left); const kb = priceEntryKey(right); return ka < kb ? -1 : ka > kb ? 1 : 0 })
672
+ // Deterministic duplicate selection: within one normalized key, compare only
673
+ // the small conflict group by their full content (fetchedAt excluded), so
674
+ // ordering swaps cannot change the surviving price.
675
+ const stableText = (entry) => {
676
+ const copy = Object.assign({}, entry)
677
+ delete copy.fetchedAt
678
+ if (typeof copy.providerId === 'string') copy.providerId = copy.providerId.trim().toLowerCase()
679
+ return JSON.stringify(copy)
680
+ }
681
+ const ordered = []
682
+ {
683
+ let index = 0
684
+ while (index < entries.length) {
685
+ const key = priceEntryKey(entries[index])
686
+ let end = index + 1
687
+ while (end < entries.length && priceEntryKey(entries[end]) === key) end += 1
688
+ 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)
689
+ ordered.push(group[0])
690
+ index = end
691
+ }
692
+ }
693
+ ordered.length = Math.min(ordered.length, MAX_PRICE_ENTRIES)
694
+ // The fetchedAt stamp differs on every fetch; exclude it from the content
695
+ // hash so identical catalog contents hash identically across syncs.
696
+ const canonical = JSON.stringify(ordered.map((entry) => {
697
+ if (entry.fetchedAt === undefined) return entry
698
+ const { fetchedAt, ...stable } = entry
699
+ return stable
700
+ }))
290
701
  return {
291
702
  ok: true,
292
703
  catalog: {
@@ -294,7 +705,7 @@ function parseModelsDevCatalog(raw, fetchedAt = Date.now()) {
294
705
  sourceUrl: MODEL_CATALOG_URL,
295
706
  fetchedAt,
296
707
  catalogHash: createHash('sha256').update(canonical).digest('hex'),
297
- entries,
708
+ entries: ordered,
298
709
  },
299
710
  }
300
711
  }
@@ -312,9 +723,11 @@ function normalizePricingState(raw) {
312
723
  const model = normalizeModelId(mapping.model || mapping.modelId)
313
724
  const catalogProviderId = typeof mapping.catalogProviderId === 'string' ? mapping.catalogProviderId.trim() : ''
314
725
  const catalogModelId = normalizeModelId(mapping.catalogModelId || mapping.catalogModel)
315
- if (provider === '' && model === '' && catalogProviderId === '' && catalogModelId === '') return null
726
+ const identityKey = (typeof mapping.identityKey === 'string' && mapping.identityKey.trim() !== '' ? mapping.identityKey : mapping.usageIdentityKey)
727
+ const normalizedIdentityKey = typeof identityKey === 'string' ? identityKey.trim().slice(0, 1024) : ''
728
+ if (provider === '' && model === '' && catalogProviderId === '' && catalogModelId === '' && normalizedIdentityKey === '') return null
316
729
  return {
317
- identityKey: typeof mapping.identityKey === 'string' ? mapping.identityKey.slice(0, 1024) : '',
730
+ identityKey: normalizedIdentityKey,
318
731
  provider,
319
732
  model,
320
733
  catalogProviderId,
@@ -380,10 +793,25 @@ function serializePricingState(state) {
380
793
  }
381
794
 
382
795
  function sameRates(left, right) {
383
- return left.input === right.input && left.output === right.output && left.cacheRead === right.cacheRead && left.cacheWrite === right.cacheWrite && left.currency === right.currency
796
+ 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 || left.temporalPricingInvalid !== right.temporalPricingInvalid) return false
797
+ // Two entries with identical flat rates but different band plans are NOT the
798
+ // same price: the winner must never depend on enumeration order.
799
+ if (JSON.stringify(left.temporalPricing || null) !== JSON.stringify(right.temporalPricing || null)) return false
800
+ const leftTiers = Array.isArray(left.tiers) ? left.tiers : []
801
+ const rightTiers = Array.isArray(right.tiers) ? right.tiers : []
802
+ if (leftTiers.length !== rightTiers.length) return false
803
+ return leftTiers.every((leftTier, index) => {
804
+ const rightTier = rightTiers[index]
805
+ 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
806
+ })
384
807
  }
385
808
 
386
809
  function mappingMatches(mapping, identity) {
810
+ if (mapping.identityKey !== '') {
811
+ if (mapping.identityKey !== identityKeyOf(identity)) return false
812
+ return mapping.provider === '' || (isRecord(identity) && typeof identity.provider === 'string' && identity.provider.trim().toLowerCase() === mapping.provider.toLowerCase())
813
+ }
814
+ if (mapping.provider !== '' && (!isRecord(identity) || typeof identity.provider !== 'string' || identity.provider.trim().toLowerCase() !== mapping.provider.toLowerCase())) return false
387
815
  const models = identityModels(identity)
388
816
  return mapping.model !== '' && models.includes(mapping.model)
389
817
  }
@@ -399,9 +827,37 @@ function findMappedEntry(mapping, state) {
399
827
  return null
400
828
  }
401
829
 
830
+ const DEEPSEEK_OFFICIAL_PROVIDERS = ['deepseek', 'deepseek-official']
831
+
832
+ function attachTemporalProfile(entry, mapped, identity) {
833
+ if (entry === null || entry === undefined || !isRecord(entry)) return null
834
+ // An explicit but invalid temporal config must never silently fall back to
835
+ // the built-in profile: the configuration error stays visible and the model
836
+ // fails closed instead of pricing with a plan the user did not write.
837
+ if (entry.temporalPricingInvalid === true) return { invalid: true }
838
+ let profile = isRecord(entry.temporalPricing) ? entry.temporalPricing : null
839
+ if (profile === null && DEEPSEEK_OFFICIAL_PROVIDERS.includes(normalizeProvider(entry.providerId))) profile = builtinTemporalProfileFor(entry.modelId)
840
+ if (profile === null) return null
841
+ const providerId = normalizeProvider(entry.providerId)
842
+ const identityProvider = normalizeProvider(identity && identity.provider)
843
+ // Safety boundary: the peak/off-peak table applies only when the DSH route
844
+ // is the first-party provider, or when the user explicitly mapped this route
845
+ // to the DeepSeek official entry. OpenRouter and other resellers never get
846
+ // the DeepSeek temporal band automatically.
847
+ const route = DEEPSEEK_OFFICIAL_PROVIDERS.includes(providerId) && DEEPSEEK_OFFICIAL_PROVIDERS.includes(identityProvider) ? 'official' : mapped !== undefined && mapped !== null ? 'mapped' : 'other'
848
+ return {
849
+ route,
850
+ profile: {
851
+ policies: profile.policies.map((policy) => ({ ...policy, policyHash: temporalPolicyHash(policy) })),
852
+ },
853
+ }
854
+ }
855
+
402
856
  function resolvePricing(identity, rawState) {
403
857
  const state = rawState && rawState._normalized === true ? rawState : normalizePricingState(rawState)
404
- const mapped = state.mappings.find((mapping) => mappingMatches(mapping, identity))
858
+ const identityKey = identityKeyOf(identity)
859
+ const exactMapped = identityKey === '' ? undefined : state.mappings.find((mapping) => mapping.identityKey !== '' && mapping.identityKey === identityKey && mappingMatches(mapping, identity))
860
+ const mapped = exactMapped || state.mappings.find((mapping) => mapping.identityKey === '' && mappingMatches(mapping, identity))
405
861
  const mappedEntry = mapped === undefined ? null : findMappedEntry(mapped, state)
406
862
  if (mapped !== undefined && mappedEntry === null && (mapped.catalogModelId !== '' || mapped.catalogProviderId !== '')) {
407
863
  return { status: 'unpriced', reason: 'mapping-target-not-found', pricingModel: mapped.catalogModelId || null, providerId: mapped.catalogProviderId || null }
@@ -438,9 +894,12 @@ function resolvePricing(identity, rawState) {
438
894
  for (const match of sameRank) if (!unique.some((entry) => sameRates(entry, match.entry))) unique.push(match.entry)
439
895
  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
896
  const entry = unique[0] || best.entry
897
+ const tiered = entry.tiered === true
898
+ const tiers = Array.isArray(entry.tiers) ? entry.tiers.map((tier) => ({ ...tier })) : []
899
+ const tieredSupported = !tiered || (entry.tieredInvalid !== true && tiers.length > 0)
441
900
  return {
442
- status: 'priced',
443
- reason: '',
901
+ status: tieredSupported ? 'priced' : 'unsupported',
902
+ reason: tieredSupported ? '' : 'tiered-pricing-not-modeled',
444
903
  pricingModel: entry.modelId,
445
904
  providerId: entry.providerId || null,
446
905
  providerName: entry.providerName || null,
@@ -448,10 +907,12 @@ function resolvePricing(identity, rawState) {
448
907
  currency: entry.currency,
449
908
  rates: { input: entry.input, output: entry.output, cacheRead: entry.cacheRead, cacheWrite: entry.cacheWrite },
450
909
  source: entry.source,
451
- tiered: entry.tiered,
910
+ tiered,
911
+ ...(tiered ? { tiers, tieredInvalid: entry.tieredInvalid === true } : {}),
452
912
  reasoningRateAvailable: entry.reasoningRateAvailable,
453
913
  inputTokenSemantics: mapped && INPUT_SEMANTICS.includes(mapped.inputTokenSemantics) ? mapped.inputTokenSemantics : 'fresh',
454
914
  multiplier: mapped ? mapped.multiplier : '1',
915
+ ...(() => { const temporal = attachTemporalProfile(entry, mapped, identity); return temporal === null ? {} : temporal.invalid === true ? { temporalConfigInvalid: true } : { temporalProfile: temporal.profile, temporalRoute: temporal.route } })(),
455
916
  }
456
917
  }
457
918
  const model = models[0] || null
@@ -473,7 +934,43 @@ function costPerMillion(tokens, rate) {
473
934
  return decimalText(numerator.toString() + (scale > 0 ? 'e-' + scale : '')) || '0'
474
935
  }
475
936
 
476
- function calculateCost(values, resolved) {
937
+ function contextTokenCount(input, cacheRead, cacheWrite, inputSemantics) {
938
+ const values = inputSemantics === 'total' ? [input] : inputSemantics === 'legacy' ? [input, cacheWrite] : [input, cacheRead, cacheWrite]
939
+ let total = 0
940
+ for (const value of values) {
941
+ const tokens = Math.max(0, Math.trunc(value))
942
+ if (!Number.isFinite(tokens) || total > Number.MAX_SAFE_INTEGER - tokens) return Number.MAX_SAFE_INTEGER
943
+ total += tokens
944
+ }
945
+ return total
946
+ }
947
+
948
+ function validContextTier(tier, previousSize = 0) {
949
+ if (!isRecord(tier) || tier.type !== 'context' || !Number.isSafeInteger(tier.size) || tier.size <= previousSize || tier.size > 1000000000) return false
950
+ return RATE_KEYS.every((key) => decimalText(tier[key]) !== null)
951
+ }
952
+
953
+ function validContextTierSchedule(tiers) {
954
+ if (!Array.isArray(tiers) || tiers.length === 0) return false
955
+ let previousSize = 0
956
+ for (const tier of tiers) {
957
+ if (!validContextTier(tier, previousSize)) return false
958
+ previousSize = tier.size
959
+ }
960
+ return true
961
+ }
962
+
963
+ function selectContextTier(tiers, contextTokens) {
964
+ let selected = null
965
+ for (const tier of tiers) {
966
+ // models.dev defines size as the point where the next band starts.
967
+ if (contextTokens > tier.size) selected = tier
968
+ else break
969
+ }
970
+ return selected
971
+ }
972
+
973
+ function calculateCost(values, resolved, pricingAtMs = null, pricingTimeSource = 'usage-event') {
477
974
  const input = finiteNumber(values && values.input) || 0
478
975
  const output = finiteNumber(values && values.output) || 0
479
976
  const cacheRead = finiteNumber(values && values.cacheRead) || 0
@@ -481,10 +978,39 @@ function calculateCost(values, resolved) {
481
978
  const inputSemantics = INPUT_SEMANTICS.includes(resolved && resolved.inputTokenSemantics) ? resolved.inputTokenSemantics : 'fresh'
482
979
  const billableInput = inputSemantics === 'total' ? Math.max(0, input - cacheRead - cacheWrite) : inputSemantics === 'legacy' ? Math.max(0, input - cacheRead) : input
483
980
  const billableOutput = output
981
+ const tiered = resolved && resolved.tiered === true
982
+ const tiers = tiered && Array.isArray(resolved.tiers) ? resolved.tiers : []
983
+ const tierScheduleValid = !tiered || validContextTierSchedule(tiers)
984
+ const plan = temporalPlanFor(resolved, pricingAtMs)
985
+ const pricingAt = Number.isFinite(pricingAtMs) && pricingAtMs >= 0 ? Math.trunc(pricingAtMs) : null
986
+ const timeSource = TEMPORAL_TIME_SOURCES.includes(pricingTimeSource) ? pricingTimeSource : 'usage-event'
987
+ let status = tierScheduleValid && resolved && COST_STATUSES.includes(resolved.status) ? resolved.status : tiered ? 'unsupported' : 'unpriced'
988
+ let reason = tiered && !tierScheduleValid ? 'tiered-pricing-not-modeled' : resolved && typeof resolved.reason === 'string' ? resolved.reason : 'model-not-found'
989
+ if (status === 'priced') {
990
+ if (plan.status === 'config-invalid') {
991
+ // The user submitted a temporal config that failed validation; never
992
+ // silently price with the built-in profile or the static fallback.
993
+ status = 'unsupported'
994
+ reason = 'temporal-config-invalid'
995
+ } else if (tiered && (plan.status === 'applied' || plan.status === 'history-gap' || plan.status === 'time-missing')) {
996
+ // Band plans and context tiers are two independent pricing axes; a model
997
+ // carrying both is not modeled and fails closed instead of guessing.
998
+ status = 'unsupported'
999
+ reason = 'temporal-tiered-unsupported'
1000
+ } else if (plan.status === 'history-gap') {
1001
+ // A profile exists but the usage instant falls outside its effective
1002
+ // window: never discount into an unverifiable period.
1003
+ status = 'unsupported'
1004
+ reason = 'temporal-price-history-unavailable'
1005
+ } else if (plan.status === 'time-missing') {
1006
+ status = 'unsupported'
1007
+ reason = 'temporal-time-unavailable'
1008
+ }
1009
+ }
484
1010
  const base = {
485
1011
  schemaVersion: COST_SCHEMA_VERSION,
486
1012
  pricingMode: 'official-model',
487
- status: resolved && COST_STATUSES.includes(resolved.status) ? resolved.status : 'unpriced',
1013
+ status,
488
1014
  currency: resolved && resolved.currency ? resolved.currency : 'USD',
489
1015
  source: resolved && resolved.source ? resolved.source : 'none',
490
1016
  pricingModel: resolved && resolved.pricingModel ? resolved.pricingModel : null,
@@ -493,15 +1019,35 @@ function calculateCost(values, resolved) {
493
1019
  multiplier: decimalText(resolved && resolved.multiplier !== undefined ? resolved.multiplier : '1') || '1',
494
1020
  billableInputTokens: Math.trunc(billableInput),
495
1021
  billableOutputTokens: Math.trunc(billableOutput),
1022
+ pricingAt,
1023
+ pricingTimeSource: timeSource,
1024
+ pricingBand: plan.band,
1025
+ pricingTimezone: plan.timezone,
1026
+ pricingPolicyId: plan.policyId,
1027
+ pricingPolicyHash: plan.policyHash,
1028
+ temporalApplicable: plan.status === 'applied',
1029
+ temporalExemptReason: plan.exemptReason,
496
1030
  rates: resolved && resolved.rates ? { ...resolved.rates } : { input: '0', output: '0', cacheRead: '0', cacheWrite: '0' },
497
1031
  breakdown: { input: '0', output: '0', cacheRead: '0', cacheWrite: '0' },
498
1032
  baseTotal: '0',
499
1033
  total: '0',
500
- reason: resolved && typeof resolved.reason === 'string' ? resolved.reason : 'model-not-found',
501
- tiered: resolved && resolved.tiered === true,
1034
+ reason,
1035
+ tiered,
502
1036
  reasoningRateAvailable: resolved && resolved.reasoningRateAvailable === true,
503
1037
  }
504
1038
  if (base.status !== 'priced') return base
1039
+ if (tiered) {
1040
+ const contextTokens = contextTokenCount(input, cacheRead, cacheWrite, inputSemantics)
1041
+ const selectedTier = selectContextTier(tiers, contextTokens)
1042
+ const selectedRates = selectedTier || base.rates
1043
+ base.rates = { input: selectedRates.input, output: selectedRates.output, cacheRead: selectedRates.cacheRead, cacheWrite: selectedRates.cacheWrite }
1044
+ base.contextTokens = contextTokens
1045
+ base.selectedTier = { type: 'context', size: selectedTier ? selectedTier.size : 0 }
1046
+ } else if (plan.status === 'applied' && plan.ruleRates !== null && typeof plan.ruleRates === 'object') {
1047
+ // Peak windows carry their own rates; off-peak plans without an explicit
1048
+ // default inherit the live entry rates (catalog updates flow through).
1049
+ base.rates = { input: plan.ruleRates.input, output: plan.ruleRates.output, cacheRead: plan.ruleRates.cacheRead, cacheWrite: plan.ruleRates.cacheWrite }
1050
+ }
505
1051
  base.breakdown.input = costPerMillion(base.billableInputTokens, base.rates.input)
506
1052
  base.breakdown.output = costPerMillion(base.billableOutputTokens, base.rates.output)
507
1053
  base.breakdown.cacheRead = costPerMillion(cacheRead, base.rates.cacheRead)
@@ -516,6 +1062,7 @@ function emptyCostAggregate(currency = 'USD') {
516
1062
  }
517
1063
 
518
1064
  function addCostAggregate(target, cost) {
1065
+ if (isCostAccumulator(target)) return addCostAccumulator(target, cost)
519
1066
  const value = isRecord(cost) ? cost : {}
520
1067
  const status = COST_STATUSES.includes(value.status) ? value.status : 'unpriced'
521
1068
  if (status === 'priced') {
@@ -534,14 +1081,16 @@ function addCostAggregate(target, cost) {
534
1081
 
535
1082
  function serializeCostAggregate(cost) {
536
1083
  const value = cost || emptyCostAggregate()
1084
+ const accumulator = isCostAccumulator(value)
1085
+ const text = (field) => accumulator ? decimalAccumulatorText(value[field]) : decimalText(value[field]) || '0'
537
1086
  return {
538
1087
  currency: typeof value.currency === 'string' && value.currency !== '' ? value.currency : 'USD',
539
- input: decimalText(value.input) || '0',
540
- output: decimalText(value.output) || '0',
541
- cacheRead: decimalText(value.cacheRead) || '0',
542
- cacheWrite: decimalText(value.cacheWrite) || '0',
543
- baseTotal: decimalText(value.baseTotal) || '0',
544
- total: decimalText(value.total) || '0',
1088
+ input: text('input'),
1089
+ output: text('output'),
1090
+ cacheRead: text('cacheRead'),
1091
+ cacheWrite: text('cacheWrite'),
1092
+ baseTotal: text('baseTotal'),
1093
+ total: text('total'),
545
1094
  pricedCalls: Number.isFinite(value.pricedCalls) ? value.pricedCalls : 0,
546
1095
  unpricedCalls: Number.isFinite(value.unpricedCalls) ? value.unpricedCalls : 0,
547
1096
  ambiguousCalls: Number.isFinite(value.ambiguousCalls) ? value.ambiguousCalls : 0,
@@ -567,6 +1116,32 @@ function normalizeCostSnapshot(raw) {
567
1116
  const total = decimalText(raw.total)
568
1117
  const multiplier = decimalText(raw.multiplier)
569
1118
  if (baseTotal === null || total === null || multiplier === null) return null
1119
+ let contextTokens = null
1120
+ if (raw.contextTokens !== undefined) {
1121
+ const parsed = finiteNumber(raw.contextTokens)
1122
+ if (parsed === null || parsed < 0 || !Number.isSafeInteger(parsed)) return null
1123
+ contextTokens = parsed
1124
+ }
1125
+ let selectedTier = null
1126
+ if (raw.selectedTier !== undefined && raw.selectedTier !== null) {
1127
+ if (!isRecord(raw.selectedTier) || raw.selectedTier.type !== 'context' || !Number.isSafeInteger(raw.selectedTier.size) || raw.selectedTier.size < 0 || raw.selectedTier.size > 1000000000) return null
1128
+ selectedTier = { type: 'context', size: raw.selectedTier.size }
1129
+ }
1130
+ const isV2 = raw.schemaVersion === COST_SCHEMA_VERSION
1131
+ const legacy = !isV2
1132
+ let pricingAt = null
1133
+ if (raw.pricingAt !== undefined && raw.pricingAt !== null) {
1134
+ if (!Number.isFinite(raw.pricingAt) || raw.pricingAt < 0 || !Number.isSafeInteger(raw.pricingAt)) return null
1135
+ pricingAt = raw.pricingAt
1136
+ }
1137
+ if (raw.pricingTimeSource !== undefined && raw.pricingTimeSource !== null && raw.pricingTimeSource !== 'legacy-unknown' && !TEMPORAL_TIME_SOURCES.includes(raw.pricingTimeSource)) return null
1138
+ if (raw.pricingBand !== undefined && raw.pricingBand !== null && raw.pricingBand !== 'peak' && raw.pricingBand !== 'off-peak') return null
1139
+ if (raw.pricingTimezone !== undefined && raw.pricingTimezone !== null && raw.pricingTimezone !== 'UTC') return null
1140
+ const pricingTimeSource = TEMPORAL_TIME_SOURCES.includes(raw.pricingTimeSource) ? raw.pricingTimeSource : legacy ? 'legacy-unknown' : null
1141
+ const pricingBand = raw.pricingBand === 'peak' || raw.pricingBand === 'off-peak' ? raw.pricingBand : null
1142
+ const pricingTimezone = raw.pricingTimezone === 'UTC' ? 'UTC' : null
1143
+ const pricingPolicyId = typeof raw.pricingPolicyId === 'string' && raw.pricingPolicyId !== '' ? raw.pricingPolicyId.slice(0, 128) : null
1144
+ const pricingPolicyHash = typeof raw.pricingPolicyHash === 'string' && raw.pricingPolicyHash !== '' ? raw.pricingPolicyHash.slice(0, 128) : null
570
1145
  return {
571
1146
  schemaVersion: COST_SCHEMA_VERSION,
572
1147
  pricingMode: raw.pricingMode === 'official-model' ? 'official-model' : 'legacy-provider-aware',
@@ -579,6 +1154,16 @@ function normalizeCostSnapshot(raw) {
579
1154
  multiplier,
580
1155
  billableInputTokens: Number.isFinite(raw.billableInputTokens) ? Math.max(0, Math.trunc(raw.billableInputTokens)) : 0,
581
1156
  billableOutputTokens: Number.isFinite(raw.billableOutputTokens) ? Math.max(0, Math.trunc(raw.billableOutputTokens)) : 0,
1157
+ pricingAt,
1158
+ pricingTimeSource,
1159
+ pricingBand,
1160
+ pricingTimezone,
1161
+ pricingPolicyId,
1162
+ pricingPolicyHash,
1163
+ temporalApplicable: raw.temporalApplicable === true,
1164
+ temporalExemptReason: raw.temporalExemptReason === null || raw.temporalExemptReason === undefined ? null : typeof raw.temporalExemptReason === 'string' ? raw.temporalExemptReason.slice(0, 64) : null,
1165
+ ...(contextTokens === null ? {} : { contextTokens }),
1166
+ ...(selectedTier === null ? {} : { selectedTier }),
582
1167
  rates: normalizedRates,
583
1168
  breakdown: normalizedBreakdown,
584
1169
  baseTotal,
@@ -625,7 +1210,9 @@ export {
625
1210
  PRICING_SCHEMA_VERSION,
626
1211
  RATE_KEYS,
627
1212
  addCostAggregate,
1213
+ addCostAccumulator,
628
1214
  calculateCost,
1215
+ createCostAccumulator,
629
1216
  createEmptyPricingState,
630
1217
  decimalAdd,
631
1218
  decimalMultiply,
@@ -633,13 +1220,18 @@ export {
633
1220
  decimalText,
634
1221
  emptyCostAggregate,
635
1222
  fetchModelsDevCatalog,
1223
+ isCostAccumulator,
636
1224
  modelPricingCandidates,
637
1225
  officialProviderIds,
638
1226
  normalizeCostSnapshot,
639
1227
  normalizeModelId,
640
1228
  normalizePricingState,
1229
+ normalizeTemporalPricing,
641
1230
  parseModelsDevCatalog,
642
1231
  resolvePricing,
643
1232
  serializeCostAggregate,
644
1233
  serializePricingState,
1234
+ temporalBand,
1235
+ temporalPlanFor,
1236
+ temporalPolicyHash,
645
1237
  }