@goodandready/dsh-cost-meter 0.8.24 โ†’ 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. */
@@ -86,6 +88,10 @@ export function slotOf(timeMs) {
86
88
  export const modelSlotsSchema = zod.record(zod.string(), bucketSchema)
87
89
 
88
90
  export const costByModelViewSchema = zod.object({
91
+ route: zod.object({
92
+ provider: zod.string(),
93
+ model: zod.string(),
94
+ }).nullable().optional(),
89
95
  models: zod.array(zod.object({
90
96
  provider: zod.string(),
91
97
  model: zod.string(),
@@ -148,7 +154,20 @@ export const usageByModelProjection = {
148
154
  ? event.time
149
155
  : (event.time instanceof Date && !Number.isNaN(event.time.getTime()) ? event.time.getTime() : Date.now())
150
156
 
157
+ const promptTokens = buckets.uncachedInputTokens + buckets.cacheReadTokens
158
+ const cacheHitRate = promptTokens > 0
159
+ ? Math.round((buckets.cacheReadTokens / promptTokens) * 100)
160
+ : 0
161
+
151
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
+
152
171
  const existingIndex = currentTurns.findIndex((t) => t.turn === turnNum && t.step === stepNum)
153
172
  let nextTurns
154
173
  if (existingIndex >= 0) {
@@ -163,6 +182,8 @@ export const usageByModelProjection = {
163
182
  cacheWriteTokens: buckets.cacheWriteTokens,
164
183
  costDeltaUsd: nextTurns[existingIndex].costDeltaUsd || 0,
165
184
  timestamp,
185
+ durationMs: nextTurns[existingIndex].durationMs || durationMs,
186
+ cacheHitRate,
166
187
  }
167
188
  } else {
168
189
  const newEntry = {
@@ -175,6 +196,8 @@ export const usageByModelProjection = {
175
196
  cacheWriteTokens: buckets.cacheWriteTokens,
176
197
  costDeltaUsd: 0,
177
198
  timestamp,
199
+ durationMs,
200
+ cacheHitRate,
178
201
  }
179
202
  nextTurns = [...currentTurns, newEntry].slice(-MAX_TURNS_HISTORY)
180
203
  }
@@ -194,6 +217,7 @@ export const usageByModelProjection = {
194
217
  wire: {
195
218
  viewSchema: costByModelViewSchema,
196
219
  view: (state) => ({
220
+ ...(state.route ? { route: state.route } : {}),
197
221
  models: Object.keys(state.byKey).map((key) => {
198
222
  const slash = key.indexOf('/')
199
223
  return {
package/lib/routes.js CHANGED
@@ -80,18 +80,27 @@ export function registerRoutes(ctx, options) {
80
80
  void refresh()
81
81
  }
82
82
 
83
- const route = defaultRoute(ctx) ?? null
83
+ const url = new URL(req.url, 'http://localhost')
84
+ const currentQueryRoute = url.searchParams.get('currentRoute') || url.searchParams.get('model')
85
+ const parsedCurrent = currentQueryRoute ? splitRoute(currentQueryRoute) : null
86
+ const route = parsedCurrent ?? (defaultRoute(ctx) ?? null)
84
87
  const own = getCachedTariff(cfg, route)
85
88
 
86
89
  // Also resolve tariffs for any session routes requested in query params
87
90
  const schedules = {}
88
- const url = new URL(req.url, 'http://localhost')
89
- const extraRoutes = url.searchParams.getAll('route')
91
+ const extraRoutes = [
92
+ ...url.searchParams.getAll('route'),
93
+ ...url.searchParams.getAll('models').flatMap((m) => m.split(',')),
94
+ ]
90
95
  for (const key of extraRoutes) {
96
+ if (!key) continue
91
97
  const parsed = splitRoute(key)
92
98
  if (parsed) {
93
99
  const res = getCachedTariff(cfg, parsed)
94
- if (res && res.schedule) schedules[key] = res.schedule
100
+ if (res && res.schedule) {
101
+ schedules[key] = res.schedule
102
+ if (parsed.model) schedules[parsed.model] = res.schedule
103
+ }
95
104
  }
96
105
  }
97
106
 
@@ -101,6 +110,9 @@ export function registerRoutes(ctx, options) {
101
110
  displayTimeZone: typeof cfg?.displayTimeZone === 'string' && cfg.displayTimeZone ? cfg.displayTimeZone : 'Europe/Moscow',
102
111
  budgetThreshold: Number(cfg?.budgetThreshold) || 0,
103
112
  displayMode: cfg?.displayMode || 'auto',
113
+ alertBeforeSwitch: cfg?.alertBeforeSwitch !== false,
114
+ alertLeadMinutes: Number(cfg?.alertLeadMinutes) || 5,
115
+ browserNotifications: Boolean(cfg?.browserNotifications),
104
116
  route,
105
117
  source: own.source,
106
118
  matchedId: own.matchedId,
package/lib/tariff.js CHANGED
@@ -139,8 +139,16 @@ export function matchModel(index, route, modelMap) {
139
139
  if (byLowerId[lower]) return byLowerId[lower]
140
140
  if (byLowerId[key.toLowerCase()]) return byLowerId[key.toLowerCase()]
141
141
  const bySuffix = index.bySuffix || {}
142
- const list = bySuffix[lower]
143
- if (!list || !list.length) return undefined
142
+ let list = bySuffix[lower]
143
+ if (!list || !list.length) {
144
+ // Prefix fallback for short names like "beam" -> "beam-1.0-501b-a23b"
145
+ const prefixKey = Object.keys(bySuffix).find((k) => k.startsWith(lower + '-') || k.startsWith(lower + '.'))
146
+ if (prefixKey && bySuffix[prefixKey]?.length) {
147
+ list = bySuffix[prefixKey]
148
+ } else {
149
+ return undefined
150
+ }
151
+ }
144
152
  if (list.length === 1) return list[0]
145
153
 
146
154
  // Multiple catalog entries share the same model suffix: disambiguate by provider
@@ -155,8 +163,8 @@ export function matchModel(index, route, modelMap) {
155
163
  })
156
164
  if (matched) return matched
157
165
  }
158
- // Ambiguous suffix with no provider match: do not guess arbitrarily
159
- return undefined
166
+ // When provider does not match or is absent, fall back to the first catalog candidate for this model
167
+ return list[0]
160
168
  }
161
169
 
162
170
  /** Build lookup tables once per fetch; the raw catalog is ~700 KB. */
@@ -273,4 +281,4 @@ export function calculateCacheSavings(usage, rates) {
273
281
  return { savedUsd, readTokens, percent }
274
282
  }
275
283
 
276
-
284
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cost-meter",
3
- "version": "0.8.24",
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",