@goodandready/dsh-cost-meter 0.8.8 → 0.8.10
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/index.js +132 -305
- package/lib/tariff.js +236 -0
- package/package.json +1 -1
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
|
|
|
@@ -35,21 +38,40 @@ export function isLoopback(value) {
|
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
export function isTrustedCaller(request) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const site = request
|
|
41
|
-
if (site === 'same-origin' || site === 'same-site') return true
|
|
41
|
+
if (!request) return false
|
|
42
|
+
const remote = request.socket?.remoteAddress || request.connection?.remoteAddress
|
|
43
|
+
const site = request.headers?.['sec-fetch-site']
|
|
42
44
|
if (site === 'cross-site') return false
|
|
43
|
-
|
|
44
|
-
const host = request?.headers?.['host']
|
|
45
|
-
|
|
45
|
+
|
|
46
|
+
const host = request.headers?.['x-forwarded-host'] || request.headers?.['host']
|
|
47
|
+
const origin = request.headers?.['origin']
|
|
48
|
+
if (origin) {
|
|
46
49
|
try {
|
|
47
50
|
const url = new URL(origin)
|
|
48
|
-
if (url.host === host) return true
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
if (host && url.host.toLowerCase() === host.toLowerCase()) return true
|
|
52
|
+
if (isLoopback(url.hostname) && isLoopback(remote)) return true
|
|
53
|
+
return false
|
|
54
|
+
} catch {
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const referer = request.headers?.['referer']
|
|
60
|
+
if (referer) {
|
|
61
|
+
try {
|
|
62
|
+
const url = new URL(referer)
|
|
63
|
+
if (host && url.host.toLowerCase() === host.toLowerCase()) return true
|
|
64
|
+
if (isLoopback(url.hostname) && isLoopback(remote)) return true
|
|
65
|
+
return false
|
|
66
|
+
} catch {
|
|
67
|
+
return false
|
|
51
68
|
}
|
|
52
69
|
}
|
|
70
|
+
|
|
71
|
+
if (site === 'same-site') return false
|
|
72
|
+
if (site === 'same-origin') return true
|
|
73
|
+
if (isLoopback(remote)) return true
|
|
74
|
+
|
|
53
75
|
return false
|
|
54
76
|
}
|
|
55
77
|
|
|
@@ -64,35 +86,6 @@ const PriceRow = z.object({
|
|
|
64
86
|
output: z.number().description('Output tokens (reasoning included).').default(0),
|
|
65
87
|
})
|
|
66
88
|
|
|
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
89
|
export const Config = z.object({
|
|
97
90
|
currency: z.string().description('Currency symbol shown in the widget.').default('$'),
|
|
98
91
|
usdRate: z.number().description('Units of `currency` per USD. 1 keeps everything in USD.').default(1),
|
|
@@ -131,113 +124,6 @@ export const Config = z.object({
|
|
|
131
124
|
.default(1),
|
|
132
125
|
})
|
|
133
126
|
|
|
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
127
|
// ---------------------------------------------------------------------------
|
|
242
128
|
// Per-model usage projection
|
|
243
129
|
// ---------------------------------------------------------------------------
|
|
@@ -300,15 +186,35 @@ export function slotOf(timeMs) {
|
|
|
300
186
|
return Math.floor((d.getUTCHours() * 60 + d.getUTCMinutes()) / 30)
|
|
301
187
|
}
|
|
302
188
|
|
|
303
|
-
const
|
|
189
|
+
const modelSlotsSchema = zod.record(zod.string(), bucketSchema)
|
|
190
|
+
|
|
191
|
+
const costByModelViewSchema = zod.object({
|
|
192
|
+
models: zod.array(zod.object({
|
|
193
|
+
provider: zod.string(),
|
|
194
|
+
model: zod.string(),
|
|
195
|
+
slots: modelSlotsSchema,
|
|
196
|
+
}).strict()),
|
|
197
|
+
}).strict()
|
|
198
|
+
|
|
199
|
+
const costByModelStateSchema = zod.object({
|
|
200
|
+
route: zod.object({
|
|
201
|
+
provider: zod.string(),
|
|
202
|
+
model: zod.string(),
|
|
203
|
+
}).nullable(),
|
|
204
|
+
byKey: zod.record(zod.string(), modelSlotsSchema),
|
|
205
|
+
last: zod.object({
|
|
206
|
+
turn: zod.number().int().nonnegative(),
|
|
207
|
+
step: zod.number().int().nonnegative(),
|
|
208
|
+
key: zod.string(),
|
|
209
|
+
slot: zod.string(),
|
|
210
|
+
buckets: bucketSchema,
|
|
211
|
+
}).nullable(),
|
|
212
|
+
}).strict()
|
|
213
|
+
|
|
214
|
+
export const usageByModelProjection = {
|
|
304
215
|
key: 'costByModel',
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
provider: zod.string(),
|
|
308
|
-
model: zod.string(),
|
|
309
|
-
slots: zod.record(zod.string(), bucketSchema),
|
|
310
|
-
})),
|
|
311
|
-
}).strict(),
|
|
216
|
+
stateVersion: 2,
|
|
217
|
+
stateSchema: costByModelStateSchema,
|
|
312
218
|
init: () => ({ route: null, byKey: {}, last: null }),
|
|
313
219
|
apply: (state, event) => {
|
|
314
220
|
if (event.type === 'request/header') {
|
|
@@ -344,17 +250,19 @@ const usageByModelProjection = {
|
|
|
344
250
|
last: { turn: sample.turn, step: sample.step, key, slot, buckets },
|
|
345
251
|
}
|
|
346
252
|
},
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
253
|
+
wire: {
|
|
254
|
+
viewSchema: costByModelViewSchema,
|
|
255
|
+
view: (state) => ({
|
|
256
|
+
models: Object.keys(state.byKey).map((key) => {
|
|
257
|
+
const slash = key.indexOf('/')
|
|
258
|
+
return {
|
|
259
|
+
provider: key.slice(0, slash),
|
|
260
|
+
model: key.slice(slash + 1),
|
|
261
|
+
slots: state.byKey[key],
|
|
262
|
+
}
|
|
263
|
+
}),
|
|
355
264
|
}),
|
|
356
|
-
}
|
|
357
|
-
stateVersion: 2,
|
|
265
|
+
},
|
|
358
266
|
}
|
|
359
267
|
|
|
360
268
|
/** The agent's default route, read off settings descriptors when available. */
|
|
@@ -373,80 +281,6 @@ function defaultRoute(ctx) {
|
|
|
373
281
|
}
|
|
374
282
|
}
|
|
375
283
|
|
|
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
284
|
/** "provider/model" -> {provider, model}; the model half may contain slashes. */
|
|
451
285
|
export function splitRoute(key) {
|
|
452
286
|
const str = String(key || '').trim()
|
|
@@ -516,109 +350,102 @@ export function apply(ctx, config) {
|
|
|
516
350
|
}
|
|
517
351
|
}
|
|
518
352
|
|
|
519
|
-
// Live config from
|
|
520
|
-
ctx.
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
const stop = scope.watch(() => {
|
|
524
|
-
current = scope.get() ?? config
|
|
353
|
+
// Live config reaction from GUI / settings service
|
|
354
|
+
ctx.on('settings/updated', (ns, next) => {
|
|
355
|
+
if (ns === NS && next) {
|
|
356
|
+
current = { ...config, ...next }
|
|
525
357
|
clearResolveCache()
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
358
|
+
}
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
ctx.on('settings/document-updated', (ns) => {
|
|
362
|
+
if (!ns || ns === NS) {
|
|
531
363
|
clearResolveCache()
|
|
532
|
-
}
|
|
364
|
+
}
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
ctx.inject(['settings'], (sctx) => {
|
|
368
|
+
if (typeof sctx.settings?.describe === 'function') {
|
|
369
|
+
try {
|
|
370
|
+
const desc = sctx.settings.describe(NS)
|
|
371
|
+
if (desc?.value && typeof desc.value === 'object') {
|
|
372
|
+
current = { ...config, ...desc.value }
|
|
373
|
+
clearResolveCache()
|
|
374
|
+
}
|
|
375
|
+
} catch {
|
|
376
|
+
/* namespace might not have descriptor yet */
|
|
377
|
+
}
|
|
378
|
+
}
|
|
533
379
|
})
|
|
534
380
|
|
|
535
|
-
//
|
|
381
|
+
// Hook the session projection so the widget can show spend breakdown.
|
|
536
382
|
ctx.inject(['sessionProjections'], (pctx) => {
|
|
537
383
|
pctx.sessionProjections.register(usageByModelProjection)
|
|
538
384
|
})
|
|
539
385
|
|
|
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)
|
|
386
|
+
// Route 1: POST /dsh-cost-meter/refresh (manual catalog reload)
|
|
551
387
|
ctx.effect(
|
|
552
388
|
() =>
|
|
553
389
|
ctx.webServer.register({
|
|
554
|
-
kind: 'exact',
|
|
555
390
|
path: REFRESH_PATH,
|
|
391
|
+
method: 'POST',
|
|
556
392
|
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
393
|
if (!isTrustedCaller(req)) {
|
|
563
394
|
res.writeHead(403, { 'Content-Type': 'application/json' })
|
|
564
|
-
res.end(JSON.stringify({ error: 'forbidden
|
|
395
|
+
res.end(JSON.stringify({ error: 'forbidden' }))
|
|
565
396
|
return
|
|
566
397
|
}
|
|
567
398
|
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 }))
|
|
399
|
+
if (now - lastManualRefreshAt < 60_000) {
|
|
400
|
+
res.writeHead(429, { 'Content-Type': 'application/json' })
|
|
401
|
+
res.end(JSON.stringify({ error: 'rate_limited', retryAfterMs: 60_000 - (now - lastManualRefreshAt) }))
|
|
576
402
|
return
|
|
577
403
|
}
|
|
578
404
|
lastManualRefreshAt = now
|
|
579
|
-
|
|
580
|
-
await refresh(controller.signal, true)
|
|
581
|
-
const modelsCount = Object.keys((catalog.index && catalog.index.byId) || {}).length
|
|
405
|
+
await refresh(null, true)
|
|
582
406
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
583
|
-
res.end(
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
407
|
+
res.end(
|
|
408
|
+
JSON.stringify({
|
|
409
|
+
ok: !catalog.error,
|
|
410
|
+
fetchedAt: catalog.fetchedAt,
|
|
411
|
+
models: Object.keys((catalog.index && catalog.index.byId) || {}).length,
|
|
412
|
+
error: catalog.error,
|
|
413
|
+
}),
|
|
414
|
+
)
|
|
589
415
|
},
|
|
590
416
|
}),
|
|
591
417
|
'dsh-cost-meter: refresh route',
|
|
592
418
|
)
|
|
593
419
|
|
|
594
|
-
// Route 2: GET /dsh-cost-meter/state (
|
|
420
|
+
// Route 2: GET /dsh-cost-meter/state (the browser poller hits this)
|
|
595
421
|
ctx.effect(
|
|
596
422
|
() =>
|
|
597
423
|
ctx.webServer.register({
|
|
598
|
-
kind: 'exact',
|
|
599
424
|
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
|
-
}
|
|
425
|
+
method: 'GET',
|
|
426
|
+
handler: async (req, res) => {
|
|
606
427
|
const cfg = current ?? config
|
|
428
|
+
|
|
429
|
+
// On the very first request after boot, wait for the fetch if we have
|
|
430
|
+
// no disk cache either. Later runs refresh in the background.
|
|
431
|
+
if (!catalog.fetchedAt && !catalog.error) {
|
|
432
|
+
await refresh()
|
|
433
|
+
} else {
|
|
434
|
+
void refresh()
|
|
435
|
+
}
|
|
436
|
+
|
|
607
437
|
const route = defaultRoute(ctx) ?? null
|
|
608
438
|
const own = getCachedTariff(cfg, route)
|
|
609
439
|
|
|
440
|
+
// Also resolve tariffs for any session routes requested in query params
|
|
610
441
|
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,
|
|
442
|
+
const url = new URL(req.url, 'http://localhost')
|
|
443
|
+
const extraRoutes = url.searchParams.getAll('route')
|
|
444
|
+
for (const key of extraRoutes) {
|
|
445
|
+
const parsed = splitRoute(key)
|
|
446
|
+
if (parsed) {
|
|
447
|
+
const res = getCachedTariff(cfg, parsed)
|
|
448
|
+
if (res && res.schedule) schedules[key] = res.schedule
|
|
622
449
|
}
|
|
623
450
|
}
|
|
624
451
|
|
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.10",
|
|
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",
|