@goodandready/dsh-cost-meter 0.8.18 → 0.8.20

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
@@ -98,6 +98,8 @@ window.__ModuleLoader__.load({
98
98
  'tokens.no_usage_yet': 'Provider has not reported usage for this session yet.',
99
99
  'tokens.not_priced': 'Not priced',
100
100
  'tz.invalid': 'Invalid IANA time zone identifier',
101
+ 'sparkline.title': 'Turn dynamics',
102
+ 'sparkline.steps': 'steps',
101
103
  }
102
104
 
103
105
  const zh = {
@@ -176,6 +178,8 @@ window.__ModuleLoader__.load({
176
178
  'tokens.no_usage_yet': '提供商尚未报告当前会话的用量数据。',
177
179
  'tokens.not_priced': '未定价',
178
180
  'tz.invalid': '无效的 IANA 时区标识',
181
+ 'sparkline.title': '步骤动态',
182
+ 'sparkline.steps': '步',
179
183
  }
180
184
 
181
185
  function makeT(dict, fallback) {
@@ -288,8 +292,16 @@ window.__ModuleLoader__.load({
288
292
  .dcm-total { display: flex; justify-content: space-between; font-weight: 600; font-size: 13.5px; }
289
293
  .dcm-accordion-btn { appearance: none; background: transparent; border: 0; color: var(--dsw-alias-state-brand-primary); font: inherit; font-size: 11.5px; cursor: pointer; padding: 2px 0; text-align: left; }
290
294
  .dcm-accordion-btn:hover { text-decoration: underline; }
295
+ .dcm-sparkline-box { margin: 6px 0; padding: 8px 10px; border-radius: 8px; background: color-mix(in srgb, var(--dsw-alias-bg-layer-2) 60%, transparent); border: 1px solid var(--dsw-alias-border-l2); }
296
+ .dcm-sparkline-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
297
+ .dcm-sparkline-count { font-size: 11px; color: var(--dsw-alias-label-tertiary); font-variant-numeric: tabular-nums; }
298
+ .dcm-sparkline-svg { width: 100%; height: 36px; display: block; overflow: visible; }
299
+ .dcm-spark-bar { fill: color-mix(in srgb, var(--dsw-alias-state-brand-primary) 45%, transparent); transition: fill .15s ease; cursor: pointer; }
300
+ .dcm-spark-bar:hover { fill: var(--dsw-alias-state-brand-primary); }
301
+ .dcm-spark-peak { fill: var(--dsw-alias-state-brand-primary); }
291
302
  `
292
303
 
304
+
293
305
  function ensureStyles() {
294
306
  if (typeof document === 'undefined') return
295
307
  if (document.getElementById('dsh-cost-meter-unified-css')) return
@@ -1039,8 +1051,63 @@ window.__ModuleLoader__.load({
1039
1051
  return city.replace(/_/g, ' ')
1040
1052
  }
1041
1053
 
1054
+
1055
+ function renderSparkline(turns, options) {
1056
+ if (!Array.isArray(turns) || turns.length === 0) return null
1057
+ const { h, currency, fx, activeRate, t } = options
1058
+
1059
+ const maxTokens = Math.max(1, ...turns.map((item) => Number(item.tokens) || 0))
1060
+ const height = 36
1061
+ const width = 240
1062
+ const count = turns.length
1063
+ const gap = count > 15 ? 2 : 4
1064
+ const barWidth = Math.max(3, Math.min(16, Math.floor((width - (count - 1) * gap) / count)))
1065
+ const totalBarSpace = count * barWidth + (count - 1) * gap
1066
+ const startX = Math.max(0, Math.floor((width - totalBarSpace) / 2))
1067
+
1068
+ const bars = turns.map((item, idx) => {
1069
+ const tok = Number(item.tokens) || 0
1070
+ const barHeight = Math.max(3, Math.round((tok / maxTokens) * (height - 6)))
1071
+ const x = startX + idx * (barWidth + gap)
1072
+ const y = height - barHeight
1073
+ const isPeak = tok === maxTokens && tok > 0
1074
+ const stepCost = (item.costDeltaUsd && item.costDeltaUsd > 0)
1075
+ ? item.costDeltaUsd * fx
1076
+ : (activeRate ? (tok / 1e6) * ((activeRate.input + activeRate.output) / 2) * fx : 0)
1077
+
1078
+ const tip = (t ? t('sparkline.steps') : 'step') + ' ' + (item.turn || idx + 1) +
1079
+ (item.step ? '.' + item.step : '') + ': ' + tokens(tok) +
1080
+ (stepCost > 0 ? ' (≈ ' + money(stepCost, currency) + ')' : '')
1081
+
1082
+ return h('rect', {
1083
+ key: 'bar-' + idx,
1084
+ x,
1085
+ y,
1086
+ width: barWidth,
1087
+ height: barHeight,
1088
+ rx: 2,
1089
+ ry: 2,
1090
+ className: isPeak ? 'dcm-spark-bar dcm-spark-peak' : 'dcm-spark-bar',
1091
+ }, h('title', null, tip))
1092
+ })
1093
+
1094
+ return h('div', { key: 'sparkline', className: 'dcm-sparkline-box' }, [
1095
+ h('div', { key: 'spark-hdr', className: 'dcm-sparkline-header' }, [
1096
+ h('span', { key: 'lbl', className: 'dcm-section-lbl' }, t ? t('sparkline.title') : 'Turn dynamics'),
1097
+ h('span', { key: 'cnt', className: 'dcm-sparkline-count' }, count + ' ' + (t ? t('sparkline.steps') : 'steps')),
1098
+ ]),
1099
+ h('svg', {
1100
+ key: 'spark-svg',
1101
+ viewBox: '0 0 ' + width + ' ' + height,
1102
+ className: 'dcm-sparkline-svg',
1103
+ preserveAspectRatio: 'xMidYMid meet',
1104
+ }, bars),
1105
+ ])
1106
+ }
1107
+
1042
1108
  // ------------------------------------------------------------ CostMeter
1043
1109
 
1110
+
1044
1111
  function CostMeter(props) {
1045
1112
  const ctx = props.ctx
1046
1113
  const locale = useActiveLocale(ctx)
@@ -1048,6 +1115,7 @@ window.__ModuleLoader__.load({
1048
1115
  const useProjection = props.useProjection
1049
1116
  const usage = useProjection ? useProjection('tokenUsage') : undefined
1050
1117
  const byModel = useProjection ? useProjection('costByModel') : undefined
1118
+ const turns = (byModel?.turns && byModel.turns.length > 0) ? byModel.turns : (state?.turns || [])
1051
1119
  const [state, setState] = React.useState(null)
1052
1120
  const [open, setOpen] = React.useState(false)
1053
1121
  const [now, setNow] = React.useState(() => new Date())
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/projection.js CHANGED
@@ -3,6 +3,18 @@ import { z as zod } from 'zod'
3
3
 
4
4
  export const ZERO = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
5
5
 
6
+ export const MAX_TURNS_HISTORY = 30
7
+
8
+ let lastSessionTurns = []
9
+
10
+ export function getLastTurns() {
11
+ return lastSessionTurns.slice(-MAX_TURNS_HISTORY)
12
+ }
13
+
14
+ export function resetLastTurns() {
15
+ lastSessionTurns = []
16
+ }
17
+
6
18
  export const bucketSchema = zod.object({
7
19
  uncachedInputTokens: zod.number().nonnegative(),
8
20
  outputTokens: zod.number().nonnegative(),
@@ -10,6 +22,18 @@ export const bucketSchema = zod.object({
10
22
  cacheWriteTokens: zod.number().nonnegative(),
11
23
  }).strict()
12
24
 
25
+ export const turnRecordSchema = zod.object({
26
+ turn: zod.number().int().nonnegative(),
27
+ step: zod.number().int().nonnegative().optional(),
28
+ tokens: zod.number().nonnegative(),
29
+ uncachedInputTokens: zod.number().nonnegative().optional(),
30
+ outputTokens: zod.number().nonnegative().optional(),
31
+ cacheReadTokens: zod.number().nonnegative().optional(),
32
+ cacheWriteTokens: zod.number().nonnegative().optional(),
33
+ costDeltaUsd: zod.number().nonnegative().optional(),
34
+ timestamp: zod.number().nonnegative(),
35
+ }).passthrough()
36
+
13
37
  /** Pull the four buckets out of one provider usage record. */
14
38
  export function bucketsFromUsage(usage) {
15
39
  return {
@@ -67,6 +91,7 @@ export const costByModelViewSchema = zod.object({
67
91
  model: zod.string(),
68
92
  slots: modelSlotsSchema,
69
93
  }).strict()),
94
+ turns: zod.array(turnRecordSchema).optional(),
70
95
  }).strict()
71
96
 
72
97
  export const costByModelStateSchema = zod.object({
@@ -82,6 +107,7 @@ export const costByModelStateSchema = zod.object({
82
107
  slot: zod.string(),
83
108
  buckets: bucketSchema,
84
109
  }).nullable(),
110
+ turns: zod.array(turnRecordSchema).optional(),
85
111
  }).strict()
86
112
 
87
113
  export const usageByModelProjection = {
@@ -113,6 +139,47 @@ export const usageByModelProjection = {
113
139
  ? state.last.buckets
114
140
  : undefined
115
141
  if (previous !== undefined && sameBuckets(previous, buckets)) return state
142
+
143
+ // Track turns history capped to MAX_TURNS_HISTORY
144
+ const turnNum = typeof sample.turn === 'number' ? sample.turn : 0
145
+ const stepNum = typeof sample.step === 'number' ? sample.step : 0
146
+ const totalTokens = buckets.uncachedInputTokens + buckets.outputTokens + buckets.cacheReadTokens + buckets.cacheWriteTokens
147
+ const timestamp = (typeof event.time === 'number' && Number.isFinite(event.time))
148
+ ? event.time
149
+ : (event.time instanceof Date && !Number.isNaN(event.time.getTime()) ? event.time.getTime() : Date.now())
150
+
151
+ const currentTurns = Array.isArray(state.turns) ? state.turns : []
152
+ const existingIndex = currentTurns.findIndex((t) => t.turn === turnNum && t.step === stepNum)
153
+ let nextTurns
154
+ if (existingIndex >= 0) {
155
+ nextTurns = currentTurns.slice()
156
+ nextTurns[existingIndex] = {
157
+ turn: turnNum,
158
+ step: stepNum,
159
+ tokens: totalTokens,
160
+ uncachedInputTokens: buckets.uncachedInputTokens,
161
+ outputTokens: buckets.outputTokens,
162
+ cacheReadTokens: buckets.cacheReadTokens,
163
+ cacheWriteTokens: buckets.cacheWriteTokens,
164
+ costDeltaUsd: nextTurns[existingIndex].costDeltaUsd || 0,
165
+ timestamp,
166
+ }
167
+ } else {
168
+ const newEntry = {
169
+ turn: turnNum,
170
+ step: stepNum,
171
+ tokens: totalTokens,
172
+ uncachedInputTokens: buckets.uncachedInputTokens,
173
+ outputTokens: buckets.outputTokens,
174
+ cacheReadTokens: buckets.cacheReadTokens,
175
+ cacheWriteTokens: buckets.cacheWriteTokens,
176
+ costDeltaUsd: 0,
177
+ timestamp,
178
+ }
179
+ nextTurns = [...currentTurns, newEntry].slice(-MAX_TURNS_HISTORY)
180
+ }
181
+ lastSessionTurns = nextTurns
182
+
116
183
  const slots = state.byKey[key] || {}
117
184
  return {
118
185
  route: state.route,
@@ -121,6 +188,7 @@ export const usageByModelProjection = {
121
188
  [key]: { ...slots, [slot]: addBuckets(slots[slot] || ZERO, previous, buckets) },
122
189
  },
123
190
  last: { turn: sample.turn, step: sample.step, key, slot, buckets },
191
+ turns: nextTurns,
124
192
  }
125
193
  },
126
194
  wire: {
@@ -134,6 +202,7 @@ export const usageByModelProjection = {
134
202
  slots: state.byKey[key],
135
203
  }
136
204
  }),
205
+ ...(state.turns && state.turns.length ? { turns: state.turns } : {}),
137
206
  }),
138
207
  },
139
- }
208
+ }
package/lib/routes.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // dsh-cost-meter — HTTP routes registration for webServer service.
2
2
  import { createHash } from 'node:crypto'
3
3
  import { registerPluginUpdater } from './updater.js'
4
+ import { getLastTurns } from './projection.js'
4
5
 
5
6
  export const STATE_PATH = '/dsh-cost-meter/state'
6
7
  export const REFRESH_PATH = '/dsh-cost-meter/refresh'
@@ -102,6 +103,7 @@ export function registerRoutes(ctx, options) {
102
103
  source: own.source,
103
104
  matchedId: own.matchedId,
104
105
  schedules,
106
+ turns: getLastTurns(),
105
107
  fetchedAt: catalog.fetchedAt || null,
106
108
  catalogError: catalog.error,
107
109
  catalogModelsCount: Object.keys((catalog.index && catalog.index.byId) || {}).length,
@@ -139,5 +141,4 @@ export function registerRoutes(ctx, options) {
139
141
  manifestUrl: new URL('../package.json', import.meta.url),
140
142
  }), 'dsh-cost-meter: updater')
141
143
  }
142
- }
143
-
144
+ }
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.20",
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",