@goodandready/dsh-cost-meter 0.8.15 → 0.8.17
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 +47 -15
- package/lib/index.js +72 -319
- package/lib/projection.js +139 -0
- package/lib/routes.js +143 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -433,10 +433,25 @@ window.__ModuleLoader__.load({
|
|
|
433
433
|
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000))
|
|
434
434
|
if (minutes < 1) return isZh ? '刚刚' : 'just now'
|
|
435
435
|
if (minutes < 60) return minutes + (isZh ? ' 分钟前' : ' min ago')
|
|
436
|
-
const hours = Math.round(minutes / 60)
|
|
437
436
|
return hours < 48 ? hours + (isZh ? ' 小时前' : ' hrs ago') : Math.round(hours / 24) + (isZh ? ' 天前' : ' days ago')
|
|
438
437
|
}
|
|
439
438
|
|
|
439
|
+
function fetchWithTimeout(url, options = {}, timeoutMs = 15000) {
|
|
440
|
+
const { signal, ...rest } = options
|
|
441
|
+
const timer = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
|
|
442
|
+
? AbortSignal.timeout(timeoutMs)
|
|
443
|
+
: null
|
|
444
|
+
let combined = signal
|
|
445
|
+
if (timer) {
|
|
446
|
+
if (signal && typeof AbortSignal.any === 'function') {
|
|
447
|
+
combined = AbortSignal.any([signal, timer])
|
|
448
|
+
} else if (!signal) {
|
|
449
|
+
combined = timer
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return fetch(url, { ...rest, ...(combined ? { signal: combined } : {}) })
|
|
453
|
+
}
|
|
454
|
+
|
|
440
455
|
// ---------------------------------------------------------------- card
|
|
441
456
|
|
|
442
457
|
function CostMeterCard(props) {
|
|
@@ -467,10 +482,10 @@ window.__ModuleLoader__.load({
|
|
|
467
482
|
notice: '',
|
|
468
483
|
})
|
|
469
484
|
|
|
470
|
-
const checkUpdate = React.useCallback(async () => {
|
|
485
|
+
const checkUpdate = React.useCallback(async (signal) => {
|
|
471
486
|
setUpdateState((s) => ({ ...s, checking: true, error: '' }))
|
|
472
487
|
try {
|
|
473
|
-
const res = await
|
|
488
|
+
const res = await fetchWithTimeout(UPDATE_PATH, { signal })
|
|
474
489
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
475
490
|
const data = await res.json().catch(() => ({}))
|
|
476
491
|
setUpdateState((s) => ({
|
|
@@ -482,22 +497,27 @@ window.__ModuleLoader__.load({
|
|
|
482
497
|
canAutoUpdate: data.canAutoUpdate !== false,
|
|
483
498
|
}))
|
|
484
499
|
} catch (e) {
|
|
485
|
-
|
|
500
|
+
if (e?.name !== 'AbortError') {
|
|
501
|
+
setUpdateState((s) => ({ ...s, checking: false }))
|
|
502
|
+
}
|
|
486
503
|
}
|
|
487
504
|
}, [])
|
|
488
505
|
|
|
489
506
|
React.useEffect(() => {
|
|
490
|
-
if (open)
|
|
507
|
+
if (!open) return
|
|
508
|
+
const controller = new AbortController()
|
|
509
|
+
checkUpdate(controller.signal)
|
|
510
|
+
return () => controller.abort()
|
|
491
511
|
}, [open, checkUpdate])
|
|
492
512
|
|
|
493
513
|
async function handleTriggerUpdate() {
|
|
494
514
|
if (updateState.updating) return
|
|
495
515
|
setUpdateState((s) => ({ ...s, updating: true, error: '', notice: '' }))
|
|
496
516
|
try {
|
|
497
|
-
const res = await
|
|
517
|
+
const res = await fetchWithTimeout(UPDATE_PATH, {
|
|
498
518
|
method: 'POST',
|
|
499
519
|
headers: { 'x-dsh-plugin-update': '1' },
|
|
500
|
-
})
|
|
520
|
+
}, 30000)
|
|
501
521
|
const data = await res.json().catch(() => ({}))
|
|
502
522
|
if (!res.ok || data.ok === false || data.error) {
|
|
503
523
|
throw new Error(data.error || ('HTTP ' + res.status))
|
|
@@ -534,8 +554,8 @@ window.__ModuleLoader__.load({
|
|
|
534
554
|
|
|
535
555
|
React.useEffect(() => { ensureStyles() }, [])
|
|
536
556
|
|
|
537
|
-
const fetchDiag = React.useCallback(() => {
|
|
538
|
-
|
|
557
|
+
const fetchDiag = React.useCallback((signal) => {
|
|
558
|
+
fetchWithTimeout(STATE_PATH, { signal, headers: { accept: 'application/json' } })
|
|
539
559
|
.then((r) => (r.ok ? r.json() : null))
|
|
540
560
|
.then((data) => { if (data) setDiagState(data) })
|
|
541
561
|
.catch(() => {})
|
|
@@ -543,7 +563,9 @@ window.__ModuleLoader__.load({
|
|
|
543
563
|
|
|
544
564
|
React.useEffect(() => {
|
|
545
565
|
if (!open) return
|
|
546
|
-
|
|
566
|
+
const controller = new AbortController()
|
|
567
|
+
fetchDiag(controller.signal)
|
|
568
|
+
return () => controller.abort()
|
|
547
569
|
}, [open, fetchDiag])
|
|
548
570
|
|
|
549
571
|
const status = (snapshot && snapshot.status) || 'loading'
|
|
@@ -572,7 +594,7 @@ window.__ModuleLoader__.load({
|
|
|
572
594
|
setSyncing(true)
|
|
573
595
|
setSyncMsg('')
|
|
574
596
|
try {
|
|
575
|
-
const res = await
|
|
597
|
+
const res = await fetchWithTimeout(REFRESH_PATH, { method: 'POST', headers: { accept: 'application/json' } })
|
|
576
598
|
if (res.status === 429) {
|
|
577
599
|
const data = await res.json().catch(() => ({}))
|
|
578
600
|
const retryAfter = data.retryAfter || 30
|
|
@@ -1034,10 +1056,10 @@ window.__ModuleLoader__.load({
|
|
|
1034
1056
|
|
|
1035
1057
|
const reqSeq = React.useRef(0)
|
|
1036
1058
|
|
|
1037
|
-
const load = React.useCallback((keys) => {
|
|
1059
|
+
const load = React.useCallback((keys, signal) => {
|
|
1038
1060
|
const seq = ++reqSeq.current
|
|
1039
1061
|
const query = keys ? '?models=' + encodeURIComponent(keys) : ''
|
|
1040
|
-
|
|
1062
|
+
fetchWithTimeout(STATE_PATH + query, { signal, headers: { accept: 'application/json' } })
|
|
1041
1063
|
.then((r) => (r.ok ? r.json() : null))
|
|
1042
1064
|
.then((data) => {
|
|
1043
1065
|
if (data && seq === reqSeq.current) setState(data)
|
|
@@ -1045,8 +1067,17 @@ window.__ModuleLoader__.load({
|
|
|
1045
1067
|
.catch(() => {})
|
|
1046
1068
|
}, [])
|
|
1047
1069
|
|
|
1048
|
-
React.useEffect(() => {
|
|
1049
|
-
|
|
1070
|
+
React.useEffect(() => {
|
|
1071
|
+
const controller = new AbortController()
|
|
1072
|
+
load(usedKeys, controller.signal)
|
|
1073
|
+
return () => controller.abort()
|
|
1074
|
+
}, [load, usedKeys])
|
|
1075
|
+
React.useEffect(() => {
|
|
1076
|
+
if (!open) return
|
|
1077
|
+
const controller = new AbortController()
|
|
1078
|
+
load(usedKeys, controller.signal)
|
|
1079
|
+
return () => controller.abort()
|
|
1080
|
+
}, [open, load, usedKeys])
|
|
1050
1081
|
React.useEffect(() => {
|
|
1051
1082
|
const id = setInterval(() => setNow(new Date()), 15000)
|
|
1052
1083
|
return () => clearInterval(id)
|
|
@@ -1449,6 +1480,7 @@ window.__ModuleLoader__.load({
|
|
|
1449
1480
|
minutesUntilChange,
|
|
1450
1481
|
extremes,
|
|
1451
1482
|
ratesAt,
|
|
1483
|
+
fetchWithTimeout,
|
|
1452
1484
|
}
|
|
1453
1485
|
return module.exports
|
|
1454
1486
|
},
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { registerRoutes, STATE_PATH, REFRESH_PATH } from './routes.js'
|
|
2
2
|
// dsh-cost-meter — host half.
|
|
3
3
|
//
|
|
4
4
|
// The harness prices nothing on purpose: pi-ai carries a `cost` field but
|
|
@@ -14,7 +14,6 @@ import { registerPluginUpdater } from './updater.js'
|
|
|
14
14
|
//
|
|
15
15
|
// No agent hooks: the widget only reads, so it cannot corrupt a session.
|
|
16
16
|
|
|
17
|
-
import { createHash } from 'node:crypto'
|
|
18
17
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
19
18
|
import { homedir } from 'node:os'
|
|
20
19
|
import { dirname, join } from 'node:path'
|
|
@@ -22,35 +21,49 @@ import z from '@deepseek-ai/schemastery'
|
|
|
22
21
|
|
|
23
22
|
// Ensure .volatile() helper exists on Schemastery schema prototype for DSH 0.2
|
|
24
23
|
try {
|
|
25
|
-
const
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
return typeof this.extra === 'function' ? this.extra('volatile', true) : this
|
|
29
|
-
}
|
|
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 }
|
|
30
27
|
}
|
|
31
|
-
} catch {
|
|
32
|
-
// safe fallback
|
|
33
|
-
}
|
|
34
|
-
import { z as zod } from 'zod'
|
|
28
|
+
} catch {}
|
|
35
29
|
|
|
36
30
|
export * from './tariff.js'
|
|
37
|
-
|
|
31
|
+
export * from './projection.js'
|
|
32
|
+
export { STATE_PATH, REFRESH_PATH } from './routes.js'
|
|
33
|
+
import { indexCatalog, resolveTariff, matchModel, unwrapVolatile } from './tariff.js'
|
|
34
|
+
import { usageByModelProjection } from './projection.js'
|
|
38
35
|
|
|
39
36
|
export const name = '@goodandready/dsh-cost-meter'
|
|
40
37
|
export const inject = ['webServer', 'settings', 'sessionProjections']
|
|
41
38
|
|
|
42
39
|
const NS = 'dsh-cost-meter'
|
|
43
|
-
const STATE_PATH = '/dsh-cost-meter/state'
|
|
44
|
-
const REFRESH_PATH = '/dsh-cost-meter/refresh'
|
|
45
40
|
export function isLoopback(value) {
|
|
46
41
|
const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
|
|
47
42
|
return address === 'localhost' || address === 'localhost.' || address === '::1'
|
|
48
|
-
|| address?.startsWith('127.') === true
|
|
49
|
-
|
|
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
|
+
}
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
export function isTrustedCaller(request) {
|
|
57
|
+
export function isTrustedCaller(request, ctx = null) {
|
|
53
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
|
+
}
|
|
54
67
|
const remote = request.socket?.remoteAddress || request.connection?.remoteAddress
|
|
55
68
|
const site = request.headers?.['sec-fetch-site']
|
|
56
69
|
if (site === 'cross-site') return false
|
|
@@ -97,6 +110,24 @@ export function isTrustedCaller(request) {
|
|
|
97
110
|
return false
|
|
98
111
|
}
|
|
99
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
|
+
|
|
100
131
|
const CATALOG_URL = 'https://openrouter.ai/api/v1/models'
|
|
101
132
|
const CACHE_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'storages', 'dsh-cost-meter-catalog.json')
|
|
102
133
|
|
|
@@ -111,189 +142,18 @@ const PriceRow = z.object({
|
|
|
111
142
|
export const Config = z.object({
|
|
112
143
|
currency: z.string().description('Currency symbol shown in the widget.').default('$').volatile(),
|
|
113
144
|
usdRate: z.number().description('Units of `currency` per USD. 1 keeps everything in USD.').default(1).volatile(),
|
|
114
|
-
displayTimeZone: z
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
.volatile(),
|
|
119
|
-
budgetThreshold: z
|
|
120
|
-
.number()
|
|
121
|
-
.description('Optional session budget threshold in USD. 0 disables threshold warnings.')
|
|
122
|
-
.default(0)
|
|
123
|
-
.volatile(),
|
|
124
|
-
deepseekPeakPrices: z
|
|
125
|
-
.dict(PriceRow)
|
|
126
|
-
.description('Override the built-in DeepSeek peak rates, by model id.')
|
|
127
|
-
.default({})
|
|
128
|
-
.volatile(),
|
|
129
|
-
useOpenRouter: z
|
|
130
|
-
.boolean()
|
|
131
|
-
.description('Pull rates and discount windows from the public OpenRouter catalog.')
|
|
132
|
-
.default(true)
|
|
133
|
-
.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(),
|
|
134
149
|
refreshHours: z.number().description('How often to refetch the catalog.').default(24).volatile(),
|
|
135
|
-
modelMap: z
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
.volatile(),
|
|
140
|
-
prices: z
|
|
141
|
-
.dict(PriceRow)
|
|
142
|
-
.description('Manual rates by "provider/model" (or "*"). Win over the catalog.')
|
|
143
|
-
.default({})
|
|
144
|
-
.volatile(),
|
|
145
|
-
manualPeakWindowsUtc: z
|
|
146
|
-
.array(z.string())
|
|
147
|
-
.description('Peak windows HH:MM-HH:MM UTC, used only with manual prices.')
|
|
148
|
-
.default([])
|
|
149
|
-
.volatile(),
|
|
150
|
-
manualOffPeakMultiplier: z
|
|
151
|
-
.number()
|
|
152
|
-
.description('Multiplier outside the manual peak windows.')
|
|
153
|
-
.default(1)
|
|
154
|
-
.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(),
|
|
155
154
|
})
|
|
156
155
|
|
|
157
|
-
//
|
|
158
|
-
// Per-model usage projection
|
|
159
|
-
// ---------------------------------------------------------------------------
|
|
160
|
-
|
|
161
|
-
const ZERO = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
|
162
|
-
|
|
163
|
-
const bucketSchema = zod.object({
|
|
164
|
-
uncachedInputTokens: zod.number().nonnegative(),
|
|
165
|
-
outputTokens: zod.number().nonnegative(),
|
|
166
|
-
cacheReadTokens: zod.number().nonnegative(),
|
|
167
|
-
cacheWriteTokens: zod.number().nonnegative(),
|
|
168
|
-
}).strict()
|
|
169
|
-
|
|
170
|
-
/** Pull the four buckets out of one provider usage record. */
|
|
171
|
-
function bucketsFromUsage(usage) {
|
|
172
|
-
return {
|
|
173
|
-
uncachedInputTokens: Number(usage.inputTokens) || 0,
|
|
174
|
-
outputTokens: Number(usage.outputTokens) || 0,
|
|
175
|
-
cacheReadTokens: Number(usage.cacheReadTokens) || 0,
|
|
176
|
-
cacheWriteTokens: Number(usage.cacheWriteTokens) || 0,
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function addBuckets(base, remove, add) {
|
|
181
|
-
const out = {}
|
|
182
|
-
for (const k of Object.keys(ZERO)) {
|
|
183
|
-
out[k] = (base[k] || 0) - ((remove && remove[k]) || 0) + (add[k] || 0)
|
|
184
|
-
}
|
|
185
|
-
return out
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function sameBuckets(a, b) {
|
|
189
|
-
return a && b && Object.keys(ZERO).every((k) => (a[k] || 0) === (b[k] || 0))
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** The usage a streamed chunk or a finalized assistant message reports. */
|
|
193
|
-
function usageOf(event) {
|
|
194
|
-
if (event.type === 'assistant/chunk' && event.data.chunk && event.data.chunk.type === 'usage') {
|
|
195
|
-
return { turn: event.data.turn, step: event.data.step, usage: event.data.chunk.usage }
|
|
196
|
-
}
|
|
197
|
-
if (event.type === 'assistant/message' && event.data.usage !== undefined) {
|
|
198
|
-
return { turn: event.data.turn, step: event.data.step, usage: event.data.usage }
|
|
199
|
-
}
|
|
200
|
-
return undefined
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Half-hour slot of the UTC day an event falls into (0..47).
|
|
205
|
-
*/
|
|
206
|
-
export function slotOf(timeMs) {
|
|
207
|
-
let ms = typeof timeMs === 'number' && Number.isFinite(timeMs) ? timeMs : NaN
|
|
208
|
-
if (Number.isNaN(ms)) {
|
|
209
|
-
if (timeMs instanceof Date && !Number.isNaN(timeMs.getTime())) {
|
|
210
|
-
ms = timeMs.getTime()
|
|
211
|
-
} else {
|
|
212
|
-
ms = Date.now()
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
const d = new Date(ms)
|
|
216
|
-
return Math.floor((d.getUTCHours() * 60 + d.getUTCMinutes()) / 30)
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const modelSlotsSchema = zod.record(zod.string(), bucketSchema)
|
|
220
|
-
|
|
221
|
-
const costByModelViewSchema = zod.object({
|
|
222
|
-
models: zod.array(zod.object({
|
|
223
|
-
provider: zod.string(),
|
|
224
|
-
model: zod.string(),
|
|
225
|
-
slots: modelSlotsSchema,
|
|
226
|
-
}).strict()),
|
|
227
|
-
}).strict()
|
|
228
|
-
|
|
229
|
-
const costByModelStateSchema = zod.object({
|
|
230
|
-
route: zod.object({
|
|
231
|
-
provider: zod.string(),
|
|
232
|
-
model: zod.string(),
|
|
233
|
-
}).nullable(),
|
|
234
|
-
byKey: zod.record(zod.string(), modelSlotsSchema),
|
|
235
|
-
last: zod.object({
|
|
236
|
-
turn: zod.number().int().nonnegative(),
|
|
237
|
-
step: zod.number().int().nonnegative(),
|
|
238
|
-
key: zod.string(),
|
|
239
|
-
slot: zod.string(),
|
|
240
|
-
buckets: bucketSchema,
|
|
241
|
-
}).nullable(),
|
|
242
|
-
}).strict()
|
|
243
|
-
|
|
244
|
-
export const usageByModelProjection = {
|
|
245
|
-
key: 'costByModel',
|
|
246
|
-
stateVersion: 2,
|
|
247
|
-
stateSchema: costByModelStateSchema,
|
|
248
|
-
init: () => ({ route: null, byKey: {}, last: null }),
|
|
249
|
-
apply: (state, event) => {
|
|
250
|
-
if (event.type === 'request/header') {
|
|
251
|
-
const config = event.data && event.data.header && event.data.header.config
|
|
252
|
-
const provider = config && typeof config.provider === 'string' ? config.provider : undefined
|
|
253
|
-
const model = config && typeof config.model === 'string' ? config.model : undefined
|
|
254
|
-
if (provider === undefined || model === undefined) return state
|
|
255
|
-
if (state.route && state.route.provider === provider && state.route.model === model) return state
|
|
256
|
-
return { ...state, route: { provider, model } }
|
|
257
|
-
}
|
|
258
|
-
const sample = usageOf(event)
|
|
259
|
-
if (sample === undefined) return state
|
|
260
|
-
const route = state.route || { provider: '', model: 'unknown' }
|
|
261
|
-
const key = route.provider + '/' + route.model
|
|
262
|
-
const slot = String(slotOf(event.time))
|
|
263
|
-
const buckets = bucketsFromUsage(sample.usage)
|
|
264
|
-
const previous =
|
|
265
|
-
state.last !== null &&
|
|
266
|
-
state.last.turn === sample.turn &&
|
|
267
|
-
state.last.step === sample.step &&
|
|
268
|
-
state.last.key === key &&
|
|
269
|
-
state.last.slot === slot
|
|
270
|
-
? state.last.buckets
|
|
271
|
-
: undefined
|
|
272
|
-
if (previous !== undefined && sameBuckets(previous, buckets)) return state
|
|
273
|
-
const slots = state.byKey[key] || {}
|
|
274
|
-
return {
|
|
275
|
-
route: state.route,
|
|
276
|
-
byKey: {
|
|
277
|
-
...state.byKey,
|
|
278
|
-
[key]: { ...slots, [slot]: addBuckets(slots[slot] || ZERO, previous, buckets) },
|
|
279
|
-
},
|
|
280
|
-
last: { turn: sample.turn, step: sample.step, key, slot, buckets },
|
|
281
|
-
}
|
|
282
|
-
},
|
|
283
|
-
wire: {
|
|
284
|
-
viewSchema: costByModelViewSchema,
|
|
285
|
-
view: (state) => ({
|
|
286
|
-
models: Object.keys(state.byKey).map((key) => {
|
|
287
|
-
const slash = key.indexOf('/')
|
|
288
|
-
return {
|
|
289
|
-
provider: key.slice(0, slash),
|
|
290
|
-
model: key.slice(slash + 1),
|
|
291
|
-
slots: state.byKey[key],
|
|
292
|
-
}
|
|
293
|
-
}),
|
|
294
|
-
}),
|
|
295
|
-
},
|
|
296
|
-
}
|
|
156
|
+
// Per-model usage projection is defined in and exported from ./projection.js
|
|
297
157
|
|
|
298
158
|
/** The agent's default route, read off settings descriptors when available. */
|
|
299
159
|
function defaultRoute(ctx) {
|
|
@@ -321,9 +181,8 @@ export function splitRoute(key) {
|
|
|
321
181
|
}
|
|
322
182
|
|
|
323
183
|
export function apply(ctx, config) {
|
|
324
|
-
let current = config
|
|
184
|
+
let current = normalizeConfig(config)
|
|
325
185
|
let catalog = { index: { byId: {}, byLowerId: {}, bySuffix: {} }, fetchedAt: 0, error: null }
|
|
326
|
-
let lastManualRefreshAt = 0
|
|
327
186
|
|
|
328
187
|
// Server-side cache for resolved tariffs: key -> { schedule, source, matchedId }
|
|
329
188
|
const resolveCache = new Map()
|
|
@@ -396,14 +255,14 @@ export function apply(ctx, config) {
|
|
|
396
255
|
}
|
|
397
256
|
const row = readRow()
|
|
398
257
|
if (row?.value && typeof row.value === 'object') {
|
|
399
|
-
current = { ...config, ...row.value }
|
|
258
|
+
current = normalizeConfig({ ...config, ...row.value })
|
|
400
259
|
clearResolveCache()
|
|
401
260
|
}
|
|
402
261
|
ctx.on('settings/document-updated', (ns) => {
|
|
403
262
|
if (ns && ns !== NS) return
|
|
404
263
|
const fresh = readRow()
|
|
405
264
|
if (fresh?.value && typeof fresh.value === 'object') {
|
|
406
|
-
current = { ...config, ...fresh.value }
|
|
265
|
+
current = normalizeConfig({ ...config, ...fresh.value })
|
|
407
266
|
}
|
|
408
267
|
clearResolveCache()
|
|
409
268
|
})
|
|
@@ -415,122 +274,16 @@ export function apply(ctx, config) {
|
|
|
415
274
|
eff(() => pctx.sessionProjections.register(usageByModelProjection))
|
|
416
275
|
})
|
|
417
276
|
|
|
418
|
-
//
|
|
419
|
-
ctx
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
}
|
|
430
|
-
const now = Date.now()
|
|
431
|
-
if (now - lastManualRefreshAt < 60_000) {
|
|
432
|
-
res.writeHead(429, { 'Content-Type': 'application/json' })
|
|
433
|
-
res.end(JSON.stringify({ error: 'rate_limited', retryAfterMs: 60_000 - (now - lastManualRefreshAt) }))
|
|
434
|
-
return
|
|
435
|
-
}
|
|
436
|
-
lastManualRefreshAt = now
|
|
437
|
-
await refresh(null, true)
|
|
438
|
-
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
439
|
-
res.end(
|
|
440
|
-
JSON.stringify({
|
|
441
|
-
ok: !catalog.error,
|
|
442
|
-
fetchedAt: catalog.fetchedAt,
|
|
443
|
-
models: Object.keys((catalog.index && catalog.index.byId) || {}).length,
|
|
444
|
-
error: catalog.error,
|
|
445
|
-
}),
|
|
446
|
-
)
|
|
447
|
-
},
|
|
448
|
-
}),
|
|
449
|
-
'dsh-cost-meter: refresh route',
|
|
450
|
-
)
|
|
451
|
-
|
|
452
|
-
// Route 2: GET /dsh-cost-meter/state (the browser poller hits this)
|
|
453
|
-
ctx.effect(
|
|
454
|
-
() =>
|
|
455
|
-
ctx.webServer.register({
|
|
456
|
-
path: STATE_PATH,
|
|
457
|
-
method: 'GET',
|
|
458
|
-
handler: async (req, res) => {
|
|
459
|
-
if (!isTrustedCaller(req)) {
|
|
460
|
-
res.writeHead(403, { 'Content-Type': 'application/json' })
|
|
461
|
-
res.end(JSON.stringify({ error: 'forbidden' }))
|
|
462
|
-
return
|
|
463
|
-
}
|
|
464
|
-
const cfg = current ?? config
|
|
465
|
-
|
|
466
|
-
// On the very first request after boot, wait for the fetch if we have
|
|
467
|
-
// no disk cache either. Later runs refresh in the background.
|
|
468
|
-
if (!catalog.fetchedAt && !catalog.error) {
|
|
469
|
-
await refresh()
|
|
470
|
-
} else {
|
|
471
|
-
void refresh()
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
const route = defaultRoute(ctx) ?? null
|
|
475
|
-
const own = getCachedTariff(cfg, route)
|
|
476
|
-
|
|
477
|
-
// Also resolve tariffs for any session routes requested in query params
|
|
478
|
-
const schedules = {}
|
|
479
|
-
const url = new URL(req.url, 'http://localhost')
|
|
480
|
-
const extraRoutes = url.searchParams.getAll('route')
|
|
481
|
-
for (const key of extraRoutes) {
|
|
482
|
-
const parsed = splitRoute(key)
|
|
483
|
-
if (parsed) {
|
|
484
|
-
const res = getCachedTariff(cfg, parsed)
|
|
485
|
-
if (res && res.schedule) schedules[key] = res.schedule
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
const payloadObj = {
|
|
490
|
-
currency: cfg.currency,
|
|
491
|
-
usdRate: cfg.usdRate,
|
|
492
|
-
displayTimeZone: cfg.displayTimeZone || 'Europe/Moscow',
|
|
493
|
-
budgetThreshold: Number(cfg.budgetThreshold) || 0,
|
|
494
|
-
route,
|
|
495
|
-
source: own.source,
|
|
496
|
-
matchedId: own.matchedId,
|
|
497
|
-
schedules,
|
|
498
|
-
fetchedAt: catalog.fetchedAt || null,
|
|
499
|
-
catalogError: catalog.error,
|
|
500
|
-
catalogModelsCount: Object.keys((catalog.index && catalog.index.byId) || {}).length,
|
|
501
|
-
schedule: own.schedule,
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
const serialized = JSON.stringify(payloadObj)
|
|
505
|
-
const etag = 'W/"' + createHash('sha1').update(serialized).digest('base64url').slice(0, 16) + '"'
|
|
506
|
-
|
|
507
|
-
if (req.headers['if-none-match'] === etag) {
|
|
508
|
-
res.writeHead(304, {
|
|
509
|
-
'ETag': etag,
|
|
510
|
-
'Cache-Control': 'no-cache',
|
|
511
|
-
})
|
|
512
|
-
res.end()
|
|
513
|
-
return
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
res.writeHead(200, {
|
|
517
|
-
'Content-Type': 'application/json',
|
|
518
|
-
'Cache-Control': 'no-cache',
|
|
519
|
-
'ETag': etag,
|
|
520
|
-
})
|
|
521
|
-
res.end(serialized)
|
|
522
|
-
},
|
|
523
|
-
}),
|
|
524
|
-
'dsh-cost-meter: state route',
|
|
525
|
-
)
|
|
526
|
-
|
|
527
|
-
// Route 3: /dsh-cost-meter/update (in-app one-click updater)
|
|
528
|
-
if (ctx.webServer?.register) {
|
|
529
|
-
ctx.effect(() => registerPluginUpdater(ctx, {
|
|
530
|
-
endpoint: '/dsh-cost-meter/update',
|
|
531
|
-
packageName: name,
|
|
532
|
-
manifestUrl: new URL('../package.json', import.meta.url),
|
|
533
|
-
}), 'dsh-cost-meter: updater')
|
|
534
|
-
}
|
|
535
|
-
|
|
277
|
+
// Register HTTP routes (refresh, state, update) via webServer service
|
|
278
|
+
registerRoutes(ctx, {
|
|
279
|
+
name,
|
|
280
|
+
isTrustedCaller,
|
|
281
|
+
getCachedTariff,
|
|
282
|
+
refresh,
|
|
283
|
+
splitRoute,
|
|
284
|
+
defaultRoute,
|
|
285
|
+
getCurrentConfig: () => (current ? normalizeConfig(current) : normalizeConfig(config)),
|
|
286
|
+
getCatalog: () => catalog,
|
|
287
|
+
})
|
|
536
288
|
}
|
|
289
|
+
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// dsh-cost-meter — per-model usage projection for sessionProjections service.
|
|
2
|
+
import { z as zod } from 'zod'
|
|
3
|
+
|
|
4
|
+
export const ZERO = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
|
5
|
+
|
|
6
|
+
export const bucketSchema = zod.object({
|
|
7
|
+
uncachedInputTokens: zod.number().nonnegative(),
|
|
8
|
+
outputTokens: zod.number().nonnegative(),
|
|
9
|
+
cacheReadTokens: zod.number().nonnegative(),
|
|
10
|
+
cacheWriteTokens: zod.number().nonnegative(),
|
|
11
|
+
}).strict()
|
|
12
|
+
|
|
13
|
+
/** Pull the four buckets out of one provider usage record. */
|
|
14
|
+
export function bucketsFromUsage(usage) {
|
|
15
|
+
return {
|
|
16
|
+
uncachedInputTokens: Number(usage.inputTokens) || 0,
|
|
17
|
+
outputTokens: Number(usage.outputTokens) || 0,
|
|
18
|
+
cacheReadTokens: Number(usage.cacheReadTokens) || 0,
|
|
19
|
+
cacheWriteTokens: Number(usage.cacheWriteTokens) || 0,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function addBuckets(base, remove, add) {
|
|
24
|
+
const out = {}
|
|
25
|
+
for (const k of Object.keys(ZERO)) {
|
|
26
|
+
out[k] = (base[k] || 0) - ((remove && remove[k]) || 0) + (add[k] || 0)
|
|
27
|
+
}
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function sameBuckets(a, b) {
|
|
32
|
+
return a && b && Object.keys(ZERO).every((k) => (a[k] || 0) === (b[k] || 0))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The usage a streamed chunk or a finalized assistant message reports. */
|
|
36
|
+
export function usageOf(event) {
|
|
37
|
+
if (event.type === 'assistant/chunk' && event.data.chunk && event.data.chunk.type === 'usage') {
|
|
38
|
+
return { turn: event.data.turn, step: event.data.step, usage: event.data.chunk.usage }
|
|
39
|
+
}
|
|
40
|
+
if (event.type === 'assistant/message' && event.data.usage !== undefined) {
|
|
41
|
+
return { turn: event.data.turn, step: event.data.step, usage: event.data.usage }
|
|
42
|
+
}
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Half-hour slot of the UTC day an event falls into (0..47).
|
|
48
|
+
*/
|
|
49
|
+
export function slotOf(timeMs) {
|
|
50
|
+
let ms = typeof timeMs === 'number' && Number.isFinite(timeMs) ? timeMs : NaN
|
|
51
|
+
if (Number.isNaN(ms)) {
|
|
52
|
+
if (timeMs instanceof Date && !Number.isNaN(timeMs.getTime())) {
|
|
53
|
+
ms = timeMs.getTime()
|
|
54
|
+
} else {
|
|
55
|
+
ms = Date.now()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const d = new Date(ms)
|
|
59
|
+
return Math.floor((d.getUTCHours() * 60 + d.getUTCMinutes()) / 30)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const modelSlotsSchema = zod.record(zod.string(), bucketSchema)
|
|
63
|
+
|
|
64
|
+
export const costByModelViewSchema = zod.object({
|
|
65
|
+
models: zod.array(zod.object({
|
|
66
|
+
provider: zod.string(),
|
|
67
|
+
model: zod.string(),
|
|
68
|
+
slots: modelSlotsSchema,
|
|
69
|
+
}).strict()),
|
|
70
|
+
}).strict()
|
|
71
|
+
|
|
72
|
+
export const costByModelStateSchema = zod.object({
|
|
73
|
+
route: zod.object({
|
|
74
|
+
provider: zod.string(),
|
|
75
|
+
model: zod.string(),
|
|
76
|
+
}).nullable(),
|
|
77
|
+
byKey: zod.record(zod.string(), modelSlotsSchema),
|
|
78
|
+
last: zod.object({
|
|
79
|
+
turn: zod.number().int().nonnegative(),
|
|
80
|
+
step: zod.number().int().nonnegative(),
|
|
81
|
+
key: zod.string(),
|
|
82
|
+
slot: zod.string(),
|
|
83
|
+
buckets: bucketSchema,
|
|
84
|
+
}).nullable(),
|
|
85
|
+
}).strict()
|
|
86
|
+
|
|
87
|
+
export const usageByModelProjection = {
|
|
88
|
+
key: 'costByModel',
|
|
89
|
+
stateVersion: 2,
|
|
90
|
+
stateSchema: costByModelStateSchema,
|
|
91
|
+
init: () => ({ route: null, byKey: {}, last: null }),
|
|
92
|
+
apply: (state, event) => {
|
|
93
|
+
if (event.type === 'request/header') {
|
|
94
|
+
const config = event.data && event.data.header && event.data.header.config
|
|
95
|
+
const provider = config && typeof config.provider === 'string' ? config.provider : undefined
|
|
96
|
+
const model = config && typeof config.model === 'string' ? config.model : undefined
|
|
97
|
+
if (provider === undefined || model === undefined) return state
|
|
98
|
+
if (state.route && state.route.provider === provider && state.route.model === model) return state
|
|
99
|
+
return { ...state, route: { provider, model } }
|
|
100
|
+
}
|
|
101
|
+
const sample = usageOf(event)
|
|
102
|
+
if (sample === undefined) return state
|
|
103
|
+
const route = state.route || { provider: '', model: 'unknown' }
|
|
104
|
+
const key = route.provider + '/' + route.model
|
|
105
|
+
const slot = String(slotOf(event.time))
|
|
106
|
+
const buckets = bucketsFromUsage(sample.usage)
|
|
107
|
+
const previous =
|
|
108
|
+
state.last !== null &&
|
|
109
|
+
state.last.turn === sample.turn &&
|
|
110
|
+
state.last.step === sample.step &&
|
|
111
|
+
state.last.key === key &&
|
|
112
|
+
state.last.slot === slot
|
|
113
|
+
? state.last.buckets
|
|
114
|
+
: undefined
|
|
115
|
+
if (previous !== undefined && sameBuckets(previous, buckets)) return state
|
|
116
|
+
const slots = state.byKey[key] || {}
|
|
117
|
+
return {
|
|
118
|
+
route: state.route,
|
|
119
|
+
byKey: {
|
|
120
|
+
...state.byKey,
|
|
121
|
+
[key]: { ...slots, [slot]: addBuckets(slots[slot] || ZERO, previous, buckets) },
|
|
122
|
+
},
|
|
123
|
+
last: { turn: sample.turn, step: sample.step, key, slot, buckets },
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
wire: {
|
|
127
|
+
viewSchema: costByModelViewSchema,
|
|
128
|
+
view: (state) => ({
|
|
129
|
+
models: Object.keys(state.byKey).map((key) => {
|
|
130
|
+
const slash = key.indexOf('/')
|
|
131
|
+
return {
|
|
132
|
+
provider: key.slice(0, slash),
|
|
133
|
+
model: key.slice(slash + 1),
|
|
134
|
+
slots: state.byKey[key],
|
|
135
|
+
}
|
|
136
|
+
}),
|
|
137
|
+
}),
|
|
138
|
+
},
|
|
139
|
+
}
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// dsh-cost-meter — HTTP routes registration for webServer service.
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { registerPluginUpdater } from './updater.js'
|
|
4
|
+
|
|
5
|
+
export const STATE_PATH = '/dsh-cost-meter/state'
|
|
6
|
+
export const REFRESH_PATH = '/dsh-cost-meter/refresh'
|
|
7
|
+
|
|
8
|
+
export function registerRoutes(ctx, options) {
|
|
9
|
+
const {
|
|
10
|
+
name,
|
|
11
|
+
isTrustedCaller,
|
|
12
|
+
getCachedTariff,
|
|
13
|
+
refresh,
|
|
14
|
+
splitRoute,
|
|
15
|
+
defaultRoute,
|
|
16
|
+
getCurrentConfig,
|
|
17
|
+
getCatalog,
|
|
18
|
+
} = options
|
|
19
|
+
|
|
20
|
+
let lastManualRefreshAt = 0
|
|
21
|
+
|
|
22
|
+
// Route 1: POST /dsh-cost-meter/refresh (manual catalog reload)
|
|
23
|
+
ctx.effect(
|
|
24
|
+
() =>
|
|
25
|
+
ctx.webServer.register({
|
|
26
|
+
path: REFRESH_PATH,
|
|
27
|
+
method: 'POST',
|
|
28
|
+
handler: async (req, res) => {
|
|
29
|
+
if (!isTrustedCaller(req, ctx)) {
|
|
30
|
+
res.writeHead(403, { 'Content-Type': 'application/json' })
|
|
31
|
+
res.end(JSON.stringify({ error: 'forbidden' }))
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
const now = Date.now()
|
|
35
|
+
if (now - lastManualRefreshAt < 60_000) {
|
|
36
|
+
res.writeHead(429, { 'Content-Type': 'application/json' })
|
|
37
|
+
res.end(JSON.stringify({ error: 'rate_limited', retryAfterMs: 60_000 - (now - lastManualRefreshAt) }))
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
lastManualRefreshAt = now
|
|
41
|
+
await refresh(null, true)
|
|
42
|
+
const catalog = getCatalog()
|
|
43
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
44
|
+
res.end(
|
|
45
|
+
JSON.stringify({
|
|
46
|
+
ok: !catalog.error,
|
|
47
|
+
fetchedAt: catalog.fetchedAt,
|
|
48
|
+
models: Object.keys((catalog.index && catalog.index.byId) || {}).length,
|
|
49
|
+
error: catalog.error,
|
|
50
|
+
}),
|
|
51
|
+
)
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
'dsh-cost-meter: refresh route',
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
// Route 2: GET /dsh-cost-meter/state (the browser poller hits this)
|
|
58
|
+
ctx.effect(
|
|
59
|
+
() =>
|
|
60
|
+
ctx.webServer.register({
|
|
61
|
+
path: STATE_PATH,
|
|
62
|
+
method: 'GET',
|
|
63
|
+
handler: async (req, res) => {
|
|
64
|
+
if (!isTrustedCaller(req, ctx)) {
|
|
65
|
+
res.writeHead(403, { 'Content-Type': 'application/json' })
|
|
66
|
+
res.end(JSON.stringify({ error: 'forbidden' }))
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
const cfg = getCurrentConfig()
|
|
70
|
+
let catalog = getCatalog()
|
|
71
|
+
|
|
72
|
+
// On the very first request after boot, wait for the fetch if we have
|
|
73
|
+
// no disk cache either. Later runs refresh in the background.
|
|
74
|
+
if (!catalog.fetchedAt && !catalog.error) {
|
|
75
|
+
await refresh()
|
|
76
|
+
catalog = getCatalog()
|
|
77
|
+
} else {
|
|
78
|
+
void refresh()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const route = defaultRoute(ctx) ?? null
|
|
82
|
+
const own = getCachedTariff(cfg, route)
|
|
83
|
+
|
|
84
|
+
// Also resolve tariffs for any session routes requested in query params
|
|
85
|
+
const schedules = {}
|
|
86
|
+
const url = new URL(req.url, 'http://localhost')
|
|
87
|
+
const extraRoutes = url.searchParams.getAll('route')
|
|
88
|
+
for (const key of extraRoutes) {
|
|
89
|
+
const parsed = splitRoute(key)
|
|
90
|
+
if (parsed) {
|
|
91
|
+
const res = getCachedTariff(cfg, parsed)
|
|
92
|
+
if (res && res.schedule) schedules[key] = res.schedule
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const payloadObj = {
|
|
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
|
+
route,
|
|
102
|
+
source: own.source,
|
|
103
|
+
matchedId: own.matchedId,
|
|
104
|
+
schedules,
|
|
105
|
+
fetchedAt: catalog.fetchedAt || null,
|
|
106
|
+
catalogError: catalog.error,
|
|
107
|
+
catalogModelsCount: Object.keys((catalog.index && catalog.index.byId) || {}).length,
|
|
108
|
+
schedule: own.schedule,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const serialized = JSON.stringify(payloadObj)
|
|
112
|
+
const etag = 'W/"' + createHash('sha1').update(serialized).digest('base64url').slice(0, 16) + '"'
|
|
113
|
+
|
|
114
|
+
if (req.headers['if-none-match'] === etag) {
|
|
115
|
+
res.writeHead(304, {
|
|
116
|
+
'ETag': etag,
|
|
117
|
+
'Cache-Control': 'no-cache',
|
|
118
|
+
})
|
|
119
|
+
res.end()
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
res.writeHead(200, {
|
|
124
|
+
'Content-Type': 'application/json',
|
|
125
|
+
'Cache-Control': 'no-cache',
|
|
126
|
+
'ETag': etag,
|
|
127
|
+
})
|
|
128
|
+
res.end(serialized)
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
'dsh-cost-meter: state route',
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
// Route 3: /dsh-cost-meter/update (in-app one-click updater)
|
|
135
|
+
if (ctx.webServer?.register) {
|
|
136
|
+
ctx.effect(() => registerPluginUpdater(ctx, {
|
|
137
|
+
endpoint: '/dsh-cost-meter/update',
|
|
138
|
+
packageName: name,
|
|
139
|
+
manifestUrl: new URL('../package.json', import.meta.url),
|
|
140
|
+
}), 'dsh-cost-meter: updater')
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
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.17",
|
|
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",
|