@goodandready/dsh-cost-meter 0.8.7 → 0.8.9
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 +9 -9
- package/lib/index.js +43 -275
- package/lib/tariff.js +236 -0
- package/package.json +4 -2
package/lib/client.js
CHANGED
|
@@ -341,7 +341,7 @@ window.__ModuleLoader__.load({
|
|
|
341
341
|
const ErrorBoundary = createErrorBoundary()
|
|
342
342
|
|
|
343
343
|
function getSettingsScope(ctx) {
|
|
344
|
-
return (ctx && typeof ctx.get === 'function' ? ctx.get('
|
|
344
|
+
return (ctx && typeof ctx.get === 'function' ? ctx.get('configForms') : null) || (ctx && ctx.configForms) || (ctx && ctx.services && ctx.services.configForms) || null
|
|
345
345
|
}
|
|
346
346
|
|
|
347
347
|
function refreshMirrorUntilVisible(ctx) {
|
|
@@ -520,10 +520,10 @@ window.__ModuleLoader__.load({
|
|
|
520
520
|
}
|
|
521
521
|
}
|
|
522
522
|
|
|
523
|
-
const
|
|
523
|
+
const configForms = getSettingsScope(ctx)
|
|
524
524
|
const scope = React.useMemo(
|
|
525
|
-
() => (
|
|
526
|
-
[
|
|
525
|
+
() => (configForms ? configForms.get(NS) : undefined),
|
|
526
|
+
[configForms],
|
|
527
527
|
)
|
|
528
528
|
|
|
529
529
|
const snapshot = React.useSyncExternalStore(
|
|
@@ -1013,15 +1013,15 @@ window.__ModuleLoader__.load({
|
|
|
1013
1013
|
|
|
1014
1014
|
React.useEffect(() => { ensureStyles() }, [])
|
|
1015
1015
|
|
|
1016
|
-
const
|
|
1016
|
+
const configForms = getSettingsScope(ctx)
|
|
1017
1017
|
const scope = React.useMemo(
|
|
1018
|
-
() => (
|
|
1019
|
-
[
|
|
1018
|
+
() => (configForms && typeof configForms.get === 'function' ? configForms.get(NS) : undefined),
|
|
1019
|
+
[configForms],
|
|
1020
1020
|
)
|
|
1021
1021
|
const settingsSnap = React.useSyncExternalStore
|
|
1022
1022
|
? React.useSyncExternalStore(
|
|
1023
1023
|
React.useMemo(() => (cb) => (scope && typeof scope.subscribe === 'function' ? scope.subscribe(cb) : () => {}), [scope]),
|
|
1024
|
-
React.useCallback(() => (scope && typeof scope.getSnapshot === 'function' ? scope.getSnapshot() : (
|
|
1024
|
+
React.useCallback(() => (scope && typeof scope.getSnapshot === 'function' ? scope.getSnapshot() : (configForms && typeof configForms.get === 'function' ? configForms.get(NS) : null)), [scope, configForms]),
|
|
1025
1025
|
React.useCallback(() => null, []),
|
|
1026
1026
|
)
|
|
1027
1027
|
: null
|
|
@@ -1441,7 +1441,7 @@ window.__ModuleLoader__.load({
|
|
|
1441
1441
|
|
|
1442
1442
|
module.exports = {
|
|
1443
1443
|
apply,
|
|
1444
|
-
inject: ['slots', 'locale', '
|
|
1444
|
+
inject: ['slots', 'locale', 'configForms'],
|
|
1445
1445
|
CostMeterCard,
|
|
1446
1446
|
CostMeter,
|
|
1447
1447
|
ErrorBoundary,
|
package/lib/index.js
CHANGED
|
@@ -21,6 +21,9 @@ import { dirname, join } from 'node:path'
|
|
|
21
21
|
import z from '@deepseek-ai/schemastery'
|
|
22
22
|
import { z as zod } from 'zod'
|
|
23
23
|
|
|
24
|
+
export * from './tariff.js'
|
|
25
|
+
import { indexCatalog, resolveTariff, matchModel } from './tariff.js'
|
|
26
|
+
|
|
24
27
|
export const name = '@goodandready/dsh-cost-meter'
|
|
25
28
|
export const inject = ['webServer']
|
|
26
29
|
|
|
@@ -64,35 +67,6 @@ const PriceRow = z.object({
|
|
|
64
67
|
output: z.number().description('Output tokens (reasoning included).').default(0),
|
|
65
68
|
})
|
|
66
69
|
|
|
67
|
-
/**
|
|
68
|
-
* DeepSeek's own tariff, from https://api-docs.deepseek.com/quick_start/pricing.
|
|
69
|
-
* Their API publishes no prices (/models lists ids, there is no pricing route),
|
|
70
|
-
* and OpenRouter's `deepseek/*` entries quote different numbers, so this table
|
|
71
|
-
* is the authoritative source for the official route. Values are PEAK, USD per
|
|
72
|
-
* 1M tokens; off-peak is exactly half. Peak: 01:00-04:00 and 06:00-10:00 UTC.
|
|
73
|
-
*/
|
|
74
|
-
const DEEPSEEK_PEAK_RATES = {
|
|
75
|
-
'deepseek-v4-flash': { cacheHit: 0.014, input: 0.44, cacheWrite: 0.44, output: 1.32 },
|
|
76
|
-
'deepseek-v4-pro': { cacheHit: 0.044, input: 1.32, cacheWrite: 1.32, output: 3.96 },
|
|
77
|
-
}
|
|
78
|
-
const DEEPSEEK_PEAK_WINDOWS = [{ start: 60, end: 240 }, { start: 360, end: 600 }]
|
|
79
|
-
|
|
80
|
-
/** Common provider / vendor aliases for seamless catalog matching. */
|
|
81
|
-
export const VENDOR_ALIASES = {
|
|
82
|
-
'z-ai': 'zai',
|
|
83
|
-
'zai': 'zai',
|
|
84
|
-
'google': 'google',
|
|
85
|
-
'gemini': 'google',
|
|
86
|
-
'deepseek-ai': 'deepseek',
|
|
87
|
-
'deepseek-official': 'deepseek',
|
|
88
|
-
'deepseek': 'deepseek',
|
|
89
|
-
'anthropic-ai': 'anthropic',
|
|
90
|
-
'anthropic': 'anthropic',
|
|
91
|
-
'openai': 'openai',
|
|
92
|
-
'meta-llama': 'meta-llama',
|
|
93
|
-
'meta': 'meta-llama',
|
|
94
|
-
}
|
|
95
|
-
|
|
96
70
|
export const Config = z.object({
|
|
97
71
|
currency: z.string().description('Currency symbol shown in the widget.').default('$'),
|
|
98
72
|
usdRate: z.number().description('Units of `currency` per USD. 1 keeps everything in USD.').default(1),
|
|
@@ -131,113 +105,6 @@ export const Config = z.object({
|
|
|
131
105
|
.default(1),
|
|
132
106
|
})
|
|
133
107
|
|
|
134
|
-
/** OpenRouter prices are USD per token; the widget speaks per 1M tokens. */
|
|
135
|
-
function perMillion(value) {
|
|
136
|
-
const n = Number(value)
|
|
137
|
-
return Number.isFinite(n) ? n * 1e6 : 0
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Pull the four buckets out of one OpenRouter pricing object. */
|
|
141
|
-
function bucketsOf(pricing) {
|
|
142
|
-
return {
|
|
143
|
-
cacheHit: perMillion(pricing.input_cache_read),
|
|
144
|
-
input: perMillion(pricing.prompt),
|
|
145
|
-
cacheWrite: perMillion(pricing.input_cache_write),
|
|
146
|
-
output: perMillion(pricing.completion),
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** OpenRouter encodes window edges as UTC HHMM integers (1000 = 10:00). */
|
|
151
|
-
export function hhmmToMinutes(value) {
|
|
152
|
-
const n = Number(value)
|
|
153
|
-
if (!Number.isFinite(n) || n < 0 || n > 2400) return undefined
|
|
154
|
-
return Math.floor(n / 100) * 60 + (n % 100)
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** "HH:MM-HH:MM" -> {start,end} in minutes; undefined when malformed. */
|
|
158
|
-
export function parseWindow(spec) {
|
|
159
|
-
const m = /^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/.exec(String(spec).trim())
|
|
160
|
-
if (!m) return undefined
|
|
161
|
-
return { start: Number(m[1]) * 60 + Number(m[2]), end: Number(m[3]) * 60 + Number(m[4]) }
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Normalize one catalog entry into the schedule the browser consumes:
|
|
166
|
-
* a base rate card plus the discount windows that shadow it.
|
|
167
|
-
*/
|
|
168
|
-
function scheduleOf(entry) {
|
|
169
|
-
const pricing = entry.pricing || {}
|
|
170
|
-
const base = bucketsOf(pricing)
|
|
171
|
-
const windows = []
|
|
172
|
-
for (const override of pricing.overrides || []) {
|
|
173
|
-
const start = hhmmToMinutes(override.utc_start)
|
|
174
|
-
const end = hhmmToMinutes(override.utc_end)
|
|
175
|
-
if (start === undefined || end === undefined) continue
|
|
176
|
-
// An override omits buckets it does not change; fall back to the base.
|
|
177
|
-
const rates = bucketsOf(override)
|
|
178
|
-
windows.push({
|
|
179
|
-
start,
|
|
180
|
-
end,
|
|
181
|
-
rates: {
|
|
182
|
-
cacheHit: rates.cacheHit || base.cacheHit,
|
|
183
|
-
input: rates.input || base.input,
|
|
184
|
-
cacheWrite: rates.cacheWrite || base.cacheWrite,
|
|
185
|
-
output: rates.output || base.output,
|
|
186
|
-
},
|
|
187
|
-
})
|
|
188
|
-
}
|
|
189
|
-
return { base, windows }
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Map a harness route onto an OpenRouter id. Exact id first, then normalized
|
|
194
|
-
* vendor alias + model, then vendor suffix.
|
|
195
|
-
*/
|
|
196
|
-
export function matchModel(index, route, modelMap) {
|
|
197
|
-
if (!route || !index) return undefined
|
|
198
|
-
const key = route.provider ? route.provider + '/' + route.model : route.model
|
|
199
|
-
const mapped = modelMap && (modelMap[key] || modelMap[route.model])
|
|
200
|
-
if (mapped && index.byId && index.byId[mapped]) return mapped
|
|
201
|
-
if (index.byId && index.byId[key]) return key
|
|
202
|
-
if (index.byId && index.byId[route.model]) return route.model
|
|
203
|
-
|
|
204
|
-
// Provider alias check (e.g. z-ai -> zai, deepseek-official -> deepseek)
|
|
205
|
-
const prov = String(route.provider || '').toLowerCase()
|
|
206
|
-
const aliasProv = VENDOR_ALIASES[prov]
|
|
207
|
-
if (aliasProv && route.model) {
|
|
208
|
-
const aliasKey = (aliasProv + '/' + route.model).toLowerCase()
|
|
209
|
-
if (index.byLowerId && index.byLowerId[aliasKey]) return index.byLowerId[aliasKey]
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Harness routes name models in their own casing (`MiniMax-M3`), the catalog
|
|
213
|
-
// is lower-case (`minimax/minimax-m3`), so matching is case-insensitive.
|
|
214
|
-
const lower = String(route.model).toLowerCase()
|
|
215
|
-
const byLowerId = index.byLowerId || {}
|
|
216
|
-
if (byLowerId[lower]) return byLowerId[lower]
|
|
217
|
-
if (byLowerId[key.toLowerCase()]) return byLowerId[key.toLowerCase()]
|
|
218
|
-
const bySuffix = index.bySuffix || {}
|
|
219
|
-
const list = bySuffix[lower]
|
|
220
|
-
return list && list.length ? list[0] : undefined
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** Build lookup tables once per fetch; the raw catalog is ~700 KB. */
|
|
224
|
-
export function indexCatalog(models) {
|
|
225
|
-
const byId = {}
|
|
226
|
-
const byLowerId = {}
|
|
227
|
-
const bySuffix = {}
|
|
228
|
-
for (const entry of models) {
|
|
229
|
-
if (!entry || typeof entry.id !== 'string' || !entry.pricing) continue
|
|
230
|
-
// Variants like ":batch" or ":free" are different products; skip them so a
|
|
231
|
-
// suffix match cannot silently price against a cheaper batch tier.
|
|
232
|
-
if (entry.id.includes(':')) continue
|
|
233
|
-
byId[entry.id] = scheduleOf(entry)
|
|
234
|
-
byLowerId[entry.id.toLowerCase()] = entry.id
|
|
235
|
-
const suffix = entry.id.includes('/') ? entry.id.slice(entry.id.indexOf('/') + 1) : entry.id
|
|
236
|
-
;(bySuffix[suffix.toLowerCase()] = bySuffix[suffix.toLowerCase()] || []).push(entry.id)
|
|
237
|
-
}
|
|
238
|
-
return { byId, byLowerId, bySuffix }
|
|
239
|
-
}
|
|
240
|
-
|
|
241
108
|
// ---------------------------------------------------------------------------
|
|
242
109
|
// Per-model usage projection
|
|
243
110
|
// ---------------------------------------------------------------------------
|
|
@@ -373,80 +240,6 @@ function defaultRoute(ctx) {
|
|
|
373
240
|
}
|
|
374
241
|
}
|
|
375
242
|
|
|
376
|
-
/**
|
|
377
|
-
* Resolve one route's tariff: manual config wins, then DeepSeek's own table,
|
|
378
|
-
* then the OpenRouter catalog.
|
|
379
|
-
*/
|
|
380
|
-
export function resolveTariff(cfg, index, route) {
|
|
381
|
-
if (!route) return { schedule: null, source: 'none', matchedId: null }
|
|
382
|
-
const key = route.provider ? route.provider + '/' + route.model : route.model
|
|
383
|
-
const prices = cfg.prices || {}
|
|
384
|
-
const manual = prices[key] || prices[route.model] || prices['*']
|
|
385
|
-
|
|
386
|
-
if (manual) {
|
|
387
|
-
const base = {
|
|
388
|
-
cacheHit: Number(manual.cacheHit) || 0,
|
|
389
|
-
input: Number(manual.input) || 0,
|
|
390
|
-
cacheWrite: Number(manual.cacheWrite) || 0,
|
|
391
|
-
output: Number(manual.output) || 0,
|
|
392
|
-
}
|
|
393
|
-
const factor = Number(cfg.manualOffPeakMultiplier) || 1
|
|
394
|
-
const windows = []
|
|
395
|
-
for (const spec of cfg.manualPeakWindowsUtc || []) {
|
|
396
|
-
const parsed = parseWindow(spec)
|
|
397
|
-
if (parsed) windows.push({ start: parsed.start, end: parsed.end, rates: base })
|
|
398
|
-
}
|
|
399
|
-
const schedule = windows.length
|
|
400
|
-
? {
|
|
401
|
-
base: {
|
|
402
|
-
cacheHit: base.cacheHit * factor,
|
|
403
|
-
input: base.input * factor,
|
|
404
|
-
cacheWrite: base.cacheWrite * factor,
|
|
405
|
-
output: base.output * factor,
|
|
406
|
-
},
|
|
407
|
-
windows,
|
|
408
|
-
}
|
|
409
|
-
: { base, windows: [] }
|
|
410
|
-
return { schedule, source: 'manual', matchedId: key }
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
const providerLower = String(route.provider || '').toLowerCase()
|
|
414
|
-
if (providerLower === 'openrouter' && cfg.useOpenRouter !== false) {
|
|
415
|
-
const matchedId = matchModel(index, route, cfg.modelMap)
|
|
416
|
-
if (matchedId) return { schedule: index.byId[matchedId], source: 'openrouter', matchedId }
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
const base = String(route.model).toLowerCase().split(':')[0]
|
|
420
|
-
const peak = (cfg.deepseekPeakPrices || {})[base] || DEEPSEEK_PEAK_RATES[base]
|
|
421
|
-
if (peak) {
|
|
422
|
-
const full = {
|
|
423
|
-
cacheHit: Number(peak.cacheHit) || 0,
|
|
424
|
-
input: Number(peak.input) || 0,
|
|
425
|
-
cacheWrite: Number(peak.cacheWrite) || 0,
|
|
426
|
-
output: Number(peak.output) || 0,
|
|
427
|
-
}
|
|
428
|
-
return {
|
|
429
|
-
schedule: {
|
|
430
|
-
base: {
|
|
431
|
-
cacheHit: full.cacheHit / 2,
|
|
432
|
-
input: full.input / 2,
|
|
433
|
-
cacheWrite: full.cacheWrite / 2,
|
|
434
|
-
output: full.output / 2,
|
|
435
|
-
},
|
|
436
|
-
windows: DEEPSEEK_PEAK_WINDOWS.map((w) => ({ start: w.start, end: w.end, rates: full })),
|
|
437
|
-
},
|
|
438
|
-
source: 'deepseek',
|
|
439
|
-
matchedId: base,
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
if (cfg.useOpenRouter !== false) {
|
|
444
|
-
const matchedId = matchModel(index, route, cfg.modelMap)
|
|
445
|
-
if (matchedId) return { schedule: index.byId[matchedId], source: 'openrouter', matchedId }
|
|
446
|
-
}
|
|
447
|
-
return { schedule: null, source: 'none', matchedId: null }
|
|
448
|
-
}
|
|
449
|
-
|
|
450
243
|
/** "provider/model" -> {provider, model}; the model half may contain slashes. */
|
|
451
244
|
export function splitRoute(key) {
|
|
452
245
|
const str = String(key || '').trim()
|
|
@@ -520,105 +313,80 @@ export function apply(ctx, config) {
|
|
|
520
313
|
ctx.inject(['settings'], (sctx) => {
|
|
521
314
|
const scope = sctx.settings.register(NS, Config, { base: config })
|
|
522
315
|
current = scope.get() ?? config
|
|
523
|
-
|
|
524
|
-
current =
|
|
525
|
-
clearResolveCache()
|
|
526
|
-
void refresh()
|
|
527
|
-
})
|
|
528
|
-
sctx.effect(() => () => {
|
|
529
|
-
stop()
|
|
530
|
-
current = config
|
|
316
|
+
scope.watch((next) => {
|
|
317
|
+
current = next
|
|
531
318
|
clearResolveCache()
|
|
532
319
|
})
|
|
533
320
|
})
|
|
534
321
|
|
|
535
|
-
//
|
|
536
|
-
ctx.inject(['
|
|
537
|
-
pctx.
|
|
322
|
+
// Hook the session projection so the widget can show spend breakdown.
|
|
323
|
+
ctx.inject(['chatSessionProjections'], (pctx) => {
|
|
324
|
+
pctx.chatSessionProjections.register(usageByModelProjection)
|
|
538
325
|
})
|
|
539
326
|
|
|
540
|
-
|
|
541
|
-
const controller = new AbortController()
|
|
542
|
-
void refresh(controller.signal)
|
|
543
|
-
const timer = setInterval(() => void refresh(controller.signal), 3600e3)
|
|
544
|
-
return () => {
|
|
545
|
-
clearInterval(timer)
|
|
546
|
-
controller.abort()
|
|
547
|
-
}
|
|
548
|
-
}, 'dsh-cost-meter: catalog refresh')
|
|
549
|
-
|
|
550
|
-
// Route 1: POST /dsh-cost-meter/refresh (manual on-demand sync with 30s cooldown)
|
|
327
|
+
// Route 1: POST /dsh-cost-meter/refresh (manual catalog reload)
|
|
551
328
|
ctx.effect(
|
|
552
329
|
() =>
|
|
553
330
|
ctx.webServer.register({
|
|
554
|
-
kind: 'exact',
|
|
555
331
|
path: REFRESH_PATH,
|
|
332
|
+
method: 'POST',
|
|
556
333
|
handler: async (req, res) => {
|
|
557
|
-
if (req.method !== 'POST') {
|
|
558
|
-
res.writeHead(405, { 'Content-Type': 'application/json' })
|
|
559
|
-
res.end(JSON.stringify({ error: 'method not allowed' }))
|
|
560
|
-
return
|
|
561
|
-
}
|
|
562
334
|
if (!isTrustedCaller(req)) {
|
|
563
335
|
res.writeHead(403, { 'Content-Type': 'application/json' })
|
|
564
|
-
res.end(JSON.stringify({ error: 'forbidden
|
|
336
|
+
res.end(JSON.stringify({ error: 'forbidden' }))
|
|
565
337
|
return
|
|
566
338
|
}
|
|
567
339
|
const now = Date.now()
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
res.writeHead(429, {
|
|
572
|
-
'Content-Type': 'application/json',
|
|
573
|
-
'Retry-After': String(retryAfter),
|
|
574
|
-
})
|
|
575
|
-
res.end(JSON.stringify({ error: 'rate limit exceeded', retryAfter }))
|
|
340
|
+
if (now - lastManualRefreshAt < 60_000) {
|
|
341
|
+
res.writeHead(429, { 'Content-Type': 'application/json' })
|
|
342
|
+
res.end(JSON.stringify({ error: 'rate_limited', retryAfterMs: 60_000 - (now - lastManualRefreshAt) }))
|
|
576
343
|
return
|
|
577
344
|
}
|
|
578
345
|
lastManualRefreshAt = now
|
|
579
|
-
|
|
580
|
-
await refresh(controller.signal, true)
|
|
581
|
-
const modelsCount = Object.keys((catalog.index && catalog.index.byId) || {}).length
|
|
346
|
+
await refresh(null, true)
|
|
582
347
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
583
|
-
res.end(
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
348
|
+
res.end(
|
|
349
|
+
JSON.stringify({
|
|
350
|
+
ok: !catalog.error,
|
|
351
|
+
fetchedAt: catalog.fetchedAt,
|
|
352
|
+
models: Object.keys((catalog.index && catalog.index.byId) || {}).length,
|
|
353
|
+
error: catalog.error,
|
|
354
|
+
}),
|
|
355
|
+
)
|
|
589
356
|
},
|
|
590
357
|
}),
|
|
591
358
|
'dsh-cost-meter: refresh route',
|
|
592
359
|
)
|
|
593
360
|
|
|
594
|
-
// Route 2: GET /dsh-cost-meter/state (
|
|
361
|
+
// Route 2: GET /dsh-cost-meter/state (the browser poller hits this)
|
|
595
362
|
ctx.effect(
|
|
596
363
|
() =>
|
|
597
364
|
ctx.webServer.register({
|
|
598
|
-
kind: 'exact',
|
|
599
365
|
path: STATE_PATH,
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
res.writeHead(405, { 'Content-Type': 'application/json' })
|
|
603
|
-
res.end(JSON.stringify({ error: 'method not allowed' }))
|
|
604
|
-
return
|
|
605
|
-
}
|
|
366
|
+
method: 'GET',
|
|
367
|
+
handler: async (req, res) => {
|
|
606
368
|
const cfg = current ?? config
|
|
369
|
+
|
|
370
|
+
// On the very first request after boot, wait for the fetch if we have
|
|
371
|
+
// no disk cache either. Later runs refresh in the background.
|
|
372
|
+
if (!catalog.fetchedAt && !catalog.error) {
|
|
373
|
+
await refresh()
|
|
374
|
+
} else {
|
|
375
|
+
void refresh()
|
|
376
|
+
}
|
|
377
|
+
|
|
607
378
|
const route = defaultRoute(ctx) ?? null
|
|
608
379
|
const own = getCachedTariff(cfg, route)
|
|
609
380
|
|
|
381
|
+
// Also resolve tariffs for any session routes requested in query params
|
|
610
382
|
const schedules = {}
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
schedules[trimmed] = {
|
|
619
|
-
schedule: resolved.schedule,
|
|
620
|
-
source: resolved.source,
|
|
621
|
-
matchedId: resolved.matchedId,
|
|
383
|
+
const url = new URL(req.url, 'http://localhost')
|
|
384
|
+
const extraRoutes = url.searchParams.getAll('route')
|
|
385
|
+
for (const key of extraRoutes) {
|
|
386
|
+
const parsed = splitRoute(key)
|
|
387
|
+
if (parsed) {
|
|
388
|
+
const res = getCachedTariff(cfg, parsed)
|
|
389
|
+
if (res && res.schedule) schedules[key] = res.schedule
|
|
622
390
|
}
|
|
623
391
|
}
|
|
624
392
|
|
package/lib/tariff.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Tariff calculation, normalization, catalog indexing, and model matching.
|
|
2
|
+
// Extracted from lib/index.js for modularity (<600 LOC per file).
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* DeepSeek's own tariff, from https://api-docs.deepseek.com/quick_start/pricing.
|
|
6
|
+
* Their API publishes no prices (/models lists ids, there is no pricing route),
|
|
7
|
+
* and OpenRouter's `deepseek/*` entries quote different numbers, so this table
|
|
8
|
+
* is the authoritative source for the official route. Values are PEAK, USD per
|
|
9
|
+
* 1M tokens; off-peak is exactly half. Peak: 01:00-04:00 and 06:00-10:00 UTC.
|
|
10
|
+
*/
|
|
11
|
+
export const DEEPSEEK_PEAK_RATES = {
|
|
12
|
+
'deepseek-v4-flash': { cacheHit: 0.014, input: 0.44, cacheWrite: 0.44, output: 1.32 },
|
|
13
|
+
'deepseek-v4-pro': { cacheHit: 0.044, input: 1.32, cacheWrite: 1.32, output: 3.96 },
|
|
14
|
+
}
|
|
15
|
+
export const DEEPSEEK_PEAK_WINDOWS = [{ start: 60, end: 240 }, { start: 360, end: 600 }]
|
|
16
|
+
|
|
17
|
+
/** Common provider / vendor aliases for seamless catalog matching. */
|
|
18
|
+
export const VENDOR_ALIASES = {
|
|
19
|
+
'z-ai': 'zai',
|
|
20
|
+
'zai': 'zai',
|
|
21
|
+
'google': 'google',
|
|
22
|
+
'gemini': 'google',
|
|
23
|
+
'deepseek-ai': 'deepseek',
|
|
24
|
+
'deepseek-official': 'deepseek',
|
|
25
|
+
'deepseek': 'deepseek',
|
|
26
|
+
'anthropic-ai': 'anthropic',
|
|
27
|
+
'anthropic': 'anthropic',
|
|
28
|
+
'openai': 'openai',
|
|
29
|
+
'meta-llama': 'meta-llama',
|
|
30
|
+
'meta': 'meta-llama',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** OpenRouter prices are USD per token; the widget speaks per 1M tokens. */
|
|
34
|
+
export function perMillion(value) {
|
|
35
|
+
const n = Number(value)
|
|
36
|
+
return Number.isFinite(n) ? n * 1e6 : 0
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Pull the four buckets out of one OpenRouter pricing object. */
|
|
40
|
+
export function bucketsOf(pricing) {
|
|
41
|
+
return {
|
|
42
|
+
cacheHit: perMillion(pricing.input_cache_read),
|
|
43
|
+
input: perMillion(pricing.prompt),
|
|
44
|
+
cacheWrite: perMillion(pricing.input_cache_write),
|
|
45
|
+
output: perMillion(pricing.completion),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** OpenRouter encodes window edges as UTC HHMM integers (1000 = 10:00). */
|
|
50
|
+
export function hhmmToMinutes(value) {
|
|
51
|
+
const n = Number(value)
|
|
52
|
+
if (!Number.isFinite(n) || n < 0 || n > 2400) return undefined
|
|
53
|
+
return Math.floor(n / 100) * 60 + (n % 100)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** "HH:MM-HH:MM" -> {start,end} in minutes; undefined when malformed. */
|
|
57
|
+
export function parseWindow(spec) {
|
|
58
|
+
const m = /^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/.exec(String(spec).trim())
|
|
59
|
+
if (!m) return undefined
|
|
60
|
+
return { start: Number(m[1]) * 60 + Number(m[2]), end: Number(m[3]) * 60 + Number(m[4]) }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function overrideBucket(overrideVal, baseVal) {
|
|
64
|
+
if (overrideVal !== undefined && overrideVal !== null && overrideVal !== '') {
|
|
65
|
+
const n = Number(overrideVal)
|
|
66
|
+
if (Number.isFinite(n)) return n * 1e6
|
|
67
|
+
}
|
|
68
|
+
return baseVal
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Normalize one catalog entry into the schedule the browser consumes:
|
|
73
|
+
* a base rate card plus the discount windows that shadow it.
|
|
74
|
+
*/
|
|
75
|
+
export function scheduleOf(entry) {
|
|
76
|
+
const pricing = entry.pricing || {}
|
|
77
|
+
const base = bucketsOf(pricing)
|
|
78
|
+
const windows = []
|
|
79
|
+
for (const override of pricing.overrides || []) {
|
|
80
|
+
const start = hhmmToMinutes(override.utc_start)
|
|
81
|
+
const end = hhmmToMinutes(override.utc_end)
|
|
82
|
+
if (start === undefined || end === undefined) continue
|
|
83
|
+
// An override omits buckets it does not change; fall back to the base.
|
|
84
|
+
// Explicit 0 in override must NOT fall back to non-zero base price.
|
|
85
|
+
windows.push({
|
|
86
|
+
start,
|
|
87
|
+
end,
|
|
88
|
+
rates: {
|
|
89
|
+
cacheHit: overrideBucket(override.input_cache_read, base.cacheHit),
|
|
90
|
+
input: overrideBucket(override.prompt, base.input),
|
|
91
|
+
cacheWrite: overrideBucket(override.input_cache_write, base.cacheWrite),
|
|
92
|
+
output: overrideBucket(override.completion, base.output),
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
return { base, windows }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Map a harness route onto an OpenRouter id. Exact id first, then normalized
|
|
101
|
+
* vendor alias + model, then vendor suffix.
|
|
102
|
+
*/
|
|
103
|
+
export function matchModel(index, route, modelMap) {
|
|
104
|
+
if (!route || !index) return undefined
|
|
105
|
+
const key = route.provider ? route.provider + '/' + route.model : route.model
|
|
106
|
+
const mapped = modelMap && (modelMap[key] || modelMap[route.model])
|
|
107
|
+
if (mapped && index.byId && index.byId[mapped]) return mapped
|
|
108
|
+
if (index.byId && index.byId[key]) return key
|
|
109
|
+
if (index.byId && index.byId[route.model]) return route.model
|
|
110
|
+
|
|
111
|
+
// Provider alias check (e.g. z-ai -> zai, deepseek-official -> deepseek)
|
|
112
|
+
const prov = String(route.provider || '').toLowerCase()
|
|
113
|
+
const aliasProv = VENDOR_ALIASES[prov]
|
|
114
|
+
if (aliasProv && route.model) {
|
|
115
|
+
const aliasKey = (aliasProv + '/' + route.model).toLowerCase()
|
|
116
|
+
if (index.byLowerId && index.byLowerId[aliasKey]) return index.byLowerId[aliasKey]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Harness routes name models in their own casing (`MiniMax-M3`), the catalog
|
|
120
|
+
// is lower-case (`minimax/minimax-m3`), so matching is case-insensitive.
|
|
121
|
+
const lower = String(route.model).toLowerCase()
|
|
122
|
+
const byLowerId = index.byLowerId || {}
|
|
123
|
+
if (byLowerId[lower]) return byLowerId[lower]
|
|
124
|
+
if (byLowerId[key.toLowerCase()]) return byLowerId[key.toLowerCase()]
|
|
125
|
+
const bySuffix = index.bySuffix || {}
|
|
126
|
+
const list = bySuffix[lower]
|
|
127
|
+
if (!list || !list.length) return undefined
|
|
128
|
+
if (list.length === 1) return list[0]
|
|
129
|
+
|
|
130
|
+
// Multiple catalog entries share the same model suffix: disambiguate by provider
|
|
131
|
+
if (route.provider) {
|
|
132
|
+
const provLower = String(route.provider).toLowerCase()
|
|
133
|
+
const normProv = VENDOR_ALIASES[provLower] || provLower
|
|
134
|
+
const matched = list.find((id) => {
|
|
135
|
+
const slash = id.indexOf('/')
|
|
136
|
+
if (slash < 0) return false
|
|
137
|
+
const prefix = id.slice(0, slash).toLowerCase()
|
|
138
|
+
return prefix === normProv || prefix === provLower
|
|
139
|
+
})
|
|
140
|
+
if (matched) return matched
|
|
141
|
+
}
|
|
142
|
+
// Ambiguous suffix with no provider match: do not guess arbitrarily
|
|
143
|
+
return undefined
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Build lookup tables once per fetch; the raw catalog is ~700 KB. */
|
|
147
|
+
export function indexCatalog(models) {
|
|
148
|
+
const byId = {}
|
|
149
|
+
const byLowerId = {}
|
|
150
|
+
const bySuffix = {}
|
|
151
|
+
for (const entry of models) {
|
|
152
|
+
if (!entry || typeof entry.id !== 'string' || !entry.pricing) continue
|
|
153
|
+
// Variants like ":batch" or ":free" are different products; skip them so a
|
|
154
|
+
// suffix match cannot silently price against a cheaper batch tier.
|
|
155
|
+
if (entry.id.includes(':')) continue
|
|
156
|
+
byId[entry.id] = scheduleOf(entry)
|
|
157
|
+
byLowerId[entry.id.toLowerCase()] = entry.id
|
|
158
|
+
const suffix = entry.id.includes('/') ? entry.id.slice(entry.id.indexOf('/') + 1) : entry.id
|
|
159
|
+
;(bySuffix[suffix.toLowerCase()] = bySuffix[suffix.toLowerCase()] || []).push(entry.id)
|
|
160
|
+
}
|
|
161
|
+
return { byId, byLowerId, bySuffix }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Resolve one route's tariff: manual config wins, then DeepSeek's own table,
|
|
166
|
+
* then the OpenRouter catalog.
|
|
167
|
+
*/
|
|
168
|
+
export function resolveTariff(cfg, index, route) {
|
|
169
|
+
if (!route) return { schedule: null, source: 'none', matchedId: null }
|
|
170
|
+
const key = route.provider ? route.provider + '/' + route.model : route.model
|
|
171
|
+
const prices = cfg.prices || {}
|
|
172
|
+
const manual = prices[key] || prices[route.model] || prices['*']
|
|
173
|
+
|
|
174
|
+
if (manual) {
|
|
175
|
+
const base = {
|
|
176
|
+
cacheHit: Number(manual.cacheHit) || 0,
|
|
177
|
+
input: Number(manual.input) || 0,
|
|
178
|
+
cacheWrite: Number(manual.cacheWrite) || 0,
|
|
179
|
+
output: Number(manual.output) || 0,
|
|
180
|
+
}
|
|
181
|
+
const factor = Number(cfg.manualOffPeakMultiplier) || 1
|
|
182
|
+
const windows = []
|
|
183
|
+
for (const spec of cfg.manualPeakWindowsUtc || []) {
|
|
184
|
+
const parsed = parseWindow(spec)
|
|
185
|
+
if (parsed) windows.push({ start: parsed.start, end: parsed.end, rates: base })
|
|
186
|
+
}
|
|
187
|
+
const schedule = windows.length
|
|
188
|
+
? {
|
|
189
|
+
base: {
|
|
190
|
+
cacheHit: base.cacheHit * factor,
|
|
191
|
+
input: base.input * factor,
|
|
192
|
+
cacheWrite: base.cacheWrite * factor,
|
|
193
|
+
output: base.output * factor,
|
|
194
|
+
},
|
|
195
|
+
windows,
|
|
196
|
+
}
|
|
197
|
+
: { base, windows: [] }
|
|
198
|
+
return { schedule, source: 'manual', matchedId: key }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const providerLower = String(route.provider || '').toLowerCase()
|
|
202
|
+
if (providerLower === 'openrouter' && cfg.useOpenRouter !== false) {
|
|
203
|
+
const matchedId = matchModel(index, route, cfg.modelMap)
|
|
204
|
+
if (matchedId) return { schedule: index.byId[matchedId], source: 'openrouter', matchedId }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const base = String(route.model).toLowerCase().split(':')[0]
|
|
208
|
+
const peak = (cfg.deepseekPeakPrices || {})[base] || DEEPSEEK_PEAK_RATES[base]
|
|
209
|
+
if (peak) {
|
|
210
|
+
const full = {
|
|
211
|
+
cacheHit: Number(peak.cacheHit) || 0,
|
|
212
|
+
input: Number(peak.input) || 0,
|
|
213
|
+
cacheWrite: Number(peak.cacheWrite) || 0,
|
|
214
|
+
output: Number(peak.output) || 0,
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
schedule: {
|
|
218
|
+
base: {
|
|
219
|
+
cacheHit: full.cacheHit / 2,
|
|
220
|
+
input: full.input / 2,
|
|
221
|
+
cacheWrite: full.cacheWrite / 2,
|
|
222
|
+
output: full.output / 2,
|
|
223
|
+
},
|
|
224
|
+
windows: DEEPSEEK_PEAK_WINDOWS.map((w) => ({ start: w.start, end: w.end, rates: full })),
|
|
225
|
+
},
|
|
226
|
+
source: 'deepseek',
|
|
227
|
+
matchedId: base,
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (cfg.useOpenRouter !== false) {
|
|
232
|
+
const matchedId = matchModel(index, route, cfg.modelMap)
|
|
233
|
+
if (matchedId) return { schedule: index.byId[matchedId], source: 'openrouter', matchedId }
|
|
234
|
+
}
|
|
235
|
+
return { schedule: null, source: 'none', matchedId: null }
|
|
236
|
+
}
|
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.9",
|
|
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",
|
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
"client": {
|
|
29
29
|
"platform": "web",
|
|
30
30
|
"inject": [
|
|
31
|
-
"@deepseek-ai/dsh-client-locale"
|
|
31
|
+
"@deepseek-ai/dsh-client-locale",
|
|
32
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
33
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
32
34
|
]
|
|
33
35
|
}
|
|
34
36
|
},
|