@goodandready/dsh-cost-meter 0.8.18 → 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.
Files changed (3) hide show
  1. package/lib/fx.js +130 -0
  2. package/lib/index.js +13 -2
  3. package/package.json +1 -1
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cost-meter",
3
- "version": "0.8.18",
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",