@mzzsfy/dsh-usage-dash 0.1.0
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/README.md +49 -0
- package/cordis.patch.yml +9 -0
- package/package.json +46 -0
- package/src/client.js +2416 -0
- package/src/collector.js +327 -0
- package/src/index.js +104 -0
- package/src/pricing.js +119 -0
- package/src/query.js +193 -0
- package/src/routes.js +323 -0
- package/src/store.js +333 -0
- package/test/client.test.mjs +614 -0
- package/test/collector.test.mjs +603 -0
- package/test/pricing-parity.test.mjs +231 -0
- package/test/pricing.test.mjs +285 -0
- package/test/query.test.mjs +427 -0
- package/test/routes.test.mjs +566 -0
- package/test/stats-line.test.mjs +407 -0
- package/test/store.test.mjs +397 -0
- package/test/switch-guard.test.mjs +32 -0
- package/test/turn-tail.test.mjs +120 -0
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
// 路由测试:webServer 用内存假件捕获 exact 注册,直接调 handler(req,res)。
|
|
2
|
+
// Given/When/Then 场景嵌于各用例,覆盖守卫、信封、校验与数据管线。
|
|
3
|
+
|
|
4
|
+
import { test } from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { Readable } from 'node:stream'
|
|
7
|
+
|
|
8
|
+
import { MAX_RANGE_SPAN_DAYS, registerUsageRoutes } from '../src/routes.js'
|
|
9
|
+
import { DEFAULT_MINUTE_RETENTION_DAYS, minuteKey } from '../src/store.js'
|
|
10
|
+
|
|
11
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
12
|
+
const MINUTE_MS = 60 * 1000
|
|
13
|
+
const ROUTE_PREFIX = '/api/usage-dash'
|
|
14
|
+
const PATH_RANGE = `${ROUTE_PREFIX}/range`
|
|
15
|
+
const PATH_HOURS = `${ROUTE_PREFIX}/hours`
|
|
16
|
+
const PATH_MINUTES = `${ROUTE_PREFIX}/minutes`
|
|
17
|
+
const PATH_STATUS = `${ROUTE_PREFIX}/status`
|
|
18
|
+
const PATH_RESET = `${ROUTE_PREFIX}/reset`
|
|
19
|
+
const PATH_PRICING = `${ROUTE_PREFIX}/pricing`
|
|
20
|
+
const PATH_UNKNOWN = `${ROUTE_PREFIX}/unknown`
|
|
21
|
+
const LOCAL_HOST = '127.0.0.1:3080'
|
|
22
|
+
// 固定"当前时刻",分钟保留窗口断言以此换算,消除真实时钟不确定性
|
|
23
|
+
const FIXED_NOW = Date.UTC(2026, 1, 10, 12, 0, 0)
|
|
24
|
+
const JSON_HEADERS = { 'content-type': 'application/json' }
|
|
25
|
+
|
|
26
|
+
const makeRow = (overrides = {}) => ({
|
|
27
|
+
bucket: '2026-02-01',
|
|
28
|
+
provider: 'default',
|
|
29
|
+
model: 'deepseek-chat',
|
|
30
|
+
inputTokens: 0,
|
|
31
|
+
outputTokens: 0,
|
|
32
|
+
cacheReadTokens: 0,
|
|
33
|
+
cacheWriteTokens: 0,
|
|
34
|
+
requests: 0,
|
|
35
|
+
turns: 0,
|
|
36
|
+
lastSeen: 0,
|
|
37
|
+
...overrides,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
function makeCtx() {
|
|
41
|
+
return {
|
|
42
|
+
webServer: undefined,
|
|
43
|
+
logger: {
|
|
44
|
+
warnings: [],
|
|
45
|
+
warn(message) {
|
|
46
|
+
this.warnings.push(message)
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
effect(setup) {
|
|
50
|
+
const disposer = setup()
|
|
51
|
+
return typeof disposer === 'function' ? disposer : () => {}
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeWebServer() {
|
|
57
|
+
const routes = new Map()
|
|
58
|
+
return {
|
|
59
|
+
routes,
|
|
60
|
+
register(route) {
|
|
61
|
+
if (routes.has(route.path)) throw new Error(`duplicate route ${route.path}`)
|
|
62
|
+
routes.set(route.path, route)
|
|
63
|
+
return () => routes.delete(route.path)
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function makeStore({ rows = [], fail } = {}) {
|
|
69
|
+
const calls = { rangeRows: 0 }
|
|
70
|
+
return {
|
|
71
|
+
calls,
|
|
72
|
+
async rangeRows(g, from, to) {
|
|
73
|
+
calls.rangeRows += 1
|
|
74
|
+
if (fail) throw fail
|
|
75
|
+
return rows.filter((row) => row.bucket >= from && row.bucket <= to)
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function makeCollector({ stateOverrides = {}, rebuilding = false } = {}) {
|
|
81
|
+
const state = {
|
|
82
|
+
running: false,
|
|
83
|
+
total: 0,
|
|
84
|
+
done: 0,
|
|
85
|
+
scannedSessions: 0,
|
|
86
|
+
lastSessionId: undefined,
|
|
87
|
+
error: undefined,
|
|
88
|
+
recordFailures: 0,
|
|
89
|
+
...stateOverrides,
|
|
90
|
+
}
|
|
91
|
+
const calls = { resetAndRescan: 0 }
|
|
92
|
+
return {
|
|
93
|
+
calls,
|
|
94
|
+
get running() {
|
|
95
|
+
return state.running
|
|
96
|
+
},
|
|
97
|
+
get rebuilding() {
|
|
98
|
+
return rebuilding
|
|
99
|
+
},
|
|
100
|
+
status() {
|
|
101
|
+
return { ...state }
|
|
102
|
+
},
|
|
103
|
+
async resetAndRescan() {
|
|
104
|
+
calls.resetAndRescan += 1
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function makePricing({ active = true, rules = [] } = {}) {
|
|
110
|
+
const state = { rules: [...rules], revision: 0 }
|
|
111
|
+
const calls = { replace: 0 }
|
|
112
|
+
return {
|
|
113
|
+
active,
|
|
114
|
+
calls,
|
|
115
|
+
rules() {
|
|
116
|
+
return state.rules
|
|
117
|
+
},
|
|
118
|
+
revision() {
|
|
119
|
+
return state.revision
|
|
120
|
+
},
|
|
121
|
+
async replace(next) {
|
|
122
|
+
calls.replace += 1
|
|
123
|
+
state.rules = next
|
|
124
|
+
state.revision += 1
|
|
125
|
+
return next
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function mount({ store, collector, retentionDays, now, pricing } = {}) {
|
|
131
|
+
const ctx = makeCtx()
|
|
132
|
+
ctx.webServer = makeWebServer()
|
|
133
|
+
const dispose = registerUsageRoutes(ctx, {
|
|
134
|
+
store: store ?? makeStore(),
|
|
135
|
+
collector: collector ?? makeCollector(),
|
|
136
|
+
retentionDays: retentionDays ?? (() => DEFAULT_MINUTE_RETENTION_DAYS),
|
|
137
|
+
now: now ?? (() => FIXED_NOW),
|
|
138
|
+
pricing,
|
|
139
|
+
})
|
|
140
|
+
return { routes: ctx.webServer.routes, dispose, ctx }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function makeReq({ url, method = 'POST', headers = {}, body } = {}) {
|
|
144
|
+
const req = new Readable({ read() {} })
|
|
145
|
+
req.method = method
|
|
146
|
+
req.url = url
|
|
147
|
+
req.headers = headers
|
|
148
|
+
if (body !== undefined) req.push(body)
|
|
149
|
+
req.push(null)
|
|
150
|
+
return req
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function makeRes() {
|
|
154
|
+
return {
|
|
155
|
+
statusCode: 0,
|
|
156
|
+
headers: undefined,
|
|
157
|
+
body: undefined,
|
|
158
|
+
writeHead(status, headers) {
|
|
159
|
+
this.statusCode = status
|
|
160
|
+
this.headers = headers
|
|
161
|
+
},
|
|
162
|
+
end(payload) {
|
|
163
|
+
this.body = payload
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function invoke(routes, path, reqOptions = {}) {
|
|
169
|
+
const route = routes.get(path)
|
|
170
|
+
assert.ok(route, `route ${path} 未注册`)
|
|
171
|
+
const res = makeRes()
|
|
172
|
+
await route.handler(makeReq({ url: path, ...reqOptions }), res)
|
|
173
|
+
return res
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const invokeJson = (routes, path, { headers, payload, ...rest } = {}) =>
|
|
177
|
+
invoke(routes, path, {
|
|
178
|
+
headers: { ...JSON_HEADERS, ...headers },
|
|
179
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
180
|
+
...rest,
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// 值经 JSON 线协议传输,undefined 键不出现,按线形态对比
|
|
184
|
+
const wireOf = (value) => JSON.parse(JSON.stringify(value))
|
|
185
|
+
|
|
186
|
+
test('跨源 POST 拒绝 403 forbidden', async () => {
|
|
187
|
+
const { routes } = mount()
|
|
188
|
+
const res = await invoke(routes, PATH_STATUS, {
|
|
189
|
+
headers: { origin: 'http://evil.example', host: LOCAL_HOST },
|
|
190
|
+
})
|
|
191
|
+
const parsed = JSON.parse(res.body)
|
|
192
|
+
assert.equal(res.statusCode, 403)
|
|
193
|
+
assert.deepEqual(parsed, { ok: false, error: { code: 'forbidden', message: parsed.error.message } })
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('无 Origin 的 POST 放行', async () => {
|
|
197
|
+
const { routes } = mount()
|
|
198
|
+
const res = await invokeJson(routes, PATH_STATUS, { headers: { host: LOCAL_HOST } })
|
|
199
|
+
const parsed = JSON.parse(res.body)
|
|
200
|
+
assert.equal(res.statusCode, 200)
|
|
201
|
+
assert.equal(parsed.ok, true)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('content-type 非 JSON 拒绝 400', async () => {
|
|
205
|
+
const { routes } = mount()
|
|
206
|
+
const res = await invoke(routes, PATH_STATUS, { headers: { 'content-type': 'text/plain' } })
|
|
207
|
+
const parsed = JSON.parse(res.body)
|
|
208
|
+
assert.equal(res.statusCode, 400)
|
|
209
|
+
assert.equal(parsed.ok, false)
|
|
210
|
+
assert.equal(parsed.error.code, 'usage_api_error')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('非 POST 方法拒绝 405', async () => {
|
|
214
|
+
const { routes } = mount()
|
|
215
|
+
const res = await invoke(routes, PATH_STATUS, { method: 'GET' })
|
|
216
|
+
const parsed = JSON.parse(res.body)
|
|
217
|
+
assert.equal(res.statusCode, 405)
|
|
218
|
+
assert.equal(parsed.ok, false)
|
|
219
|
+
assert.equal(parsed.error.code, 'usage_api_error')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('未知路径 404 not_found', async () => {
|
|
223
|
+
const { routes } = mount()
|
|
224
|
+
const res = await invoke(routes, PATH_STATUS, { url: PATH_UNKNOWN })
|
|
225
|
+
const parsed = JSON.parse(res.body)
|
|
226
|
+
assert.equal(res.statusCode, 404)
|
|
227
|
+
assert.equal(parsed.ok, false)
|
|
228
|
+
assert.equal(parsed.error.code, 'not_found')
|
|
229
|
+
assert.ok(parsed.error.message.includes(PATH_UNKNOWN))
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('回扫进行中 reset 冲突 409 且不触发重建', async () => {
|
|
233
|
+
const collector = makeCollector({ stateOverrides: { running: true, total: 9 } })
|
|
234
|
+
const { routes } = mount({ collector })
|
|
235
|
+
const res = await invokeJson(routes, PATH_RESET)
|
|
236
|
+
const parsed = JSON.parse(res.body)
|
|
237
|
+
assert.equal(res.statusCode, 409)
|
|
238
|
+
assert.equal(parsed.ok, false)
|
|
239
|
+
assert.equal(parsed.error.code, 'usage_api_error')
|
|
240
|
+
assert.equal(parsed.error.message, 'usage stats: backfill already running')
|
|
241
|
+
assert.equal(collector.calls.resetAndRescan, 0)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('reset 引发的重建中放行并回状态快照', async () => {
|
|
245
|
+
const collector = makeCollector({ stateOverrides: { running: true, total: 9, done: 2 }, rebuilding: true })
|
|
246
|
+
const { routes } = mount({ collector })
|
|
247
|
+
const res = await invokeJson(routes, PATH_RESET)
|
|
248
|
+
const parsed = JSON.parse(res.body)
|
|
249
|
+
assert.equal(res.statusCode, 200)
|
|
250
|
+
assert.equal(parsed.ok, true)
|
|
251
|
+
assert.deepEqual(parsed.value, wireOf(collector.status()))
|
|
252
|
+
assert.equal(collector.calls.resetAndRescan, 1)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('hours 桶串分量回滚拒绝 400', async () => {
|
|
256
|
+
const { routes } = mount()
|
|
257
|
+
const res = await invokeJson(routes, PATH_HOURS, { payload: { from: '2026-02-01T24', to: '2026-02-02T00' } })
|
|
258
|
+
const parsed = JSON.parse(res.body)
|
|
259
|
+
assert.equal(res.statusCode, 400)
|
|
260
|
+
assert.equal(parsed.ok, false)
|
|
261
|
+
assert.equal(parsed.error.code, 'usage_api_error')
|
|
262
|
+
assert.ok(parsed.error.message.startsWith('usage stats: '))
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test('hours to 先于 from 拒绝 400', async () => {
|
|
266
|
+
const { routes } = mount()
|
|
267
|
+
const res = await invokeJson(routes, PATH_HOURS, { payload: { from: '2026-02-02T00', to: '2026-02-01T00' } })
|
|
268
|
+
const parsed = JSON.parse(res.body)
|
|
269
|
+
assert.equal(res.statusCode, 400)
|
|
270
|
+
assert.equal(parsed.ok, false)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('range 跨度超上限拒绝 400', async () => {
|
|
274
|
+
const { routes } = mount()
|
|
275
|
+
const from = '2025-01-01'
|
|
276
|
+
const to = new Date(Date.UTC(2025, 0, 1) + (MAX_RANGE_SPAN_DAYS + 1) * DAY_MS).toISOString().slice(0, 10)
|
|
277
|
+
const res = await invokeJson(routes, PATH_RANGE, { payload: { from, to } })
|
|
278
|
+
const parsed = JSON.parse(res.body)
|
|
279
|
+
assert.equal(res.statusCode, 400)
|
|
280
|
+
assert.equal(parsed.ok, false)
|
|
281
|
+
assert.equal(parsed.error.code, 'usage_api_error')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('range 合法请求回 UsageStatsRange 信封', async () => {
|
|
285
|
+
const store = makeStore({
|
|
286
|
+
rows: [
|
|
287
|
+
makeRow({ bucket: '2026-02-01', inputTokens: 1, outputTokens: 2, requests: 1 }),
|
|
288
|
+
makeRow({ bucket: '2026-02-02', outputTokens: 5 }),
|
|
289
|
+
],
|
|
290
|
+
})
|
|
291
|
+
const { routes } = mount({ store })
|
|
292
|
+
const res = await invokeJson(routes, PATH_RANGE, { payload: { from: '2026-02-01', to: '2026-02-02' } })
|
|
293
|
+
const parsed = JSON.parse(res.body)
|
|
294
|
+
assert.equal(res.statusCode, 200)
|
|
295
|
+
assert.equal(res.headers['content-type'], 'application/json; charset=utf-8')
|
|
296
|
+
assert.equal(parsed.ok, true)
|
|
297
|
+
assert.deepEqual(
|
|
298
|
+
{ from: parsed.value.from, to: parsed.value.to, tokens: parsed.value.tokens, daily: parsed.value.daily.map((slot) => slot.day) },
|
|
299
|
+
{ from: '2026-02-01', to: '2026-02-02', tokens: 8, daily: ['2026-02-01', '2026-02-02'] },
|
|
300
|
+
)
|
|
301
|
+
assert.equal(store.calls.rangeRows, 1)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('status 回采集快照', async () => {
|
|
305
|
+
const collector = makeCollector({
|
|
306
|
+
stateOverrides: { running: true, total: 5, done: 2, scannedSessions: 7, lastSessionId: 's1', error: 'boom', recordFailures: 1 },
|
|
307
|
+
})
|
|
308
|
+
const { routes } = mount({ collector })
|
|
309
|
+
const res = await invokeJson(routes, PATH_STATUS)
|
|
310
|
+
const parsed = JSON.parse(res.body)
|
|
311
|
+
assert.equal(res.statusCode, 200)
|
|
312
|
+
assert.equal(parsed.ok, true)
|
|
313
|
+
assert.deepEqual(parsed.value, collector.status())
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
test('minutes 超出保留窗口标注 coveredFrom/coveredTo', async () => {
|
|
317
|
+
const { routes } = mount({ retentionDays: () => DEFAULT_MINUTE_RETENTION_DAYS })
|
|
318
|
+
const from = minuteKey(FIXED_NOW - (DEFAULT_MINUTE_RETENTION_DAYS + 1) * DAY_MS)
|
|
319
|
+
const to = minuteKey(FIXED_NOW)
|
|
320
|
+
const res = await invokeJson(routes, PATH_MINUTES, { payload: { from, to } })
|
|
321
|
+
const parsed = JSON.parse(res.body)
|
|
322
|
+
assert.equal(res.statusCode, 200)
|
|
323
|
+
assert.equal(parsed.ok, true)
|
|
324
|
+
assert.equal(parsed.value.coveredFrom, minuteKey(FIXED_NOW - DEFAULT_MINUTE_RETENTION_DAYS * DAY_MS))
|
|
325
|
+
assert.equal(parsed.value.coveredTo, to)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
test('minutes 禁用保留时标注空 covered', async () => {
|
|
329
|
+
const { routes } = mount({ retentionDays: () => 0 })
|
|
330
|
+
const from = minuteKey(FIXED_NOW - 2 * MINUTE_MS)
|
|
331
|
+
const res = await invokeJson(routes, PATH_MINUTES, { payload: { from, to: minuteKey(FIXED_NOW) } })
|
|
332
|
+
const parsed = JSON.parse(res.body)
|
|
333
|
+
assert.equal(res.statusCode, 200)
|
|
334
|
+
assert.equal(parsed.ok, true)
|
|
335
|
+
assert.equal(parsed.value.coveredFrom, '')
|
|
336
|
+
assert.equal(parsed.value.coveredTo, '')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
test('minutes from 未对齐 10 分钟桶边界拒绝 400', async () => {
|
|
340
|
+
const { routes } = mount()
|
|
341
|
+
const d = new Date(FIXED_NOW)
|
|
342
|
+
const unaligned = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}T12:07`
|
|
343
|
+
const res = await invokeJson(routes, PATH_MINUTES, { payload: { from: unaligned, to: minuteKey(FIXED_NOW) } })
|
|
344
|
+
const parsed = JSON.parse(res.body)
|
|
345
|
+
assert.equal(res.statusCode, 400)
|
|
346
|
+
assert.equal(parsed.ok, false)
|
|
347
|
+
assert.match(parsed.error.message, /align to 10 minutes/)
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
test('minutes 保留值超上限时 covered 窗口按 clamp 计算', async () => {
|
|
351
|
+
const { routes } = mount({ retentionDays: () => 30 })
|
|
352
|
+
const from = minuteKey(FIXED_NOW - 3 * DAY_MS)
|
|
353
|
+
const to = minuteKey(FIXED_NOW)
|
|
354
|
+
const res = await invokeJson(routes, PATH_MINUTES, { payload: { from, to } })
|
|
355
|
+
const parsed = JSON.parse(res.body)
|
|
356
|
+
assert.equal(res.statusCode, 200)
|
|
357
|
+
assert.equal(parsed.value.coveredFrom, minuteKey(FIXED_NOW - 2 * DAY_MS))
|
|
358
|
+
assert.equal(parsed.value.coveredTo, to)
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
test('请求体缺失 from/to 拒绝 400', async () => {
|
|
362
|
+
const { routes } = mount()
|
|
363
|
+
const res = await invoke(routes, PATH_RANGE, { headers: { ...JSON_HEADERS } })
|
|
364
|
+
const parsed = JSON.parse(res.body)
|
|
365
|
+
assert.equal(res.statusCode, 400)
|
|
366
|
+
assert.equal(parsed.ok, false)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
test('内部错误 500 不回显内部文本', async () => {
|
|
370
|
+
const store = makeStore({ fail: new Error('secret db path leaked') })
|
|
371
|
+
const { routes, ctx } = mount({ store })
|
|
372
|
+
const res = await invokeJson(routes, PATH_RANGE, { payload: { from: '2026-02-01', to: '2026-02-02' } })
|
|
373
|
+
const parsed = JSON.parse(res.body)
|
|
374
|
+
assert.equal(res.statusCode, 500)
|
|
375
|
+
assert.equal(parsed.ok, false)
|
|
376
|
+
assert.equal(parsed.error.code, 'usage_api_error')
|
|
377
|
+
assert.equal(parsed.error.message, 'internal error')
|
|
378
|
+
assert.ok(!res.body.includes('secret'))
|
|
379
|
+
assert.equal(ctx.logger.warnings.length, 1)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
// ---- S13 pricing 端点与 cost 集成 ----
|
|
383
|
+
|
|
384
|
+
const PRICING_RULE = {
|
|
385
|
+
model: 'deepseek-chat',
|
|
386
|
+
currency: '¥',
|
|
387
|
+
price: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
|
|
388
|
+
conditions: [{ kind: 'dailyWindow', from: '09:00', to: '18:00' }],
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const ruleWithInputPrice = (input) => ({
|
|
392
|
+
model: 'deepseek-chat',
|
|
393
|
+
currency: '¥',
|
|
394
|
+
price: { input, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
395
|
+
conditions: [],
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
test('pricing GET 默认返回空规则与零 revision 且不要求 content-type', async () => {
|
|
399
|
+
const { routes } = mount()
|
|
400
|
+
const res = await invoke(routes, PATH_PRICING, { method: 'GET' })
|
|
401
|
+
const parsed = JSON.parse(res.body)
|
|
402
|
+
assert.equal(res.statusCode, 200)
|
|
403
|
+
assert.deepEqual(parsed, { ok: true, value: { revision: 0, rules: [] } })
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
test('pricing GET 回读能力当前规则', async () => {
|
|
407
|
+
const pricing = makePricing({ rules: [PRICING_RULE] })
|
|
408
|
+
const { routes } = mount({ pricing })
|
|
409
|
+
const res = await invoke(routes, PATH_PRICING, { method: 'GET' })
|
|
410
|
+
const parsed = JSON.parse(res.body)
|
|
411
|
+
assert.equal(res.statusCode, 200)
|
|
412
|
+
assert.deepEqual(parsed, { ok: true, value: { revision: 0, rules: [PRICING_RULE] } })
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
test('pricing POST 合法规则写入并回读自增 revision', async () => {
|
|
416
|
+
const pricing = makePricing()
|
|
417
|
+
const { routes } = mount({ pricing })
|
|
418
|
+
const res = await invokeJson(routes, PATH_PRICING, { payload: { rules: [PRICING_RULE] } })
|
|
419
|
+
const parsed = JSON.parse(res.body)
|
|
420
|
+
assert.equal(res.statusCode, 200)
|
|
421
|
+
assert.deepEqual(parsed, { ok: true, value: { revision: 1, rules: [PRICING_RULE] } })
|
|
422
|
+
assert.equal(pricing.calls.replace, 1)
|
|
423
|
+
assert.deepEqual(pricing.rules(), [PRICING_RULE])
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
test('pricing POST 缺 content-type 同样放行', async () => {
|
|
427
|
+
const pricing = makePricing()
|
|
428
|
+
const { routes } = mount({ pricing })
|
|
429
|
+
const res = await invoke(routes, PATH_PRICING, { body: JSON.stringify({ rules: [] }) })
|
|
430
|
+
assert.equal(res.statusCode, 200)
|
|
431
|
+
assert.equal(JSON.parse(res.body).ok, true)
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
test('pricing POST 非法规则拒绝 400 且不写入', async () => {
|
|
435
|
+
const pricing = makePricing()
|
|
436
|
+
const { routes } = mount({ pricing })
|
|
437
|
+
const invalidPayloads = [
|
|
438
|
+
{ rules: 'x' },
|
|
439
|
+
{},
|
|
440
|
+
{ rules: [{ model: 1, currency: '¥', price: PRICING_RULE.price, conditions: [] }] },
|
|
441
|
+
{ rules: [{ ...PRICING_RULE, price: { input: -1, output: 0, cacheRead: 0, cacheWrite: 0 } }] },
|
|
442
|
+
{ rules: [{ ...PRICING_RULE, conditions: [{ kind: 'unknown' }] }] },
|
|
443
|
+
{ rules: [{ ...PRICING_RULE, conditions: [{ kind: 'weekdays', days: [7] }] }] },
|
|
444
|
+
{ rules: [{ ...PRICING_RULE, currency: '€' }] },
|
|
445
|
+
]
|
|
446
|
+
for (const payload of invalidPayloads) {
|
|
447
|
+
const res = await invokeJson(routes, PATH_PRICING, { payload })
|
|
448
|
+
const parsed = JSON.parse(res.body)
|
|
449
|
+
assert.equal(res.statusCode, 400)
|
|
450
|
+
assert.equal(parsed.ok, false)
|
|
451
|
+
assert.equal(parsed.error.message, 'usage stats: invalid pricing rules')
|
|
452
|
+
}
|
|
453
|
+
assert.equal(pricing.calls.replace, 0)
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
test('pricing POST 跨 Origin 拒绝 403', async () => {
|
|
457
|
+
const pricing = makePricing()
|
|
458
|
+
const { routes } = mount({ pricing })
|
|
459
|
+
const res = await invokeJson(routes, PATH_PRICING, {
|
|
460
|
+
payload: { rules: [] },
|
|
461
|
+
headers: { origin: 'http://evil.example', host: LOCAL_HOST },
|
|
462
|
+
})
|
|
463
|
+
const parsed = JSON.parse(res.body)
|
|
464
|
+
assert.equal(res.statusCode, 403)
|
|
465
|
+
assert.equal(parsed.error.code, 'forbidden')
|
|
466
|
+
assert.equal(pricing.calls.replace, 0)
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
test('pricing 非 GET/POST 方法拒绝 405', async () => {
|
|
470
|
+
const { routes } = mount({ pricing: makePricing() })
|
|
471
|
+
const res = await invoke(routes, PATH_PRICING, { method: 'PUT' })
|
|
472
|
+
const parsed = JSON.parse(res.body)
|
|
473
|
+
assert.equal(res.statusCode, 405)
|
|
474
|
+
assert.equal(parsed.error.code, 'usage_api_error')
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
test('settings 缺席时 pricing POST 拒绝 503', async () => {
|
|
478
|
+
const { routes } = mount()
|
|
479
|
+
const res = await invokeJson(routes, PATH_PRICING, { payload: { rules: [] } })
|
|
480
|
+
const parsed = JSON.parse(res.body)
|
|
481
|
+
assert.equal(res.statusCode, 503)
|
|
482
|
+
assert.equal(parsed.error.code, 'usage_api_error')
|
|
483
|
+
assert.equal(parsed.error.message, 'usage stats: pricing unavailable')
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
test('写入规则后 range 响应携带 cost 与 unpriced', async () => {
|
|
487
|
+
const store = makeStore({
|
|
488
|
+
rows: [
|
|
489
|
+
makeRow({ bucket: '2026-02-01', model: 'deepseek-chat', inputTokens: 1000000 }),
|
|
490
|
+
makeRow({ bucket: '2026-02-01', model: 'other-model', inputTokens: 500000 }),
|
|
491
|
+
makeRow({ bucket: '2026-02-01T05', model: 'deepseek-chat', inputTokens: 1000000 }),
|
|
492
|
+
makeRow({ bucket: '2026-02-01T06', model: 'other-model', inputTokens: 500000 }),
|
|
493
|
+
],
|
|
494
|
+
})
|
|
495
|
+
const pricing = makePricing({ rules: [ruleWithInputPrice(2)] })
|
|
496
|
+
const { routes } = mount({ store, pricing })
|
|
497
|
+
const res = await invokeJson(routes, PATH_RANGE, { payload: { from: '2026-02-01', to: '2026-02-01' } })
|
|
498
|
+
const parsed = JSON.parse(res.body)
|
|
499
|
+
assert.equal(res.statusCode, 200)
|
|
500
|
+
assert.equal(parsed.value.cost, 2)
|
|
501
|
+
assert.equal(parsed.value.unpriced, 1)
|
|
502
|
+
assert.equal(parsed.value.daily[0].cost, 2)
|
|
503
|
+
assert.deepEqual(parsed.value.models.map((entry) => [entry.model, entry.cost]), [
|
|
504
|
+
['deepseek-chat', 2],
|
|
505
|
+
['other-model', 0],
|
|
506
|
+
])
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
test('改规则重查 range cost 随之变化', async () => {
|
|
510
|
+
const store = makeStore({ rows: [makeRow({ bucket: '2026-02-01T05', model: 'deepseek-chat', inputTokens: 1000000 })] })
|
|
511
|
+
const pricing = makePricing()
|
|
512
|
+
const { routes } = mount({ store, pricing })
|
|
513
|
+
const rangeCost = async () => {
|
|
514
|
+
const res = await invokeJson(routes, PATH_RANGE, { payload: { from: '2026-02-01', to: '2026-02-01' } })
|
|
515
|
+
return JSON.parse(res.body).value.cost
|
|
516
|
+
}
|
|
517
|
+
assert.equal(await rangeCost(), 0)
|
|
518
|
+
await invokeJson(routes, PATH_PRICING, { payload: { rules: [ruleWithInputPrice(1)] } })
|
|
519
|
+
assert.equal(await rangeCost(), 1)
|
|
520
|
+
await invokeJson(routes, PATH_PRICING, { payload: { rules: [ruleWithInputPrice(3)] } })
|
|
521
|
+
assert.equal(await rangeCost(), 3)
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
test('range 末日 H 行计入 cost(上界补足当日末小时)', async () => {
|
|
525
|
+
const store = makeStore({ rows: [makeRow({ bucket: '2026-02-01T23', model: 'deepseek-chat', inputTokens: 1000000 })] })
|
|
526
|
+
const pricing = makePricing({ rules: [ruleWithInputPrice(1)] })
|
|
527
|
+
const { routes } = mount({ store, pricing })
|
|
528
|
+
const res = await invokeJson(routes, PATH_RANGE, { payload: { from: '2026-02-01', to: '2026-02-01' } })
|
|
529
|
+
const parsed = JSON.parse(res.body)
|
|
530
|
+
assert.equal(parsed.value.cost, 1)
|
|
531
|
+
assert.equal(parsed.value.unpriced, 0)
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
test('hours 端点同窗口行直接计价', async () => {
|
|
535
|
+
const store = makeStore({ rows: [makeRow({ bucket: '2026-02-01T05', model: 'deepseek-chat', inputTokens: 1000000 })] })
|
|
536
|
+
const pricing = makePricing({ rules: [ruleWithInputPrice(4)] })
|
|
537
|
+
const { routes } = mount({ store, pricing })
|
|
538
|
+
const res = await invokeJson(routes, PATH_HOURS, { payload: { from: '2026-02-01T05', to: '2026-02-01T05' } })
|
|
539
|
+
const parsed = JSON.parse(res.body)
|
|
540
|
+
assert.equal(parsed.value.cost, 4)
|
|
541
|
+
assert.equal(parsed.value.daily[0].cost, 4)
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
test('minutes 端点按父 H 价格计价', async () => {
|
|
545
|
+
const to = minuteKey(FIXED_NOW)
|
|
546
|
+
const store = makeStore({ rows: [makeRow({ bucket: to, model: 'deepseek-chat', inputTokens: 1000000 })] })
|
|
547
|
+
const pricing = makePricing({ rules: [ruleWithInputPrice(2)] })
|
|
548
|
+
const { routes } = mount({ store, pricing })
|
|
549
|
+
const from = minuteKey(FIXED_NOW - 10 * MINUTE_MS)
|
|
550
|
+
const res = await invokeJson(routes, PATH_MINUTES, { payload: { from, to } })
|
|
551
|
+
const parsed = JSON.parse(res.body)
|
|
552
|
+
assert.equal(parsed.value.cost, 2)
|
|
553
|
+
assert.equal(parsed.value.unpriced, 0)
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
test('settings 缺席时 range 响应与现状形状一致(无 cost/unpriced)', async () => {
|
|
557
|
+
const store = makeStore({ rows: [makeRow({ bucket: '2026-02-01', inputTokens: 10, requests: 1 })] })
|
|
558
|
+
const { routes } = mount({ store })
|
|
559
|
+
const res = await invokeJson(routes, PATH_RANGE, { payload: { from: '2026-02-01', to: '2026-02-01' } })
|
|
560
|
+
const parsed = JSON.parse(res.body)
|
|
561
|
+
assert.equal(res.statusCode, 200)
|
|
562
|
+
assert.equal('cost' in parsed.value, false)
|
|
563
|
+
assert.equal('unpriced' in parsed.value, false)
|
|
564
|
+
assert.equal('cost' in parsed.value.daily[0], false)
|
|
565
|
+
assert.equal('cost' in parsed.value.models[0], false)
|
|
566
|
+
})
|