@goodandready/dsh-cost-meter 0.8.25 โ†’ 0.8.26

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.
@@ -0,0 +1,260 @@
1
+ // dsh-cost-meter โ€” slash command /cost and receipt generator (Issue #125).
2
+
3
+ export function formatNumber(num) {
4
+ if (typeof num !== 'number' || Number.isNaN(num)) return '0'
5
+ return num.toLocaleString('en-US')
6
+ }
7
+
8
+ export function formatMoney(amount, currency = '$') {
9
+ const n = Number(amount) || 0
10
+ if (n === 0) return `${currency}0.00`
11
+ if (n < 0.0001) return `${currency}${n.toExponential(2)}`
12
+ if (n < 0.1) return `${currency}${n.toFixed(4)}`
13
+ return `${currency}${n.toFixed(2)}`
14
+ }
15
+
16
+ /**
17
+ * Format structured receipt markdown from session spend stats.
18
+ */
19
+ export function formatCostReceipt(data) {
20
+ const {
21
+ model = 'unknown',
22
+ provider = '',
23
+ currency = '$',
24
+ fx = 1,
25
+ costUsd = 0,
26
+ tokens = {},
27
+ cacheSavingsUsd = 0,
28
+ cacheSavingsPercent = 0,
29
+ tier = 'Standard',
30
+ rates = {},
31
+ burnRatePerHourUsd = 0,
32
+ projectedCostUsd = 0,
33
+ turnsCount = 0,
34
+ } = data || {}
35
+
36
+ const totalCost = costUsd * fx
37
+ const uncachedInput = Number(tokens.uncachedInputTokens || tokens.inputTokens) || 0
38
+ const output = Number(tokens.outputTokens) || 0
39
+ const cacheRead = Number(tokens.cacheReadTokens) || 0
40
+ const cacheWrite = Number(tokens.cacheWriteTokens) || 0
41
+ const totalTokens = uncachedInput + output + cacheRead + cacheWrite
42
+
43
+ const totalPromptTokens = uncachedInput + cacheRead
44
+ const cacheHitRate = totalPromptTokens > 0
45
+ ? Math.round((cacheRead / totalPromptTokens) * 100)
46
+ : 0
47
+
48
+ const routeLabel = provider ? `${provider}/${model}` : model
49
+
50
+ const lines = [
51
+ `### ๐Ÿงพ Cost Meter โ€” Session Spend Receipt`,
52
+ ``,
53
+ `| Metric | Value |`,
54
+ `| :--- | :--- |`,
55
+ `| **Model** | \`${routeLabel}\` |`,
56
+ `| **Session Spend** | **${formatMoney(totalCost, currency)}** |`,
57
+ `| **Total Tokens** | ${formatNumber(totalTokens)} (${turnsCount} ${turnsCount === 1 ? 'turn' : 'turns'}) |`,
58
+ `| **Uncached Input** | ${formatNumber(uncachedInput)} |`,
59
+ `| **Prompt Cache Read** | ${formatNumber(cacheRead)} (${cacheHitRate}% hit rate) |`,
60
+ `| **Output Tokens** | ${formatNumber(output)} |`,
61
+ ]
62
+
63
+ if (cacheWrite > 0) {
64
+ lines.push(`| **Cache Write** | ${formatNumber(cacheWrite)} |`)
65
+ }
66
+
67
+ if (cacheSavingsUsd > 0) {
68
+ lines.push(`| **Cache Savings** | **โ‰ˆ ${formatMoney(cacheSavingsUsd * fx, currency)}** (${cacheSavingsPercent}% saved) |`)
69
+ }
70
+
71
+ lines.push(`| **Tariff Tier** | ${tier} |`)
72
+
73
+ if (rates.input !== undefined) {
74
+ const inR = rates.input ? `$${rates.input}/1M` : 'โ€”'
75
+ const outR = rates.output ? `$${rates.output}/1M` : 'โ€”'
76
+ const hitR = rates.cacheHit ? `$${rates.cacheHit}/1M` : 'โ€”'
77
+ lines.push(`| **Rates (1M tok)** | In ${inR} ยท Out ${outR} ยท Cache ${hitR} |`)
78
+ }
79
+
80
+ if (burnRatePerHourUsd > 0) {
81
+ lines.push(`| **Burn Rate** | โ‰ˆ ${formatMoney(burnRatePerHourUsd * fx, currency)}/hr (proj. ${formatMoney(projectedCostUsd * fx, currency)}) |`)
82
+ }
83
+
84
+ return lines.join('\n')
85
+ }
86
+
87
+ /**
88
+ * Format compact 1-line or 2-line summary.
89
+ */
90
+ export function formatCostSummary(data) {
91
+ const {
92
+ model = 'model',
93
+ currency = '$',
94
+ fx = 1,
95
+ costUsd = 0,
96
+ tokens = {},
97
+ cacheSavingsPercent = 0,
98
+ } = data || {}
99
+
100
+ const totalCost = costUsd * fx
101
+ const uncachedInput = Number(tokens.uncachedInputTokens || tokens.inputTokens) || 0
102
+ const output = Number(tokens.outputTokens) || 0
103
+ const cacheRead = Number(tokens.cacheReadTokens) || 0
104
+ const totalTokens = uncachedInput + output + cacheRead
105
+ const totalPrompt = uncachedInput + cacheRead
106
+ const cacheHit = totalPrompt > 0 ? Math.round((cacheRead / totalPrompt) * 100) : 0
107
+
108
+ return `๐Ÿ’ฐ **Cost Meter**: โ‰ˆ ${formatMoney(totalCost, currency)} ยท ${formatNumber(totalTokens)} tokens (${cacheHit}% cache hit) ยท \`${model}\``
109
+ }
110
+
111
+ /**
112
+ * Register DSH slash command /cost and /cost-summary.
113
+ */
114
+ export function registerSlashCommand(ctx, options) {
115
+ if (typeof ctx?.inject !== 'function') return
116
+ const {
117
+ getCurrentConfig,
118
+ getCachedTariff,
119
+ getLastTurns,
120
+ resetLastTurns,
121
+ calculateBurnRateAndProjection,
122
+ calculateCacheSavings,
123
+ ratesAt,
124
+ splitRoute,
125
+ defaultRoute,
126
+ } = options || {}
127
+
128
+ ctx.inject(['commands'], (cmdCtx) => {
129
+ const commands = cmdCtx.commands
130
+ if (typeof commands?.register !== 'function') return
131
+
132
+ const executeCommand = async (rawArgs) => {
133
+ try {
134
+ const subcmd = String(rawArgs || '').trim().toLowerCase().split(/\s+/)[0] || 'receipt'
135
+ const cfg = typeof getCurrentConfig === 'function' ? getCurrentConfig() : {}
136
+ const currency = cfg.currency || '$'
137
+ const fx = Number(cfg.usdRate) || 1
138
+ const route = defaultRoute ? (defaultRoute(ctx) || { model: 'default' }) : { model: 'default' }
139
+ const tariff = typeof getCachedTariff === 'function' ? getCachedTariff(cfg, route) : null
140
+ const schedule = tariff?.schedule
141
+ const minute = (new Date().getUTCHours() * 60 + new Date().getUTCMinutes()) % 1440
142
+ const activeRates = (schedule && typeof ratesAt === 'function')
143
+ ? ratesAt(schedule, minute)
144
+ : (schedule?.base || {})
145
+
146
+ const turns = typeof getLastTurns === 'function' ? getLastTurns() : []
147
+
148
+ if (subcmd === 'reset') {
149
+ if (typeof resetLastTurns === 'function') resetLastTurns()
150
+ return 'โœจ Cost Meter: session turn spend history has been reset.'
151
+ }
152
+
153
+ if (subcmd === 'help') {
154
+ return [
155
+ '**Cost Meter Commands**:',
156
+ '- `/cost` or `/cost receipt`: generate detailed markdown spend receipt for this session',
157
+ '- `/cost summary`: compact spend and token usage summary',
158
+ '- `/cost reset`: reset per-session turn counter',
159
+ '- `/cost help`: show this help message',
160
+ ].join('\n')
161
+ }
162
+
163
+ // Aggregate tokens across turns
164
+ let totalUncachedInput = 0
165
+ let totalOutput = 0
166
+ let totalCacheRead = 0
167
+ let totalCacheWrite = 0
168
+ let totalCostUsd = 0
169
+
170
+ for (const t of turns) {
171
+ totalUncachedInput += Number(t.uncachedInputTokens) || 0
172
+ totalOutput += Number(t.outputTokens) || 0
173
+ totalCacheRead += Number(t.cacheReadTokens) || 0
174
+ totalCacheWrite += Number(t.cacheWriteTokens) || 0
175
+ totalCostUsd += Number(t.costDeltaUsd) || 0
176
+ }
177
+
178
+ const tokensObj = {
179
+ uncachedInputTokens: totalUncachedInput,
180
+ outputTokens: totalOutput,
181
+ cacheReadTokens: totalCacheRead,
182
+ cacheWriteTokens: totalCacheWrite,
183
+ }
184
+
185
+ const savings = (typeof calculateCacheSavings === 'function' && activeRates)
186
+ ? calculateCacheSavings(tokensObj, activeRates)
187
+ : { savedUsd: 0, percent: 0 }
188
+
189
+ const analytics = typeof calculateBurnRateAndProjection === 'function'
190
+ ? calculateBurnRateAndProjection(turns, totalCostUsd)
191
+ : { burnRatePerHourUsd: 0, projectedCostUsd: 0 }
192
+
193
+ const payload = {
194
+ model: route.model || 'active model',
195
+ provider: route.provider || '',
196
+ currency,
197
+ fx,
198
+ costUsd: totalCostUsd,
199
+ tokens: tokensObj,
200
+ cacheSavingsUsd: savings.savedUsd,
201
+ cacheSavingsPercent: savings.percent,
202
+ tier: tariff?.source === 'deepseek' ? 'DeepSeek Tariff' : (tariff?.source || 'Standard'),
203
+ rates: activeRates,
204
+ burnRatePerHourUsd: analytics.burnRatePerHourUsd,
205
+ projectedCostUsd: analytics.projectedCostUsd,
206
+ turnsCount: turns.length,
207
+ }
208
+
209
+ if (subcmd === 'summary' || subcmd === 'brief') {
210
+ return formatCostSummary(payload)
211
+ }
212
+
213
+ return formatCostReceipt(payload)
214
+ } catch (err) {
215
+ return `Cost Meter error: ${String(err?.message || err)}`
216
+ }
217
+ }
218
+
219
+ const commandHandler = async (invocation) => {
220
+ const rawArgs = typeof invocation === 'string'
221
+ ? invocation
222
+ : (invocation?.rawInput || invocation?.input || invocation?.text || invocation?.args || '')
223
+ const text = await executeCommand(rawArgs)
224
+ return {
225
+ kind: 'success',
226
+ text,
227
+ toString() { return text },
228
+ }
229
+ }
230
+
231
+ const commandExecute = async (rawArgs, invocation = null) => {
232
+ const result = await commandHandler(invocation || rawArgs)
233
+ return result.text
234
+ }
235
+
236
+ const unregister1 = commands.register({
237
+ definitionId: '@goodandready/dsh-cost-meter',
238
+ name: 'cost',
239
+ description: 'Show session spend receipt, tokens, cache savings and burn rate (/cost [receipt|summary|reset|help])',
240
+ input: { hint: '[receipt|summary|reset|help]' },
241
+ handler: commandHandler,
242
+ execute: commandExecute,
243
+ })
244
+
245
+ const unregister2 = commands.register({
246
+ definitionId: '@goodandready/dsh-cost-meter',
247
+ name: 'cost-summary',
248
+ description: 'Show compact 1-line session spend summary',
249
+ handler: async () => commandHandler('summary'),
250
+ execute: async () => commandExecute('summary'),
251
+ })
252
+
253
+ if (typeof ctx.effect === 'function') {
254
+ ctx.effect(() => () => {
255
+ unregister1?.()
256
+ unregister2?.()
257
+ }, 'dsh-cost-meter: slash-command')
258
+ }
259
+ })
260
+ }
package/lib/index.js CHANGED
@@ -30,11 +30,15 @@ try {
30
30
  export * from './tariff.js'
31
31
  export * from './projection.js'
32
32
  export * from './fx.js'
33
+ export * from './alerts.js'
34
+ export * from './commands.js'
33
35
  export { STATE_PATH, REFRESH_PATH } from './routes.js'
34
- import { indexCatalog, resolveTariff, matchModel, unwrapVolatile } from './tariff.js'
35
- import { usageByModelProjection } from './projection.js'
36
+ import { indexCatalog, resolveTariff, matchModel, unwrapVolatile, calculateCacheSavings } from './tariff.js'
37
+ import { usageByModelProjection, getLastTurns, resetLastTurns } from './projection.js'
36
38
  import { getFxRate, refreshFxRates } from './fx.js'
37
-
39
+ import { registerSlashCommand } from './commands.js'
40
+ import { calculateBurnRateAndProjection } from './analytics.js'
41
+ import { ratesAt } from './alerts.js'
38
42
 
39
43
  export const name = '@goodandready/dsh-cost-meter'
40
44
  export const inject = ['webServer', 'settings', 'sessionProjections']
@@ -84,7 +88,6 @@ export function isTrustedCaller(request, ctx = null) {
84
88
  if (isLoopback(url.hostname) && loopback) return true
85
89
  return false
86
90
  }
87
- // Non-http/https origin (e.g. dsh-app://app in DSH Desktop)
88
91
  if (loopback) return true
89
92
  return false
90
93
  } catch {
@@ -101,7 +104,6 @@ export function isTrustedCaller(request, ctx = null) {
101
104
  if (isLoopback(url.hostname) && loopback) return true
102
105
  return false
103
106
  }
104
- // Non-http/https referer (e.g. dsh-app://app/ in DSH Desktop)
105
107
  if (loopback) return true
106
108
  return false
107
109
  } catch {
@@ -110,7 +112,6 @@ export function isTrustedCaller(request, ctx = null) {
110
112
  }
111
113
 
112
114
  if (loopback) return true
113
-
114
115
  return false
115
116
  }
