@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.
@@ -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: 680px) {
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.20",
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",