@goodandready/dsh-cost-meter 0.8.8 → 0.8.10

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 +132 -305
  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
 
@@ -35,21 +38,40 @@ export function isLoopback(value) {
35
38
  }
36
39
 
37
40
  export function isTrustedCaller(request) {
38
- const remote = request?.socket?.remoteAddress || request?.connection?.remoteAddress
39
- if (isLoopback(remote)) return true
40
- const site = request?.headers?.['sec-fetch-site']
41
- if (site === 'same-origin' || site === 'same-site') return true
41
+ if (!request) return false
42
+ const remote = request.socket?.remoteAddress || request.connection?.remoteAddress
43
+ const site = request.headers?.['sec-fetch-site']
42
44
  if (site === 'cross-site') return false
43
- const origin = request?.headers?.['origin']
44
- const host = request?.headers?.['host']
45
- if (origin && host) {
45
+
46
+ const host = request.headers?.['x-forwarded-host'] || request.headers?.['host']
47
+ const origin = request.headers?.['origin']
48
+ if (origin) {
46
49
  try {
47
50
  const url = new URL(origin)
48
- if (url.host === host) return true
49
- } catch (e) {
50
- /* invalid url */
51
+ if (host && url.host.toLowerCase() === host.toLowerCase()) return true
52
+ if (isLoopback(url.hostname) && isLoopback(remote)) return true
53
+ return false
54
+ } catch {
55
+ return false
56
+ }
57
+ }
58
+
59
+ const referer = request.headers?.['referer']
60
+ if (referer) {
61
+ try {
62
+ const url = new URL(referer)
63
+ if (host && url.host.toLowerCase() === host.toLowerCase()) return true
64
+ if (isLoopback(url.hostname) && isLoopback(remote)) return true
65
+ return false
66
+ } catch {
67
+ return false
51
68
  }
52
69
  }
70
+
71
+ if (site === 'same-site') return false
72
+ if (site === 'same-origin') return true
73
+ if (isLoopback(remote)) return true
74
+
53
75
  return false
54
76
  }
55
77
 
@@ -64,35 +86,6 @@ const PriceRow = z.object({
64
86
  output: z.number().description('Output tokens (reasoning included).').default(0),
65
87
  })
66
88
 
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
89
  export const Config = z.object({
97
90
  currency: z.string().description('Currency symbol shown in the widget.').default('$'),
98
91
  usdRate: z.number().description('Units of `currency` per USD. 1 keeps everything in USD.').default(1),
@@ -131,113 +124,6 @@ export const Config = z.object({
131
124
  .default(1),
132
125
  })
133
126
 
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
127
  // ---------------------------------------------------------------------------
242
128
  // Per-model usage projection
243
129
  // ---------------------------------------------------------------------------
@@ -300,15 +186,35 @@ export function slotOf(timeMs) {
300
186
  return Math.floor((d.getUTCHours() * 60 + d.getUTCMinutes()) / 30)
301
187
  }
302
188
 
303
- const usageByModelProjection = {
189
+ const modelSlotsSchema = zod.record(zod.string(), bucketSchema)
190
+
191
+ const costByModelViewSchema = zod.object({
192
+ models: zod.array(zod.object({
193
+ provider: zod.string(),
194
+ model: zod.string(),
195
+ slots: modelSlotsSchema,
196
+ }).strict()),
197
+ }).strict()
198
+
199
+ const costByModelStateSchema = zod.object({
200
+ route: zod.object({
201
+ provider: zod.string(),
202
+ model: zod.string(),
203
+ }).nullable(),
204
+ byKey: zod.record(zod.string(), modelSlotsSchema),
205
+ last: zod.object({
206
+ turn: zod.number().int().nonnegative(),
207
+ step: zod.number().int().nonnegative(),
208
+ key: zod.string(),
209
+ slot: zod.string(),
210
+ buckets: bucketSchema,
211
+ }).nullable(),
212
+ }).strict()
213
+
214
+ export const usageByModelProjection = {
304
215
  key: 'costByModel',
305
- schema: zod.object({
306
- models: zod.array(zod.object({
307
- provider: zod.string(),
308
- model: zod.string(),
309
- slots: zod.record(zod.string(), bucketSchema),
310
- })),
311
- }).strict(),
216
+ stateVersion: 2,
217
+ stateSchema: costByModelStateSchema,
312
218
  init: () => ({ route: null, byKey: {}, last: null }),
313
219
  apply: (state, event) => {
314
220
  if (event.type === 'request/header') {
@@ -344,17 +250,19 @@ const usageByModelProjection = {
344
250
  last: { turn: sample.turn, step: sample.step, key, slot, buckets },
345
251
  }
346
252
  },
347
- view: (state) => ({
348
- models: Object.keys(state.byKey).map((key) => {
349
- const slash = key.indexOf('/')
350
- return {
351
- provider: key.slice(0, slash),
352
- model: key.slice(slash + 1),
353
- slots: state.byKey[key],
354
- }
253
+ wire: {
254
+ viewSchema: costByModelViewSchema,
255
+ view: (state) => ({
256
+ models: Object.keys(state.byKey).map((key) => {
257
+ const slash = key.indexOf('/')
258
+ return {
259
+ provider: key.slice(0, slash),
260
+ model: key.slice(slash + 1),
261
+ slots: state.byKey[key],
262
+ }
263
+ }),
355
264
  }),
356
- }),
357
- stateVersion: 2,
265
+ },
358
266
  }
359
267
 
360
268
  /** The agent's default route, read off settings descriptors when available. */
@@ -373,80 +281,6 @@ function defaultRoute(ctx) {
373
281
  }
374
282
  }
375
283
 
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
284
  /** "provider/model" -> {provider, model}; the model half may contain slashes. */
451
285
  export function splitRoute(key) {
452
286
  const str = String(key || '').trim()
@@ -516,109 +350,102 @@ export function apply(ctx, config) {
516
350
  }
517
351
  }
518
352
 
519
- // Live config from the GUI, with the composition entry as the base layer.
520
- ctx.inject(['settings'], (sctx) => {
521
- const scope = sctx.settings.register(NS, Config, { base: config })
522
- current = scope.get() ?? config
523
- const stop = scope.watch(() => {
524
- current = scope.get() ?? config
353
+ // Live config reaction from GUI / settings service
354
+ ctx.on('settings/updated', (ns, next) => {
355
+ if (ns === NS && next) {
356
+ current = { ...config, ...next }
525
357
  clearResolveCache()
526
- void refresh()
527
- })
528
- sctx.effect(() => () => {
529
- stop()
530
- current = config
358
+ }
359
+ })
360
+
361
+ ctx.on('settings/document-updated', (ns) => {
362
+ if (!ns || ns === NS) {
531
363
  clearResolveCache()
532
- })
364
+ }
365
+ })
366
+
367
+ ctx.inject(['settings'], (sctx) => {
368
+ if (typeof sctx.settings?.describe === 'function') {
369
+ try {
370
+ const desc = sctx.settings.describe(NS)
371
+ if (desc?.value && typeof desc.value === 'object') {
372
+ current = { ...config, ...desc.value }
373
+ clearResolveCache()
374
+ }
375
+ } catch {
376
+ /* namespace might not have descriptor yet */
377
+ }
378
+ }
533
379
  })
534
380
 
535
- // Optional capability: headless assemblies without the registry keep working
381
+ // Hook the session projection so the widget can show spend breakdown.
536
382
  ctx.inject(['sessionProjections'], (pctx) => {
537
383
  pctx.sessionProjections.register(usageByModelProjection)
538
384
  })
539
385
 
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)
386
+ // Route 1: POST /dsh-cost-meter/refresh (manual catalog reload)
551
387
  ctx.effect(
552
388
  () =>
553
389
  ctx.webServer.register({
554
- kind: 'exact',
555
390
  path: REFRESH_PATH,
391
+ method: 'POST',
556
392
  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
393
  if (!isTrustedCaller(req)) {
563
394
  res.writeHead(403, { 'Content-Type': 'application/json' })
564
- res.end(JSON.stringify({ error: 'forbidden source origin' }))
395
+ res.end(JSON.stringify({ error: 'forbidden' }))
565
396
  return
566
397
  }
567
398
  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 }))
399
+ if (now - lastManualRefreshAt < 60_000) {
400
+ res.writeHead(429, { 'Content-Type': 'application/json' })
401
+ res.end(JSON.stringify({ error: 'rate_limited', retryAfterMs: 60_000 - (now - lastManualRefreshAt) }))
576
402
  return
577
403
  }
578
404
  lastManualRefreshAt = now
579
- const controller = new AbortController()
580
- await refresh(controller.signal, true)
581
- const modelsCount = Object.keys((catalog.index && catalog.index.byId) || {}).length
405
+ await refresh(null, true)
582
406
  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
- }))
407
+ res.end(
408
+ JSON.stringify({
409
+ ok: !catalog.error,
410
+ fetchedAt: catalog.fetchedAt,
411
+ models: Object.keys((catalog.index && catalog.index.byId) || {}).length,
412
+ error: catalog.error,
413
+ }),
414
+ )
589
415
  },
590
416
  }),
591
417
  'dsh-cost-meter: refresh route',
592
418
  )
