@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/LICENSE +21 -21
- package/README.md +63 -55
- package/README_EN.md +63 -55
- package/client.js +253 -242
- package/cordis.patch.yml +3 -3
- package/index.js +337 -341
- package/package.json +40 -60
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
let
|
|
170
|
-
let
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (r.header.
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if (typeof a.
|
|
304
|
-
if (typeof a.
|
|
305
|
-
if (typeof a.
|
|
306
|
-
if (typeof a.
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
ctx.
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
if (
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
+
}
|