@goodandready/dsh-cost-meter 0.8.8 → 0.8.9

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.
Files changed (3) hide show
  1. package/lib/index.js +43 -275
  2. package/lib/tariff.js +236 -0
  3. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -21,6 +21,9 @@ import { dirname, join } from 'node:path'
21
21
  import z from '@deepseek-ai/schemastery'
22
22
  import { z as zod } from 'zod'
23
23
 
24
+ export * from './tariff.js'
25
+ import { indexCatalog, resolveTariff, matchModel } from './tariff.js'
26
+
24
27
  export const name = '@goodandready/dsh-cost-meter'
25
28
  export const inject = ['webServer']
26
29
 
@@ -64,35 +67,6 @@ const PriceRow = z.object({
64
67
  output: z.number().description('Output tokens (reasoning included).').default(0),
65
68
  })
66
69
 
67
- /**
68
- * DeepSeek's own tariff, from https://api-docs.deepseek.com/quick_start/pricing.
69
- * Their API publishes no prices (/models lists ids, there is no pricing route),
70
- * and OpenRouter's `deepseek/*` entries quote different numbers, so this table
71
- * is the authoritative source for the official route. Values are PEAK, USD per
72
- * 1M tokens; off-peak is exactly half. Peak: 01:00-04:00 and 06:00-10:00 UTC.
73
- */
74
- const DEEPSEEK_PEAK_RATES = {
75
- 'deepseek-v4-flash': { cacheHit: 0.014, input: 0.44, cacheWrite: 0.44, output: 1.32 },
76
- 'deepseek-v4-pro': { cacheHit: 0.044, input: 1.32, cacheWrite: 1.32, output: 3.96 },
77
- }
78
- const DEEPSEEK_PEAK_WINDOWS = [{ start: 60, end: 240 }, { start: 360, end: 600 }]
79
-
80
- /** Common provider / vendor aliases for seamless catalog matching. */
81
- export const VENDOR_ALIASES = {
82
- 'z-ai': 'zai',
83
- 'zai': 'zai',
84
- 'google': 'google',
85
- 'gemini': 'google',
86
- 'deepseek-ai': 'deepseek',
87
- 'deepseek-official': 'deepseek',
88
- 'deepseek': 'deepseek',
89
- 'anthropic-ai': 'anthropic',
90
- 'anthropic': 'anthropic',
91
- 'openai': 'openai',
92
- 'meta-llama': 'meta-llama',
93
- 'meta': 'meta-llama',
94
- }
95
-
96
70
  export const Config = z.object({
97
71
  currency: z.string().description('Currency symbol shown in the widget.').default('$'),
98
72
  usdRate: z.number().description('Units of `currency` per USD. 1 keeps everything in USD.').default(1),
@@ -131,113 +105,6 @@ export const Config = z.object({
131
105
  .default(1),
132
106
  })
133
107
 
134
- /** OpenRouter prices are USD per token; the widget speaks per 1M tokens. */
135
- function perMillion(value) {
136
- const n = Number(value)
137
- return Number.isFinite(n) ? n * 1e6 : 0
138
- }
139
-
140
- /** Pull the four buckets out of one OpenRouter pricing object. */
141
- function bucketsOf(pricing) {
142
- return {
143
- cacheHit: perMillion(pricing.input_cache_read),
144
- input: perMillion(pricing.prompt),
145
- cacheWrite: perMillion(pricing.input_cache_write),
146
- output: perMillion(pricing.completion),
147
- }
148
- }
149
-
150
- /** OpenRouter encodes window edges as UTC HHMM integers (1000 = 10:00). */
151
- export function hhmmToMinutes(value) {
152
- const n = Number(value)
153
- if (!Number.isFinite(n) || n < 0 || n > 2400) return undefined
154
- return Math.floor(n / 100) * 60 + (n % 100)
155
- }
156
-
157
- /** "HH:MM-HH:MM" -> {start,end} in minutes; undefined when malformed. */
158
- export function parseWindow(spec) {
159
- const m = /^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/.exec(String(spec).trim())
160
- if (!m) return undefined
161
- return { start: Number(m[1]) * 60 + Number(m[2]), end: Number(m[3]) * 60 + Number(m[4]) }
162
- }
163
-
164
- /**
165
- * Normalize one catalog entry into the schedule the browser consumes:
166
- * a base rate card plus the discount windows that shadow it.
167
- */
168
- function scheduleOf(entry) {
169
- const pricing = entry.pricing || {}
170
- const base = bucketsOf(pricing)
171
- const windows = []
172
- for (const override of pricing.overrides || []) {
173
- const start = hhmmToMinutes(override.utc_start)
174
- const end = hhmmToMinutes(override.utc_end)
175
- if (start === undefined || end === undefined) continue
176
- // An override omits buckets it does not change; fall back to the base.
177
- const rates = bucketsOf(override)
178
- windows.push({
179
- start,
180
- end,
181
- rates: {
182
- cacheHit: rates.cacheHit || base.cacheHit,
183
- input: rates.input || base.input,
184
- cacheWrite: rates.cacheWrite || base.cacheWrite,
185
- output: rates.output || base.output,
186
- },
187
- })
188
- }
189
- return { base, windows }
190
- }
191
-
192
- /**
193
- * Map a harness route onto an OpenRouter id. Exact id first, then normalized
194
- * vendor alias + model, then vendor suffix.
195
- */
196
- export function matchModel(index, route, modelMap) {
197
- if (!route || !index) return undefined
198
- const key = route.provider ? route.provider + '/' + route.model : route.model
199
- const mapped = modelMap && (modelMap[key] || modelMap[route.model])
200
- if (mapped && index.byId && index.byId[mapped]) return mapped
201
- if (index.byId && index.byId[key]) return key
202
- if (index.byId && index.byId[route.model]) return route.model
203
-
204
- // Provider alias check (e.g. z-ai -> zai, deepseek-official -> deepseek)
205
- const prov = String(route.provider || '').toLowerCase()
206
- const aliasProv = VENDOR_ALIASES[prov]
207
- if (aliasProv && route.model) {
208
- const aliasKey = (aliasProv + '/' + route.model).toLowerCase()
209
- if (index.byLowerId && index.byLowerId[aliasKey]) return index.byLowerId[aliasKey]
210
- }
211
-
212
- // Harness routes name models in their own casing (`MiniMax-M3`), the catalog
213
- // is lower-case (`minimax/minimax-m3`), so matching is case-insensitive.
214
- const lower = String(route.model).toLowerCase()
215
- const byLowerId = index.byLowerId || {}
216
- if (byLowerId[lower]) return byLowerId[lower]
217
- if (byLowerId[key.toLowerCase()]) return byLowerId[key.toLowerCase()]
218
- const bySuffix = index.bySuffix || {}
219
- const list = bySuffix[lower]
220
- return list && list.length ? list[0] : undefined
221
- }
222
-
223
- /** Build lookup tables once per fetch; the raw catalog is ~700 KB. */
224
- export function indexCatalog(models) {
225
- const byId = {}
226
- const byLowerId = {}
227
- const bySuffix = {}
228
- for (const entry of models) {
229
- if (!entry || typeof entry.id !== 'string' || !entry.pricing) continue
230
- // Variants like ":batch" or ":free" are different products; skip them so a
231
- // suffix match cannot silently price against a cheaper batch tier.
232
- if (entry.id.includes(':')) continue
233
- byId[entry.id] = scheduleOf(entry)
234
- byLowerId[entry.id.toLowerCase()] = entry.id
235
- const suffix = entry.id.includes('/') ? entry.id.slice(entry.id.indexOf('/') + 1) : entry.id
236
- ;(bySuffix[suffix.toLowerCase()] = bySuffix[suffix.toLowerCase()] || []).push(entry.id)
237
- }
238
- return { byId, byLowerId, bySuffix }
239
- }
240
-
241
108
  // ---------------------------------------------------------------------------
242
109
  // Per-model usage projection
243
110
  // ---------------------------------------------------------------------------
@@ -373,80 +240,6 @@ function defaultRoute(ctx) {
373
240
  }
374
241
  }
375
242
 
376
- /**
377
- * Resolve one route's tariff: manual config wins, then DeepSeek's own table,
378
- * then the OpenRouter catalog.
379
- */
380
- export function resolveTariff(cfg, index, route) {
381
- if (!route) return { schedule: null, source: 'none', matchedId: null }
382
- const key = route.provider ? route.provider + '/' + route.model : route.model
383
- const prices = cfg.prices || {}
384
- const manual = prices[key] || prices[route.model] || prices['*']
385
-
386
- if (manual) {
387
- const base = {
388
- cacheHit: Number(manual.cacheHit) || 0,
389
- input: Number(manual.input) || 0,
390
- cacheWrite: Number(manual.cacheWrite) || 0,
391
- output: Number(manual.output) || 0,
392
- }
393
- const factor = Number(cfg.manualOffPeakMultiplier) || 1
394
- const windows = []
395
- for (const spec of cfg.manualPeakWindowsUtc || []) {
396
- const parsed = parseWindow(spec)
397
- if (parsed) windows.push({ start: parsed.start, end: parsed.end, rates: base })
398
- }
399
- const schedule = windows.length
400
- ? {
401
- base: {
402
- cacheHit: base.cacheHit * factor,
403
- input: base.input * factor,
404
- cacheWrite: base.cacheWrite * factor,
405
- output: base.output * factor,
406
- },
407
- windows,
408
- }
409
- : { base, windows: [] }
410
- return { schedule, source: 'manual', matchedId: key }
411
- }
412
-
413
- const providerLower = String(route.provider || '').toLowerCase()
414
- if (providerLower === 'openrouter' && cfg.useOpenRouter !== false) {
415
- const matchedId = matchModel(index, route, cfg.modelMap)
416
- if (matchedId) return { schedule: index.byId[matchedId], source: 'openrouter', matchedId }
417
- }
418
-
419
- const base = String(route.model).toLowerCase().split(':')[0]
420
- const peak = (cfg.deepseekPeakPrices || {})[base] || DEEPSEEK_PEAK_RATES[base]
421
- if (peak) {
422
- const full = {
423
- cacheHit: Number(peak.cacheHit) || 0,
424
- input: Number(peak.input) || 0,
425
- cacheWrite: Number(peak.cacheWrite) || 0,
426
- output: Number(peak.output) || 0,
427
- }
428
- return {
429
- schedule: {
430
- base: {
431
- cacheHit: full.cacheHit / 2,
432
- input: full.input / 2,
433
- cacheWrite: full.cacheWrite / 2,
434
- output: full.output / 2,
435
- },
436
- windows: DEEPSEEK_PEAK_WINDOWS.map((w) => ({ start: w.start, end: w.end, rates: full })),
437
- },
438
- source: 'deepseek',
439
- matchedId: base,
440
- }
441
- }
442
-
443
- if (cfg.useOpenRouter !== false) {
444
- const matchedId = matchModel(index, route, cfg.modelMap)
445
- if (matchedId) return { schedule: index.byId[matchedId], source: 'openrouter', matchedId }
446
- }
447
- return { schedule: null, source: 'none', matchedId: null }
448
- }
449
-
450
243
  /** "provider/model" -> {provider, model}; the model half may contain slashes. */
451
244
  export function splitRoute(key) {
452
245
  const str = String(key || '').trim()
@@ -520,105 +313,80 @@ export function apply(ctx, config) {
520
313
  ctx.inject(['settings'], (sctx) => {
521
314
  const scope = sctx.settings.register(NS, Config, { base: config })
522
315
  current = scope.get() ?? config
523
- const stop = scope.watch(() => {
524
- current = scope.get() ?? config
525
- clearResolveCache()
526
- void refresh()
527
- })
528
- sctx.effect(() => () => {
529
- stop()
530
- current = config
316
+ scope.watch((next) => {
317
+ current = next
531
318
  clearResolveCache()
532
319
  })
533
320
  })
534
321
 
535
- // Optional capability: headless assemblies without the registry keep working
536
- ctx.inject(['sessionProjections'], (pctx) => {
537
- pctx.sessionProjections.register(usageByModelProjection)
322
+ // Hook the session projection so the widget can show spend breakdown.
323
+ ctx.inject(['chatSessionProjections'], (pctx) => {
324
+ pctx.chatSessionProjections.register(usageByModelProjection)
538
325
  })
539
326
 
540
- ctx.effect(() => {
541
- const controller = new AbortController()
542
- void refresh(controller.signal)
543
- const timer = setInterval(() => void refresh(controller.signal), 3600e3)
544
- return () => {
545
- clearInterval(timer)
546
- controller.abort()
547
- }
548
- }, 'dsh-cost-meter: catalog refresh')
549
-
550
- // Route 1: POST /dsh-cost-meter/refresh (manual on-demand sync with 30s cooldown)
327
+ // Route 1: POST /dsh-cost-meter/refresh (manual catalog reload)
551
328
  ctx.effect(
552
329
  () =>
553
330
  ctx.webServer.register({
554
- kind: 'exact',
555
331
  path: REFRESH_PATH,
332
+ method: 'POST',
556
333
  handler: async (req, res) => {
557
- if (req.method !== 'POST') {
558
- res.writeHead(405, { 'Content-Type': 'application/json' })
559
- res.end(JSON.stringify({ error: 'method not allowed' }))
560
- return
561
- }
562
334
  if (!isTrustedCaller(req)) {
563
335
  res.writeHead(403, { 'Content-Type': 'application/json' })
564
- res.end(JSON.stringify({ error: 'forbidden source origin' }))
336
+ res.end(JSON.stringify({ error: 'forbidden' }))
565
337
  return
566
338
  }
567
339
  const now = Date.now()
568
- const COOLDOWN_MS = 30000
569
- if (now - lastManualRefreshAt < COOLDOWN_MS) {
570
- const retryAfter = Math.ceil((COOLDOWN_MS - (now - lastManualRefreshAt)) / 1000)
571
- res.writeHead(429, {
572
- 'Content-Type': 'application/json',
573
- 'Retry-After': String(retryAfter),
574
- })
575
- res.end(JSON.stringify({ error: 'rate limit exceeded', retryAfter }))
340
+ if (now - lastManualRefreshAt < 60_000) {
341
+ res.writeHead(429, { 'Content-Type': 'application/json' })
342
+ res.end(JSON.stringify({ error: 'rate_limited', retryAfterMs: 60_000 - (now - lastManualRefreshAt) }))
576
343
  return
577
344
  }
578
345
  lastManualRefreshAt = now
579
- const controller = new AbortController()
580
- await refresh(controller.signal, true)
581
- const modelsCount = Object.keys((catalog.index && catalog.index.byId) || {}).length
346
+ await refresh(null, true)
582
347
  res.writeHead(200, { 'Content-Type': 'application/json' })
583
- res.end(JSON.stringify({
584
- ok: !catalog.error,
585
- fetchedAt: catalog.fetchedAt,
586
- modelsCount,
587
- error: catalog.error,
588
- }))
348
+ res.end(
349
+ JSON.stringify({
350
+ ok: !catalog.error,
351
+ fetchedAt: catalog.fetchedAt,
352
+ models: Object.keys((catalog.index && catalog.index.byId) || {}).length,
353
+ error: catalog.error,
354
+ }),
355
+ )
589
356
  },
590
357
  }),
591
358
  'dsh-cost-meter: refresh route',
592
359
  )
593
360
 
594
- // Route 2: GET /dsh-cost-meter/state (with ETag and 304 Not Modified)
361
+ // Route 2: GET /dsh-cost-meter/state (the browser poller hits this)
595
362
  ctx.effect(
596
363
  () =>
597
364
  ctx.webServer.register({
598
- kind: 'exact',
599
365
  path: STATE_PATH,
600
- handler: (req, res) => {
601
- if (req.method !== 'GET') {
602
- res.writeHead(405, { 'Content-Type': 'application/json' })
603
- res.end(JSON.stringify({ error: 'method not allowed' }))
604
- return
605
- }
366
+ method: 'GET',
367
+ handler: async (req, res) => {
606
368
  const cfg = current ?? config
369
+
370
+ // On the very first request after boot, wait for the fetch if we have
371
+ // no disk cache either. Later runs refresh in the background.
372
+ if (!catalog.fetchedAt && !catalog.error) {
373
+ await refresh()
374
+ } else {
375
+ void refresh()
376
+ }
377
+
607
378
  const route = defaultRoute(ctx) ?? null
608
379
  const own = getCachedTariff(cfg, route)
609
380
 
381
+ // Also resolve tariffs for any session routes requested in query params
610
382
  const schedules = {}
611
- const asked = new URL(req.url, 'http://localhost').searchParams.get('models')
612
- for (const key of (asked ? asked.split(',') : []).slice(0, 24)) {
613
- const trimmed = String(key || '').trim()
614
- if (!trimmed || trimmed.length > 128) continue
615
- const parsed = splitRoute(trimmed)
616
- if (!parsed) continue
617
- const resolved = getCachedTariff(cfg, parsed)
618
- schedules[trimmed] = {
619
- schedule: resolved.schedule,
620
- source: resolved.source,
621
- matchedId: resolved.matchedId,
383
+ const url = new URL(req.url, 'http://localhost')
384
+ const extraRoutes = url.searchParams.getAll('route')
385
+ for (const key of extraRoutes) {
386
+ const parsed = splitRoute(key)
387
+ if (parsed) {
388
+ const res = getCachedTariff(cfg, parsed)
389
+ if (res && res.schedule) schedules[key] = res.schedule
622
390
  }
623
391
  }
624
392
 
package/lib/tariff.js ADDED
@@ -0,0 +1,236 @@
1
+ // Tariff calculation, normalization, catalog indexing, and model matching.
2
+ // Extracted from lib/index.js for modularity (<600 LOC per file).
3
+
4
+ /**
5
+ * DeepSeek's own tariff, from https://api-docs.deepseek.com/quick_start/pricing.
6
+ * Their API publishes no prices (/models lists ids, there is no pricing route),
7
+ * and OpenRouter's `deepseek/*` entries quote different numbers, so this table
8
+ * is the authoritative source for the official route. Values are PEAK, USD per
9
+ * 1M tokens; off-peak is exactly half. Peak: 01:00-04:00 and 06:00-10:00 UTC.
10
+ */
11
+ export const DEEPSEEK_PEAK_RATES = {
12
+ 'deepseek-v4-flash': { cacheHit: 0.014, input: 0.44, cacheWrite: 0.44, output: 1.32 },
13
+ 'deepseek-v4-pro': { cacheHit: 0.044, input: 1.32, cacheWrite: 1.32, output: 3.96 },
14
+ }
15
+ export const DEEPSEEK_PEAK_WINDOWS = [{ start: 60, end: 240 }, { start: 360, end: 600 }]
16
+
17
+ /** Common provider / vendor aliases for seamless catalog matching. */
18
+ export const VENDOR_ALIASES = {
19
+ 'z-ai': 'zai',
20
+ 'zai': 'zai',
21
+ 'google': 'google',
22
+ 'gemini': 'google',
23
+ 'deepseek-ai': 'deepseek',
24
+ 'deepseek-official': 'deepseek',
25
+ 'deepseek': 'deepseek',
26
+ 'anthropic-ai': 'anthropic',
27
+ 'anthropic': 'anthropic',
28
+ 'openai': 'openai',
29
+ 'meta-llama': 'meta-llama',
30
+ 'meta': 'meta-llama',
31
+ }
32
+
33
+ /** OpenRouter prices are USD per token; the widget speaks per 1M tokens. */
34
+ export function perMillion(value) {
35
+ const n = Number(value)
36
+ return Number.isFinite(n) ? n * 1e6 : 0
37
+ }
38
+
39
+ /** Pull the four buckets out of one OpenRouter pricing object. */
40
+ export function bucketsOf(pricing) {
41
+ return {
42
+ cacheHit: perMillion(pricing.input_cache_read),
43
+ input: perMillion(pricing.prompt),
44
+ cacheWrite: perMillion(pricing.input_cache_write),
45
+ output: perMillion(pricing.completion),
46
+ }
47
+ }
48
+
49
+ /** OpenRouter encodes window edges as UTC HHMM integers (1000 = 10:00). */
50
+ export function hhmmToMinutes(value) {
51
+ const n = Number(value)
52
+ if (!Number.isFinite(n) || n < 0 || n > 2400) return undefined
53
+ return Math.floor(n / 100) * 60 + (n % 100)
54
+ }
55
+
56
+ /** "HH:MM-HH:MM" -> {start,end} in minutes; undefined when malformed. */
57
+ export function parseWindow(spec) {
58
+ const m = /^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/.exec(String(spec).trim())
59
+ if (!m) return undefined
60
+ return { start: Number(m[1]) * 60 + Number(m[2]), end: Number(m[3]) * 60 + Number(m[4]) }
61
+ }
62
+
63
+ export function overrideBucket(overrideVal, baseVal) {
64
+ if (overrideVal !== undefined && overrideVal !== null && overrideVal !== '') {
65
+ const n = Number(overrideVal)
66
+ if (Number.isFinite(n)) return n * 1e6
67
+ }
68
+ return baseVal
69
+ }
70
+
71
+ /**
72
+ * Normalize one catalog entry into the schedule the browser consumes:
73
+ * a base rate card plus the discount windows that shadow it.
74
+ */
75
+ export function scheduleOf(entry) {
76
+ const pricing = entry.pricing || {}
77
+ const base = bucketsOf(pricing)
78
+ const windows = []
79
+ for (const override of pricing.overrides || []) {
80
+ const start = hhmmToMinutes(override.utc_start)
81
+ const end = hhmmToMinutes(override.utc_end)
82
+ if (start === undefined || end === undefined) continue
83
+ // An override omits buckets it does not change; fall back to the base.
84
+ // Explicit 0 in override must NOT fall back to non-zero base price.
85
+ windows.push({
86
+ start,
87
+ end,
88
+ rates: {
89
+ cacheHit: overrideBucket(override.input_cache_read, base.cacheHit),
90
+ input: overrideBucket(override.prompt, base.input),
91
+ cacheWrite: overrideBucket(override.input_cache_write, base.cacheWrite),
92
+ output: overrideBucket(override.completion, base.output),
93
+ },
94
+ })
95
+ }
96
+ return { base, windows }
97
+ }
98
+
99
+ /**
100
+ * Map a harness route onto an OpenRouter id. Exact id first, then normalized
101
+ * vendor alias + model, then vendor suffix.
102
+ */
103
+ export function matchModel(index, route, modelMap) {
104
+ if (!route || !index) return undefined
105
+ const key = route.provider ? route.provider + '/' + route.model : route.model
106
+ const mapped = modelMap && (modelMap[key] || modelMap[route.model])
107
+ if (mapped && index.byId && index.byId[mapped]) return mapped
108
+ if (index.byId && index.byId[key]) return key
109
+ if (index.byId && index.byId[route.model]) return route.model
110
+
111
+ // Provider alias check (e.g. z-ai -> zai, deepseek-official -> deepseek)
112
+ const prov = String(route.provider || '').toLowerCase()
113
+ const aliasProv = VENDOR_ALIASES[prov]
114
+ if (aliasProv && route.model) {
115
+ const aliasKey = (aliasProv + '/' + route.model).toLowerCase()
116
+ if (index.byLowerId && index.byLowerId[aliasKey]) return index.byLowerId[aliasKey]
117
+ }
118
+
119
+ // Harness routes name models in their own casing (`MiniMax-M3`), the catalog
120
+ // is lower-case (`minimax/minimax-m3`), so matching is case-insensitive.
121
+ const lower = String(route.model).toLowerCase()
122
+ const byLowerId = index.byLowerId || {}
123
+ if (byLowerId[lower]) return byLowerId[lower]
124
+ if (byLowerId[key.toLowerCase()]) return byLowerId[key.toLowerCase()]
125
+ const bySuffix = index.bySuffix || {}
126
+ const list = bySuffix[lower]
127
+ if (!list || !list.length) return undefined
128
+ if (list.length === 1) return list[0]
129
+
130
+ // Multiple catalog entries share the same model suffix: disambiguate by provider
131
+ if (route.provider) {
132
+ const provLower = String(route.provider).toLowerCase()
133
+ const normProv = VENDOR_ALIASES[provLower] || provLower
134
+ const matched = list.find((id) => {
135
+ const slash = id.indexOf('/')
136
+ if (slash < 0) return false
137
+ const prefix = id.slice(0, slash).toLowerCase()
138
+ return prefix === normProv || prefix === provLower
139
+ })
140
+ if (matched) return matched
141
+ }
142
+ // Ambiguous suffix with no provider match: do not guess arbitrarily
143
+ return undefined
144
+ }
145
+
146
+ /** Build lookup tables once per fetch; the raw catalog is ~700 KB. */
147
+ export function indexCatalog(models) {
148
+ const byId = {}
149
+ const byLowerId = {}
150
+ const bySuffix = {}
151
+ for (const entry of models) {
152
+ if (!entry || typeof entry.id !== 'string' || !entry.pricing) continue
153
+ // Variants like ":batch" or ":free" are different products; skip them so a
154
+ // suffix match cannot silently price against a cheaper batch tier.
155
+ if (entry.id.includes(':')) continue
156
+ byId[entry.id] = scheduleOf(entry)
157
+ byLowerId[entry.id.toLowerCase()] = entry.id
158
+ const suffix = entry.id.includes('/') ? entry.id.slice(entry.id.indexOf('/') + 1) : entry.id
159
+ ;(bySuffix[suffix.toLowerCase()] = bySuffix[suffix.toLowerCase()] || []).push(entry.id)
160
+ }
161
+ return { byId, byLowerId, bySuffix }
162
+ }
163
+
164
+ /**
165
+ * Resolve one route's tariff: manual config wins, then DeepSeek's own table,
166
+ * then the OpenRouter catalog.
167
+ */
168
+ export function resolveTariff(cfg, index, route) {
169
+ if (!route) return { schedule: null, source: 'none', matchedId: null }
170
+ const key = route.provider ? route.provider + '/' + route.model : route.model
171
+ const prices = cfg.prices || {}
172
+ const manual = prices[key] || prices[route.model] || prices['*']
173
+
174
+ if (manual) {
175
+ const base = {
176
+ cacheHit: Number(manual.cacheHit) || 0,
177
+ input: Number(manual.input) || 0,
178
+ cacheWrite: Number(manual.cacheWrite) || 0,
179
+ output: Number(manual.output) || 0,
180
+ }
181
+ const factor = Number(cfg.manualOffPeakMultiplier) || 1
182
+ const windows = []
183
+ for (const spec of cfg.manualPeakWindowsUtc || []) {
184
+ const parsed = parseWindow(spec)
185
+ if (parsed) windows.push({ start: parsed.start, end: parsed.end, rates: base })
186
+ }
187
+ const schedule = windows.length
188
+ ? {
189
+ base: {
190
+ cacheHit: base.cacheHit * factor,
191
+ input: base.input * factor,
192
+ cacheWrite: base.cacheWrite * factor,
193
+ output: base.output * factor,
194
+ },
195
+ windows,
196
+ }
197
+ : { base, windows: [] }
198
+ return { schedule, source: 'manual', matchedId: key }
199
+ }
200
+
201
+ const providerLower = String(route.provider || '').toLowerCase()
202
+ if (providerLower === 'openrouter' && cfg.useOpenRouter !== false) {
203
+ const matchedId = matchModel(index, route, cfg.modelMap)
204
+ if (matchedId) return { schedule: index.byId[matchedId], source: 'openrouter', matchedId }
205
+ }
206
+
207
+ const base = String(route.model).toLowerCase().split(':')[0]
208
+ const peak = (cfg.deepseekPeakPrices || {})[base] || DEEPSEEK_PEAK_RATES[base]
209
+ if (peak) {
210
+ const full = {
211
+ cacheHit: Number(peak.cacheHit) || 0,
212
+ input: Number(peak.input) || 0,
213
+ cacheWrite: Number(peak.cacheWrite) || 0,
214
+ output: Number(peak.output) || 0,
215
+ }
216
+ return {
217
+ schedule: {
218
+ base: {
219
+ cacheHit: full.cacheHit / 2,
220
+ input: full.input / 2,
221
+ cacheWrite: full.cacheWrite / 2,
222
+ output: full.output / 2,
223
+ },
224
+ windows: DEEPSEEK_PEAK_WINDOWS.map((w) => ({ start: w.start, end: w.end, rates: full })),
225
+ },
226
+ source: 'deepseek',
227
+ matchedId: base,
228
+ }
229
+ }
230
+
231
+ if (cfg.useOpenRouter !== false) {
232
+ const matchedId = matchModel(index, route, cfg.modelMap)
233
+ if (matchedId) return { schedule: index.byId[matchedId], source: 'openrouter', matchedId }
234
+ }
235
+ return { schedule: null, source: 'none', matchedId: null }
236
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cost-meter",
3
- "version": "0.8.8",
3
+ "version": "0.8.9",
4
4
  "description": "DeepSeek Harness plugin: a session cost chip in the conversation header - live token usage by bucket, peak/off-peak tariff, and a countdown to the next tariff switch.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",