@goodandready/dsh-cost-meter 0.8.20 → 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
@@ -100,6 +100,9 @@ window.__ModuleLoader__.load({
100
100
  'tz.invalid': 'Invalid IANA time zone identifier',
101
101
  'sparkline.title': 'Turn dynamics',
102
102
  'sparkline.steps': 'steps',
103
+ 'projection.burn_rate': 'Burn rate:',
104
+ 'projection.projected': 'Projected:',
105
+ 'projection.per_hour': '/hr',
103
106
  }
104
107
 
105
108
  const zh = {
@@ -180,6 +183,9 @@ window.__ModuleLoader__.load({
180
183
  'tz.invalid': '无效的 IANA 时区标识',
181
184
  'sparkline.title': '步骤动态',
182
185
  'sparkline.steps': '步',
186
+ 'projection.burn_rate': '消耗速率:',
187
+ 'projection.projected': '预估总计:',
188
+ 'projection.per_hour': '/小时',
183
189
  }
184
190
 
185
191
  function makeT(dict, fallback) {
@@ -299,9 +305,13 @@ window.__ModuleLoader__.load({
299
305
  .dcm-spark-bar { fill: color-mix(in srgb, var(--dsw-alias-state-brand-primary) 45%, transparent); transition: fill .15s ease; cursor: pointer; }
300
306
  .dcm-spark-bar:hover { fill: var(--dsw-alias-state-brand-primary); }
301
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; }
302
311
  `
303
312
 
304
313
 
314
+
305
315
  function ensureStyles() {
306
316
  if (typeof document === 'undefined') return
307
317
  if (document.getElementById('dsh-cost-meter-unified-css')) return
@@ -1052,6 +1062,36 @@ window.__ModuleLoader__.load({
1052
1062
  }
1053
1063
 
1054
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
+
1055
1095
  function renderSparkline(turns, options) {
1056
1096
  if (!Array.isArray(turns) || turns.length === 0) return null
1057
1097
  const { h, currency, fx, activeRate, t } = options
package/lib/routes.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { createHash } from 'node:crypto'
3
3
  import { registerPluginUpdater } from './updater.js'
4
4
  import { getLastTurns } from './projection.js'
5
+ import { calculateBurnRateAndProjection } from './analytics.js'
5
6
 
6
7
  export const STATE_PATH = '/dsh-cost-meter/state'
7
8
  export const REFRESH_PATH = '/dsh-cost-meter/refresh'
@@ -104,6 +105,8 @@ export function registerRoutes(ctx, options) {
104
105
  matchedId: own.matchedId,
105
106
  schedules,
106
107
  turns: getLastTurns(),
108
+ burnRatePerHourUsd: calculateBurnRateAndProjection(getLastTurns(), 0).burnRatePerHourUsd,
109
+ projectedCostUsd: calculateBurnRateAndProjection(getLastTurns(), 0).projectedCostUsd,
107
110
  fetchedAt: catalog.fetchedAt || null,
108
111
  catalogError: catalog.error,
109
112
  catalogModelsCount: Object.keys((catalog.index && catalog.index.byId) || {}).length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cost-meter",
3
- "version": "0.8.20",
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",