dsh-all-usage 1.1.3 → 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,7 +1,7 @@
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
@@ -14,6 +14,11 @@ const MAX_CATALOG_CANDIDATES = 30000
14
14
  const MAX_OVERRIDES = 500
15
15
  const MAX_MAPPINGS = 500
16
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']
17
22
  const RATE_KEYS = ['input', 'output', 'cacheRead', 'cacheWrite']
18
23
  const INPUT_SEMANTICS = ['legacy', 'total', 'fresh']
19
24
  const COST_STATUSES = ['priced', 'unpriced', 'ambiguous', 'unsupported']
@@ -25,7 +30,7 @@ const OFFICIAL_PROVIDER_RULES = [
25
30
  { providers: ['anthropic'], prefixes: ['claude-'] },
26
31
  { providers: ['google'], prefixes: ['gemini-', 'gemma-'] },
27
32
  { providers: ['xai'], prefixes: ['grok-'] },
28
- { providers: ['deepseek'], prefixes: ['deepseek-'] },
33
+ { providers: ['deepseek', 'deepseek-official'], prefixes: ['deepseek-'] },
29
34
  { providers: ['moonshotai', 'moonshot'], prefixes: ['kimi-', 'moonshot-'] },
30
35
  { providers: ['qwen', 'alibaba'], prefixes: ['qwen'] },
31
36
  { providers: ['zai', 'zhipuai', 'zhipu'], prefixes: ['glm-', 'chatglm-'] },
@@ -325,6 +330,227 @@ function decimalRate(raw, keys, fallback = undefined) {
325
330
  return decimalText(value)
326
331
  }
327
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
+
328
554
  function normalizeContextTier(raw, fallbackRates, legacySize = null) {
329
555
  if (!isRecord(raw)) return null
330
556
  const descriptor = isRecord(raw.tier) ? raw.tier : raw
@@ -377,6 +603,12 @@ function normalizePriceEntry(raw, sourceDefault = 'models.dev') {
377
603
  tiers.length = 0
378
604
  tieredInvalid = false
379
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
380
612
  return {
381
613
  providerId: typeof raw.providerId === 'string' ? raw.providerId.trim().toLowerCase() : '',
382
614
  providerName: typeof raw.providerName === 'string' ? raw.providerName.trim().slice(0, 200) : '',
@@ -392,6 +624,8 @@ function normalizePriceEntry(raw, sourceDefault = 'models.dev') {
392
624
  ...(tiered ? { tiers, tieredInvalid } : {}),
393
625
  reasoningRateAvailable: raw.reasoningRateAvailable === true || raw.reasoning !== undefined,
394
626
  fetchedAt: Number.isFinite(raw.fetchedAt) ? raw.fetchedAt : 0,
627
+ ...(temporalPricing === undefined ? {} : { temporalPricing }),
628
+ ...(temporalPricingInvalid === true ? { temporalPricingInvalid: true } : {}),
395
629
  }
396
630
  }
397
631
 
@@ -559,7 +793,10 @@ function serializePricingState(state) {
559
793
  }
560
794
 
561
795
  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
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
563
800
  const leftTiers = Array.isArray(left.tiers) ? left.tiers : []
564
801
  const rightTiers = Array.isArray(right.tiers) ? right.tiers : []
565
802
  if (leftTiers.length !== rightTiers.length) return false
@@ -590,6 +827,32 @@ function findMappedEntry(mapping, state) {
590
827
  return null
591
828
  }
592
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
+
593
856
  function resolvePricing(identity, rawState) {
594
857
  const state = rawState && rawState._normalized === true ? rawState : normalizePricingState(rawState)
595
858
  const identityKey = identityKeyOf(identity)
@@ -649,6 +912,7 @@ function resolvePricing(identity, rawState) {
649
912
  reasoningRateAvailable: entry.reasoningRateAvailable,
650
913
  inputTokenSemantics: mapped && INPUT_SEMANTICS.includes(mapped.inputTokenSemantics) ? mapped.inputTokenSemantics : 'fresh',
651
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 } })(),
652
916
  }
653
917
  }
