@goodandready/dsh-cost-meter 0.8.26 → 0.8.27

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/export.js ADDED
@@ -0,0 +1,378 @@
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
+ 'Total_Tokens',
44
+ 'Cost_USD',
45
+ 'Cost_Local',
46
+ 'Currency',
47
+ ]
48
+
49
+ const rows = [headers.map(sanitizeCsvCell).join(',')]
50
+
51
+ let sumUncached = 0
52
+ let sumHit = 0
53
+ let sumOut = 0
54
+ let sumTotalTok = 0
55
+ let sumCostUsd = 0
56
+
57
+ for (const t of turns) {
58
+ const ts = t.timestamp ? Number(t.timestamp) : Date.now()
59
+ const iso = new Date(ts).toISOString()
60
+ const uncached = Number(t.uncachedInputTokens) || 0
61
+ const hit = Number(t.cacheReadTokens) || 0
62
+ const out = Number(t.outputTokens) || 0
63
+ const totalTok = uncached + hit + out
64
+ const hitRate = t.cacheHitRate !== undefined
65
+ ? t.cacheHitRate
66
+ : (uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0)
67
+ const costUsd = Number(t.costDeltaUsd) || 0
68
+ const costLocal = costUsd * fx
69
+
70
+ sumUncached += uncached
71
+ sumHit += hit
72
+ sumOut += out
73
+ sumTotalTok += totalTok
74
+ sumCostUsd += costUsd
75
+
76
+ const row = [
77
+ ts,
78
+ iso,
79
+ t.turn ?? '',
80
+ t.step ?? 0,
81
+ provider ? `${provider}/${model}` : model,
82
+ uncached,
83
+ hit,
84
+ hitRate,
85
+ out,
86
+ totalTok,
87
+ costUsd.toFixed(6),
88
+ costLocal.toFixed(4),
89
+ currency,
90
+ ]
91
+ rows.push(row.map(sanitizeCsvCell).join(','))
92
+ }
93
+
94
+ // Summary row
95
+ const overallHitRate = sumUncached + sumHit > 0 ? Math.round((sumHit / (sumUncached + sumHit)) * 100) : 0
96
+ const summaryRow = [
97
+ 'TOTAL',
98
+ exportedAt,
99
+ turns.length,
100
+ '',
101
+ provider ? `${provider}/${model}` : model,
102
+ sumUncached,
103
+ sumHit,
104
+ overallHitRate,
105
+ sumOut,
106
+ sumTotalTok,
107
+ (totalCostUsd || sumCostUsd).toFixed(6),
108
+ ((totalCostUsd || sumCostUsd) * fx).toFixed(4),
109
+ currency,
110
+ ]
111
+ rows.push(summaryRow.map(sanitizeCsvCell).join(','))
112
+
113
+ // Prepend UTF-8 BOM (\uFEFF)
114
+ return '\uFEFF' + rows.join('\r\n')
115
+ }
116
+
117
+ /**
118
+ * Generate structured, versioned JSON export.
119
+ */
120
+ export function generateSessionJson(data) {
121
+ const {
122
+ model = 'unknown',
123
+ provider = '',
124
+ currency = '$',
125
+ fx = 1,
126
+ turns = [],
127
+ totalCostUsd = 0,
128
+ cacheSavingsUsd = 0,
129
+ cacheSavingsPercent = 0,
130
+ tier = 'Standard',
131
+ rates = {},
132
+ burnRatePerHourUsd = 0,
133
+ projectedCostUsd = 0,
134
+ exportedAt = new Date().toISOString(),
135
+ } = data || {}
136
+
137
+ let sumUncached = 0
138
+ let sumHit = 0
139
+ let sumOut = 0
140
+
141
+ const turnsFormatted = turns.map((t) => {
142
+ const uncached = Number(t.uncachedInputTokens) || 0
143
+ const hit = Number(t.cacheReadTokens) || 0
144
+ const out = Number(t.outputTokens) || 0
145
+ sumUncached += uncached
146
+ sumHit += hit
147
+ sumOut += out
148
+ return {
149
+ turn: t.turn,
150
+ step: t.step ?? 0,
151
+ timestamp: t.timestamp,
152
+ tokens: {
153
+ uncachedInput: uncached,
154
+ cacheRead: hit,
155
+ output: out,
156
+ total: uncached + hit + out,
157
+ cacheHitRate: t.cacheHitRate !== undefined ? t.cacheHitRate : (uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0),
158
+ },
159
+ durationMs: t.durationMs || 0,
160
+ costDeltaUsd: Number(t.costDeltaUsd) || 0,
161
+ costDeltaLocal: (Number(t.costDeltaUsd) || 0) * fx,
162
+ }
163
+ })
164
+
165
+ const totalPrompt = sumUncached + sumHit
166
+ const overallHitRate = totalPrompt > 0 ? Math.round((sumHit / totalPrompt) * 100) : 0
167
+
168
+ return {
169
+ version: 1,
170
+ generator: '@goodandready/dsh-cost-meter',
171
+ exportedAt,
172
+ session: {
173
+ model,
174
+ provider,
175
+ currency,
176
+ fxRate: fx,
177
+ totalCostUsd,
178
+ totalCostLocal: totalCostUsd * fx,
179
+ tokens: {
180
+ uncachedInput: sumUncached,
181
+ cacheRead: sumHit,
182
+ output: sumOut,
183
+ total: sumUncached + sumHit + sumOut,
184
+ cacheHitRate: overallHitRate,
185
+ },
186
+ cacheSavings: {
187
+ savedUsd: cacheSavingsUsd,
188
+ savedLocal: cacheSavingsUsd * fx,
189
+ percent: cacheSavingsPercent,
190
+ },
191
+ tier,
192
+ rates,
193
+ burnRate: {
194
+ perHourUsd: burnRatePerHourUsd,
195
+ perHourLocal: burnRatePerHourUsd * fx,
196
+ projectedUsd: projectedCostUsd,
197
+ projectedLocal: projectedCostUsd * fx,
198
+ },
199
+ turnsCount: turns.length,
200
+ },
201
+ turns: turnsFormatted,
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Render visual summary card on an HTML5 Canvas (high-res 1200x680, 2x retina).
207
+ */
208
+ export function renderSummaryCardToCanvas(canvas, data) {
209
+ if (!canvas || typeof canvas.getContext !== 'function') return null
210
+
211
+ const width = 1200
212
+ const height = 680
213
+ canvas.width = width
214
+ canvas.height = height
215
+
216
+ const ctx = canvas.getContext('2d')
217
+ if (!ctx) return null
218
+
219
+ const {
220
+ model = 'deepseek-chat',
221
+ provider = 'deepseek',
222
+ currency = '$',
223
+ fx = 1,
224
+ costUsd = 0,
225
+ tokens = {},
226
+ cacheSavingsUsd = 0,
227
+ cacheSavingsPercent = 0,
228
+ tier = 'Off-Peak Tier (-50%)',
229
+ turns = [],
230
+ } = data || {}
231
+
232
+ const totalCost = costUsd * fx
233
+ const costFormatted = totalCost >= 1 ? `${currency}${totalCost.toFixed(2)}` : (totalCost > 0 ? `${currency}${totalCost.toFixed(4)}` : `${currency}0.00`)
234
+ const uncached = Number(tokens.uncachedInputTokens || tokens.inputTokens) || 0
235
+ const hit = Number(tokens.cacheReadTokens) || 0
236
+ const out = Number(tokens.outputTokens) || 0
237
+ const totalTok = uncached + hit + out
238
+ const hitRate = uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0
239
+ const savedAmt = cacheSavingsUsd * fx
240
+ const savedFormatted = savedAmt >= 1 ? `${currency}${savedAmt.toFixed(2)}` : `${currency}${savedAmt.toFixed(4)}`
241
+
242
+ // Background gradient
243
+ const bgGrad = ctx.createLinearGradient(0, 0, width, height)
244
+ bgGrad.addColorStop(0, '#0f172a') // dark slate 900
245
+ bgGrad.addColorStop(1, '#1e293b') // dark slate 800
246
+ ctx.fillStyle = bgGrad
247
+ ctx.fillRect(0, 0, width, height)
248
+
249
+ // Outer border with subtle neon glow
250
+ ctx.strokeStyle = '#334155'
251
+ ctx.lineWidth = 3
252
+ ctx.strokeRect(20, 20, width - 40, height - 40)
253
+
254
+ // Top header pill
255
+ ctx.fillStyle = '#1e293b'
256
+ ctx.beginPath()
257
+ ctx.roundRect(50, 45, 220, 44, 22)
258
+ ctx.fill()
259
+ ctx.strokeStyle = '#3b82f6'
260
+ ctx.lineWidth = 2
261
+ ctx.stroke()
262
+
263
+ ctx.fillStyle = '#38bdf8'
264
+ ctx.font = 'bold 20px system-ui, -apple-system, sans-serif'
265
+ ctx.fillText('⚡ Cost Meter', 75, 74)
266
+
267
+ // Date / timestamp
268
+ ctx.fillStyle = '#94a3b8'
269
+ ctx.font = '18px system-ui, -apple-system, sans-serif'
270
+ ctx.textAlign = 'right'
271
+ ctx.fillText(new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }), width - 60, 74)
272
+ ctx.textAlign = 'left'
273
+
274
+ // Model Name & Provider
275
+ ctx.fillStyle = '#f8fafc'
276
+ ctx.font = 'bold 36px system-ui, -apple-system, sans-serif'
277
+ const routeStr = provider ? `${provider}/${model}` : model
278
+ ctx.fillText(routeStr, 50, 145)
279
+
280
+ // Tariff Tier badge
281
+ ctx.fillStyle = tier.includes('Off-Peak') ? '#10b981' : '#f59e0b'
282
+ ctx.font = 'bold 20px system-ui, -apple-system, sans-serif'
283
+ ctx.fillText(tier, 50, 180)
284
+
285
+ // 4 Metric Tiles
286
+ const tiles = [
287
+ { label: 'SESSION SPEND', val: costFormatted, color: '#38bdf8' },
288
+ { label: 'TOTAL TOKENS', val: totalTok.toLocaleString(), color: '#f8fafc' },
289
+ { label: 'CACHE HIT RATE', val: `${hitRate}%`, color: '#34d399' },
290
+ { label: 'CACHE SAVED', val: `≈ ${savedFormatted} (${cacheSavingsPercent}%)`, color: '#a78bfa' },
291
+ ]
292
+
293
+ const tileW = 250
294
+ const tileH = 110
295
+ const startX = 50
296
+ const tileY = 215
297
+
298
+ tiles.forEach((t, i) => {
299
+ const x = startX + i * (tileW + 20)
300
+ ctx.fillStyle = 'rgba(30, 41, 59, 0.7)'
301
+ ctx.beginPath()
302
+ ctx.roundRect(x, tileY, tileW, tileH, 12)
303
+ ctx.fill()
304
+ ctx.strokeStyle = '#334155'
305
+ ctx.lineWidth = 1.5
306
+ ctx.stroke()
307
+
308
+ ctx.fillStyle = '#94a3b8'
309
+ ctx.font = 'bold 14px system-ui, -apple-system, sans-serif'
310
+ ctx.fillText(t.label, x + 16, tileY + 36)
311
+
312
+ ctx.fillStyle = t.color
313
+ ctx.font = 'bold 26px system-ui, -apple-system, sans-serif'
314
+ ctx.fillText(t.val, x + 16, tileY + 80)
315
+ })
316
+
317
+ // Turns mini-chart
318
+ const chartY = 360
319
+ const chartH = 190
320
+ const chartW = width - 100
321
+ ctx.fillStyle = 'rgba(15, 23, 42, 0.6)'
322
+ ctx.beginPath()
323
+ ctx.roundRect(50, chartY, chartW, chartH, 12)
324
+ ctx.fill()
325
+ ctx.strokeStyle = '#334155'
326
+ ctx.lineWidth = 1.5
327
+ ctx.stroke()
328
+
329
+ ctx.fillStyle = '#64748b'
330
+ ctx.font = 'bold 15px system-ui, -apple-system, sans-serif'
331
+ ctx.fillText('TURN HISTORY & STEP DYNAMICS', 70, chartY + 35)
332
+
333
+ const activeTurns = turns.slice(-25)
334
+ if (activeTurns.length > 0) {
335
+ const maxTok = Math.max(...activeTurns.map((t) => Number(t.tokens) || 1), 100)
336
+ const barW = Math.min(32, Math.floor((chartW - 60) / activeTurns.length) - 6)
337
+ const baseY = chartY + chartH - 30
338
+ const availH = 100
339
+
340
+ activeTurns.forEach((t, i) => {
341
+ const x = 70 + i * (barW + 8)
342
+ const tok = Number(t.tokens) || 0
343
+ const h = Math.max(6, Math.round((tok / maxTok) * availH))
344
+ const y = baseY - h
345
+
346
+ // Bar fill with cache hit proportion
347
+ ctx.fillStyle = '#3b82f6'
348
+ ctx.beginPath()
349
+ ctx.roundRect(x, y, barW, h, 4)
350
+ ctx.fill()
351
+
352
+ const hitTokens = Number(t.cacheReadTokens) || 0
353
+ if (hitTokens > 0) {
354
+ const hitH = Math.round((hitTokens / tok) * h)
355
+ ctx.fillStyle = '#10b981'
356
+ ctx.beginPath()
357
+ ctx.roundRect(x, baseY - hitH, barW, hitH, 4)
358
+ ctx.fill()
359
+ }
360
+ })
361
+ } else {
362
+ ctx.fillStyle = '#64748b'
363
+ ctx.font = '16px system-ui, -apple-system, sans-serif'
364
+ ctx.fillText('No turn history recorded yet.', 70, chartY + 100)
365
+ }
366
+
367
+ // Footer
368
+ ctx.fillStyle = '#64748b'
369
+ ctx.font = '16px system-ui, -apple-system, sans-serif'
370
+ ctx.fillText('Generated by @goodandready/dsh-cost-meter', 50, height - 35)
371
+
372
+ ctx.fillStyle = '#475569'
373
+ ctx.textAlign = 'right'
374
+ ctx.fillText('goodandready.app', width - 50, height - 35)
375
+ ctx.textAlign = 'left'
376
+
377
+ return canvas
378
+ }
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'
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,6 +108,7 @@ 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,
112
114
  displayMode: cfg?.displayMode || 'auto',
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.27",
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",