@goodandready/dsh-cost-meter 0.8.0 → 0.8.2
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/README.md +25 -2
- package/README.ru.md +156 -0
- package/README.zh.md +144 -0
- package/docs/README.ru.md +23 -1
- package/docs/README.zh.md +17 -1
- package/docs/design/DESIGN.md +15 -0
- package/docs/plans/findings.md +11 -11
- package/docs/plans/progress.md +11 -24
- package/docs/plans/task_plan.md +29 -23
- package/lib/client.js +415 -141
- package/lib/index.js +128 -53
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
//
|
|
14
14
|
// No agent hooks: the widget only reads, so it cannot corrupt a session.
|
|
15
15
|
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
16
17
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
17
18
|
import { homedir } from 'node:os'
|
|
18
19
|
import { dirname, join } from 'node:path'
|
|
@@ -24,6 +25,7 @@ export const inject = ['webServer']
|
|
|
24
25
|
|
|
25
26
|
const NS = 'dsh-cost-meter'
|
|
26
27
|
const STATE_PATH = '/dsh-cost-meter/state'
|
|
28
|
+
const REFRESH_PATH = '/dsh-cost-meter/refresh'
|
|
27
29
|
const CATALOG_URL = 'https://openrouter.ai/api/v1/models'
|
|
28
30
|
const CACHE_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'storages', 'dsh-cost-meter-catalog.json')
|
|
29
31
|
|
|
@@ -48,10 +50,21 @@ const DEEPSEEK_PEAK_RATES = {
|
|
|
48
50
|
}
|
|
49
51
|
const DEEPSEEK_PEAK_WINDOWS = [{ start: 60, end: 240 }, { start: 360, end: 600 }]
|
|
50
52
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
/** Common provider / vendor aliases for seamless catalog matching. */
|
|
54
|
+
export const VENDOR_ALIASES = {
|
|
55
|
+
'z-ai': 'zai',
|
|
56
|
+
'zai': 'zai',
|
|
57
|
+
'google': 'google',
|
|
58
|
+
'gemini': 'google',
|
|
59
|
+
'deepseek-ai': 'deepseek',
|
|
60
|
+
'deepseek-official': 'deepseek',
|
|
61
|
+
'deepseek': 'deepseek',
|
|
62
|
+
'anthropic-ai': 'anthropic',
|
|
63
|
+
'anthropic': 'anthropic',
|
|
64
|
+
'openai': 'openai',
|
|
65
|
+
'meta-llama': 'meta-llama',
|
|
66
|
+
'meta': 'meta-llama',
|
|
67
|
+
}
|
|
55
68
|
|
|
56
69
|
export const Config = z.object({
|
|
57
70
|
currency: z.string().description('Currency symbol shown in the widget.').default('$'),
|
|
@@ -60,6 +73,10 @@ export const Config = z.object({
|
|
|
60
73
|
.string()
|
|
61
74
|
.description('IANA zone the widget shows tariff hours in.')
|
|
62
75
|
.default('Europe/Moscow'),
|
|
76
|
+
budgetThreshold: z
|
|
77
|
+
.number()
|
|
78
|
+
.description('Optional session budget threshold in USD. 0 disables threshold warnings.')
|
|
79
|
+
.default(0),
|
|
63
80
|
deepseekPeakPrices: z
|
|
64
81
|
.dict(PriceRow)
|
|
65
82
|
.description('Override the built-in DeepSeek peak rates, by model id.')
|
|
@@ -146,9 +163,8 @@ function scheduleOf(entry) {
|
|
|
146
163
|
}
|
|
147
164
|
|
|
148
165
|
/**
|
|
149
|
-
* Map a harness route onto an OpenRouter id. Exact id first, then
|
|
150
|
-
*
|
|
151
|
-
* which is what the shared model names make possible.
|
|
166
|
+
* Map a harness route onto an OpenRouter id. Exact id first, then normalized
|
|
167
|
+
* vendor alias + model, then vendor suffix.
|
|
152
168
|
*/
|
|
153
169
|
export function matchModel(index, route, modelMap) {
|
|
154
170
|
if (!route || !index) return undefined
|
|
@@ -157,6 +173,15 @@ export function matchModel(index, route, modelMap) {
|
|
|
157
173
|
if (mapped && index.byId && index.byId[mapped]) return mapped
|
|
158
174
|
if (index.byId && index.byId[key]) return key
|
|
159
175
|
if (index.byId && index.byId[route.model]) return route.model
|
|
176
|
+
|
|
177
|
+
// Provider alias check (e.g. z-ai -> zai, deepseek-official -> deepseek)
|
|
178
|
+
const prov = String(route.provider || '').toLowerCase()
|
|
179
|
+
const aliasProv = VENDOR_ALIASES[prov]
|
|
180
|
+
if (aliasProv && route.model) {
|
|
181
|
+
const aliasKey = (aliasProv + '/' + route.model).toLowerCase()
|
|
182
|
+
if (index.byLowerId && index.byLowerId[aliasKey]) return index.byLowerId[aliasKey]
|
|
183
|
+
}
|
|
184
|
+
|
|
160
185
|
// Harness routes name models in their own casing (`MiniMax-M3`), the catalog
|
|
161
186
|
// is lower-case (`minimax/minimax-m3`), so matching is case-insensitive.
|
|
162
187
|
const lower = String(route.model).toLowerCase()
|
|
@@ -188,23 +213,10 @@ export function indexCatalog(models) {
|
|
|
188
213
|
|
|
189
214
|
// ---------------------------------------------------------------------------
|
|
190
215
|
// Per-model usage projection
|
|
191
|
-
//
|
|
192
|
-
// `tokenUsage` from dsh-token-meter totals the whole session, which prices
|
|
193
|
-
// wrongly the moment the model changes mid-session. The log has what is
|
|
194
|
-
// needed: `request/header` carries `header.config = {provider, model}`, and
|
|
195
|
-
// usage arrives afterwards for that same step. So this unit keeps the last
|
|
196
|
-
// header's route and files every usage sample under it.
|
|
197
|
-
//
|
|
198
|
-
// The replace-if-same-step guard mirrors token-meter: a streamed usage chunk
|
|
199
|
-
// and the finalized assistant message report the same numbers for one step.
|
|
200
216
|
// ---------------------------------------------------------------------------
|
|
201
217
|
|
|
202
218
|
const ZERO = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
|
203
219
|
|
|
204
|
-
// Projection schemas are validated with ZOD, not schemastery: the registry
|
|
205
|
-
// calls `schema.parse(view)` at the boundary, and a schemastery schema has no
|
|
206
|
-
// `.parse`, which fails every history read with
|
|
207
|
-
// "registration.def.schema.parse is not a function".
|
|
208
220
|
const bucketSchema = zod.object({
|
|
209
221
|
uncachedInputTokens: zod.number().nonnegative(),
|
|
210
222
|
outputTokens: zod.number().nonnegative(),
|
|
@@ -246,10 +258,7 @@ function usageOf(event) {
|
|
|
246
258
|
}
|
|
247
259
|
|
|
248
260
|
/**
|
|
249
|
-
* Half-hour slot of the UTC day an event falls into (0..47).
|
|
250
|
-
* expressed in UTC clock time, and every real schedule so far lands on a whole
|
|
251
|
-
* or half hour, so 48 slots reproduce the rate exactly while keeping the state
|
|
252
|
-
* small enough to ship on every projection frame.
|
|
261
|
+
* Half-hour slot of the UTC day an event falls into (0..47).
|
|
253
262
|
*/
|
|
254
263
|
export function slotOf(timeMs) {
|
|
255
264
|
let ms = typeof timeMs === 'number' && Number.isFinite(timeMs) ? timeMs : NaN
|
|
@@ -270,9 +279,6 @@ const usageByModelProjection = {
|
|
|
270
279
|
models: zod.array(zod.object({
|
|
271
280
|
provider: zod.string(),
|
|
272
281
|
model: zod.string(),
|
|
273
|
-
// Usage split by the half-hour of the UTC day it was spent in. Spend is
|
|
274
|
-
// priced at the rate in force THEN; a later tariff switch must not
|
|
275
|
-
// reprice tokens that are already paid for.
|
|
276
282
|
slots: zod.record(zod.string(), bucketSchema),
|
|
277
283
|
})),
|
|
278
284
|
}).strict(),
|
|
@@ -292,8 +298,6 @@ const usageByModelProjection = {
|
|
|
292
298
|
const key = route.provider + '/' + route.model
|
|
293
299
|
const slot = String(slotOf(event.time))
|
|
294
300
|
const buckets = bucketsFromUsage(sample.usage)
|
|
295
|
-
// A streamed usage chunk and the finalized assistant message report the
|
|
296
|
-
// same step twice; the second one replaces the first instead of adding.
|
|
297
301
|
const previous =
|
|
298
302
|
state.last !== null &&
|
|
299
303
|
state.last.turn === sample.turn &&
|
|
@@ -344,12 +348,11 @@ function defaultRoute(ctx) {
|
|
|
344
348
|
|
|
345
349
|
/**
|
|
346
350
|
* Resolve one route's tariff: manual config wins, then DeepSeek's own table,
|
|
347
|
-
* then the OpenRouter catalog.
|
|
348
|
-
* so the widget can say so instead of pricing at zero.
|
|
351
|
+
* then the OpenRouter catalog.
|
|
349
352
|
*/
|
|
350
353
|
export function resolveTariff(cfg, index, route) {
|
|
351
354
|
if (!route) return { schedule: null, source: 'none', matchedId: null }
|
|
352
|
-
const key = route.provider + '/' + route.model
|
|
355
|
+
const key = route.provider ? route.provider + '/' + route.model : route.model
|
|
353
356
|
const prices = cfg.prices || {}
|
|
354
357
|
const manual = prices[key] || prices[route.model] || prices['*']
|
|
355
358
|
|
|
@@ -380,15 +383,10 @@ export function resolveTariff(cfg, index, route) {
|
|
|
380
383
|
return { schedule, source: 'manual', matchedId: key }
|
|
381
384
|
}
|
|
382
385
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
// #3: Provider-aware routing: if routed explicitly through OpenRouter,
|
|
388
|
-
// consult OpenRouter catalog first to honor actual provider billing.
|
|
389
|
-
if (providerLower === "openrouter" && cfg.useOpenRouter !== false) {
|
|
390
|
-
const matchedId = matchModel(index, route, cfg.modelMap);
|
|
391
|
-
if (matchedId) return { schedule: index.byId[matchedId], source: "openrouter", matchedId };
|
|
386
|
+
const providerLower = String(route.provider || '').toLowerCase()
|
|
387
|
+
if (providerLower === 'openrouter' && cfg.useOpenRouter !== false) {
|
|
388
|
+
const matchedId = matchModel(index, route, cfg.modelMap)
|
|
389
|
+
if (matchedId) return { schedule: index.byId[matchedId], source: 'openrouter', matchedId }
|
|
392
390
|
}
|
|
393
391
|
|
|
394
392
|
const base = String(route.model).toLowerCase().split(':')[0]
|
|
@@ -434,6 +432,25 @@ export function splitRoute(key) {
|
|
|
434
432
|
export function apply(ctx, config) {
|
|
435
433
|
let current = config
|
|
436
434
|
let catalog = { index: { byId: {}, byLowerId: {}, bySuffix: {} }, fetchedAt: 0, error: null }
|
|
435
|
+
let lastManualRefreshAt = 0
|
|
436
|
+
|
|
437
|
+
// Server-side cache for resolved tariffs: key -> { schedule, source, matchedId }
|
|
438
|
+
const resolveCache = new Map()
|
|
439
|
+
|
|
440
|
+
function getCachedTariff(cfg, route) {
|
|
441
|
+
if (!route) return resolveTariff(cfg, catalog.index, route)
|
|
442
|
+
const cacheKey = (route.provider || '') + '/' + (route.model || '')
|
|
443
|
+
if (resolveCache.has(cacheKey)) return resolveCache.get(cacheKey)
|
|
444
|
+
const res = resolveTariff(cfg, catalog.index, route)
|
|
445
|
+
// Limit cache size to prevent runaway memory
|
|
446
|
+
if (resolveCache.size >= 500) resolveCache.clear()
|
|
447
|
+
resolveCache.set(cacheKey, res)
|
|
448
|
+
return res
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function clearResolveCache() {
|
|
452
|
+
resolveCache.clear()
|
|
453
|
+
}
|
|
437
454
|
|
|
438
455
|
// Disk cache survives restarts, so a boot without network still prices.
|
|
439
456
|
try {
|
|
@@ -445,11 +462,11 @@ export function apply(ctx, config) {
|
|
|
445
462
|
// absent or unreadable cache is normal on first run
|
|
446
463
|
}
|
|
447
464
|
|
|
448
|
-
async function refresh(signal) {
|
|
465
|
+
async function refresh(signal, force = false) {
|
|
449
466
|
const cfg = current ?? config
|
|
450
467
|
if (cfg.useOpenRouter === false) return
|
|
451
468
|
const ageMs = Date.now() - catalog.fetchedAt
|
|
452
|
-
if (catalog.fetchedAt && ageMs < (Number(cfg.refreshHours) || 24) * 3600e3) return
|
|
469
|
+
if (!force && catalog.fetchedAt && ageMs < (Number(cfg.refreshHours) || 24) * 3600e3) return
|
|
453
470
|
try {
|
|
454
471
|
const timeoutSignal = AbortSignal.timeout(15000)
|
|
455
472
|
const combinedSignal = signal ? (AbortSignal.any ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal) : timeoutSignal
|
|
@@ -458,9 +475,9 @@ export function apply(ctx, config) {
|
|
|
458
475
|
const body = await response.json()
|
|
459
476
|
const models = Array.isArray(body && body.data) ? body.data : []
|
|
460
477
|
if (models.length === 0) throw new Error('empty catalog')
|
|
461
|
-
// Keep only what pricing needs — the raw payload is mostly prose.
|
|
462
478
|
const slim = models.map((m) => ({ id: m.id, pricing: m.pricing }))
|
|
463
479
|
catalog = { index: indexCatalog(slim), fetchedAt: Date.now(), error: null }
|
|
480
|
+
clearResolveCache()
|
|
464
481
|
try {
|
|
465
482
|
mkdirSync(dirname(CACHE_FILE), { recursive: true })
|
|
466
483
|
writeFileSync(CACHE_FILE, JSON.stringify({ fetchedAt: catalog.fetchedAt, models: slim }))
|
|
@@ -478,16 +495,17 @@ export function apply(ctx, config) {
|
|
|
478
495
|
current = scope.get() ?? config
|
|
479
496
|
const stop = scope.watch(() => {
|
|
480
497
|
current = scope.get() ?? config
|
|
498
|
+
clearResolveCache()
|
|
481
499
|
void refresh()
|
|
482
500
|
})
|
|
483
501
|
sctx.effect(() => () => {
|
|
484
502
|
stop()
|
|
485
503
|
current = config
|
|
504
|
+
clearResolveCache()
|
|
486
505
|
})
|
|
487
506
|
})
|
|
488
507
|
|
|
489
|
-
// Optional capability: headless assemblies without the registry keep working
|
|
490
|
-
// the widget just falls back to the session-wide tokenUsage projection.
|
|
508
|
+
// Optional capability: headless assemblies without the registry keep working
|
|
491
509
|
ctx.inject(['sessionProjections'], (pctx) => {
|
|
492
510
|
pctx.sessionProjections.register(usageByModelProjection)
|
|
493
511
|
})
|
|
@@ -502,6 +520,46 @@ export function apply(ctx, config) {
|
|
|
502
520
|
}
|
|
503
521
|
}, 'dsh-cost-meter: catalog refresh')
|
|
504
522
|
|
|
523
|
+
// Route 1: POST /dsh-cost-meter/refresh (manual on-demand sync with 30s cooldown)
|
|
524
|
+
ctx.effect(
|
|
525
|
+
() =>
|
|
526
|
+
ctx.webServer.register({
|
|
527
|
+
kind: 'exact',
|
|
528
|
+
path: REFRESH_PATH,
|
|
529
|
+
handler: async (req, res) => {
|
|
530
|
+
if (req.method !== 'POST') {
|
|
531
|
+
res.writeHead(405, { 'Content-Type': 'application/json' })
|
|
532
|
+
res.end(JSON.stringify({ error: 'method not allowed' }))
|
|
533
|
+
return
|
|
534
|
+
}
|
|
535
|
+
const now = Date.now()
|
|
536
|
+
const COOLDOWN_MS = 30000
|
|
537
|
+
if (now - lastManualRefreshAt < COOLDOWN_MS) {
|
|
538
|
+
const retryAfter = Math.ceil((COOLDOWN_MS - (now - lastManualRefreshAt)) / 1000)
|
|
539
|
+
res.writeHead(429, {
|
|
540
|
+
'Content-Type': 'application/json',
|
|
541
|
+
'Retry-After': String(retryAfter),
|
|
542
|
+
})
|
|
543
|
+
res.end(JSON.stringify({ error: 'rate limit exceeded', retryAfter }))
|
|
544
|
+
return
|
|
545
|
+
}
|
|
546
|
+
lastManualRefreshAt = now
|
|
547
|
+
const controller = new AbortController()
|
|
548
|
+
await refresh(controller.signal, true)
|
|
549
|
+
const modelsCount = Object.keys((catalog.index && catalog.index.byId) || {}).length
|
|
550
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
551
|
+
res.end(JSON.stringify({
|
|
552
|
+
ok: !catalog.error,
|
|
553
|
+
fetchedAt: catalog.fetchedAt,
|
|
554
|
+
modelsCount,
|
|
555
|
+
error: catalog.error,
|
|
556
|
+
}))
|
|
557
|
+
},
|
|
558
|
+
}),
|
|
559
|
+
'dsh-cost-meter: refresh route',
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
// Route 2: GET /dsh-cost-meter/state (with ETag and 304 Not Modified)
|
|
505
563
|
ctx.effect(
|
|
506
564
|
() =>
|
|
507
565
|
ctx.webServer.register({
|
|
@@ -515,10 +573,8 @@ export function apply(ctx, config) {
|
|
|
515
573
|
}
|
|
516
574
|
const cfg = current ?? config
|
|
517
575
|
const route = defaultRoute(ctx) ?? null
|
|
518
|
-
const own =
|
|
576
|
+
const own = getCachedTariff(cfg, route)
|
|
519
577
|
|
|
520
|
-
// The client asks for every model the session actually used, so a
|
|
521
|
-
// session that switched models can price each part on its own tariff.
|
|
522
578
|
const schedules = {}
|
|
523
579
|
const asked = new URL(req.url, 'http://localhost').searchParams.get('models')
|
|
524
580
|
for (const key of (asked ? asked.split(',') : []).slice(0, 24)) {
|
|
@@ -526,7 +582,7 @@ export function apply(ctx, config) {
|
|
|
526
582
|
if (!trimmed || trimmed.length > 128) continue
|
|
527
583
|
const parsed = splitRoute(trimmed)
|
|
528
584
|
if (!parsed) continue
|
|
529
|
-
const resolved =
|
|
585
|
+
const resolved = getCachedTariff(cfg, parsed)
|
|
530
586
|
schedules[trimmed] = {
|
|
531
587
|
schedule: resolved.schedule,
|
|
532
588
|
source: resolved.source,
|
|
@@ -534,11 +590,11 @@ export function apply(ctx, config) {
|
|
|
534
590
|
}
|
|
535
591
|
}
|
|
536
592
|
|
|
537
|
-
|
|
538
|
-
res.end(JSON.stringify({
|
|
593
|
+
const payloadObj = {
|
|
539
594
|
currency: cfg.currency,
|
|
540
595
|
usdRate: cfg.usdRate,
|
|
541
596
|
displayTimeZone: cfg.displayTimeZone || 'Europe/Moscow',
|
|
597
|
+
budgetThreshold: Number(cfg.budgetThreshold) || 0,
|
|
542
598
|
route,
|
|
543
599
|
source: own.source,
|
|
544
600
|
matchedId: own.matchedId,
|
|
@@ -547,7 +603,26 @@ export function apply(ctx, config) {
|
|
|
547
603
|
catalogError: catalog.error,
|
|
548
604
|
catalogModelsCount: Object.keys((catalog.index && catalog.index.byId) || {}).length,
|
|
549
605
|
schedule: own.schedule,
|
|
550
|
-
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const serialized = JSON.stringify(payloadObj)
|
|
609
|
+
const etag = 'W/"' + createHash('sha1').update(serialized).digest('base64url').slice(0, 16) + '"'
|
|
610
|
+
|
|
611
|
+
if (req.headers['if-none-match'] === etag) {
|
|
612
|
+
res.writeHead(304, {
|
|
613
|
+
'ETag': etag,
|
|
614
|
+
'Cache-Control': 'no-cache',
|
|
615
|
+
})
|
|
616
|
+
res.end()
|
|
617
|
+
return
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
res.writeHead(200, {
|
|
621
|
+
'Content-Type': 'application/json',
|
|
622
|
+
'Cache-Control': 'no-cache',
|
|
623
|
+
'ETag': etag,
|
|
624
|
+
})
|
|
625
|
+
res.end(serialized)
|
|
551
626
|
},
|
|
552
627
|
}),
|
|
553
628
|
'dsh-cost-meter: state route',
|
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.2",
|
|
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",
|