@goodandready/dsh-cost-meter 0.8.14 → 0.8.16

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/client.js CHANGED
@@ -433,10 +433,25 @@ window.__ModuleLoader__.load({
433
433
  const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000))
434
434
  if (minutes < 1) return isZh ? '刚刚' : 'just now'
435
435
  if (minutes < 60) return minutes + (isZh ? ' 分钟前' : ' min ago')
436
- const hours = Math.round(minutes / 60)
437
436
  return hours < 48 ? hours + (isZh ? ' 小时前' : ' hrs ago') : Math.round(hours / 24) + (isZh ? ' 天前' : ' days ago')
438
437
  }
439
438
 
439
+ function fetchWithTimeout(url, options = {}, timeoutMs = 15000) {
440
+ const { signal, ...rest } = options
441
+ const timer = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
442
+ ? AbortSignal.timeout(timeoutMs)
443
+ : null
444
+ let combined = signal
445
+ if (timer) {
446
+ if (signal && typeof AbortSignal.any === 'function') {
447
+ combined = AbortSignal.any([signal, timer])
448
+ } else if (!signal) {
449
+ combined = timer
450
+ }
451
+ }
452
+ return fetch(url, { ...rest, ...(combined ? { signal: combined } : {}) })
453
+ }
454
+
440
455
  // ---------------------------------------------------------------- card
441
456
 
