@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 +56 -9
- package/lib/fx.js +130 -0
- package/lib/index.js +13 -2
- package/lib/tariff.js +22 -0
- package/package.json +1 -1
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
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
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
|
|
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/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.
|
|
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",
|