@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.
@@ -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
+ }