@goodandready/dsh-cost-meter 0.8.27 → 0.8.29

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 CHANGED
@@ -90,4 +90,59 @@ export function calculateBurnRateAndProjection(turns, totalCostUsd, options = {}
90
90
  projectedCostUsd,
91
91
  confidence,
92
92
  }
93
- }
93
+ }
94
+
95
+ /**
96
+ * Returns estimated context window token limit for model identifier.
97
+ */
98
+ export function getFallbackContextLength(model) {
99
+ const m = String(model || '').toLowerCase()
100
+ if (m.includes('claude-3-5') || m.includes('claude-3-7') || m.includes('claude-3-opus')) return 200000
101
+ if (m.includes('claude')) return 200000
102
+ if (m.includes('gemini-1.5') || m.includes('gemini-2.0') || m.includes('gemini-exp')) return 1000000
103
+ if (m.includes('gpt-4.1') || m.includes('gpt-4o') || m.includes('o1') || m.includes('o3')) return 128000
104
+ if (m.includes('qwen-2.5') || m.includes('qwq')) return 128000
105
+ if (m.includes('deepseek')) return 128000
106
+ return 128000
107
+ }
108
+
109
+ /**
110
+ * Calculates context window saturation metrics and compaction risk.
111
+ *
112
+ * @param {Array<{ turn: number, uncachedInputTokens?: number, cacheReadTokens?: number }>} turns
113
+ * @param {object} [tariff]
114
+ * @param {object} [route]
115
+ * @returns {{ currentTokens: number, contextLength: number, saturationPercent: number, riskLevel: 'low' | 'moderate' | 'high', nextTurnEstUsd: number }}
116
+ */
117
+ export function calculateContextMetrics(turns, tariff, route) {
118
+ const lastTurn = (Array.isArray(turns) && turns.length > 0) ? turns[turns.length - 1] : null
119
+ const currentTokens = lastTurn
120
+ ? (Number(lastTurn.uncachedInputTokens || 0) + Number(lastTurn.cacheReadTokens || 0))
121
+ : 0
122
+
123
+ const contextLength = (tariff && Number(tariff.contextLength)) || getFallbackContextLength(route?.model)
124
+ const saturationPercent = contextLength > 0
125
+ ? Math.min(100, Math.round((currentTokens / contextLength) * 100))
126
+ : 0
127
+
128
+ let riskLevel = 'low'
129
+ if (saturationPercent >= 80) {
130
+ riskLevel = 'high'
131
+ } else if (saturationPercent >= 50) {
132
+ riskLevel = 'moderate'
133
+ }
134
+
135
+ const rates = tariff?.schedule?.base || {}
136
+ const inputRate = Number(rates.input) || 0.14
137
+ const nextTurnEstUsd = currentTokens > 0
138
+ ? Number(((currentTokens * inputRate) / 1e6).toFixed(6))
139
+ : 0
140
+
141
+ return {
142
+ currentTokens,
143
+ contextLength,
144
+ saturationPercent,
145
+ riskLevel,
146
+ nextTurnEstUsd,
147
+ }
148
+ }
package/lib/client.js CHANGED
@@ -91,6 +91,25 @@ window.__ModuleLoader__.load({
91
91
  'turn.modal_title': 'Message Token & Cost Inspector',
92
92
  'turn.tokens_total': 'Turn Tokens',
93
93
  'turn.cost_title': 'Turn Cost',
94
+ 'turn.reasoning': 'Thinking / Reasoning',
95
+ 'context.title': 'Context Window',
96
+ 'context.saturation': 'Context Saturation',
97
+ 'context.risk_low': 'Healthy',
98
+ 'context.risk_moderate': 'Moderate',
99
+ 'context.risk_high': 'Compaction Risk',
100
+ 'context.next_turn': 'Next turn base: ≈ {cost}',
101
+ 'context.compaction_warning': 'Context saturation over 80%. High compaction and prompt fee risk.',
102
+
103
+ 'privacy.toggle': 'Streamer / Privacy Mode',
104
+ 'privacy.enabled': 'Privacy mode active (amounts masked)',
105
+ 'field.privacyMode': 'Privacy / Streamer Mode',
106
+ 'hint.privacyMode': 'Mask currency amounts with asterisks to safely share screens or recordings.',
107
+ 'field.showHeaderChip': 'Show Cost Chip in Header',
108
+ 'hint.showHeaderChip': 'Display the live cost meter chip in the chat conversation header.',
109
+ 'field.showMessageAction': 'Show Token Inspector on Messages',
110
+ 'hint.showMessageAction': 'Display the lightning token inspector button on assistant messages.',
111
+ 'notice.hidden_access': 'Cost metering is active in background. Full statistics and session data remain available in this settings panel and via the /cost command.',
112
+
94
113
  'turn.cache_hit_rate': 'Cache Hit',
95
114
  'turn.duration': 'Duration',
96
115
  'turn.uncached_in': 'Uncached Input',
@@ -209,6 +228,25 @@ window.__ModuleLoader__.load({
209
228
  'turn.modal_title': '单次回复 Token 与成本详情',
210
229
  'turn.tokens_total': '本轮 Token 总量',
211
230
  'turn.cost_title': '单次成本',
231
+ 'turn.reasoning': '思考推理 Token',
232
+ 'context.title': '上下文窗口',
233
+ 'context.saturation': '上下文饱和度',
234
+ 'context.risk_low': '充足',
235
+ 'context.risk_moderate': '适中',
236
+ 'context.risk_high': '压缩风险',
237
+ 'context.next_turn': '下轮基础成本:≈ {cost}',
238
+ 'context.compaction_warning': '上下文占用超过 80%,存在压缩丢忆与费用激增风险。',
239
+
240
+ 'privacy.toggle': '主播/隐私模式',
241
+ 'privacy.enabled': '隐私模式已开启(金额已隐藏)',
242
+ 'field.privacyMode': '隐私/主播模式',
243
+ 'hint.privacyMode': '隐藏所有金额显示(替换为星号),便于录屏、直播或截图分享。',
244
+ 'field.showHeaderChip': '在顶部显示消费小组件',
245
+ 'hint.showHeaderChip': '在对话顶部标题栏显示实时费用与费率倒计时小组件。',
246
+ 'field.showMessageAction': '在单条消息旁显示 Token 详情按钮',
247
+ 'hint.showMessageAction': '在助手回复的消息操作栏中显示闪电 Token 详情按钮。',
248
+ 'notice.hidden_access': '后台费用计量与记录正常运行。即使隐藏顶部组件,仍可在此设置面板或通过 /cost 命令随时查看完整统计。',
249
+
212
250
  'turn.cache_hit_rate': '缓存命中率',
213
251
  'turn.duration': '耗时',
214
252
  'turn.uncached_in': '未命中输入',
@@ -381,6 +419,19 @@ window.__ModuleLoader__.load({
381
419
  .dcm-proj-item { display: inline-flex; align-items: center; gap: 4px; }
382
420
  .dcm-proj-val { font-weight: 600; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
383
421
 
422
+ .dcm-odometer { display: inline-block; font-variant-numeric: tabular-nums; transition: transform 0.15s ease-out; }
423
+ @media (prefers-reduced-motion: reduce) { .dcm-odometer { transition: none !important; } }
424
+ .dcm-context-gauge { margin: 8px 0; padding: 8px 10px; border-radius: 6px; background: color-mix(in srgb, var(--dsw-alias-bg-layer-2) 60%, transparent); border: 1px solid var(--dsw-alias-border-l2); display: flex; flex-direction: column; gap: 5px; }
425
+ .dcm-context-header { display: flex; justify-content: space-between; align-items: center; font-size: 11px; }
426
+ .dcm-context-bar-bg { width: 100%; height: 5px; border-radius: 3px; background: color-mix(in srgb, var(--dsw-alias-bg-layer-2) 90%, transparent); overflow: hidden; }
427
+ .dcm-context-bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s ease-out; }
428
+ .dcm-context-footer { display: flex; justify-content: space-between; font-size: 10.5px; color: var(--dsw-alias-label-secondary); }
429
+ .dcm-risk-badge { padding: 1px 5px; border-radius: 8px; font-size: 10px; font-weight: 500; }
430
+ .dcm-risk-low { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 15%, transparent); color: var(--dsw-alias-state-success-primary); }
431
+ .dcm-risk-moderate { background: color-mix(in srgb, var(--dsw-alias-state-warning-primary) 15%, transparent); color: var(--dsw-alias-state-warning-primary); }
432
+ .dcm-risk-high { background: color-mix(in srgb, var(--dsw-alias-state-danger-primary) 15%, transparent); color: var(--dsw-alias-state-danger-primary); }
433
+
434
+
384
435
  .dcm-chip-alert-peak { box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-warning-primary) 50%, transparent); animation: dcm-pulse-peak 2s infinite ease-in-out; }
385
436
  .dcm-chip-alert-offpeak { box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-success-primary) 50%, transparent); animation: dcm-pulse-offpeak 2s infinite ease-in-out; }
386
437
  @keyframes dcm-pulse-peak { 0%, 100% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--dsw-alias-state-warning-primary) 30%, transparent); } 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--dsw-alias-state-warning-primary) 70%, transparent); } }
