@goodandready/dsh-cost-meter 0.8.0 → 0.8.2

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/index.js CHANGED
@@ -13,6 +13,7 @@
13
13
  //
14
14
  // No agent hooks: the widget only reads, so it cannot corrupt a session.
15
15
 
16
+ import { createHash } from 'node:crypto'
16
17
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
17
18
  import { homedir } from 'node:os'
18
19
  import { dirname, join } from 'node:path'
@@ -24,6 +25,7 @@ export const inject = ['webServer']
24
25
 
25
26
  const NS = 'dsh-cost-meter'
26
27
  const STATE_PATH = '/dsh-cost-meter/state'
28
+ const REFRESH_PATH = '/dsh-cost-meter/refresh'
27
29
  const CATALOG_URL = 'https://openrouter.ai/api/v1/models'
28
30
  const CACHE_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'storages', 'dsh-cost-meter-catalog.json')
29
31
 
@@ -48,10 +50,21 @@ const DEEPSEEK_PEAK_RATES = {
48
50
  }
49
51
  const DEEPSEEK_PEAK_WINDOWS = [{ start: 60, end: 240 }, { start: 360, end: 600 }]
50
52
 
51
- // ponytail: a DeepSeek model reached THROUGH OpenRouter is still priced from
52
- // this table, though the bill comes from OpenRouter. Fix by classifying the
53
- // route (provider id / baseURL) before the model, and for a `:floor` slug take
54
- // the minimum over /models/{id}/endpoints rather than the base listing.
53
+ /** Common provider / vendor aliases for seamless catalog matching. */
54
+ export const VENDOR_ALIASES = {
55
+ 'z-ai': 'zai',
56
+ 'zai': 'zai',
57
+ 'google': 'google',
58
+ 'gemini': 'google',
59
+ 'deepseek-ai': 'deepseek',
60
+ 'deepseek-official': 'deepseek',
61
+ 'deepseek': 'deepseek',
62
+ 'anthropic-ai': 'anthropic',
63
+ 'anthropic': 'anthropic',
64
+ 'openai': 'openai',
65
+ 'meta-llama': 'meta-llama',
66
+ 'meta': 'meta-llama',
67
+ }
55
68
 