593
419
 
594
- // Route 2: GET /dsh-cost-meter/state (with ETag and 304 Not Modified)
420
+ // Route 2: GET /dsh-cost-meter/state (the browser poller hits this)
595
421
  ctx.effect(
596
422
  () =>
597
423
  ctx.webServer.register({
598
- kind: 'exact',
599
424
  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
- }
425
+ method: 'GET',
426
+ handler: async (req, res) => {
606
427
  const cfg = current ?? config
428
+
429
+ // On the very first request after boot, wait for the fetch if we have
430
+ // no disk cache either. Later runs refresh in the background.
431
+ if (!catalog.fetchedAt && !catalog.error) {
432
+ await refresh()
433
+ } else {
434
+ void refresh()
435
+ }
436
+
607
437
  const route = defaultRoute(ctx) ?? null
608
438
  const own = getCachedTariff(cfg, route)
609
439
 
440
+ // Also resolve tariffs for any session routes requested in query params
610
441
  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,
442
+ const url = new URL(req.url, 'http://localhost')
443
+ const extraRoutes = url.searchParams.getAll('route')
444
+ for (const key of extraRoutes) {
445
+ const parsed = splitRoute(key)
446
+ if (parsed) {
447
+ const res = getCachedTariff(cfg, parsed)
448
+ if (res && res.schedule) schedules[key] = res.schedule
622
449
  }
623
450
  }
624
451
 
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.10",
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",