654
918
  const model = models[0] || null
@@ -706,7 +970,7 @@ function selectContextTier(tiers, contextTokens) {
706
970
  return selected
707
971
  }
708
972
 
709
- function calculateCost(values, resolved) {
973
+ function calculateCost(values, resolved, pricingAtMs = null, pricingTimeSource = 'usage-event') {
710
974
  const input = finiteNumber(values && values.input) || 0
711
975
  const output = finiteNumber(values && values.output) || 0
712
976
  const cacheRead = finiteNumber(values && values.cacheRead) || 0
@@ -717,10 +981,36 @@ function calculateCost(values, resolved) {
717
981
  const tiered = resolved && resolved.tiered === true
718
982
  const tiers = tiered && Array.isArray(resolved.tiers) ? resolved.tiers : []
719
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
+ }
720
1010
  const base = {
721
1011
  schemaVersion: COST_SCHEMA_VERSION,
722
1012
  pricingMode: 'official-model',
723
- status: tierScheduleValid && resolved && COST_STATUSES.includes(resolved.status) ? resolved.status : tiered ? 'unsupported' : 'unpriced',
1013
+ status,
724
1014
  currency: resolved && resolved.currency ? resolved.currency : 'USD',
725
1015
  source: resolved && resolved.source ? resolved.source : 'none',
726
1016
  pricingModel: resolved && resolved.pricingModel ? resolved.pricingModel : null,
@@ -729,11 +1019,19 @@ function calculateCost(values, resolved) {
729
1019
  multiplier: decimalText(resolved && resolved.multiplier !== undefined ? resolved.multiplier : '1') || '1',
730
1020
  billableInputTokens: Math.trunc(billableInput),
731
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,
732
1030
  rates: resolved && resolved.rates ? { ...resolved.rates } : { input: '0', output: '0', cacheRead: '0', cacheWrite: '0' },
733
1031
  breakdown: { input: '0', output: '0', cacheRead: '0', cacheWrite: '0' },
734
1032
  baseTotal: '0',
735
1033
  total: '0',
736
- reason: tiered && !tierScheduleValid ? 'tiered-pricing-not-modeled' : resolved && typeof resolved.reason === 'string' ? resolved.reason : 'model-not-found',
1034
+ reason,
737
1035
  tiered,
738
1036
  reasoningRateAvailable: resolved && resolved.reasoningRateAvailable === true,
739
1037
  }
@@ -745,6 +1043,10 @@ function calculateCost(values, resolved) {
745
1043
  base.rates = { input: selectedRates.input, output: selectedRates.output, cacheRead: selectedRates.cacheRead, cacheWrite: selectedRates.cacheWrite }
746
1044
  base.contextTokens = contextTokens
747
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 }
748
1050
  }
749
1051
  base.breakdown.input = costPerMillion(base.billableInputTokens, base.rates.input)
750
1052
  base.breakdown.output = costPerMillion(base.billableOutputTokens, base.rates.output)
@@ -825,6 +1127,21 @@ function normalizeCostSnapshot(raw) {
825
1127
  if (!isRecord(raw.selectedTier) || raw.selectedTier.type !== 'context' || !Number.isSafeInteger(raw.selectedTier.size) || raw.selectedTier.size < 0 || raw.selectedTier.size > 1000000000) return null
826
1128
  selectedTier = { type: 'context', size: raw.selectedTier.size }
827
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
828
1145
  return {
829
1146
  schemaVersion: COST_SCHEMA_VERSION,
830
1147
  pricingMode: raw.pricingMode === 'official-model' ? 'official-model' : 'legacy-provider-aware',
@@ -837,6 +1154,14 @@ function normalizeCostSnapshot(raw) {
837
1154
  multiplier,
838
1155
  billableInputTokens: Number.isFinite(raw.billableInputTokens) ? Math.max(0, Math.trunc(raw.billableInputTokens)) : 0,
839
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,
840
1165
  ...(contextTokens === null ? {} : { contextTokens }),
841
1166
  ...(selectedTier === null ? {} : { selectedTier }),
842
1167
  rates: normalizedRates,
@@ -901,8 +1226,12 @@ export {
901
1226
  normalizeCostSnapshot,
902
1227
  normalizeModelId,
903
1228
  normalizePricingState,
1229
+ normalizeTemporalPricing,
904
1230
  parseModelsDevCatalog,
905
1231
  resolvePricing,
906
1232
  serializeCostAggregate,
907
1233
  serializePricingState,
1234
+ temporalBand,
1235
+ temporalPlanFor,
1236
+ temporalPolicyHash,
908
1237
  }
@@ -1,4 +1,4 @@
1
- import { extractUsageEvent } from './usage-core.js'
1
+ import { contextTimeKey, extractUsageEvent, pickPricingTime, touchContextTimes } from './usage-core.js'
2
2
 
3
3
  const RECONCILE_INTERVAL_MS = 120000
4
4
  const RECONCILE_HINT_DELAY_MS = 3000
@@ -25,6 +25,7 @@ export function createSessionSync(host) {
25
25
  drainLedgerWrites,
26
26
  } = host.ledger
27
27
  const backfillUnpricedCosts = (...args) => host.pricing.backfillUnpricedCosts(...args)
28
+ const reconcileTemporalPricing = (...args) => host.pricing.reconcileTemporalPricing(...args)
28
29
  const safeContextTimeout = async (ms) => {
29
30
  if (state.disposed) return false
30
31
  try {
@@ -38,7 +39,14 @@ export function createSessionSync(host) {
38
39
 
39
40
  function foldEvent(wsId, time, type, data, sid, seq, materialization = 'live') {
40
41
  if ((type === 'turn/end' || type === 'assistant/message' || type === 'assistant/chunk') && !validEventTime(time)) return
41
- if (type === 'request/context' || type === 'request/header') {
42
+ if (type === 'request/context') {
43
+ state.sessionModel.set(sid, identityFromRoute(data, state.sessionModel.get(sid)))
44
+ if (validEventTime(time)) {
45
+ let times = state.sessionContextTimes.get(sid)
46
+ if (times === undefined) { times = new Map(); state.sessionContextTimes.set(sid, times) }
47
+ touchContextTimes(times, contextTimeKey(data && data.turn, data && data.step), time)
48
+ }
49
+ } else if (type === 'request/header') {
42
50
  state.sessionModel.set(sid, identityFromRoute(data, state.sessionModel.get(sid)))
43
51
  } else if (type === 'turn/end') {
44
52
  addTurn(wsId, time, sid, data && typeof data.turn === 'number' ? data.turn : null, state.sessionModel.get(sid), materialization, seq)
@@ -47,10 +55,15 @@ export function createSessionSync(host) {
47
55
  if (usageEvent === null) return
48
56
  const identity = usageEvent.kind === 'message' ? identityFromMessage(data, state.sessionModel.get(sid)) : coerceIdentity(state.sessionModel.get(sid))
49
57
  state.sessionModel.set(sid, identity)
50
- addUsage(wsId, time, usageEvent.usage, identity, sid, data, seq, materialization)
58
+ const pricing = pickPricingTime(state.sessionContextTimes.get(sid), time, usageEvent.turn, usageEvent.step)
59
+ addUsage(wsId, time, usageEvent.usage, identity, sid, data, seq, materialization, pricing.time, pricing.source)
51
60
  }
52
61
  }
53
62
  function foldEvents(wsId, events, fromSeq, sid, materialization = 'scan') {
63
+ // Full folds keep their callers responsible for atomicity: runBaseline and
64
+ // syncLiveSession remove the session's old usage/turn/context/identity
65
+ // first when the snapshot is authoritative (see rebuildSession below), so
66
+ // usage deleted by a history rewrite disappears from the aggregate.
54
67
  for (const ev of events) {
55
68
  if (fromSeq !== undefined) {
56
69
  const s = typeof ev.seq === 'number' ? ev.seq : -1
@@ -140,13 +153,41 @@ export function createSessionSync(host) {
140
153
  const snap = await ctx.sessionQuery.readSession(sid)
141
154
  if (state.disposed || generation !== state.aggregationGeneration) return true
142
155
  if (snap && Array.isArray(snap.events)) {
143
- const previousLast = state.sessionSeq.get(sid)
144
- const previousLastSafe = Number.isSafeInteger(previousLast) && previousLast >= 0 ? previousLast : -1
145
156
  const snapshotLast = lastSeqOf(snap.events)
146
- foldEvents(wsId, snap.events, undefined, sid, 'live')
147
- let nextLast = Math.max(previousLastSafe, snapshotLast)
157
+ const snapshotProfile = sequenceProfile(snap.events)
148
158
  const eventSeq = event === null || event === undefined ? -1 : (Number.isSafeInteger(event.seq) && event.seq >= 0 ? event.seq : -1)
149
- const needsFollowup = event !== null && event !== undefined && (eventSeq < 0 || eventSeq > snapshotLast)
159
+ // The snapshot may replace the session only when it is provably
160
+ // complete AND it already contains the triggering event: a timer / full
161
+ // resync (no event), or an explicitly contained, monotonic event
162
+ // stream. A snapshot that merely has a higher tail than the event is
163
+ // NOT proof — the event could be a live append the snapshot missed (or
164
+ // the snapshot could have been rewritten without it), so the event is
165
+ // upserted and the session stays pending for a follow-up alignment.
166
+ // Events without a usable seq never trigger a destructive replacement.
167
+ const containsEvent = event !== null && event !== undefined && eventSeq >= 0 && snap.events.some((candidate) => candidate && Number.isSafeInteger(candidate.seq) && candidate.seq === eventSeq)
168
+ const authoritative = event === null || event === undefined ? true : eventSeq < 0 ? false : containsEvent && !snapshotProfile.nonMonotonic && !snapshotProfile.hasInvalid
169
+ if (authoritative) {
170
+ host.aggregation.removeSession(sid)
171
+ // The authoritative snapshot is the new truth for both the in-memory
172
+ // aggregate and the durable ledger: rebuild the record from scratch
173
+ // and persist it, so a restart after a live history rewrite cannot
174
+ // resurrect usage that the snapshot deleted.
175
+ const rebuiltRecord = buildLedgerRecord({ id: sid, events: snap.events }, wsId, 'scan', undefined, null, true)
176
+ if (rebuiltRecord !== null) {
177
+ const canonical = replaceLedgerRecord(rebuiltRecord)
178
+ if (canonical === rebuiltRecord) {
179
+ void persistLedgerRecord(rebuiltRecord).then(() => {
180
+ if (state.ledgerWriteFailedSessions && state.ledgerWriteFailedSessions.has(sid)) {
181
+ console.error('[all-usage] authoritative resync ledger persist failed; session stays dirty:', sid)
182
+ noteSyncError('resync-ledger-persist-failed')
183
+ }
184
+ })
185
+ }
186
+ }
187
+ }
188
+ foldEvents(wsId, snap.events, undefined, sid, 'live')
189
+ let nextLast = snapshotLast
190
+ const needsFollowup = !authoritative && event !== null && event !== undefined
150
191
  if (needsFollowup) {
151
192
  foldLiveFallback(sid, wsId, event)
152
193
  const current = state.sessionSeq.get(sid)
@@ -154,8 +195,14 @@ export function createSessionSync(host) {
154
195
  }
155
196
  state.sessionSeq.set(sid, nextLast)
156
197
  state.sessionCount.add(sid)
157
- if (needsFollowup) scheduleLiveResync(sid, wsId, generation)
158
- else cancelLiveResync(sid)
198
+ if (needsFollowup) {
199
+ // The snapshot is behind: keep the pending flag so the follow-up
200
+ // resync realigns the cursor once the missing history arrives.
201
+ scheduleLiveResync(sid, wsId, generation)
202
+ } else {
203
+ state.liveResyncPending.delete(sid)
204
+ cancelLiveResync(sid)
205
+ }
159
206
  return true
160
207
  }
161
208
  } catch (err) {
@@ -420,6 +467,12 @@ export function createSessionSync(host) {
420
467
  markStatsChanged('pricing')
421
468
  }
422
469
  if (state.disposed || generation !== state.aggregationGeneration) return
470
+ const temporalReconcile = reconcileTemporalPricing()
471
+ if (temporalReconcile.reconciled > 0) {
472
+ await drainLedgerWrites()
473
+ markStatsChanged('pricing')
474
+ }
475
+ if (state.disposed || generation !== state.aggregationGeneration) return
423
476
  state.knownSessionIds.clear()
424
477
  for (const sid of listedSessionIds) state.knownSessionIds.add(sid)
425
478
  state.scan.done = true
package/lib/usage-core.js CHANGED
@@ -107,6 +107,50 @@ export function usageStepKey(sid, data, seq, fallback) {
107
107
  return safeSid + ':event:' + (serialized === undefined ? String(fallback === undefined ? '' : fallback) : serialized)
108
108
  }
109
109
 
110
+ /** Recalculate the billing instant for a re-priced sample, preferring the recorded request time. */
111
+ export function billingInstantOf(item, oldCost) {
112
+ const oldAt = oldCost != null && Number.isFinite(oldCost.pricingAt) ? oldCost.pricingAt : null
113
+ const at = Number.isFinite(item.pricingAt) ? item.pricingAt : oldAt !== null ? oldAt : validEventTime(item.time) ? item.time : null
114
+ const oldSource = oldCost != null && (oldCost.pricingTimeSource === 'request-context' || oldCost.pricingTimeSource === 'usage-event') ? oldCost.pricingTimeSource : null
115
+ const source = typeof item.pricingTimeSource === 'string' && (item.pricingTimeSource === 'request-context' || item.pricingTimeSource === 'usage-event') ? item.pricingTimeSource : (oldSource || 'usage-event')
116
+ return { at, source }
117
+ }
118
+
119
+ /** Bounded context archive shared by the live fold and the persisted ledger. */
120
+ export const MAX_CONTEXT_TIMES = 512
121
+
122
+ /** Insert-or-refresh a context entry with real LRU eviction (delete + set). */
123
+ export function touchContextTimes(times, key, time) {
124
+ if (typeof key !== 'string' || key === '') return
125
+ if (times.has(key)) times.delete(key)
126
+ times.set(key, time)
127
+ while (times.size > MAX_CONTEXT_TIMES) times.delete(times.keys().next().value)
128
+ }
129
+
130
+ /** Stable per-(turn, step) key for the pricing-time context of a request. */
131
+ export function contextTimeKey(turn, step) {
132
+ const safeTurn = Number.isSafeInteger(turn) && turn >= 0 ? turn : null
133
+ const safeStep = Number.isSafeInteger(step) && step >= 0 ? step : null
134
+ if (safeTurn !== null && safeStep !== null) return 'context:' + safeTurn + ':' + safeStep
135
+ return 'context:__latest__'
136
+ }
137
+
138
+ /**
139
+ * Pick the billing instant for a usage event: the request/context time that
140
+ * matches the same turn/step wins (parallel requests stay per-step), otherwise
141
+ * the usage event time itself is the auditable fallback.
142
+ */
143
+ export function pickPricingTime(contextTimes, eventTime, turn, step) {
144
+ if (contextTimes instanceof Map && validEventTime(eventTime)) {
145
+ const exact = contextTimes.get(contextTimeKey(turn, step))
146
+ const candidate = exact !== undefined ? exact : contextTimes.get(contextTimeKey(null, null))
147
+ if (Number.isFinite(candidate) && candidate <= eventTime) {
148
+ return { time: candidate, source: 'request-context' }
149
+ }
150
+ }
151
+ return { time: Number.isFinite(eventTime) && eventTime >= 0 ? eventTime : null, source: 'usage-event' }
152
+ }
153
+
110
154
  /** Replace one logical sample; stale lower-seq replays are ignored. */
111
155
  /** Event sequence contract: -1 (missing) or a non-negative safe integer. */
112
156
  export function normalizeEventSeq(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-all-usage",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "description": "DeepSeek Harness usage dashboard with model, provider, workspace, cache, balance, and CSV insights",
5
5
  "repository": {
6
6
  "type": "git",