442
457
  function CostMeterCard(props) {
@@ -467,10 +482,10 @@ window.__ModuleLoader__.load({
467
482
  notice: '',
468
483
  })
469
484
 
470
- const checkUpdate = React.useCallback(async () => {
485
+ const checkUpdate = React.useCallback(async (signal) => {
471
486
  setUpdateState((s) => ({ ...s, checking: true, error: '' }))
472
487
  try {
473
- const res = await fetch(UPDATE_PATH)
488
+ const res = await fetchWithTimeout(UPDATE_PATH, { signal })
474
489
  if (!res.ok) throw new Error('HTTP ' + res.status)
475
490
  const data = await res.json().catch(() => ({}))
476
491
  setUpdateState((s) => ({
@@ -482,22 +497,27 @@ window.__ModuleLoader__.load({
482
497
  canAutoUpdate: data.canAutoUpdate !== false,
483
498
  }))
484
499
  } catch (e) {
485
- setUpdateState((s) => ({ ...s, checking: false }))
500
+ if (e?.name !== 'AbortError') {
501
+ setUpdateState((s) => ({ ...s, checking: false }))
502
+ }
486
503
  }
487
504
  }, [])
488
505
 
489
506
  React.useEffect(() => {
490
- if (open) checkUpdate()
507
+ if (!open) return
508
+ const controller = new AbortController()
509
+ checkUpdate(controller.signal)
510
+ return () => controller.abort()
491
511
  }, [open, checkUpdate])
492
512
 
493
513
  async function handleTriggerUpdate() {
494
514
  if (updateState.updating) return
495
515
  setUpdateState((s) => ({ ...s, updating: true, error: '', notice: '' }))
496
516
  try {
497
- const res = await fetch(UPDATE_PATH, {
517
+ const res = await fetchWithTimeout(UPDATE_PATH, {
498
518
  method: 'POST',
499
519
  headers: { 'x-dsh-plugin-update': '1' },
500
- })
520
+ }, 30000)
501
521
  const data = await res.json().catch(() => ({}))
502
522
  if (!res.ok || data.ok === false || data.error) {
503
523
  throw new Error(data.error || ('HTTP ' + res.status))
@@ -534,8 +554,8 @@ window.__ModuleLoader__.load({
534
554
 
535
555
  React.useEffect(() => { ensureStyles() }, [])
536
556
 
537
- const fetchDiag = React.useCallback(() => {
538
- fetch(STATE_PATH, { headers: { accept: 'application/json' } })
557
+ const fetchDiag = React.useCallback((signal) => {
558
+ fetchWithTimeout(STATE_PATH, { signal, headers: { accept: 'application/json' } })
539
559
  .then((r) => (r.ok ? r.json() : null))
540
560
  .then((data) => { if (data) setDiagState(data) })
541
561
  .catch(() => {})
@@ -543,7 +563,9 @@ window.__ModuleLoader__.load({
543
563
 
544
564
  React.useEffect(() => {
545
565
  if (!open) return
546
- fetchDiag()
566
+ const controller = new AbortController()
567
+ fetchDiag(controller.signal)
568
+ return () => controller.abort()
547
569
  }, [open, fetchDiag])
548
570
 
549
571
  const status = (snapshot && snapshot.status) || 'loading'
@@ -572,7 +594,7 @@ window.__ModuleLoader__.load({
572
594
  setSyncing(true)
573
595
  setSyncMsg('')
574
596
  try {
575
- const res = await fetch(REFRESH_PATH, { method: 'POST', headers: { accept: 'application/json' } })
597
+ const res = await fetchWithTimeout(REFRESH_PATH, { method: 'POST', headers: { accept: 'application/json' } })
576
598
  if (res.status === 429) {
577
599
  const data = await res.json().catch(() => ({}))
578
600
  const retryAfter = data.retryAfter || 30
@@ -1034,10 +1056,10 @@ window.__ModuleLoader__.load({
1034
1056
 
1035
1057
  const reqSeq = React.useRef(0)
1036
1058
 
1037
- const load = React.useCallback((keys) => {
1059
+ const load = React.useCallback((keys, signal) => {
1038
1060
  const seq = ++reqSeq.current
1039
1061
  const query = keys ? '?models=' + encodeURIComponent(keys) : ''
1040
- fetch(STATE_PATH + query, { headers: { accept: 'application/json' } })
1062
+ fetchWithTimeout(STATE_PATH + query, { signal, headers: { accept: 'application/json' } })
1041
1063
  .then((r) => (r.ok ? r.json() : null))
1042
1064
  .then((data) => {
1043
1065
  if (data && seq === reqSeq.current) setState(data)
@@ -1045,8 +1067,17 @@ window.__ModuleLoader__.load({
1045
1067
  .catch(() => {})
1046
1068
  }, [])
1047
1069
 
1048
- React.useEffect(() => { load(usedKeys) }, [load, usedKeys])
1049
- React.useEffect(() => { if (open) load(usedKeys) }, [open, load, usedKeys])
1070
+ React.useEffect(() => {
1071
+ const controller = new AbortController()
1072
+ load(usedKeys, controller.signal)
1073
+ return () => controller.abort()
1074
+ }, [load, usedKeys])
1075
+ React.useEffect(() => {
1076
+ if (!open) return
1077
+ const controller = new AbortController()
1078
+ load(usedKeys, controller.signal)
1079
+ return () => controller.abort()
1080
+ }, [open, load, usedKeys])
1050
1081
  React.useEffect(() => {
1051
1082
  const id = setInterval(() => setNow(new Date()), 15000)
1052
1083
  return () => clearInterval(id)
@@ -1449,6 +1480,7 @@ window.__ModuleLoader__.load({
1449
1480
  minutesUntilChange,
1450
1481
  extremes,
1451
1482
  ratesAt,
1483
+ fetchWithTimeout,
1452
1484
  }
1453
1485
  return module.exports
1454
1486
  },
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { registerPluginUpdater } from './updater.js'
1
+ import { registerRoutes, STATE_PATH, REFRESH_PATH } from './routes.js'
2
2
  // dsh-cost-meter — host half.
3
3
  //
4
4
  // The harness prices nothing on purpose: pi-ai carries a `cost` field but
@@ -14,7 +14,6 @@ import { registerPluginUpdater } from './updater.js'
14
14
  //
15
15
  // No agent hooks: the widget only reads, so it cannot corrupt a session.
16
16
 
17
- import { createHash } from 'node:crypto'
18
17
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
19
18
  import { homedir } from 'node:os'
20
19
  import { dirname, join } from 'node:path'
@@ -31,17 +30,16 @@ try {
31
30
  } catch {
32
31
  // safe fallback
33
32
  }
34
- import { z as zod } from 'zod'
35
-
36
33
  export * from './tariff.js'
34
+ export * from './projection.js'
35
+ export { STATE_PATH, REFRESH_PATH } from './routes.js'
37
36
  import { indexCatalog, resolveTariff, matchModel } from './tariff.js'
37
+ import { usageByModelProjection } from './projection.js'
38
38
 
39
39
  export const name = '@goodandready/dsh-cost-meter'
40
- export const inject = ['webServer']
40
+ export const inject = ['webServer', 'settings', 'sessionProjections']
41
41
 
42
42
  const NS = 'dsh-cost-meter'
43
- const STATE_PATH = '/dsh-cost-meter/state'
44
- const REFRESH_PATH = '/dsh-cost-meter/refresh'
45
43
  export function isLoopback(value) {
46
44
  const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
47
45
  return address === 'localhost' || address === 'localhost.' || address === '::1'
@@ -154,146 +152,7 @@ export const Config = z.object({
154
152
  .volatile(),
155
153
  })
156
154
 
157
- // ---------------------------------------------------------------------------
158
- // Per-model usage projection
159
- // ---------------------------------------------------------------------------
160
-
161
- const ZERO = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
162
-
163
- const bucketSchema = zod.object({
164
- uncachedInputTokens: zod.number().nonnegative(),
165
- outputTokens: zod.number().nonnegative(),
166
- cacheReadTokens: zod.number().nonnegative(),
167
- cacheWriteTokens: zod.number().nonnegative(),
168
- }).strict()
169
-
170
- /** Pull the four buckets out of one provider usage record. */
171
- function bucketsFromUsage(usage) {
172
- return {
173
- uncachedInputTokens: Number(usage.inputTokens) || 0,
174
- outputTokens: Number(usage.outputTokens) || 0,
175
- cacheReadTokens: Number(usage.cacheReadTokens) || 0,
176
- cacheWriteTokens: Number(usage.cacheWriteTokens) || 0,
177
- }
178
- }
179
-
180
- function addBuckets(base, remove, add) {
181
- const out = {}
182
- for (const k of Object.keys(ZERO)) {
183
- out[k] = (base[k] || 0) - ((remove && remove[k]) || 0) + (add[k] || 0)
184
- }
185
- return out
186
- }
187
-
188
- function sameBuckets(a, b) {
189
- return a && b && Object.keys(ZERO).every((k) => (a[k] || 0) === (b[k] || 0))
190
- }
191
-
192
- /** The usage a streamed chunk or a finalized assistant message reports. */
193
- function usageOf(event) {
194
- if (event.type === 'assistant/chunk' && event.data.chunk && event.data.chunk.type === 'usage') {
195
- return { turn: event.data.turn, step: event.data.step, usage: event.data.chunk.usage }
196
- }
197
- if (event.type === 'assistant/message' && event.data.usage !== undefined) {
198
- return { turn: event.data.turn, step: event.data.step, usage: event.data.usage }
199
- }
200
- return undefined
201
- }
202
-
203
- /**
204
- * Half-hour slot of the UTC day an event falls into (0..47).
205
- */
206
- export function slotOf(timeMs) {
207
- let ms = typeof timeMs === 'number' && Number.isFinite(timeMs) ? timeMs : NaN
208
- if (Number.isNaN(ms)) {
209
- if (timeMs instanceof Date && !Number.isNaN(timeMs.getTime())) {
210
- ms = timeMs.getTime()
211
- } else {
212
- ms = Date.now()
213
- }
214
- }
215
- const d = new Date(ms)
216
- return Math.floor((d.getUTCHours() * 60 + d.getUTCMinutes()) / 30)
217
- }
218
-
219
- const modelSlotsSchema = zod.record(zod.string(), bucketSchema)
220
-
221
- const costByModelViewSchema = zod.object({
222
- models: zod.array(zod.object({
223
- provider: zod.string(),
224
- model: zod.string(),
225
- slots: modelSlotsSchema,
226
- }).strict()),
227
- }).strict()
228
-
229
- const costByModelStateSchema = zod.object({
230
- route: zod.object({
231
- provider: zod.string(),
232
- model: zod.string(),
233
- }).nullable(),
234
- byKey: zod.record(zod.string(), modelSlotsSchema),
235
- last: zod.object({
236
- turn: zod.number().int().nonnegative(),
237
- step: zod.number().int().nonnegative(),
238
- key: zod.string(),
239
- slot: zod.string(),
240
- buckets: bucketSchema,
241
- }).nullable(),
242
- }).strict()
243
-
244
- export const usageByModelProjection = {
245
- key: 'costByModel',
246
- stateVersion: 2,
247
- stateSchema: costByModelStateSchema,
248
- init: () => ({ route: null, byKey: {}, last: null }),
249
- apply: (state, event) => {
250
- if (event.type === 'request/header') {
251
- const config = event.data && event.data.header && event.data.header.config
252
- const provider = config && typeof config.provider === 'string' ? config.provider : undefined
253
- const model = config && typeof config.model === 'string' ? config.model : undefined
254
- if (provider === undefined || model === undefined) return state
255
- if (state.route && state.route.provider === provider && state.route.model === model) return state
256
- return { ...state, route: { provider, model } }
257
- }
258
- const sample = usageOf(event)
259
- if (sample === undefined) return state
260
- const route = state.route || { provider: '', model: 'unknown' }
261
- const key = route.provider + '/' + route.model
262
- const slot = String(slotOf(event.time))
263
- const buckets = bucketsFromUsage(sample.usage)
264
- const previous =
265
- state.last !== null &&
266
- state.last.turn === sample.turn &&
267
- state.last.step === sample.step &&
268
- state.last.key === key &&
269
- state.last.slot === slot
270
- ? state.last.buckets
271
- : undefined
272
- if (previous !== undefined && sameBuckets(previous, buckets)) return state
273
- const slots = state.byKey[key] || {}
274
- return {
275
- route: state.route,
276
- byKey: {
277
- ...state.byKey,
278
- [key]: { ...slots, [slot]: addBuckets(slots[slot] || ZERO, previous, buckets) },
279
- },
280
- last: { turn: sample.turn, step: sample.step, key, slot, buckets },
281
- }
282
- },
283
- wire: {
284
- viewSchema: costByModelViewSchema,
285
- view: (state) => ({
286
- models: Object.keys(state.byKey).map((key) => {
287
- const slash = key.indexOf('/')
288
- return {
289
- provider: key.slice(0, slash),
290
- model: key.slice(slash + 1),
291
- slots: state.byKey[key],
292
- }
293
- }),
294
- }),
295
- },
296
- }
155
+ // Per-model usage projection is defined in and exported from ./projection.js
297
156
 
298
157
  /** The agent's default route, read off settings descriptors when available. */
299
158
  function defaultRoute(ctx) {
@@ -411,125 +270,19 @@ export function apply(ctx, config) {
411
270
 
412
271
  // Hook the session projection so the widget can show spend breakdown.
413
272
  ctx.inject(['sessionProjections'], (pctx) => {
414
- pctx.sessionProjections.register(usageByModelProjection)
273
+ const eff = typeof pctx.effect === 'function' ? pctx.effect.bind(pctx) : (typeof ctx.effect === 'function' ? ctx.effect.bind(ctx) : (fn) => fn())
274
+ eff(() => pctx.sessionProjections.register(usageByModelProjection))
415
275
  })
416
276
 
417
- // Route 1: POST /dsh-cost-meter/refresh (manual catalog reload)
418
- ctx.effect(
419
- () =>
420
- ctx.webServer.register({
421
- path: REFRESH_PATH,
422
- method: 'POST',
423
- handler: async (req, res) => {
424
- if (!isTrustedCaller(req)) {
425
- res.writeHead(403, { 'Content-Type': 'application/json' })
426
- res.end(JSON.stringify({ error: 'forbidden' }))
427
- return
428
- }
429
- const now = Date.now()
430
- if (now - lastManualRefreshAt < 60_000) {
431
- res.writeHead(429, { 'Content-Type': 'application/json' })
432
- res.end(JSON.stringify({ error: 'rate_limited', retryAfterMs: 60_000 - (now - lastManualRefreshAt) }))
433
- return
434
- }
435
- lastManualRefreshAt = now
436
- await refresh(null, true)
437
- res.writeHead(200, { 'Content-Type': 'application/json' })
438
- res.end(
439
- JSON.stringify({
440
- ok: !catalog.error,
441
- fetchedAt: catalog.fetchedAt,
442
- models: Object.keys((catalog.index && catalog.index.byId) || {}).length,
443
- error: catalog.error,
444
- }),
445
- )
446
- },
447
- }),
448
- 'dsh-cost-meter: refresh route',
449
- )
450
-
451
- // Route 2: GET /dsh-cost-meter/state (the browser poller hits this)
452
- ctx.effect(
453
- () =>
454
- ctx.webServer.register({
455
- path: STATE_PATH,
456
- method: 'GET',
457
- handler: async (req, res) => {
458
- if (!isTrustedCaller(req)) {
459
- res.writeHead(403, { 'Content-Type': 'application/json' })
460
- res.end(JSON.stringify({ error: 'forbidden' }))
461
- return
462
- }
463
- const cfg = current ?? config
464
-
465
- // On the very first request after boot, wait for the fetch if we have
466
- // no disk cache either. Later runs refresh in the background.
467
- if (!catalog.fetchedAt && !catalog.error) {
468
- await refresh()
469
- } else {
470
- void refresh()
471
- }
472
-
473
- const route = defaultRoute(ctx) ?? null
474
- const own = getCachedTariff(cfg, route)
475
-
476
- // Also resolve tariffs for any session routes requested in query params
477
- const schedules = {}
478
- const url = new URL(req.url, 'http://localhost')
479
- const extraRoutes = url.searchParams.getAll('route')
480
- for (const key of extraRoutes) {
481
- const parsed = splitRoute(key)
482
- if (parsed) {
483
- const res = getCachedTariff(cfg, parsed)
484
- if (res && res.schedule) schedules[key] = res.schedule
485
- }
486
- }
487
-
488
- const payloadObj = {
489
- currency: cfg.currency,
490
- usdRate: cfg.usdRate,
491
- displayTimeZone: cfg.displayTimeZone || 'Europe/Moscow',
492
- budgetThreshold: Number(cfg.budgetThreshold) || 0,
493
- route,
494
- source: own.source,
495
- matchedId: own.matchedId,
496
- schedules,
497
- fetchedAt: catalog.fetchedAt || null,
498
- catalogError: catalog.error,
499
- catalogModelsCount: Object.keys((catalog.index && catalog.index.byId) || {}).length,
500
- schedule: own.schedule,
501
- }
502
-
503
- const serialized = JSON.stringify(payloadObj)
504
- const etag = 'W/"' + createHash('sha1').update(serialized).digest('base64url').slice(0, 16) + '"'
505
-
506
- if (req.headers['if-none-match'] === etag) {
507
- res.writeHead(304, {
508
- 'ETag': etag,
509
- 'Cache-Control': 'no-cache',
510
- })
511
- res.end()
512
- return
513
- }
514
-
515
- res.writeHead(200, {
516
- 'Content-Type': 'application/json',
517
- 'Cache-Control': 'no-cache',
518
- 'ETag': etag,
519
- })
520
- res.end(serialized)
521
- },
522
- }),
523
- 'dsh-cost-meter: state route',
524
- )
525
-
526
- // Route 3: /dsh-cost-meter/update (in-app one-click updater)
527
- if (ctx.webServer?.register) {
528
- ctx.effect(() => registerPluginUpdater(ctx, {
529
- endpoint: '/dsh-cost-meter/update',
530
- packageName: name,
531
- manifestUrl: new URL('../package.json', import.meta.url),
532
- }), 'dsh-cost-meter: updater')
533
- }
534
-
277
+ // Register HTTP routes (refresh, state, update) via webServer service
278
+ registerRoutes(ctx, {
279
+ name,
280
+ isTrustedCaller,
281
+ getCachedTariff,
282
+ refresh,
283
+ splitRoute,
284
+ defaultRoute,
285
+ getCurrentConfig: () => current ?? config,
286
+ getCatalog: () => catalog,
287
+ })
535
288
  }
@@ -0,0 +1,139 @@
1
+ // dsh-cost-meter — per-model usage projection for sessionProjections service.
2
+ import { z as zod } from 'zod'
3
+
4
+ export const ZERO = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
5
+
6
+ export const bucketSchema = zod.object({
7
+ uncachedInputTokens: zod.number().nonnegative(),
8
+ outputTokens: zod.number().nonnegative(),
9
+ cacheReadTokens: zod.number().nonnegative(),
10
+ cacheWriteTokens: zod.number().nonnegative(),
11
+ }).strict()
12
+
13
+ /** Pull the four buckets out of one provider usage record. */
14
+ export function bucketsFromUsage(usage) {
15
+ return {
16
+ uncachedInputTokens: Number(usage.inputTokens) || 0,
17
+ outputTokens: Number(usage.outputTokens) || 0,
18
+ cacheReadTokens: Number(usage.cacheReadTokens) || 0,
19
+ cacheWriteTokens: Number(usage.cacheWriteTokens) || 0,
20
+ }
21
+ }
22
+
23
+ export function addBuckets(base, remove, add) {
24
+ const out = {}
25
+ for (const k of Object.keys(ZERO)) {
26
+ out[k] = (base[k] || 0) - ((remove && remove[k]) || 0) + (add[k] || 0)
27
+ }
28
+ return out
29
+ }
30
+
31
+ export function sameBuckets(a, b) {
32
+ return a && b && Object.keys(ZERO).every((k) => (a[k] || 0) === (b[k] || 0))
33
+ }
34
+
35
+ /** The usage a streamed chunk or a finalized assistant message reports. */
36
+ export function usageOf(event) {
37
+ if (event.type === 'assistant/chunk' && event.data.chunk && event.data.chunk.type === 'usage') {
38
+ return { turn: event.data.turn, step: event.data.step, usage: event.data.chunk.usage }
39
+ }
40
+ if (event.type === 'assistant/message' && event.data.usage !== undefined) {
41
+ return { turn: event.data.turn, step: event.data.step, usage: event.data.usage }
42
+ }
43
+ return undefined
44
+ }
45
+
46
+ /**
47
+ * Half-hour slot of the UTC day an event falls into (0..47).
48
+ */
49
+ export function slotOf(timeMs) {
50
+ let ms = typeof timeMs === 'number' && Number.isFinite(timeMs) ? timeMs : NaN
51
+ if (Number.isNaN(ms)) {
52
+ if (timeMs instanceof Date && !Number.isNaN(timeMs.getTime())) {
53
+ ms = timeMs.getTime()
54
+ } else {
55
+ ms = Date.now()
56
+ }
57
+ }
58
+ const d = new Date(ms)
59
+ return Math.floor((d.getUTCHours() * 60 + d.getUTCMinutes()) / 30)
60
+ }
61
+
62
+ export const modelSlotsSchema = zod.record(zod.string(), bucketSchema)
63
+
64
+ export const costByModelViewSchema = zod.object({
65
+ models: zod.array(zod.object({
66
+ provider: zod.string(),
67
+ model: zod.string(),
68
+ slots: modelSlotsSchema,
69
+ }).strict()),
70
+ }).strict()
71
+
72
+ export const costByModelStateSchema = zod.object({
73
+ route: zod.object({
74
+ provider: zod.string(),
75
+ model: zod.string(),
76
+ }).nullable(),
77
+ byKey: zod.record(zod.string(), modelSlotsSchema),
78
+ last: zod.object({
79
+ turn: zod.number().int().nonnegative(),
80
+ step: zod.number().int().nonnegative(),
81
+ key: zod.string(),
82
+ slot: zod.string(),
83
+ buckets: bucketSchema,
84
+ }).nullable(),
85
+ }).strict()
86
+
87
+ export const usageByModelProjection = {
88
+ key: 'costByModel',
89
+ stateVersion: 2,
90
+ stateSchema: costByModelStateSchema,
91
+ init: () => ({ route: null, byKey: {}, last: null }),
92
+ apply: (state, event) => {
93
+ if (event.type === 'request/header') {
94
+ const config = event.data && event.data.header && event.data.header.config
95
+ const provider = config && typeof config.provider === 'string' ? config.provider : undefined
96
+ const model = config && typeof config.model === 'string' ? config.model : undefined
97
+ if (provider === undefined || model === undefined) return state
98
+ if (state.route && state.route.provider === provider && state.route.model === model) return state
99
+ return { ...state, route: { provider, model } }
100
+ }
101
+ const sample = usageOf(event)
102
+ if (sample === undefined) return state
103
+ const route = state.route || { provider: '', model: 'unknown' }
104
+ const key = route.provider + '/' + route.model
105
+ const slot = String(slotOf(event.time))
106
+ const buckets = bucketsFromUsage(sample.usage)
107
+ const previous =
108
+ state.last !== null &&
109
+ state.last.turn === sample.turn &&
110
+ state.last.step === sample.step &&
111
+ state.last.key === key &&
112
+ state.last.slot === slot
113
+ ? state.last.buckets
114
+ : undefined
115
+ if (previous !== undefined && sameBuckets(previous, buckets)) return state
116
+ const slots = state.byKey[key] || {}
117
+ return {
118
+ route: state.route,
119
+ byKey: {
120
+ ...state.byKey,
121
+ [key]: { ...slots, [slot]: addBuckets(slots[slot] || ZERO, previous, buckets) },
122
+ },
123
+ last: { turn: sample.turn, step: sample.step, key, slot, buckets },
124
+ }
125
+ },
126
+ wire: {
127
+ viewSchema: costByModelViewSchema,
128
+ view: (state) => ({
129
+ models: Object.keys(state.byKey).map((key) => {
130
+ const slash = key.indexOf('/')
131
+ return {
132
+ provider: key.slice(0, slash),
133
+ model: key.slice(slash + 1),
134
+ slots: state.byKey[key],
135
+ }
136
+ }),
137
+ }),
138
+ },
139
+ }
package/lib/routes.js ADDED
@@ -0,0 +1,142 @@
1
+ // dsh-cost-meter — HTTP routes registration for webServer service.
2
+ import { createHash } from 'node:crypto'
3
+ import { registerPluginUpdater } from './updater.js'
4
+
5
+ export const STATE_PATH = '/dsh-cost-meter/state'
6
+ export const REFRESH_PATH = '/dsh-cost-meter/refresh'
7
+
8
+ export function registerRoutes(ctx, options) {
9
+ const {
10
+ name,
11
+ isTrustedCaller,
12
+ getCachedTariff,
13
+ refresh,
14
+ splitRoute,
15
+ defaultRoute,
16
+ getCurrentConfig,
17
+ getCatalog,
18
+ } = options
19
+
20
+ let lastManualRefreshAt = 0
21
+
22
+ // Route 1: POST /dsh-cost-meter/refresh (manual catalog reload)
23
+ ctx.effect(
24
+ () =>
25
+ ctx.webServer.register({
26
+ path: REFRESH_PATH,
27
+ method: 'POST',
28
+ handler: async (req, res) => {
29
+ if (!isTrustedCaller(req)) {
30
+ res.writeHead(403, { 'Content-Type': 'application/json' })
31
+ res.end(JSON.stringify({ error: 'forbidden' }))
32
+ return
33
+ }
34
+ const now = Date.now()
35
+ if (now - lastManualRefreshAt < 60_000) {
36
+ res.writeHead(429, { 'Content-Type': 'application/json' })
37
+ res.end(JSON.stringify({ error: 'rate_limited', retryAfterMs: 60_000 - (now - lastManualRefreshAt) }))
38
+ return
39
+ }
40
+ lastManualRefreshAt = now
41
+ await refresh(null, true)
42
+ const catalog = getCatalog()
43
+ res.writeHead(200, { 'Content-Type': 'application/json' })
44
+ res.end(
45
+ JSON.stringify({
46
+ ok: !catalog.error,
47
+ fetchedAt: catalog.fetchedAt,
48
+ models: Object.keys((catalog.index && catalog.index.byId) || {}).length,
49
+ error: catalog.error,
50
+ }),
51
+ )
52
+ },
53
+ }),
54
+ 'dsh-cost-meter: refresh route',
55
+ )
56
+
57
+ // Route 2: GET /dsh-cost-meter/state (the browser poller hits this)
58
+ ctx.effect(
59
+ () =>
60
+ ctx.webServer.register({
61
+ path: STATE_PATH,
62
+ method: 'GET',
63
+ handler: async (req, res) => {
64
+ if (!isTrustedCaller(req)) {
65
+ res.writeHead(403, { 'Content-Type': 'application/json' })
66
+ res.end(JSON.stringify({ error: 'forbidden' }))
67
+ return
68
+ }
69
+ const cfg = getCurrentConfig()
70
+ let catalog = getCatalog()
71
+
72
+ // On the very first request after boot, wait for the fetch if we have
73
+ // no disk cache either. Later runs refresh in the background.
74
+ if (!catalog.fetchedAt && !catalog.error) {
75
+ await refresh()
76
+ catalog = getCatalog()
77
+ } else {
78
+ void refresh()
79
+ }
80
+
81
+ const route = defaultRoute(ctx) ?? null
82
+ const own = getCachedTariff(cfg, route)
83
+
84
+ // Also resolve tariffs for any session routes requested in query params
85
+ const schedules = {}
86
+ const url = new URL(req.url, 'http://localhost')
87
+ const extraRoutes = url.searchParams.getAll('route')
88
+ for (const key of extraRoutes) {
89
+ const parsed = splitRoute(key)
90
+ if (parsed) {
91
+ const res = getCachedTariff(cfg, parsed)
92
+ if (res && res.schedule) schedules[key] = res.schedule
93
+ }
94
+ }
95
+
96
+ const payloadObj = {
97
+ currency: cfg.currency,
98
+ usdRate: cfg.usdRate,
99
+ displayTimeZone: cfg.displayTimeZone || 'Europe/Moscow',
100
+ budgetThreshold: Number(cfg.budgetThreshold) || 0,
101
+ route,
102
+ source: own.source,
103
+ matchedId: own.matchedId,
104
+ schedules,
105
+ fetchedAt: catalog.fetchedAt || null,
106
+ catalogError: catalog.error,
107
+ catalogModelsCount: Object.keys((catalog.index && catalog.index.byId) || {}).length,
108
+ schedule: own.schedule,
109
+ }
110
+
111
+ const serialized = JSON.stringify(payloadObj)
112
+ const etag = 'W/"' + createHash('sha1').update(serialized).digest('base64url').slice(0, 16) + '"'
113
+
114
+ if (req.headers['if-none-match'] === etag) {
115
+ res.writeHead(304, {
116
+ 'ETag': etag,
117
+ 'Cache-Control': 'no-cache',
118
+ })
119
+ res.end()
120
+ return
121
+ }
122
+
123
+ res.writeHead(200, {
124
+ 'Content-Type': 'application/json',
125
+ 'Cache-Control': 'no-cache',
126
+ 'ETag': etag,
127
+ })
128
+ res.end(serialized)
129
+ },
130
+ }),
131
+ 'dsh-cost-meter: state route',
132
+ )
133
+
134
+ // Route 3: /dsh-cost-meter/update (in-app one-click updater)
135
+ if (ctx.webServer?.register) {
136
+ ctx.effect(() => registerPluginUpdater(ctx, {
137
+ endpoint: '/dsh-cost-meter/update',
138
+ packageName: name,
139
+ manifestUrl: new URL('../package.json', import.meta.url),
140
+ }), 'dsh-cost-meter: updater')
141
+ }
142
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cost-meter",
3
- "version": "0.8.14",
3
+ "version": "0.8.16",
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",
@@ -51,6 +51,9 @@
51
51
  },
52
52
  "peerDependencies": {
53
53
  "@deepseek-ai/cordis": "^4.0.1",
54
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.7-rc.2 || >=0.2.0-rc.1",
55
+ "@deepseek-ai/dsh-session-projection": ">=0.1.7-rc.2 || >=0.2.0-rc.1",
56
+ "@deepseek-ai/dsh-settings": ">=0.1.7-rc.2 || >=0.2.0-rc.1",
54
57
  "@deepseek-ai/schemastery": "^3.18.1"
55
58
  },
56
59
  "dependencies": {