@goodandready/dsh-cost-meter 0.8.24 โ 0.8.26
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 +2058 -1697
- package/lib/commands.js +260 -0
- package/lib/index.js +28 -22
- package/lib/projection.js +24 -0
- package/lib/routes.js +16 -4
- package/lib/tariff.js +13 -5
- 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/index.js
CHANGED
|
@@ -30,11 +30,15 @@ try {
|
|
|
30
30
|
export * from './tariff.js'
|
|
31
31
|
export * from './projection.js'
|
|
32
32
|
export * from './fx.js'
|
|
33
|
+
export * from './alerts.js'
|
|
34
|
+
export * from './commands.js'
|
|
33
35
|
export { STATE_PATH, REFRESH_PATH } from './routes.js'
|
|
34
|
-
import { indexCatalog, resolveTariff, matchModel, unwrapVolatile } from './tariff.js'
|
|
35
|
-
import { usageByModelProjection } from './projection.js'
|
|
36
|
+
import { indexCatalog, resolveTariff, matchModel, unwrapVolatile, calculateCacheSavings } from './tariff.js'
|
|
37
|
+
import { usageByModelProjection, getLastTurns, resetLastTurns } from './projection.js'
|
|
36
38
|
import { getFxRate, refreshFxRates } from './fx.js'
|
|
37
|
-
|
|
39
|
+
import { registerSlashCommand } from './commands.js'
|
|
40
|
+
import { calculateBurnRateAndProjection } from './analytics.js'
|
|
41
|
+
import { ratesAt } from './alerts.js'
|
|
38
42
|
|
|
39
43
|
export const name = '@goodandready/dsh-cost-meter'
|
|
40
44
|
export const inject = ['webServer', 'settings', 'sessionProjections']
|
|
@@ -84,7 +88,6 @@ export function isTrustedCaller(request, ctx = null) {
|
|
|
84
88
|
if (isLoopback(url.hostname) && loopback) return true
|
|
85
89
|
return false
|
|
86
90
|
}
|
|
87
|
-
// Non-http/https origin (e.g. dsh-app://app in DSH Desktop)
|
|
88
91
|
if (loopback) return true
|
|
89
92
|
return false
|
|
90
93
|
} catch {
|
|
@@ -101,7 +104,6 @@ export function isTrustedCaller(request, ctx = null) {
|
|
|
101
104
|
if (isLoopback(url.hostname) && loopback) return true
|
|
102
105
|
return false
|
|
103
106
|
}
|
|
104
|
-
// Non-http/https referer (e.g. dsh-app://app/ in DSH Desktop)
|
|
105
107
|
if (loopback) return true
|
|
106
108
|
return false
|
|
107
109
|
} catch {
|
|
@@ -110,7 +112,6 @@ export function isTrustedCaller(request, ctx = null) {
|
|
|
110
112
|
}
|
|
111
113
|
|
|
112
114
|
if (loopback) return true
|
|
113
|
-
|
|
114
115
|
return false
|
|
115
116
|
}
|
|
116
117
|
|
|
@@ -126,6 +127,9 @@ export function normalizeConfig(raw) {
|
|
|
126
127
|
displayTimeZone: typeof u.displayTimeZone === 'string' && u.displayTimeZone ? u.displayTimeZone : 'Europe/Moscow',
|
|
127
128
|
budgetThreshold: Number(u.budgetThreshold) || 0,
|
|
128
129
|
displayMode: (u.displayMode === 'compact' || u.displayMode === 'full') ? u.displayMode : 'auto',
|
|
130
|
+
alertBeforeSwitch: u.alertBeforeSwitch !== false,
|
|
131
|
+
alertLeadMinutes: Math.max(1, Math.min(30, Number(u.alertLeadMinutes) || 5)),
|
|
132
|
+
browserNotifications: Boolean(u.browserNotifications),
|
|
129
133
|
useOpenRouter: u.useOpenRouter !== false,
|
|
130
134
|
refreshHours: Number(u.refreshHours) || 24,
|
|
131
135
|
deepseekPeakPrices: u.deepseekPeakPrices || {},
|
|
@@ -154,6 +158,9 @@ export const Config = z.object({
|
|
|
154
158
|
displayTimeZone: z.string().description('IANA zone the widget shows tariff hours in.').default('Europe/Moscow').volatile(),
|
|
155
159
|
budgetThreshold: z.number().description('Optional session budget threshold in USD. 0 disables threshold warnings.').default(0).volatile(),
|
|
156
160
|
displayMode: z.union(['auto', 'full', 'compact']).description('Widget display mode: auto, full, or compact.').default('auto').volatile(),
|
|
161
|
+
alertBeforeSwitch: z.boolean().description('Pre-switch notification and visual alert before tariff change.').default(true).volatile(),
|
|
162
|
+
alertLeadMinutes: z.number().description('Minutes before tariff change to trigger pre-switch alert.').default(5).volatile(),
|
|
163
|
+
browserNotifications: z.boolean().description('Enable desktop browser notifications for tariff switches.').default(false).volatile(),
|
|
157
164
|
deepseekPeakPrices: z.dict(PriceRow).description('Override the built-in DeepSeek peak rates, by model id.').default({}).volatile(),
|
|
158
165
|
useOpenRouter: z.boolean().description('Pull rates and discount windows from the public OpenRouter catalog.').default(true).volatile(),
|
|
159
166
|
refreshHours: z.number().description('How often to refetch the catalog.').default(24).volatile(),
|
|
@@ -163,9 +170,6 @@ export const Config = z.object({
|
|
|
163
170
|
manualOffPeakMultiplier: z.number().description('Multiplier outside the manual peak windows.').default(1).volatile(),
|
|
164
171
|
})
|
|
165
172
|
|
|
166
|
-
|
|
167
|
-
// Per-model usage projection is defined in and exported from ./projection.js
|
|
168
|
-
|
|
169
173
|
/** The agent's default route, read off settings descriptors when available. */
|
|
170
174
|
function defaultRoute(ctx) {
|
|
171
175
|
const settings = (ctx?.get && typeof ctx.get === 'function' ? ctx.get('settings') : null) || ctx?.settings
|
|
@@ -195,17 +199,13 @@ export function apply(ctx, config) {
|
|
|
195
199
|
void refreshFxRates()
|
|
196
200
|
let current = normalizeConfig(config)
|
|
197
201
|
let catalog = { index: { byId: {}, byLowerId: {}, bySuffix: {} }, fetchedAt: 0, error: null }
|
|
198
|
-
|
|
199
|
-
// Server-side cache for resolved tariffs: key -> { schedule, source, matchedId }
|
|
200
202
|
const resolveCache = new Map()
|
|
201
203
|
|
|
202
|
-
|
|
203
204
|
function getCachedTariff(cfg, route) {
|
|
204
205
|
if (!route) return resolveTariff(cfg, catalog.index, route)
|
|
205
206
|
const cacheKey = (route.provider || '') + '/' + (route.model || '')
|
|
206
207
|
if (resolveCache.has(cacheKey)) return resolveCache.get(cacheKey)
|
|
207
208
|
const res = resolveTariff(cfg, catalog.index, route)
|
|
208
|
-
// Limit cache size to prevent runaway memory
|
|
209
209
|
if (resolveCache.size >= 500) resolveCache.clear()
|
|
210
210
|
resolveCache.set(cacheKey, res)
|
|
211
211
|
return res
|
|
@@ -215,15 +215,12 @@ export function apply(ctx, config) {
|
|
|
215
215
|
resolveCache.clear()
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
-
// Disk cache survives restarts, so a boot without network still prices.
|
|
219
218
|
try {
|
|
220
219
|
const cached = JSON.parse(readFileSync(CACHE_FILE, 'utf8'))
|
|
221
220
|
if (cached && Array.isArray(cached.models)) {
|
|
222
221
|
catalog = { index: indexCatalog(cached.models), fetchedAt: cached.fetchedAt || 0, error: null }
|
|
223
222
|
}
|
|
224
|
-
} catch {
|
|
225
|
-
// absent or unreadable cache is normal on first run
|
|
226
|
-
}
|
|
223
|
+
} catch {}
|
|
227
224
|
|
|
228
225
|
async function refresh(signal, force = false) {
|
|
229
226
|
const cfg = current ?? config
|
|
@@ -244,9 +241,7 @@ export function apply(ctx, config) {
|
|
|
244
241
|
try {
|
|
245
242
|
mkdirSync(dirname(CACHE_FILE), { recursive: true })
|
|
246
243
|
writeFileSync(CACHE_FILE, JSON.stringify({ fetchedAt: catalog.fetchedAt, models: slim }))
|
|
247
|
-
} catch {
|
|
248
|
-
// a read-only storages dir only costs us the restart-time cache
|
|
249
|
-
}
|
|
244
|
+
} catch {}
|
|
250
245
|
} catch (error) {
|
|
251
246
|
catalog = { ...catalog, error: String((error && error.message) || error) }
|
|
252
247
|
}
|
|
@@ -287,6 +282,19 @@ export function apply(ctx, config) {
|
|
|
287
282
|
eff(() => pctx.sessionProjections.register(usageByModelProjection))
|
|
288
283
|
})
|
|
289
284
|
|
|
285
|
+
// Register slash command /cost and /cost-summary
|
|
286
|
+
registerSlashCommand(ctx, {
|
|
287
|
+
getCurrentConfig: () => (current ? normalizeConfig(current) : normalizeConfig(config)),
|
|
288
|
+
getCachedTariff,
|
|
289
|
+
getLastTurns,
|
|
290
|
+
resetLastTurns,
|
|
291
|
+
calculateBurnRateAndProjection,
|
|
292
|
+
calculateCacheSavings,
|
|
293
|
+
ratesAt,
|
|
294
|
+
splitRoute,
|
|
295
|
+
defaultRoute,
|
|
296
|
+
})
|
|
297
|
+
|
|
290
298
|
// Register HTTP routes (refresh, state, update) via webServer service
|
|
291
299
|
registerRoutes(ctx, {
|
|
292
300
|
name,
|
|
@@ -299,5 +307,3 @@ export function apply(ctx, config) {
|
|
|
299
307
|
getCatalog: () => catalog,
|
|
300
308
|
})
|
|
301
309
|
}
|
|
302
|
-
|
|
303
|
-
|
package/lib/projection.js
CHANGED
|
@@ -32,6 +32,8 @@ export const turnRecordSchema = zod.object({
|
|
|
32
32
|
cacheWriteTokens: zod.number().nonnegative().optional(),
|
|
33
33
|
costDeltaUsd: zod.number().nonnegative().optional(),
|
|
34
34
|
timestamp: zod.number().nonnegative(),
|
|
35
|
+
durationMs: zod.number().nonnegative().optional(),
|
|
36
|
+
cacheHitRate: zod.number().min(0).max(100).optional(),
|
|
35
37
|
}).passthrough()
|
|
36
38
|
|
|
37
39
|
/** Pull the four buckets out of one provider usage record. */
|
|
@@ -86,6 +88,10 @@ export function slotOf(timeMs) {
|
|
|
86
88
|
export const modelSlotsSchema = zod.record(zod.string(), bucketSchema)
|
|
87
89
|
|
|
88
90
|
export const costByModelViewSchema = zod.object({
|
|
91
|
+
route: zod.object({
|
|
92
|
+
provider: zod.string(),
|
|
93
|
+
model: zod.string(),
|
|
94
|
+
}).nullable().optional(),
|
|
89
95
|
models: zod.array(zod.object({
|
|
90
96
|
provider: zod.string(),
|
|
91
97
|
model: zod.string(),
|
|
@@ -148,7 +154,20 @@ export const usageByModelProjection = {
|
|
|
148
154
|
? event.time
|
|
149
155
|
: (event.time instanceof Date && !Number.isNaN(event.time.getTime()) ? event.time.getTime() : Date.now())
|
|
150
156
|
|
|
157
|
+
const promptTokens = buckets.uncachedInputTokens + buckets.cacheReadTokens
|
|
158
|
+
const cacheHitRate = promptTokens > 0
|
|
159
|
+
? Math.round((buckets.cacheReadTokens / promptTokens) * 100)
|
|
160
|
+
: 0
|
|
161
|
+
|
|
151
162
|
const currentTurns = Array.isArray(state.turns) ? state.turns : []
|
|
163
|
+
let durationMs = 0
|
|
164
|
+
if (currentTurns.length > 0) {
|
|
165
|
+
const prevTs = currentTurns[currentTurns.length - 1].timestamp
|
|
166
|
+
if (timestamp > prevTs && timestamp - prevTs < 600000) {
|
|
167
|
+
durationMs = timestamp - prevTs
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
152
171
|
const existingIndex = currentTurns.findIndex((t) => t.turn === turnNum && t.step === stepNum)
|
|
153
172
|
let nextTurns
|
|
154
173
|
if (existingIndex >= 0) {
|
|
@@ -163,6 +182,8 @@ export const usageByModelProjection = {
|
|
|
163
182
|
cacheWriteTokens: buckets.cacheWriteTokens,
|
|
164
183
|
costDeltaUsd: nextTurns[existingIndex].costDeltaUsd || 0,
|
|
165
184
|
timestamp,
|
|
185
|
+
durationMs: nextTurns[existingIndex].durationMs || durationMs,
|
|
186
|
+
cacheHitRate,
|
|
166
187
|
}
|
|
167
188
|
} else {
|
|
168
189
|
const newEntry = {
|
|
@@ -175,6 +196,8 @@ export const usageByModelProjection = {
|
|
|
175
196
|
cacheWriteTokens: buckets.cacheWriteTokens,
|
|
176
197
|
costDeltaUsd: 0,
|
|
177
198
|
timestamp,
|
|
199
|
+
durationMs,
|
|
200
|
+
cacheHitRate,
|
|
178
201
|
}
|
|
179
202
|
nextTurns = [...currentTurns, newEntry].slice(-MAX_TURNS_HISTORY)
|
|
180
203
|
}
|
|
@@ -194,6 +217,7 @@ export const usageByModelProjection = {
|
|
|
194
217
|
wire: {
|
|
195
218
|
viewSchema: costByModelViewSchema,
|
|
196
219
|
view: (state) => ({
|
|
220
|
+
...(state.route ? { route: state.route } : {}),
|
|
197
221
|
models: Object.keys(state.byKey).map((key) => {
|
|
198
222
|
const slash = key.indexOf('/')
|
|
199
223
|
return {
|
package/lib/routes.js
CHANGED
|
@@ -80,18 +80,27 @@ export function registerRoutes(ctx, options) {
|
|
|
80
80
|
void refresh()
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
const
|
|
83
|
+
const url = new URL(req.url, 'http://localhost')
|
|
84
|
+
const currentQueryRoute = url.searchParams.get('currentRoute') || url.searchParams.get('model')
|
|
85
|
+
const parsedCurrent = currentQueryRoute ? splitRoute(currentQueryRoute) : null
|
|
86
|
+
const route = parsedCurrent ?? (defaultRoute(ctx) ?? null)
|
|
84
87
|
const own = getCachedTariff(cfg, route)
|
|
85
88
|
|
|
86
89
|
// Also resolve tariffs for any session routes requested in query params
|
|
87
90
|
const schedules = {}
|
|
88
|
-
const
|
|
89
|
-
|
|
91
|
+
const extraRoutes = [
|
|
92
|
+
...url.searchParams.getAll('route'),
|
|
93
|
+
...url.searchParams.getAll('models').flatMap((m) => m.split(',')),
|
|
94
|
+
]
|
|
90
95
|
for (const key of extraRoutes) {
|
|
96
|
+
if (!key) continue
|
|
91
97
|
const parsed = splitRoute(key)
|
|
92
98
|
if (parsed) {
|
|
93
99
|
const res = getCachedTariff(cfg, parsed)
|
|
94
|
-
if (res && res.schedule)
|
|
100
|
+
if (res && res.schedule) {
|
|
101
|
+
schedules[key] = res.schedule
|
|
102
|
+
if (parsed.model) schedules[parsed.model] = res.schedule
|
|
103
|
+
}
|
|
95
104
|
}
|
|
96
105
|
}
|
|
97
106
|
|
|
@@ -101,6 +110,9 @@ export function registerRoutes(ctx, options) {
|
|
|
101
110
|
displayTimeZone: typeof cfg?.displayTimeZone === 'string' && cfg.displayTimeZone ? cfg.displayTimeZone : 'Europe/Moscow',
|
|
102
111
|
budgetThreshold: Number(cfg?.budgetThreshold) || 0,
|
|
103
112
|
displayMode: cfg?.displayMode || 'auto',
|
|
113
|
+
alertBeforeSwitch: cfg?.alertBeforeSwitch !== false,
|
|
114
|
+
alertLeadMinutes: Number(cfg?.alertLeadMinutes) || 5,
|
|
115
|
+
browserNotifications: Boolean(cfg?.browserNotifications),
|
|
104
116
|
route,
|
|
105
117
|
source: own.source,
|
|
106
118
|
matchedId: own.matchedId,
|
package/lib/tariff.js
CHANGED
|
@@ -139,8 +139,16 @@ export function matchModel(index, route, modelMap) {
|
|
|
139
139
|
if (byLowerId[lower]) return byLowerId[lower]
|
|
140
140
|
if (byLowerId[key.toLowerCase()]) return byLowerId[key.toLowerCase()]
|
|
141
141
|
const bySuffix = index.bySuffix || {}
|
|
142
|
-
|
|
143
|
-
if (!list || !list.length)
|
|
142
|
+
let list = bySuffix[lower]
|
|
143
|
+
if (!list || !list.length) {
|
|
144
|
+
// Prefix fallback for short names like "beam" -> "beam-1.0-501b-a23b"
|
|
145
|
+
const prefixKey = Object.keys(bySuffix).find((k) => k.startsWith(lower + '-') || k.startsWith(lower + '.'))
|
|
146
|
+
if (prefixKey && bySuffix[prefixKey]?.length) {
|
|
147
|
+
list = bySuffix[prefixKey]
|
|
148
|
+
} else {
|
|
149
|
+
return undefined
|
|
150
|
+
}
|
|
151
|
+
}
|
|
144
152
|
if (list.length === 1) return list[0]
|
|
145
153
|
|
|
146
154
|
// Multiple catalog entries share the same model suffix: disambiguate by provider
|
|
@@ -155,8 +163,8 @@ export function matchModel(index, route, modelMap) {
|
|
|
155
163
|
})
|
|
156
164
|
if (matched) return matched
|
|
157
165
|
}
|
|
158
|
-
//
|
|
159
|
-
return
|
|
166
|
+
// When provider does not match or is absent, fall back to the first catalog candidate for this model
|
|
167
|
+
return list[0]
|
|
160
168
|
}
|
|
161
169
|
|
|
162
170
|
/** Build lookup tables once per fetch; the raw catalog is ~700 KB. */
|
|
@@ -273,4 +281,4 @@ export function calculateCacheSavings(usage, rates) {
|
|
|
273
281
|
return { savedUsd, readTokens, percent }
|
|
274
282
|
}
|
|
275
283
|
|
|
276
|
-
|
|
284
|
+
|
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.26",
|
|
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",
|