@yyfather/dsh-balance 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,341 +1,337 @@
1
- /**
2
- * dsh-balance — Host half.
3
- *
4
- * Standard DSH plugin (namespace export: name / inject / Config / apply).
5
- * The Host owns every credential and upstream call; the browser client only
6
- * talks to two loopback-only same-origin routes exposed via `ctx.webServer`.
7
- */
8
- import z from '@deepseek-ai/schemastery'
9
-
10
- export const name = 'dsh-balance'
11
- export const inject = ['webServer', 'credentials', 'timer']
12
-
13
- export const Config = z.object({
14
- apiKeyRef: z.string().role('credential-ref').default('DEEPSEEK_API_KEY'),
15
- baseUrl: z.string().default('https://api.deepseek.com'),
16
- timeoutMs: z.number().step(1).min(1000).max(60000).default(20000),
17
- allowRemote: z.boolean().default(false),
18
- })
19
-
20
- const STATE_ROUTE = '/dsh-balance/api/state'
21
- const CONFIG_ROUTE = '/dsh-balance/api/config'
22
-
23
- const BASE_PRICES = {
24
- default: { currency: 'CNY', in: 1.5, cache: 0.05, out: 4.5, peakIn: 3.0, peakCache: 0.10, peakOut: 9.0 },
25
- 'deepseek-v4-flash': { currency: 'CNY', in: 1.5, cache: 0.05, out: 4.5, peakIn: 3.0, peakCache: 0.10, peakOut: 9.0 },
26
- 'deepseek-v4-flash-vision-exp': { currency: 'CNY', in: 1.5, cache: 0.05, out: 4.5, peakIn: 3.0, peakCache: 0.10, peakOut: 9.0 },
27
- 'deepseek-v4-pro': { currency: 'CNY', in: 4.5, cache: 0.15, out: 13.5, peakIn: 9.0, peakCache: 0.30, peakOut: 27.0 },
28
- 'mimo-v2.5': { currency: 'USD', in: 0.10, cache: 0.02, out: 0.40, peakIn: 0.10, peakCache: 0.02, peakOut: 0.40 },
29
- 'mimo-v2.5-pro': { currency: 'USD', in: 1.00, cache: 0.20, out: 3.00, peakIn: 1.00, peakCache: 0.20, peakOut: 3.00 },
30
- 'mimo-v2.5-pro-ultraspeed': { currency: 'USD', in: 1.00, cache: 0.20, out: 3.00, peakIn: 1.00, peakCache: 0.20, peakOut: 3.00 },
31
- }
32
-
33
- function isLoopbackRequest(req) {
34
- const host = req.headers.host
35
- if (host === undefined) return false
36
- try {
37
- const hostname = new URL(`http://${host}`).hostname
38
- return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]'
39
- } catch {
40
- return false
41
- }
42
- }
43
-
44
- function sendJson(res, status, body) {
45
- res.writeHead(status, {
46
- 'cache-control': 'no-store',
47
- 'content-type': 'application/json; charset=utf-8',
48
- 'x-content-type-options': 'nosniff',
49
- })
50
- res.end(JSON.stringify(body))
51
- }
52
-
53
- function readJsonBody(req, maxBytes = 65536) {
54
- return new Promise((resolve, reject) => {
55
- let size = 0
56
- const chunks = []
57
- req.on('data', (chunk) => {
58
- size += chunk.length
59
- if (size > maxBytes) {
60
- reject(new Error('body too large'))
61
- req.destroy()
62
- return
63
- }
64
- chunks.push(chunk)
65
- })
66
- req.on('end', () => {
67
- if (chunks.length === 0) { resolve({}); return }
68
- try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))) } catch (error) { reject(error) }
69
- })
70
- req.on('error', reject)
71
- })
72
- }
73
-
74
- const round2 = (n) => Math.round(n * 100) / 100
75
-
76
- function tierOfTime(timeMs) {
77
- const b = new Date(timeMs + 8 * 3600e3)
78
- const day = b.getUTCDay()
79
- const h = b.getUTCHours()
80
- return day >= 1 && day <= 5 && ((h >= 9 && h < 12) || (h >= 14 && h < 18)) ? 'peak' : 'off'
81
- }
82
-
83
- const isExternal = (provider) => provider !== null && provider !== 'deepseek-official'
84
-
85
- /** Fold a session event stream into per-request cost buckets (model + tier per request time). */
86
- function foldCost(events, since) {
87
- const totals = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
88
- let cost = 0
89
- let costActive = 0
90
- let external = false
91
- let lastModel = null
92
- let lastProvider = null
93
- let lastSample = null
94
- let samples = 0
95
- let samplesActive = 0
96
- let lastCost = 0
97
- let lastTime = 0
98
- let lastTokens = null
99
- if (Array.isArray(events)) {
100
- for (const e of events) {
101
- if (e === null || typeof e !== 'object' || e.data === null || typeof e.data !== 'object') continue
102
- if (e.type === 'request/context') {
103
- if (e.data.model) lastModel = String(e.data.model)
104
- if (e.data.provider) lastProvider = String(e.data.provider)
105
- continue
106
- }
107
- let turn; let step; let usage
108
- if (e.type === 'assistant/chunk' && e.data.chunk !== null && typeof e.data.chunk === 'object' && e.data.chunk.type === 'usage') {
109
- turn = e.data.turn; step = e.data.step; usage = e.data.chunk.usage
110
- } else if (e.type === 'assistant/message' && e.data.usage !== undefined) {
111
- turn = e.data.turn; step = e.data.step; usage = e.data.usage
112
- } else continue
113
- if (usage === null || typeof usage !== 'object') continue
114
- const b = { input: usage.inputTokens || 0, cacheRead: usage.cacheReadTokens || 0, cacheWrite: usage.cacheWriteTokens || 0, output: usage.outputTokens || 0 }
115
- const ext = isExternal(lastProvider)
116
- const tms = typeof e.time === 'number' ? e.time : Date.now()
117
- const inActive = since === undefined || tms >= since
118
- const sc = costOfAt(b, lastModel, tms, configRuntime)
119
- const scA = inActive ? sc : 0
120
- if (lastSample !== null && lastSample.turn === turn && lastSample.step === step) {
121
- totals.input = Math.max(0, totals.input - lastSample.buckets.input + b.input)
122
- totals.cacheRead = Math.max(0, totals.cacheRead - lastSample.buckets.cacheRead + b.cacheRead)
123
- totals.cacheWrite = Math.max(0, totals.cacheWrite - lastSample.buckets.cacheWrite + b.cacheWrite)
124
- totals.output = Math.max(0, totals.output - lastSample.buckets.output + b.output)
125
- cost = Math.max(0, round2(cost - lastSample.cost + sc))
126
- costActive = Math.max(0, round2(costActive - lastSample.costActive + scA))
127
- lastSample = { turn, step, buckets: b, cost: sc, costActive: scA }
128
- } else {
129
- totals.input += b.input; totals.cacheRead += b.cacheRead; totals.cacheWrite += b.cacheWrite; totals.output += b.output
130
- cost = round2(cost + sc)
131
- costActive = round2(costActive + scA)
132
- lastSample = { turn, step, buckets: b, cost: sc, costActive: scA }
133
- samples += 1
134
- if (inActive) samplesActive += 1
135
- }
136
- lastCost = sc
137
- lastTime = tms
138
- lastTokens = b
139
- if (ext) external = true
140
- }
141
- }
142
- return { totals, cost, costActive, external, model: lastModel, samples, samplesActive, lastCost, lastTime, lastTokens }
143
- }
144
-
145
- /** Runtime-adjustable pricing (edited from the click panel); priceOf/costOfAt read it. */
146
- let configRuntime = {
147
- threshold: 10,
148
- spendAlert: true,
149
- spendThreshold: 1,
150
- usdRate: 7.2,
151
- afterTurn: true,
152
- every5min: true,
153
- prices: structuredClone(BASE_PRICES),
154
- }
155
-
156
- function priceOf(model, rt) { return rt.prices[model] || rt.prices.default }
157
- function costOfAt(t, model, timeMs, rt) {
158
- const p = priceOf(model, rt)
159
- const peak = tierOfTime(timeMs) === 'peak'
160
- const rate = p.currency === 'USD' ? rt.usdRate : 1
161
- const inP = (peak ? p.peakIn : p.in) * rate
162
- const cacheP = (peak ? p.peakCache : p.cache) * rate
163
- const outP = (peak ? p.peakOut : p.out) * rate
164
- return round2((t.input * inP + t.cacheRead * cacheP + t.cacheWrite * inP + t.output * outP) / 1e6)
165
- }
166
-
167
- export function apply(ctx, config) {
168
- const activeSince = Date.now()
169
- let refreshing = false
170
- let sessionId = undefined
171
- let measuredFor = undefined
172
- let lastBalance = null
173
- const state = {
174
- status: 'loading', balance: null, currency: 'CNY', updatedAt: 0, delta: null,
175
- cost: 0, costTotal: 0, tokens: null, model: null, samples: 0, samplesTotal: 0,
176
- external: false, spendAlert: false, lastCost: null, lastTime: 0, lastTokens: null,
177
- prevCost: null, prevModel: null, prevSamples: 0, prevExternal: false,
178
- error: '',
179
- }
180
-
181
- const patch = (p) => Object.assign(state, p)
182
-
183
- const snapshot = () => ({ state: { ...state }, config: structuredClone(configRuntime) })
184
-
185
- const applyTokens = (sid) => {
186
- if (!sid) return
187
- const sessions = ctx.get('sessions')
188
- if (sessions === undefined) return
189
- const session = sessions.get(sid)
190
- if (session === undefined) return
191
- measuredFor = sid
192
- const f = foldCost(session.events, activeSince)
193
- patch({
194
- tokens: f.totals, model: f.model, external: f.external,
195
- cost: f.costActive, costTotal: f.cost,
196
- samples: f.samplesActive, samplesTotal: f.samples,
197
- lastCost: f.samples > 0 ? f.lastCost : null, lastTime: f.lastTime, lastTokens: f.lastTokens,
198
- spendAlert: configRuntime.spendAlert && f.costActive >= configRuntime.spendThreshold,
199
- })
200
- }
201
-
202
- const computePrev = async (sid) => {
203
- if (!sid) return
204
- const sessionQuery = ctx.get('sessionQuery')
205
- const sessions = ctx.get('sessions')
206
- if (sessionQuery === undefined || sessions === undefined) return
207
- const cur = sessions.get(sid)
208
- if (cur === undefined) return
209
- const cwd = cur.header.cwd
210
- const curCreated = cur.header.createdAt
211
- let records
212
- try { records = await sessionQuery.listSessions() } catch { return }
213
- let prev = null
214
- for (const r of records) {
215
- if (r.header.id === sid) continue
216
- if (r.header.origin === 'subagent' || r.header.parentSession !== undefined) continue
217
- if (cwd !== undefined && cwd !== r.header.cwd) continue
218
- if (curCreated !== undefined && curCreated !== null && typeof r.header.createdAt === 'number' && r.header.createdAt >= curCreated) continue
219
- prev = r.header
220
- break
221
- }
222
- if (prev === null) { patch({ prevSessionId: undefined, prevCost: null, prevModel: null, prevExternal: false, prevSamples: 0 }); return }
223
- try {
224
- const load = await sessionQuery.load(prev.id)
225
- if (load === null || typeof load !== 'object') { patch({ prevSessionId: prev.id, prevModel: null, prevExternal: false, prevCost: null, prevSamples: 0 }); return }
226
- const f = foldCost(load.events, undefined)
227
- patch({ prevSessionId: prev.id, prevModel: f.model, prevExternal: f.external, prevCost: f.cost, prevSamples: f.samples })
228
- } catch {
229
- patch({ prevSessionId: prev.id, prevModel: null, prevExternal: false, prevCost: null, prevSamples: 0 })
230
- }
231
- }
232
-
233
- const fetchBalance = async () => {
234
- const hit = await ctx.credentials.resolve(config.apiKeyRef)
235
- if (hit === undefined || !hit.value) {
236
- patch({ status: 'unconfigured', balance: null, delta: null, error: '' })
237
- return
238
- }
239
- const controller = new AbortController()
240
- const timer = setTimeout(() => controller.abort(), config.timeoutMs)
241
- let payload
242
- try {
243
- const resp = await fetch(`${config.baseUrl.replace(/\/+$/u, '')}/user/balance`, {
244
- headers: { authorization: `Bearer ${hit.value}` },
245
- signal: controller.signal,
246
- })
247
- payload = await resp.json()
248
- } finally {
249
- clearTimeout(timer)
250
- }
251
- if (payload === null || typeof payload !== 'object' || payload.is_available !== true || !Array.isArray(payload.balance_infos) || payload.balance_infos.length < 1) {
252
- throw new Error('余额接口响应格式异常')
253
- }
254
- const info = payload.balance_infos[0]
255
- const balance = Number(info.total_balance)
256
- if (!Number.isFinite(balance)) throw new Error('余额字段异常')
257
- const prev = lastBalance
258
- lastBalance = balance
259
- patch({
260
- status: 'ok', balance, currency: info.currency || 'CNY', updatedAt: Date.now(),
261
- delta: prev === null ? null : round2(balance - prev), error: '',
262
- })
263
- }
264
-
265
- const refresh = async () => {
266
- if (refreshing) return
267
- refreshing = true
268
- try {
269
- await fetchBalance()
270
- applyTokens(sessionId)
271
- await computePrev(sessionId)
272
- } catch (error) {
273
- patch({ status: 'error', error: error instanceof Error ? error.message : String(error), delta: null })
274
- } finally {
275
- refreshing = false
276
- }
277
- }
278
-
279
- const stateHandler = async (req, res) => {
280
- if (req.method !== 'GET') { sendJson(res, 405, { ok: false, code: 'METHOD_NOT_ALLOWED', message: '仅支持 GET' }); return }
281
- if (!config.allowRemote && !isLoopbackRequest(req)) { sendJson(res, 403, { ok: false, code: 'FORBIDDEN', message: '仅允许本机访问' }); return }
282
- try {
283
- const url = new URL(req.url ?? STATE_ROUTE, 'http://localhost')
284
- const sid = url.searchParams.get('session') || sessionId
285
- if (sid && (measuredFor !== sid || url.searchParams.get('refresh') === '1')) {
286
- sessionId = sid
287
- applyTokens(sid)
288
- await computePrev(sid)
289
- } else if (url.searchParams.get('refresh') === '1') {
290
- await refresh()
291
- }
292
- sendJson(res, 200, { ok: true, ...snapshot() })
293
- } catch (error) {
294
- sendJson(res, 500, { ok: false, code: 'HOST_ERROR', message: error instanceof Error ? error.message : String(error) })
295
- }
296
- }
297
-
298
- const configHandler = async (req, res) => {
299
- if (req.method !== 'POST') { sendJson(res, 405, { ok: false, code: 'METHOD_NOT_ALLOWED', message: '仅支持 POST' }); return }
300
- if (!config.allowRemote && !isLoopbackRequest(req)) { sendJson(res, 403, { ok: false, code: 'FORBIDDEN', message: '仅允许本机访问' }); return }
301
- try {
302
- const a = await readJsonBody(req)
303
- if (typeof a.threshold === 'number' && Number.isFinite(a.threshold) && a.threshold >= 0) configRuntime.threshold = a.threshold
304
- if (typeof a.spendAlert === 'boolean') configRuntime.spendAlert = a.spendAlert
305
- if (typeof a.spendThreshold === 'number' && Number.isFinite(a.spendThreshold) && a.spendThreshold >= 0) configRuntime.spendThreshold = a.spendThreshold
306
- if (typeof a.usdRate === 'number' && Number.isFinite(a.usdRate) && a.usdRate > 0) configRuntime.usdRate = a.usdRate
307
- if (typeof a.afterTurn === 'boolean') configRuntime.afterTurn = a.afterTurn
308
- if (typeof a.every5min === 'boolean') configRuntime.every5min = a.every5min
309
- if (typeof a.priceKey === 'string' && a.priceKey !== '' && a.price !== null && typeof a.price === 'object') {
310
- const p = a.price
311
- const inV = Number(p.in); const cacheV = Number(p.cache); const outV = Number(p.out)
312
- if (Number.isFinite(inV) && inV >= 0 && Number.isFinite(cacheV) && cacheV >= 0 && Number.isFinite(outV) && outV >= 0) {
313
- const cur = configRuntime.prices[a.priceKey] || structuredClone({ currency: 'CNY', in: 1.5, cache: 0.05, out: 4.5, peakIn: 3.0, peakCache: 0.10, peakOut: 9.0 })
314
- if (a.tier === 'peak') { cur.peakIn = inV; cur.peakCache = cacheV; cur.peakOut = outV }
315
- else { cur.in = inV; cur.cache = cacheV; cur.out = outV }
316
- configRuntime.prices[a.priceKey] = cur
317
- }
318
- }
319
- applyTokens(sessionId)
320
- await computePrev(sessionId)
321
- sendJson(res, 200, { ok: true, ...snapshot() })
322
- } catch (error) {
323
- sendJson(res, 500, { ok: false, code: 'HOST_ERROR', message: error instanceof Error ? error.message : String(error) })
324
- }
325
- }
326
-
327
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: STATE_ROUTE, handler: stateHandler }), 'dsh-balance: state route')
328
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: CONFIG_ROUTE, handler: configHandler }), 'dsh-balance: config route')
329
-
330
- // Refresh after each conversation turn ends, and every 5 minutes.
331
- ctx.on('agent/turn-stopping', (payload) => {
332
- if (payload && payload.agent && payload.agent.id) sessionId = payload.agent.id
333
- if (!configRuntime.afterTurn) return
334
- void refresh()
335
- })
336
- ctx.interval(() => {
337
- if (configRuntime.every5min) void refresh()
338
- }, 5 * 60 * 1000)
339
-
340
- void refresh()
341
- }
1
+ /**
2
+ * dsh-balance — Host half.
3
+ *
4
+ * Standard DSH plugin (namespace export: name / inject / Config / apply).
5
+ * The Host owns every credential and upstream call; the browser client only
6
+ * talks to two loopback-only same-origin routes exposed via `ctx.webServer`.
7
+ */
8
+ import z from '@deepseek-ai/schemastery'
9
+
10
+ export const name = 'dsh-balance'
11
+ export const inject = ['webServer', 'credentials', 'timer']
12
+
13
+ export const Config = z.object({
14
+ apiKeyRef: z.string().role('credential-ref').default('DEEPSEEK_API_KEY'),
15
+ baseUrl: z.string().default('https://api.deepseek.com'),
16
+ timeoutMs: z.number().step(1).min(1000).max(60000).default(20000),
17
+ allowRemote: z.boolean().default(false),
18
+ })
19
+
20
+ const STATE_ROUTE = '/dsh-balance/api/state'
21
+ const CONFIG_ROUTE = '/dsh-balance/api/config'
22
+
23
+ const BASE_PRICES = {
24
+ default: { currency: 'CNY', in: 1.5, cache: 0.05, out: 4.5, peakIn: 3.0, peakCache: 0.10, peakOut: 9.0 },
25
+ 'deepseek-v4-flash': { currency: 'CNY', in: 1.5, cache: 0.05, out: 4.5, peakIn: 3.0, peakCache: 0.10, peakOut: 9.0 },
26
+ 'deepseek-v4-flash-vision-exp': { currency: 'CNY', in: 1.5, cache: 0.05, out: 4.5, peakIn: 3.0, peakCache: 0.10, peakOut: 9.0 },
27
+ 'deepseek-v4-pro': { currency: 'CNY', in: 4.5, cache: 0.15, out: 13.5, peakIn: 9.0, peakCache: 0.30, peakOut: 27.0 },
28
+ 'mimo-v2.5': { currency: 'USD', in: 0.10, cache: 0.02, out: 0.40, peakIn: 0.10, peakCache: 0.02, peakOut: 0.40 },
29
+ 'mimo-v2.5-pro': { currency: 'USD', in: 1.00, cache: 0.20, out: 3.00, peakIn: 1.00, peakCache: 0.20, peakOut: 3.00 },
30
+ 'mimo-v2.5-pro-ultraspeed': { currency: 'USD', in: 1.00, cache: 0.20, out: 3.00, peakIn: 1.00, peakCache: 0.20, peakOut: 3.00 },
31
+ }
32
+
33
+ function isLoopbackRequest(req) {
34
+ const host = req.headers.host
35
+ if (host === undefined) return false
36
+ try {
37
+ const hostname = new URL(`http://${host}`).hostname
38
+ return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]'
39
+ } catch {
40
+ return false
41
+ }
42
+ }
43
+
44
+ function sendJson(res, status, body) {
45
+ res.writeHead(status, {
46
+ 'cache-control': 'no-store',
47
+ 'content-type': 'application/json; charset=utf-8',
48
+ 'x-content-type-options': 'nosniff',
49
+ })
50
+ res.end(JSON.stringify(body))
51
+ }
52
+
53
+ function readJsonBody(req, maxBytes = 65536) {
54
+ return new Promise((resolve, reject) => {
55
+ let size = 0
56
+ const chunks = []
57
+ req.on('data', (chunk) => {
58
+ size += chunk.length
59
+ if (size > maxBytes) {
60
+ reject(new Error('body too large'))
61
+ req.destroy()
62
+ return
63
+ }
64
+ chunks.push(chunk)
65
+ })
66
+ req.on('end', () => {
67
+ if (chunks.length === 0) { resolve({}); return }
68
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))) } catch (error) { reject(error) }
69
+ })
70
+ req.on('error', reject)
71
+ })
72
+ }
73
+
74
+ const round2 = (n) => Math.round(n * 100) / 100
75
+
76
+ function tierOfTime(timeMs) {
77
+ const b = new Date(timeMs + 8 * 3600e3)
78
+ const day = b.getUTCDay()
79
+ const h = b.getUTCHours()
80
+ return day >= 1 && day <= 5 && ((h >= 9 && h < 12) || (h >= 14 && h < 18)) ? 'peak' : 'off'
81
+ }
82
+
83
+ const isExternal = (provider) => provider !== null && provider !== 'deepseek-official'
84
+
85
+ let configRuntime = {
86
+ threshold: 10,
87
+ spendAlert: true,
88
+ spendThreshold: 1,
89
+ usdRate: 7.2,
90
+ afterTurn: true,
91
+ every5min: true,
92
+ prices: structuredClone(BASE_PRICES),
93
+ }
94
+
95
+ function priceOf(model, rt) { return rt.prices[model] || rt.prices.default }
96
+ function costOfAt(t, model, timeMs, rt) {
97
+ const p = priceOf(model, rt)
98
+ const peak = tierOfTime(timeMs) === 'peak'
99
+ const rate = p.currency === 'USD' ? rt.usdRate : 1
100
+ const inP = (peak ? p.peakIn : p.in) * rate
101
+ const cacheP = (peak ? p.peakCache : p.cache) * rate
102
+ const outP = (peak ? p.peakOut : p.out) * rate
103
+ return round2((t.input * inP + t.cacheRead * cacheP + t.cacheWrite * inP + t.output * outP) / 1e6)
104
+ }
105
+
106
+ function foldCost(events, since) {
107
+ const totals = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
108
+ let cost = 0
109
+ let costActive = 0
110
+ let external = false
111
+ let lastModel = null
112
+ let lastProvider = null
113
+ let lastSample = null
114
+ let samples = 0
115
+ let samplesActive = 0
116
+ let lastCost = 0
117
+ let lastTime = 0
118
+ let lastTokens = null
119
+ if (Array.isArray(events)) {
120
+ for (const e of events) {
121
+ if (e === null || typeof e !== 'object' || e.data === null || typeof e.data !== 'object') continue
122
+ if (e.type === 'request/context') {
123
+ if (e.data.model) lastModel = String(e.data.model)
124
+ if (e.data.provider) lastProvider = String(e.data.provider)
125
+ continue
126
+ }
127
+ let turn; let step; let usage
128
+ if (e.type === 'assistant/chunk' && e.data.chunk !== null && typeof e.data.chunk === 'object' && e.data.chunk.type === 'usage') {
129
+ turn = e.data.turn; step = e.data.step; usage = e.data.chunk.usage
130
+ } else if (e.type === 'assistant/message' && e.data.usage !== undefined) {
131
+ turn = e.data.turn; step = e.data.step; usage = e.data.usage
132
+ } else continue
133
+ if (usage === null || typeof usage !== 'object') continue
134
+ const b = { input: usage.inputTokens || 0, cacheRead: usage.cacheReadTokens || 0, cacheWrite: usage.cacheWriteTokens || 0, output: usage.outputTokens || 0 }
135
+ const ext = isExternal(lastProvider)
136
+ const tms = typeof e.time === 'number' ? e.time : Date.now()
137
+ const inActive = since === undefined || tms >= since
138
+ const sc = costOfAt(b, lastModel, tms, configRuntime)
139
+ const scA = inActive ? sc : 0
140
+ if (lastSample !== null && lastSample.turn === turn && lastSample.step === step) {
141
+ totals.input = Math.max(0, totals.input - lastSample.buckets.input + b.input)
142
+ totals.cacheRead = Math.max(0, totals.cacheRead - lastSample.buckets.cacheRead + b.cacheRead)
143
+ totals.cacheWrite = Math.max(0, totals.cacheWrite - lastSample.buckets.cacheWrite + b.cacheWrite)
144
+ totals.output = Math.max(0, totals.output - lastSample.buckets.output + b.output)
145
+ cost = Math.max(0, round2(cost - lastSample.cost + sc))
146
+ costActive = Math.max(0, round2(costActive - lastSample.costActive + scA))
147
+ lastSample = { turn, step, buckets: b, cost: sc, costActive: scA }
148
+ } else {
149
+ totals.input += b.input; totals.cacheRead += b.cacheRead; totals.cacheWrite += b.cacheWrite; totals.output += b.output
150
+ cost = round2(cost + sc)
151
+ costActive = round2(costActive + scA)
152
+ lastSample = { turn, step, buckets: b, cost: sc, costActive: scA }
153
+ samples += 1
154
+ if (inActive) samplesActive += 1
155
+ }
156
+ lastCost = sc
157
+ lastTime = tms
158
+ lastTokens = b
159
+ if (ext) external = true
160
+ }
161
+ }
162
+ return { totals, cost, costActive, external, model: lastModel, samples, samplesActive, lastCost, lastTime, lastTokens }
163
+ }
164
+
165
+ export function apply(ctx, config) {
166
+ const activeSince = Date.now()
167
+ let refreshing = false
168
+ let sessionId = undefined
169
+ let measuredFor = undefined
170
+ let lastBalance = null
171
+ const state = {
172
+ status: 'loading', balance: null, currency: 'CNY', updatedAt: 0, delta: null,
173
+ cost: 0, costTotal: 0, tokens: null, model: null, samples: 0, samplesTotal: 0,
174
+ external: false, spendAlert: false, lastCost: null, lastTime: 0, lastTokens: null,
175
+ prevCost: null, prevModel: null, prevSamples: 0, prevExternal: false,
176
+ error: '',
177
+ }
178
+
179
+ const patch = (p) => Object.assign(state, p)
180
+ const snapshot = () => ({ state: { ...state }, config: structuredClone(configRuntime) })
181
+
182
+ const applyTokens = (sid) => {
183
+ if (!sid) return
184
+ const sessions = ctx.get('sessions')
185
+ if (sessions === undefined) return
186
+ const session = sessions.get(sid)
187
+ if (session === undefined) return
188
+ measuredFor = sid
189
+ const f = foldCost(session.events, activeSince)
190
+ patch({
191
+ tokens: f.totals, model: f.model, external: f.external,
192
+ cost: f.costActive, costTotal: f.cost,
193
+ samples: f.samplesActive, samplesTotal: f.samples,
194
+ lastCost: f.samples > 0 ? f.lastCost : null, lastTime: f.lastTime, lastTokens: f.lastTokens,
195
+ spendAlert: configRuntime.spendAlert && f.costActive >= configRuntime.spendThreshold,
196
+ })
197
+ }
198
+
199
+ const computePrev = async (sid) => {
200
+ if (!sid) return
201
+ const sessionQuery = ctx.get('sessionQuery')
202
+ const sessions = ctx.get('sessions')
203
+ if (sessionQuery === undefined || sessions === undefined) return
204
+ const cur = sessions.get(sid)
205
+ if (cur === undefined) return
206
+ const cwd = cur.header.cwd
207
+ const curCreated = cur.header.createdAt
208
+ let records
209
+ try { records = await sessionQuery.listSessions() } catch { return }
210
+ let prev = null
211
+ for (const r of records) {
212
+ if (r.header.id === sid) continue
213
+ if (r.header.origin === 'subagent' || r.header.parentSession !== undefined) continue
214
+ if (cwd !== undefined && cwd !== r.header.cwd) continue
215
+ if (curCreated !== undefined && curCreated !== null && typeof r.header.createdAt === 'number' && r.header.createdAt >= curCreated) continue
216
+ prev = r.header
217
+ break
218
+ }
219
+ if (prev === null) { patch({ prevSessionId: undefined, prevCost: null, prevModel: null, prevExternal: false, prevSamples: 0 }); return }
220
+ try {
221
+ const load = await sessionQuery.load(prev.id)
222
+ if (load === null || typeof load !== 'object') { patch({ prevSessionId: prev.id, prevModel: null, prevExternal: false, prevCost: null, prevSamples: 0 }); return }
223
+ const f = foldCost(load.events, undefined)
224
+ patch({ prevSessionId: prev.id, prevModel: f.model, prevExternal: f.external, prevCost: f.cost, prevSamples: f.samples })
225
+ } catch {
226
+ patch({ prevSessionId: prev.id, prevModel: null, prevExternal: false, prevCost: null, prevSamples: 0 })
227
+ }
228
+ }
229
+
230
+ const fetchBalance = async () => {
231
+ const hit = await ctx.credentials.resolve(config.apiKeyRef)
232
+ if (hit === undefined || !hit.value) {
233
+ patch({ status: 'unconfigured', balance: null, delta: null, error: '' })
234
+ return
235
+ }
236
+ const controller = new AbortController()
237
+ const timer = setTimeout(() => controller.abort(), config.timeoutMs)
238
+ let payload
239
+ try {
240
+ const resp = await fetch(`${config.baseUrl.replace(/\/+$/u, '')}/user/balance`, {
241
+ headers: { authorization: `Bearer ${hit.value}` },
242
+ signal: controller.signal,
243
+ })
244
+ payload = await resp.json()
245
+ } finally {
246
+ clearTimeout(timer)
247
+ }
248
+ if (payload === null || typeof payload !== 'object' || payload.is_available !== true || !Array.isArray(payload.balance_infos) || payload.balance_infos.length < 1) {
249
+ throw new Error('余额接口响应格式异常')
250
+ }
251
+ const info = payload.balance_infos[0]
252
+ const balance = Number(info.total_balance)
253
+ if (!Number.isFinite(balance)) throw new Error('余额字段异常')
254
+ const prev = lastBalance
255
+ lastBalance = balance
256
+ patch({
257
+ status: 'ok', balance, currency: info.currency || 'CNY', updatedAt: Date.now(),
258
+ delta: prev === null ? null : round2(balance - prev), error: '',
259
+ })
260
+ }
261
+
262
+ const refresh = async () => {
263
+ if (refreshing) return
264
+ refreshing = true
265
+ try {
266
+ await fetchBalance()
267
+ applyTokens(sessionId)
268
+ await computePrev(sessionId)
269
+ } catch (error) {
270
+ patch({ status: 'error', error: error instanceof Error ? error.message : String(error), delta: null })
271
+ } finally {
272
+ refreshing = false
273
+ }
274
+ }
275
+
276
+ const stateHandler = async (req, res) => {
277
+ if (req.method !== 'GET') { sendJson(res, 405, { ok: false, code: 'METHOD_NOT_ALLOWED', message: '仅支持 GET' }); return }
278
+ if (!config.allowRemote && !isLoopbackRequest(req)) { sendJson(res, 403, { ok: false, code: 'FORBIDDEN', message: '仅允许本机访问' }); return }
279
+ try {
280
+ const url = new URL(req.url ?? STATE_ROUTE, 'http://localhost')
281
+ const sid = url.searchParams.get('session') || sessionId
282
+ if (sid && (measuredFor !== sid || url.searchParams.get('refresh') === '1')) {
283
+ sessionId = sid
284
+ applyTokens(sid)
285
+ await computePrev(sid)
286
+ } else if (url.searchParams.get('refresh') === '1') {
287
+ await refresh()
288
+ }
289
+ sendJson(res, 200, { ok: true, ...snapshot() })
290
+ } catch (error) {
291
+ sendJson(res, 500, { ok: false, code: 'HOST_ERROR', message: error instanceof Error ? error.message : String(error) })
292
+ }
293
+ }
294
+
295
+ const configHandler = async (req, res) => {
296
+ if (req.method !== 'POST') { sendJson(res, 405, { ok: false, code: 'METHOD_NOT_ALLOWED', message: '仅支持 POST' }); return }
297
+ if (!config.allowRemote && !isLoopbackRequest(req)) { sendJson(res, 403, { ok: false, code: 'FORBIDDEN', message: '仅允许本机访问' }); return }
298
+ try {
299
+ const a = await readJsonBody(req)
300
+ if (typeof a.threshold === 'number' && Number.isFinite(a.threshold) && a.threshold >= 0) configRuntime.threshold = a.threshold
301
+ if (typeof a.spendAlert === 'boolean') configRuntime.spendAlert = a.spendAlert
302
+ if (typeof a.spendThreshold === 'number' && Number.isFinite(a.spendThreshold) && a.spendThreshold >= 0) configRuntime.spendThreshold = a.spendThreshold
303
+ if (typeof a.usdRate === 'number' && Number.isFinite(a.usdRate) && a.usdRate > 0) configRuntime.usdRate = a.usdRate
304
+ if (typeof a.afterTurn === 'boolean') configRuntime.afterTurn = a.afterTurn
305
+ if (typeof a.every5min === 'boolean') configRuntime.every5min = a.every5min
306
+ if (typeof a.priceKey === 'string' && a.priceKey !== '' && a.price !== null && typeof a.price === 'object') {
307
+ const p = a.price
308
+ const inV = Number(p.in); const cacheV = Number(p.cache); const outV = Number(p.out)
309
+ if (Number.isFinite(inV) && inV >= 0 && Number.isFinite(cacheV) && cacheV >= 0 && Number.isFinite(outV) && outV >= 0) {
310
+ const cur = configRuntime.prices[a.priceKey] || structuredClone({ currency: 'CNY', in: 1.5, cache: 0.05, out: 4.5, peakIn: 3.0, peakCache: 0.10, peakOut: 9.0 })
311
+ if (a.tier === 'peak') { cur.peakIn = inV; cur.peakCache = cacheV; cur.peakOut = outV }
312
+ else { cur.in = inV; cur.cache = cacheV; cur.out = outV }
313
+ configRuntime.prices[a.priceKey] = cur
314
+ }
315
+ }
316
+ applyTokens(sessionId)
317
+ await computePrev(sessionId)
318
+ sendJson(res, 200, { ok: true, ...snapshot() })
319
+ } catch (error) {
320
+ sendJson(res, 500, { ok: false, code: 'HOST_ERROR', message: error instanceof Error ? error.message : String(error) })
321
+ }
322
+ }
323
+
324
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: STATE_ROUTE, handler: stateHandler }), 'dsh-balance: state route')
325
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: CONFIG_ROUTE, handler: configHandler }), 'dsh-balance: config route')
326
+
327
+ ctx.on('agent/turn-stopping', (payload) => {
328
+ if (payload && payload.agent && payload.agent.id) sessionId = payload.agent.id
329
+ if (!configRuntime.afterTurn) return
330
+ void refresh()
331
+ })
332
+ ctx.interval(() => {
333
+ if (configRuntime.every5min) void refresh()
334
+ }, 5 * 60 * 1000)
335
+
336
+ void refresh()
337
+ }