@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
package/src/collector.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// 采集器:session/event 实时折叠与持久化日志回扫,产出 {time,...} 采集样本交
|
|
2
|
+
// store.record 落三粒度。事件判定表、(session,turn,step) 首样本生效、归因链
|
|
3
|
+
// message.source > 观测路由 > requestContext 懒播种,均与原插件同构;单次
|
|
4
|
+
// 事件 pass 内的去重由 per-session fold 保证,跨 boot/回扫的不重复由持久
|
|
5
|
+
// 游标(liveFirstSeq 分区 + backfilledSessions)保证。采集是观测性的:一切
|
|
6
|
+
// store 失败计入 recordFailures,绝不成为逃逸拒绝。
|
|
7
|
+
|
|
8
|
+
const UNKNOWN_SESSION_ID = '(unknown-session)'
|
|
9
|
+
const SEQ_UNKNOWN = -1
|
|
10
|
+
const DEFAULT_BACKFILL_CONCURRENCY = 4
|
|
11
|
+
const MARK_BATCH = 32
|
|
12
|
+
|
|
13
|
+
// 会话内单 pass 折叠:跟踪每个 (turn,step) 槽的最新报告,只把首次发射交 store
|
|
14
|
+
export class UsageFold {
|
|
15
|
+
constructor() {
|
|
16
|
+
this.seen = new Map()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
keyOf(event) {
|
|
20
|
+
const data = event.data
|
|
21
|
+
if (typeof data?.turn !== 'number' || typeof data?.step !== 'number') return null
|
|
22
|
+
return `${data.turn}:${data.step}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
fold(event) {
|
|
26
|
+
if (event.type === 'turn/end') {
|
|
27
|
+
// 一个 turn 恰好结束一次,失败轮也发,无需去重键
|
|
28
|
+
return {
|
|
29
|
+
time: event.time,
|
|
30
|
+
inputTokens: 0,
|
|
31
|
+
outputTokens: 0,
|
|
32
|
+
cacheReadTokens: 0,
|
|
33
|
+
cacheWriteTokens: 0,
|
|
34
|
+
turn: true,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (event.type === 'step/start' || event.type === 'llm/retry-started') {
|
|
38
|
+
// step/start 恰开一次模型调用,retry-started 标记每次实际启动的重试;
|
|
39
|
+
// 请求只由标记计数,与 token 样本双计
|
|
40
|
+
return {
|
|
41
|
+
time: event.time,
|
|
42
|
+
inputTokens: 0,
|
|
43
|
+
outputTokens: 0,
|
|
44
|
+
cacheReadTokens: 0,
|
|
45
|
+
cacheWriteTokens: 0,
|
|
46
|
+
request: true,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (event.type === 'assistant/chunk') {
|
|
50
|
+
const usage = event.data?.chunk?.type === 'usage' ? event.data.chunk.usage : undefined
|
|
51
|
+
return usage ? this.replaceSample(event, usage) : null
|
|
52
|
+
}
|
|
53
|
+
if (event.type === 'assistant/message') {
|
|
54
|
+
return event.data?.usage ? this.replaceSample(event, event.data.usage) : null
|
|
55
|
+
}
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
replaceSample(event, usage) {
|
|
60
|
+
// 四桶全零为噪声;纯缓存调用(仅缓存桶非零)仍有效
|
|
61
|
+
const sum = (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
|
|
62
|
+
+ (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
|
|
63
|
+
if (sum <= 0) return null
|
|
64
|
+
const key = this.keyOf(event)
|
|
65
|
+
const sample = {
|
|
66
|
+
time: event.time,
|
|
67
|
+
inputTokens: usage.inputTokens ?? 0,
|
|
68
|
+
outputTokens: usage.outputTokens ?? 0,
|
|
69
|
+
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
70
|
+
cacheWriteTokens: usage.cacheWriteTokens ?? 0,
|
|
71
|
+
}
|
|
72
|
+
if (key === null) return sample
|
|
73
|
+
const prev = this.seen.get(key)
|
|
74
|
+
this.seen.set(key, sample)
|
|
75
|
+
// 首样本生效:内部无条件跟踪最新报告,但交 store 的只有首次发射的独立拷贝
|
|
76
|
+
return prev ? null : { ...sample }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sessionIdOf(target) {
|
|
81
|
+
const id = target?.id
|
|
82
|
+
return typeof id === 'string' && id !== '' ? id : UNKNOWN_SESSION_ID
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 规范 provider/model 引用:双全拼引用,仅 model 用裸名
|
|
86
|
+
function refOf(route) {
|
|
87
|
+
if (!route) return undefined
|
|
88
|
+
if (route.provider && route.model) return `${route.provider}/${route.model}`
|
|
89
|
+
return route.model || undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class UsageCollector {
|
|
93
|
+
constructor(ctx, store) {
|
|
94
|
+
this.ctx = ctx
|
|
95
|
+
this.store = store
|
|
96
|
+
this.folds = new Map()
|
|
97
|
+
this.routes = new Map()
|
|
98
|
+
this.started = false
|
|
99
|
+
this.liveMarked = new Set()
|
|
100
|
+
this.liveMarkBuffer = new Map()
|
|
101
|
+
this.liveMarkFlushScheduled = false
|
|
102
|
+
this.scanController = null
|
|
103
|
+
this.resetInFlight = null
|
|
104
|
+
this.#state = {
|
|
105
|
+
running: false,
|
|
106
|
+
total: 0,
|
|
107
|
+
done: 0,
|
|
108
|
+
scannedSessions: 0,
|
|
109
|
+
lastSessionId: undefined,
|
|
110
|
+
error: undefined,
|
|
111
|
+
recordFailures: 0,
|
|
112
|
+
skippedSessions: 0,
|
|
113
|
+
}
|
|
114
|
+
// error 保留给采集器自身故障;单会话读取失败走 skippedSessions 跳过计数
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#state
|
|
118
|
+
|
|
119
|
+
status() {
|
|
120
|
+
return { ...this.#state }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
get running() {
|
|
124
|
+
return this.#state.running
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// reset 引发的重扫同样 running 为 true,409 守卫据此区分两种 running
|
|
128
|
+
get rebuilding() {
|
|
129
|
+
return this.resetInFlight !== null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
start() {
|
|
133
|
+
if (this.started) return
|
|
134
|
+
this.started = true
|
|
135
|
+
this.ctx.on?.('session/event', (session, event) => {
|
|
136
|
+
const sid = sessionIdOf(session)
|
|
137
|
+
this.markLiveSession(sid, event?.seq)
|
|
138
|
+
if (event.type === 'request/context') {
|
|
139
|
+
if (event.data?.provider && event.data?.model) this.routes.set(sid, `${event.data.provider}/${event.data.model}`)
|
|
140
|
+
else if (event.data?.model) this.routes.set(sid, event.data.model)
|
|
141
|
+
}
|
|
142
|
+
let fromSource
|
|
143
|
+
if (event.type === 'assistant/message') {
|
|
144
|
+
fromSource = refOf(event.data?.message?.source)
|
|
145
|
+
if (fromSource !== undefined) this.routes.set(sid, fromSource)
|
|
146
|
+
}
|
|
147
|
+
const sample = this.foldFor(sid).fold(event)
|
|
148
|
+
if (!sample) return
|
|
149
|
+
// turn 标记落合成行不归因;其余样本先取本调用 source,再取会话路由
|
|
150
|
+
if (!sample.turn) sample.model = fromSource ?? this.routeFor(sid)
|
|
151
|
+
void this.store.record(sample).catch(() => {
|
|
152
|
+
this.#state.recordFailures += 1
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
// 释放销毁会话的内存桶,防长跑宿主按会话数累积
|
|
156
|
+
this.ctx.on?.('session/disposed', (session) => {
|
|
157
|
+
const sid = sessionIdOf(session)
|
|
158
|
+
this.folds.delete(sid)
|
|
159
|
+
this.routes.delete(sid)
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
routeFor(sid) {
|
|
164
|
+
const known = this.routes.get(sid)
|
|
165
|
+
if (known !== undefined) return known
|
|
166
|
+
// request/context 只在路由变更时落日志,先于采集器存在的会话靠
|
|
167
|
+
// requestContext() 懒播种归因,否则用量全部落入 (unknown)
|
|
168
|
+
const ref = refOf(this.ctx.sessions?.get?.(sid)?.requestContext?.())
|
|
169
|
+
if (ref !== undefined) this.routes.set(sid, ref)
|
|
170
|
+
return ref
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
foldFor(sid) {
|
|
174
|
+
let fold = this.folds.get(sid)
|
|
175
|
+
if (!fold) {
|
|
176
|
+
fold = new UsageFold()
|
|
177
|
+
this.folds.set(sid, fold)
|
|
178
|
+
}
|
|
179
|
+
return fold
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 首个被观测的实时事件写入游标边界,合批为单次 markLiveSequences;
|
|
183
|
+
// -1 哨兵记录"观测到但边界未知",语义是重放零事件,宁漏不冒双计
|
|
184
|
+
markLiveSession(sid, firstSeq) {
|
|
185
|
+
if (sid === UNKNOWN_SESSION_ID || this.liveMarked.has(sid)) return
|
|
186
|
+
this.liveMarked.add(sid)
|
|
187
|
+
this.liveMarkBuffer.set(sid, typeof firstSeq === 'number' ? firstSeq : SEQ_UNKNOWN)
|
|
188
|
+
if (this.liveMarkFlushScheduled) return
|
|
189
|
+
this.liveMarkFlushScheduled = true
|
|
190
|
+
queueMicrotask(() => {
|
|
191
|
+
this.liveMarkFlushScheduled = false
|
|
192
|
+
const batch = [...this.liveMarkBuffer.entries()]
|
|
193
|
+
this.liveMarkBuffer.clear()
|
|
194
|
+
if (batch.length > 0) {
|
|
195
|
+
void this.store.markLiveSequences(batch).catch(() => {
|
|
196
|
+
this.#state.recordFailures += 1
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 单控制器槽:boot 回扫、reset 重扫共用,新扫描先中止旧扫描
|
|
203
|
+
async rescan() {
|
|
204
|
+
this.scanController?.abort()
|
|
205
|
+
const controller = new AbortController()
|
|
206
|
+
this.scanController = controller
|
|
207
|
+
await this.store.readyPromise()
|
|
208
|
+
await this.backfill(this.ctx.sessionPersistence, this.ctx.sessions, controller.signal)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
abort() {
|
|
212
|
+
this.scanController?.abort()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// wipe 时刻为每个活跃会话取日志长度作重放上界:回扫恰好重建 [0,watermark)
|
|
216
|
+
// 一次,watermark 起的事件仍归在跑的实时路径;seq 读不出沿用旧游标边界,
|
|
217
|
+
// 无则 -1 哨兵。死会话不进边界,全量重放即精确。并发调用合并为一次重建
|
|
218
|
+
resetAndRescan() {
|
|
219
|
+
if (this.resetInFlight) return this.resetInFlight
|
|
220
|
+
this.resetInFlight = (async () => {
|
|
221
|
+
const previous = await this.store.liveSequences()
|
|
222
|
+
const boundaries = new Map()
|
|
223
|
+
for (const handle of this.ctx.sessions.list()) {
|
|
224
|
+
const seq = handle.seq
|
|
225
|
+
if (typeof seq === 'number' && Number.isFinite(seq) && seq >= 0) {
|
|
226
|
+
boundaries.set(handle.id, seq)
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
const old = previous.get(handle.id)
|
|
230
|
+
boundaries.set(handle.id, typeof old === 'number' ? old : SEQ_UNKNOWN)
|
|
231
|
+
}
|
|
232
|
+
await this.store.reset(boundaries)
|
|
233
|
+
await this.rescan()
|
|
234
|
+
})().finally(() => {
|
|
235
|
+
this.resetInFlight = null
|
|
236
|
+
})
|
|
237
|
+
return this.resetInFlight
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 回扫:persistence.list 驱动,逐会话全新 fold 重放;seen 独自决定是否重扫,
|
|
241
|
+
// liveFirstSeq 边界划走实时已拥区间,liveness 复查防陈旧快照放大重放范围,
|
|
242
|
+
// inheritedCut 跳过 fork 继承前缀。只有干净重放完的会话进游标,失败下轮重试
|
|
243
|
+
async backfill(persistence, sessions, signal) {
|
|
244
|
+
if (this.#state.running) return
|
|
245
|
+
if (signal?.aborted) return
|
|
246
|
+
this.#state.running = true
|
|
247
|
+
this.#state.error = undefined
|
|
248
|
+
try {
|
|
249
|
+
const headers = await persistence.list(signal)
|
|
250
|
+
const seen = await this.store.seenSessions()
|
|
251
|
+
const liveSeq = await this.store.liveSequences()
|
|
252
|
+
const targets = headers.filter((header) => !seen.has(header.id))
|
|
253
|
+
this.#state.total = targets.length
|
|
254
|
+
this.#state.done = 0
|
|
255
|
+
this.#state.skippedSessions = 0
|
|
256
|
+
const workerCount = Math.min(DEFAULT_BACKFILL_CONCURRENCY, Math.max(1, targets.length))
|
|
257
|
+
let next = 0
|
|
258
|
+
const completed = []
|
|
259
|
+
const flushCompleted = async () => {
|
|
260
|
+
if (completed.length === 0) return
|
|
261
|
+
const batch = completed.splice(0, completed.length)
|
|
262
|
+
await this.store.markSeenSessions(batch)
|
|
263
|
+
}
|
|
264
|
+
const worker = async () => {
|
|
265
|
+
for (;;) {
|
|
266
|
+
if (signal?.aborted) return
|
|
267
|
+
const i = next++
|
|
268
|
+
if (i >= targets.length) return
|
|
269
|
+
const header = targets[i]
|
|
270
|
+
this.#state.lastSessionId = header.id
|
|
271
|
+
const boundary = liveSeq.get(header.id)
|
|
272
|
+
// 活跃会话仅在有有效边界时安全重放前缀;无边界或 -1 哨兵说明
|
|
273
|
+
// 事件归本 boot 实时路径,或会话 mid-scan 恢复、边界在快照后写入
|
|
274
|
+
const isLiveNow = sessions.list().some((item) => item.id === header.id)
|
|
275
|
+
if (isLiveNow && (boundary === undefined || boundary < 0)) {
|
|
276
|
+
this.#state.done += 1
|
|
277
|
+
continue
|
|
278
|
+
}
|
|
279
|
+
const fromScratch = boundary === undefined
|
|
280
|
+
const replayNothing = !fromScratch && boundary < 0
|
|
281
|
+
const skipLiveOwned = (event) => {
|
|
282
|
+
if (replayNothing) return true
|
|
283
|
+
if (fromScratch) return false
|
|
284
|
+
return typeof event.seq === 'number' && event.seq >= boundary
|
|
285
|
+
}
|
|
286
|
+
// 每会话全新 fold:(turn,step) 键按会话隔离,并发重放不共享
|
|
287
|
+
const fold = new UsageFold()
|
|
288
|
+
let route = ''
|
|
289
|
+
try {
|
|
290
|
+
const inspection = await persistence.inspect(header.id, signal)
|
|
291
|
+
const inheritedCut = inspection.inheritedEventCount
|
|
292
|
+
for (const event of inspection.events) {
|
|
293
|
+
if (signal?.aborted) return
|
|
294
|
+
if (skipLiveOwned(event)) continue
|
|
295
|
+
if (inheritedCut > 0 && typeof event.seq === 'number' && event.seq < inheritedCut) continue
|
|
296
|
+
// 继承前缀整体跳过含路由播种:子会话路由在首次变更时重新宣告
|
|
297
|
+
if (event.type === 'request/context') {
|
|
298
|
+
if (event.data?.provider && event.data?.model) route = `${event.data.provider}/${event.data.model}`
|
|
299
|
+
else if (event.data?.model) route = event.data.model
|
|
300
|
+
} else if (event.type === 'assistant/message') {
|
|
301
|
+
const ref = refOf(event.data?.message?.source)
|
|
302
|
+
if (ref !== undefined) route = ref
|
|
303
|
+
}
|
|
304
|
+
const sample = fold.fold(event)
|
|
305
|
+
if (sample) {
|
|
306
|
+
if (!sample.turn) sample.model = route || undefined
|
|
307
|
+
await this.store.record(sample)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
completed.push(header.id)
|
|
311
|
+
if (completed.length >= MARK_BATCH) await flushCompleted()
|
|
312
|
+
this.#state.scannedSessions += 1
|
|
313
|
+
} catch {
|
|
314
|
+
// 读取失败(如宿主报会话日志损坏)按定案跳过:计数并继续,不中断回扫、不挂错误横幅
|
|
315
|
+
this.#state.skippedSessions += 1
|
|
316
|
+
} finally {
|
|
317
|
+
this.#state.done += 1
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()))
|
|
322
|
+
await flushCompleted()
|
|
323
|
+
} finally {
|
|
324
|
+
this.#state.running = false
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// 用量统计面板 Host 半区:采集 session/event 落 usage_stats 域,经 webServer
|
|
2
|
+
// 暴露 /api/usage-dash/* 供浏览器半区消费。S2 落存储域与 settings 注册,
|
|
3
|
+
// S3 接入采集与启动回扫,S13 扩展 pricing 形状并接线定价能力。
|
|
4
|
+
|
|
5
|
+
import schemastery from '@deepseek-ai/schemastery'
|
|
6
|
+
|
|
7
|
+
import { UsageCollector } from './collector.js'
|
|
8
|
+
import { CURRENCIES, CONDITION_KINDS, UNIT_PER_MILLION } from './pricing.js'
|
|
9
|
+
import { registerUsageRoutes } from './routes.js'
|
|
10
|
+
import { DEFAULT_MINUTE_RETENTION_DAYS, sharedStore } from './store.js'
|
|
11
|
+
// 顶层 inject 仅声明 web profile 必然存在的四个服务;settings 在 apply 内
|
|
12
|
+
// 嵌套 inject(通道级静默不激活),构成干净禁用。
|
|
13
|
+
export const inject = ['webServer', 'sessionPersistence', 'sessions', 'storageDomain']
|
|
14
|
+
|
|
15
|
+
export const name = 'dsh-usage-dash'
|
|
16
|
+
|
|
17
|
+
const SETTINGS_NAMESPACE = 'usage-dash'
|
|
18
|
+
|
|
19
|
+
// 定价形状与 pricing.js 契约同源:kind 判别 required 恒成立,
|
|
20
|
+
// 防 nullable 直通误入首个 union 成员
|
|
21
|
+
const WEEKDAY_MIN = 0
|
|
22
|
+
const WEEKDAY_MAX = 6
|
|
23
|
+
|
|
24
|
+
const CONDITION_FIELD_SCHEMAS = {
|
|
25
|
+
dailyWindow: { from: schemastery.string(), to: schemastery.string() },
|
|
26
|
+
weekdays: { days: schemastery.array(schemastery.number().min(WEEKDAY_MIN).max(WEEKDAY_MAX).step(1)) },
|
|
27
|
+
monthDays: { from: schemastery.number().step(1), to: schemastery.number().step(1) },
|
|
28
|
+
dateRange: { from: schemastery.string(), to: schemastery.string() },
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const CONDITION_SCHEMA = schemastery.union(
|
|
32
|
+
CONDITION_KINDS.map((kind) => schemastery.object({
|
|
33
|
+
kind: schemastery.const(kind).required(),
|
|
34
|
+
...CONDITION_FIELD_SCHEMAS[kind],
|
|
35
|
+
})),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const PRICING_RULE_SCHEMA = schemastery.object({
|
|
39
|
+
model: schemastery.string(),
|
|
40
|
+
unit: schemastery.const(UNIT_PER_MILLION),
|
|
41
|
+
currency: schemastery.union(CURRENCIES.map((currency) => schemastery.const(currency))),
|
|
42
|
+
price: schemastery.object({
|
|
43
|
+
input: schemastery.number().min(0),
|
|
44
|
+
output: schemastery.number().min(0),
|
|
45
|
+
cacheRead: schemastery.number().min(0),
|
|
46
|
+
cacheWrite: schemastery.number().min(0),
|
|
47
|
+
}),
|
|
48
|
+
conditions: schemastery.array(CONDITION_SCHEMA),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const SETTINGS_SCHEMA = schemastery.object({
|
|
52
|
+
minuteRetentionDays: schemastery.number().min(0).step(1).default(DEFAULT_MINUTE_RETENTION_DAYS)
|
|
53
|
+
.description('分钟桶保留天数(上限 48h),0 表示禁用分钟桶'),
|
|
54
|
+
pricing: schemastery.object({ rules: schemastery.array(PRICING_RULE_SCHEMA) })
|
|
55
|
+
.description('定价规则(wire 由 /api/usage-dash/pricing 读写)'),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
export function apply(ctx, config) {
|
|
59
|
+
const store = sharedStore(ctx.storageDomain)
|
|
60
|
+
const collector = new UsageCollector(ctx, store)
|
|
61
|
+
// pricing 能力门面:settings 激活前置 active=false(routes 侧 GET 空值、
|
|
62
|
+
// POST 拒 503),激活后实时读 settings——改价下次查询即时生效
|
|
63
|
+
const pricing = {
|
|
64
|
+
active: false,
|
|
65
|
+
rules: () => [],
|
|
66
|
+
revision: () => 0,
|
|
67
|
+
replace: async () => {
|
|
68
|
+
throw new Error('usage-dash: pricing settings inactive')
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
72
|
+
const settings = settingsCtx.settings
|
|
73
|
+
settings.register(SETTINGS_NAMESPACE, SETTINGS_SCHEMA, { base: config })
|
|
74
|
+
// 每日本地日首次写入时按保留值清理分钟/小时桶;启动回扫前的触发在下方回扫入口
|
|
75
|
+
store.retentionDays = () =>
|
|
76
|
+
settings.get(SETTINGS_NAMESPACE)?.minuteRetentionDays ?? DEFAULT_MINUTE_RETENTION_DAYS
|
|
77
|
+
pricing.active = true
|
|
78
|
+
pricing.rules = () => settings.get(SETTINGS_NAMESPACE)?.pricing?.rules ?? []
|
|
79
|
+
// revision 取 describe 中本 ns 描述符,settings 任何写入都会自增
|
|
80
|
+
pricing.revision = () =>
|
|
81
|
+
settings.describe().find((descriptor) => descriptor.ns === SETTINGS_NAMESPACE)?.revision ?? 0
|
|
82
|
+
pricing.replace = async (rules) => {
|
|
83
|
+
// update 深合并下数组整体替换,不动 minuteRetentionDays 用户层
|
|
84
|
+
await settings.update(SETTINGS_NAMESPACE, { pricing: { rules } })
|
|
85
|
+
return settings.get(SETTINGS_NAMESPACE)?.pricing?.rules ?? []
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
const bootScan = async () => {
|
|
89
|
+
await store.readyPromise()
|
|
90
|
+
await store.pruneMinutes()
|
|
91
|
+
await store.pruneHours()
|
|
92
|
+
await collector.rescan()
|
|
93
|
+
}
|
|
94
|
+
ctx.effect(() => {
|
|
95
|
+
collector.start()
|
|
96
|
+
void bootScan().catch((err) => {
|
|
97
|
+
ctx.logger?.warn(`usage-dash: 启动回扫未完成 ${err instanceof Error ? err.message : String(err)}`)
|
|
98
|
+
})
|
|
99
|
+
// 卸载中止在飞扫描,防热重载后遗留扫描向已关闭的域写入
|
|
100
|
+
return () => collector.abort()
|
|
101
|
+
}, 'usage-dash: collector')
|
|
102
|
+
// 保留值经 store 单源转发:settings 激活即取设置值,未激活回落构造默认
|
|
103
|
+
registerUsageRoutes(ctx, { store, collector, retentionDays: () => store.retentionDays(), pricing })
|
|
104
|
+
}
|
package/src/pricing.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// 定价纯函数:规则匹配与四桶计价,零宿主依赖、零 IO、无全局状态。
|
|
2
|
+
// 本地时区取 Date 本地分量,timestamp 接受 Date 或 epoch 毫秒;匹配只读遍历入参规则。
|
|
3
|
+
|
|
4
|
+
export const UNIT_PER_MILLION = 'perMillion'
|
|
5
|
+
export const CURRENCIES = ['¥', '$']
|
|
6
|
+
export const CONDITION_KINDS = ['dailyWindow', 'weekdays', 'monthDays', 'dateRange']
|
|
7
|
+
export const TOKENS_PER_MILLION = 1000 * 1000
|
|
8
|
+
|
|
9
|
+
const MODEL_WILDCARD = '*'
|
|
10
|
+
const MINUTES_PER_HOUR = 60
|
|
11
|
+
const DAY_PART_WIDTH = 2
|
|
12
|
+
const ISO_DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
|
13
|
+
|
|
14
|
+
const pad = (value) => String(value).padStart(DAY_PART_WIDTH, '0')
|
|
15
|
+
const formatDate = (date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
|
16
|
+
|
|
17
|
+
const minutesOfDay = (date) => date.getHours() * MINUTES_PER_HOUR + date.getMinutes()
|
|
18
|
+
|
|
19
|
+
const toMinutesOfDay = (hhmm) => {
|
|
20
|
+
if (typeof hhmm !== 'string') return Number.NaN
|
|
21
|
+
const [hours, minutes] = hhmm.split(':')
|
|
22
|
+
const h = Number(hours)
|
|
23
|
+
const m = Number(minutes)
|
|
24
|
+
return Number.isFinite(h) && Number.isFinite(m) ? h * MINUTES_PER_HOUR + m : Number.NaN
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// from<to 含头不含尾;from>to 跨午夜;from===to 全天生效
|
|
28
|
+
const dailyWindowMatches = (condition, date) => {
|
|
29
|
+
const from = toMinutesOfDay(condition.from)
|
|
30
|
+
const to = toMinutesOfDay(condition.to)
|
|
31
|
+
if (Number.isNaN(from) || Number.isNaN(to)) return false
|
|
32
|
+
const m = minutesOfDay(date)
|
|
33
|
+
if (from < to) return m >= from && m < to
|
|
34
|
+
if (from > to) return m >= from || m < to
|
|
35
|
+
return true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// days 空数组不成立;0=周日,取 getDay()
|
|
39
|
+
const weekdaysMatches = (condition, date) => {
|
|
40
|
+
const { days } = condition
|
|
41
|
+
return Array.isArray(days) && days.length > 0 && days.includes(date.getDay())
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 号段双闭;from>to 跨月环绕(如 26~25 账单周期);日号必须整数,2 月无 31 号自然不触发
|
|
45
|
+
const monthDaysMatches = (condition, date) => {
|
|
46
|
+
const { from, to } = condition
|
|
47
|
+
if (!Number.isInteger(from) || !Number.isInteger(to)) return false
|
|
48
|
+
const d = date.getDate()
|
|
49
|
+
return from <= to ? d >= from && d <= to : d >= from || d <= to
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 要求零填充 YYYY-MM-DD 字典序双闭;from>to 属配置错误不成立,非规范串同样不成立
|
|
53
|
+
const dateRangeMatches = (condition, date) => {
|
|
54
|
+
const { from, to } = condition
|
|
55
|
+
if (typeof from !== 'string' || typeof to !== 'string') return false
|
|
56
|
+
if (!ISO_DAY_PATTERN.test(from) || !ISO_DAY_PATTERN.test(to) || from > to) return false
|
|
57
|
+
const iso = formatDate(date)
|
|
58
|
+
return iso >= from && iso <= to
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const CONDITION_MATCHERS = {
|
|
62
|
+
dailyWindow: dailyWindowMatches,
|
|
63
|
+
weekdays: weekdaysMatches,
|
|
64
|
+
monthDays: monthDaysMatches,
|
|
65
|
+
dateRange: dateRangeMatches,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 单条件判定;未知 kind、形状残缺或非法 Date 一律不成立
|
|
69
|
+
export const conditionMatches = (condition, date) => {
|
|
70
|
+
const matcher = condition && CONDITION_MATCHERS[condition.kind]
|
|
71
|
+
if (!matcher || !(date instanceof Date) || Number.isNaN(date.getTime())) return false
|
|
72
|
+
return matcher(condition, date)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 形状残缺规则跳过:缺 model/price、unit 非 perMillion、conditions 非数组(含缺失)
|
|
76
|
+
const isRuleShaped = (rule) =>
|
|
77
|
+
!!rule && typeof rule === 'object' && typeof rule.model === 'string'
|
|
78
|
+
&& (rule.unit === undefined || rule.unit === UNIT_PER_MILLION)
|
|
79
|
+
&& !!rule.price && typeof rule.price === 'object' && !Array.isArray(rule.price)
|
|
80
|
+
&& Array.isArray(rule.conditions)
|
|
81
|
+
|
|
82
|
+
const firstMatchingPrice = (rules, date, modelFilter) => {
|
|
83
|
+
for (const rule of rules) {
|
|
84
|
+
if (!isRuleShaped(rule) || !modelFilter(rule)) continue
|
|
85
|
+
if (rule.conditions.every((condition) => conditionMatches(condition, date))) return rule.price
|
|
86
|
+
}
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const toLocalDate = (timestamp) => {
|
|
91
|
+
const date = timestamp instanceof Date ? timestamp : new Date(timestamp)
|
|
92
|
+
return Number.isNaN(date.getTime()) ? null : date
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 精确子集按数组序取首个命中;无精确子集或全不命中回落 '*' 子集;仍无命中为 null(调用方计 unpriced)
|
|
96
|
+
export const matchPrice = (rules, model, timestamp) => {
|
|
97
|
+
const date = toLocalDate(timestamp)
|
|
98
|
+
if (!Array.isArray(rules) || !date || typeof model !== 'string') return null
|
|
99
|
+
return firstMatchingPrice(rules, date, (rule) => rule.model === model)
|
|
100
|
+
?? firstMatchingPrice(rules, date, (rule) => rule.model === MODEL_WILDCARD)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const BUCKET_PRICE_KEYS = [
|
|
104
|
+
{ tokens: 'inputTokens', price: 'input' },
|
|
105
|
+
{ tokens: 'outputTokens', price: 'output' },
|
|
106
|
+
{ tokens: 'cacheReadTokens', price: 'cacheRead' },
|
|
107
|
+
{ tokens: 'cacheWriteTokens', price: 'cacheWrite' },
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
const toFiniteNumber = (value) => (Number.isFinite(value) ? value : 0)
|
|
111
|
+
|
|
112
|
+
// 费用 = Σ(桶 token × 桶单价) / 每百万;缺桶或非法值按 0,原始浮点不圆整(展示层负责)
|
|
113
|
+
export const costOf = (price, buckets) => {
|
|
114
|
+
let raw = 0
|
|
115
|
+
for (const { tokens, price: priceKey } of BUCKET_PRICE_KEYS) {
|
|
116
|
+
raw += toFiniteNumber(buckets?.[tokens]) * toFiniteNumber(price?.[priceKey])
|
|
117
|
+
}
|
|
118
|
+
return raw / TOKENS_PER_MILLION
|
|
119
|
+
}
|