@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.
- package/lib/analytics.js +93 -0
- package/lib/client.js +40 -0
- package/lib/routes.js +3 -0
- 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
|
@@ -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.
|
|
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",
|