56
69
  export const Config = z.object({
57
70
  currency: z.string().description('Currency symbol shown in the widget.').default('$'),
@@ -60,6 +73,10 @@ export const Config = z.object({
60
73
  .string()
61
74
  .description('IANA zone the widget shows tariff hours in.')
62
75
  .default('Europe/Moscow'),
76
+ budgetThreshold: z
77
+ .number()
78
+ .description('Optional session budget threshold in USD. 0 disables threshold warnings.')
79
+ .default(0),
63
80
  deepseekPeakPrices: z
64
81
  .dict(PriceRow)
65
82
  .description('Override the built-in DeepSeek peak rates, by model id.')
@@ -146,9 +163,8 @@ function scheduleOf(entry) {
146
163
  }
147
164
 
148
165
  /**
149
- * Map a harness route onto an OpenRouter id. Exact id first, then the vendor
150
- * suffix (`deepseek-official/deepseek-v4-flash` -> `deepseek/deepseek-v4-flash`),
151
- * which is what the shared model names make possible.
166
+ * Map a harness route onto an OpenRouter id. Exact id first, then normalized
167
+ * vendor alias + model, then vendor suffix.
152
168
  */
153
169
  export function matchModel(index, route, modelMap) {
154
170
  if (!route || !index) return undefined
@@ -157,6 +173,15 @@ export function matchModel(index, route, modelMap) {
157
173
  if (mapped && index.byId && index.byId[mapped]) return mapped
158
174
  if (index.byId && index.byId[key]) return key
159
175
  if (index.byId && index.byId[route.model]) return route.model
176
+
177
+ // Provider alias check (e.g. z-ai -> zai, deepseek-official -> deepseek)
178
+ const prov = String(route.provider || '').toLowerCase()
179
+ const aliasProv = VENDOR_ALIASES[prov]
180
+ if (aliasProv && route.model) {
181
+ const aliasKey = (aliasProv + '/' + route.model).toLowerCase()
182
+ if (index.byLowerId && index.byLowerId[aliasKey]) return index.byLowerId[aliasKey]
183
+ }
184
+
160
185
  // Harness routes name models in their own casing (`MiniMax-M3`), the catalog
161
186
  // is lower-case (`minimax/minimax-m3`), so matching is case-insensitive.
162
187
  const lower = String(route.model).toLowerCase()
@@ -188,23 +213,10 @@ export function indexCatalog(models) {
188
213
 
189
214
  // ---------------------------------------------------------------------------
190
215
  // Per-model usage projection
191
- //
192
- // `tokenUsage` from dsh-token-meter totals the whole session, which prices
193
- // wrongly the moment the model changes mid-session. The log has what is
194
- // needed: `request/header` carries `header.config = {provider, model}`, and
195
- // usage arrives afterwards for that same step. So this unit keeps the last
196
- // header's route and files every usage sample under it.
197
- //
198
- // The replace-if-same-step guard mirrors token-meter: a streamed usage chunk
199
- // and the finalized assistant message report the same numbers for one step.
200
216
  // ---------------------------------------------------------------------------
201
217
 
202
218
  const ZERO = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
203
219
 
204
- // Projection schemas are validated with ZOD, not schemastery: the registry
205
- // calls `schema.parse(view)` at the boundary, and a schemastery schema has no
206
- // `.parse`, which fails every history read with
207
- // "registration.def.schema.parse is not a function".
208
220
  const bucketSchema = zod.object({
209
221
  uncachedInputTokens: zod.number().nonnegative(),
210
222
  outputTokens: zod.number().nonnegative(),
@@ -246,10 +258,7 @@ function usageOf(event) {
246
258
  }
247
259
 
248
260
  /**
249
- * Half-hour slot of the UTC day an event falls into (0..47). Tariff windows are
250
- * expressed in UTC clock time, and every real schedule so far lands on a whole
251
- * or half hour, so 48 slots reproduce the rate exactly while keeping the state
252
- * small enough to ship on every projection frame.
261
+ * Half-hour slot of the UTC day an event falls into (0..47).
253
262
  */
254
263
  export function slotOf(timeMs) {
255
264
  let ms = typeof timeMs === 'number' && Number.isFinite(timeMs) ? timeMs : NaN
@@ -270,9 +279,6 @@ const usageByModelProjection = {
270
279
  models: zod.array(zod.object({
271
280
  provider: zod.string(),
272
281
  model: zod.string(),
273
- // Usage split by the half-hour of the UTC day it was spent in. Spend is
274
- // priced at the rate in force THEN; a later tariff switch must not
275
- // reprice tokens that are already paid for.
276
282
  slots: zod.record(zod.string(), bucketSchema),
277
283
  })),
278
284
  }).strict(),
@@ -292,8 +298,6 @@ const usageByModelProjection = {
292
298
  const key = route.provider + '/' + route.model
293
299
  const slot = String(slotOf(event.time))
294
300
  const buckets = bucketsFromUsage(sample.usage)
295
- // A streamed usage chunk and the finalized assistant message report the
296
- // same step twice; the second one replaces the first instead of adding.
297
301
  const previous =
298
302
  state.last !== null &&
299
303
  state.last.turn === sample.turn &&
@@ -344,12 +348,11 @@ function defaultRoute(ctx) {
344
348
 
345
349
  /**
346
350
  * Resolve one route's tariff: manual config wins, then DeepSeek's own table,
347
- * then the OpenRouter catalog. Returns `schedule: null` when nothing knows it,
348
- * so the widget can say so instead of pricing at zero.
351
+ * then the OpenRouter catalog.
349
352
  */
350
353
  export function resolveTariff(cfg, index, route) {
351
354
  if (!route) return { schedule: null, source: 'none', matchedId: null }
352
- const key = route.provider + '/' + route.model
355
+ const key = route.provider ? route.provider + '/' + route.model : route.model
353
356
  const prices = cfg.prices || {}
354
357
  const manual = prices[key] || prices[route.model] || prices['*']
355
358
 
@@ -380,15 +383,10 @@ export function resolveTariff(cfg, index, route) {
380
383
  return { schedule, source: 'manual', matchedId: key }
381
384
  }
382
385
 
383
- // Keyed by MODEL, not provider: a DeepSeek model is billed at DeepSeek's
384
- // published rate whoever routes it (deepseek-official, opencode-go, ollama).
385
- // Date/variant suffixes (`deepseek-v4-flash:0731`) name the same product.
386
- const providerLower = String(route.provider || "").toLowerCase();
387
- // #3: Provider-aware routing: if routed explicitly through OpenRouter,
388
- // consult OpenRouter catalog first to honor actual provider billing.
389
- if (providerLower === "openrouter" && cfg.useOpenRouter !== false) {
390
- const matchedId = matchModel(index, route, cfg.modelMap);
391
- if (matchedId) return { schedule: index.byId[matchedId], source: "openrouter", matchedId };
386
+ const providerLower = String(route.provider || '').toLowerCase()
387
+ if (providerLower === 'openrouter' && cfg.useOpenRouter !== false) {
388
+ const matchedId = matchModel(index, route, cfg.modelMap)
389
+ if (matchedId) return { schedule: index.byId[matchedId], source: 'openrouter', matchedId }
392
390
  }
393
391
 
394
392
  const base = String(route.model).toLowerCase().split(':')[0]
@@ -434,6 +432,25 @@ export function splitRoute(key) {
434
432
  export function apply(ctx, config) {
435
433
  let current = config
436
434
  let catalog = { index: { byId: {}, byLowerId: {}, bySuffix: {} }, fetchedAt: 0, error: null }
435
+ let lastManualRefreshAt = 0
436
+
437
+ // Server-side cache for resolved tariffs: key -> { schedule, source, matchedId }
438
+ const resolveCache = new Map()
439
+
440
+ function getCachedTariff(cfg, route) {
441
+ if (!route) return resolveTariff(cfg, catalog.index, route)
442
+ const cacheKey = (route.provider || '') + '/' + (route.model || '')
443
+ if (resolveCache.has(cacheKey)) return resolveCache.get(cacheKey)
444
+ const res = resolveTariff(cfg, catalog.index, route)
445
+ // Limit cache size to prevent runaway memory
446
+ if (resolveCache.size >= 500) resolveCache.clear()
447
+ resolveCache.set(cacheKey, res)
448
+ return res
449
+ }
450
+
451
+ function clearResolveCache() {
452
+ resolveCache.clear()
453
+ }
437
454
 
438
455
  // Disk cache survives restarts, so a boot without network still prices.
439
456
  try {
@@ -445,11 +462,11 @@ export function apply(ctx, config) {
445
462
  // absent or unreadable cache is normal on first run
446
463
  }
447
464
 
448
- async function refresh(signal) {
465
+ async function refresh(signal, force = false) {
449
466
  const cfg = current ?? config
450
467
  if (cfg.useOpenRouter === false) return
451
468
  const ageMs = Date.now() - catalog.fetchedAt
452
- if (catalog.fetchedAt && ageMs < (Number(cfg.refreshHours) || 24) * 3600e3) return
469
+ if (!force && catalog.fetchedAt && ageMs < (Number(cfg.refreshHours) || 24) * 3600e3) return
453
470
  try {
454
471
  const timeoutSignal = AbortSignal.timeout(15000)
455
472
  const combinedSignal = signal ? (AbortSignal.any ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal) : timeoutSignal
@@ -458,9 +475,9 @@ export function apply(ctx, config) {
458
475
  const body = await response.json()
459
476
  const models = Array.isArray(body && body.data) ? body.data : []
460
477
  if (models.length === 0) throw new Error('empty catalog')
461
- // Keep only what pricing needs — the raw payload is mostly prose.
462
478
  const slim = models.map((m) => ({ id: m.id, pricing: m.pricing }))
463
479
  catalog = { index: indexCatalog(slim), fetchedAt: Date.now(), error: null }
480
+ clearResolveCache()
464
481
  try {
465
482
  mkdirSync(dirname(CACHE_FILE), { recursive: true })
466
483
  writeFileSync(CACHE_FILE, JSON.stringify({ fetchedAt: catalog.fetchedAt, models: slim }))
@@ -478,16 +495,17 @@ export function apply(ctx, config) {
478
495
  current = scope.get() ?? config
479
496
  const stop = scope.watch(() => {
480
497
  current = scope.get() ?? config
498
+ clearResolveCache()
481
499
  void refresh()
482
500
  })
483
501
  sctx.effect(() => () => {
484
502
  stop()
485
503
  current = config
504
+ clearResolveCache()
486
505
  })
487
506
  })
488
507
 
489
- // Optional capability: headless assemblies without the registry keep working,
490
- // the widget just falls back to the session-wide tokenUsage projection.
508
+ // Optional capability: headless assemblies without the registry keep working
491
509
  ctx.inject(['sessionProjections'], (pctx) => {
492
510
  pctx.sessionProjections.register(usageByModelProjection)
493
511
  })
@@ -502,6 +520,46 @@ export function apply(ctx, config) {
502
520
  }
503
521
  }, 'dsh-cost-meter: catalog refresh')
504
522
 
523
+ // Route 1: POST /dsh-cost-meter/refresh (manual on-demand sync with 30s cooldown)
524
+ ctx.effect(
525
+ () =>
526
+ ctx.webServer.register({
527
+ kind: 'exact',
528
+ path: REFRESH_PATH,
529
+ handler: async (req, res) => {
530
+ if (req.method !== 'POST') {
531
+ res.writeHead(405, { 'Content-Type': 'application/json' })
532
+ res.end(JSON.stringify({ error: 'method not allowed' }))
533
+ return
534
+ }
535
+ const now = Date.now()
536
+ const COOLDOWN_MS = 30000
537
+ if (now - lastManualRefreshAt < COOLDOWN_MS) {
538
+ const retryAfter = Math.ceil((COOLDOWN_MS - (now - lastManualRefreshAt)) / 1000)
539
+ res.writeHead(429, {
540
+ 'Content-Type': 'application/json',
541
+ 'Retry-After': String(retryAfter),
542
+ })
543
+ res.end(JSON.stringify({ error: 'rate limit exceeded', retryAfter }))
544
+ return
545
+ }
546
+ lastManualRefreshAt = now
547
+ const controller = new AbortController()
548
+ await refresh(controller.signal, true)
549
+ const modelsCount = Object.keys((catalog.index && catalog.index.byId) || {}).length
550
+ res.writeHead(200, { 'Content-Type': 'application/json' })
551
+ res.end(JSON.stringify({
552
+ ok: !catalog.error,
553
+ fetchedAt: catalog.fetchedAt,
554
+ modelsCount,
555
+ error: catalog.error,
556
+ }))
557
+ },
558
+ }),
559
+ 'dsh-cost-meter: refresh route',
560
+ )
561
+
562
+ // Route 2: GET /dsh-cost-meter/state (with ETag and 304 Not Modified)
505
563
  ctx.effect(
506
564
  () =>
507
565
  ctx.webServer.register({
@@ -515,10 +573,8 @@ export function apply(ctx, config) {
515
573
  }
516
574
  const cfg = current ?? config
517
575
  const route = defaultRoute(ctx) ?? null
518
- const own = resolveTariff(cfg, catalog.index, route)
576
+ const own = getCachedTariff(cfg, route)
519
577
 
520
- // The client asks for every model the session actually used, so a
521
- // session that switched models can price each part on its own tariff.
522
578
  const schedules = {}
523
579
  const asked = new URL(req.url, 'http://localhost').searchParams.get('models')
524
580
  for (const key of (asked ? asked.split(',') : []).slice(0, 24)) {
@@ -526,7 +582,7 @@ export function apply(ctx, config) {
526
582
  if (!trimmed || trimmed.length > 128) continue
527
583
  const parsed = splitRoute(trimmed)
528
584
  if (!parsed) continue
529
- const resolved = resolveTariff(cfg, catalog.index, parsed)
585
+ const resolved = getCachedTariff(cfg, parsed)
530
586
  schedules[trimmed] = {
531
587
  schedule: resolved.schedule,
532
588
  source: resolved.source,
@@ -534,11 +590,11 @@ export function apply(ctx, config) {
534
590
  }
535
591
  }
536
592
 
537
- res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
538
- res.end(JSON.stringify({
593
+ const payloadObj = {
539
594
  currency: cfg.currency,
540
595
  usdRate: cfg.usdRate,
541
596
  displayTimeZone: cfg.displayTimeZone || 'Europe/Moscow',
597
+ budgetThreshold: Number(cfg.budgetThreshold) || 0,
542
598
  route,
543
599
  source: own.source,
544
600
  matchedId: own.matchedId,
@@ -547,7 +603,26 @@ export function apply(ctx, config) {
547
603
  catalogError: catalog.error,
548
604
  catalogModelsCount: Object.keys((catalog.index && catalog.index.byId) || {}).length,
549
605
  schedule: own.schedule,
550
- }))
606
+ }
607
+
608
+ const serialized = JSON.stringify(payloadObj)
609
+ const etag = 'W/"' + createHash('sha1').update(serialized).digest('base64url').slice(0, 16) + '"'
610
+
611
+ if (req.headers['if-none-match'] === etag) {
612
+ res.writeHead(304, {
613
+ 'ETag': etag,
614
+ 'Cache-Control': 'no-cache',
615
+ })
616
+ res.end()
617
+ return
618
+ }
619
+
620
+ res.writeHead(200, {
621
+ 'Content-Type': 'application/json',
622
+ 'Cache-Control': 'no-cache',
623
+ 'ETag': etag,
624
+ })
625
+ res.end(serialized)
551
626
  },
552
627
  }),
553
628
  'dsh-cost-meter: state route',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cost-meter",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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",