@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 +345 -24
- package/lib/commands.js +4 -0
- package/lib/export.js +389 -0
- package/lib/index.js +7 -0
- package/lib/projection.js +12 -0
- package/lib/routes.js +5 -0
- package/package.json +1 -1
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
|
|
1533
|
-
|
|
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' },
|
|
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 ?
|
|
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: ≈ ' +
|
|
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:
|
|
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:
|
|
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 ? '≈ ' +
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 ?
|
|
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' },
|
|
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' }, '≈ ' +
|
|
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 ?
|
|
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 ? '≈ ' +
|
|
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',
|
|
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.
|
|
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",
|