@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/client.js +2329 -2058
- package/lib/export.js +378 -0
- package/lib/index.js +1 -0
- package/lib/routes.js +2 -0
- package/package.json +1 -1
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.
|
|
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",
|