@goodandready/dsh-cost-meter 0.8.17 → 0.8.19

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
@@ -961,6 +961,28 @@ window.__ModuleLoader__.load({
961
961
  return usd
962
962
  }
963
963
 
964
+ function cacheSavingsBySlots(slots, schedule) {
965
+ let savedUsd = 0
966
+ let missedUsd = 0
967
+ let readTokens = 0
968
+ for (const key of Object.keys(slots || {})) {
969
+ const minute = (Number(key) * 30 + 15) % 1440
970
+ const rates = ratesAt(schedule, minute)
971
+ const u = slots[key] || {}
972
+ const cRead = Number(u.cacheReadTokens) || 0
973
+ if (cRead > 0 && rates && rates.input > rates.cacheHit) {
974
+ readTokens += cRead
975
+ const missed = (cRead / 1e6) * (rates.input || 0)
976
+ const hit = (cRead / 1e6) * (rates.cacheHit || 0)
977
+ savedUsd += Math.max(0, missed - hit)
978
+ missedUsd += missed
979
+ }
980
+ }
981
+ const percent = missedUsd > 0 ? Math.round((savedUsd / missedUsd) * 100) : 0
982
+ return { savedUsd, missedUsd, readTokens, percent }
983
+ }
984
+
985
+
964
986
  function spendUsd(usage, rates) {
965
987
  const u = usage || {}
966
988
  const perMillion =
@@ -1193,17 +1215,36 @@ window.__ModuleLoader__.load({
1193
1215
  .join(', ')
1194
1216
  const nowLocal = hhmmInZone(minute, zone, now)
1195
1217
 
1196
- // Calculate cache savings amount
1218
+ // Calculate cache savings amount across all used models (#92)
1197
1219
  let cacheSavedAmount = 0
1198
1220
  let cacheSavedPercent = 0
1199
- if (hasUsage && usage && Number(usage.cacheReadTokens) > 0 && active.input > active.cacheHit) {
1200
- const tokensRead = Number(usage.cacheReadTokens)
1201
- const missedCost = (tokensRead / 1e6) * active.input
1202
- const hitCost = (tokensRead / 1e6) * active.cacheHit
1203
- cacheSavedAmount = Math.max(0, (missedCost - hitCost) * fx)
1204
- cacheSavedPercent = missedCost > 0 ? Math.round(((missedCost - hitCost) / missedCost) * 100) : 0
1221
+ if (hasUsage) {
1222
+ if (perModel.length > 0) {
1223
+ let totalSavedUsd = 0
1224
+ let totalMissedUsd = 0
1225
+ for (const m of perModel) {
1226
+ const own = schedules[m.key]
1227
+ const sched = own && own.schedule ? own.schedule : schedule
1228
+ if (sched) {
1229
+ const res = cacheSavingsBySlots(m.slots || (m.usage ? { 0: m.usage } : {}), sched)
1230
+ totalSavedUsd += res.savedUsd
1231
+ totalMissedUsd += res.missedUsd
1232
+ }
1233
+ }
1234
+ if (totalSavedUsd > 0) {
1235
+ cacheSavedAmount = totalSavedUsd * fx
1236
+ cacheSavedPercent = totalMissedUsd > 0 ? Math.round((totalSavedUsd / totalMissedUsd) * 100) : 0
1237
+ }
1238
+ } else if (usage && Number(usage.cacheReadTokens) > 0 && active.input > active.cacheHit) {
1239
+ const tokensRead = Number(usage.cacheReadTokens)
1240
+ const missedCost = (tokensRead / 1e6) * active.input
1241
+ const hitCost = (tokensRead / 1e6) * active.cacheHit
1242
+ cacheSavedAmount = Math.max(0, (missedCost - hitCost) * fx)
1243
+ cacheSavedPercent = missedCost > 0 ? Math.round(((missedCost - hitCost) / missedCost) * 100) : 0
1244
+ }
1205
1245
  }
1206
1246
 
1247
+
1207
1248
  // Copy summary report to clipboard
1208
1249
  const copyReport = () => {
1209
1250
  const lines = [
@@ -1255,9 +1296,11 @@ window.__ModuleLoader__.load({
1255
1296
  key: 'chip', type: 'button',
1256
1297
  className: 'dcm-chip',
1257
1298
  onClick: () => setOpen((v) => !v),
1258
- title: budgetExceeded
1299
+ title: (budgetExceeded
1259
1300
  ? t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })
1260
- : (flat ? 'Flat tariff' : cheapNow ? t('offpeak') : t('peak')),
1301
+ : (flat ? 'Flat tariff' : cheapNow ? t('offpeak') : t('peak'))) +
1302
+ (cacheSavedAmount > 0 ? ' · ' + t('savings.cache_saved', { amount: money(cacheSavedAmount, currency), percent: cacheSavedPercent }) : ''),
1303
+
1261
1304
  }, [
1262
1305
  h('span', { key: 'dot', className: dotClass }),
1263
1306
  h('span', { key: 'sum' }, hasUsage ? '≈ ' + money(total, currency) : '—'),
@@ -1480,8 +1523,12 @@ window.__ModuleLoader__.load({
1480
1523
  minutesUntilChange,
1481
1524
  extremes,
1482
1525
  ratesAt,
1526
+ spendBySlots,
1527
+ cacheSavingsBySlots,
1483
1528
  fetchWithTimeout,
1484
1529
  }
1530
+
1485
1531
  return module.exports
1486
1532
  },
1487
1533
  })
1534
+
package/lib/fx.js ADDED
@@ -0,0 +1,130 @@
1
+ // dsh-cost-meter — Live Foreign Exchange (FX) rate provider.
2
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { dirname, join } from 'node:path'
5
+
6
+ const CBR_URL = 'https://www.cbr-xml-daily.ru/daily_json.js'
7
+ const OPEN_ER_URL = 'https://open.er-api.com/v6/latest/USD'
8
+ const FX_CACHE_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'storages', 'dsh-cost-meter-fx.json')
9
+ const FX_TTL_MS = 12 * 3600_000 // 12 hours
10
+
11
+ let inMemoryCache = {
12
+ fetchedAt: 0,
13
+ rates: { RUB: 80, EUR: 0.92, CNY: 7.15 },
14
+ }
15
+
16
+ // Load disk cache synchronously on module load
17
+ try {
18
+ const cached = JSON.parse(readFileSync(FX_CACHE_FILE, 'utf8'))
19
+ if (cached && typeof cached === 'object' && cached.rates) {
20
+ inMemoryCache = {
21
+ fetchedAt: Number(cached.fetchedAt) || 0,
22
+ rates: { ...inMemoryCache.rates, ...cached.rates },
23
+ }
24
+ }
25
+ } catch {
26
+ // safe fallback to defaults on fresh install or read-only filesystem
27
+ }
28
+
29
+ export function normalizeCurrencyCode(symbolOrCode) {
30
+ const str = String(symbolOrCode || '').trim().toUpperCase()
31
+ if (str === '₽' || str === 'RUB' || str === 'РУБ') return 'RUB'
32
+ if (str === '€' || str === 'EUR') return 'EUR'
33
+ if (str === '¥' || str === 'CNY' || str === 'RMB') return 'CNY'
34
+ if (str === '$' || str === 'USD') return 'USD'
35
+ return str
36
+ }
37
+
38
+ export function parseCbrRates(body) {
39
+ if (!body || !body.Valute || typeof body.Valute !== 'object') return null
40
+ const usd = Number(body.Valute.USD && body.Valute.USD.Value)
41
+ const eur = Number(body.Valute.EUR && body.Valute.EUR.Value)
42
+ const cny = Number(body.Valute.CNY && body.Valute.CNY.Value)
43
+ if (!usd || usd <= 0) return null
44
+
45
+ const rates = { RUB: Math.round(usd * 1000) / 1000 }
46
+ if (eur > 0) rates.EUR = Math.round((usd / eur) * 10000) / 10000
47
+ if (cny > 0) rates.CNY = Math.round((usd / cny) * 1000) / 1000
48
+ return rates
49
+ }
50
+
51
+ export function parseOpenErRates(body) {
52
+ if (!body || !body.rates || typeof body.rates !== 'object') return null
53
+ const r = body.rates
54
+ const rates = {}
55
+ if (Number(r.RUB) > 0) rates.RUB = Math.round(Number(r.RUB) * 1000) / 1000
56
+ if (Number(r.EUR) > 0) rates.EUR = Math.round(Number(r.EUR) * 10000) / 10000
57
+ if (Number(r.CNY) > 0) rates.CNY = Math.round(Number(r.CNY) * 1000) / 1000
58
+ return Object.keys(rates).length ? rates : null
59
+ }
60
+
61
+ export async function fetchLiveRates(signal) {
62
+ // 1. Primary: CBR daily JSON
63
+ try {
64
+ const cbrSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(5000)]) : AbortSignal.timeout(5000)
65
+ const res = await fetch(CBR_URL, { signal: cbrSignal, headers: { accept: 'application/json' } })
66
+ if (res.ok) {
67
+ const parsed = parseCbrRates(await res.json())
68
+ if (parsed) return parsed
69
+ }
70
+ } catch {
71
+ // fallback to secondary
72
+ }
73
+
74
+ // 2. Secondary: Open Exchange Rates API (open.er-api.com)
75
+ try {
76
+ const fallbackSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(5000)]) : AbortSignal.timeout(5000)
77
+ const res = await fetch(OPEN_ER_URL, { signal: fallbackSignal, headers: { accept: 'application/json' } })
78
+ if (res.ok) {
79
+ const parsed = parseOpenErRates(await res.json())
80
+ if (parsed) return parsed
81
+ }
82
+ } catch {
83
+ // fail-safe
84
+ }
85
+
86
+ return null
87
+ }
88
+
89
+ export async function refreshFxRates(signal, force = false) {
90
+ const age = Date.now() - inMemoryCache.fetchedAt
91
+ if (!force && inMemoryCache.fetchedAt > 0 && age < FX_TTL_MS) {
92
+ return inMemoryCache.rates
93
+ }
94
+ const fresh = await fetchLiveRates(signal)
95
+ if (fresh) {
96
+ inMemoryCache = {
97
+ fetchedAt: Date.now(),
98
+ rates: { ...inMemoryCache.rates, ...fresh },
99
+ }
100
+ try {
101
+ mkdirSync(dirname(FX_CACHE_FILE), { recursive: true })
102
+ writeFileSync(FX_CACHE_FILE, JSON.stringify(inMemoryCache))
103
+ } catch {
104
+ // transient or read-only filesystem
105
+ }
106
+ }
107
+ return inMemoryCache.rates
108
+ }
109
+
110
+ export function getFxRate(currencySymbolOrCode, manualRate, autoFx = true) {
111
+ const code = normalizeCurrencyCode(currencySymbolOrCode)
112
+ if (code === 'USD') return 1
113
+
114
+ const fallback = Number(manualRate) > 0 ? Number(manualRate) : 1
115
+ if (autoFx === false) return fallback
116
+
117
+ const live = inMemoryCache.rates[code]
118
+ if (live && Number(live) > 0) {
119
+ return Number(live)
120
+ }
121
+ return fallback
122
+ }
123
+
124
+ export function getCachedFxState() {
125
+ return {
126
+ fetchedAt: inMemoryCache.fetchedAt,
127
+ rates: { ...inMemoryCache.rates },
128
+ }
129
+ }
130
+
package/lib/index.js CHANGED
@@ -29,9 +29,12 @@ try {
29
29
 
30
30
  export * from './tariff.js'
31
31
  export * from './projection.js'
32
+ export * from './fx.js'
32
33
  export { STATE_PATH, REFRESH_PATH } from './routes.js'
33
34
  import { indexCatalog, resolveTariff, matchModel, unwrapVolatile } from './tariff.js'
34
35
  import { usageByModelProjection } from './projection.js'
36
+ import { getFxRate, refreshFxRates } from './fx.js'
37
+
35
38
 
36
39
  export const name = '@goodandready/dsh-cost-meter'
37
40
  export const inject = ['webServer', 'settings', 'sessionProjections']
@@ -112,10 +115,13 @@ export function isTrustedCaller(request, ctx = null) {
112
115
 
113
116
  export function normalizeConfig(raw) {
114
117
  const u = unwrapVolatile(raw) || {}
118
+ const currency = typeof u.currency === 'string' && u.currency ? u.currency : '$'
119
+ const autoFx = u.autoFxRate !== false
115
120
  return {
116
121
  ...u,
117
- currency: typeof u.currency === 'string' && u.currency ? u.currency : '$',
118
- usdRate: Number(u.usdRate) > 0 ? Number(u.usdRate) : 1,
122
+ currency,
123
+ autoFxRate: autoFx,
124
+ usdRate: getFxRate(currency, u.usdRate, autoFx),
119
125
  displayTimeZone: typeof u.displayTimeZone === 'string' && u.displayTimeZone ? u.displayTimeZone : 'Europe/Moscow',
120
126
  budgetThreshold: Number(u.budgetThreshold) || 0,
121
127
  useOpenRouter: u.useOpenRouter !== false,
@@ -141,6 +147,7 @@ const PriceRow = z.object({
141
147
 
142
148
  export const Config = z.object({
143
149
  currency: z.string().description('Currency symbol shown in the widget.').default('$').volatile(),
150
+ autoFxRate: z.boolean().description('Automatically fetch daily exchange rates for non-USD currencies.').default(true).volatile(),
144
151
  usdRate: z.number().description('Units of `currency` per USD. 1 keeps everything in USD.').default(1).volatile(),
145
152
  displayTimeZone: z.string().description('IANA zone the widget shows tariff hours in.').default('Europe/Moscow').volatile(),
146
153
  budgetThreshold: z.number().description('Optional session budget threshold in USD. 0 disables threshold warnings.').default(0).volatile(),
@@ -153,6 +160,7 @@ export const Config = z.object({
153
160
  manualOffPeakMultiplier: z.number().description('Multiplier outside the manual peak windows.').default(1).volatile(),
154
161
  })
155
162
 
163
+
156
164
  // Per-model usage projection is defined in and exported from ./projection.js
157
165
 
158
166
  /** The agent's default route, read off settings descriptors when available. */
@@ -181,12 +189,14 @@ export function splitRoute(key) {
181
189
  }
182
190
 
183
191
  export function apply(ctx, config) {
192
+ void refreshFxRates()
184
193
  let current = normalizeConfig(config)
185
194
  let catalog = { index: { byId: {}, byLowerId: {}, bySuffix: {} }, fetchedAt: 0, error: null }
186
195
 
187
196
  // Server-side cache for resolved tariffs: key -> { schedule, source, matchedId }
188
197
  const resolveCache = new Map()
189
198
 
199
+
190
200
  function getCachedTariff(cfg, route) {
191
201
  if (!route) return resolveTariff(cfg, catalog.index, route)
192
202
  const cacheKey = (route.provider || '') + '/' + (route.model || '')
@@ -287,3 +297,4 @@ export function apply(ctx, config) {
287
297
  })
288
298
  }
289
299
 
300
+
package/lib/tariff.js CHANGED
@@ -252,3 +252,25 @@ export function resolveTariff(rawCfg, index, route) {
252
252
  }
253
253
  return { schedule: null, source: 'none', matchedId: null }
254
254
  }
255
+
256
+ /**
257
+ * Calculate saved cost from prompt cache hits vs regular input price (#92).
258
+ * Returns { savedUsd, readTokens, percent }
259
+ */
260
+ export function calculateCacheSavings(usage, rates) {
261
+ const u = usage || {}
262
+ const r = rates || {}
263
+ const readTokens = Number(u.cacheReadTokens) || 0
264
+ const inputRate = Number(r.input) || 0
265
+ const cacheHitRate = Number(r.cacheHit) || 0
266
+ if (readTokens <= 0 || inputRate <= cacheHitRate) {
267
+ return { savedUsd: 0, readTokens: 0, percent: 0 }
268
+ }
269
+ const missedUsd = (readTokens / 1e6) * inputRate
270
+ const hitUsd = (readTokens / 1e6) * cacheHitRate
271
+ const savedUsd = Math.max(0, missedUsd - hitUsd)
272
+ const percent = missedUsd > 0 ? Math.round((savedUsd / missedUsd) * 100) : 0
273
+ return { savedUsd, readTokens, percent }
274
+ }
275
+
276
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cost-meter",
3
- "version": "0.8.17",
3
+ "version": "0.8.19",
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",