116
117
 
@@ -126,6 +127,9 @@ export function normalizeConfig(raw) {
126
127
  displayTimeZone: typeof u.displayTimeZone === 'string' && u.displayTimeZone ? u.displayTimeZone : 'Europe/Moscow',
127
128
  budgetThreshold: Number(u.budgetThreshold) || 0,
128
129
  displayMode: (u.displayMode === 'compact' || u.displayMode === 'full') ? u.displayMode : 'auto',
130
+ alertBeforeSwitch: u.alertBeforeSwitch !== false,
131
+ alertLeadMinutes: Math.max(1, Math.min(30, Number(u.alertLeadMinutes) || 5)),
132
+ browserNotifications: Boolean(u.browserNotifications),
129
133
  useOpenRouter: u.useOpenRouter !== false,
130
134
  refreshHours: Number(u.refreshHours) || 24,
131
135
  deepseekPeakPrices: u.deepseekPeakPrices || {},
@@ -154,6 +158,9 @@ export const Config = z.object({
154
158
  displayTimeZone: z.string().description('IANA zone the widget shows tariff hours in.').default('Europe/Moscow').volatile(),
155
159
  budgetThreshold: z.number().description('Optional session budget threshold in USD. 0 disables threshold warnings.').default(0).volatile(),
156
160
  displayMode: z.union(['auto', 'full', 'compact']).description('Widget display mode: auto, full, or compact.').default('auto').volatile(),
161
+ alertBeforeSwitch: z.boolean().description('Pre-switch notification and visual alert before tariff change.').default(true).volatile(),
162
+ alertLeadMinutes: z.number().description('Minutes before tariff change to trigger pre-switch alert.').default(5).volatile(),
163
+ browserNotifications: z.boolean().description('Enable desktop browser notifications for tariff switches.').default(false).volatile(),
157
164
  deepseekPeakPrices: z.dict(PriceRow).description('Override the built-in DeepSeek peak rates, by model id.').default({}).volatile(),
158
165
  useOpenRouter: z.boolean().description('Pull rates and discount windows from the public OpenRouter catalog.').default(true).volatile(),
159
166
  refreshHours: z.number().description('How often to refetch the catalog.').default(24).volatile(),
@@ -163,9 +170,6 @@ export const Config = z.object({
163
170
  manualOffPeakMultiplier: z.number().description('Multiplier outside the manual peak windows.').default(1).volatile(),
164
171
  })
165
172
 
166
-
167
- // Per-model usage projection is defined in and exported from ./projection.js
168
-
169
173
  /** The agent's default route, read off settings descriptors when available. */
170
174
  function defaultRoute(ctx) {
171
175
  const settings = (ctx?.get && typeof ctx.get === 'function' ? ctx.get('settings') : null) || ctx?.settings
@@ -195,17 +199,13 @@ export function apply(ctx, config) {
195
199
  void refreshFxRates()
196
200
  let current = normalizeConfig(config)
197
201
  let catalog = { index: { byId: {}, byLowerId: {}, bySuffix: {} }, fetchedAt: 0, error: null }
198
-
199
- // Server-side cache for resolved tariffs: key -> { schedule, source, matchedId }
200
202
  const resolveCache = new Map()
201
203
 
202
-
203
204
  function getCachedTariff(cfg, route) {
204
205
  if (!route) return resolveTariff(cfg, catalog.index, route)
205
206
  const cacheKey = (route.provider || '') + '/' + (route.model || '')
206
207
  if (resolveCache.has(cacheKey)) return resolveCache.get(cacheKey)
207
208
  const res = resolveTariff(cfg, catalog.index, route)
208
- // Limit cache size to prevent runaway memory
209
209
  if (resolveCache.size >= 500) resolveCache.clear()
210
210
  resolveCache.set(cacheKey, res)
211
211
  return res
@@ -215,15 +215,12 @@ export function apply(ctx, config) {
215
215
  resolveCache.clear()
216
216
  }
217
217
 
218
- // Disk cache survives restarts, so a boot without network still prices.
219
218
  try {
220
219
  const cached = JSON.parse(readFileSync(CACHE_FILE, 'utf8'))
221
220
  if (cached && Array.isArray(cached.models)) {
222
221
  catalog = { index: indexCatalog(cached.models), fetchedAt: cached.fetchedAt || 0, error: null }
223
222
  }
224
- } catch {
225
- // absent or unreadable cache is normal on first run
226
- }
223
+ } catch {}
227
224
 
228
225
  async function refresh(signal, force = false) {
229
226
  const cfg = current ?? config
@@ -244,9 +241,7 @@ export function apply(ctx, config) {
244
241
  try {
245
242
  mkdirSync(dirname(CACHE_FILE), { recursive: true })
246
243
  writeFileSync(CACHE_FILE, JSON.stringify({ fetchedAt: catalog.fetchedAt, models: slim }))
247
- } catch {
248
- // a read-only storages dir only costs us the restart-time cache
249
- }
244
+ } catch {}
250
245
  } catch (error) {
251
246
  catalog = { ...catalog, error: String((error && error.message) || error) }
252
247
  }
@@ -287,6 +282,19 @@ export function apply(ctx, config) {
287
282
  eff(() => pctx.sessionProjections.register(usageByModelProjection))
288
283
  })
289
284
 
285
+ // Register slash command /cost and /cost-summary
286
+ registerSlashCommand(ctx, {
287
+ getCurrentConfig: () => (current ? normalizeConfig(current) : normalizeConfig(config)),
288
+ getCachedTariff,
289
+ getLastTurns,
290
+ resetLastTurns,
291
+ calculateBurnRateAndProjection,
292
+ calculateCacheSavings,
293
+ ratesAt,
294
+ splitRoute,
295
+ defaultRoute,
296
+ })
297
+
290
298
  // Register HTTP routes (refresh, state, update) via webServer service
291
299
  registerRoutes(ctx, {
292
300
  name,
@@ -299,5 +307,3 @@ export function apply(ctx, config) {
299
307
  getCatalog: () => catalog,
300
308
  })
301
309
  }
302
-
303
-
package/lib/projection.js CHANGED
@@ -32,6 +32,8 @@ export const turnRecordSchema = zod.object({
32
32
  cacheWriteTokens: zod.number().nonnegative().optional(),
33
33
  costDeltaUsd: zod.number().nonnegative().optional(),
34
34
  timestamp: zod.number().nonnegative(),
35
+ durationMs: zod.number().nonnegative().optional(),
36
+ cacheHitRate: zod.number().min(0).max(100).optional(),
35
37
  }).passthrough()
36
38
 
37
39
  /** Pull the four buckets out of one provider usage record. */
@@ -152,7 +154,20 @@ export const usageByModelProjection = {
152
154
  ? event.time
153
155
  : (event.time instanceof Date && !Number.isNaN(event.time.getTime()) ? event.time.getTime() : Date.now())
154
156
 
157
+ const promptTokens = buckets.uncachedInputTokens + buckets.cacheReadTokens
158
+ const cacheHitRate = promptTokens > 0
159
+ ? Math.round((buckets.cacheReadTokens / promptTokens) * 100)
160
+ : 0
161
+
155
162
  const currentTurns = Array.isArray(state.turns) ? state.turns : []
163
+ let durationMs = 0
164
+ if (currentTurns.length > 0) {
165
+ const prevTs = currentTurns[currentTurns.length - 1].timestamp
166
+ if (timestamp > prevTs && timestamp - prevTs < 600000) {
167
+ durationMs = timestamp - prevTs
168
+ }
169
+ }
170
+
156
171
  const existingIndex = currentTurns.findIndex((t) => t.turn === turnNum && t.step === stepNum)
157
172
  let nextTurns
158
173
  if (existingIndex >= 0) {
@@ -167,6 +182,8 @@ export const usageByModelProjection = {
167
182
  cacheWriteTokens: buckets.cacheWriteTokens,
168
183
  costDeltaUsd: nextTurns[existingIndex].costDeltaUsd || 0,
169
184
  timestamp,
185
+ durationMs: nextTurns[existingIndex].durationMs || durationMs,
186
+ cacheHitRate,
170
187
  }
171
188
  } else {
172
189
  const newEntry = {
@@ -179,6 +196,8 @@ export const usageByModelProjection = {
179
196
  cacheWriteTokens: buckets.cacheWriteTokens,
180
197
  costDeltaUsd: 0,
181
198
  timestamp,
199
+ durationMs,
200
+ cacheHitRate,
182
201
  }
183
202
  nextTurns = [...currentTurns, newEntry].slice(-MAX_TURNS_HISTORY)
184
203
  }
package/lib/routes.js CHANGED
@@ -110,6 +110,9 @@ export function registerRoutes(ctx, options) {
110
110
  displayTimeZone: typeof cfg?.displayTimeZone === 'string' && cfg.displayTimeZone ? cfg.displayTimeZone : 'Europe/Moscow',
111
111
  budgetThreshold: Number(cfg?.budgetThreshold) || 0,
112
112
  displayMode: cfg?.displayMode || 'auto',
113
+ alertBeforeSwitch: cfg?.alertBeforeSwitch !== false,
114
+ alertLeadMinutes: Number(cfg?.alertLeadMinutes) || 5,
115
+ browserNotifications: Boolean(cfg?.browserNotifications),
113
116
  route,
114
117
  source: own.source,
115
118
  matchedId: own.matchedId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cost-meter",
3
- "version": "0.8.25",
3
+ "version": "0.8.26",
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",