@goodandready/dsh-cost-meter 0.8.26 → 0.8.28

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/client.js CHANGED
@@ -67,6 +67,12 @@ window.__ModuleLoader__.load({
67
67
  'action.sync_ok': 'Catalog updated',
68
68
  'action.sync_err': 'Sync failed',
69
69
  'action.sync_rate_limited': 'Wait {seconds}s',
70
+ 'action.export_csv': 'Export CSV',
71
+ 'action.export_json': 'Export JSON',
72
+ 'action.export_png': 'PNG Card',
73
+ 'action.exporting_png': 'Generating…',
74
+ 'action.exported': 'Downloaded!',
75
+ 'curr.select': 'Currency',
70
76
  'action.copy_report': 'Copy Summary',
71
77
  'action.copied': 'Copied!',
72
78
  'alert.pre_switch_peak': '⚠️ Peak tariff starts in {time} (higher rates)',
@@ -85,6 +91,17 @@ window.__ModuleLoader__.load({
85
91
  'turn.modal_title': 'Message Token & Cost Inspector',
86
92
  'turn.tokens_total': 'Turn Tokens',
87
93
  'turn.cost_title': 'Turn Cost',
94
+ 'turn.reasoning': 'Thinking / Reasoning',
95
+ 'privacy.toggle': 'Streamer / Privacy Mode',
96
+ 'privacy.enabled': 'Privacy mode active (amounts masked)',
97
+ 'field.privacyMode': 'Privacy / Streamer Mode',
98
+ 'hint.privacyMode': 'Mask currency amounts with asterisks to safely share screens or recordings.',
99
+ 'field.showHeaderChip': 'Show Cost Chip in Header',
100
+ 'hint.showHeaderChip': 'Display the live cost meter chip in the chat conversation header.',
101
+ 'field.showMessageAction': 'Show Token Inspector on Messages',
102
+ 'hint.showMessageAction': 'Display the lightning token inspector button on assistant messages.',
103
+ '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.',
104
+
88
105
  'turn.cache_hit_rate': 'Cache Hit',
89
106
  'turn.duration': 'Duration',
90
107
  'turn.uncached_in': 'Uncached Input',
@@ -179,6 +196,12 @@ window.__ModuleLoader__.load({
179
196
  'action.sync_ok': '目录已更新',
180
197
  'action.sync_err': '同步失败',
181
198
  'action.sync_rate_limited': '请等待 {seconds} 秒',
199
+ 'action.export_csv': '导出 CSV',
200
+ 'action.export_json': '导出 JSON',
201
+ 'action.export_png': '生成图片卡片',
202
+ 'action.exporting_png': '生成中…',
203
+ 'action.exported': '已下载!',
204
+ 'curr.select': '货币',
182
205
  'action.copy_report': '复制消费报告',
183
206
  'action.copied': '已复制!',
184
207
  'alert.pre_switch_peak': '⚠️ 高峰期计费将在 {time} 后开始(标准费率)',
@@ -197,6 +220,17 @@ window.__ModuleLoader__.load({
197
220
  'turn.modal_title': '单次回复 Token 与成本详情',
198
221
  'turn.tokens_total': '本轮 Token 总量',
199
222
  'turn.cost_title': '单次成本',
223
+ 'turn.reasoning': '思考推理 Token',
224
+ 'privacy.toggle': '主播/隐私模式',
225
+ 'privacy.enabled': '隐私模式已开启(金额已隐藏)',
226
+ 'field.privacyMode': '隐私/主播模式',
227
+ 'hint.privacyMode': '隐藏所有金额显示(替换为星号),便于录屏、直播或截图分享。',
228
+ 'field.showHeaderChip': '在顶部显示消费小组件',
229
+ 'hint.showHeaderChip': '在对话顶部标题栏显示实时费用与费率倒计时小组件。',
230
+ 'field.showMessageAction': '在单条消息旁显示 Token 详情按钮',
231
+ 'hint.showMessageAction': '在助手回复的消息操作栏中显示闪电 Token 详情按钮。',
232
+ 'notice.hidden_access': '后台费用计量与记录正常运行。即使隐藏顶部组件,仍可在此设置面板或通过 /cost 命令随时查看完整统计。',
233
+
200
234
  'turn.cache_hit_rate': '缓存命中率',
201
235
  'turn.duration': '耗时',
202
236
  'turn.uncached_in': '未命中输入',
@@ -387,6 +421,12 @@ window.__ModuleLoader__.load({
387
421
  .dcm-inspector-stat-val { font-size: 13.5px; font-weight: 600; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
388
422
  .dcm-turn-list { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; max-height: 180px; overflow-y: auto; }
389
423
  .dcm-turn-row { display: flex; justify-content: space-between; align-items: center; padding: 4px 6px; border-radius: 6px; background: color-mix(in srgb, var(--dsw-alias-bg-layer-3) 60%, transparent); font-size: 11px; }
424
+ .dcm-curr-switcher { display: inline-flex; align-items: center; gap: 2px; padding: 2px; border-radius: 6px; background: color-mix(in srgb, var(--dsw-alias-bg-layer-3) 80%, transparent); border: 1px solid var(--dsw-alias-border-l2); }
425
+ .dcm-curr-btn { appearance: none; background: transparent; border: 0; padding: 2px 7px; border-radius: 4px; font-size: 11.5px; font-weight: 500; color: var(--dsw-alias-label-secondary); cursor: pointer; transition: all .15s ease; user-select: none; }
426
+ .dcm-curr-btn:hover { color: var(--dsw-alias-label-primary); background: color-mix(in srgb, var(--dsw-alias-label-primary) 8%, transparent); }
427
+ .dcm-curr-btn-active { background: var(--dsw-alias-label-primary) !important; color: var(--dsw-alias-bg-layer-3) !important; font-weight: 600; }
428
+ .dcm-export-group { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 10px; justify-content: flex-end; }
429
+
390
430
  `
391
431
 
392
432
 
@@ -682,6 +722,9 @@ window.__ModuleLoader__.load({
682
722
  alertBeforeSwitch: stored.alertBeforeSwitch !== undefined ? Boolean(stored.alertBeforeSwitch) : true,
683
723
  alertLeadMinutes: stored.alertLeadMinutes !== undefined ? String(stored.alertLeadMinutes) : '5',
684
724
  browserNotifications: stored.browserNotifications !== undefined ? Boolean(stored.browserNotifications) : false,
725
+ privacyMode: stored.privacyMode !== undefined ? Boolean(stored.privacyMode) : false,
726
+ showHeaderChip: stored.showHeaderChip !== undefined ? Boolean(stored.showHeaderChip) : true,
727
+ showMessageAction: stored.showMessageAction !== undefined ? Boolean(stored.showMessageAction) : true,
685
728
  useOpenRouter: stored.useOpenRouter !== undefined ? Boolean(stored.useOpenRouter) : true,
686
729
  refreshHours: stored.refreshHours !== undefined ? String(stored.refreshHours) : '24',
687
730
  })
@@ -1180,13 +1223,23 @@ window.__ModuleLoader__.load({
1180
1223
  return perMillion / 1e6
1181
1224
  }
1182
1225
 
1226
+ function normalizeCurrencyCode(symbolOrCode) {
1227
+ const str = String(symbolOrCode || '').trim().toUpperCase()
1228
+ if (str === '₽' || str === 'RUB' || str === 'РУБ') return 'RUB'
1229
+ if (str === '€' || str === 'EUR') return 'EUR'
1230
+ if (str === '¥' || str === 'CNY' || str === 'RMB') return 'CNY'
1231
+ if (str === '$' || str === 'USD') return 'USD'
1232
+ return str
1233
+ }
1234
+
1183
1235
  function rate(value) {
1184
1236
  if (!value) return '—'
1185
1237
  const text = value < 0.1 ? value.toFixed(3) : value.toFixed(2)
1186
1238
  return '$' + text.replace(/(\\.\\d*?)0+$/, '$1').replace(/\\.$/, '')
1187
1239
  }
1188
1240
 
1189
- function money(value, currency) {
1241
+ function money(value, currency, privacy = false) {
1242
+ if (privacy) return currency + '••••'
1190
1243
  const n = Number(value) || 0
1191
1244
  return currency + n.toFixed(Math.abs(n) >= 100 ? 0 : 2)
1192
1245
  }
@@ -1282,7 +1335,7 @@ window.__ModuleLoader__.load({
1282
1335
 
1283
1336
  const tip = (t ? t('sparkline.steps') : 'step') + ' ' + (item.turn || idx + 1) +
1284
1337
  (item.step ? '.' + item.step : '') + ': ' + tokens(tok) +
1285
- (stepCost > 0 ? ' (≈ ' + money(stepCost, currency) + ')' : '')
1338
+ (stepCost > 0 ? ' (≈ ' + money(stepCost, currency, isPrivacy) + ')' : '')
1286
1339
 
1287
1340
  return h('rect', {
1288
1341
  key: 'bar-' + idx,
@@ -1311,6 +1364,135 @@ window.__ModuleLoader__.load({
1311
1364
  }
1312
1365
 
1313
1366
 
1367
+
1368
+ // --------------------------------------------------- Export helpers (Issue #118)
1369
+
1370
+ function sanitizeCsvCell(value) {
1371
+ if (value === null || value === undefined) return ''
1372
+ let str = String(value)
1373
+ if (/^[=+\-@\t\r]/.test(str)) str = "'" + str
1374
+ if (/[",\n\r]/.test(str)) str = '"' + str.replace(/"/g, '""') + '"'
1375
+ return str
1376
+ }
1377
+
1378
+ function generateSessionCsv(data) {
1379
+ const { model = 'unknown', provider = '', currency = '$', fx = 1, turns = [], totalCostUsd = 0 } = data || {}
1380
+ const headers = ['Timestamp', 'ISO_Time', 'Turn', 'Step', 'Model', 'Uncached_Input', 'Cache_Read', 'Cache_Hit_%', 'Output', 'Total_Tokens', 'Cost_USD', 'Cost_Local', 'Currency']
1381
+ const rows = [headers.map(sanitizeCsvCell).join(',')]
1382
+ let sumIn = 0, sumHit = 0, sumOut = 0, sumTot = 0, sumCost = 0
1383
+ for (const t of turns) {
1384
+ const ts = t.timestamp ? Number(t.timestamp) : Date.now()
1385
+ const iso = new Date(ts).toISOString()
1386
+ const uncached = Number(t.uncachedInputTokens) || 0
1387
+ const hit = Number(t.cacheReadTokens) || 0
1388
+ const out = Number(t.outputTokens) || 0
1389
+ const tot = uncached + hit + out
1390
+ const hitRate = t.cacheHitRate !== undefined ? t.cacheHitRate : (uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0)
1391
+ const costUsd = Number(t.costDeltaUsd) || 0
1392
+ sumIn += uncached; sumHit += hit; sumOut += out; sumTot += tot; sumCost += costUsd
1393
+ rows.push([ts, iso, t.turn ?? '', t.step ?? 0, provider ? (provider + '/' + model) : model, uncached, hit, hitRate, out, tot, costUsd.toFixed(6), (costUsd * fx).toFixed(4), currency].map(sanitizeCsvCell).join(','))
1394
+ }
1395
+ const overallHit = sumIn + sumHit > 0 ? Math.round((sumHit / (sumIn + sumHit)) * 100) : 0
1396
+ rows.push(['TOTAL', new Date().toISOString(), turns.length, '', provider ? (provider + '/' + model) : model, sumIn, sumHit, overallHit, sumOut, sumTot, (totalCostUsd || sumCost).toFixed(6), ((totalCostUsd || sumCost) * fx).toFixed(4), currency].map(sanitizeCsvCell).join(','))
1397
+ return '\uFEFF' + rows.join('\r\n')
1398
+ }
1399
+
1400
+ function generateSessionJson(data) {
1401
+ const { model = 'unknown', provider = '', currency = '$', fx = 1, turns = [], totalCostUsd = 0, cacheSavingsUsd = 0, cacheSavingsPercent = 0, tier = 'Standard' } = data || {}
1402
+ return {
1403
+ version: 1,
1404
+ generator: '@goodandready/dsh-cost-meter',
1405
+ exportedAt: new Date().toISOString(),
1406
+ session: {
1407
+ model, provider, currency, fxRate: fx, totalCostUsd, totalCostLocal: totalCostUsd * fx,
1408
+ cacheSavings: { savedUsd: cacheSavingsUsd, savedLocal: cacheSavingsUsd * fx, percent: cacheSavingsPercent },
1409
+ tier, turnsCount: turns.length,
1410
+ },
1411
+ turns: turns.map((t) => ({
1412
+ turn: t.turn, step: t.step ?? 0, timestamp: t.timestamp,
1413
+ tokens: { uncachedInput: Number(t.uncachedInputTokens) || 0, cacheRead: Number(t.cacheReadTokens) || 0, output: Number(t.outputTokens) || 0, total: Number(t.tokens) || 0, cacheHitRate: t.cacheHitRate || 0 },
1414
+ costDeltaUsd: Number(t.costDeltaUsd) || 0, costDeltaLocal: (Number(t.costDeltaUsd) || 0) * fx,
1415
+ })),
1416
+ }
1417
+ }
1418
+
1419
+ function renderSummaryCardToCanvas(canvas, data) {
1420
+ if (!canvas || typeof canvas.getContext !== 'function') return null
1421
+ const width = 1200, height = 680
1422
+ canvas.width = width; canvas.height = height
1423
+ const ctx = canvas.getContext('2d')
1424
+ if (!ctx) return null
1425
+ const { model = 'model', provider = '', currency = '$', fx = 1, costUsd = 0, tokens = {}, cacheSavingsUsd = 0, cacheSavingsPercent = 0, tier = 'Standard', turns = [] } = data || {}
1426
+ const totalCost = costUsd * fx
1427
+ const costFmt = totalCost >= 1 ? (currency + totalCost.toFixed(2)) : (totalCost > 0 ? (currency + totalCost.toFixed(4)) : (currency + '0.00'))
1428
+ const uncached = Number(tokens.uncachedInputTokens || tokens.inputTokens) || 0
1429
+ const hit = Number(tokens.cacheReadTokens) || 0
1430
+ const out = Number(tokens.outputTokens) || 0
1431
+ const totTok = uncached + hit + out
1432
+ const hitRate = uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0
1433
+ const savedAmt = cacheSavingsUsd * fx
1434
+ const savedFmt = savedAmt >= 1 ? (currency + savedAmt.toFixed(2)) : (currency + savedAmt.toFixed(4))
1435
+
1436
+ const bg = ctx.createLinearGradient(0, 0, width, height)
1437
+ bg.addColorStop(0, '#0f172a'); bg.addColorStop(1, '#1e293b')
1438
+ ctx.fillStyle = bg; ctx.fillRect(0, 0, width, height)
1439
+ ctx.strokeStyle = '#334155'; ctx.lineWidth = 3; ctx.strokeRect(20, 20, width - 40, height - 40)
1440
+
1441
+ ctx.fillStyle = '#1e293b'; ctx.beginPath(); ctx.roundRect(50, 45, 220, 44, 22); ctx.fill()
1442
+ ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = 2; ctx.stroke()
1443
+ ctx.fillStyle = '#38bdf8'; ctx.font = 'bold 20px system-ui, sans-serif'; ctx.fillText('⚡ Cost Meter', 75, 74)
1444
+
1445
+ ctx.fillStyle = '#94a3b8'; ctx.font = '18px system-ui, sans-serif'; ctx.textAlign = 'right'
1446
+ ctx.fillText(new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }), width - 60, 74)
1447
+ ctx.textAlign = 'left'
1448
+
1449
+ ctx.fillStyle = '#f8fafc'; ctx.font = 'bold 36px system-ui, sans-serif'
1450
+ ctx.fillText(provider ? (provider + '/' + model) : model, 50, 145)
1451
+
1452
+ ctx.fillStyle = tier.includes('Off-Peak') || tier.includes('低谷') ? '#10b981' : '#f59e0b'
1453
+ ctx.font = 'bold 20px system-ui, sans-serif'; ctx.fillText(tier, 50, 180)
1454
+
1455
+ const tiles = [
1456
+ { label: 'SESSION SPEND', val: costFmt, color: '#38bdf8' },
1457
+ { label: 'TOTAL TOKENS', val: totTok.toLocaleString(), color: '#f8fafc' },
1458
+ { label: 'CACHE HIT RATE', val: hitRate + '%', color: '#34d399' },
1459
+ { label: 'CACHE SAVED', val: '≈ ' + savedFmt + ' (' + cacheSavingsPercent + '%)', color: '#a78bfa' },
1460
+ ]
1461
+ tiles.forEach((t, i) => {
1462
+ const x = 50 + i * 270, y = 215
1463
+ ctx.fillStyle = 'rgba(30, 41, 59, 0.7)'; ctx.beginPath(); ctx.roundRect(x, y, 250, 110, 12); ctx.fill()
1464
+ ctx.strokeStyle = '#334155'; ctx.lineWidth = 1.5; ctx.stroke()
1465
+ ctx.fillStyle = '#94a3b8'; ctx.font = 'bold 14px system-ui, sans-serif'; ctx.fillText(t.label, x + 16, y + 36)
1466
+ ctx.fillStyle = t.color; ctx.font = 'bold 26px system-ui, sans-serif'; ctx.fillText(t.val, x + 16, y + 80)
1467
+ })
1468
+
1469
+ const chartY = 360, chartH = 190, chartW = width - 100
1470
+ ctx.fillStyle = 'rgba(15, 23, 42, 0.6)'; ctx.beginPath(); ctx.roundRect(50, chartY, chartW, chartH, 12); ctx.fill()
1471
+ ctx.strokeStyle = '#334155'; ctx.lineWidth = 1.5; ctx.stroke()
1472
+ ctx.fillStyle = '#64748b'; ctx.font = 'bold 15px system-ui, sans-serif'; ctx.fillText('TURN HISTORY & STEP DYNAMICS', 70, chartY + 35)
1473
+
1474
+ const activeTurns = turns.slice(-25)
1475
+ if (activeTurns.length > 0) {
1476
+ const maxTok = Math.max(...activeTurns.map((t) => Number(t.tokens) || 1), 100)
1477
+ const barW = Math.min(32, Math.floor((chartW - 60) / activeTurns.length) - 6)
1478
+ const baseY = chartY + chartH - 30
1479
+ activeTurns.forEach((t, i) => {
1480
+ const x = 70 + i * (barW + 8)
1481
+ const tok = Number(t.tokens) || 0
1482
+ const h = Math.max(6, Math.round((tok / maxTok) * 100))
1483
+ ctx.fillStyle = '#3b82f6'; ctx.beginPath(); ctx.roundRect(x, baseY - h, barW, h, 4); ctx.fill()
1484
+ const hitTokens = Number(t.cacheReadTokens) || 0
1485
+ if (hitTokens > 0) {
1486
+ const hitH = Math.round((hitTokens / tok) * h)
1487
+ ctx.fillStyle = '#10b981'; ctx.beginPath(); ctx.roundRect(x, baseY - hitH, barW, hitH, 4); ctx.fill()
1488
+ }
1489
+ })
1490
+ }
1491
+
1492
+ ctx.fillStyle = '#64748b'; ctx.font = '16px system-ui, sans-serif'; ctx.fillText('Generated by @goodandready/dsh-cost-meter', 50, height - 35)
1493
+ return canvas
1494
+ }
1495
+
1314
1496
  // --------------------------------------------------- MessageTokenAction (Issue #120)
1315
1497
 
1316
1498
  function MessageTokenAction(props) {
@@ -1318,6 +1500,16 @@ window.__ModuleLoader__.load({
1318
1500
  const locale = useActiveLocale(ctx)
1319
1501
  const t = props.t || makeT(locale === 'zh' ? zh : en, en)
1320
1502
  const [open, setOpen] = React.useState(false)
1503
+ const configForms = getSettingsScope(ctx)
1504
+ const scope = React.useMemo(() => (configForms ? configForms.get(NS) : undefined), [configForms])
1505
+ const snapshot = React.useSyncExternalStore(
1506
+ React.useMemo(() => (cb) => (scope ? scope.subscribe(cb) : () => {}), [scope]),
1507
+ React.useCallback(() => (scope ? scope.getSnapshot() : { status: 'loading' }), [scope]),
1508
+ React.useCallback(() => ({ status: 'loading' }), []),
1509
+ )
1510
+ const stored = (snapshot && snapshot.value) || {}
1511
+ if (stored.showMessageAction === false) return null
1512
+
1321
1513
  const useProjection = props.useProjection || (ctx && ctx.useProjection)
1322
1514
  const byModel = useProjection ? useProjection('costByModel') : undefined
1323
1515
 
@@ -1345,6 +1537,8 @@ window.__ModuleLoader__.load({
1345
1537
  : (uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0)
1346
1538
  const durMs = Number(tr.durationMs) || 0
1347
1539
  const cost = Number(tr.costDeltaUsd) || 0
1540
+ const reasoning = Number(tr.reasoningTokens) || 0
1541
+ const isPrivacy = Boolean(stored.privacyMode)
1348
1542
 
1349
1543
  const fmtDur = (ms) => {
1350
1544
  if (!ms || ms <= 0) return '0s'
@@ -1359,7 +1553,7 @@ window.__ModuleLoader__.load({
1359
1553
  key: 'btn',
1360
1554
  type: 'button',
1361
1555
  className: 'dcm-msg-action-btn',
1362
- title: t('turn.modal_title'),
1556
+ title: t('turn.modal_title') + (reasoning > 0 ? (' (🧠 ' + tokens(reasoning) + ')') : ''),
1363
1557
  onClick: () => setOpen(true),
1364
1558
  }, [
1365
1559
  React.createElement('span', { key: 'icon' }, '⚡'),
@@ -1398,7 +1592,7 @@ window.__ModuleLoader__.load({
1398
1592
  ]),
1399
1593
  React.createElement('div', { key: 's4', className: 'dcm-inspector-stat' }, [
1400
1594
  React.createElement('div', { className: 'dcm-inspector-stat-lbl' }, t('turn.cost_title')),
1401
- React.createElement('div', { className: 'dcm-inspector-stat-val' }, cost > 0 ? ('$' + cost.toFixed(4)) : '—'),
1595
+ React.createElement('div', { className: 'dcm-inspector-stat-val' }, isPrivacy ? '$••••' : (cost > 0 ? ('$' + cost.toFixed(4)) : '—')),
1402
1596
  ]),
1403
1597
  ]),
1404
1598
  React.createElement('div', { key: 'breakdown', style: { display: 'flex', flexDirection: 'column', gap: 6, fontSize: 11.5, marginTop: 8 } }, [
@@ -1414,6 +1608,10 @@ window.__ModuleLoader__.load({
1414
1608
  React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('turn.output')),
1415
1609
  React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, tokens(out)),
1416
1610
  ]),
1611
+ reasoning > 0 ? React.createElement('div', { key: 'b-reasoning', style: { display: 'flex', justifyContent: 'space-between', color: 'var(--dsw-alias-brand-primary)' } }, [
1612
+ React.createElement('span', null, '🧠 ' + t('turn.reasoning')),
1613
+ React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, tokens(reasoning)),
1614
+ ]) : null,
1417
1615
  ]),
1418
1616
  ])
1419
1617
  ) : null
@@ -1438,6 +1636,9 @@ window.__ModuleLoader__.load({
1438
1636
  const [copied, setCopied] = React.useState(false)
1439
1637
  const [showAllModels, setShowAllModels] = React.useState(false)
1440
1638
  const [showTurnInspector, setShowTurnInspector] = React.useState(false)
1639
+ const [selectedCurrency, setSelectedCurrency] = React.useState(null)
1640
+ const [selectedPrivacy, setSelectedPrivacy] = React.useState(null)
1641
+ const [exportingPng, setExportingPng] = React.useState(false)
1441
1642
  const lastAlertRef = React.useRef(null)
1442
1643
  const box = React.useRef(null)
1443
1644
 
@@ -1527,10 +1728,33 @@ window.__ModuleLoader__.load({
1527
1728
 
1528
1729
  if (!state) return null
1529
1730
 
1530
- const turns = (byModel?.turns && byModel.turns.length > 0) ? byModel.turns : (state.turns || [])
1531
1731
  const stored = (settingsSnap && settingsSnap.value) || {}
1532
- const currency = stored.currency !== undefined ? String(stored.currency) : (state.currency || '$')
1533
- const fx = stored.usdRate !== undefined && !isNaN(Number(stored.usdRate)) ? Number(stored.usdRate) : (Number(state.usdRate) || 1)
1732
+ const showHeaderChip = stored.showHeaderChip !== undefined ? Boolean(stored.showHeaderChip) : (state.showHeaderChip !== false)
1733
+ if (!showHeaderChip) return null
1734
+
1735
+ const turns = (byModel?.turns && byModel.turns.length > 0) ? byModel.turns : (state.turns || [])
1736
+ const CURRENCY_LIST = [{ symbol: '$', code: 'USD' }, { symbol: '¥', code: 'CNY' }, { symbol: '₽', code: 'RUB' }, { symbol: '€', code: 'EUR' }]
1737
+ const activeCurrency = selectedCurrency || (stored.currency !== undefined ? String(stored.currency) : (state.currency || '$'))
1738
+ const fxRates = state.fxRates || { USD: 1, RUB: 80, CNY: 7.15, EUR: 0.92 }
1739
+ const normCode = normalizeCurrencyCode(activeCurrency)
1740
+ const fx = (selectedCurrency && fxRates[normCode] !== undefined)
1741
+ ? Number(fxRates[normCode])
1742
+ : (stored.usdRate !== undefined && !isNaN(Number(stored.usdRate)) ? Number(stored.usdRate) : (Number(state.usdRate) || 1))
1743
+ const currency = activeCurrency
1744
+ const isPrivacy = selectedPrivacy !== null ? selectedPrivacy : (stored.privacyMode !== undefined ? Boolean(stored.privacyMode) : Boolean(state.privacyMode))
1745
+ const formatMoney = (val) => money(val, currency, isPrivacy)
1746
+
1747
+ const handleCurrencySelect = (sym) => {
1748
+ setSelectedCurrency(sym)
1749
+ const code = normalizeCurrencyCode(sym)
1750
+ const newFx = fxRates[code] || 1
1751
+ if (scope && typeof scope.set === 'function') {
1752
+ try {
1753
+ scope.set('currency', sym)
1754
+ scope.set('usdRate', newFx)
1755
+ } catch (_) {}
1756
+ }
1757
+ }
1534
1758
  const budgetThreshold = stored.budgetThreshold !== undefined ? Number(stored.budgetThreshold) : (Number(state.budgetThreshold) || 0)
1535
1759
  const displayMode = stored.displayMode !== undefined ? stored.displayMode : (state.displayMode || 'auto')
1536
1760
  const schedules = state.schedules || {}
@@ -1657,7 +1881,7 @@ window.__ModuleLoader__.load({
1657
1881
  return [
1658
1882
  h('div', { key: label + '-l', className: 'dcm-td' }, label),
1659
1883
  h('div', { key: label + '-n', className: 'dcm-num' }, tokens(n)),
1660
- h('div', { key: label + '-v', className: 'dcm-cost' }, money(usd * fx, currency)),
1884
+ h('div', { key: label + '-v', className: 'dcm-cost' }, formatMoney(usd * fx)),
1661
1885
  ]
1662
1886
  }
1663
1887
 
@@ -1703,12 +1927,12 @@ window.__ModuleLoader__.load({
1703
1927
  const copyReport = () => {
1704
1928
  const lines = [
1705
1929
  '=== ' + (activeModelName || (state.route ? state.route.model : 'Cost Meter Report')) + ' ===',
1706
- t('tokens.session_total') + ' ' + (hasUsage ? money(total, currency) : '—') + (budgetThreshold > 0 ? ' (limit: ' + money(budgetThreshold * fx, currency) + ')' : ''),
1930
+ t('tokens.session_total') + ' ' + (hasUsage ? formatMoney(total) : '—') + (budgetThreshold > 0 ? ' (limit: ' + money(budgetThreshold * fx, currency) + ')' : ''),
1707
1931
  'Tier: ' + (flat ? 'Flat' : (cheapNow ? t('offpeak') : t('peak'))),
1708
1932
  'Rates (1M): input ' + rate(active.input) + ', output ' + rate(active.output) + ', cache ' + rate(active.cacheHit),
1709
1933
  ]
1710
1934
  if (cacheSavedAmount > 0) {
1711
- lines.push('Cache saved: ≈ ' + money(cacheSavedAmount, currency) + ' (' + cacheSavedPercent + '%)')
1935
+ lines.push('Cache saved: ≈ ' + formatMoney(cacheSavedAmount) + ' (' + cacheSavedPercent + '%)')
1712
1936
  }
1713
1937
  if (hasUsage && usage) {
1714
1938
  lines.push('Tokens: in ' + tokens(usage.inputTokens) + ', out ' + tokens(usage.outputTokens) + ', cache hit ' + tokens(usage.cacheReadTokens))
@@ -1751,20 +1975,30 @@ window.__ModuleLoader__.load({
1751
1975
  className: 'dcm-chip' + (displayMode === 'compact' ? ' dcm-chip-compact' : '') + (alertActive ? (alertDirection === 'peak' ? ' dcm-chip-alert-peak' : ' dcm-chip-alert-offpeak') : ''),
1752
1976
  onClick: () => setOpen((v) => !v),
1753
1977
  title: (budgetExceeded
1754
- ? t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })
1978
+ ? t('budget.exceeded', { limit: formatMoney(budgetThreshold * fx) })
1755
1979
  : (flat ? 'Flat tariff' : cheapNow ? t('offpeak') : t('peak'))) +
1756
- (cacheSavedAmount > 0 ? ' · ' + t('savings.cache_saved', { amount: money(cacheSavedAmount, currency), percent: cacheSavedPercent }) : ''),
1980
+ (cacheSavedAmount > 0 ? ' · ' + t('savings.cache_saved', { amount: formatMoney(cacheSavedAmount), percent: cacheSavedPercent }) : ''),
1757
1981
 
1758
1982
  }, [
1759
1983
  h('span', { key: 'dot', className: dotClass }),
1760
- h('span', { key: 'sum' }, hasUsage ? '≈ ' + money(total, currency) : '—'),
1984
+ h('span', { key: 'sum' }, hasUsage ? '≈ ' + formatMoney(total) : '—'),
1761
1985
  left ? h('span', { key: 'left', className: 'dcm-chip-timer' }, left) : null,
1762
1986
  ]),
1763
1987
 
1764
1988
  open ? h('div', { key: 'panel', className: 'dcm-panel' }, [
1765
1989
  h('div', { key: 'title', className: 'dcm-panel-title' }, [
1766
1990
  h('span', { key: 'model-name' }, activeModelName || (state.route ? state.route.model : 'model')),
1767
- flat ? null : h('span', { key: 'tier', style: { fontSize: 11.5, opacity: 0.85 } }, cheapNow ? t('offpeak') : t('peak')),
1991
+ h('div', { key: 'curr-switcher', className: 'dcm-curr-switcher' },
1992
+ CURRENCY_LIST.map((c) =>
1993
+ h('button', {
1994
+ key: c.code,
1995
+ type: 'button',
1996
+ className: 'dcm-curr-btn' + (currency === c.symbol || currency === c.code ? ' dcm-curr-btn-active' : ''),
1997
+ onClick: () => handleCurrencySelect(c.symbol),
1998
+ title: c.code,
1999
+ }, c.symbol)
2000
+ )
2001
+ ),
1768
2002
  ]),
1769
2003
 
1770
2004
  h('div', { key: 'note', className: 'dcm-panel-note' },
@@ -1787,11 +2021,11 @@ window.__ModuleLoader__.load({
1787
2021
 
1788
2022
  cacheSavedAmount > 0
1789
2023
  ? h('div', { key: 'cache-saved-box', className: 'dcm-cache-box', style: { marginTop: 6 } },
1790
- t('savings.cache_saved', { amount: money(cacheSavedAmount, currency), percent: cacheSavedPercent }))
2024
+ t('savings.cache_saved', { amount: formatMoney(cacheSavedAmount), percent: cacheSavedPercent }))
1791
2025
  : null,
1792
2026
 
1793
2027
  budgetExceeded ? h('div', { key: 'budget-warn', className: 'dcm-alert dcm-alert-err', style: { marginTop: 6, padding: '6px 10px', fontSize: 11 } },
1794
- t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })) : null,
2028
+ t('budget.exceeded', { limit: formatMoney(budgetThreshold * fx) })) : null,
1795
2029
 
1796
2030
  renderSparkline(turns, {
1797
2031
  h,
@@ -1823,10 +2057,12 @@ window.__ModuleLoader__.load({
1823
2057
  ? tr.cacheHitRate
1824
2058
  : (uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0)
1825
2059
  const cost = Number(tr.costDeltaUsd) || 0
2060
+ const reasoning = Number(tr.reasoningTokens) || 0
2061
+ const isPrivacy = Boolean(stored.privacyMode)
1826
2062
  return h('div', { key: 'tr-' + idx, className: 'dcm-turn-row' }, [
1827
2063
  h('span', { key: 'tn', style: { fontWeight: 500 } }, 'T#' + tr.turn + (tr.step ? '.' + tr.step : '')),
1828
2064
  h('span', { key: 'tok', style: { color: 'var(--dsw-alias-label-secondary)' } }, tokens(tok) + ' tok (' + hitRate + '% hit)'),
1829
- h('span', { key: 'c', style: { fontWeight: 600 } }, cost > 0 ? money(cost * fx, currency) : '—'),
2065
+ h('span', { key: 'c', style: { fontWeight: 600 } }, cost > 0 ? formatMoney(cost * fx) : '—'),
1830
2066
  ])
1831
2067
  })
1832
2068
  ) : null,
@@ -1840,11 +2076,11 @@ window.__ModuleLoader__.load({
1840
2076
  return h('div', { key: 'proj-box', className: 'dcm-proj-box' }, [
1841
2077
  h('span', { key: 'burn', className: 'dcm-proj-item' }, [
1842
2078
  h('span', null, t('projection.burn_rate') + ' '),
1843
- h('span', { className: 'dcm-proj-val' }, money(burnRate, currency) + t('projection.per_hour')),
2079
+ h('span', { className: 'dcm-proj-val' }, formatMoney(burnRate) + t('projection.per_hour')),
1844
2080
  ]),
1845
2081
  h('span', { key: 'proj', className: 'dcm-proj-item' }, [
1846
2082
  h('span', null, t('projection.projected') + ' '),
1847
- h('span', { className: 'dcm-proj-val' }, '≈ ' + money(projectedTotal, currency)),
2083
+ h('span', { className: 'dcm-proj-val' }, '≈ ' + formatMoney(projectedTotal)),
1848
2084
  ]),
1849
2085
  ])
1850
2086
  })(),
@@ -1916,7 +2152,7 @@ window.__ModuleLoader__.load({
1916
2152
  color: m.known ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-tertiary)',
1917
2153
  },
1918
2154
  },
1919
- m.known ? (hasUsage ? money(m.cost, currency) : '—') : t('tokens.not_priced'),
2155
+ m.known ? (hasUsage ? formatMoney(m.cost) : '—') : t('tokens.not_priced'),
1920
2156
  ),
1921
2157
  ],
1922
2158
  ),
@@ -1936,19 +2172,104 @@ window.__ModuleLoader__.load({
1936
2172
 
1937
2173
  h('div', { key: 'total', className: 'dcm-total' }, [
1938
2174
  h('span', { key: 'l' }, t('tokens.session_total')),
1939
- h('span', { key: 'v' }, hasUsage ? '≈ ' + money(total, currency) : '—'),
2175
+ h('span', { key: 'v' }, hasUsage ? '≈ ' + formatMoney(total) : '—'),
1940
2176
  ]),
1941
2177
 
1942
2178
  hasUsage ? null : h('div', { key: 'nodata', className: 'dcm-panel-note', style: { paddingTop: 6 } },
1943
2179
  t('tokens.no_usage_yet')),
1944
2180
 
1945
- h('div', { key: 'footer-actions', style: { display: 'flex', justifyContent: 'flex-end', marginTop: 10 } },
2181
+ h('div', { key: 'footer-actions', className: 'dcm-export-group' }, [
1946
2182
  h('button', {
2183
+ key: 'btn-csv',
2184
+ type: 'button',
2185
+ className: 'dcm-btn dcm-btn-sm',
2186
+ title: t('action.export_csv'),
2187
+ onClick: () => {
2188
+ const csvData = generateSessionCsv({
2189
+ model: activeModelName || (state.route ? state.route.model : 'model'),
2190
+ provider: activeProvider || '',
2191
+ currency,
2192
+ fx,
2193
+ turns,
2194
+ totalCostUsd: totalUsd,
2195
+ })
2196
+ const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' })
2197
+ const link = document.createElement('a')
2198
+ link.href = URL.createObjectURL(blob)
2199
+ link.download = (activeModelName || 'cost-meter') + '-session.csv'
2200
+ link.click()
2201
+ },
2202
+ }, '📥 CSV'),
2203
+
2204
+ h('button', {
2205
+ key: 'btn-json',
2206
+ type: 'button',
2207
+ className: 'dcm-btn dcm-btn-sm',
2208
+ title: t('action.export_json'),
2209
+ onClick: () => {
2210
+ const jsonData = generateSessionJson({
2211
+ model: activeModelName || (state.route ? state.route.model : 'model'),
2212
+ provider: activeProvider || '',
2213
+ currency,
2214
+ fx,
2215
+ turns,
2216
+ totalCostUsd: totalUsd,
2217
+ cacheSavingsUsd: cacheSavedAmount / fx,
2218
+ cacheSavingsPercent: cacheSavedPercent,
2219
+ tier: flat ? 'Flat' : (cheapNow ? t('offpeak') : t('peak')),
2220
+ })
2221
+ const blob = new Blob([JSON.stringify(jsonData, null, 2)], { type: 'application/json' })
2222
+ const link = document.createElement('a')
2223
+ link.href = URL.createObjectURL(blob)
2224
+ link.download = (activeModelName || 'cost-meter') + '-session.json'
2225
+ link.click()
2226
+ },
2227
+ }, '📋 JSON'),
2228
+
2229
+ h('button', {
2230
+ key: 'btn-png',
2231
+ type: 'button',
2232
+ className: 'dcm-btn dcm-btn-sm',
2233
+ title: t('action.export_png'),
2234
+ disabled: exportingPng,
2235
+ onClick: () => {
2236
+ setExportingPng(true)
2237
+ try {
2238
+ const canvas = document.createElement('canvas')
2239
+ renderSummaryCardToCanvas(canvas, {
2240
+ model: activeModelName || (state.route ? state.route.model : 'model'),
2241
+ provider: activeProvider || '',
2242
+ currency,
2243
+ fx,
2244
+ costUsd: totalUsd,
2245
+ tokens: usage || {},
2246
+ cacheSavingsUsd: cacheSavedAmount / fx,
2247
+ cacheSavingsPercent: cacheSavedPercent,
2248
+ tier: flat ? 'Flat Tariff' : (cheapNow ? ('Off-Peak (' + t('offpeak') + ')') : ('Peak Tariff (' + t('peak') + ')')),
2249
+ turns,
2250
+ })
2251
+ canvas.toBlob((blob) => {
2252
+ setExportingPng(false)
2253
+ if (blob) {
2254
+ const link = document.createElement('a')
2255
+ link.href = URL.createObjectURL(blob)
2256
+ link.download = (activeModelName || 'cost-meter') + '-summary.png'
2257
+ link.click()
2258
+ }
2259
+ }, 'image/png')
2260
+ } catch (_) {
2261
+ setExportingPng(false)
2262
+ }
2263
+ },
2264
+ }, exportingPng ? t('action.exporting_png') : '🖼️ PNG'),
2265
+
2266
+ h('button', {
2267
+ key: 'btn-copy',
1947
2268
  type: 'button',
1948
2269
  className: 'dcm-btn dcm-btn-sm',
1949
2270
  onClick: copyReport,
1950
- }, copied ? t('action.copied') : t('action.copy_report'))
1951
- ),
2271
+ }, copied ? t('action.copied') : t('action.copy_report')),
2272
+ ]),
1952
2273
  ]) : null,
1953
2274
  ])
1954
2275
  }
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 ADDED
@@ -0,0 +1,389 @@
1
+ // dsh-cost-meter — session spend export to CSV, JSON, and PNG canvas card (Issue #118).
2
+
3
+ /**
4
+ * Sanitize cell values for CSV and prevent CSV formula injection (CWE-1236).
5
+ * Prepends a single quote if string starts with dangerous characters.
6
+ */
7
+ export function sanitizeCsvCell(value) {
8
+ if (value === null || value === undefined) return ''
9
+ let str = String(value)
10
+ if (/^[=+\-@\t\r]/.test(str)) {
11
+ str = "'" + str
12
+ }
13
+ if (/[",\n\r]/.test(str)) {
14
+ str = '"' + str.replace(/"/g, '""') + '"'
15
+ }
16
+ return str
17
+ }
18
+
19
+ /**
20
+ * Generate formatted CSV with UTF-8 BOM for Excel / spreadsheet compatibility.
21
+ */
22
+ export function generateSessionCsv(data) {
23
+ const {
24
+ model = 'unknown',
25
+ provider = '',
26
+ currency = '$',
27
+ fx = 1,
28
+ turns = [],
29
+ totalCostUsd = 0,
30
+ exportedAt = new Date().toISOString(),
31
+ } = data || {}
32
+
33
+ const headers = [
34
+ 'Timestamp',
35
+ 'ISO_Time',
36
+ 'Turn',
37
+ 'Step',
38
+ 'Model',
39
+ 'Uncached_Input_Tokens',
40
+ 'Cache_Read_Tokens',
41
+ 'Cache_Hit_Rate_%',
42
+ 'Output_Tokens',
43
+ 'Reasoning_Tokens',
44
+ 'Total_Tokens',
45
+ 'Cost_USD',
46
+ 'Cost_Local',
47
+ 'Currency',
48
+ ]
49
+
50
+ const rows = [headers.map(sanitizeCsvCell).join(',')]
51
+
52
+ let sumUncached = 0
53
+ let sumHit = 0
54
+ let sumOut = 0
55
+ let sumReasoning = 0
56
+ let sumTotalTok = 0
57
+ let sumCostUsd = 0
58
+
59
+ for (const t of turns) {
60
+ const ts = t.timestamp ? Number(t.timestamp) : Date.now()
61
+ const iso = new Date(ts).toISOString()
62
+ const uncached = Number(t.uncachedInputTokens) || 0
63
+ const hit = Number(t.cacheReadTokens) || 0
64
+ const out = Number(t.outputTokens) || 0
65
+ const reasoning = Number(t.reasoningTokens) || 0
66
+ const totalTok = uncached + hit + out
67
+ const hitRate = t.cacheHitRate !== undefined
68
+ ? t.cacheHitRate
69
+ : (uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0)
70
+ const costUsd = Number(t.costDeltaUsd) || 0
71
+ const costLocal = costUsd * fx
72
+
73
+ sumUncached += uncached
74
+ sumHit += hit
75
+ sumOut += out
76
+ sumReasoning += reasoning
77
+ sumTotalTok += totalTok
78
+ sumCostUsd += costUsd
79
+
80
+ const row = [
81
+ ts,
82
+ iso,
83
+ t.turn ?? '',
84
+ t.step ?? 0,
85
+ provider ? `${provider}/${model}` : model,
86
+ uncached,
87
+ hit,
88
+ hitRate,
89
+ out,
90
+ reasoning,
91
+ totalTok,
92
+ costUsd.toFixed(6),
93
+ costLocal.toFixed(4),
94
+ currency,
95
+ ]
96
+ rows.push(row.map(sanitizeCsvCell).join(','))
97
+ }
98
+
99
+ // Summary row
100
+ const overallHitRate = sumUncached + sumHit > 0 ? Math.round((sumHit / (sumUncached + sumHit)) * 100) : 0
101
+ const summaryRow = [
102
+ 'TOTAL',
103
+ exportedAt,
104
+ turns.length,
105
+ '',
106
+ provider ? `${provider}/${model}` : model,
107
+ sumUncached,
108
+ sumHit,
109
+ overallHitRate,
110
+ sumOut,
111
+ sumReasoning,
112
+ sumTotalTok,
113
+ (totalCostUsd || sumCostUsd).toFixed(6),
114
+ ((totalCostUsd || sumCostUsd) * fx).toFixed(4),
115
+ currency,
116
+ ]
117
+ rows.push(summaryRow.map(sanitizeCsvCell).join(','))
118
+
119
+ // Prepend UTF-8 BOM (\uFEFF)
120
+ return '\uFEFF' + rows.join('\r\n')
121
+ }
122
+
123
+ /**
124
+ * Generate structured, versioned JSON export.
125
+ */
126
+ export function generateSessionJson(data) {
127
+ const {
128
+ model = 'unknown',
129
+ provider = '',
130
+ currency = '$',
131
+ fx = 1,
132
+ turns = [],
133
+ totalCostUsd = 0,
134
+ cacheSavingsUsd = 0,
135
+ cacheSavingsPercent = 0,
136
+ tier = 'Standard',
137
+ rates = {},
138
+ burnRatePerHourUsd = 0,
139
+ projectedCostUsd = 0,
140
+ exportedAt = new Date().toISOString(),
141
+ } = data || {}
142
+
143
+ let sumUncached = 0
144
+ let sumHit = 0
145
+ let sumOut = 0
146
+ let sumReasoning = 0
147
+
148
+ const turnsFormatted = turns.map((t) => {
149
+ const uncached = Number(t.uncachedInputTokens) || 0
150
+ const hit = Number(t.cacheReadTokens) || 0
151
+ const out = Number(t.outputTokens) || 0
152
+ const reasoning = Number(t.reasoningTokens) || 0
153
+ sumUncached += uncached
154
+ sumHit += hit
155
+ sumOut += out
156
+ sumReasoning += reasoning
157
+ return {
158
+ turn: t.turn,
159
+ step: t.step ?? 0,
160
+ timestamp: t.timestamp,
161
+ tokens: {
162
+ uncachedInput: uncached,
163
+ cacheRead: hit,
164
+ output: out,
165
+ reasoningTokens: reasoning,
166
+ total: uncached + hit + out,
167
+ cacheHitRate: t.cacheHitRate !== undefined ? t.cacheHitRate : (uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0),
168
+ },
169
+ durationMs: t.durationMs || 0,
170
+ costDeltaUsd: Number(t.costDeltaUsd) || 0,
171
+ costDeltaLocal: (Number(t.costDeltaUsd) || 0) * fx,
172
+ }
173
+ })
174
+
175
+ const totalPrompt = sumUncached + sumHit
176
+ const overallHitRate = totalPrompt > 0 ? Math.round((sumHit / totalPrompt) * 100) : 0
177
+
178
+ return {
179
+ version: 1,
180
+ generator: '@goodandready/dsh-cost-meter',
181
+ exportedAt,
182
+ session: {
183
+ model,
184
+ provider,
185
+ currency,
186
+ fxRate: fx,
187
+ totalCostUsd,
188
+ totalCostLocal: totalCostUsd * fx,
189
+ tokens: {
190
+ uncachedInput: sumUncached,
191
+ cacheRead: sumHit,
192
+ output: sumOut,
193
+ reasoningTokens: sumReasoning,
194
+ total: sumUncached + sumHit + sumOut,
195
+ cacheHitRate: overallHitRate,
196
+ },
197
+ cacheSavings: {
198
+ savedUsd: cacheSavingsUsd,
199
+ savedLocal: cacheSavingsUsd * fx,
200
+ percent: cacheSavingsPercent,
201
+ },
202
+ tier,
203
+ rates,
204
+ burnRate: {
205
+ perHourUsd: burnRatePerHourUsd,
206
+ perHourLocal: burnRatePerHourUsd * fx,
207
+ projectedUsd: projectedCostUsd,
208
+ projectedLocal: projectedCostUsd * fx,
209
+ },
210
+ turnsCount: turns.length,
211
+ },
212
+ turns: turnsFormatted,
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Render visual summary card on an HTML5 Canvas (high-res 1200x680, 2x retina).
218
+ */
219
+ export function renderSummaryCardToCanvas(canvas, data) {
220
+ if (!canvas || typeof canvas.getContext !== 'function') return null
221
+
222
+ const width = 1200
223
+ const height = 680
224
+ canvas.width = width
225
+ canvas.height = height
226
+
227
+ const ctx = canvas.getContext('2d')
228
+ if (!ctx) return null
229
+
230
+ const {
231
+ model = 'deepseek-chat',
232
+ provider = 'deepseek',
233
+ currency = '$',
234
+ fx = 1,
235
+ costUsd = 0,
236
+ tokens = {},
237
+ cacheSavingsUsd = 0,
238
+ cacheSavingsPercent = 0,
239
+ tier = 'Off-Peak Tier (-50%)',
240
+ turns = [],
241
+ } = data || {}
242
+
243
+ const totalCost = costUsd * fx
244
+ const costFormatted = data?.privacyMode ? `${currency}****` : (totalCost >= 1 ? `${currency}${totalCost.toFixed(2)}` : (totalCost > 0 ? `${currency}${totalCost.toFixed(4)}` : `${currency}0.00`))
245
+ const uncached = Number(tokens.uncachedInputTokens || tokens.inputTokens) || 0
246
+ const hit = Number(tokens.cacheReadTokens) || 0
247
+ const out = Number(tokens.outputTokens) || 0
248
+ const totalTok = uncached + hit + out
249
+ const hitRate = uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0
250
+ const savedAmt = cacheSavingsUsd * fx
251
+ const savedFormatted = savedAmt >= 1 ? `${currency}${savedAmt.toFixed(2)}` : `${currency}${savedAmt.toFixed(4)}`
252
+
253
+ // Background gradient
254
+ const bgGrad = ctx.createLinearGradient(0, 0, width, height)
255
+ bgGrad.addColorStop(0, '#0f172a') // dark slate 900
256
+ bgGrad.addColorStop(1, '#1e293b') // dark slate 800
257
+ ctx.fillStyle = bgGrad
258
+ ctx.fillRect(0, 0, width, height)
259
+
260
+ // Outer border with subtle neon glow
261
+ ctx.strokeStyle = '#334155'
262
+ ctx.lineWidth = 3
263
+ ctx.strokeRect(20, 20, width - 40, height - 40)
264
+
265
+ // Top header pill
266
+ ctx.fillStyle = '#1e293b'
267
+ ctx.beginPath()
268
+ ctx.roundRect(50, 45, 220, 44, 22)
269
+ ctx.fill()
270
+ ctx.strokeStyle = '#3b82f6'
271
+ ctx.lineWidth = 2
272
+ ctx.stroke()
273
+
274
+ ctx.fillStyle = '#38bdf8'
275
+ ctx.font = 'bold 20px system-ui, -apple-system, sans-serif'
276
+ ctx.fillText('⚡ Cost Meter', 75, 74)
277
+
278
+ // Date / timestamp
279
+ ctx.fillStyle = '#94a3b8'
280
+ ctx.font = '18px system-ui, -apple-system, sans-serif'
281
+ ctx.textAlign = 'right'
282
+ ctx.fillText(new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }), width - 60, 74)
283
+ ctx.textAlign = 'left'
284
+
285
+ // Model Name & Provider
286
+ ctx.fillStyle = '#f8fafc'
287
+ ctx.font = 'bold 36px system-ui, -apple-system, sans-serif'
288
+ const routeStr = provider ? `${provider}/${model}` : model
289
+ ctx.fillText(routeStr, 50, 145)
290
+
291
+ // Tariff Tier badge
292
+ ctx.fillStyle = tier.includes('Off-Peak') ? '#10b981' : '#f59e0b'
293
+ ctx.font = 'bold 20px system-ui, -apple-system, sans-serif'
294
+ ctx.fillText(tier, 50, 180)
295
+
296
+ // 4 Metric Tiles
297
+ const tiles = [
298
+ { label: 'SESSION SPEND', val: costFormatted, color: '#38bdf8' },
299
+ { label: 'TOTAL TOKENS', val: totalTok.toLocaleString(), color: '#f8fafc' },
300
+ { label: 'CACHE HIT RATE', val: `${hitRate}%`, color: '#34d399' },
301
+ { label: 'CACHE SAVED', val: `≈ ${savedFormatted} (${cacheSavingsPercent}%)`, color: '#a78bfa' },
302
+ ]
303
+
304
+ const tileW = 250
305
+ const tileH = 110
306
+ const startX = 50
307
+ const tileY = 215
308
+
309
+ tiles.forEach((t, i) => {
310
+ const x = startX + i * (tileW + 20)
311
+ ctx.fillStyle = 'rgba(30, 41, 59, 0.7)'
312
+ ctx.beginPath()
313
+ ctx.roundRect(x, tileY, tileW, tileH, 12)
314
+ ctx.fill()
315
+ ctx.strokeStyle = '#334155'
316
+ ctx.lineWidth = 1.5
317
+ ctx.stroke()
318
+
319
+ ctx.fillStyle = '#94a3b8'
320
+ ctx.font = 'bold 14px system-ui, -apple-system, sans-serif'
321
+ ctx.fillText(t.label, x + 16, tileY + 36)
322
+
323
+ ctx.fillStyle = t.color
324
+ ctx.font = 'bold 26px system-ui, -apple-system, sans-serif'
325
+ ctx.fillText(t.val, x + 16, tileY + 80)
326
+ })
327
+
328
+ // Turns mini-chart
329
+ const chartY = 360
330
+ const chartH = 190
331
+ const chartW = width - 100
332
+ ctx.fillStyle = 'rgba(15, 23, 42, 0.6)'
333
+ ctx.beginPath()
334
+ ctx.roundRect(50, chartY, chartW, chartH, 12)
335
+ ctx.fill()
336
+ ctx.strokeStyle = '#334155'
337
+ ctx.lineWidth = 1.5
338
+ ctx.stroke()
339
+
340
+ ctx.fillStyle = '#64748b'
341
+ ctx.font = 'bold 15px system-ui, -apple-system, sans-serif'
342
+ ctx.fillText('TURN HISTORY & STEP DYNAMICS', 70, chartY + 35)
343
+
344
+ const activeTurns = turns.slice(-25)
345
+ if (activeTurns.length > 0) {
346
+ const maxTok = Math.max(...activeTurns.map((t) => Number(t.tokens) || 1), 100)
347
+ const barW = Math.min(32, Math.floor((chartW - 60) / activeTurns.length) - 6)
348
+ const baseY = chartY + chartH - 30
349
+ const availH = 100
350
+
351
+ activeTurns.forEach((t, i) => {
352
+ const x = 70 + i * (barW + 8)
353
+ const tok = Number(t.tokens) || 0
354
+ const h = Math.max(6, Math.round((tok / maxTok) * availH))
355
+ const y = baseY - h
356
+
357
+ // Bar fill with cache hit proportion
358
+ ctx.fillStyle = '#3b82f6'
359
+ ctx.beginPath()
360
+ ctx.roundRect(x, y, barW, h, 4)
361
+ ctx.fill()
362
+
363
+ const hitTokens = Number(t.cacheReadTokens) || 0
364
+ if (hitTokens > 0) {
365
+ const hitH = Math.round((hitTokens / tok) * h)
366
+ ctx.fillStyle = '#10b981'
367
+ ctx.beginPath()
368
+ ctx.roundRect(x, baseY - hitH, barW, hitH, 4)
369
+ ctx.fill()
370
+ }
371
+ })
372
+ } else {
373
+ ctx.fillStyle = '#64748b'
374
+ ctx.font = '16px system-ui, -apple-system, sans-serif'
375
+ ctx.fillText('No turn history recorded yet.', 70, chartY + 100)
376
+ }
377
+
378
+ // Footer
379
+ ctx.fillStyle = '#64748b'
380
+ ctx.font = '16px system-ui, -apple-system, sans-serif'
381
+ ctx.fillText('Generated by @goodandready/dsh-cost-meter', 50, height - 35)
382
+
383
+ ctx.fillStyle = '#475569'
384
+ ctx.textAlign = 'right'
385
+ ctx.fillText('goodandready.app', width - 50, height - 35)
386
+ ctx.textAlign = 'left'
387
+
388
+ return canvas
389
+ }
package/lib/index.js CHANGED
@@ -32,6 +32,7 @@ export * from './projection.js'
32
32
  export * from './fx.js'
33
33
  export * from './alerts.js'
34
34
  export * from './commands.js'
35
+ export * from './export.js'
35
36
  export { STATE_PATH, REFRESH_PATH } from './routes.js'
36
37
  import { indexCatalog, resolveTariff, matchModel, unwrapVolatile, calculateCacheSavings } from './tariff.js'
37
38
  import { usageByModelProjection, getLastTurns, resetLastTurns } from './projection.js'
@@ -130,6 +131,9 @@ export function normalizeConfig(raw) {
130
131
  alertBeforeSwitch: u.alertBeforeSwitch !== false,
131
132
  alertLeadMinutes: Math.max(1, Math.min(30, Number(u.alertLeadMinutes) || 5)),
132
133
  browserNotifications: Boolean(u.browserNotifications),
134
+ privacyMode: Boolean(u.privacyMode),
135
+ showHeaderChip: u.showHeaderChip !== false,
136
+ showMessageAction: u.showMessageAction !== false,
133
137
  useOpenRouter: u.useOpenRouter !== false,
134
138
  refreshHours: Number(u.refreshHours) || 24,
135
139
  deepseekPeakPrices: u.deepseekPeakPrices || {},
@@ -161,6 +165,9 @@ export const Config = z.object({
161
165
  alertBeforeSwitch: z.boolean().description('Pre-switch notification and visual alert before tariff change.').default(true).volatile(),
162
166
  alertLeadMinutes: z.number().description('Minutes before tariff change to trigger pre-switch alert.').default(5).volatile(),
163
167
  browserNotifications: z.boolean().description('Enable desktop browser notifications for tariff switches.').default(false).volatile(),
168
+ privacyMode: z.boolean().description('Streamer/Privacy mode: mask financial spend with asterisks while retaining token stats.').default(false).volatile(),
169
+ showHeaderChip: z.boolean().description('Show the cost meter chip in the conversation header.').default(true).volatile(),
170
+ showMessageAction: z.boolean().description('Show the token inspector action button on assistant messages.').default(true).volatile(),
164
171
  deepseekPeakPrices: z.dict(PriceRow).description('Override the built-in DeepSeek peak rates, by model id.').default({}).volatile(),
165
172
  useOpenRouter: z.boolean().description('Pull rates and discount windows from the public OpenRouter catalog.').default(true).volatile(),
166
173
  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
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto'
3
3
  import { registerPluginUpdater } from './updater.js'
4
4
  import { getLastTurns } from './projection.js'
5
5
  import { calculateBurnRateAndProjection } from './analytics.js'
6
+ import { getCachedFxState } from './fx.js'
6
7
 
7
8
  export const STATE_PATH = '/dsh-cost-meter/state'
8
9
  export const REFRESH_PATH = '/dsh-cost-meter/refresh'
@@ -107,8 +108,12 @@ export function registerRoutes(ctx, options) {
107
108
  const payloadObj = {
108
109
  currency: typeof cfg?.currency === 'string' && cfg.currency ? cfg.currency : '$',
109
110
  usdRate: Number(cfg?.usdRate) > 0 ? Number(cfg.usdRate) : 1,
111
+ fxRates: { USD: 1, ...(getCachedFxState().rates || {}) },
110
112
  displayTimeZone: typeof cfg?.displayTimeZone === 'string' && cfg.displayTimeZone ? cfg.displayTimeZone : 'Europe/Moscow',
111
113
  budgetThreshold: Number(cfg?.budgetThreshold) || 0,
114
+ privacyMode: Boolean(cfg?.privacyMode),
115
+ showHeaderChip: cfg?.showHeaderChip !== false,
116
+ showMessageAction: cfg?.showMessageAction !== false,
112
117
  displayMode: cfg?.displayMode || 'auto',
113
118
  alertBeforeSwitch: cfg?.alertBeforeSwitch !== false,
114
119
  alertLeadMinutes: Number(cfg?.alertLeadMinutes) || 5,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cost-meter",
3
- "version": "0.8.26",
3
+ "version": "0.8.28",
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",