@goodandready/dsh-cost-meter 0.8.16 → 0.8.18
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 +56 -9
- package/lib/index.js +58 -57
- package/lib/routes.js +7 -6
- package/lib/tariff.js +22 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -961,6 +961,28 @@ window.__ModuleLoader__.load({
|
|
|
961
961
|
return usd
|
|
962
962
|
}
|
|
963
963
|
|
|
964
|
+
function cacheSavingsBySlots(slots, schedule) {
|
|
965
|
+
let savedUsd = 0
|
|
966
|
+
let missedUsd = 0
|
|
967
|
+
let readTokens = 0
|
|
968
|
+
for (const key of Object.keys(slots || {})) {
|
|
969
|
+
const minute = (Number(key) * 30 + 15) % 1440
|
|
970
|
+
const rates = ratesAt(schedule, minute)
|
|
971
|
+
const u = slots[key] || {}
|
|
972
|
+
const cRead = Number(u.cacheReadTokens) || 0
|
|
973
|
+
if (cRead > 0 && rates && rates.input > rates.cacheHit) {
|
|
974
|
+
readTokens += cRead
|
|
975
|
+
const missed = (cRead / 1e6) * (rates.input || 0)
|
|
976
|
+
const hit = (cRead / 1e6) * (rates.cacheHit || 0)
|
|
977
|
+
savedUsd += Math.max(0, missed - hit)
|
|
978
|
+
missedUsd += missed
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
const percent = missedUsd > 0 ? Math.round((savedUsd / missedUsd) * 100) : 0
|
|
982
|
+
return { savedUsd, missedUsd, readTokens, percent }
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
|
|
964
986
|
function spendUsd(usage, rates) {
|
|
965
987
|
const u = usage || {}
|
|
966
988
|
const perMillion =
|
|
@@ -1193,17 +1215,36 @@ window.__ModuleLoader__.load({
|
|
|
1193
1215
|
.join(', ')
|
|
1194
1216
|
const nowLocal = hhmmInZone(minute, zone, now)
|
|
1195
1217
|
|
|
1196
|
-
// Calculate cache savings amount
|
|
1218
|
+
// Calculate cache savings amount across all used models (#92)
|
|
1197
1219
|
let cacheSavedAmount = 0
|
|
1198
1220
|
let cacheSavedPercent = 0
|
|
1199
|
-
if (hasUsage
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1221
|
+
if (hasUsage) {
|
|
1222
|
+
if (perModel.length > 0) {
|
|
1223
|
+
let totalSavedUsd = 0
|
|
1224
|
+
let totalMissedUsd = 0
|
|
1225
|
+
for (const m of perModel) {
|
|
1226
|
+
const own = schedules[m.key]
|
|
1227
|
+
const sched = own && own.schedule ? own.schedule : schedule
|
|
1228
|
+
if (sched) {
|
|
1229
|
+
const res = cacheSavingsBySlots(m.slots || (m.usage ? { 0: m.usage } : {}), sched)
|
|
1230
|
+
totalSavedUsd += res.savedUsd
|
|
1231
|
+
totalMissedUsd += res.missedUsd
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
if (totalSavedUsd > 0) {
|
|
1235
|
+
cacheSavedAmount = totalSavedUsd * fx
|
|
1236
|
+
cacheSavedPercent = totalMissedUsd > 0 ? Math.round((totalSavedUsd / totalMissedUsd) * 100) : 0
|
|
1237
|
+
}
|
|
1238
|
+
} else if (usage && Number(usage.cacheReadTokens) > 0 && active.input > active.cacheHit) {
|
|
1239
|
+
const tokensRead = Number(usage.cacheReadTokens)
|
|
1240
|
+
const missedCost = (tokensRead / 1e6) * active.input
|
|
1241
|
+
const hitCost = (tokensRead / 1e6) * active.cacheHit
|
|
1242
|
+
cacheSavedAmount = Math.max(0, (missedCost - hitCost) * fx)
|
|
1243
|
+
cacheSavedPercent = missedCost > 0 ? Math.round(((missedCost - hitCost) / missedCost) * 100) : 0
|
|
1244
|
+
}
|
|
1205
1245
|
}
|
|
1206
1246
|
|
|
1247
|
+
|
|
1207
1248
|
// Copy summary report to clipboard
|
|
1208
1249
|
const copyReport = () => {
|
|
1209
1250
|
const lines = [
|
|
@@ -1255,9 +1296,11 @@ window.__ModuleLoader__.load({
|
|
|
1255
1296
|
key: 'chip', type: 'button',
|
|
1256
1297
|
className: 'dcm-chip',
|
|
1257
1298
|
onClick: () => setOpen((v) => !v),
|
|
1258
|
-
title: budgetExceeded
|
|
1299
|
+
title: (budgetExceeded
|
|
1259
1300
|
? t('budget.exceeded', { limit: money(budgetThreshold * fx, currency) })
|
|
1260
|
-
: (flat ? 'Flat tariff' : cheapNow ? t('offpeak') : t('peak'))
|
|
1301
|
+
: (flat ? 'Flat tariff' : cheapNow ? t('offpeak') : t('peak'))) +
|
|
1302
|
+
(cacheSavedAmount > 0 ? ' · ' + t('savings.cache_saved', { amount: money(cacheSavedAmount, currency), percent: cacheSavedPercent }) : ''),
|
|
1303
|
+
|
|
1261
1304
|
}, [
|
|
1262
1305
|
h('span', { key: 'dot', className: dotClass }),
|
|
1263
1306
|
h('span', { key: 'sum' }, hasUsage ? '≈ ' + money(total, currency) : '—'),
|
|
@@ -1480,8 +1523,12 @@ window.__ModuleLoader__.load({
|
|
|
1480
1523
|
minutesUntilChange,
|
|
1481
1524
|
extremes,
|
|
1482
1525
|
ratesAt,
|
|
1526
|
+
spendBySlots,
|
|
1527
|
+
cacheSavingsBySlots,
|
|
1483
1528
|
fetchWithTimeout,
|
|
1484
1529
|
}
|
|
1530
|
+
|
|
1485
1531
|
return module.exports
|
|
1486
1532
|
},
|
|
1487
1533
|
})
|
|
1534
|
+
|
package/lib/index.js
CHANGED
|
@@ -21,19 +21,16 @@ import z from '@deepseek-ai/schemastery'
|
|
|
21
21
|
|
|
22
22
|
// Ensure .volatile() helper exists on Schemastery schema prototype for DSH 0.2
|
|
23
23
|
try {
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
return typeof this.extra === 'function' ? this.extra('volatile', true) : this
|
|
28
|
-
}
|
|
24
|
+
const p = Object.getPrototypeOf(z.boolean())
|
|
25
|
+
if (p && typeof p.volatile !== 'function') {
|
|
26
|
+
p.volatile = function () { return typeof this.extra === 'function' ? this.extra('volatile', true) : this }
|
|
29
27
|
}
|
|
30
|
-
} catch {
|
|
31
|
-
|
|
32
|
-
}
|
|
28
|
+
} catch {}
|
|
29
|
+
|
|
33
30
|
export * from './tariff.js'
|
|
34
31
|
export * from './projection.js'
|
|
35
32
|
export { STATE_PATH, REFRESH_PATH } from './routes.js'
|
|
36
|
-
import { indexCatalog, resolveTariff, matchModel } from './tariff.js'
|
|
33
|
+
import { indexCatalog, resolveTariff, matchModel, unwrapVolatile } from './tariff.js'
|
|
37
34
|
import { usageByModelProjection } from './projection.js'
|
|
38
35
|
|
|
39
36
|
export const name = '@goodandready/dsh-cost-meter'
|
|
@@ -43,12 +40,30 @@ const NS = 'dsh-cost-meter'
|
|
|
43
40
|
export function isLoopback(value) {
|
|
44
41
|
const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
|
|
45
42
|
return address === 'localhost' || address === 'localhost.' || address === '::1'
|
|
46
|
-
|| address?.startsWith('127.') === true
|
|
47
|
-
|
|
43
|
+
|| address?.startsWith('127.') === true || address?.startsWith('::ffff:127.') === true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getConnection(ctx) {
|
|
47
|
+
try {
|
|
48
|
+
const f = ctx?.reflect?.get
|
|
49
|
+
? (ctx.reflect.get('connection') || ctx?.connection)
|
|
50
|
+
: (ctx?.get ? (ctx.get('connection') || ctx?.connection) : Reflect.get(ctx || {}, 'connection'))
|
|
51
|
+
return f || (ctx?.root ? getConnection(ctx.root) : undefined)
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
48
55
|
}
|
|
49
56
|
|
|
50
|
-
export function isTrustedCaller(request) {
|
|
57
|
+
export function isTrustedCaller(request, ctx = null) {
|
|
51
58
|
if (!request) return false
|
|
59
|
+
const conn = getConnection(ctx) || request?.connectionService || request?.connection
|
|
60
|
+
if (conn && typeof conn.requestRejection === 'function') {
|
|
61
|
+
try {
|
|
62
|
+
if (conn.requestRejection(request) !== undefined) return false
|
|
63
|
+
} catch {
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
52
67
|
const remote = request.socket?.remoteAddress || request.connection?.remoteAddress
|
|
53
68
|
const site = request.headers?.['sec-fetch-site']
|
|
54
69
|
if (site === 'cross-site') return false
|
|
@@ -95,6 +110,24 @@ export function isTrustedCaller(request) {
|
|
|
95
110
|
return false
|
|
96
111
|
}
|
|
97
112
|
|
|
113
|
+
export function normalizeConfig(raw) {
|
|
114
|
+
const u = unwrapVolatile(raw) || {}
|
|
115
|
+
return {
|
|
116
|
+
...u,
|
|
117
|
+
currency: typeof u.currency === 'string' && u.currency ? u.currency : '$',
|
|
118
|
+
usdRate: Number(u.usdRate) > 0 ? Number(u.usdRate) : 1,
|
|
119
|
+
displayTimeZone: typeof u.displayTimeZone === 'string' && u.displayTimeZone ? u.displayTimeZone : 'Europe/Moscow',
|
|
120
|
+
budgetThreshold: Number(u.budgetThreshold) || 0,
|
|
121
|
+
useOpenRouter: u.useOpenRouter !== false,
|
|
122
|
+
refreshHours: Number(u.refreshHours) || 24,
|
|
123
|
+
deepseekPeakPrices: u.deepseekPeakPrices || {},
|
|
124
|
+
modelMap: u.modelMap || {},
|
|
125
|
+
prices: u.prices || {},
|
|
126
|
+
manualPeakWindowsUtc: Array.isArray(u.manualPeakWindowsUtc) ? u.manualPeakWindowsUtc : [],
|
|
127
|
+
manualOffPeakMultiplier: Number(u.manualOffPeakMultiplier) || 1,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
98
131
|
const CATALOG_URL = 'https://openrouter.ai/api/v1/models'
|
|
99
132
|
const CACHE_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'storages', 'dsh-cost-meter-catalog.json')
|
|
100
133
|
|
|
@@ -109,47 +142,15 @@ const PriceRow = z.object({
|
|
|
109
142
|
export const Config = z.object({
|
|
110
143
|
currency: z.string().description('Currency symbol shown in the widget.').default('$').volatile(),
|
|
111
144
|
usdRate: z.number().description('Units of `currency` per USD. 1 keeps everything in USD.').default(1).volatile(),
|
|
112
|
-
displayTimeZone: z
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
.volatile(),
|
|
117
|
-
budgetThreshold: z
|
|
118
|
-
.number()
|
|
119
|
-
.description('Optional session budget threshold in USD. 0 disables threshold warnings.')
|
|
120
|
-
.default(0)
|
|
121
|
-
.volatile(),
|
|
122
|
-
deepseekPeakPrices: z
|
|
123
|
-
.dict(PriceRow)
|
|
124
|
-
.description('Override the built-in DeepSeek peak rates, by model id.')
|
|
125
|
-
.default({})
|
|
126
|
-
.volatile(),
|
|
127
|
-
useOpenRouter: z
|
|
128
|
-
.boolean()
|
|
129
|
-
.description('Pull rates and discount windows from the public OpenRouter catalog.')
|
|
130
|
-
.default(true)
|
|
131
|
-
.volatile(),
|
|
145
|
+
displayTimeZone: z.string().description('IANA zone the widget shows tariff hours in.').default('Europe/Moscow').volatile(),
|
|
146
|
+
budgetThreshold: z.number().description('Optional session budget threshold in USD. 0 disables threshold warnings.').default(0).volatile(),
|
|
147
|
+
deepseekPeakPrices: z.dict(PriceRow).description('Override the built-in DeepSeek peak rates, by model id.').default({}).volatile(),
|
|
148
|
+
useOpenRouter: z.boolean().description('Pull rates and discount windows from the public OpenRouter catalog.').default(true).volatile(),
|
|
132
149
|
refreshHours: z.number().description('How often to refetch the catalog.').default(24).volatile(),
|
|
133
|
-
modelMap: z
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
.volatile(),
|
|
138
|
-
prices: z
|
|
139
|
-
.dict(PriceRow)
|
|
140
|
-
.description('Manual rates by "provider/model" (or "*"). Win over the catalog.')
|
|
141
|
-
.default({})
|
|
142
|
-
.volatile(),
|
|
143
|
-
manualPeakWindowsUtc: z
|
|
144
|
-
.array(z.string())
|
|
145
|
-
.description('Peak windows HH:MM-HH:MM UTC, used only with manual prices.')
|
|
146
|
-
.default([])
|
|
147
|
-
.volatile(),
|
|
148
|
-
manualOffPeakMultiplier: z
|
|
149
|
-
.number()
|
|
150
|
-
.description('Multiplier outside the manual peak windows.')
|
|
151
|
-
.default(1)
|
|
152
|
-
.volatile(),
|
|
150
|
+
modelMap: z.dict(z.string()).description('Route override: "provider/model" -> OpenRouter model id.').default({}).volatile(),
|
|
151
|
+
prices: z.dict(PriceRow).description('Manual rates by "provider/model" (or "*"). Win over the catalog.').default({}).volatile(),
|
|
152
|
+
manualPeakWindowsUtc: z.array(z.string()).description('Peak windows HH:MM-HH:MM UTC, used only with manual prices.').default([]).volatile(),
|
|
153
|
+
manualOffPeakMultiplier: z.number().description('Multiplier outside the manual peak windows.').default(1).volatile(),
|
|
153
154
|
})
|
|
154
155
|
|
|
155
156
|
// Per-model usage projection is defined in and exported from ./projection.js
|
|
@@ -180,9 +181,8 @@ export function splitRoute(key) {
|
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
export function apply(ctx, config) {
|
|
183
|
-
let current = config
|
|
184
|
+
let current = normalizeConfig(config)
|
|
184
185
|
let catalog = { index: { byId: {}, byLowerId: {}, bySuffix: {} }, fetchedAt: 0, error: null }
|
|
185
|
-
let lastManualRefreshAt = 0
|
|
186
186
|
|
|
187
187
|
// Server-side cache for resolved tariffs: key -> { schedule, source, matchedId }
|
|
188
188
|
const resolveCache = new Map()
|
|
@@ -255,14 +255,14 @@ export function apply(ctx, config) {
|
|
|
255
255
|
}
|
|
256
256
|
const row = readRow()
|
|
257
257
|
if (row?.value && typeof row.value === 'object') {
|
|
258
|
-
current = { ...config, ...row.value }
|
|
258
|
+
current = normalizeConfig({ ...config, ...row.value })
|
|
259
259
|
clearResolveCache()
|
|
260
260
|
}
|
|
261
261
|
ctx.on('settings/document-updated', (ns) => {
|
|
262
262
|
if (ns && ns !== NS) return
|
|
263
263
|
const fresh = readRow()
|
|
264
264
|
if (fresh?.value && typeof fresh.value === 'object') {
|
|
265
|
-
current = { ...config, ...fresh.value }
|
|
265
|
+
current = normalizeConfig({ ...config, ...fresh.value })
|
|
266
266
|
}
|
|
267
267
|
clearResolveCache()
|
|
268
268
|
})
|
|
@@ -282,7 +282,8 @@ export function apply(ctx, config) {
|
|
|
282
282
|
refresh,
|
|
283
283
|
splitRoute,
|
|
284
284
|
defaultRoute,
|
|
285
|
-
getCurrentConfig: () => current
|
|
285
|
+
getCurrentConfig: () => (current ? normalizeConfig(current) : normalizeConfig(config)),
|
|
286
286
|
getCatalog: () => catalog,
|
|
287
287
|
})
|
|
288
288
|
}
|
|
289
|
+
|
package/lib/routes.js
CHANGED
|
@@ -26,7 +26,7 @@ export function registerRoutes(ctx, options) {
|
|
|
26
26
|
path: REFRESH_PATH,
|
|
27
27
|
method: 'POST',
|
|
28
28
|
handler: async (req, res) => {
|
|
29
|
-
if (!isTrustedCaller(req)) {
|
|
29
|
+
if (!isTrustedCaller(req, ctx)) {
|
|
30
30
|
res.writeHead(403, { 'Content-Type': 'application/json' })
|
|
31
31
|
res.end(JSON.stringify({ error: 'forbidden' }))
|
|
32
32
|
return
|
|
@@ -61,7 +61,7 @@ export function registerRoutes(ctx, options) {
|
|
|
61
61
|
path: STATE_PATH,
|
|
62
62
|
method: 'GET',
|
|
63
63
|
handler: async (req, res) => {
|
|
64
|
-
if (!isTrustedCaller(req)) {
|
|
64
|
+
if (!isTrustedCaller(req, ctx)) {
|
|
65
65
|
res.writeHead(403, { 'Content-Type': 'application/json' })
|
|
66
66
|
res.end(JSON.stringify({ error: 'forbidden' }))
|
|
67
67
|
return
|
|
@@ -94,10 +94,10 @@ export function registerRoutes(ctx, options) {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
const payloadObj = {
|
|
97
|
-
currency: cfg.currency,
|
|
98
|
-
usdRate: cfg.usdRate,
|
|
99
|
-
displayTimeZone: cfg.displayTimeZone
|
|
100
|
-
budgetThreshold: Number(cfg
|
|
97
|
+
currency: typeof cfg?.currency === 'string' && cfg.currency ? cfg.currency : '$',
|
|
98
|
+
usdRate: Number(cfg?.usdRate) > 0 ? Number(cfg.usdRate) : 1,
|
|
99
|
+
displayTimeZone: typeof cfg?.displayTimeZone === 'string' && cfg.displayTimeZone ? cfg.displayTimeZone : 'Europe/Moscow',
|
|
100
|
+
budgetThreshold: Number(cfg?.budgetThreshold) || 0,
|
|
101
101
|
route,
|
|
102
102
|
source: own.source,
|
|
103
103
|
matchedId: own.matchedId,
|
|
@@ -140,3 +140,4 @@ export function registerRoutes(ctx, options) {
|
|
|
140
140
|
}), 'dsh-cost-meter: updater')
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
|
+
|
package/lib/tariff.js
CHANGED
|
@@ -252,3 +252,25 @@ export function resolveTariff(rawCfg, index, route) {
|
|
|
252
252
|
}
|
|
253
253
|
return { schedule: null, source: 'none', matchedId: null }
|
|
254
254
|
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Calculate saved cost from prompt cache hits vs regular input price (#92).
|
|
258
|
+
* Returns { savedUsd, readTokens, percent }
|
|
259
|
+
*/
|
|
260
|
+
export function calculateCacheSavings(usage, rates) {
|
|
261
|
+
const u = usage || {}
|
|
262
|
+
const r = rates || {}
|
|
263
|
+
const readTokens = Number(u.cacheReadTokens) || 0
|
|
264
|
+
const inputRate = Number(r.input) || 0
|
|
265
|
+
const cacheHitRate = Number(r.cacheHit) || 0
|
|
266
|
+
if (readTokens <= 0 || inputRate <= cacheHitRate) {
|
|
267
|
+
return { savedUsd: 0, readTokens: 0, percent: 0 }
|
|
268
|
+
}
|
|
269
|
+
const missedUsd = (readTokens / 1e6) * inputRate
|
|
270
|
+
const hitUsd = (readTokens / 1e6) * cacheHitRate
|
|
271
|
+
const savedUsd = Math.max(0, missedUsd - hitUsd)
|
|
272
|
+
const percent = missedUsd > 0 ? Math.round((savedUsd / missedUsd) * 100) : 0
|
|
273
|
+
return { savedUsd, readTokens, percent }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
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.18",
|
|
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",
|