@mzzsfy/dsh-usage-dash 0.5.0 → 0.7.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/src/query.js CHANGED
@@ -1,249 +1,254 @@
1
- // 用量聚合纯函数:store 行形状仅作数据约定,零宿主依赖。
2
- // 桶串本地时区推导,同粒度字典序即时间序;daily 零值槽全枚举,超槽数保最新丢最旧。
3
-
4
- import { costOf, matchPrice } from './pricing.js'
5
-
6
- export const MAX_SLOTS = 2000
7
-
8
- const PAD_WIDTH = 2
9
- const PERCENT_SCALE = 100
10
- const MS_PER_SECOND = 1000
11
-
12
- const GRANULARITY_DAILY = 'D'
13
- const GRANULARITY_HOURLY = 'H'
14
- const GRANULARITY_MINUTE = 'M'
15
-
16
- const DAY_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
17
- const HOUR_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}$/
18
- const MINUTE_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/
19
-
20
- // 桶串前缀宽:D 段定位日槽,M 行截取父 H 桶
21
- const DAY_KEY_WIDTH = 'YYYY-MM-DD'.length
22
- const HOUR_KEY_WIDTH = 'YYYY-MM-DDTHH'.length
23
-
24
- const pad = (value) => String(value).padStart(PAD_WIDTH, '0')
25
- const formatDate = (date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
26
- const formatHour = (date) => `${formatDate(date)}T${pad(date.getHours())}`
27
- const formatMinute = (date) => `${formatHour(date)}:${pad(date.getMinutes())}`
28
-
29
- const nextDay = (date) => {
30
- const next = new Date(date)
31
- next.setDate(next.getDate() + 1)
32
- return next
33
- }
34
- const nextHour = (date) => {
35
- const next = new Date(date)
36
- next.setHours(next.getHours() + 1)
37
- return next
38
- }
39
- const nextMinute = (date) => {
40
- const next = new Date(date)
41
- next.setMinutes(next.getMinutes() + MINUTE_STEP_MINUTES)
42
- return next
43
- }
44
-
45
- // 分钟桶粒度:枚举与桶键共用同一步长,from 必须对齐桶边界
46
- const MINUTE_STEP_MINUTES = 10
47
-
48
- // pattern 锚定桶串形态,suffix 补全为本地时区可解析日期串,format 回读校验分量合法性
49
- const BUCKET_FORMS = {
50
- [GRANULARITY_DAILY]: { pattern: DAY_KEY_PATTERN, suffix: 'T00:00:00', format: formatDate, step: nextDay },
51
- [GRANULARITY_HOURLY]: { pattern: HOUR_KEY_PATTERN, suffix: ':00:00', format: formatHour, step: nextHour },
52
- [GRANULARITY_MINUTE]: { pattern: MINUTE_KEY_PATTERN, suffix: ':00', format: formatMinute, step: nextMinute, align: MINUTE_STEP_MINUTES },
53
- }
54
-
55
- const parseBucketKey = (key, form) => {
56
- if (typeof key !== 'string' || !form.pattern.test(key)) return null
57
- const parsed = new Date(`${key}${form.suffix}`)
58
- return Number.isNaN(parsed.getTime()) || form.format(parsed) !== key ? null : parsed
59
- }
60
-
61
- const enumerateBucketKeys = (form) => (from, to) => {
62
- const start = parseBucketKey(from, form)
63
- const end = parseBucketKey(to, form)
64
- if (!start || !end) return []
65
- if (form.align && start.getMinutes() % form.align !== 0) return []
66
- const keys = []
67
- for (let cursor = start, key = form.format(cursor); key <= to; key = form.format(cursor)) {
68
- keys.push(key)
69
- cursor = form.step(cursor)
70
- }
71
- return keys
72
- }
73
-
74
- export const daysInRange = enumerateBucketKeys(BUCKET_FORMS[GRANULARITY_DAILY])
75
- export const hourKeysInRange = enumerateBucketKeys(BUCKET_FORMS[GRANULARITY_HOURLY])
76
- export const minuteKeysInRange = enumerateBucketKeys(BUCKET_FORMS[GRANULARITY_MINUTE])
77
-
78
- const emptySlot = (day) => ({
79
- day,
80
- total: 0,
81
- byModel: {},
82
- byProvider: {},
83
- requests: 0,
84
- turns: 0,
85
- cacheHit: 0,
86
- cacheMiss: 0,
87
- })
88
-
89
- const addRowToSlot = (slot, row, tokens) => {
90
- slot.total += tokens
91
- slot.requests += row.requests
92
- slot.turns += row.turns
93
- slot.cacheHit += row.cacheReadTokens
94
- slot.cacheMiss += row.inputTokens + row.cacheWriteTokens
95
- }
96
-
97
- const percentOf = (part, total) => (total === 0 ? 0 : (part / total) * PERCENT_SCALE)
98
-
99
- const rowTokens = (row) => row.inputTokens + row.outputTokens + row.cacheReadTokens + row.cacheWriteTokens
100
-
101
- // 速度配对分子:decode 口径取 decodeTokens;存量旧格式行(带时长无 decodeTokens)
102
- // 回落 outputTokens,聚合随新数据自然收敛
103
- const speedTokensOf = (row) => (row.durationMs ? row.decodeTokens ?? row.outputTokens : 0)
104
-
105
- export function aggregateRange(rows, g, from, to) {
106
- const form = BUCKET_FORMS[g]
107
- const slots = enumerateBucketKeys(form)(from, to).map((key) => emptySlot(key))
108
- const slotByKey = new Map(slots.map((slot) => [slot.day, slot]))
109
- const modelTotals = new Map()
110
- const providerTotals = new Map()
111
- const activeBuckets = new Set()
112
- // 槽级配对:桶串 速度对 {decodeTokens, durationMs} 与首字对 {ttftMs, ttftSteps},
113
- // 与模型级同口径(仅带配对数据的行计入)
114
- const slotSpeeds = new Map()
115
- const slotTtfts = new Map()
116
- for (const row of rows) {
117
- const slot = slotByKey.get(row.bucket)
118
- // 桶串未落在枚举序列(如改粒度前的历史残行)不可归属,跳过防崩
119
- if (!slot) continue
120
- const tokens = rowTokens(row)
121
- addRowToSlot(slot, row, tokens)
122
- // timing 行( token + decode 配对)不参与归因,但仍进配对聚合
123
- if (tokens > 0) {
124
- activeBuckets.add(row.bucket)
125
- slot.byModel[row.model] = (slot.byModel[row.model] ?? 0) + tokens
126
- slot.byProvider[row.provider] = (slot.byProvider[row.provider] ?? 0) + tokens
127
- }
128
- if (row.durationMs) {
129
- const pair = slotSpeeds.get(row.bucket) ?? { decodeTokens: 0, durationMs: 0 }
130
- pair.decodeTokens += speedTokensOf(row)
131
- pair.durationMs += row.durationMs
132
- slotSpeeds.set(row.bucket, pair)
133
- }
134
- if (row.ttftSteps > 0) {
135
- const pair = slotTtfts.get(row.bucket) ?? { ttftMs: 0, ttftSteps: 0 }
136
- pair.ttftMs += row.ttftMs ?? 0
137
- pair.ttftSteps += row.ttftSteps
138
- slotTtfts.set(row.bucket, pair)
139
- }
140
- const modelTotal = modelTotals.get(row.model)
141
- if (modelTotal) {
142
- modelTotal.tokens += tokens
143
- modelTotal.speedDurationMs += row.durationMs ?? 0
144
- modelTotal.speedOutputTokens += speedTokensOf(row)
145
- modelTotal.ttftMs += row.ttftMs ?? 0
146
- modelTotal.ttftSteps += row.ttftSteps ?? 0
147
- } else {
148
- modelTotals.set(row.model, {
149
- provider: row.provider,
150
- tokens,
151
- speedDurationMs: row.durationMs ?? 0,
152
- speedOutputTokens: speedTokensOf(row),
153
- ttftMs: row.ttftMs ?? 0,
154
- ttftSteps: row.ttftSteps ?? 0,
155
- })
156
- }
157
- providerTotals.set(row.provider, (providerTotals.get(row.provider) ?? 0) + tokens)
158
- }
159
- // 槽级 speed/ttft 条件挂:无配对数据的槽不挂字段(存量槽形契约不变)
160
- for (const slot of slots) {
161
- const speedPair = slotSpeeds.get(slot.day)
162
- if (speedPair && speedPair.durationMs > 0) slot.speed = speedPair.decodeTokens / (speedPair.durationMs / MS_PER_SECOND)
163
- const ttftPair = slotTtfts.get(slot.day)
164
- if (ttftPair && ttftPair.ttftSteps > 0) slot.ttft = ttftPair.ttftMs / ttftPair.ttftSteps
165
- }
166
- const totals = { tokens: 0, requests: 0, turns: 0, cacheHit: 0, cacheMiss: 0 }
167
- for (const slot of slots) {
168
- totals.tokens += slot.total
169
- totals.requests += slot.requests
170
- totals.turns += slot.turns
171
- totals.cacheHit += slot.cacheHit
172
- totals.cacheMiss += slot.cacheMiss
173
- }
174
- // speed = decode 配对口径(decodeTokens ÷ 时长秒);ttft = 首 token 延迟
175
- // 加权平均(毫秒);仅配对数据存在的条目挂字段,无数据条目不挂;
176
- // 纯 timing 行可能产生 0-token 条目,列表保持只含 token 行(存量契约)
177
- const models = [...modelTotals.entries()]
178
- .filter(([, agg]) => agg.tokens > 0)
179
- .map(([model, agg]) => ({
180
- model,
181
- provider: agg.provider,
182
- tokens: agg.tokens,
183
- percent: percentOf(agg.tokens, totals.tokens),
184
- ...(agg.speedDurationMs > 0 ? { speed: agg.speedOutputTokens / (agg.speedDurationMs / MS_PER_SECOND) } : {}),
185
- ...(agg.ttftSteps > 0 ? { ttft: agg.ttftMs / agg.ttftSteps } : {}),
186
- }))
187
- .sort((a, b) => b.tokens - a.tokens)
188
- const providers = [...providerTotals.entries()]
189
- .filter(([, tokens]) => tokens > 0)
190
- .map(([provider, tokens]) => ({ provider, tokens, percent: percentOf(tokens, totals.tokens) }))
191
- .sort((a, b) => b.tokens - a.tokens)
192
- const truncated = slots.length > MAX_SLOTS
193
- const daily = truncated ? slots.slice(-MAX_SLOTS) : slots
194
- const top = models[0]
195
- const result = {
196
- from,
197
- to,
198
- tokens: totals.tokens,
199
- requests: totals.requests,
200
- turns: totals.turns,
201
- cacheHit: totals.cacheHit,
202
- cacheMiss: totals.cacheMiss,
203
- activeDays: activeBuckets.size,
204
- topModel: top?.model ?? '',
205
- topProvider: top?.provider ?? '',
206
- daily,
207
- models,
208
- providers,
209
- }
210
- if (truncated) result.truncated = true
211
- return result
212
- }
213
-
214
- // 聚合计价的槽定位:D 折叠到日槽,H/M 即本槽;计价一律取行所属 H 桶起点
215
- const COST_SLOT_KEYS = {
216
- [GRANULARITY_DAILY]: (bucket) => bucket.slice(0, DAY_KEY_WIDTH),
217
- [GRANULARITY_HOURLY]: (bucket) => bucket,
218
- [GRANULARITY_MINUTE]: (bucket) => bucket,
219
- }
220
-
221
- // 聚合计价:以可见槽为唯一口径,cost 行按 H 桶起点匹配价格后累加;
222
- // unpriced = 有 token 而未命中价的去重 H 桶数,被截断丢弃的行整体不参与。
223
- // 纯函数返回新 result,不修改入参;调用方不调用则响应无 cost/unpriced 字段
224
- export function attachCosts(result, costRows, granularity, rules) {
225
- const slotKeyOf = COST_SLOT_KEYS[granularity]
226
- const slotOfDay = new Map(result.daily.map((slot) => [slot.day, slot]))
227
- const slotCosts = new Map()
228
- const modelCosts = new Map(result.models.map((entry) => [entry.model, 0]))
229
- const unpricedHours = new Set()
230
- for (const row of costRows) {
231
- const slot = slotOfDay.get(slotKeyOf(row.bucket))
232
- if (!slot || rowTokens(row) === 0) continue
233
- const hourKey = row.bucket.slice(0, HOUR_KEY_WIDTH)
234
- const date = parseBucketKey(hourKey, BUCKET_FORMS[GRANULARITY_HOURLY])
235
- if (!date) continue
236
- const price = matchPrice(rules, row.model, date)
237
- if (!price) {
238
- unpricedHours.add(hourKey)
239
- continue
240
- }
241
- const cost = costOf(price, row)
242
- slotCosts.set(slot.day, (slotCosts.get(slot.day) ?? 0) + cost)
243
- if (modelCosts.has(row.model)) modelCosts.set(row.model, modelCosts.get(row.model) + cost)
244
- }
245
- const daily = result.daily.map((slot) => ({ ...slot, cost: slotCosts.get(slot.day) ?? 0 }))
246
- const cost = daily.reduce((sum, slot) => sum + slot.cost, 0)
247
- const models = result.models.map((entry) => ({ ...entry, cost: modelCosts.get(entry.model) }))
248
- return { ...result, daily, models, cost, unpriced: unpricedHours.size }
249
- }
1
+ import { costOf, matchPrice } from './pricing.js'
2
+
3
+ export const MAX_SLOTS = 2000
4
+
5
+ const PAD_WIDTH = 2
6
+ const PERCENT_SCALE = 100
7
+ const MS_PER_SECOND = 1000
8
+
9
+ const GRANULARITY_DAILY = 'D'
10
+ const GRANULARITY_HOURLY = 'H'
11
+ const GRANULARITY_MINUTE = 'M'
12
+
13
+ const DAY_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
14
+ const HOUR_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}$/
15
+ const MINUTE_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/
16
+
17
+ // 桶串前缀宽:D 段定位日桶,M 行截取父 H 桶
18
+ const DAY_KEY_WIDTH = 'YYYY-MM-DD'.length
19
+ const HOUR_KEY_WIDTH = 'YYYY-MM-DDTHH'.length
20
+
21
+ const pad = (value) => String(value).padStart(PAD_WIDTH, '0')
22
+ const formatDate = (date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
23
+ const formatHour = (date) => `${formatDate(date)}T${pad(date.getHours())}`
24
+ const formatMinute = (date) => `${formatHour(date)}:${pad(date.getMinutes())}`
25
+
26
+ const nextDay = (date) => {
27
+ const next = new Date(date)
28
+ next.setDate(next.getDate() + 1)
29
+ return next
30
+ }
31
+ const nextHour = (date) => {
32
+ const next = new Date(date)
33
+ next.setHours(next.getHours() + 1)
34
+ return next
35
+ }
36
+ const nextMinute = (date) => {
37
+ const next = new Date(date)
38
+ next.setMinutes(next.getMinutes() + MINUTE_STEP_MINUTES)
39
+ return next
40
+ }
41
+
42
+ // 分钟桶粒度:枚举与桶键共用同一步长,from 必须对齐桶边界
43
+ const MINUTE_STEP_MINUTES = 10
44
+
45
+ // pattern 锚定桶串外形,suffix 补全为本地时区可解析日期串,format 回读校验分量合法性
46
+ const BUCKET_FORMS = {
47
+ [GRANULARITY_DAILY]: { pattern: DAY_KEY_PATTERN, suffix: 'T00:00:00', format: formatDate, step: nextDay },
48
+ [GRANULARITY_HOURLY]: { pattern: HOUR_KEY_PATTERN, suffix: ':00:00', format: formatHour, step: nextHour },
49
+ [GRANULARITY_MINUTE]: { pattern: MINUTE_KEY_PATTERN, suffix: ':00', format: formatMinute, step: nextMinute, align: MINUTE_STEP_MINUTES },
50
+ }
51
+
52
+ const parseBucketKey = (key, form) => {
53
+ if (typeof key !== 'string' || !form.pattern.test(key)) return null
54
+ const parsed = new Date(`${key}${form.suffix}`)
55
+ return Number.isNaN(parsed.getTime()) || form.format(parsed) !== key ? null : parsed
56
+ }
57
+
58
+ const enumerateBucketKeys = (form) => (from, to) => {
59
+ const start = parseBucketKey(from, form)
60
+ const end = parseBucketKey(to, form)
61
+ if (!start || !end) return []
62
+ if (form.align && start.getMinutes() % form.align !== 0) return []
63
+ const keys = []
64
+ for (let cursor = start, key = form.format(cursor); key <= to; key = form.format(cursor)) {
65
+ keys.push(key)
66
+ cursor = form.step(cursor)
67
+ }
68
+ return keys
69
+ }
70
+
71
+ export const daysInRange = enumerateBucketKeys(BUCKET_FORMS[GRANULARITY_DAILY])
72
+ export const hourKeysInRange = enumerateBucketKeys(BUCKET_FORMS[GRANULARITY_HOURLY])
73
+ export const minuteKeysInRange = enumerateBucketKeys(BUCKET_FORMS[GRANULARITY_MINUTE])
74
+
75
+ const emptySlot = (day) => ({
76
+ day,
77
+ total: 0,
78
+ byModel: {},
79
+ byProvider: {},
80
+ requests: 0,
81
+ turns: 0,
82
+ cacheHit: 0,
83
+ cacheMiss: 0,
84
+ })
85
+
86
+ const addRowToSlot = (slot, row, tokens) => {
87
+ slot.total += tokens
88
+ slot.requests += row.requests
89
+ slot.turns += row.turns
90
+ slot.cacheHit += row.cacheReadTokens
91
+ slot.cacheMiss += row.inputTokens + row.cacheWriteTokens
92
+ }
93
+
94
+ const percentOf = (part, total) => (total === 0 ? 0 : (part / total) * PERCENT_SCALE)
95
+
96
+ const rowTokens = (row) => row.inputTokens + row.outputTokens + row.cacheReadTokens + row.cacheWriteTokens
97
+
98
+ // 速度配对分子:decode 口径取 decodeTokens;存量旧格式行(带时长无 decodeTokens)
99
+ // 回落 outputTokens,聚合随新数据自然收敛
100
+ const speedTokensOf = (row) => (row.durationMs ? row.decodeTokens ?? row.outputTokens : 0)
101
+
102
+ export function aggregateRange(rows, g, from, to) {
103
+ const form = BUCKET_FORMS[g]
104
+ const slots = enumerateBucketKeys(form)(from, to).map((key) => emptySlot(key))
105
+ const slotByKey = new Map(slots.map((slot) => [slot.day, slot]))
106
+ const modelTotals = new Map()
107
+ const providerTotals = new Map()
108
+ const activeBuckets = new Set()
109
+ // 槽级配对:桶串 速度对 {decodeTokens, durationMs} 与首字对 {ttftMs, ttftSteps},
110
+ const slotSpeeds = new Map()
111
+ const slotTtfts = new Map()
112
+ for (const row of rows) {
113
+ const slot = slotByKey.get(row.bucket)
114
+ // 桶串未落在枚举序列(如改粒度前的历史残行)不可归属,跳过防崩
115
+ if (!slot) continue
116
+ const tokens = rowTokens(row)
117
+ addRowToSlot(slot, row, tokens)
118
+ // 纯 timing 行(零 token 桶 + decode 配对)不参与归属,但仍进配对聚合
119
+ if (tokens > 0) {
120
+ activeBuckets.add(row.bucket)
121
+ slot.byModel[row.model] = (slot.byModel[row.model] ?? 0) + tokens
122
+ slot.byProvider[row.provider] = (slot.byProvider[row.provider] ?? 0) + tokens
123
+ }
124
+ if (row.durationMs) {
125
+ const pair = slotSpeeds.get(row.bucket) ?? { decodeTokens: 0, durationMs: 0 }
126
+ pair.decodeTokens += speedTokensOf(row)
127
+ pair.durationMs += row.durationMs
128
+ slotSpeeds.set(row.bucket, pair)
129
+ }
130
+ if (row.ttftSteps > 0) {
131
+ const pair = slotTtfts.get(row.bucket) ?? { ttftMs: 0, ttftSteps: 0 }
132
+ pair.ttftMs += row.ttftMs ?? 0
133
+ pair.ttftSteps += row.ttftSteps
134
+ slotTtfts.set(row.bucket, pair)
135
+ }
136
+ const modelTotal = modelTotals.get(row.model)
137
+ if (modelTotal) {
138
+ modelTotal.tokens += tokens
139
+ modelTotal.inputTokens += row.inputTokens
140
+ modelTotal.outputTokens += row.outputTokens
141
+ modelTotal.cacheReadTokens += row.cacheReadTokens
142
+ modelTotal.cacheWriteTokens += row.cacheWriteTokens
143
+ modelTotal.speedDurationMs += row.durationMs ?? 0
144
+ modelTotal.speedOutputTokens += speedTokensOf(row)
145
+ modelTotal.ttftMs += row.ttftMs ?? 0
146
+ modelTotal.ttftSteps += row.ttftSteps ?? 0
147
+ } else {
148
+ modelTotals.set(row.model, {
149
+ provider: row.provider,
150
+ tokens,
151
+ inputTokens: row.inputTokens,
152
+ outputTokens: row.outputTokens,
153
+ cacheReadTokens: row.cacheReadTokens,
154
+ cacheWriteTokens: row.cacheWriteTokens,
155
+ speedDurationMs: row.durationMs ?? 0,
156
+ speedOutputTokens: speedTokensOf(row),
157
+ ttftMs: row.ttftMs ?? 0,
158
+ ttftSteps: row.ttftSteps ?? 0,
159
+ })
160
+ }
161
+ providerTotals.set(row.provider, (providerTotals.get(row.provider) ?? 0) + tokens)
162
+ }
163
+ for (const slot of slots) {
164
+ const speedPair = slotSpeeds.get(slot.day)
165
+ if (speedPair && speedPair.durationMs > 0) slot.speed = speedPair.decodeTokens / (speedPair.durationMs / MS_PER_SECOND)
166
+ const ttftPair = slotTtfts.get(slot.day)
167
+ if (ttftPair && ttftPair.ttftSteps > 0) slot.ttft = ttftPair.ttftMs / ttftPair.ttftSteps
168
+ }
169
+ const totals = { tokens: 0, requests: 0, turns: 0, cacheHit: 0, cacheMiss: 0 }
170
+ for (const slot of slots) {
171
+ totals.tokens += slot.total
172
+ totals.requests += slot.requests
173
+ totals.turns += slot.turns
174
+ totals.cacheHit += slot.cacheHit
175
+ totals.cacheMiss += slot.cacheMiss
176
+ }
177
+ // speed = decode 配对口径(decodeTokens ÷ 时长秒);ttft = 首 token 延迟
178
+ // 加权平均(毫秒);仅配对数据存在的条目挂字段,无数据条目不挂
179
+ // timing 行可能产生 0-token 条目,列表保持只含 token 行(存量契约)
180
+ const models = [...modelTotals.entries()]
181
+ .filter(([, agg]) => agg.tokens > 0)
182
+ .map(([model, agg]) => ({
183
+ model,
184
+ provider: agg.provider,
185
+ tokens: agg.tokens,
186
+ inputTokens: agg.inputTokens,
187
+ outputTokens: agg.outputTokens,
188
+ cacheReadTokens: agg.cacheReadTokens,
189
+ cacheWriteTokens: agg.cacheWriteTokens,
190
+ percent: percentOf(agg.tokens, totals.tokens),
191
+ ...(agg.speedDurationMs > 0 ? { speed: agg.speedOutputTokens / (agg.speedDurationMs / MS_PER_SECOND) } : {}),
192
+ ...(agg.ttftSteps > 0 ? { ttft: agg.ttftMs / agg.ttftSteps } : {}),
193
+ }))
194
+ .sort((a, b) => b.tokens - a.tokens)
195
+ const providers = [...providerTotals.entries()]
196
+ .filter(([, tokens]) => tokens > 0)
197
+ .map(([provider, tokens]) => ({ provider, tokens, percent: percentOf(tokens, totals.tokens) }))
198
+ .sort((a, b) => b.tokens - a.tokens)
199
+ const truncated = slots.length > MAX_SLOTS
200
+ const daily = truncated ? slots.slice(-MAX_SLOTS) : slots
201
+ const top = models[0]
202
+ const result = {
203
+ from,
204
+ to,
205
+ tokens: totals.tokens,
206
+ requests: totals.requests,
207
+ turns: totals.turns,
208
+ cacheHit: totals.cacheHit,
209
+ cacheMiss: totals.cacheMiss,
210
+ activeDays: activeBuckets.size,
211
+ topModel: top?.model ?? '',
212
+ topProvider: top?.provider ?? '',
213
+ daily,
214
+ models,
215
+ providers,
216
+ }
217
+ if (truncated) result.truncated = true
218
+ return result
219
+ }
220
+
221
+ // 聚合计价的槽定位:D 折叠到日槽,H/M 即本槽,计价一律取行所属 H 槽起点
222
+ const COST_SLOT_KEYS = {
223
+ [GRANULARITY_DAILY]: (bucket) => bucket.slice(0, DAY_KEY_WIDTH),
224
+ [GRANULARITY_HOURLY]: (bucket) => bucket,
225
+ [GRANULARITY_MINUTE]: (bucket) => bucket,
226
+ }
227
+
228
+ // 聚合计价:以可见槽为唯一口径,cost 行按 H 槽起点匹配价格后累加;
229
+ export function attachCosts(result, costRows, granularity, rules) {
230
+ const slotKeyOf = COST_SLOT_KEYS[granularity]
231
+ const slotOfDay = new Map(result.daily.map((slot) => [slot.day, slot]))
232
+ const slotCosts = new Map()
233
+ const modelCosts = new Map(result.models.map((entry) => [entry.model, 0]))
234
+ const unpricedHours = new Set()
235
+ for (const row of costRows) {
236
+ const slot = slotOfDay.get(slotKeyOf(row.bucket))
237
+ if (!slot || rowTokens(row) === 0) continue
238
+ const hourKey = row.bucket.slice(0, HOUR_KEY_WIDTH)
239
+ const date = parseBucketKey(hourKey, BUCKET_FORMS[GRANULARITY_HOURLY])
240
+ if (!date) continue
241
+ const price = matchPrice(rules, row.model, date)
242
+ if (!price) {
243
+ unpricedHours.add(hourKey)
244
+ continue
245
+ }
246
+ const cost = costOf(price, row)
247
+ slotCosts.set(slot.day, (slotCosts.get(slot.day) ?? 0) + cost)
248
+ if (modelCosts.has(row.model)) modelCosts.set(row.model, modelCosts.get(row.model) + cost)
249
+ }
250
+ const daily = result.daily.map((slot) => ({ ...slot, cost: slotCosts.get(slot.day) ?? 0 }))
251
+ const cost = daily.reduce((sum, slot) => sum + slot.cost, 0)
252
+ const models = result.models.map((entry) => ({ ...entry, cost: modelCosts.get(entry.model) }))
253
+ return { ...result, daily, models, cost, unpriced: unpricedHours.size }
254
+ }