@goodandready/dsh-cost-meter 0.8.20 → 0.8.22
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 +74 -4
- package/lib/index.js +4 -2
- package/lib/routes.js +4 -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,14 @@ 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',
|
|
106
|
+
'field.displayMode': 'Display mode',
|
|
107
|
+
'hint.displayMode': 'Widget display mode: auto (collapses on mobile <= 768px), full, or always compact.',
|
|
108
|
+
'mode.auto': 'Auto (responsive)',
|
|
109
|
+
'mode.full': 'Full',
|
|
110
|
+
'mode.compact': 'Compact pill',
|
|
103
111
|
}
|
|
104
112
|
|
|
105
113
|
const zh = {
|
|
@@ -180,6 +188,14 @@ window.__ModuleLoader__.load({
|
|
|
180
188
|
'tz.invalid': '无效的 IANA 时区标识',
|
|
181
189
|
'sparkline.title': '步骤动态',
|
|
182
190
|
'sparkline.steps': '步',
|
|
191
|
+
'projection.burn_rate': '消耗速率:',
|
|
192
|
+
'projection.projected': '预估总计:',
|
|
193
|
+
'projection.per_hour': '/小时',
|
|
194
|
+
'field.displayMode': '显示模式',
|
|
195
|
+
'hint.displayMode': '计量器外观模式:自动(<=768px 移动端自适应折叠)、完整或始终紧凑。',
|
|
196
|
+
'mode.auto': '自动自适应',
|
|
197
|
+
'mode.full': '完整显示',
|
|
198
|
+
'mode.compact': '紧凑药丸',
|
|
183
199
|
}
|
|
184
200
|
|
|
185
201
|
function makeT(dict, fallback) {
|
|
@@ -269,9 +285,11 @@ window.__ModuleLoader__.load({
|
|
|
269
285
|
.dcm-dot-idle { background: var(--dsw-alias-label-tertiary); }
|
|
270
286
|
|
|
271
287
|
.dcm-chip-timer { opacity: 0.72; font-size: 11px; padding-left: 2px; border-left: 1px solid var(--dsw-alias-border-l2); }
|
|
272
|
-
@media (max-width:
|
|
288
|
+
@media (max-width: 768px) {
|
|
273
289
|
.dcm-chip-timer { display: none; }
|
|
290
|
+
.dcm-panel { width: min(320px, calc(100vw - 32px)); right: 0; }
|
|
274
291
|
}
|
|
292
|
+
.dcm-chip-compact .dcm-chip-timer { display: none !important; }
|
|
275
293
|
|
|
276
294
|
@keyframes dcm-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.85); } }
|
|
277
295
|
|
|
@@ -299,9 +317,13 @@ window.__ModuleLoader__.load({
|
|
|
299
317
|
.dcm-spark-bar { fill: color-mix(in srgb, var(--dsw-alias-state-brand-primary) 45%, transparent); transition: fill .15s ease; cursor: pointer; }
|
|
300
318
|
.dcm-spark-bar:hover { fill: var(--dsw-alias-state-brand-primary); }
|
|
301
319
|
.dcm-spark-peak { fill: var(--dsw-alias-state-brand-primary); }
|
|
320
|
+
.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; }
|
|
321
|
+
.dcm-proj-item { display: inline-flex; align-items: center; gap: 4px; }
|
|
322
|
+
.dcm-proj-val { font-weight: 600; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
|
|
302
323
|
`
|
|
303
324
|
|
|
304
325
|
|
|
326
|
+
|
|
305
327
|
function ensureStyles() {
|
|
306
328
|
if (typeof document === 'undefined') return
|
|
307
329
|
if (document.getElementById('dsh-cost-meter-unified-css')) return
|
|
@@ -630,6 +652,10 @@ window.__ModuleLoader__.load({
|
|
|
630
652
|
setSaving(true); setErr(''); setSaved(false)
|
|
631
653
|
const broken = []
|
|
632
654
|
|
|
655
|
+
try {
|
|
656
|
+
await scope.set('displayMode', (draft.displayMode === 'compact' || draft.displayMode === 'full') ? draft.displayMode : 'auto')
|
|
657
|
+
} catch (e) { broken.push('displayMode: ' + (e && e.message || String(e))) }
|
|
658
|
+
|
|
633
659
|
try {
|
|
634
660
|
await scope.set('currency', String(draft.currency || '$'))
|
|
635
661
|
} catch (e) { broken.push('currency: ' + (e && e.message || String(e))) }
|
|
@@ -809,6 +835,19 @@ window.__ModuleLoader__.load({
|
|
|
809
835
|
),
|
|
810
836
|
),
|
|
811
837
|
),
|
|
838
|
+
React.createElement('div', { className: 'dcm-field', style: { marginTop: 6 } },
|
|
839
|
+
React.createElement('label', { className: 'dcm-label' }, t('field.displayMode')),
|
|
840
|
+
React.createElement('select', {
|
|
841
|
+
className: 'dcm-input',
|
|
842
|
+
value: (draft && draft.displayMode) || 'auto',
|
|
843
|
+
onChange: (e) => setDraft({ ...draft, displayMode: e.target.value }),
|
|
844
|
+
}, [
|
|
845
|
+
React.createElement('option', { key: 'auto', value: 'auto' }, t('mode.auto')),
|
|
846
|
+
React.createElement('option', { key: 'full', value: 'full' }, t('mode.full')),
|
|
847
|
+
React.createElement('option', { key: 'compact', value: 'compact' }, t('mode.compact')),
|
|
848
|
+
]),
|
|
849
|
+
React.createElement('span', { className: 'dcm-hint' }, t('hint.displayMode')),
|
|
850
|
+
),
|
|
812
851
|
),
|
|
813
852
|
|
|
814
853
|
// Section 2: OpenRouter Pricing Catalog
|
|
@@ -1052,6 +1091,36 @@ window.__ModuleLoader__.load({
|
|
|
1052
1091
|
}
|
|
1053
1092
|
|
|
1054
1093
|
|
|
1094
|
+
|
|
1095
|
+
function calculateBurnRateAndProjection(turns, totalCostUsd, options = {}) {
|
|
1096
|
+
const currentTotal = Number(totalCostUsd) > 0 ? Number(totalCostUsd) : 0
|
|
1097
|
+
if (!Array.isArray(turns) || turns.length < 2) {
|
|
1098
|
+
return { burnRatePerHourUsd: 0, projectedCostUsd: currentTotal, confidence: 'low' }
|
|
1099
|
+
}
|
|
1100
|
+
const now = typeof options.now === 'number' ? options.now : Date.now()
|
|
1101
|
+
const windowMs = 10 * 60 * 1000
|
|
1102
|
+
const sorted = turns.filter((t) => t && typeof t.timestamp === 'number').sort((a, b) => a.timestamp - b.timestamp)
|
|
1103
|
+
if (sorted.length < 2) return { burnRatePerHourUsd: 0, projectedCostUsd: currentTotal, confidence: 'low' }
|
|
1104
|
+
let sample = sorted.filter((t) => t.timestamp >= now - windowMs)
|
|
1105
|
+
if (sample.length < 2) sample = sorted.slice(-Math.min(sorted.length, 10))
|
|
1106
|
+
const durationMs = sample[sample.length - 1].timestamp - sample[0].timestamp
|
|
1107
|
+
if (durationMs < 5000) return { burnRatePerHourUsd: 0, projectedCostUsd: currentTotal, confidence: 'low' }
|
|
1108
|
+
let costInSample = sample.reduce((acc, t) => acc + (Number(t.costDeltaUsd) || 0), 0)
|
|
1109
|
+
if (costInSample <= 0 && currentTotal > 0) {
|
|
1110
|
+
const totalTokens = sorted.reduce((acc, t) => acc + (Number(t.tokens) || 0), 0)
|
|
1111
|
+
const sampleTokens = sample.reduce((acc, t) => acc + (Number(t.tokens) || 0), 0)
|
|
1112
|
+
costInSample = totalTokens > 0 ? (sampleTokens / totalTokens) * currentTotal : currentTotal
|
|
1113
|
+
}
|
|
1114
|
+
const hours = durationMs / (1000 * 60 * 60)
|
|
1115
|
+
const rawBurnRate = hours > 0 ? (costInSample / hours) : 0
|
|
1116
|
+
const burnRatePerHourUsd = (Number.isFinite(rawBurnRate) && rawBurnRate >= 0) ? Math.round(rawBurnRate * 10000) / 10000 : 0
|
|
1117
|
+
const stepCount = sorted.length
|
|
1118
|
+
const avgCostPerStep = currentTotal / stepCount
|
|
1119
|
+
const projectedExtra = Math.min(currentTotal * 2, Math.max(avgCostPerStep * 3, burnRatePerHourUsd * 0.25))
|
|
1120
|
+
const projectedCostUsd = Math.round((currentTotal + (projectedExtra > 0 ? projectedExtra : 0)) * 10000) / 10000
|
|
1121
|
+
return { burnRatePerHourUsd, projectedCostUsd }
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1055
1124
|
function renderSparkline(turns, options) {
|
|
1056
1125
|
if (!Array.isArray(turns) || turns.length === 0) return null
|
|
1057
1126
|
const { h, currency, fx, activeRate, t } = options
|
|
@@ -1185,6 +1254,7 @@ window.__ModuleLoader__.load({
|
|
|
1185
1254
|
const currency = stored.currency !== undefined ? String(stored.currency) : (state.currency || '$')
|
|
1186
1255
|
const fx = stored.usdRate !== undefined && !isNaN(Number(stored.usdRate)) ? Number(stored.usdRate) : (Number(state.usdRate) || 1)
|
|
1187
1256
|
const budgetThreshold = stored.budgetThreshold !== undefined ? Number(stored.budgetThreshold) : (Number(state.budgetThreshold) || 0)
|
|
1257
|
+
const displayMode = stored.displayMode !== undefined ? stored.displayMode : (state.displayMode || 'auto')
|
|
1188
1258
|
const schedule = state.schedule
|
|
1189
1259
|
const hasUsage = usage !== undefined && usage !== null
|
|
1190
1260
|
const minute = now.getUTCHours() * 60 + now.getUTCMinutes()
|
|
@@ -1193,7 +1263,7 @@ window.__ModuleLoader__.load({
|
|
|
1193
1263
|
return h('div', { ref: box, style: { position: 'relative' } }, [
|
|
1194
1264
|
h('button', {
|
|
1195
1265
|
key: 'chip', type: 'button',
|
|
1196
|
-
className: 'dcm-chip',
|
|
1266
|
+
className: 'dcm-chip' + (displayMode === 'compact' ? ' dcm-chip-compact' : ''),
|
|
1197
1267
|
onClick: () => setOpen((v) => !v), title: t('chipUnknown'),
|
|
1198
1268
|
}, [
|
|
1199
1269
|
h('span', { key: 'dot', className: 'dcm-dot dcm-dot-idle' }),
|
|
@@ -1362,7 +1432,7 @@ window.__ModuleLoader__.load({
|
|
|
1362
1432
|
return h('div', { ref: box, style: { position: 'relative' } }, [
|
|
1363
1433
|
h('button', {
|
|
1364
1434
|
key: 'chip', type: 'button',
|
|
1365
|
-
className: 'dcm-chip',
|
|
1435
|
+
className: 'dcm-chip' + (displayMode === 'compact' ? ' dcm-chip-compact' : ''),
|
|
1366
1436
|
onClick: () => setOpen((v) => !v),
|
|
1367
1437
|
title: (budgetExceeded
|
|
1368
1438
|
? t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })
|
|
@@ -1599,4 +1669,4 @@ window.__ModuleLoader__.load({
|
|
|
1599
1669
|
return module.exports
|
|
1600
1670
|
},
|
|
1601
1671
|
})
|
|
1602
|
-
|
|
1672
|
+
|
package/lib/index.js
CHANGED
|
@@ -124,6 +124,7 @@ export function normalizeConfig(raw) {
|
|
|
124
124
|
usdRate: getFxRate(currency, u.usdRate, autoFx),
|
|
125
125
|
displayTimeZone: typeof u.displayTimeZone === 'string' && u.displayTimeZone ? u.displayTimeZone : 'Europe/Moscow',
|
|
126
126
|
budgetThreshold: Number(u.budgetThreshold) || 0,
|
|
127
|
+
displayMode: (u.displayMode === 'compact' || u.displayMode === 'full') ? u.displayMode : 'auto',
|
|
127
128
|
useOpenRouter: u.useOpenRouter !== false,
|
|
128
129
|
refreshHours: Number(u.refreshHours) || 24,
|
|
129
130
|
deepseekPeakPrices: u.deepseekPeakPrices || {},
|
|
@@ -151,6 +152,7 @@ export const Config = z.object({
|
|
|
151
152
|
usdRate: z.number().description('Units of `currency` per USD. 1 keeps everything in USD.').default(1).volatile(),
|
|
152
153
|
displayTimeZone: z.string().description('IANA zone the widget shows tariff hours in.').default('Europe/Moscow').volatile(),
|
|
153
154
|
budgetThreshold: z.number().description('Optional session budget threshold in USD. 0 disables threshold warnings.').default(0).volatile(),
|
|
155
|
+
displayMode: z.union(['auto', 'full', 'compact']).description('Widget display mode: auto, full, or compact.').default('auto').volatile(),
|
|
154
156
|
deepseekPeakPrices: z.dict(PriceRow).description('Override the built-in DeepSeek peak rates, by model id.').default({}).volatile(),
|
|
155
157
|
useOpenRouter: z.boolean().description('Pull rates and discount windows from the public OpenRouter catalog.').default(true).volatile(),
|
|
156
158
|
refreshHours: z.number().description('How often to refetch the catalog.').default(24).volatile(),
|
|
@@ -296,5 +298,5 @@ export function apply(ctx, config) {
|
|
|
296
298
|
getCatalog: () => catalog,
|
|
297
299
|
})
|
|
298
300
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
+
|
|
302
|
+
|
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'
|
|
@@ -99,11 +100,14 @@ export function registerRoutes(ctx, options) {
|
|
|
99
100
|
usdRate: Number(cfg?.usdRate) > 0 ? Number(cfg.usdRate) : 1,
|
|
100
101
|
displayTimeZone: typeof cfg?.displayTimeZone === 'string' && cfg.displayTimeZone ? cfg.displayTimeZone : 'Europe/Moscow',
|
|
101
102
|
budgetThreshold: Number(cfg?.budgetThreshold) || 0,
|
|
103
|
+
displayMode: cfg?.displayMode || 'auto',
|
|
102
104
|
route,
|
|
103
105
|
source: own.source,
|
|
104
106
|
matchedId: own.matchedId,
|
|
105
107
|
schedules,
|
|
106
108
|
turns: getLastTurns(),
|
|
109
|
+
burnRatePerHourUsd: calculateBurnRateAndProjection(getLastTurns(), 0).burnRatePerHourUsd,
|
|
110
|
+
projectedCostUsd: calculateBurnRateAndProjection(getLastTurns(), 0).projectedCostUsd,
|
|
107
111
|
fetchedAt: catalog.fetchedAt || null,
|
|
108
112
|
catalogError: catalog.error,
|
|
109
113
|
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.22",
|
|
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",
|