@goodandready/dsh-cost-meter 0.8.19 → 0.8.21
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/analytics.js +93 -0
- package/lib/client.js +108 -0
- package/lib/projection.js +70 -1
- package/lib/routes.js +6 -2
- package/package.json +1 -1
package/lib/analytics.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// dsh-cost-meter — burn rate calculation and cost projection.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Calculates burn rate ($/hour) and projected total session cost ($)
|
|
5
|
+
* from recorded turns and current session cost.
|
|
6
|
+
*
|
|
7
|
+
* @param {Array<{ turn: number, step?: number, tokens: number, costDeltaUsd?: number, timestamp: number }>} turns
|
|
8
|
+
* @param {number} totalCostUsd Current total cost of session in USD
|
|
9
|
+
* @param {object} [options]
|
|
10
|
+
* @param {number} [options.now] Current timestamp (ms)
|
|
11
|
+
* @param {number} [options.windowMs] Recent window (default 10 mins = 600,000ms)
|
|
12
|
+
* @returns {{ burnRatePerHourUsd: number, projectedCostUsd: number, confidence: 'low' | 'medium' | 'high' }}
|
|
13
|
+
*/
|
|
14
|
+
export function calculateBurnRateAndProjection(turns, totalCostUsd, options = {}) {
|
|
15
|
+
const currentTotal = Number(totalCostUsd) > 0 ? Number(totalCostUsd) : 0
|
|
16
|
+
if (!Array.isArray(turns) || turns.length < 2) {
|
|
17
|
+
return {
|
|
18
|
+
burnRatePerHourUsd: 0,
|
|
19
|
+
projectedCostUsd: currentTotal,
|
|
20
|
+
confidence: 'low',
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const now = typeof options.now === 'number' && Number.isFinite(options.now) ? options.now : Date.now()
|
|
25
|
+
const windowMs = typeof options.windowMs === 'number' && options.windowMs > 0 ? options.windowMs : 10 * 60 * 1000
|
|
26
|
+
|
|
27
|
+
// Filter or take recent turns
|
|
28
|
+
const sorted = turns
|
|
29
|
+
.filter((t) => t && typeof t.timestamp === 'number' && Number.isFinite(t.timestamp))
|
|
30
|
+
.sort((a, b) => a.timestamp - b.timestamp)
|
|
31
|
+
|
|
32
|
+
if (sorted.length < 2) {
|
|
33
|
+
return {
|
|
34
|
+
burnRatePerHourUsd: 0,
|
|
35
|
+
projectedCostUsd: currentTotal,
|
|
36
|
+
confidence: 'low',
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Pick turns in window or last N turns
|
|
41
|
+
let sample = sorted.filter((t) => t.timestamp >= now - windowMs)
|
|
42
|
+
if (sample.length < 2) {
|
|
43
|
+
sample = sorted.slice(-Math.min(sorted.length, 10))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const firstTime = sample[0].timestamp
|
|
47
|
+
const lastTime = sample[sample.length - 1].timestamp
|
|
48
|
+
const durationMs = lastTime - firstTime
|
|
49
|
+
|
|
50
|
+
// Guard against near-zero time duration (< 5 seconds) to avoid division by zero
|
|
51
|
+
if (durationMs < 5000) {
|
|
52
|
+
return {
|
|
53
|
+
burnRatePerHourUsd: 0,
|
|
54
|
+
projectedCostUsd: currentTotal,
|
|
55
|
+
confidence: 'low',
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Cost incurred in sample window
|
|
60
|
+
let costInSample = sample.reduce((acc, t) => acc + (Number(t.costDeltaUsd) || 0), 0)
|
|
61
|
+
// If individual turns don't have costDeltaUsd, proportion cost by tokens
|
|
62
|
+
if (costInSample <= 0 && currentTotal > 0) {
|
|
63
|
+
const totalTokens = sorted.reduce((acc, t) => acc + (Number(t.tokens) || 0), 0)
|
|
64
|
+
const sampleTokens = sample.reduce((acc, t) => acc + (Number(t.tokens) || 0), 0)
|
|
65
|
+
costInSample = totalTokens > 0 ? (sampleTokens / totalTokens) * currentTotal : currentTotal
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const hours = durationMs / (1000 * 60 * 60)
|
|
69
|
+
let rawBurnRate = hours > 0 ? (costInSample / hours) : 0
|
|
70
|
+
|
|
71
|
+
// Sanitize burn rate
|
|
72
|
+
if (!Number.isFinite(rawBurnRate) || rawBurnRate < 0) rawBurnRate = 0
|
|
73
|
+
const burnRatePerHourUsd = Math.round(rawBurnRate * 10000) / 10000
|
|
74
|
+
|
|
75
|
+
// Project final cost based on trend and remaining steps
|
|
76
|
+
const stepCount = sorted.length
|
|
77
|
+
const avgCostPerStep = currentTotal / stepCount
|
|
78
|
+
|
|
79
|
+
// Estimate extra cost based on burn rate and step trend
|
|
80
|
+
const projectedExtra = Math.min(
|
|
81
|
+
currentTotal * 2, // at most 2x current cost added
|
|
82
|
+
Math.max(avgCostPerStep * 3, burnRatePerHourUsd * 0.25),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
const projectedCostUsd = Math.round((currentTotal + (projectedExtra > 0 ? projectedExtra : 0)) * 10000) / 10000
|
|
86
|
+
const confidence = stepCount >= 10 ? 'high' : (stepCount >= 5 ? 'medium' : 'low')
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
burnRatePerHourUsd,
|
|
90
|
+
projectedCostUsd,
|
|
91
|
+
confidence,
|
|
92
|
+
}
|
|
93
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -98,6 +98,11 @@ 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',
|
|
103
|
+
'projection.burn_rate': 'Burn rate:',
|
|
104
|
+
'projection.projected': 'Projected:',
|
|
105
|
+
'projection.per_hour': '/hr',
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
const zh = {
|
|
@@ -176,6 +181,11 @@ window.__ModuleLoader__.load({
|
|
|
176
181
|
'tokens.no_usage_yet': '提供商尚未报告当前会话的用量数据。',
|
|
177
182
|
'tokens.not_priced': '未定价',
|
|
178
183
|
'tz.invalid': '无效的 IANA 时区标识',
|
|
184
|
+
'sparkline.title': '步骤动态',
|
|
185
|
+
'sparkline.steps': '步',
|
|
186
|
+
'projection.burn_rate': '消耗速率:',
|
|
187
|
+
'projection.projected': '预估总计:',
|
|
188
|
+
'projection.per_hour': '/小时',
|
|
179
189
|
}
|
|
180
190
|
|
|
181
191
|
function makeT(dict, fallback) {
|
|
@@ -288,8 +298,20 @@ window.__ModuleLoader__.load({
|
|
|
288
298
|
.dcm-total { display: flex; justify-content: space-between; font-weight: 600; font-size: 13.5px; }
|
|
289
299
|
.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
300
|
.dcm-accordion-btn:hover { text-decoration: underline; }
|
|
301
|
+
.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); }
|
|
302
|
+
.dcm-sparkline-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
|
303
|
+
.dcm-sparkline-count { font-size: 11px; color: var(--dsw-alias-label-tertiary); font-variant-numeric: tabular-nums; }
|
|
304
|
+
.dcm-sparkline-svg { width: 100%; height: 36px; display: block; overflow: visible; }
|
|
305
|
+
.dcm-spark-bar { fill: color-mix(in srgb, var(--dsw-alias-state-brand-primary) 45%, transparent); transition: fill .15s ease; cursor: pointer; }
|
|
306
|
+
.dcm-spark-bar:hover { fill: var(--dsw-alias-state-brand-primary); }
|
|
307
|
+
.dcm-spark-peak { fill: var(--dsw-alias-state-brand-primary); }
|
|
308
|
+
.dcm-proj-box { margin: 6px 0; padding: 6px 10px; border-radius: 6px; background: color-mix(in srgb, var(--dsw-alias-state-brand-primary) 6%, transparent); display: flex; justify-content: space-between; align-items: center; font-size: 11px; }
|
|
309
|
+
.dcm-proj-item { display: inline-flex; align-items: center; gap: 4px; }
|
|
310
|
+
.dcm-proj-val { font-weight: 600; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
|
|
291
311
|
`
|
|
292
312
|
|
|
313
|
+
|
|
314
|
+
|
|
293
315
|
function ensureStyles() {
|
|
294
316
|
if (typeof document === 'undefined') return
|
|
295
317
|
if (document.getElementById('dsh-cost-meter-unified-css')) return
|
|
@@ -1039,8 +1061,93 @@ window.__ModuleLoader__.load({
|
|
|
1039
1061
|
return city.replace(/_/g, ' ')
|
|
1040
1062
|
}
|
|
1041
1063
|
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
function calculateBurnRateAndProjection(turns, totalCostUsd, options = {}) {
|
|
1067
|
+
const currentTotal = Number(totalCostUsd) > 0 ? Number(totalCostUsd) : 0
|
|
1068
|
+
if (!Array.isArray(turns) || turns.length < 2) {
|
|
1069
|
+
return { burnRatePerHourUsd: 0, projectedCostUsd: currentTotal, confidence: 'low' }
|
|
1070
|
+
}
|
|
1071
|
+
const now = typeof options.now === 'number' ? options.now : Date.now()
|
|
1072
|
+
const windowMs = 10 * 60 * 1000
|
|
1073
|
+
const sorted = turns.filter((t) => t && typeof t.timestamp === 'number').sort((a, b) => a.timestamp - b.timestamp)
|
|
1074
|
+
if (sorted.length < 2) return { burnRatePerHourUsd: 0, projectedCostUsd: currentTotal, confidence: 'low' }
|
|
1075
|
+
let sample = sorted.filter((t) => t.timestamp >= now - windowMs)
|
|
1076
|
+
if (sample.length < 2) sample = sorted.slice(-Math.min(sorted.length, 10))
|
|
1077
|
+
const durationMs = sample[sample.length - 1].timestamp - sample[0].timestamp
|
|
1078
|
+
if (durationMs < 5000) return { burnRatePerHourUsd: 0, projectedCostUsd: currentTotal, confidence: 'low' }
|
|
1079
|
+
let costInSample = sample.reduce((acc, t) => acc + (Number(t.costDeltaUsd) || 0), 0)
|
|
1080
|
+
if (costInSample <= 0 && currentTotal > 0) {
|
|
1081
|
+
const totalTokens = sorted.reduce((acc, t) => acc + (Number(t.tokens) || 0), 0)
|
|
1082
|
+
const sampleTokens = sample.reduce((acc, t) => acc + (Number(t.tokens) || 0), 0)
|
|
1083
|
+
costInSample = totalTokens > 0 ? (sampleTokens / totalTokens) * currentTotal : currentTotal
|
|
1084
|
+
}
|
|
1085
|
+
const hours = durationMs / (1000 * 60 * 60)
|
|
1086
|
+
const rawBurnRate = hours > 0 ? (costInSample / hours) : 0
|
|
1087
|
+
const burnRatePerHourUsd = (Number.isFinite(rawBurnRate) && rawBurnRate >= 0) ? Math.round(rawBurnRate * 10000) / 10000 : 0
|
|
1088
|
+
const stepCount = sorted.length
|
|
1089
|
+
const avgCostPerStep = currentTotal / stepCount
|
|
1090
|
+
const projectedExtra = Math.min(currentTotal * 2, Math.max(avgCostPerStep * 3, burnRatePerHourUsd * 0.25))
|
|
1091
|
+
const projectedCostUsd = Math.round((currentTotal + (projectedExtra > 0 ? projectedExtra : 0)) * 10000) / 10000
|
|
1092
|
+
return { burnRatePerHourUsd, projectedCostUsd }
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function renderSparkline(turns, options) {
|
|
1096
|
+
if (!Array.isArray(turns) || turns.length === 0) return null
|
|
1097
|
+
const { h, currency, fx, activeRate, t } = options
|
|
1098
|
+
|
|
1099
|
+
const maxTokens = Math.max(1, ...turns.map((item) => Number(item.tokens) || 0))
|
|
1100
|
+
const height = 36
|
|
1101
|
+
const width = 240
|
|
1102
|
+
const count = turns.length
|
|
1103
|
+
const gap = count > 15 ? 2 : 4
|
|
1104
|
+
const barWidth = Math.max(3, Math.min(16, Math.floor((width - (count - 1) * gap) / count)))
|
|
1105
|
+
const totalBarSpace = count * barWidth + (count - 1) * gap
|
|
1106
|
+
const startX = Math.max(0, Math.floor((width - totalBarSpace) / 2))
|
|
1107
|
+
|
|
1108
|
+
const bars = turns.map((item, idx) => {
|
|
1109
|
+
const tok = Number(item.tokens) || 0
|
|
1110
|
+
const barHeight = Math.max(3, Math.round((tok / maxTokens) * (height - 6)))
|
|
1111
|
+
const x = startX + idx * (barWidth + gap)
|
|
1112
|
+
const y = height - barHeight
|
|
1113
|
+
const isPeak = tok === maxTokens && tok > 0
|
|
1114
|
+
const stepCost = (item.costDeltaUsd && item.costDeltaUsd > 0)
|
|
1115
|
+
? item.costDeltaUsd * fx
|
|
1116
|
+
: (activeRate ? (tok / 1e6) * ((activeRate.input + activeRate.output) / 2) * fx : 0)
|
|
1117
|
+
|
|
1118
|
+
const tip = (t ? t('sparkline.steps') : 'step') + ' ' + (item.turn || idx + 1) +
|
|
1119
|
+
(item.step ? '.' + item.step : '') + ': ' + tokens(tok) +
|
|
1120
|
+
(stepCost > 0 ? ' (≈ ' + money(stepCost, currency) + ')' : '')
|
|
1121
|
+
|
|
1122
|
+
return h('rect', {
|
|
1123
|
+
key: 'bar-' + idx,
|
|
1124
|
+
x,
|
|
1125
|
+
y,
|
|
1126
|
+
width: barWidth,
|
|
1127
|
+
height: barHeight,
|
|
1128
|
+
rx: 2,
|
|
1129
|
+
ry: 2,
|
|
1130
|
+
className: isPeak ? 'dcm-spark-bar dcm-spark-peak' : 'dcm-spark-bar',
|
|
1131
|
+
}, h('title', null, tip))
|
|
1132
|
+
})
|
|
1133
|
+
|
|
1134
|
+
return h('div', { key: 'sparkline', className: 'dcm-sparkline-box' }, [
|
|
1135
|
+
h('div', { key: 'spark-hdr', className: 'dcm-sparkline-header' }, [
|
|
1136
|
+
h('span', { key: 'lbl', className: 'dcm-section-lbl' }, t ? t('sparkline.title') : 'Turn dynamics'),
|
|
1137
|
+
h('span', { key: 'cnt', className: 'dcm-sparkline-count' }, count + ' ' + (t ? t('sparkline.steps') : 'steps')),
|
|
1138
|
+
]),
|
|
1139
|
+
h('svg', {
|
|
1140
|
+
key: 'spark-svg',
|
|
1141
|
+
viewBox: '0 0 ' + width + ' ' + height,
|
|
1142
|
+
className: 'dcm-sparkline-svg',
|
|
1143
|
+
preserveAspectRatio: 'xMidYMid meet',
|
|
1144
|
+
}, bars),
|
|
1145
|
+
])
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1042
1148
|
// ------------------------------------------------------------ CostMeter
|
|
1043
1149
|
|
|
1150
|
+
|
|
1044
1151
|
function CostMeter(props) {
|
|
1045
1152
|
const ctx = props.ctx
|
|
1046
1153
|
const locale = useActiveLocale(ctx)
|
|
@@ -1048,6 +1155,7 @@ window.__ModuleLoader__.load({
|
|
|
1048
1155
|
const useProjection = props.useProjection
|
|
1049
1156
|
const usage = useProjection ? useProjection('tokenUsage') : undefined
|
|
1050
1157
|
const byModel = useProjection ? useProjection('costByModel') : undefined
|
|
1158
|
+
const turns = (byModel?.turns && byModel.turns.length > 0) ? byModel.turns : (state?.turns || [])
|
|
1051
1159
|
const [state, setState] = React.useState(null)
|
|
1052
1160
|
const [open, setOpen] = React.useState(false)
|
|
1053
1161
|
const [now, setNow] = React.useState(() => new Date())
|
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,8 @@
|
|
|
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'
|
|
5
|
+
import { calculateBurnRateAndProjection } from './analytics.js'
|
|
4
6
|
|
|
5
7
|
export const STATE_PATH = '/dsh-cost-meter/state'
|
|
6
8
|
export const REFRESH_PATH = '/dsh-cost-meter/refresh'
|
|
@@ -102,6 +104,9 @@ export function registerRoutes(ctx, options) {
|
|
|
102
104
|
source: own.source,
|
|
103
105
|
matchedId: own.matchedId,
|
|
104
106
|
schedules,
|
|
107
|
+
turns: getLastTurns(),
|
|
108
|
+
burnRatePerHourUsd: calculateBurnRateAndProjection(getLastTurns(), 0).burnRatePerHourUsd,
|
|
109
|
+
projectedCostUsd: calculateBurnRateAndProjection(getLastTurns(), 0).projectedCostUsd,
|
|
105
110
|
fetchedAt: catalog.fetchedAt || null,
|
|
106
111
|
catalogError: catalog.error,
|
|
107
112
|
catalogModelsCount: Object.keys((catalog.index && catalog.index.byId) || {}).length,
|
|
@@ -139,5 +144,4 @@ export function registerRoutes(ctx, options) {
|
|
|
139
144
|
manifestUrl: new URL('../package.json', import.meta.url),
|
|
140
145
|
}), 'dsh-cost-meter: updater')
|
|
141
146
|
}
|
|
142
|
-
}
|
|
143
|
-
|
|
147
|
+
}
|
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.21",
|
|
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",
|