@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.
@@ -8,6 +8,7 @@ import {
8
8
  UNIT_PER_MILLION,
9
9
  conditionMatches,
10
10
  costOf,
11
+ isTwoSegmentModel,
11
12
  matchPrice,
12
13
  } from '../src/pricing.js'
13
14
 
@@ -19,7 +20,7 @@ const SATURDAY = new Date(2026, 8, 12)
19
20
  const localTime = (day, hour, minute = 0) => new Date(2026, 8, day, hour, minute)
20
21
 
21
22
  const makeRule = (overrides = {}) => ({
22
- model: '*',
23
+ model: '*/*',
23
24
  unit: UNIT_PER_MILLION,
24
25
  currency: '¥',
25
26
  price: { input: 1, output: 2, cacheRead: 0.5, cacheWrite: 1 },
@@ -53,9 +54,9 @@ test('无条件规则恒命中', () => {
53
54
  })
54
55
 
55
56
  test('精确模型优先于通配规则', () => {
56
- // Given 精确规则与 '*' 规则并存 When 匹配 Then 取精确规则 price
57
+ // Given 精确规则与 '*/*' 规则并存 When 匹配 Then 取精确规则 price
57
58
  const exact = makeRule({ model: 'a/b', price: { input: 5 } })
58
- const wildcard = makeRule({ model: '*', price: { input: 1 } })
59
+ const wildcard = makeRule({ model: '*/*', price: { input: 1 } })
59
60
  assert.deepEqual(matchPrice([wildcard, exact], 'a/b', SUNDAY), exact.price)
60
61
  })
61
62
 
@@ -67,25 +68,126 @@ test('精确子集按数组序取首个命中', () => {
67
68
  })
68
69
 
69
70
  test('精确子集全不命中回落通配子集', () => {
70
- // Given 精确规则条件不满足、'*' 规则无条件 When 匹配 Then 回落 '*' 而非 null
71
+ // Given 精确规则条件不满足、'*/*' 规则无条件 When 匹配 Then 回落 '*/*' 而非 null
71
72
  const exact = makeRule({ model: 'a/b', conditions: [{ kind: 'weekdays', days: [1] }] })
72
- const wildcard = makeRule({ model: '*', price: { input: 3 } })
73
+ const wildcard = makeRule({ model: '*/*', price: { input: 3 } })
73
74
  assert.deepEqual(matchPrice([exact, wildcard], 'a/b', SUNDAY), wildcard.price)
74
75
  })
75
76
 
76
77
  test('通配子集按数组序取首个命中', () => {
77
- // Given 首条 '*' 条件满足、次条无条件 When 匹配 Then 取首条
78
- const first = makeRule({ model: '*', conditions: [{ kind: 'weekdays', days: [0] }], price: { input: 1 } })
79
- const second = makeRule({ model: '*', price: { input: 2 } })
78
+ // Given 首条 '*/*' 条件满足、次条无条件 When 匹配 Then 取首条
79
+ const first = makeRule({ model: '*/*', conditions: [{ kind: 'weekdays', days: [0] }], price: { input: 1 } })
80
+ const second = makeRule({ model: '*/*', price: { input: 2 } })
80
81
  assert.deepEqual(matchPrice([first, second], 'a/b', SUNDAY), first.price)
81
82
  })
82
83
 
83
84
  test('无精确子集直接查通配子集', () => {
84
- // Given 仅 '*' 规则 When 匹配未列出模型 Then 命中通配
85
- const wildcard = makeRule({ model: '*', price: { input: 4 } })
85
+ // Given 仅 '*/*' 规则 When 匹配未列出模型 Then 命中通配
86
+ const wildcard = makeRule({ model: '*/*', price: { input: 4 } })
86
87
  assert.deepEqual(matchPrice([wildcard], 'other/c', SUNDAY), wildcard.price)
87
88
  })
88
89
 
90
+ test('匹配链:全名优先于模型名、供应商、全通配', () => {
91
+ // Given 四档规则并存 When 匹配 Then 依次全名 > 模型名 > 供应商 > 全通配
92
+ const rules = [
93
+ makeRule({ model: 'x/*', price: { input: 3 } }),
94
+ makeRule({ model: '*/*', price: { input: 4 } }),
95
+ makeRule({ model: '*/a', price: { input: 2 } }),
96
+ makeRule({ model: 'x/a', price: { input: 1 } }),
97
+ ]
98
+ assert.deepEqual(matchPrice(rules, 'x/a', SUNDAY), { input: 1 })
99
+ })
100
+
101
+ test('匹配链:模型名通配优先于供应商通配', () => {
102
+ // Given '*/a' 与 'x/*' 并存 When 匹配 x/a Then 模型名精确档胜
103
+ const rules = [
104
+ makeRule({ model: 'x/*', price: { input: 3 } }),
105
+ makeRule({ model: '*/a', price: { input: 2 } }),
106
+ ]
107
+ assert.deepEqual(matchPrice(rules, 'x/a', SUNDAY), { input: 2 })
108
+ })
109
+
110
+ test('匹配链:供应商通配优先于全通配', () => {
111
+ // Given 'x/*' 与 '*/*' 并存 When 匹配 x/a Then 供应商精确档胜
112
+ const rules = [
113
+ makeRule({ model: '*/*', price: { input: 4 } }),
114
+ makeRule({ model: 'x/*', price: { input: 3 } }),
115
+ ]
116
+ assert.deepEqual(matchPrice(rules, 'x/a', SUNDAY), { input: 3 })
117
+ })
118
+
119
+ test('模型名通配跨供应商命中同模型名', () => {
120
+ // Given '*/gpt-4o' When 匹配不同供应商同模型名 Then 命中;模型名不同不命中
121
+ const rule = makeRule({ model: '*/gpt-4o', price: { input: 7 } })
122
+ assert.deepEqual(matchPrice([rule], 'openai/gpt-4o', SUNDAY), { input: 7 })
123
+ assert.deepEqual(matchPrice([rule], 'azure/gpt-4o', SUNDAY), { input: 7 })
124
+ assert.equal(matchPrice([rule], 'openai/gpt-4o-mini', SUNDAY), null)
125
+ })
126
+
127
+ test('供应商通配命中同供应商全部模型', () => {
128
+ // Given 'openai/*' When 匹配同供应商多模型 Then 命中;异供应商不命中
129
+ const rule = makeRule({ model: 'openai/*', price: { input: 6 } })
130
+ assert.deepEqual(matchPrice([rule], 'openai/gpt-4o', SUNDAY), { input: 6 })
131
+ assert.deepEqual(matchPrice([rule], 'openai/o3', SUNDAY), { input: 6 })
132
+ assert.equal(matchPrice([rule], 'azure/gpt-4o', SUNDAY), null)
133
+ })
134
+
135
+ test('模型名段允许含斜杠按首个斜杠分段', () => {
136
+ // Given vendor 段通配或精确 When 匹配模型名含斜杠的多级模型 Then 按 vendor/余段比对
137
+ const vendorWide = makeRule({ model: 'openrouter/*', price: { input: 8 } })
138
+ assert.deepEqual(matchPrice([vendorWide], 'openrouter/deepseek/deepseek-chat', SUNDAY), { input: 8 })
139
+ const named = makeRule({ model: '*/deepseek/deepseek-chat', price: { input: 9 } })
140
+ assert.deepEqual(matchPrice([named], 'openrouter/deepseek/deepseek-chat', SUNDAY), { input: 9 })
141
+ assert.equal(matchPrice([named], 'openrouter/deepseek/chat', SUNDAY), null)
142
+ })
143
+
144
+ test('单段规则属旧形态失效不命中', () => {
145
+ // Given 旧写法 '*' 与裸名规则 When 匹配 Then 一律 null
146
+ const legacy = makeRule({ model: '*', price: { input: 1 } })
147
+ const bare = makeRule({ model: 'a', price: { input: 2 } })
148
+ assert.equal(matchPrice([legacy, bare], 'a/b', SUNDAY), null)
149
+ assert.equal(matchPrice([legacy, bare], 'a', SUNDAY), null)
150
+ })
151
+
152
+ test('请求裸名模型归 default 供应商', () => {
153
+ // Given 无斜杠请求 When 匹配 'default/名' 与 '*/名' Then 全名档优先
154
+ const exact = makeRule({ model: 'default/abc', price: { input: 1 } })
155
+ const named = makeRule({ model: '*/abc', price: { input: 2 } })
156
+ assert.deepEqual(matchPrice([named, exact], 'abc', SUNDAY), { input: 1 })
157
+ assert.deepEqual(matchPrice([named], 'abc', SUNDAY), { input: 2 })
158
+ })
159
+
160
+ test('档位回落:高档条件不满足逐层落低档', () => {
161
+ // Given 全名与模型名档条件仅周一 When 周日匹配 Then 落到无条件的供应商档
162
+ const exact = makeRule({ model: 'x/a', conditions: [{ kind: 'weekdays', days: [1] }], price: { input: 1 } })
163
+ const modelName = makeRule({ model: '*/a', conditions: [{ kind: 'weekdays', days: [1] }], price: { input: 2 } })
164
+ const vendor = makeRule({ model: 'x/*', price: { input: 3 } })
165
+ assert.deepEqual(matchPrice([exact, modelName, vendor], 'x/a', SUNDAY), { input: 3 })
166
+ })
167
+
168
+ test('同档规则按数组序取首个条件满足者', () => {
169
+ // Given 同为 '*/a' 两条,首条条件周日 When 周日匹配 Then 取首条
170
+ const first = makeRule({ model: '*/a', price: { input: 1 }, conditions: [{ kind: 'weekdays', days: [0] }] })
171
+ const second = makeRule({ model: '*/a', price: { input: 2 } })
172
+ assert.deepEqual(matchPrice([first, second], 'x/a', SUNDAY), { input: 1 })
173
+ })
174
+
175
+ test('isTwoSegmentModel 判定提交形态两段式', () => {
176
+ // Given 两段式与单段/空白段串 When 判定 Then 首斜杠在首位之后且两段无空白才成立
177
+ assert.equal(isTwoSegmentModel('a/b'), true)
178
+ assert.equal(isTwoSegmentModel('*/*'), true)
179
+ assert.equal(isTwoSegmentModel('a/b/c'), true)
180
+ assert.equal(isTwoSegmentModel('a'), false)
181
+ assert.equal(isTwoSegmentModel('*'), false)
182
+ assert.equal(isTwoSegmentModel('/b'), false)
183
+ assert.equal(isTwoSegmentModel(''), false)
184
+ assert.equal(isTwoSegmentModel('a/'), false)
185
+ assert.equal(isTwoSegmentModel(' /b'), false)
186
+ assert.equal(isTwoSegmentModel('a/ '), false)
187
+ assert.equal(isTwoSegmentModel('a /b'), false)
188
+ assert.equal(isTwoSegmentModel('* /b'), false)
189
+ })
190
+
89
191
  test('两子集全不命中返回 null 计 unpriced', () => {
90
192
  // Given 仅一条精确规则且条件不满足 When 匹配本模型与陌生模型 Then 均为 null
91
193
  const rules = [makeRule({ model: 'a/b', conditions: [{ kind: 'weekdays', days: [1] }] })]
@@ -257,7 +257,7 @@ test('H 粒度槽数恰为 MAX_SLOTS 时不出现 truncated', () => {
257
257
  // ---- attachCosts 聚合计价:计价唯一以 H 桶起点匹配,费用精度=小时级 ----
258
258
 
259
259
  const ruleOf = (overrides = {}) => ({
260
- model: '*',
260
+ model: '*/*',
261
261
  currency: '¥',
262
262
  price: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
263
263
  conditions: [],
@@ -266,12 +266,42 @@ const ruleOf = (overrides = {}) => ({
266
266
 
267
267
  const inputPrice = (input) => ({ input, output: 0, cacheRead: 0, cacheWrite: 0 })
268
268
 
269
+ test('speed 配对聚合:仅 durationMs>0 行计入分子分母,老行只进 tokens', () => {
270
+ const rows = [
271
+ makeRow({ bucket: '2020-01-01', model: 'm1', provider: 'p1', inputTokens: 5, outputTokens: 20, durationMs: 6500 }),
272
+ makeRow({ bucket: '2020-01-02', model: 'm1', provider: 'p1', outputTokens: 30, durationMs: 2500 }),
273
+ // 存量旧格式行:无 durationMs,token 计入总量但速度分母不含
274
+ makeRow({ bucket: '2020-01-03', model: 'm1', provider: 'p1', outputTokens: 100 }),
275
+ ]
276
+ const out = aggregateRange(rows, 'D', '2020-01-01', '2020-01-03')
277
+ const entry = out.models.find((item) => item.model === 'm1')
278
+ assert.equal(entry.tokens, 25 + 30 + 100)
279
+ assert.equal(entry.speed, (20 + 30) / ((6500 + 2500) / 1000))
280
+ })
281
+
282
+ test('无 durationMs 数据时 models 条目无 speed 字段', () => {
283
+ const rows = [
284
+ makeRow({ bucket: '2020-01-01', model: 'm1', provider: 'p1', outputTokens: 30 }),
285
+ ]
286
+ const out = aggregateRange(rows, 'D', '2020-01-01', '2020-01-01')
287
+ assert.equal(out.models.length, 1)
288
+ assert.equal('speed' in out.models[0], false)
289
+ })
290
+
291
+ test('纯缓存行带时长:输出 0 计入分母,speed 为 0', () => {
292
+ const rows = [
293
+ makeRow({ bucket: '2020-01-01', model: 'm1', provider: 'p1', cacheReadTokens: 10, outputTokens: 0, durationMs: 4000 }),
294
+ ]
295
+ const out = aggregateRange(rows, 'D', '2020-01-01', '2020-01-01')
296
+ assert.equal(out.models[0].speed, 0)
297
+ })
298
+
269
299
  test('attachCosts H 槽按桶起点计价并归集 totals 与 models', () => {
270
300
  const rows = [
271
301
  makeRow({ bucket: '2020-01-01T01', model: 'm1', provider: 'p1', inputTokens: 1000000, outputTokens: 500000 }),
272
302
  ]
273
303
  const result = aggregateRange(rows, 'H', '2020-01-01T00', '2020-01-01T02')
274
- const out = attachCosts(result, rows, 'H', [ruleOf({ model: 'm1', price: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 } })])
304
+ const out = attachCosts(result, rows, 'H', [ruleOf({ model: 'default/m1', price: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 } })])
275
305
  assert.equal(out.daily[1].cost, 2)
276
306
  assert.equal(out.cost, 2)
277
307
  assert.equal(out.unpriced, 0)
@@ -289,7 +319,7 @@ test('attachCosts 多行同桶多规则累加且残缺规则跳过后回落通
289
319
  makeRow({ bucket: '2020-01-01T01', model: 'm2', inputTokens: 1000000 }),
290
320
  ]
291
321
  const rules = [
292
- ruleOf({ model: 'm1', price: inputPrice(1) }),
322
+ ruleOf({ model: 'default/m1', price: inputPrice(1) }),
293
323
  { model: 'm2' },
294
324
  ruleOf({ price: inputPrice(3) }),
295
325
  ]
@@ -6,7 +6,7 @@ import assert from 'node:assert/strict'
6
6
  import { Readable } from 'node:stream'
7
7
 
8
8
  import { MAX_RANGE_SPAN_DAYS, registerUsageRoutes } from '../src/routes.js'
9
- import { DEFAULT_MINUTE_RETENTION_DAYS, minuteKey } from '../src/store.js'
9
+ import { DEFAULT_MINUTE_RETENTION_DAYS, hourKey, minuteKey } from '../src/store.js'
10
10
 
11
11
  const DAY_MS = 24 * 60 * 60 * 1000
12
12
  const MINUTE_MS = 60 * 1000
@@ -338,8 +338,9 @@ test('minutes 禁用保留时标注空 covered', async () => {
338
338
 
339
339
  test('minutes from 未对齐 10 分钟桶边界拒绝 400', async () => {
340
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`
341
+ // from hourKey 本地渲染保证与 to 同一时区基准,前一小时的 :07 早于 to 且未对齐;
342
+ // 前提是 FIXED_NOW 不落入 DST 回拨小时(当前 2 月常量全球无转变日)
343
+ const unaligned = `${hourKey(FIXED_NOW - 60 * MINUTE_MS)}:07`
343
344
  const res = await invokeJson(routes, PATH_MINUTES, { payload: { from: unaligned, to: minuteKey(FIXED_NOW) } })
344
345
  const parsed = JSON.parse(res.body)
345
346
  assert.equal(res.statusCode, 400)
@@ -349,12 +350,12 @@ test('minutes from 未对齐 10 分钟桶边界拒绝 400', async () => {
349
350
 
350
351
  test('minutes 保留值超上限时 covered 窗口按 clamp 计算', async () => {
351
352
  const { routes } = mount({ retentionDays: () => 30 })
352
- const from = minuteKey(FIXED_NOW - 3 * DAY_MS)
353
+ const from = minuteKey(FIXED_NOW - 8 * DAY_MS)
353
354
  const to = minuteKey(FIXED_NOW)
354
355
  const res = await invokeJson(routes, PATH_MINUTES, { payload: { from, to } })
355
356
  const parsed = JSON.parse(res.body)
356
357
  assert.equal(res.statusCode, 200)
357
- assert.equal(parsed.value.coveredFrom, minuteKey(FIXED_NOW - 2 * DAY_MS))
358
+ assert.equal(parsed.value.coveredFrom, minuteKey(FIXED_NOW - 7 * DAY_MS))
358
359
  assert.equal(parsed.value.coveredTo, to)
359
360
  })
360
361
 
@@ -382,14 +383,14 @@ test('内部错误 500 不回显内部文本', async () => {
382
383
  // ---- S13 pricing 端点与 cost 集成 ----
383
384
 
384
385
  const PRICING_RULE = {
385
- model: 'deepseek-chat',
386
+ model: 'deepseek/deepseek-chat',
386
387
  currency: '¥',
387
388
  price: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
388
389
  conditions: [{ kind: 'dailyWindow', from: '09:00', to: '18:00' }],
389
390
  }
390
391
 
391
392
  const ruleWithInputPrice = (input) => ({
392
- model: 'deepseek-chat',
393
+ model: 'default/deepseek-chat',
393
394
  currency: '¥',
394
395
  price: { input, output: 0, cacheRead: 0, cacheWrite: 0 },
395
396
  conditions: [],
@@ -438,6 +439,9 @@ test('pricing POST 非法规则拒绝 400 且不写入', async () => {
438
439
  { rules: 'x' },
439
440
  {},
440
441
  { rules: [{ model: 1, currency: '¥', price: PRICING_RULE.price, conditions: [] }] },
442
+ { rules: [{ ...PRICING_RULE, model: 'deepseek-chat' }] },
443
+ { rules: [{ ...PRICING_RULE, model: '*' }] },
444
+ { rules: [{ ...PRICING_RULE, model: '/deepseek-chat' }] },
441
445
  { rules: [{ ...PRICING_RULE, price: { input: -1, output: 0, cacheRead: 0, cacheWrite: 0 } }] },
442
446
  { rules: [{ ...PRICING_RULE, conditions: [{ kind: 'unknown' }] }] },
443
447
  { rules: [{ ...PRICING_RULE, conditions: [{ kind: 'weekdays', days: [7] }] }] },
@@ -1,19 +1,11 @@
1
1
  // 底部信息栏增强纯函数层测试:官方 StatsLine 口径(dsh-client-ui-chat 同构)+ usp 双开关状态
2
2
  // Given/When/Then 场景内嵌于用例描述
3
- // client.js 为非模块 script(bundle 求值形态,禁 import/export),整源求值后按顶层声明名收集
3
+ // client.js 为经典 script bundle( import/export),整文件 IIFE 书挡;经 client-eval 剥壳后整源求值,按顶层声明名收集
4
4
  import { test } from 'node:test'
5
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
-
10
- const CLIENT_SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'client.js'), 'utf8')
11
- const DECLARATION_NAMES = [
12
- ...new Set(
13
- [...CLIENT_SOURCE.matchAll(/^(?:const|function|let) ([A-Za-z_$][\w$]*)/gm)].map((match) => match[1])
14
- ),
15
- ]
16
- const core = new Function(`${CLIENT_SOURCE}\nreturn { ${DECLARATION_NAMES.join(', ')} }`)()
6
+ import { CLIENT_BODY, DECLARATION_NAMES } from './client-eval.mjs'
7
+
8
+ const core = new Function(`${CLIENT_BODY}\nreturn { ${DECLARATION_NAMES.join(', ')} }`)()
17
9
 
18
10
  const {
19
11
  STATS_LINE_STORAGE_KEY,
@@ -43,11 +35,11 @@ const PREFS_COST_ON = { cachePrecision: false, tokenDetail: false, costDisplay:
43
35
  const zhT = createTranslator(MESSAGES_ZH)
44
36
  const enT = createTranslator(MESSAGES_EN)
45
37
 
46
- // 费用组装配输入:精确规则在前、通配规则兜底,通配仅全天时段生效
38
+ // 费用组装配输入:精确规则在前、全通配规则兜底,通配仅全天时段生效
47
39
  const COST_RULES = [
48
40
  { model: 'p/m', currency: '¥', price: { input: 2, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] },
49
41
  {
50
- model: '*', currency: '', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 },
42
+ model: '*/*', currency: '', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 },
51
43
  conditions: [{ kind: 'dailyWindow', from: '00:00', to: '00:00' }],
52
44
  },
53
45
  ]
@@ -353,8 +345,8 @@ test('buildCostItem 开关关恒为 null,组数组不受 rules 影响', () => {
353
345
  ])
354
346
  })
355
347
 
356
- test('buildCostItem routes 缺席按通配规则匹配,符号取全局口径(首个非空货币)', () => {
357
- // 无 routes → model '*',500000 × 1 / 每百万 = 0.5;命中通配(货币空),符号取表内首个非空 ¥
348
+ test('buildCostItem routes 缺席按全通配规则匹配,符号取全局口径(首个非空货币)', () => {
349
+ // 无 routes → model '*/*',500000 × 1 / 每百万 = 0.5;命中全通配(货币空),符号取表内首个非空 ¥
358
350
  assert.equal(buildCostItem(COST_USAGE, COST_RULES, PREFS_COST_ON, zhT, COST_NOW), '费用 ≈ ¥0.50')
359
351
  assert.equal(buildCostItem(COST_USAGE, COST_RULES, PREFS_COST_ON, enT, COST_NOW), 'Cost ≈ ¥0.50')
360
352
  })
@@ -94,17 +94,17 @@ const tokenSample = (time, extra = {}) => ({
94
94
  })
95
95
 
96
96
  test('分钟保留天数默认常量与上限和计划一致', () => {
97
- assert.equal(DEFAULT_MINUTE_RETENTION_DAYS, 2)
98
- assert.equal(MINUTE_RETENTION_MAX_DAYS, 2)
97
+ assert.equal(DEFAULT_MINUTE_RETENTION_DAYS, 7)
98
+ assert.equal(MINUTE_RETENTION_MAX_DAYS, 7)
99
99
  assert.equal(HOUR_RETENTION_DAYS, 15)
100
100
  })
101
101
 
102
102
  test('分钟保留值归一:非法回落默认,超上限截断,0 保留', () => {
103
103
  assert.equal(clampMinuteRetentionDays(1), 1)
104
104
  assert.equal(clampMinuteRetentionDays(0), 0)
105
- assert.equal(clampMinuteRetentionDays(7), 2)
106
- assert.equal(clampMinuteRetentionDays(-1), 2)
107
- assert.equal(clampMinuteRetentionDays('x'), 2)
105
+ assert.equal(clampMinuteRetentionDays(30), 7)
106
+ assert.equal(clampMinuteRetentionDays(-1), 7)
107
+ assert.equal(clampMinuteRetentionDays('x'), 7)
108
108
  })
109
109
 
110
110
  test('桶键生成含补零,分钟桶对齐 10 分钟', () => {
@@ -134,6 +134,7 @@ test('token 样本同刻落三粒度三行,行内无 g 字段', async () => {
134
134
  const domain = fakeDomain()
135
135
  const store = new UsageStore(facilityOf(domain))
136
136
  await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
137
+ await store.flushNow()
137
138
  const keys = ['D|2026-08-02', 'H|2026-08-02T14', 'M|2026-08-02T14:30'].map(
138
139
  (prefix) => `${prefix}|deepseek|deepseek/deepseek-chat`,
139
140
  )
@@ -155,6 +156,7 @@ test('turn 样本落合成行:provider default 与 model (turns)', async () => {
155
156
  const domain = fakeDomain()
156
157
  const store = new UsageStore(facilityOf(domain))
157
158
  await store.record({ time: local(2026, 8, 2, 14, 37), turn: true })
159
+ await store.flushNow()
158
160
  const seen = domain.rows.get('D|2026-08-02|default|(turns)')
159
161
  assert.ok(seen)
160
162
  assert.equal(seen.turns, 1)
@@ -166,6 +168,7 @@ test('request 样本只计请求数,无归因落 (unknown)', async () => {
166
168
  const store = new UsageStore(facilityOf(domain))
167
169
  await store.record({ time: local(2026, 8, 2, 14, 37), request: true, model: 'deepseek-chat' })
168
170
  await store.record({ time: local(2026, 8, 2, 14, 38), request: true })
171
+ await store.flushNow()
169
172
  const labelled = domain.rows.get('D|2026-08-02|default|deepseek-chat')
170
173
  assert.ok(labelled)
171
174
  assert.equal(labelled.requests, 1)
@@ -175,27 +178,78 @@ test('request 样本只计请求数,无归因落 (unknown)', async () => {
175
178
  assert.equal(unknown.requests, 1)
176
179
  })
177
180
 
181
+ test('token 样本时长落三粒度累加,request 与 turn 行时长恒 0', async () => {
182
+ const domain = fakeDomain()
183
+ const store = new UsageStore(facilityOf(domain))
184
+ const at = local(2026, 8, 2, 14, 37)
185
+ await store.record(tokenSample(at, { durationMs: 6500 }))
186
+ await store.record(tokenSample(at + 1000, { durationMs: 2500 }))
187
+ await store.record({ time: at, request: true, model: 'deepseek/deepseek-chat' })
188
+ await store.record({ time: at, turn: true })
189
+ await store.flushNow()
190
+ const daily = domain.rows.get('D|2026-08-02|deepseek|deepseek/deepseek-chat')
191
+ assert.equal(daily.outputTokens, 40)
192
+ assert.equal(daily.durationMs, 9000)
193
+ const hourly = domain.rows.get('H|2026-08-02T14|deepseek|deepseek/deepseek-chat')
194
+ assert.equal(hourly.durationMs, 9000)
195
+ const minute = domain.rows.get('M|2026-08-02T14:30|deepseek|deepseek/deepseek-chat')
196
+ assert.equal(minute.durationMs, 9000)
197
+ const requestRow = domain.rows.get('D|2026-08-02|deepseek|deepseek/deepseek-chat')
198
+ assert.equal(requestRow.requests, 1)
199
+ const turnRow = domain.rows.get('D|2026-08-02|default|(turns)')
200
+ assert.equal(turnRow.durationMs, 0)
201
+ })
202
+
203
+ test('老行缺 durationMs 字段:增量求和不产 NaN', async () => {
204
+ const domain = fakeDomain()
205
+ const store = new UsageStore(facilityOf(domain))
206
+ const at = local(2026, 8, 2, 14, 37)
207
+ const key = 'D|2026-08-02|deepseek|deepseek/deepseek-chat'
208
+ await store.record(tokenSample(at))
209
+ await store.flushNow()
210
+ // 模拟存量旧格式行:回写去掉 durationMs 字段
211
+ const stored = domain.rows.get(key)
212
+ delete stored.durationMs
213
+ await store.record(tokenSample(at + 1000, { durationMs: 3000 }))
214
+ await store.flushNow()
215
+ const seen = domain.rows.get(key)
216
+ assert.equal(seen.durationMs, 3000)
217
+ assert.equal(seen.outputTokens, 40)
218
+ })
219
+
178
220
  test('missing-record 首写竞态:put 种子后重试写入真值', async () => {
179
221
  const domain = fakeDomain()
180
222
  const store = new UsageStore(facilityOf(domain))
181
223
  await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
224
+ await store.flushNow()
182
225
  assert.equal(domain.stats.puts, 3)
183
226
  const seen = domain.rows.get('D|2026-08-02|deepseek|deepseek/deepseek-chat')
184
227
  assert.equal(seen.inputTokens, 10)
185
228
  })
186
229
 
187
- test('allSettled 聚合:单粒度失败上抛且其余粒度不丢', async () => {
230
+ test('flush 中单粒度失败:错误经 onFlushError 上抛,失败行留 pending 重试,成功粒度落盘', async () => {
231
+ const failedKeys = new Set()
188
232
  const domain = fakeDomain({
189
- failUpdate: (key) => (key.startsWith('H|') ? new Error('simulated hour failure') : null),
233
+ failUpdate: (key) => {
234
+ if (!key.startsWith('H|') || failedKeys.has(key)) return null
235
+ failedKeys.add(key)
236
+ return new Error('simulated hour failure')
237
+ },
190
238
  })
191
239
  const store = new UsageStore(facilityOf(domain))
192
- await assert.rejects(
193
- store.record(tokenSample(local(2026, 8, 2, 14, 37))),
194
- (err) => err.name === 'AggregateError' && err.errors.length === 1,
195
- )
240
+ const errors = []
241
+ store.onFlushError = (err) => errors.push(err)
242
+ await store.readyPromise()
243
+ await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
244
+ await store.flushNow()
196
245
  assert.ok(domain.rows.has('D|2026-08-02|deepseek|deepseek/deepseek-chat'))
197
246
  assert.ok(domain.rows.has('M|2026-08-02T14:30|deepseek|deepseek/deepseek-chat'))
198
247
  assert.equal(domain.rows.has('H|2026-08-02T14|deepseek|deepseek/deepseek-chat'), false)
248
+ assert.equal(errors.length, 1)
249
+ assert.equal(errors[0].name, 'AggregateError')
250
+ assert.equal(errors[0].errors.length, 1)
251
+ await store.flushNow()
252
+ assert.ok(domain.rows.has('H|2026-08-02T14|deepseek|deepseek/deepseek-chat'))
199
253
  })
200
254
 
201
255
  test('非 missing 错误不触发种子重试', async () => {
@@ -204,11 +258,15 @@ test('非 missing 错误不触发种子重试', async () => {
204
258
  failUpdate: (candidate) => (candidate === key ? new Error('backend write failed') : null),
205
259
  })
206
260
  const store = new UsageStore(facilityOf(domain))
207
- await assert.rejects(
208
- store.record(tokenSample(local(2026, 8, 2, 14, 37))),
209
- (err) => err.name === 'AggregateError' && /backend write failed/.test(err.errors[0].message),
210
- )
261
+ const errors = []
262
+ store.onFlushError = (err) => errors.push(err)
263
+ await store.readyPromise()
264
+ await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
265
+ await store.flushNow()
211
266
  assert.equal(domain.rows.has(key), false)
267
+ assert.equal(errors.length, 1)
268
+ assert.match(errors[0].errors[0].message, /backend write failed/)
269
+ assert.equal(domain.stats.puts, 2)
212
270
  })
213
271
 
214
272
  test('rangeRows 按粒度前缀与桶串闭区间扫描', async () => {
@@ -256,16 +314,16 @@ test('保留窗口为 0 时分钟桶全部清理', async () => {
256
314
  assert.equal(domain.rows.has('D|2026-08-10|a|m'), true)
257
315
  })
258
316
 
259
- test('分钟保留超上限时 clamp 到 48h 窗口', async () => {
317
+ test('分钟保留超上限时 clamp 到 7 天窗口', async () => {
260
318
  const domain = fakeDomain()
261
319
  const store = new UsageStore(facilityOf(domain), { now: () => local(2026, 8, 10, 12, 0) })
262
320
  const table = domain.table()
263
- // clamp 后窗口起点 = 08-08T12:00,02 天前的桶一律清理,即使注入 30 天
264
- await table.put('M|2026-08-05T00:00|a|m', rowOf('2026-08-05T00:00', 'a', 'm'))
265
- await table.put('M|2026-08-08T12:00|a|m', rowOf('2026-08-08T12:00', 'a', 'm'))
321
+ // clamp 后窗口起点 = 08-03T12:00,7 天前的桶一律清理,即使注入 30 天
322
+ await table.put('M|2026-08-01T00:00|a|m', rowOf('2026-08-01T00:00', 'a', 'm'))
323
+ await table.put('M|2026-08-03T12:00|a|m', rowOf('2026-08-03T12:00', 'a', 'm'))
266
324
  await store.pruneMinutes(30)
267
- assert.equal(domain.rows.has('M|2026-08-05T00:00|a|m'), false)
268
- assert.equal(domain.rows.has('M|2026-08-08T12:00|a|m'), true)
325
+ assert.equal(domain.rows.has('M|2026-08-01T00:00|a|m'), false)
326
+ assert.equal(domain.rows.has('M|2026-08-03T12:00|a|m'), true)
269
327
  })
270
328
 
271
329
  test('pruneHours 固定 15 天窗口清理小时桶且不碰其他粒度', async () => {
@@ -283,19 +341,22 @@ test('pruneHours 固定 15 天窗口清理小时桶且不碰其他粒度', async
283
341
  assert.equal(domain.rows.has('M|2026-07-25T00:00|a|m'), true)
284
342
  })
285
343
 
286
- test('每日本地日首次写入触发清理,同日后续写入不重复触发', async () => {
344
+ test('每日本地日首次 flush 触发清理,同日后续 flush 不重复触发', async () => {
287
345
  let clock = local(2026, 8, 10, 12, 0)
288
346
  const domain = fakeDomain()
289
347
  const store = new UsageStore(facilityOf(domain), { now: () => clock, retentionDays: () => 0 })
290
348
  const table = domain.table()
291
349
  await table.put('M|2026-08-01T00:00|a|m', rowOf('2026-08-01T00:00', 'a', 'm'))
292
350
  await store.record(tokenSample(local(2026, 8, 10, 12, 0)))
351
+ await store.flushNow()
293
352
  assert.equal(domain.rows.has('M|2026-08-01T00:00|a|m'), false)
294
353
  await table.put('M|2026-08-01T00:00|a|m', rowOf('2026-08-01T00:00', 'a', 'm'))
295
354
  await store.record(tokenSample(local(2026, 8, 10, 12, 1)))
355
+ await store.flushNow()
296
356
  assert.equal(domain.rows.has('M|2026-08-01T00:00|a|m'), true)
297
357
  clock = local(2026, 8, 11, 0, 0)
298
358
  await store.record(tokenSample(local(2026, 8, 11, 0, 0)))
359
+ await store.flushNow()
299
360
  assert.equal(domain.rows.has('M|2026-08-01T00:00|a|m'), false)
300
361
  })
301
362
 
@@ -395,3 +456,65 @@ test('globalThis 单例复用同实例,重置缝生效', () => {
395
456
  assert.notEqual(sharedStore(facilityOf(fakeDomain())), first)
396
457
  __resetSharedStoreForTests()
397
458
  })
459
+
460
+ // —— 写合并(write-behind):record 同步入 pending,flush 周期落盘 ——
461
+
462
+ test('record 只入 pending 不产生持久化写,flushNow 后每脏行恰一次 update', async () => {
463
+ const domain = fakeDomain()
464
+ const store = new UsageStore(facilityOf(domain))
465
+ await store.readyPromise()
466
+ await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
467
+ assert.equal(domain.rows.size, 0)
468
+ await store.flushNow()
469
+ assert.equal(domain.rows.size, 3)
470
+ const seen = domain.rows.get('D|2026-08-02|deepseek|deepseek/deepseek-chat')
471
+ assert.equal(seen.inputTokens, 10)
472
+ })
473
+
474
+ test('同桶多样本合并:pending 累加,一次 update 落全部计数', async () => {
475
+ const domain = fakeDomain()
476
+ const store = new UsageStore(facilityOf(domain))
477
+ await store.readyPromise()
478
+ await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
479
+ await store.record(tokenSample(local(2026, 8, 2, 14, 38)))
480
+ await store.record(tokenSample(local(2026, 8, 2, 14, 39)))
481
+ await store.flushNow()
482
+ const seen = domain.rows.get('D|2026-08-02|deepseek|deepseek/deepseek-chat')
483
+ assert.equal(seen.inputTokens, 30)
484
+ assert.equal(domain.stats.puts, 3)
485
+ })
486
+
487
+ test('flush 周期自动落盘', async () => {
488
+ const domain = fakeDomain()
489
+ const store = new UsageStore(facilityOf(domain), { flushIntervalMs: 5 })
490
+ await store.readyPromise()
491
+ await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
492
+ await new Promise((resolve) => setTimeout(resolve, 30))
493
+ assert.equal(domain.rows.size, 3)
494
+ })
495
+
496
+ test('rangeRows 前自动 flush,读己之写', async () => {
497
+ const domain = fakeDomain()
498
+ const store = new UsageStore(facilityOf(domain))
499
+ await store.readyPromise()
500
+ await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
501
+ const rows = await store.rangeRows('D', '2026-08-02', '2026-08-02')
502
+ assert.equal(rows.length, 1)
503
+ assert.equal(rows[0].inputTokens, 10)
504
+ })
505
+
506
+ test('reset 前 pending 先落盘再清表', async () => {
507
+ const domain = fakeDomain()
508
+ const store = new UsageStore(facilityOf(domain))
509
+ await store.readyPromise()
510
+ await store.record(tokenSample(local(2026, 8, 2, 14, 37)))
511
+ await store.reset(new Map())
512
+ assert.equal(domain.rows.size, 0)
513
+ assert.equal(domain.stats.puts, 3)
514
+ })
515
+
516
+ test('降级态 record 仍按调用失败', async () => {
517
+ const store = new UsageStore({ open: () => Promise.reject(new Error('domain already open')) })
518
+ await store.readyPromise()
519
+ await assert.rejects(store.record(tokenSample(local(2026, 8, 2, 14, 37))), /usage store degraded/)
520
+ })
@@ -1,23 +1,15 @@
1
1
  // 注入点B(S15)纯函数层测试:select 矩阵/模型键回退/单轮行文本/title 口径/文案键/显隐 CSS 镜像
2
- // client.js 为非模块 script(bundle 求值形态,禁 import/export),整源求值后按顶层声明名收集
2
+ // client.js 为经典 script bundle( import/export),整文件 IIFE 书挡;经 client-eval 剥壳后整源求值,按顶层声明名收集
3
3
  import { test } from 'node:test'
4
4
  import assert from 'node:assert/strict'
5
- import { readFileSync } from 'node:fs'
6
- import { fileURLToPath } from 'node:url'
7
- import { dirname, join } from 'node:path'
8
-
9
- const CLIENT_SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'client.js'), 'utf8')
10
- const DECLARATION_NAMES = [
11
- ...new Set(
12
- [...CLIENT_SOURCE.matchAll(/^(?:const|function|let) ([A-Za-z_$][\w$]*)/gm)].map((match) => match[1])
13
- ),
14
- ]
15
- const core = new Function(`${CLIENT_SOURCE}\nreturn { ${DECLARATION_NAMES.join(', ')} }`)()
5
+ import { CLIENT_SOURCE, CLIENT_BODY, DECLARATION_NAMES } from './client-eval.mjs'
6
+
7
+ const core = new Function(`${CLIENT_BODY}\nreturn { ${DECLARATION_NAMES.join(', ')} }`)()
16
8
 
17
9
  const {
18
10
  MESSAGES_EN,
19
11
  MESSAGES_ZH,
20
- MODEL_WILDCARD,
12
+ MODEL_UNROUTED,
21
13
  TURN_TAIL_DATA_KEY,
22
14
  TURN_TAIL_PRIORITY,
23
15
  buildTurnCostLine,
@@ -58,20 +50,20 @@ test('select owner 形状残缺全部 null 不抛', () => {
58
50
  assert.equal(selectTurnTokenUsage(undefined), null)
59
51
  })
60
52
 
61
- test('计价模型键取 routes 首个,缺失回退通配', () => {
53
+ test('计价模型键取 routes 首个,缺失回退全通配键', () => {
62
54
  assert.equal(turnModelOf({ routes: [{ provider: 'p', model: 'p/m' }] }), 'p/m')
63
- assert.equal(turnModelOf({ routes: [] }), MODEL_WILDCARD)
64
- assert.equal(turnModelOf({}), MODEL_WILDCARD)
65
- assert.equal(turnModelOf(null), MODEL_WILDCARD)
55
+ assert.equal(turnModelOf({ routes: [] }), MODEL_UNROUTED)
56
+ assert.equal(turnModelOf({}), MODEL_UNROUTED)
57
+ assert.equal(turnModelOf(null), MODEL_UNROUTED)
66
58
  })
67
59
 
68
- test('routes 缺失模型键命中通配规则', () => {
60
+ test('routes 缺失模型键命中全通配规则', () => {
69
61
  const rules = [
70
62
  { model: 'p/m', currency: '¥', price: { input: 9, output: 9, cacheRead: 9, cacheWrite: 9 }, conditions: [] },
71
- { model: MODEL_WILDCARD, currency: '$', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] },
63
+ { model: '*/*', currency: '$', price: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] },
72
64
  ]
73
65
  const tokenUsage = { uncachedInputTokens: 1000, outputTokens: 0, totalTokens: 1000 }
74
- assert.equal(turnModelOf(tokenUsage), MODEL_WILDCARD)
66
+ assert.equal(turnModelOf(tokenUsage), MODEL_UNROUTED)
75
67
  assert.deepEqual(matchPrice(rules, turnModelOf(tokenUsage), NOW), { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 })
76
68
  })
77
69