@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.
- package/lib/fx.js +130 -0
- package/lib/index.js +13 -2
- 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
|
|
118
|
-
|
|
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.
|
|
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",
|