@@ -700,6 +751,9 @@ window.__ModuleLoader__.load({
700
751
  alertBeforeSwitch: stored.alertBeforeSwitch !== undefined ? Boolean(stored.alertBeforeSwitch) : true,
701
752
  alertLeadMinutes: stored.alertLeadMinutes !== undefined ? String(stored.alertLeadMinutes) : '5',
702
753
  browserNotifications: stored.browserNotifications !== undefined ? Boolean(stored.browserNotifications) : false,
754
+ privacyMode: stored.privacyMode !== undefined ? Boolean(stored.privacyMode) : false,
755
+ showHeaderChip: stored.showHeaderChip !== undefined ? Boolean(stored.showHeaderChip) : true,
756
+ showMessageAction: stored.showMessageAction !== undefined ? Boolean(stored.showMessageAction) : true,
703
757
  useOpenRouter: stored.useOpenRouter !== undefined ? Boolean(stored.useOpenRouter) : true,
704
758
  refreshHours: stored.refreshHours !== undefined ? String(stored.refreshHours) : '24',
705
759
  })
@@ -1207,13 +1261,64 @@ window.__ModuleLoader__.load({
1207
1261
  return str
1208
1262
  }
1209
1263
 
1264
+ function useAnimatedNumber(target, enabled = true) {
1265
+ const [display, setDisplay] = React.useState(target)
1266
+ const animRef = React.useRef(null)
1267
+ const currentRef = React.useRef(target)
1268
+
1269
+ React.useEffect(() => {
1270
+ const prefersReduced = typeof window !== 'undefined' &&
1271
+ window.matchMedia &&
1272
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
1273
+
1274
+ if (prefersReduced || !enabled || typeof target !== 'number' || isNaN(target)) {
1275
+ currentRef.current = target
1276
+ setDisplay(target)
1277
+ return
1278
+ }
1279
+
1280
+ const start = currentRef.current
1281
+ const diff = target - start
1282
+ if (Math.abs(diff) < 0.00001) return
1283
+
1284
+ const duration = Math.min(800, Math.max(300, Math.abs(diff) * 1000))
1285
+ const startTime = performance.now()
1286
+
1287
+ const step = (now) => {
1288
+ const elapsed = now - startTime
1289
+ const progress = Math.min(1, elapsed / duration)
1290
+ const ease = 1 - Math.pow(1 - progress, 3)
1291
+ const current = start + diff * ease
1292
+ currentRef.current = current
1293
+ setDisplay(current)
1294
+
1295
+ if (progress < 1) {
1296
+ animRef.current = requestAnimationFrame(step)
1297
+ } else {
1298
+ currentRef.current = target
1299
+ setDisplay(target)
1300
+ }
1301
+ }
1302
+
1303
+ if (animRef.current) cancelAnimationFrame(animRef.current)
1304
+ animRef.current = requestAnimationFrame(step)
1305
+
1306
+ return () => {
1307
+ if (animRef.current) cancelAnimationFrame(animRef.current)
1308
+ }
1309
+ }, [target, enabled])
1310
+
1311
+ return display
1312
+ }
1313
+
1210
1314
  function rate(value) {
1211
1315
  if (!value) return '—'
1212
1316
  const text = value < 0.1 ? value.toFixed(3) : value.toFixed(2)
1213
1317
  return '$' + text.replace(/(\\.\\d*?)0+$/, '$1').replace(/\\.$/, '')
1214
1318
  }
1215
1319
 
1216
- function money(value, currency) {
1320
+ function money(value, currency, privacy = false) {
1321
+ if (privacy) return currency + '••••'
1217
1322
  const n = Number(value) || 0
1218
1323
  return currency + n.toFixed(Math.abs(n) >= 100 ? 0 : 2)
1219
1324
  }
@@ -1309,7 +1414,7 @@ window.__ModuleLoader__.load({
1309
1414
 
1310
1415
  const tip = (t ? t('sparkline.steps') : 'step') + ' ' + (item.turn || idx + 1) +
1311
1416
  (item.step ? '.' + item.step : '') + ': ' + tokens(tok) +
1312
- (stepCost > 0 ? ' (≈ ' + money(stepCost, currency) + ')' : '')
1417
+ (stepCost > 0 ? ' (≈ ' + money(stepCost, currency, isPrivacy) + ')' : '')
1313
1418
 
1314
1419
  return h('rect', {
1315
1420
  key: 'bar-' + idx,
@@ -1474,6 +1579,16 @@ window.__ModuleLoader__.load({
1474
1579
  const locale = useActiveLocale(ctx)
1475
1580
  const t = props.t || makeT(locale === 'zh' ? zh : en, en)
1476
1581
  const [open, setOpen] = React.useState(false)
1582
+ const configForms = getSettingsScope(ctx)
1583
+ const scope = React.useMemo(() => (configForms ? configForms.get(NS) : undefined), [configForms])
1584
+ const snapshot = React.useSyncExternalStore(
1585
+ React.useMemo(() => (cb) => (scope ? scope.subscribe(cb) : () => {}), [scope]),
1586
+ React.useCallback(() => (scope ? scope.getSnapshot() : { status: 'loading' }), [scope]),
1587
+ React.useCallback(() => ({ status: 'loading' }), []),
1588
+ )
1589
+ const stored = (snapshot && snapshot.value) || {}
1590
+ if (stored.showMessageAction === false) return null
1591
+
1477
1592
  const useProjection = props.useProjection || (ctx && ctx.useProjection)
1478
1593
  const byModel = useProjection ? useProjection('costByModel') : undefined
1479
1594
 
@@ -1501,6 +1616,8 @@ window.__ModuleLoader__.load({
1501
1616
  : (uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0)
1502
1617
  const durMs = Number(tr.durationMs) || 0
1503
1618
  const cost = Number(tr.costDeltaUsd) || 0
1619
+ const reasoning = Number(tr.reasoningTokens) || 0
1620
+ const isPrivacy = Boolean(stored.privacyMode)
1504
1621
 
1505
1622
  const fmtDur = (ms) => {
1506
1623
  if (!ms || ms <= 0) return '0s'
@@ -1515,7 +1632,7 @@ window.__ModuleLoader__.load({
1515
1632
  key: 'btn',
1516
1633
  type: 'button',
1517
1634
  className: 'dcm-msg-action-btn',
1518
- title: t('turn.modal_title'),
1635
+ title: t('turn.modal_title') + (reasoning > 0 ? (' (🧠 ' + tokens(reasoning) + ')') : ''),
1519
1636
  onClick: () => setOpen(true),
1520
1637
  }, [
1521
1638
  React.createElement('span', { key: 'icon' }, '⚡'),
@@ -1554,7 +1671,7 @@ window.__ModuleLoader__.load({
1554
1671
  ]),
1555
1672
  React.createElement('div', { key: 's4', className: 'dcm-inspector-stat' }, [
1556
1673
  React.createElement('div', { className: 'dcm-inspector-stat-lbl' }, t('turn.cost_title')),
1557
- React.createElement('div', { className: 'dcm-inspector-stat-val' }, cost > 0 ? ('$' + cost.toFixed(4)) : '—'),
1674
+ React.createElement('div', { className: 'dcm-inspector-stat-val' }, isPrivacy ? '$••••' : (cost > 0 ? ('$' + cost.toFixed(4)) : '—')),
1558
1675
  ]),
1559
1676
  ]),
1560
1677
  React.createElement('div', { key: 'breakdown', style: { display: 'flex', flexDirection: 'column', gap: 6, fontSize: 11.5, marginTop: 8 } }, [
@@ -1570,6 +1687,10 @@ window.__ModuleLoader__.load({
1570
1687
  React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('turn.output')),
1571
1688
  React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, tokens(out)),
1572
1689
  ]),
1690
+ reasoning > 0 ? React.createElement('div', { key: 'b-reasoning', style: { display: 'flex', justifyContent: 'space-between', color: 'var(--dsw-alias-brand-primary)' } }, [
1691
+ React.createElement('span', null, '🧠 ' + t('turn.reasoning')),
1692
+ React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, tokens(reasoning)),
1693
+ ]) : null,
1573
1694
  ]),
1574
1695
  ])
1575
1696
  ) : null
@@ -1580,6 +1701,13 @@ window.__ModuleLoader__.load({
1580
1701
  // ------------------------------------------------------------ CostMeter
1581
1702
 
1582
1703
 
1704
+ function AnimatedCost(props) {
1705
+ const { value = 0, currency = '$', isPrivacy = false, className = '' } = props || {}
1706
+ const animated = useAnimatedNumber(Number(value) || 0, !isPrivacy)
1707
+ if (isPrivacy) return React.createElement('span', { className }, currency + '••••')
1708
+ return React.createElement('span', { className }, money(animated, currency))
1709
+ }
1710
+
1583
1711
  function CostMeter(props) {
1584
1712
  const ctx = props.ctx
1585
1713
  const locale = useActiveLocale(ctx)
@@ -1595,6 +1723,7 @@ window.__ModuleLoader__.load({
1595
1723
  const [showAllModels, setShowAllModels] = React.useState(false)
1596
1724
  const [showTurnInspector, setShowTurnInspector] = React.useState(false)
1597
1725
  const [selectedCurrency, setSelectedCurrency] = React.useState(null)
1726
+ const [selectedPrivacy, setSelectedPrivacy] = React.useState(null)
1598
1727
  const [exportingPng, setExportingPng] = React.useState(false)
1599
1728
  const lastAlertRef = React.useRef(null)
1600
1729
  const box = React.useRef(null)
@@ -1685,8 +1814,11 @@ window.__ModuleLoader__.load({
1685
1814
 
1686
1815
  if (!state) return null
1687
1816
 
1688
- const turns = (byModel?.turns && byModel.turns.length > 0) ? byModel.turns : (state.turns || [])
1689
1817
  const stored = (settingsSnap && settingsSnap.value) || {}
1818
+ const showHeaderChip = stored.showHeaderChip !== undefined ? Boolean(stored.showHeaderChip) : (state.showHeaderChip !== false)
1819
+ if (!showHeaderChip) return null
1820
+
1821
+ const turns = (byModel?.turns && byModel.turns.length > 0) ? byModel.turns : (state.turns || [])
1690
1822
  const CURRENCY_LIST = [{ symbol: '$', code: 'USD' }, { symbol: '¥', code: 'CNY' }, { symbol: '₽', code: 'RUB' }, { symbol: '€', code: 'EUR' }]
1691
1823
  const activeCurrency = selectedCurrency || (stored.currency !== undefined ? String(stored.currency) : (state.currency || '$'))
1692
1824
  const fxRates = state.fxRates || { USD: 1, RUB: 80, CNY: 7.15, EUR: 0.92 }
@@ -1695,6 +1827,8 @@ window.__ModuleLoader__.load({
1695
1827
  ? Number(fxRates[normCode])
1696
1828
  : (stored.usdRate !== undefined && !isNaN(Number(stored.usdRate)) ? Number(stored.usdRate) : (Number(state.usdRate) || 1))
1697
1829
  const currency = activeCurrency
1830
+ const isPrivacy = selectedPrivacy !== null ? selectedPrivacy : (stored.privacyMode !== undefined ? Boolean(stored.privacyMode) : Boolean(state.privacyMode))
1831
+ const formatMoney = (val) => money(val, currency, isPrivacy)
1698
1832
 
1699
1833
  const handleCurrencySelect = (sym) => {
1700
1834
  setSelectedCurrency(sym)
@@ -1833,7 +1967,7 @@ window.__ModuleLoader__.load({
1833
1967
  return [
1834
1968
  h('div', { key: label + '-l', className: 'dcm-td' }, label),
1835
1969
  h('div', { key: label + '-n', className: 'dcm-num' }, tokens(n)),
1836
- h('div', { key: label + '-v', className: 'dcm-cost' }, money(usd * fx, currency)),
1970
+ h('div', { key: label + '-v', className: 'dcm-cost' }, formatMoney(usd * fx)),
1837
1971
  ]
1838
1972
  }
1839
1973
 
@@ -1879,12 +2013,12 @@ window.__ModuleLoader__.load({
1879
2013
  const copyReport = () => {
1880
2014
  const lines = [
1881
2015
  '=== ' + (activeModelName || (state.route ? state.route.model : 'Cost Meter Report')) + ' ===',
1882
- t('tokens.session_total') + ' ' + (hasUsage ? money(total, currency) : '—') + (budgetThreshold > 0 ? ' (limit: ' + money(budgetThreshold * fx, currency) + ')' : ''),
2016
+ t('tokens.session_total') + ' ' + (hasUsage ? formatMoney(total) : '—') + (budgetThreshold > 0 ? ' (limit: ' + money(budgetThreshold * fx, currency) + ')' : ''),
1883
2017
  'Tier: ' + (flat ? 'Flat' : (cheapNow ? t('offpeak') : t('peak'))),
1884
2018
  'Rates (1M): input ' + rate(active.input) + ', output ' + rate(active.output) + ', cache ' + rate(active.cacheHit),
1885
2019
  ]
1886
2020
  if (cacheSavedAmount > 0) {
1887
- lines.push('Cache saved: ≈ ' + money(cacheSavedAmount, currency) + ' (' + cacheSavedPercent + '%)')
2021
+ lines.push('Cache saved: ≈ ' + formatMoney(cacheSavedAmount) + ' (' + cacheSavedPercent + '%)')
1888
2022
  }
1889
2023
  if (hasUsage && usage) {
1890
2024
  lines.push('Tokens: in ' + tokens(usage.inputTokens) + ', out ' + tokens(usage.outputTokens) + ', cache hit ' + tokens(usage.cacheReadTokens))
@@ -1927,13 +2061,13 @@ window.__ModuleLoader__.load({
1927
2061
  className: 'dcm-chip' + (displayMode === 'compact' ? ' dcm-chip-compact' : '') + (alertActive ? (alertDirection === 'peak' ? ' dcm-chip-alert-peak' : ' dcm-chip-alert-offpeak') : ''),
1928
2062
  onClick: () => setOpen((v) => !v),
1929
2063
  title: (budgetExceeded
1930
- ? t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })
2064
+ ? t('budget.exceeded', { limit: formatMoney(budgetThreshold * fx) })
1931
2065
  : (flat ? 'Flat tariff' : cheapNow ? t('offpeak') : t('peak'))) +
1932
- (cacheSavedAmount > 0 ? ' · ' + t('savings.cache_saved', { amount: money(cacheSavedAmount, currency), percent: cacheSavedPercent }) : ''),
2066
+ (cacheSavedAmount > 0 ? ' · ' + t('savings.cache_saved', { amount: formatMoney(cacheSavedAmount), percent: cacheSavedPercent }) : ''),
1933
2067
 
1934
2068
  }, [
1935
2069
  h('span', { key: 'dot', className: dotClass }),
1936
- h('span', { key: 'sum' }, hasUsage ? '≈ ' + money(total, currency) : '—'),
2070
+ hasUsage ? h(AnimatedCost, { key: 'sum', value: total, currency, isPrivacy, className: 'dcm-odometer' }) : h('span', { key: 'sum' }, '—'),
1937
2071
  left ? h('span', { key: 'left', className: 'dcm-chip-timer' }, left) : null,
1938
2072
  ]),
1939
2073
 
@@ -1973,11 +2107,11 @@ window.__ModuleLoader__.load({
1973
2107
 
1974
2108
  cacheSavedAmount > 0
1975
2109
  ? h('div', { key: 'cache-saved-box', className: 'dcm-cache-box', style: { marginTop: 6 } },
1976
- t('savings.cache_saved', { amount: money(cacheSavedAmount, currency), percent: cacheSavedPercent }))
2110
+ t('savings.cache_saved', { amount: formatMoney(cacheSavedAmount), percent: cacheSavedPercent }))
1977
2111
  : null,
1978
2112
 
1979
2113
  budgetExceeded ? h('div', { key: 'budget-warn', className: 'dcm-alert dcm-alert-err', style: { marginTop: 6, padding: '6px 10px', fontSize: 11 } },
1980
- t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })) : null,
2114
+ t('budget.exceeded', { limit: formatMoney(budgetThreshold * fx) })) : null,
1981
2115
 
1982
2116
  renderSparkline(turns, {
1983
2117
  h,
@@ -2009,10 +2143,12 @@ window.__ModuleLoader__.load({
2009
2143
  ? tr.cacheHitRate
2010
2144
  : (uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0)
2011
2145
  const cost = Number(tr.costDeltaUsd) || 0
2146
+ const reasoning = Number(tr.reasoningTokens) || 0
2147
+ const isPrivacy = Boolean(stored.privacyMode)
2012
2148
  return h('div', { key: 'tr-' + idx, className: 'dcm-turn-row' }, [
2013
2149
  h('span', { key: 'tn', style: { fontWeight: 500 } }, 'T#' + tr.turn + (tr.step ? '.' + tr.step : '')),
2014
2150
  h('span', { key: 'tok', style: { color: 'var(--dsw-alias-label-secondary)' } }, tokens(tok) + ' tok (' + hitRate + '% hit)'),
2015
- h('span', { key: 'c', style: { fontWeight: 600 } }, cost > 0 ? money(cost * fx, currency) : '—'),
2151
+ h('span', { key: 'c', style: { fontWeight: 600 } }, cost > 0 ? formatMoney(cost * fx) : '—'),
2016
2152
  ])
2017
2153
  })
2018
2154
  ) : null,
@@ -2026,11 +2162,11 @@ window.__ModuleLoader__.load({
2026
2162
  return h('div', { key: 'proj-box', className: 'dcm-proj-box' }, [
2027
2163
  h('span', { key: 'burn', className: 'dcm-proj-item' }, [
2028
2164
  h('span', null, t('projection.burn_rate') + ' '),
2029
- h('span', { className: 'dcm-proj-val' }, money(burnRate, currency) + t('projection.per_hour')),
2165
+ h('span', { className: 'dcm-proj-val' }, formatMoney(burnRate) + t('projection.per_hour')),
2030
2166
  ]),
2031
2167
  h('span', { key: 'proj', className: 'dcm-proj-item' }, [
2032
2168
  h('span', null, t('projection.projected') + ' '),
2033
- h('span', { className: 'dcm-proj-val' }, '≈ ' + money(projectedTotal, currency)),
2169
+ h('span', { className: 'dcm-proj-val' }, '≈ ' + formatMoney(projectedTotal)),
2034
2170
  ]),
2035
2171
  ])
2036
2172
  })(),
@@ -2102,7 +2238,7 @@ window.__ModuleLoader__.load({
2102
2238
  color: m.known ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-tertiary)',
2103
2239
  },
2104
2240
  },
2105
- m.known ? (hasUsage ? money(m.cost, currency) : '—') : t('tokens.not_priced'),
2241
+ m.known ? (hasUsage ? formatMoney(m.cost) : '—') : t('tokens.not_priced'),
2106
2242
  ),
2107
2243
  ],
2108
2244
  ),
@@ -2122,7 +2258,7 @@ window.__ModuleLoader__.load({
2122
2258
 
2123
2259
  h('div', { key: 'total', className: 'dcm-total' }, [
2124
2260
  h('span', { key: 'l' }, t('tokens.session_total')),
2125
- h('span', { key: 'v' }, hasUsage ? '≈ ' + money(total, currency) : '—'),
2261
+ h('span', { key: 'v' }, hasUsage ? '≈ ' + formatMoney(total) : '—'),
2126
2262
  ]),
2127
2263
 
2128
2264
  hasUsage ? null : h('div', { key: 'nodata', className: 'dcm-panel-note', style: { paddingTop: 6 } },
package/lib/commands.js CHANGED
@@ -60,6 +60,10 @@ export function formatCostReceipt(data) {
60
60
  `| **Output Tokens** | ${formatNumber(output)} |`,
61
61
  ]
62
62
 
63
+ if (tokens.reasoningTokens > 0) {
64
+ lines.push(`| **Thinking / Reasoning** | 🧠 ${formatNumber(tokens.reasoningTokens)} |`)
65
+ }
66
+
63
67
  if (cacheWrite > 0) {
64
68
  lines.push(`| **Cache Write** | ${formatNumber(cacheWrite)} |`)
65
69
  }
package/lib/export.js CHANGED
@@ -40,6 +40,7 @@ export function generateSessionCsv(data) {
40
40
  'Cache_Read_Tokens',
41
41
  'Cache_Hit_Rate_%',
42
42
  'Output_Tokens',
43
+ 'Reasoning_Tokens',
43
44
  'Total_Tokens',
44
45
  'Cost_USD',
45
46
  'Cost_Local',
@@ -51,6 +52,7 @@ export function generateSessionCsv(data) {
51
52
  let sumUncached = 0
52
53
  let sumHit = 0
53
54
  let sumOut = 0
55
+ let sumReasoning = 0
54
56
  let sumTotalTok = 0
55
57
  let sumCostUsd = 0
56
58
 
@@ -60,6 +62,7 @@ export function generateSessionCsv(data) {
60
62
  const uncached = Number(t.uncachedInputTokens) || 0
61
63
  const hit = Number(t.cacheReadTokens) || 0
62
64
  const out = Number(t.outputTokens) || 0
65
+ const reasoning = Number(t.reasoningTokens) || 0
63
66
  const totalTok = uncached + hit + out
64
67
  const hitRate = t.cacheHitRate !== undefined
65
68
  ? t.cacheHitRate
@@ -70,6 +73,7 @@ export function generateSessionCsv(data) {
70
73
  sumUncached += uncached
71
74
  sumHit += hit
72
75
  sumOut += out
76
+ sumReasoning += reasoning
73
77
  sumTotalTok += totalTok
74
78
  sumCostUsd += costUsd
75
79
 
@@ -83,6 +87,7 @@ export function generateSessionCsv(data) {
83
87
  hit,
84
88
  hitRate,
85
89
  out,
90
+ reasoning,
86
91
  totalTok,
87
92
  costUsd.toFixed(6),
88
93
  costLocal.toFixed(4),
@@ -103,6 +108,7 @@ export function generateSessionCsv(data) {
103
108
  sumHit,
104
109
  overallHitRate,
105
110
  sumOut,
111
+ sumReasoning,
106
112
  sumTotalTok,
107
113
  (totalCostUsd || sumCostUsd).toFixed(6),
108
114
  ((totalCostUsd || sumCostUsd) * fx).toFixed(4),
@@ -137,14 +143,17 @@ export function generateSessionJson(data) {
137
143
  let sumUncached = 0
138
144
  let sumHit = 0
139
145
  let sumOut = 0
146
+ let sumReasoning = 0
140
147
 
141
148
  const turnsFormatted = turns.map((t) => {
142
149
  const uncached = Number(t.uncachedInputTokens) || 0
143
150
  const hit = Number(t.cacheReadTokens) || 0
144
151
  const out = Number(t.outputTokens) || 0
152
+ const reasoning = Number(t.reasoningTokens) || 0
145
153
  sumUncached += uncached
146
154
  sumHit += hit
147
155
  sumOut += out
156
+ sumReasoning += reasoning
148
157
  return {
149
158
  turn: t.turn,
150
159
  step: t.step ?? 0,
@@ -153,6 +162,7 @@ export function generateSessionJson(data) {
153
162
  uncachedInput: uncached,
154
163
  cacheRead: hit,
155
164
  output: out,
165
+ reasoningTokens: reasoning,
156
166
  total: uncached + hit + out,
157
167
  cacheHitRate: t.cacheHitRate !== undefined ? t.cacheHitRate : (uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0),
158
168
  },
@@ -180,6 +190,7 @@ export function generateSessionJson(data) {
180
190
  uncachedInput: sumUncached,
181
191
  cacheRead: sumHit,
182
192
  output: sumOut,
193
+ reasoningTokens: sumReasoning,
183
194
  total: sumUncached + sumHit + sumOut,
184
195
  cacheHitRate: overallHitRate,
185
196
  },
@@ -230,7 +241,7 @@ export function renderSummaryCardToCanvas(canvas, data) {
230
241
  } = data || {}
231
242
 
232
243
  const totalCost = costUsd * fx
233
- const costFormatted = totalCost >= 1 ? `${currency}${totalCost.toFixed(2)}` : (totalCost > 0 ? `${currency}${totalCost.toFixed(4)}` : `${currency}0.00`)
244
+ const costFormatted = data?.privacyMode ? `${currency}****` : (totalCost >= 1 ? `${currency}${totalCost.toFixed(2)}` : (totalCost > 0 ? `${currency}${totalCost.toFixed(4)}` : `${currency}0.00`))
234
245
  const uncached = Number(tokens.uncachedInputTokens || tokens.inputTokens) || 0
235
246
  const hit = Number(tokens.cacheReadTokens) || 0
236
247
  const out = Number(tokens.outputTokens) || 0
package/lib/index.js CHANGED
@@ -33,6 +33,7 @@ export * from './fx.js'
33
33
  export * from './alerts.js'
34
34
  export * from './commands.js'
35
35
  export * from './export.js'
36
+ export * from './analytics.js'
36
37
  export { STATE_PATH, REFRESH_PATH } from './routes.js'
37
38
  import { indexCatalog, resolveTariff, matchModel, unwrapVolatile, calculateCacheSavings } from './tariff.js'
38
39
  import { usageByModelProjection, getLastTurns, resetLastTurns } from './projection.js'
@@ -131,6 +132,9 @@ export function normalizeConfig(raw) {
131
132
  alertBeforeSwitch: u.alertBeforeSwitch !== false,
132
133
  alertLeadMinutes: Math.max(1, Math.min(30, Number(u.alertLeadMinutes) || 5)),
133
134
  browserNotifications: Boolean(u.browserNotifications),
135
+ privacyMode: Boolean(u.privacyMode),
136
+ showHeaderChip: u.showHeaderChip !== false,
137
+ showMessageAction: u.showMessageAction !== false,
134
138
  useOpenRouter: u.useOpenRouter !== false,
135
139
  refreshHours: Number(u.refreshHours) || 24,
136
140
  deepseekPeakPrices: u.deepseekPeakPrices || {},
@@ -162,6 +166,9 @@ export const Config = z.object({
162
166
  alertBeforeSwitch: z.boolean().description('Pre-switch notification and visual alert before tariff change.').default(true).volatile(),
163
167
  alertLeadMinutes: z.number().description('Minutes before tariff change to trigger pre-switch alert.').default(5).volatile(),
164
168
  browserNotifications: z.boolean().description('Enable desktop browser notifications for tariff switches.').default(false).volatile(),
169
+ privacyMode: z.boolean().description('Streamer/Privacy mode: mask financial spend with asterisks while retaining token stats.').default(false).volatile(),
170
+ showHeaderChip: z.boolean().description('Show the cost meter chip in the conversation header.').default(true).volatile(),
171
+ showMessageAction: z.boolean().description('Show the token inspector action button on assistant messages.').default(true).volatile(),
165
172
  deepseekPeakPrices: z.dict(PriceRow).description('Override the built-in DeepSeek peak rates, by model id.').default({}).volatile(),
166
173
  useOpenRouter: z.boolean().description('Pull rates and discount windows from the public OpenRouter catalog.').default(true).volatile(),
167
174
  refreshHours: z.number().description('How often to refetch the catalog.').default(24).volatile(),
package/lib/projection.js CHANGED
@@ -20,6 +20,7 @@ export const bucketSchema = zod.object({
20
20
  outputTokens: zod.number().nonnegative(),
21
21
  cacheReadTokens: zod.number().nonnegative(),
22
22
  cacheWriteTokens: zod.number().nonnegative(),
23
+ reasoningTokens: zod.number().nonnegative().optional(),
23
24
  }).strict()
24
25
 
25
26
  export const turnRecordSchema = zod.object({
@@ -30,6 +31,7 @@ export const turnRecordSchema = zod.object({
30
31
  outputTokens: zod.number().nonnegative().optional(),
31
32
  cacheReadTokens: zod.number().nonnegative().optional(),
32
33
  cacheWriteTokens: zod.number().nonnegative().optional(),
34
+ reasoningTokens: zod.number().nonnegative().optional(),
33
35
  costDeltaUsd: zod.number().nonnegative().optional(),
34
36
  timestamp: zod.number().nonnegative(),
35
37
  durationMs: zod.number().nonnegative().optional(),
@@ -38,11 +40,19 @@ export const turnRecordSchema = zod.object({
38
40
 
39
41
  /** Pull the four buckets out of one provider usage record. */
40
42
  export function bucketsFromUsage(usage) {
43
+ const reasoningTokens = Number(
44
+ usage?.completion_tokens_details?.reasoning_tokens ||
45
+ usage?.reasoningTokens ||
46
+ usage?.reasoning_tokens ||
47
+ usage?.details?.reasoning_tokens ||
48
+ 0
49
+ ) || 0
41
50
  return {
42
51
  uncachedInputTokens: Number(usage.inputTokens) || 0,
43
52
  outputTokens: Number(usage.outputTokens) || 0,
44
53
  cacheReadTokens: Number(usage.cacheReadTokens) || 0,
45
54
  cacheWriteTokens: Number(usage.cacheWriteTokens) || 0,
55
+ reasoningTokens,
46
56
  }
47
57
  }
48
58
 
@@ -180,6 +190,7 @@ export const usageByModelProjection = {
180
190
  outputTokens: buckets.outputTokens,
181
191
  cacheReadTokens: buckets.cacheReadTokens,
182
192
  cacheWriteTokens: buckets.cacheWriteTokens,
193
+ reasoningTokens: buckets.reasoningTokens || 0,
183
194
  costDeltaUsd: nextTurns[existingIndex].costDeltaUsd || 0,
184
195
  timestamp,
185
196
  durationMs: nextTurns[existingIndex].durationMs || durationMs,
@@ -194,6 +205,7 @@ export const usageByModelProjection = {
194
205
  outputTokens: buckets.outputTokens,
195
206
  cacheReadTokens: buckets.cacheReadTokens,
196
207
  cacheWriteTokens: buckets.cacheWriteTokens,
208
+ reasoningTokens: buckets.reasoningTokens || 0,
197
209
  costDeltaUsd: 0,
198
210
  timestamp,
199
211
  durationMs,
package/lib/routes.js CHANGED
@@ -2,7 +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
+ import { calculateBurnRateAndProjection, calculateContextMetrics } from './analytics.js'
6
6
  import { getCachedFxState } from './fx.js'
7
7
 
8
8
  export const STATE_PATH = '/dsh-cost-meter/state'
@@ -105,12 +105,19 @@ export function registerRoutes(ctx, options) {
105
105
  }
106
106
  }
107
107
 
108
+ const turnsList = getLastTurns()
109
+ const fxRateVal = Number(cfg?.usdRate) > 0 ? Number(cfg.usdRate) : 1
110
+ const ctxMetrics = calculateContextMetrics(turnsList, own, route)
111
+
108
112
  const payloadObj = {
109
113
  currency: typeof cfg?.currency === 'string' && cfg.currency ? cfg.currency : '$',
110
114
  usdRate: Number(cfg?.usdRate) > 0 ? Number(cfg.usdRate) : 1,
111
115
  fxRates: { USD: 1, ...(getCachedFxState().rates || {}) },
112
116
  displayTimeZone: typeof cfg?.displayTimeZone === 'string' && cfg.displayTimeZone ? cfg.displayTimeZone : 'Europe/Moscow',
113
117
  budgetThreshold: Number(cfg?.budgetThreshold) || 0,
118
+ privacyMode: Boolean(cfg?.privacyMode),
119
+ showHeaderChip: cfg?.showHeaderChip !== false,
120
+ showMessageAction: cfg?.showMessageAction !== false,
114
121
  displayMode: cfg?.displayMode || 'auto',
115
122
  alertBeforeSwitch: cfg?.alertBeforeSwitch !== false,
116
123
  alertLeadMinutes: Number(cfg?.alertLeadMinutes) || 5,
@@ -126,6 +133,14 @@ export function registerRoutes(ctx, options) {
126
133
  catalogError: catalog.error,
127
134
  catalogModelsCount: Object.keys((catalog.index && catalog.index.byId) || {}).length,
128
135
  schedule: own.schedule,
136
+ contextMetrics: {
137
+ currentTokens: ctxMetrics.currentTokens,
138
+ contextLength: ctxMetrics.contextLength,
139
+ saturationPercent: ctxMetrics.saturationPercent,
140
+ riskLevel: ctxMetrics.riskLevel,
141
+ nextTurnEstUsd: ctxMetrics.nextTurnEstUsd,
142
+ nextTurnEstLocal: ctxMetrics.nextTurnEstUsd * fxRateVal,
143
+ },
129
144
  }
130
145
 
131
146
  const serialized = JSON.stringify(payloadObj)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cost-meter",
3
- "version": "0.8.27",
3
+ "version": "0.8.29",
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",