@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/index.js
CHANGED
|
@@ -30,11 +30,16 @@ 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'
|
|
35
|
+
export * from './export.js'
|
|
33
36
|
export { STATE_PATH, REFRESH_PATH } from './routes.js'
|
|
34
|
-
import { indexCatalog, resolveTariff, matchModel, unwrapVolatile } from './tariff.js'
|
|
35
|
-
import { usageByModelProjection } from './projection.js'
|
|
37
|
+
import { indexCatalog, resolveTariff, matchModel, unwrapVolatile, calculateCacheSavings } from './tariff.js'
|
|
38
|
+
import { usageByModelProjection, getLastTurns, resetLastTurns } from './projection.js'
|
|
36
39
|
import { getFxRate, refreshFxRates } from './fx.js'
|
|
37
|
-
|
|
40
|
+
import { registerSlashCommand } from './commands.js'
|
|
41
|
+
import { calculateBurnRateAndProjection } from './analytics.js'
|
|
42
|
+
import { ratesAt } from './alerts.js'
|
|
38
43
|
|
|
39
44
|
export const name = '@goodandready/dsh-cost-meter'
|
|
40
45
|
export const inject = ['webServer', 'settings', 'sessionProjections']
|
|
@@ -84,7 +89,6 @@ export function isTrustedCaller(request, ctx = null) {
|
|
|
84
89
|
if (isLoopback(url.hostname) && loopback) return true
|
|
85
90
|
return false
|
|
86
91
|
}
|
|
87
|
-
// Non-http/https origin (e.g. dsh-app://app in DSH Desktop)
|
|
88
92
|
if (loopback) return true
|
|
89
93
|
return false
|
|
90
94
|
} catch {
|
|
@@ -101,7 +105,6 @@ export function isTrustedCaller(request, ctx = null) {
|
|
|
101
105
|
if (isLoopback(url.hostname) && loopback) return true
|
|
102
106
|
return false
|
|
103
107
|
}
|
|
104
|
-
// Non-http/https referer (e.g. dsh-app://app/ in DSH Desktop)
|
|
105
108
|
if (loopback) return true
|
|
106
109
|
return false
|
|
107
110
|
} catch {
|
|
@@ -110,7 +113,6 @@ export function isTrustedCaller(request, ctx = null) {
|
|
|
110
113
|
}
|
|
111
114
|
|
|
112
115
|
if (loopback) return true
|
|
113
|
-
|
|
114
116
|
return false
|
|
115
117
|
}
|
|
116
118
|
|
|
@@ -126,6 +128,9 @@ export function normalizeConfig(raw) {
|
|
|
126
128
|
displayTimeZone: typeof u.displayTimeZone === 'string' && u.displayTimeZone ? u.displayTimeZone : 'Europe/Moscow',
|
|
127
129
|
budgetThreshold: Number(u.budgetThreshold) || 0,
|
|
128
130
|
displayMode: (u.displayMode === 'compact' || u.displayMode === 'full') ? u.displayMode : 'auto',
|
|
131
|
+
alertBeforeSwitch: u.alertBeforeSwitch !== false,
|
|
132
|
+
alertLeadMinutes: Math.max(1, Math.min(30, Number(u.alertLeadMinutes) || 5)),
|
|
133
|
+
browserNotifications: Boolean(u.browserNotifications),
|
|
129
134
|
useOpenRouter: u.useOpenRouter !== false,
|
|
130
135
|
refreshHours: Number(u.refreshHours) || 24,
|
|
131
136
|
deepseekPeakPrices: u.deepseekPeakPrices || {},
|
|
@@ -154,6 +159,9 @@ export const Config = z.object({
|
|
|
154
159
|
displayTimeZone: z.string().description('IANA zone the widget shows tariff hours in.').default('Europe/Moscow').volatile(),
|
|
155
160
|
budgetThreshold: z.number().description('Optional session budget threshold in USD. 0 disables threshold warnings.').default(0).volatile(),
|
|
156
161
|
displayMode: z.union(['auto', 'full', 'compact']).description('Widget display mode: auto, full, or compact.').default('auto').volatile(),
|
|
162
|
+
alertBeforeSwitch: z.boolean().description('Pre-switch notification and visual alert before tariff change.').default(true).volatile(),
|
|
163
|
+
alertLeadMinutes: z.number().description('Minutes before tariff change to trigger pre-switch alert.').default(5).volatile(),
|
|
164
|
+
browserNotifications: z.boolean().description('Enable desktop browser notifications for tariff switches.').default(false).volatile(),
|
|
157
165
|
deepseekPeakPrices: z.dict(PriceRow).description('Override the built-in DeepSeek peak rates, by model id.').default({}).volatile(),
|
|
158
166
|
useOpenRouter: z.boolean().description('Pull rates and discount windows from the public OpenRouter catalog.').default(true).volatile(),
|
|
159
167
|
refreshHours: z.number().description('How often to refetch the catalog.').default(24).volatile(),
|
|
@@ -163,9 +171,6 @@ export const Config = z.object({
|
|
|
163
171
|
manualOffPeakMultiplier: z.number().description('Multiplier outside the manual peak windows.').default(1).volatile(),
|
|
164
172
|
})
|
|
165
173
|
|
|
166
|
-
|
|
167
|
-
// Per-model usage projection is defined in and exported from ./projection.js
|
|
168
|
-
|
|
169
174
|
/** The agent's default route, read off settings descriptors when available. */
|
|
170
175
|
function defaultRoute(ctx) {
|
|
171
176
|
const settings = (ctx?.get && typeof ctx.get === 'function' ? ctx.get('settings') : null) || ctx?.settings
|
|
@@ -195,17 +200,13 @@ export function apply(ctx, config) {
|
|
|
195
200
|
void refreshFxRates()
|
|
196
201
|
let current = normalizeConfig(config)
|
|
197
202
|
let catalog = { index: { byId: {}, byLowerId: {}, bySuffix: {} }, fetchedAt: 0, error: null }
|
|
198
|
-
|
|
199
|
-
// Server-side cache for resolved tariffs: key -> { schedule, source, matchedId }
|
|
200
203
|
const resolveCache = new Map()
|
|
201
204
|
|
|
202
|
-
|
|
203
205
|
function getCachedTariff(cfg, route) {
|
|
204
206
|
if (!route) return resolveTariff(cfg, catalog.index, route)
|
|
205
207
|
const cacheKey = (route.provider || '') + '/' + (route.model || '')
|
|
206
208
|
if (resolveCache.has(cacheKey)) return resolveCache.get(cacheKey)
|
|
207
209
|
const res = resolveTariff(cfg, catalog.index, route)
|
|
208
|
-
// Limit cache size to prevent runaway memory
|
|
209
210
|
if (resolveCache.size >= 500) resolveCache.clear()
|
|
210
211
|
resolveCache.set(cacheKey, res)
|
|
211
212
|
return res
|
|
@@ -215,15 +216,12 @@ export function apply(ctx, config) {
|
|
|
215
216
|
resolveCache.clear()
|
|
216
217
|
}
|
|
217
218
|
|
|
218
|
-
// Disk cache survives restarts, so a boot without network still prices.
|
|
219
219
|
try {
|
|
220
220
|
const cached = JSON.parse(readFileSync(CACHE_FILE, 'utf8'))
|
|
221
221
|
if (cached && Array.isArray(cached.models)) {
|
|
222
222
|
catalog = { index: indexCatalog(cached.models), fetchedAt: cached.fetchedAt || 0, error: null }
|
|
223
223
|
}
|
|
224
|
-
} catch {
|
|
225
|
-
// absent or unreadable cache is normal on first run
|
|
226
|
-
}
|
|
224
|
+
} catch {}
|
|
227
225
|
|
|
228
226
|
async function refresh(signal, force = false) {
|
|
229
227
|
const cfg = current ?? config
|
|
@@ -244,9 +242,7 @@ export function apply(ctx, config) {
|
|
|
244
242
|
try {
|
|
245
243
|
mkdirSync(dirname(CACHE_FILE), { recursive: true })
|
|
246
244
|
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
|
-
}
|
|
245
|
+
} catch {}
|
|
250
246
|
} catch (error) {
|
|
251
247
|
catalog = { ...catalog, error: String((error && error.message) || error) }
|
|
252
248
|
}
|
|
@@ -287,6 +283,19 @@ export function apply(ctx, config) {
|
|
|
287
283
|
eff(() => pctx.sessionProjections.register(usageByModelProjection))
|
|
288
284
|
})
|
|
289
285
|
|
|
286
|
+
// Register slash command /cost and /cost-summary
|
|
287
|
+
registerSlashCommand(ctx, {
|
|
288
|
+
getCurrentConfig: () => (current ? normalizeConfig(current) : normalizeConfig(config)),
|
|
289
|
+
getCachedTariff,
|
|
290
|
+
getLastTurns,
|
|
291
|
+
resetLastTurns,
|
|
292
|
+
calculateBurnRateAndProjection,
|
|
293
|
+
calculateCacheSavings,
|
|
294
|
+
ratesAt,
|
|
295
|
+
splitRoute,
|
|
296
|
+
defaultRoute,
|
|
297
|
+
})
|
|
298
|
+
|
|
290
299
|
// Register HTTP routes (refresh, state, update) via webServer service
|
|
291
300
|
registerRoutes(ctx, {
|
|
292
301
|
name,
|
|
@@ -299,5 +308,3 @@ export function apply(ctx, config) {
|
|
|
299
308
|
getCatalog: () => catalog,
|
|
300
309
|
})
|
|
301
310
|
}
|
|
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. */
|
|
@@ -152,7 +154,20 @@ export const usageByModelProjection = {
|
|
|
152
154
|
? event.time
|
|
153
155
|
: (event.time instanceof Date && !Number.isNaN(event.time.getTime()) ? event.time.getTime() : Date.now())
|
|
154
156
|
|
|
157
|
+
const promptTokens = buckets.uncachedInputTokens + buckets.cacheReadTokens
|
|
158
|
+
const cacheHitRate = promptTokens > 0
|
|
159
|
+
? Math.round((buckets.cacheReadTokens / promptTokens) * 100)
|
|
160
|
+
: 0
|
|
161
|
+
|
|
155
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
|
+
|
|
156
171
|
const existingIndex = currentTurns.findIndex((t) => t.turn === turnNum && t.step === stepNum)
|
|
157
172
|
let nextTurns
|
|
158
173
|
if (existingIndex >= 0) {
|
|
@@ -167,6 +182,8 @@ export const usageByModelProjection = {
|
|
|
167
182
|
cacheWriteTokens: buckets.cacheWriteTokens,
|
|
168
183
|
costDeltaUsd: nextTurns[existingIndex].costDeltaUsd || 0,
|
|
169
184
|
timestamp,
|
|
185
|
+
durationMs: nextTurns[existingIndex].durationMs || durationMs,
|
|
186
|
+
cacheHitRate,
|
|
170
187
|
}
|
|
171
188
|
} else {
|
|
172
189
|
const newEntry = {
|
|
@@ -179,6 +196,8 @@ export const usageByModelProjection = {
|
|
|
179
196
|
cacheWriteTokens: buckets.cacheWriteTokens,
|
|
180
197
|
costDeltaUsd: 0,
|
|
181
198
|
timestamp,
|
|
199
|
+
durationMs,
|
|
200
|
+
cacheHitRate,
|
|
182
201
|
}
|
|
183
202
|
nextTurns = [...currentTurns, newEntry].slice(-MAX_TURNS_HISTORY)
|
|
184
203
|
}
|
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,9 +108,13 @@ 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',
|
|
115
|
+
alertBeforeSwitch: cfg?.alertBeforeSwitch !== false,
|
|
116
|
+
alertLeadMinutes: Number(cfg?.alertLeadMinutes) || 5,
|
|
117
|
+
browserNotifications: Boolean(cfg?.browserNotifications),
|
|
113
118
|
route,
|
|
114
119
|
source: own.source,
|
|
115
120
|
matchedId: own.matchedId,
|
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",
|