@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 +68 -0
- package/lib/fx.js +130 -0
- package/lib/index.js +13 -2
- package/lib/projection.js +70 -1
- package/lib/routes.js +3 -2
- package/package.json +1 -1
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
|
|
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/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.
|
|
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",
|