@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.
@@ -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.19",
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",