@goodandready/dsh-cost-meter 0.8.25 โ 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/alerts.js +100 -0
- package/lib/client.js +608 -7
- package/lib/commands.js +260 -0
- package/lib/export.js +378 -0
- package/lib/index.js +29 -22
- package/lib/projection.js +19 -0
- package/lib/routes.js +5 -0
- package/package.json +1 -1
package/lib/commands.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// dsh-cost-meter โ slash command /cost and receipt generator (Issue #125).
|
|
2
|
+
|
|
3
|
+
export function formatNumber(num) {
|
|
4
|
+
if (typeof num !== 'number' || Number.isNaN(num)) return '0'
|
|
5
|
+
return num.toLocaleString('en-US')
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function formatMoney(amount, currency = '$') {
|
|
9
|
+
const n = Number(amount) || 0
|
|
10
|
+
if (n === 0) return `${currency}0.00`
|
|
11
|
+
if (n < 0.0001) return `${currency}${n.toExponential(2)}`
|
|
12
|
+
if (n < 0.1) return `${currency}${n.toFixed(4)}`
|
|
13
|
+
return `${currency}${n.toFixed(2)}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Format structured receipt markdown from session spend stats.
|
|
18
|
+
*/
|
|
19
|
+
export function formatCostReceipt(data) {
|
|
20
|
+
const {
|
|
21
|
+
model = 'unknown',
|
|
22
|
+
provider = '',
|
|
23
|
+
currency = '$',
|
|
24
|
+
fx = 1,
|
|
25
|
+
costUsd = 0,
|
|
26
|
+
tokens = {},
|
|
27
|
+
cacheSavingsUsd = 0,
|
|
28
|
+
cacheSavingsPercent = 0,
|
|
29
|
+
tier = 'Standard',
|
|
30
|
+
rates = {},
|
|
31
|
+
burnRatePerHourUsd = 0,
|
|
32
|
+
projectedCostUsd = 0,
|
|
33
|
+
turnsCount = 0,
|
|
34
|
+
} = data || {}
|
|
35
|
+
|
|
36
|
+
const totalCost = costUsd * fx
|
|
37
|
+
const uncachedInput = Number(tokens.uncachedInputTokens || tokens.inputTokens) || 0
|
|
38
|
+
const output = Number(tokens.outputTokens) || 0
|
|
39
|
+
const cacheRead = Number(tokens.cacheReadTokens) || 0
|
|
40
|
+
const cacheWrite = Number(tokens.cacheWriteTokens) || 0
|
|
41
|
+
const totalTokens = uncachedInput + output + cacheRead + cacheWrite
|
|
42
|
+
|
|
43
|
+
const totalPromptTokens = uncachedInput + cacheRead
|
|
44
|
+
const cacheHitRate = totalPromptTokens > 0
|
|
45
|
+
? Math.round((cacheRead / totalPromptTokens) * 100)
|
|
46
|
+
: 0
|
|
47
|
+
|
|
48
|
+
const routeLabel = provider ? `${provider}/${model}` : model
|
|
49
|
+
|
|
50
|
+
const lines = [
|
|
51
|
+
`### ๐งพ Cost Meter โ Session Spend Receipt`,
|
|
52
|
+
``,
|
|
53
|
+
`| Metric | Value |`,
|
|
54
|
+
`| :--- | :--- |`,
|
|
55
|
+
`| **Model** | \`${routeLabel}\` |`,
|
|
56
|
+
`| **Session Spend** | **${formatMoney(totalCost, currency)}** |`,
|
|
57
|
+
`| **Total Tokens** | ${formatNumber(totalTokens)} (${turnsCount} ${turnsCount === 1 ? 'turn' : 'turns'}) |`,
|
|
58
|
+
`| **Uncached Input** | ${formatNumber(uncachedInput)} |`,
|
|
59
|
+
`| **Prompt Cache Read** | ${formatNumber(cacheRead)} (${cacheHitRate}% hit rate) |`,
|
|
60
|
+
`| **Output Tokens** | ${formatNumber(output)} |`,
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
if (cacheWrite > 0) {
|
|
64
|
+
lines.push(`| **Cache Write** | ${formatNumber(cacheWrite)} |`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (cacheSavingsUsd > 0) {
|
|
68
|
+
lines.push(`| **Cache Savings** | **โ ${formatMoney(cacheSavingsUsd * fx, currency)}** (${cacheSavingsPercent}% saved) |`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
lines.push(`| **Tariff Tier** | ${tier} |`)
|
|
72
|
+
|
|
73
|
+
if (rates.input !== undefined) {
|
|
74
|
+
const inR = rates.input ? `$${rates.input}/1M` : 'โ'
|
|
75
|
+
const outR = rates.output ? `$${rates.output}/1M` : 'โ'
|
|
76
|
+
const hitR = rates.cacheHit ? `$${rates.cacheHit}/1M` : 'โ'
|
|
77
|
+
lines.push(`| **Rates (1M tok)** | In ${inR} ยท Out ${outR} ยท Cache ${hitR} |`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (burnRatePerHourUsd > 0) {
|
|
81
|
+
lines.push(`| **Burn Rate** | โ ${formatMoney(burnRatePerHourUsd * fx, currency)}/hr (proj. ${formatMoney(projectedCostUsd * fx, currency)}) |`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return lines.join('\n')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Format compact 1-line or 2-line summary.
|
|
89
|
+
*/
|
|
90
|
+
export function formatCostSummary(data) {
|
|
91
|
+
const {
|
|
92
|
+
model = 'model',
|
|
93
|
+
currency = '$',
|
|
94
|
+
fx = 1,
|
|
95
|
+
costUsd = 0,
|
|
96
|
+
tokens = {},
|
|
97
|
+
cacheSavingsPercent = 0,
|
|
98
|
+
} = data || {}
|
|
99
|
+
|
|
100
|
+
const totalCost = costUsd * fx
|
|
101
|
+
const uncachedInput = Number(tokens.uncachedInputTokens || tokens.inputTokens) || 0
|
|
102
|
+
const output = Number(tokens.outputTokens) || 0
|
|
103
|
+
const cacheRead = Number(tokens.cacheReadTokens) || 0
|
|
104
|
+
const totalTokens = uncachedInput + output + cacheRead
|
|
105
|
+
const totalPrompt = uncachedInput + cacheRead
|
|
106
|
+
const cacheHit = totalPrompt > 0 ? Math.round((cacheRead / totalPrompt) * 100) : 0
|
|
107
|
+
|
|
108
|
+
return `๐ฐ **Cost Meter**: โ ${formatMoney(totalCost, currency)} ยท ${formatNumber(totalTokens)} tokens (${cacheHit}% cache hit) ยท \`${model}\``
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Register DSH slash command /cost and /cost-summary.
|
|
113
|
+
*/
|
|
114
|
+
export function registerSlashCommand(ctx, options) {
|
|
115
|
+
if (typeof ctx?.inject !== 'function') return
|
|
116
|
+
const {
|
|
117
|
+
getCurrentConfig,
|
|
118
|
+
getCachedTariff,
|
|
119
|
+
getLastTurns,
|
|
120
|
+
resetLastTurns,
|
|
121
|
+
calculateBurnRateAndProjection,
|
|
122
|
+
calculateCacheSavings,
|
|
123
|
+
ratesAt,
|
|
124
|
+
splitRoute,
|
|
125
|
+
defaultRoute,
|
|
126
|
+
} = options || {}
|
|
127
|
+
|
|
128
|
+
ctx.inject(['commands'], (cmdCtx) => {
|
|
129
|
+
const commands = cmdCtx.commands
|
|
130
|
+
if (typeof commands?.register !== 'function') return
|
|
131
|
+
|
|
132
|
+
const executeCommand = async (rawArgs) => {
|
|
133
|
+
try {
|
|
134
|
+
const subcmd = String(rawArgs || '').trim().toLowerCase().split(/\s+/)[0] || 'receipt'
|
|
135
|
+
const cfg = typeof getCurrentConfig === 'function' ? getCurrentConfig() : {}
|
|
136
|
+
const currency = cfg.currency || '$'
|
|
137
|
+
const fx = Number(cfg.usdRate) || 1
|
|
138
|
+
const route = defaultRoute ? (defaultRoute(ctx) || { model: 'default' }) : { model: 'default' }
|
|
139
|
+
const tariff = typeof getCachedTariff === 'function' ? getCachedTariff(cfg, route) : null
|
|
140
|
+
const schedule = tariff?.schedule
|
|
141
|
+
const minute = (new Date().getUTCHours() * 60 + new Date().getUTCMinutes()) % 1440
|
|
142
|
+
const activeRates = (schedule && typeof ratesAt === 'function')
|
|
143
|
+
? ratesAt(schedule, minute)
|
|
144
|
+
: (schedule?.base || {})
|
|
145
|
+
|
|
146
|
+
const turns = typeof getLastTurns === 'function' ? getLastTurns() : []
|
|
147
|
+
|
|
148
|
+
if (subcmd === 'reset') {
|
|
149
|
+
if (typeof resetLastTurns === 'function') resetLastTurns()
|
|
150
|
+
return 'โจ Cost Meter: session turn spend history has been reset.'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (subcmd === 'help') {
|
|
154
|
+
return [
|
|
155
|
+
'**Cost Meter Commands**:',
|
|
156
|
+
'- `/cost` or `/cost receipt`: generate detailed markdown spend receipt for this session',
|
|
157
|
+
'- `/cost summary`: compact spend and token usage summary',
|
|
158
|
+
'- `/cost reset`: reset per-session turn counter',
|
|
159
|
+
'- `/cost help`: show this help message',
|
|
160
|
+
].join('\n')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Aggregate tokens across turns
|
|
164
|
+
let totalUncachedInput = 0
|
|
165
|
+
let totalOutput = 0
|
|
166
|
+
let totalCacheRead = 0
|
|
167
|
+
let totalCacheWrite = 0
|
|
168
|
+
let totalCostUsd = 0
|
|
169
|
+
|
|
170
|
+
for (const t of turns) {
|
|
171
|
+
totalUncachedInput += Number(t.uncachedInputTokens) || 0
|
|
172
|
+
totalOutput += Number(t.outputTokens) || 0
|
|
173
|
+
totalCacheRead += Number(t.cacheReadTokens) || 0
|
|
174
|
+
totalCacheWrite += Number(t.cacheWriteTokens) || 0
|
|
175
|
+
totalCostUsd += Number(t.costDeltaUsd) || 0
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const tokensObj = {
|
|
179
|
+
uncachedInputTokens: totalUncachedInput,
|
|
180
|
+
outputTokens: totalOutput,
|
|
181
|
+
cacheReadTokens: totalCacheRead,
|
|
182
|
+
cacheWriteTokens: totalCacheWrite,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const savings = (typeof calculateCacheSavings === 'function' && activeRates)
|
|
186
|
+
? calculateCacheSavings(tokensObj, activeRates)
|
|
187
|
+
: { savedUsd: 0, percent: 0 }
|
|
188
|
+
|
|
189
|
+
const analytics = typeof calculateBurnRateAndProjection === 'function'
|
|
190
|
+
? calculateBurnRateAndProjection(turns, totalCostUsd)
|
|
191
|
+
: { burnRatePerHourUsd: 0, projectedCostUsd: 0 }
|
|
192
|
+
|
|
193
|
+
const payload = {
|
|
194
|
+
model: route.model || 'active model',
|
|
195
|
+
provider: route.provider || '',
|
|
196
|
+
currency,
|
|
197
|
+
fx,
|
|
198
|
+
costUsd: totalCostUsd,
|
|
199
|
+
tokens: tokensObj,
|
|
200
|
+
cacheSavingsUsd: savings.savedUsd,
|
|
201
|
+
cacheSavingsPercent: savings.percent,
|
|
202
|
+
tier: tariff?.source === 'deepseek' ? 'DeepSeek Tariff' : (tariff?.source || 'Standard'),
|
|
203
|
+
rates: activeRates,
|
|
204
|
+
burnRatePerHourUsd: analytics.burnRatePerHourUsd,
|
|
205
|
+
projectedCostUsd: analytics.projectedCostUsd,
|
|
206
|
+
turnsCount: turns.length,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (subcmd === 'summary' || subcmd === 'brief') {
|
|
210
|
+
return formatCostSummary(payload)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return formatCostReceipt(payload)
|
|
214
|
+
} catch (err) {
|
|
215
|
+
return `Cost Meter error: ${String(err?.message || err)}`
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const commandHandler = async (invocation) => {
|
|
220
|
+
const rawArgs = typeof invocation === 'string'
|
|
221
|
+
? invocation
|
|
222
|
+
: (invocation?.rawInput || invocation?.input || invocation?.text || invocation?.args || '')
|
|
223
|
+
const text = await executeCommand(rawArgs)
|
|
224
|
+
return {
|
|
225
|
+
kind: 'success',
|
|
226
|
+
text,
|
|
227
|
+
toString() { return text },
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const commandExecute = async (rawArgs, invocation = null) => {
|
|
232
|
+
const result = await commandHandler(invocation || rawArgs)
|
|
233
|
+
return result.text
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const unregister1 = commands.register({
|
|
237
|
+
definitionId: '@goodandready/dsh-cost-meter',
|
|
238
|
+
name: 'cost',
|
|
239
|
+
description: 'Show session spend receipt, tokens, cache savings and burn rate (/cost [receipt|summary|reset|help])',
|
|
240
|
+
input: { hint: '[receipt|summary|reset|help]' },
|
|
241
|
+
handler: commandHandler,
|
|
242
|
+
execute: commandExecute,
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
const unregister2 = commands.register({
|
|
246
|
+
definitionId: '@goodandready/dsh-cost-meter',
|
|
247
|
+
name: 'cost-summary',
|
|
248
|
+
description: 'Show compact 1-line session spend summary',
|
|
249
|
+
handler: async () => commandHandler('summary'),
|
|
250
|
+
execute: async () => commandExecute('summary'),
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
if (typeof ctx.effect === 'function') {
|
|
254
|
+
ctx.effect(() => () => {
|
|
255
|
+
unregister1?.()
|
|
256
|
+
unregister2?.()
|
|
257
|
+
}, 'dsh-cost-meter: slash-command')
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
}
|
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
|
+
}
|