@mzzsfy/dsh-usage-dash 0.1.0 → 0.2.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 +11 -9
- package/package.json +1 -1
- package/src/client.js +440 -131
- package/src/collector.js +46 -12
- package/src/index.js +2 -2
- package/src/pricing.js +47 -9
- package/src/query.js +22 -3
- package/src/routes.js +3 -2
- package/src/store.js +123 -43
- package/test/client-eval.mjs +23 -0
- package/test/client-scope.test.mjs +60 -0
- package/test/client.test.mjs +145 -37
- package/test/collector.test.mjs +93 -0
- package/test/pricing-parity.test.mjs +99 -36
- package/test/pricing.test.mjs +112 -10
- package/test/query.test.mjs +33 -3
- package/test/routes.test.mjs +11 -7
- package/test/stats-line.test.mjs +8 -16
- package/test/store.test.mjs +145 -22
- package/test/turn-tail.test.mjs +12 -20
package/src/collector.js
CHANGED
|
@@ -9,11 +9,15 @@ const UNKNOWN_SESSION_ID = '(unknown-session)'
|
|
|
9
9
|
const SEQ_UNKNOWN = -1
|
|
10
10
|
const DEFAULT_BACKFILL_CONCURRENCY = 4
|
|
11
11
|
const MARK_BATCH = 32
|
|
12
|
+
const SCAN_LOG_MAX_ENTRIES = 200
|
|
12
13
|
|
|
13
14
|
// 会话内单 pass 折叠:跟踪每个 (turn,step) 槽的最新报告,只把首次发射交 store
|
|
14
15
|
export class UsageFold {
|
|
15
16
|
constructor() {
|
|
16
17
|
this.seen = new Map()
|
|
18
|
+
// (turn,step) 最近一次模型启动时刻:retry-started 覆盖重置,时长与首样本
|
|
19
|
+
// token 同源配对(分子只含最终尝试,分母不含失败尝试,避免速度被污染)
|
|
20
|
+
this.starts = new Map()
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
keyOf(event) {
|
|
@@ -37,6 +41,8 @@ export class UsageFold {
|
|
|
37
41
|
if (event.type === 'step/start' || event.type === 'llm/retry-started') {
|
|
38
42
|
// step/start 恰开一次模型调用,retry-started 标记每次实际启动的重试;
|
|
39
43
|
// 请求只由标记计数,与 token 样本双计
|
|
44
|
+
const key = this.keyOf(event)
|
|
45
|
+
if (key !== null) this.starts.set(key, event.time)
|
|
40
46
|
return {
|
|
41
47
|
time: event.time,
|
|
42
48
|
inputTokens: 0,
|
|
@@ -69,6 +75,13 @@ export class UsageFold {
|
|
|
69
75
|
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
70
76
|
cacheWriteTokens: usage.cacheWriteTokens ?? 0,
|
|
71
77
|
}
|
|
78
|
+
// 模型时长口径与官方 session-stats 投影 llmMs 同构(step/start → 汇报时刻),
|
|
79
|
+
// 附加到交 store 的首样本;未观测起点或时刻倒挂(时钟回拨)不附,0 视为无
|
|
80
|
+
const start = key !== null ? this.starts.get(key) : undefined
|
|
81
|
+
if (start !== undefined) {
|
|
82
|
+
const durationMs = Math.max(0, event.time - start)
|
|
83
|
+
if (durationMs > 0) sample.durationMs = durationMs
|
|
84
|
+
}
|
|
72
85
|
if (key === null) return sample
|
|
73
86
|
const prev = this.seen.get(key)
|
|
74
87
|
this.seen.set(key, sample)
|
|
@@ -93,6 +106,10 @@ export class UsageCollector {
|
|
|
93
106
|
constructor(ctx, store) {
|
|
94
107
|
this.ctx = ctx
|
|
95
108
|
this.store = store
|
|
109
|
+
// store 写合并周期落盘的失败行接入扫描异常日志,面板可观测
|
|
110
|
+
store.onFlushError = (error) => {
|
|
111
|
+
this.pushLog('record', error instanceof Error ? error.message : String(error))
|
|
112
|
+
}
|
|
96
113
|
this.folds = new Map()
|
|
97
114
|
this.routes = new Map()
|
|
98
115
|
this.started = false
|
|
@@ -108,16 +125,33 @@ export class UsageCollector {
|
|
|
108
125
|
scannedSessions: 0,
|
|
109
126
|
lastSessionId: undefined,
|
|
110
127
|
error: undefined,
|
|
111
|
-
|
|
112
|
-
skippedSessions: 0,
|
|
128
|
+
log: [],
|
|
113
129
|
}
|
|
114
|
-
// error
|
|
130
|
+
// error 保留给采集器自身故障;单会话读取失败走日志 skipped 条目,计数由日志派生
|
|
115
131
|
}
|
|
116
132
|
|
|
117
133
|
#state
|
|
118
134
|
|
|
119
135
|
status() {
|
|
120
|
-
|
|
136
|
+
const log = [...this.#state.log]
|
|
137
|
+
return {
|
|
138
|
+
running: this.#state.running,
|
|
139
|
+
total: this.#state.total,
|
|
140
|
+
done: this.#state.done,
|
|
141
|
+
scannedSessions: this.#state.scannedSessions,
|
|
142
|
+
lastSessionId: this.#state.lastSessionId,
|
|
143
|
+
error: this.#state.error,
|
|
144
|
+
skippedSessions: log.filter((entry) => entry.kind === 'skipped').length,
|
|
145
|
+
recordFailures: log.filter((entry) => entry.kind === 'record').length,
|
|
146
|
+
log,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 扫描异常日志:供面板明细展示,超上限丢最旧
|
|
151
|
+
pushLog(kind, detail) {
|
|
152
|
+
const log = this.#state.log
|
|
153
|
+
log.push({ time: Date.now(), kind, detail })
|
|
154
|
+
if (log.length > SCAN_LOG_MAX_ENTRIES) log.splice(0, log.length - SCAN_LOG_MAX_ENTRIES)
|
|
121
155
|
}
|
|
122
156
|
|
|
123
157
|
get running() {
|
|
@@ -148,8 +182,8 @@ export class UsageCollector {
|
|
|
148
182
|
if (!sample) return
|
|
149
183
|
// turn 标记落合成行不归因;其余样本先取本调用 source,再取会话路由
|
|
150
184
|
if (!sample.turn) sample.model = fromSource ?? this.routeFor(sid)
|
|
151
|
-
void this.store.record(sample).catch(() => {
|
|
152
|
-
this
|
|
185
|
+
void this.store.record(sample).catch((error) => {
|
|
186
|
+
this.pushLog('record', error?.message ?? String(error))
|
|
153
187
|
})
|
|
154
188
|
})
|
|
155
189
|
// 释放销毁会话的内存桶,防长跑宿主按会话数累积
|
|
@@ -192,8 +226,8 @@ export class UsageCollector {
|
|
|
192
226
|
const batch = [...this.liveMarkBuffer.entries()]
|
|
193
227
|
this.liveMarkBuffer.clear()
|
|
194
228
|
if (batch.length > 0) {
|
|
195
|
-
void this.store.markLiveSequences(batch).catch(() => {
|
|
196
|
-
this
|
|
229
|
+
void this.store.markLiveSequences(batch).catch((error) => {
|
|
230
|
+
this.pushLog('record', error?.message ?? String(error))
|
|
197
231
|
})
|
|
198
232
|
}
|
|
199
233
|
})
|
|
@@ -252,7 +286,7 @@ export class UsageCollector {
|
|
|
252
286
|
const targets = headers.filter((header) => !seen.has(header.id))
|
|
253
287
|
this.#state.total = targets.length
|
|
254
288
|
this.#state.done = 0
|
|
255
|
-
this.#state.
|
|
289
|
+
this.#state.log = []
|
|
256
290
|
const workerCount = Math.min(DEFAULT_BACKFILL_CONCURRENCY, Math.max(1, targets.length))
|
|
257
291
|
let next = 0
|
|
258
292
|
const completed = []
|
|
@@ -310,9 +344,9 @@ export class UsageCollector {
|
|
|
310
344
|
completed.push(header.id)
|
|
311
345
|
if (completed.length >= MARK_BATCH) await flushCompleted()
|
|
312
346
|
this.#state.scannedSessions += 1
|
|
313
|
-
} catch {
|
|
314
|
-
// 读取失败(如宿主报会话日志损坏)
|
|
315
|
-
this
|
|
347
|
+
} catch (error) {
|
|
348
|
+
// 读取失败(如宿主报会话日志损坏)按定案跳过:记日志并继续,不中断回扫、不挂错误横幅
|
|
349
|
+
this.pushLog('skipped', error?.message ? `${header.id} ${error.message}` : header.id)
|
|
316
350
|
} finally {
|
|
317
351
|
this.#state.done += 1
|
|
318
352
|
}
|
package/src/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import schemastery from '@deepseek-ai/schemastery'
|
|
|
7
7
|
import { UsageCollector } from './collector.js'
|
|
8
8
|
import { CURRENCIES, CONDITION_KINDS, UNIT_PER_MILLION } from './pricing.js'
|
|
9
9
|
import { registerUsageRoutes } from './routes.js'
|
|
10
|
-
import { DEFAULT_MINUTE_RETENTION_DAYS, sharedStore } from './store.js'
|
|
10
|
+
import { DEFAULT_MINUTE_RETENTION_DAYS, MINUTE_RETENTION_MAX_DAYS, sharedStore } from './store.js'
|
|
11
11
|
// 顶层 inject 仅声明 web profile 必然存在的四个服务;settings 在 apply 内
|
|
12
12
|
// 嵌套 inject(通道级静默不激活),构成干净禁用。
|
|
13
13
|
export const inject = ['webServer', 'sessionPersistence', 'sessions', 'storageDomain']
|
|
@@ -50,7 +50,7 @@ const PRICING_RULE_SCHEMA = schemastery.object({
|
|
|
50
50
|
|
|
51
51
|
const SETTINGS_SCHEMA = schemastery.object({
|
|
52
52
|
minuteRetentionDays: schemastery.number().min(0).step(1).default(DEFAULT_MINUTE_RETENTION_DAYS)
|
|
53
|
-
.description(
|
|
53
|
+
.description(`分钟桶保留天数(上限 ${MINUTE_RETENTION_MAX_DAYS} 天),0 表示禁用分钟桶`),
|
|
54
54
|
pricing: schemastery.object({ rules: schemastery.array(PRICING_RULE_SCHEMA) })
|
|
55
55
|
.description('定价规则(wire 由 /api/usage-dash/pricing 读写)'),
|
|
56
56
|
})
|
package/src/pricing.js
CHANGED
|
@@ -7,6 +7,12 @@ export const CONDITION_KINDS = ['dailyWindow', 'weekdays', 'monthDays', 'dateRan
|
|
|
7
7
|
export const TOKENS_PER_MILLION = 1000 * 1000
|
|
8
8
|
|
|
9
9
|
const MODEL_WILDCARD = '*'
|
|
10
|
+
const SEGMENT_SEPARATOR = '/'
|
|
11
|
+
// 请求侧无斜杠时的 vendor 段缺省值,与存储行 provider 口径同源
|
|
12
|
+
export const PROVIDER_UNSET = 'default'
|
|
13
|
+
// 档位权重:vendor 段通配 1 档、model 段通配 2 档,和越小越优先(模型名精确档恒优于供应商精确档)
|
|
14
|
+
const VENDOR_WILDCARD_TIER = 1
|
|
15
|
+
const MODEL_WILDCARD_TIER = 2
|
|
10
16
|
const MINUTES_PER_HOUR = 60
|
|
11
17
|
const DAY_PART_WIDTH = 2
|
|
12
18
|
const ISO_DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
|
@@ -79,12 +85,31 @@ const isRuleShaped = (rule) =>
|
|
|
79
85
|
&& !!rule.price && typeof rule.price === 'object' && !Array.isArray(rule.price)
|
|
80
86
|
&& Array.isArray(rule.conditions)
|
|
81
87
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
// 规则模型键必须两段式:首个 / 前 vendor 段、后模型段(允许含 /),首位斜杠或无斜杠均非法
|
|
89
|
+
export const splitRuleSegments = (pattern) => {
|
|
90
|
+
const slash = pattern.indexOf(SEGMENT_SEPARATOR)
|
|
91
|
+
return slash > 0 ? [pattern.slice(0, slash), pattern.slice(slash + SEGMENT_SEPARATOR.length)] : null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 段须非空且不含空白:含空白的模型键永不匹配真实请求
|
|
95
|
+
const SEGMENT_PATTERN = /^\S+$/
|
|
96
|
+
|
|
97
|
+
export const isTwoSegmentModel = (pattern) => {
|
|
98
|
+
const segments = splitRuleSegments(pattern)
|
|
99
|
+
return segments !== null && segments.every((segment) => SEGMENT_PATTERN.test(segment))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 请求侧模型键按同构规则分段,无 / 或首位斜杠时 vendor 段缺省归 default(与存储行口径一致)
|
|
103
|
+
export const splitRequestSegments = (model) => splitRuleSegments(model) ?? [PROVIDER_UNSET, model]
|
|
104
|
+
|
|
105
|
+
// 段级比对:规则段为通配或与请求段相等;两段全过才成立,通配段按各自档位计权
|
|
106
|
+
const matchTier = (ruleSegments, requestSegments) => {
|
|
107
|
+
const [ruleVendor, ruleModel] = ruleSegments
|
|
108
|
+
const [requestVendor, requestModel] = requestSegments
|
|
109
|
+
if (ruleVendor !== MODEL_WILDCARD && ruleVendor !== requestVendor) return null
|
|
110
|
+
if (ruleModel !== MODEL_WILDCARD && ruleModel !== requestModel) return null
|
|
111
|
+
return (ruleVendor === MODEL_WILDCARD ? VENDOR_WILDCARD_TIER : 0)
|
|
112
|
+
+ (ruleModel === MODEL_WILDCARD ? MODEL_WILDCARD_TIER : 0)
|
|
88
113
|
}
|
|
89
114
|
|
|
90
115
|
const toLocalDate = (timestamp) => {
|
|
@@ -92,12 +117,25 @@ const toLocalDate = (timestamp) => {
|
|
|
92
117
|
return Number.isNaN(date.getTime()) ? null : date
|
|
93
118
|
}
|
|
94
119
|
|
|
95
|
-
//
|
|
120
|
+
// 匹配链:全名 > 模型名(vendor 通配)> 供应商(model 通配)> '*/*' 全通;
|
|
121
|
+
// 档位最小者胜,同档按数组序取首个;高档条件不满足自然落低档;无命中为 null(调用方计 unpriced)
|
|
96
122
|
export const matchPrice = (rules, model, timestamp) => {
|
|
97
123
|
const date = toLocalDate(timestamp)
|
|
98
124
|
if (!Array.isArray(rules) || !date || typeof model !== 'string') return null
|
|
99
|
-
|
|
100
|
-
|
|
125
|
+
const requestSegments = splitRequestSegments(model)
|
|
126
|
+
let bestTier = Infinity
|
|
127
|
+
let bestPrice = null
|
|
128
|
+
for (const rule of rules) {
|
|
129
|
+
if (!isRuleShaped(rule)) continue
|
|
130
|
+
const ruleSegments = splitRuleSegments(rule.model)
|
|
131
|
+
if (!ruleSegments) continue
|
|
132
|
+
const tier = matchTier(ruleSegments, requestSegments)
|
|
133
|
+
if (tier === null || tier >= bestTier) continue
|
|
134
|
+
if (!rule.conditions.every((condition) => conditionMatches(condition, date))) continue
|
|
135
|
+
bestTier = tier
|
|
136
|
+
bestPrice = rule.price
|
|
137
|
+
}
|
|
138
|
+
return bestPrice
|
|
101
139
|
}
|
|
102
140
|
|
|
103
141
|
const BUCKET_PRICE_KEYS = [
|
package/src/query.js
CHANGED
|
@@ -7,6 +7,7 @@ export const MAX_SLOTS = 2000
|
|
|
7
7
|
|
|
8
8
|
const PAD_WIDTH = 2
|
|
9
9
|
const PERCENT_SCALE = 100
|
|
10
|
+
const MS_PER_SECOND = 1000
|
|
10
11
|
|
|
11
12
|
const GRANULARITY_DAILY = 'D'
|
|
12
13
|
const GRANULARITY_HOURLY = 'H'
|
|
@@ -115,8 +116,18 @@ export function aggregateRange(rows, g, from, to) {
|
|
|
115
116
|
slot.byModel[row.model] = (slot.byModel[row.model] ?? 0) + tokens
|
|
116
117
|
slot.byProvider[row.provider] = (slot.byProvider[row.provider] ?? 0) + tokens
|
|
117
118
|
const modelTotal = modelTotals.get(row.model)
|
|
118
|
-
if (modelTotal)
|
|
119
|
-
|
|
119
|
+
if (modelTotal) {
|
|
120
|
+
modelTotal.tokens += tokens
|
|
121
|
+
modelTotal.speedDurationMs += row.durationMs ?? 0
|
|
122
|
+
modelTotal.speedOutputTokens += row.durationMs ? row.outputTokens : 0
|
|
123
|
+
} else {
|
|
124
|
+
modelTotals.set(row.model, {
|
|
125
|
+
provider: row.provider,
|
|
126
|
+
tokens,
|
|
127
|
+
speedDurationMs: row.durationMs ?? 0,
|
|
128
|
+
speedOutputTokens: row.durationMs ? row.outputTokens : 0,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
120
131
|
providerTotals.set(row.provider, (providerTotals.get(row.provider) ?? 0) + tokens)
|
|
121
132
|
}
|
|
122
133
|
const totals = { tokens: 0, requests: 0, turns: 0, cacheHit: 0, cacheMiss: 0 }
|
|
@@ -127,8 +138,16 @@ export function aggregateRange(rows, g, from, to) {
|
|
|
127
138
|
totals.cacheHit += slot.cacheHit
|
|
128
139
|
totals.cacheMiss += slot.cacheMiss
|
|
129
140
|
}
|
|
141
|
+
// speed = 配对口径的输出 token ÷ 模型时长秒;仅时长>0 的行计入分子分母,
|
|
142
|
+
// 存量旧格式行只进 tokens 不进分母,无时长数据条目不挂 speed 字段
|
|
130
143
|
const models = [...modelTotals.entries()]
|
|
131
|
-
.map(([model, agg]) => ({
|
|
144
|
+
.map(([model, agg]) => ({
|
|
145
|
+
model,
|
|
146
|
+
provider: agg.provider,
|
|
147
|
+
tokens: agg.tokens,
|
|
148
|
+
percent: percentOf(agg.tokens, totals.tokens),
|
|
149
|
+
...(agg.speedDurationMs > 0 ? { speed: agg.speedOutputTokens / (agg.speedDurationMs / MS_PER_SECOND) } : {}),
|
|
150
|
+
}))
|
|
132
151
|
.sort((a, b) => b.tokens - a.tokens)
|
|
133
152
|
const providers = [...providerTotals.entries()]
|
|
134
153
|
.map(([provider, tokens]) => ({ provider, tokens, percent: percentOf(tokens, totals.tokens) }))
|
package/src/routes.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { z } from 'zod'
|
|
8
8
|
|
|
9
9
|
import { aggregateRange, attachCosts } from './query.js'
|
|
10
|
-
import { CURRENCIES, CONDITION_KINDS, UNIT_PER_MILLION } from './pricing.js'
|
|
10
|
+
import { CURRENCIES, CONDITION_KINDS, UNIT_PER_MILLION, isTwoSegmentModel } from './pricing.js'
|
|
11
11
|
import {
|
|
12
12
|
GRANULARITY_DAILY,
|
|
13
13
|
GRANULARITY_HOURLY,
|
|
@@ -75,7 +75,8 @@ const conditionSchema = z.union(
|
|
|
75
75
|
|
|
76
76
|
const pricingRulesSchema = z.array(
|
|
77
77
|
z.object({
|
|
78
|
-
model
|
|
78
|
+
// 模型键强制两段式 vendor/model(段可通配),单段旧形态提交即拒
|
|
79
|
+
model: z.string().refine(isTwoSegmentModel),
|
|
79
80
|
unit: z.literal(UNIT_PER_MILLION).optional(),
|
|
80
81
|
currency: z.enum(CURRENCIES).nullable(),
|
|
81
82
|
price: z.object({
|
package/src/store.js
CHANGED
|
@@ -1,28 +1,33 @@
|
|
|
1
1
|
// 用量统计存储:usage_stats 域,单表 buckets,行键 <粒度>|<桶串>|<provider>|<model>。
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// single 布局每次持久化写都全量重发布 unit 文档,故写入侧做合并(write-behind):
|
|
3
|
+
// record 同步累加进内存 pending,按周期 flush 批量落盘,同桶多样本折叠为一次
|
|
4
|
+
// update;flush 失败的行留在 pending 下轮重试,错误经 onFlushError 上抛。
|
|
5
|
+
// 游标读写与 flush 全部串行在同一条 promise 链上,防 lost update。进程级单例
|
|
6
|
+
// 挂 globalThis,防 HMR 热重载后重复开域。
|
|
6
7
|
|
|
7
8
|
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
|
|
8
9
|
import { z } from 'zod'
|
|
9
10
|
|
|
11
|
+
import { PROVIDER_UNSET, splitRequestSegments } from './pricing.js'
|
|
12
|
+
|
|
10
13
|
export const GRANULARITY_DAILY = 'D'
|
|
11
14
|
export const GRANULARITY_HOURLY = 'H'
|
|
12
15
|
export const GRANULARITY_MINUTE = 'M'
|
|
13
16
|
|
|
14
|
-
export const PROVIDER_DEFAULT = 'default'
|
|
15
17
|
export const MODEL_TURNS = '(turns)'
|
|
16
18
|
export const MODEL_UNKNOWN = '(unknown)'
|
|
17
19
|
|
|
18
|
-
export const DEFAULT_MINUTE_RETENTION_DAYS =
|
|
20
|
+
export const DEFAULT_MINUTE_RETENTION_DAYS = 7
|
|
19
21
|
|
|
20
22
|
// 分钟桶对齐粒度(分钟);小时/天桶不受影响
|
|
21
23
|
export const MINUTE_BUCKET_SPAN_MINUTES = 10
|
|
22
24
|
|
|
23
|
-
// 保留上限:小时桶固定 15 天,分钟桶可配置但最大
|
|
25
|
+
// 保留上限:小时桶固定 15 天,分钟桶可配置但最大 7 天(与分钟视图选择上限一致)
|
|
24
26
|
export const HOUR_RETENTION_DAYS = 15
|
|
25
|
-
export const MINUTE_RETENTION_MAX_DAYS =
|
|
27
|
+
export const MINUTE_RETENTION_MAX_DAYS = 7
|
|
28
|
+
|
|
29
|
+
// 写合并周期:pending 样本最长延迟该时长落盘;统计可由会话重扫重建,容忍窗口内丢失
|
|
30
|
+
export const FLUSH_INTERVAL_MS = 2000
|
|
26
31
|
|
|
27
32
|
const DAY_MS = 24 * 60 * 60 * 1000
|
|
28
33
|
const PAD_WIDTH = 2
|
|
@@ -64,9 +69,9 @@ export const GRANULARITIES = [
|
|
|
64
69
|
[GRANULARITY_MINUTE, minuteKey],
|
|
65
70
|
]
|
|
66
71
|
|
|
72
|
+
// vendor 段推导与定价匹配同源:首个 / 前段,无 / 或首位斜杠归 default
|
|
67
73
|
export function providerOf(modelRef) {
|
|
68
|
-
|
|
69
|
-
return slash > 0 ? modelRef.slice(0, slash) : PROVIDER_DEFAULT
|
|
74
|
+
return splitRequestSegments(modelRef)[0]
|
|
70
75
|
}
|
|
71
76
|
|
|
72
77
|
export const usageRowSchema = z.object({
|
|
@@ -77,6 +82,8 @@ export const usageRowSchema = z.object({
|
|
|
77
82
|
outputTokens: z.number(),
|
|
78
83
|
cacheReadTokens: z.number(),
|
|
79
84
|
cacheWriteTokens: z.number(),
|
|
85
|
+
// 模型时长累计(毫秒):optional 兼容存量记录(域 open 逐记录 parse)
|
|
86
|
+
durationMs: z.number().optional(),
|
|
80
87
|
requests: z.number(),
|
|
81
88
|
turns: z.number(),
|
|
82
89
|
lastSeen: z.number(),
|
|
@@ -110,12 +117,47 @@ function emptyRow(bucket, provider, model, nowMs) {
|
|
|
110
117
|
outputTokens: 0,
|
|
111
118
|
cacheReadTokens: 0,
|
|
112
119
|
cacheWriteTokens: 0,
|
|
120
|
+
durationMs: 0,
|
|
113
121
|
requests: 0,
|
|
114
122
|
turns: 0,
|
|
115
123
|
lastSeen: nowMs,
|
|
116
124
|
}
|
|
117
125
|
}
|
|
118
126
|
|
|
127
|
+
// 样本折叠为计数增量:turn/request 只计次,token 样本累加四类桶
|
|
128
|
+
function deltaOf(sample, nowMs) {
|
|
129
|
+
const delta = emptyRow('', '', '', nowMs)
|
|
130
|
+
delete delta.bucket
|
|
131
|
+
delete delta.provider
|
|
132
|
+
delete delta.model
|
|
133
|
+
if (sample.turn) delta.turns = 1
|
|
134
|
+
else if (sample.request) delta.requests = 1
|
|
135
|
+
else {
|
|
136
|
+
delta.inputTokens = sample.inputTokens
|
|
137
|
+
delta.outputTokens = sample.outputTokens
|
|
138
|
+
delta.cacheReadTokens = sample.cacheReadTokens
|
|
139
|
+
delta.cacheWriteTokens = sample.cacheWriteTokens
|
|
140
|
+
delta.durationMs = sample.durationMs ?? 0
|
|
141
|
+
}
|
|
142
|
+
return delta
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 增量累加:行与 pending 条目同构,恒等字段取 base,计数逐项相加,时刻取较新
|
|
146
|
+
function addDelta(base, delta) {
|
|
147
|
+
return {
|
|
148
|
+
...base,
|
|
149
|
+
inputTokens: base.inputTokens + delta.inputTokens,
|
|
150
|
+
outputTokens: base.outputTokens + delta.outputTokens,
|
|
151
|
+
cacheReadTokens: base.cacheReadTokens + delta.cacheReadTokens,
|
|
152
|
+
cacheWriteTokens: base.cacheWriteTokens + delta.cacheWriteTokens,
|
|
153
|
+
// base 侧 ?? 0 容存量旧格式行(缺字段);delta 侧经 deltaOf 恒为数值
|
|
154
|
+
durationMs: (base.durationMs ?? 0) + delta.durationMs,
|
|
155
|
+
requests: base.requests + delta.requests,
|
|
156
|
+
turns: base.turns + delta.turns,
|
|
157
|
+
lastSeen: Math.max(base.lastSeen, delta.lastSeen),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
119
161
|
function isMissingRecord(err) {
|
|
120
162
|
return err instanceof Error && MISSING_RECORD_PATTERN.test(err.message)
|
|
121
163
|
}
|
|
@@ -124,11 +166,16 @@ export class UsageStore {
|
|
|
124
166
|
constructor(facility, options = {}) {
|
|
125
167
|
this.now = options.now ?? Date.now
|
|
126
168
|
this.retentionDays = options.retentionDays ?? (() => DEFAULT_MINUTE_RETENTION_DAYS)
|
|
169
|
+
this.flushIntervalMs = options.flushIntervalMs ?? FLUSH_INTERVAL_MS
|
|
170
|
+
// flush 行级失败上抛缝:采集器注入后接入扫描异常日志
|
|
171
|
+
this.onFlushError = options.onFlushError
|
|
127
172
|
this.table = null
|
|
128
173
|
this.domain = null
|
|
129
174
|
this.openError = undefined
|
|
130
175
|
this.lastPruneDay = ''
|
|
131
176
|
this.markChain = Promise.resolve()
|
|
177
|
+
this.pending = new Map()
|
|
178
|
+
this.flushTimer = undefined
|
|
132
179
|
// ready 永远 resolve:打开失败转降级,操作按调用失败,逃逸拒绝会拖垮宿主
|
|
133
180
|
this.ready = this.initialize(facility).catch((err) => {
|
|
134
181
|
this.openError = err
|
|
@@ -173,16 +220,73 @@ export class UsageStore {
|
|
|
173
220
|
return this.readCursor() ?? {}
|
|
174
221
|
}
|
|
175
222
|
|
|
223
|
+
// 同步合并进 pending,不等待持久化;崩溃丢失窗口 = flush 周期
|
|
176
224
|
async record(sample) {
|
|
177
225
|
await this.ready
|
|
178
|
-
|
|
179
|
-
await this.pruneOncePerDay()
|
|
226
|
+
this.requireTable()
|
|
180
227
|
const nowMs = this.now()
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
)
|
|
184
|
-
|
|
185
|
-
|
|
228
|
+
const model = sample.turn ? MODEL_TURNS : sample.model ? sample.model : MODEL_UNKNOWN
|
|
229
|
+
const provider = sample.turn ? PROVIDER_UNSET : providerOf(model)
|
|
230
|
+
const delta = deltaOf(sample, nowMs)
|
|
231
|
+
for (const [g, bucketOf] of GRANULARITIES) {
|
|
232
|
+
const bucket = bucketOf(sample.time)
|
|
233
|
+
const entry = { ...delta, bucket, provider, model }
|
|
234
|
+
const existing = this.pending.get(rowKey(g, bucket, provider, model))
|
|
235
|
+
this.pending.set(rowKey(g, bucket, provider, model), existing ? addDelta(existing, entry) : entry)
|
|
236
|
+
}
|
|
237
|
+
this.scheduleFlush()
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
scheduleFlush() {
|
|
241
|
+
if (this.flushTimer !== undefined) return
|
|
242
|
+
this.flushTimer = setTimeout(() => {
|
|
243
|
+
this.flushTimer = undefined
|
|
244
|
+
void this.flushNow()
|
|
245
|
+
}, this.flushIntervalMs)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// pending 批量落盘:每脏行一次原子读改写,失败的行并回 pending 下轮重试,
|
|
249
|
+
// 错误聚合后经 onFlushError 上抛;与游标写同链串行
|
|
250
|
+
flushNow() {
|
|
251
|
+
const run = this.markChain.then(async () => {
|
|
252
|
+
await this.ready
|
|
253
|
+
if (this.pending.size === 0) return
|
|
254
|
+
try {
|
|
255
|
+
await this.flushBatch()
|
|
256
|
+
} catch (err) {
|
|
257
|
+
this.onFlushError?.(err)
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
this.markChain = run.then(() => {}, () => {})
|
|
261
|
+
return run
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async flushBatch() {
|
|
265
|
+
await this.pruneOncePerDay()
|
|
266
|
+
const table = this.requireTable()
|
|
267
|
+
const batch = this.pending
|
|
268
|
+
this.pending = new Map()
|
|
269
|
+
const outcomes = await Promise.allSettled([...batch].map(([key, delta]) => this.applyDelta(table, key, delta)))
|
|
270
|
+
const failures = outcomes.flatMap((outcome, index) => {
|
|
271
|
+
if (outcome.status !== 'rejected') return []
|
|
272
|
+
const key = [...batch.keys()][index]
|
|
273
|
+
const [entryKey, entry] = [key, batch.get(key)]
|
|
274
|
+
const existing = this.pending.get(entryKey)
|
|
275
|
+
this.pending.set(entryKey, existing ? addDelta(existing, entry) : entry)
|
|
276
|
+
return [outcome.reason]
|
|
277
|
+
})
|
|
278
|
+
if (failures.length > 0) throw new AggregateError(failures, 'usage store flush failed')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async applyDelta(table, key, delta) {
|
|
282
|
+
const apply = (current) => addDelta(current ?? emptyRow(delta.bucket, delta.provider, delta.model, delta.lastSeen), delta)
|
|
283
|
+
try {
|
|
284
|
+
await table.update(key, apply)
|
|
285
|
+
} catch (err) {
|
|
286
|
+
if (!isMissingRecord(err)) throw err
|
|
287
|
+
await table.put(key, emptyRow(delta.bucket, delta.provider, delta.model, delta.lastSeen))
|
|
288
|
+
await table.update(key, apply)
|
|
289
|
+
}
|
|
186
290
|
}
|
|
187
291
|
|
|
188
292
|
async pruneOncePerDay() {
|
|
@@ -220,33 +324,8 @@ export class UsageStore {
|
|
|
220
324
|
}
|
|
221
325
|
}
|
|
222
326
|
|
|
223
|
-
async recordRow(table, g, bucket, sample, nowMs) {
|
|
224
|
-
const model = sample.turn ? MODEL_TURNS : sample.model ? sample.model : MODEL_UNKNOWN
|
|
225
|
-
const provider = sample.turn ? PROVIDER_DEFAULT : providerOf(model)
|
|
226
|
-
const key = rowKey(g, bucket, provider, model)
|
|
227
|
-
const apply = (current) => {
|
|
228
|
-
const base = current ?? emptyRow(bucket, provider, model, nowMs)
|
|
229
|
-
if (sample.turn) return { ...base, turns: base.turns + 1, lastSeen: nowMs }
|
|
230
|
-
if (sample.request) return { ...base, requests: base.requests + 1, lastSeen: nowMs }
|
|
231
|
-
return {
|
|
232
|
-
...base,
|
|
233
|
-
inputTokens: base.inputTokens + sample.inputTokens,
|
|
234
|
-
outputTokens: base.outputTokens + sample.outputTokens,
|
|
235
|
-
cacheReadTokens: base.cacheReadTokens + sample.cacheReadTokens,
|
|
236
|
-
cacheWriteTokens: base.cacheWriteTokens + sample.cacheWriteTokens,
|
|
237
|
-
lastSeen: nowMs,
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
try {
|
|
241
|
-
await table.update(key, apply)
|
|
242
|
-
} catch (err) {
|
|
243
|
-
if (!isMissingRecord(err)) throw err
|
|
244
|
-
await table.put(key, emptyRow(bucket, provider, model, nowMs))
|
|
245
|
-
await table.update(key, apply)
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
327
|
async rangeRows(g, from, to) {
|
|
328
|
+
await this.flushNow()
|
|
250
329
|
await this.ready
|
|
251
330
|
const table = this.requireTable()
|
|
252
331
|
const prefix = `${g}|`
|
|
@@ -295,6 +374,7 @@ export class UsageStore {
|
|
|
295
374
|
|
|
296
375
|
reset(boundaries) {
|
|
297
376
|
return this.enqueueGlobalWrite(async () => {
|
|
377
|
+
await this.flushBatch()
|
|
298
378
|
const table = this.requireTable()
|
|
299
379
|
for (const key of [...table.keys()]) await table.delete(key)
|
|
300
380
|
const liveFirstSeq = {}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// client.js 书挡契约:首行 IIFE 开、末行 IIFE 闭;求值前剥壳得可整源求值的函数体。
|
|
2
|
+
// 供整源求值类测试统一取数,形态由 client-scope.test.mjs 守卫。
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { dirname, join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
const IIFE_OPEN = '(() => {'
|
|
8
|
+
const IIFE_CLOSE = '})()'
|
|
9
|
+
|
|
10
|
+
export const CLIENT_SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'client.js'), 'utf8')
|
|
11
|
+
|
|
12
|
+
const source = CLIENT_SOURCE.trimEnd()
|
|
13
|
+
if (!source.startsWith(IIFE_OPEN) || !source.endsWith(IIFE_CLOSE)) {
|
|
14
|
+
throw new Error(`client.js 书挡契约破坏:应以 ${IIFE_OPEN} 开、${IIFE_CLOSE} 闭`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const CLIENT_BODY = source.slice(IIFE_OPEN.length, -IIFE_CLOSE.length).trim()
|
|
18
|
+
|
|
19
|
+
export const DECLARATION_NAMES = [
|
|
20
|
+
...new Set(
|
|
21
|
+
[...CLIENT_BODY.matchAll(/^(?:const|function|let) ([A-Za-z_$][\w$]*)/gm)].map((match) => match[1])
|
|
22
|
+
),
|
|
23
|
+
]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// client.js 求值形态守卫:宿主以经典 script 整源求值,顶层词法声明落页面全局词法环境,
|
|
2
|
+
// 跨 bundle 同名即整脚本 SyntaxError 拒载;整文件 IIFE 书挡保证顶层零词法声明。
|
|
3
|
+
// Given/When/Then 场景内嵌于用例描述;vm.runInContext 与浏览器经典 script 同语义(全局词法环境跨脚本共享)。
|
|
4
|
+
import { test } from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { readFileSync } from 'node:fs'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
import vm from 'node:vm'
|
|
10
|
+
|
|
11
|
+
const CLIENT_SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'client.js'), 'utf8').trimEnd()
|
|
12
|
+
const DECLARATION_NAMES = [
|
|
13
|
+
...new Set(
|
|
14
|
+
[...CLIENT_SOURCE.matchAll(/^(?:const|let|var|class|(?:async )?function\*?) ([A-Za-z_$][\w$]*)/gm)].map((match) => match[1])
|
|
15
|
+
),
|
|
16
|
+
]
|
|
17
|
+
const BUNDLE_ID = '@mzzsfy/dsh-usage-dash'
|
|
18
|
+
|
|
19
|
+
test('Given 同 context 已有外部 CHART_PAD 声明(宿主经典 script 全局词法环境), When 整源求值 client.js, Then 双源共存不拒载且外部声明原值不变', () => {
|
|
20
|
+
const ctx = vm.createContext({})
|
|
21
|
+
vm.runInContext('const CHART_PAD = 1', ctx)
|
|
22
|
+
vm.runInContext(CLIENT_SOURCE, ctx)
|
|
23
|
+
assert.equal(vm.runInContext('CHART_PAD', ctx), 1)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('Given client.js 已整源求值, When 同 context 再声明外部 CHART_PAD, Then 双源共存不拒载', () => {
|
|
27
|
+
const ctx = vm.createContext({})
|
|
28
|
+
vm.runInContext(CLIENT_SOURCE, ctx)
|
|
29
|
+
vm.runInContext('const CHART_PAD = 1', ctx)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('Given client.js 整源求值完成, When 逐名以 const 重声明探针行首声明名, Then 全局词法环境零泄漏', () => {
|
|
33
|
+
const ctx = vm.createContext({})
|
|
34
|
+
vm.runInContext(CLIENT_SOURCE, ctx)
|
|
35
|
+
const leaked = DECLARATION_NAMES.filter((name) => {
|
|
36
|
+
try {
|
|
37
|
+
vm.runInContext(`const ${name} = null`, ctx)
|
|
38
|
+
return false
|
|
39
|
+
} catch {
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
assert.deepEqual(leaked, [])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('Given 宿主 loader 就绪, When 整源求值 client.js, Then 自注册照常发生且 id 不变', () => {
|
|
47
|
+
const registrations = []
|
|
48
|
+
const ctx = vm.createContext({ window: { __ModuleLoader__: { load: (registration) => registrations.push(registration) } } })
|
|
49
|
+
vm.runInContext(CLIENT_SOURCE, ctx)
|
|
50
|
+
assert.equal(registrations.length, 1)
|
|
51
|
+
assert.equal(registrations[0].id, BUNDLE_ID)
|
|
52
|
+
assert.equal(typeof registrations[0].factory, 'function')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('Given 书挡契约, When 校验首末行, Then 首行 IIFE 开、末行 IIFE 闭且声明收集非空', () => {
|
|
56
|
+
const lines = CLIENT_SOURCE.split('\n')
|
|
57
|
+
assert.equal(lines[0], '(() => {')
|
|
58
|
+
assert.equal(lines[lines.length - 1], '})()')
|
|
59
|
+
assert.ok(DECLARATION_NAMES.length > 0)
|
|
60
|
+
})
|