@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/README.md +4 -4
- package/package.json +1 -1
- package/src/client.js +237 -71
- package/src/query.js +254 -249
- package/test/client.test.mjs +238 -25
- package/test/query.test.mjs +53 -3
- package/test/turn-tail.test.mjs +16 -0
package/src/query.js
CHANGED
|
@@ -1,249 +1,254 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
slot.
|
|
91
|
-
slot.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
//
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
pair
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
modelTotal.
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
totals.
|
|
172
|
-
totals.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
tokens
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
if (!
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
+
}
|