@mzzsfy/dsh-usage-dash 0.6.0 → 0.7.1
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 +133 -35
- package/src/query.js +254 -249
- package/test/client.test.mjs +129 -0
- package/test/query.test.mjs +53 -3
- package/test/turn-tail.test.mjs +16 -0
package/README.md
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
- 汇总卡六张:Tokens 用量(服务商总口径)、会话数量、请求数量、最常用模型、平均缓存命中率、活跃天数(恒按天口径,不随视图切换);配置定价规则后 Tokens 卡头部行右侧显示估算费用
|
|
11
11
|
- 活动热力图:GitHub 风格周列×星期行,26 周窗口,五档色阶,悬停明细;仅按天视图展示
|
|
12
12
|
- 缓存命中率曲线:日粒度命中率 + 右侧副轴,并叠加平均生成速度曲线与首 token 延迟曲线(各自单独颜色,读数走悬停;仅含配对数据的槽参与,整图无数据不绘制),悬停显示当前时段命中率、平均生成速度、首 token 延迟与 token 明细(配置定价规则后附当前时段估算费用行,受「费用显示」开关;三粒度趋势图通用)
|
|
13
|
-
- 模型 donut 与列表:按 token 前 5 模型占比环形图(中心为总量)
|
|
14
|
-
- 三粒度堆叠柱状趋势图(数据量大时裁最旧并提示);图例可点击切换显隐:点击单选(仅显示该项,左轴刻度按可见项归一)/再点恢复全部,Ctrl
|
|
13
|
+
- 模型 donut 与列表:按 token 前 5 模型占比环形图(中心为总量);每行名称两行展示(厂商/模型名,其余折叠为「其他」),右侧三排:第一排 token 数·占比,第二排输入输出占比构成(缓存/输入/输出 三段各占该模型 token 总量,缓存=读+写,三段合计 100%,无四桶数据不显示),第三排估算费用·TTFT·生成速度(费用需配置定价规则,TTFT/速度无配对数据不显示),悬停明细含输入/缓存读/缓存写/输出四桶 token 拆分并联动
|
|
14
|
+
- 三粒度堆叠柱状趋势图(数据量大时裁最旧并提示);图例可点击切换显隐:点击单选(仅显示该项,左轴刻度按可见项归一)/再点恢复全部,Ctrl+点击多选,隐藏最后一项无效;悬停明细按当前时段数据展示,零用量模型不显示,全零时段不显示悬浮框
|
|
15
15
|
- 回扫状态行默认隐藏:首次启用自动回扫历史会话,运行中显示进度(常显);右上角为折叠箭头与刷新图标,折叠层内展开扫描异常日志块(逐条:时间/类型/明细,计数即明细条数,上限 200 条超限丢最旧,重建时清空)与「重建」入口(二次确认 3 秒后清库重扫),采集错误常显;工具栏分主区与右侧操作区,窄宽度时挡位组在主区内换行
|
|
16
16
|
- 会话底栏接管:替换官方信息行为增强版,偏好卡三个开关默认全开——精确缓存命中率(两位小数)、会话 Token 明细(总/命中缓存/未命中缓存/输出)、费用显示(底栏费用项、趋势 tooltip 费用行与回合费用芯片);三开关全关时与官方逐字节一致
|
|
17
17
|
- 回合费用芯片:经官方 `conversation.chat.assistant-actions` 槽注入动作行(复制与分支图标之间,官方赞/踩与上下文跳转同排),每轮对话结束后显示该轮估算费用(悬停 title 带 token 摘要与估算口径;显隐节奏随官方动作行——最新一轮常显,历史轮悬停显现;受「费用显示」开关;官方用量芯片弹窗已有 token 明细,芯片只承载费用)。旧宿主无该插槽时告警禁用
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
- 双语:跟随宿主语言设置(设置 → 通用 → 语言)即时切换 en/zh
|
|
20
20
|
- 数据 API 守卫:POST 同源校验 + JSON content-type(与 dsh-usage-panel 同构),局域网远程访问可用
|
|
21
21
|
|
|
22
|
-
口径:token 总量 = 未缓存输入 + 输出 + 缓存读 + 缓存写;命中率 = 缓存读 / (缓存读 + 未缓存输入 + 缓存写);桶按 host
|
|
22
|
+
口径:token 总量 = 未缓存输入 + 输出 + 缓存读 + 缓存写;命中率 = 缓存读 / (缓存读 + 未缓存输入 + 缓存写);桶按 host 本地时区。聚合响应 `models` 数组每模型带 `inputTokens`/`outputTokens`/`cacheReadTokens`/`cacheWriteTokens` 四桶拆分(与定价四桶同口径),模型列表据此展示输入输出对比。平均生成速度 = decode 配对分子 ÷ 解码时长,两者均取官方吞吐口径(与官方 session-stats 投影同构):解码时长为该步首 token 时刻(首个产出 token 的 attempt 流,回落 message 自带流)到 usage 汇报时刻,不含首 token 前的排队与提示处理等待;首 token 延迟(TTFT)= 首 token 时刻 − `step/start`(起点不随 `llm/retry-started` 重置,即含失败尝试时间)。首 token 时刻在 chunk token 样本上不可得,由后续 usage 报告(message 为主)补发零桶 timing 增量行承载(decodeTokens 与时长同源配对,不重复计 token);存量旧行(时长为旧全时长口径)速度分子回落输出 token,聚合随新数据自然收敛,重建(重扫)可全量按新口径重建。保留策略:天桶永久,小时桶固定 15 天,分钟桶默认 7 天且上限 7 天(设置项 `minuteRetentionDays`,0 = 禁用分钟桶)。
|
|
23
23
|
|
|
24
24
|
写入模型:样本先同步合并进内存 pending,按 2 秒周期批量落盘(单布局存储域每次持久化写都全量重发布 unit 文档,合并把每样本 3 次写降为每脏行 1 次,回扫万级样本写放大降约 99%);查询前自动 flush 保证读己之写;flush 失败的行留 pending 下轮重试并经异常日志可观测,崩溃丢失窗口 = flush 周期,统计可由会话重扫重建。
|
|
25
25
|
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
- 模型匹配:`model` 为两段式 `vendor/model`(首个 `/` 分段,模型段允许含 `/`),两段各自可 `*` 通配;匹配链为 全名精确 > 模型名精确(`*/model`,跨供应商同模型名同价)> 供应商精确(`vendor/*`,同供应商多模型同价)> `*/*` 全通,档位相同按数组序取首个命中,高档条件不满足逐层落低档;全链无命中不计费用并计 unpriced。提交(编辑器保存与 pricing POST)强制两段式,单段旧形态(`*` 或裸名)不再合法
|
|
33
33
|
- 同模型多规则(分时段定价):组内附加计费规则从上到下首个「条件全过」者生效,全不命中落组内默认价(无条件规则,恒兜底);附加规则卡带 ↑/↓ 调序。注意同一模型的多条规则请使用相同的模型键写法——不同写法(如 `*/model` 与 `vendor/model`)分属不同档位,按档位优先级而非数组序取胜
|
|
34
|
-
- 条件类型(数组内 AND,空数组恒生效),**所有范围条件统一双侧包含**(含起始含结束,from===to 即单点/单日;相邻区间请写 1~15 与 16~31,端点重叠时数组靠前者优先),编辑器支持添加/编辑/删除:每条计费规则下方「+添加条件」按钮追加一条默认条件(时段默认全天 00:00~23:59,行内类型下拉切换即重置为该类型默认值:周几默认空、号段默认全月 1~31、日期段默认当天单日);编辑器端校验拒绝非法时刻(需 HH:MM)、周几(需 0-6)、月号(需 1-31)、非规范日期与日期段倒序(时段/号段倒序是跨午夜/跨月环绕,合法)
|
|
34
|
+
- 条件类型(数组内 AND,空数组恒生效),**所有范围条件统一双侧包含**(含起始含结束,from===to 即单点/单日;相邻区间请写 1~15 与 16~31,端点重叠时数组靠前者优先),编辑器支持添加/编辑/删除:每条计费规则下方「+添加条件」按钮追加一条默认条件(时段默认全天 00:00~23:59,行内类型下拉切换即重置为该类型默认值:周几默认空、号段默认全月 1~31、日期段默认当天单日);编辑器端校验拒绝非法时刻(需 24 小时制 HH:MM)、周几(需 0-6)、月号(需 1-31)、非规范日期与日期段倒序(时段/号段倒序是跨午夜/跨月环绕,合法);每日时段为自绘 HH:MM 文本输入(原生 time 控件的段位由浏览器按系统区域决定,部分环境渲染秒段导致带秒值被拒),全角数字折半角,凑满四位自动补冒号,粘贴带秒串自动截取前四位
|
|
35
35
|
- `dailyWindow`:`{ from: 'HH:MM', to: 'HH:MM' }` 每日时段双侧包含,from<to 正向,from>to 跨午夜,from===to 单点;全天即 00:00~23:59
|
|
36
36
|
- `weekdays`:`{ days: [0-6] }` 星期几(集合,非范围),0=周日,空数组不成立;编辑器为日~六七枚 pill 多选
|
|
37
37
|
- `monthDays`:`{ from, to }` 月内号段双侧包含整数(1-31),from>to 跨月环绕(账单周期),from===to 单日(如 5~5 即 5 号);2 月无 31 号自然不触发
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-usage-dash",
|
|
3
3
|
"description": "用量统计面板:天/小时/分钟三粒度 token 与请求统计,26 周活跃热力图,会话底栏信息接管与官方动作行回合费用芯片,多条件分时段定价规则与费用估算;复刻自 github.com/HaoyueQin/dsh-usage-statistics-panel,感谢原作者 HaoyueQin",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
package/src/client.js
CHANGED
|
@@ -204,6 +204,9 @@ const MESSAGES_ZH = {
|
|
|
204
204
|
dailyTrend: '按天 Token 趋势',
|
|
205
205
|
trendLimited: '仅显示最近 {n} 天',
|
|
206
206
|
modelUsage: '模型用量',
|
|
207
|
+
modelCache: '缓存',
|
|
208
|
+
modelInput: '输入',
|
|
209
|
+
modelOutput: '输出',
|
|
207
210
|
other: '其他',
|
|
208
211
|
total: '总用量',
|
|
209
212
|
percent: '占比',
|
|
@@ -286,7 +289,7 @@ const MESSAGES_ZH = {
|
|
|
286
289
|
'weekday.4': '四',
|
|
287
290
|
'weekday.5': '五',
|
|
288
291
|
'weekday.6': '六',
|
|
289
|
-
condTime: '需 HH:MM',
|
|
292
|
+
condTime: '需 24 小时制时刻 HH:MM(时:分,如 09:30)',
|
|
290
293
|
condWeekday: '需 0-6 整数',
|
|
291
294
|
condMonthDay: '需 1-31 整数',
|
|
292
295
|
condDate: '需 YYYY-MM-DD',
|
|
@@ -344,6 +347,9 @@ const MESSAGES_EN = {
|
|
|
344
347
|
dailyTrend: 'Daily token trend',
|
|
345
348
|
trendLimited: 'Showing only the last {n} days',
|
|
346
349
|
modelUsage: 'Model usage',
|
|
350
|
+
modelCache: 'Cache',
|
|
351
|
+
modelInput: 'Input',
|
|
352
|
+
modelOutput: 'Output',
|
|
347
353
|
other: 'Other',
|
|
348
354
|
total: 'Total',
|
|
349
355
|
percent: 'Share',
|
|
@@ -426,7 +432,7 @@ const MESSAGES_EN = {
|
|
|
426
432
|
'weekday.4': 'Th',
|
|
427
433
|
'weekday.5': 'Fr',
|
|
428
434
|
'weekday.6': 'Sa',
|
|
429
|
-
condTime: 'Requires HH:MM',
|
|
435
|
+
condTime: 'Requires 24-hour HH:MM time (hours:minutes, e.g. 09:30)',
|
|
430
436
|
condWeekday: 'Requires integer 0-6',
|
|
431
437
|
condMonthDay: 'Requires integer 1-31',
|
|
432
438
|
condDate: 'Requires YYYY-MM-DD',
|
|
@@ -561,10 +567,12 @@ const topWithOther = (ranked) => {
|
|
|
561
567
|
const models = ranked.slice(0, GROUP_TOP_COUNT)
|
|
562
568
|
if (ranked.length > GROUP_TOP_COUNT) {
|
|
563
569
|
const rest = ranked.slice(GROUP_TOP_COUNT)
|
|
570
|
+
// 四桶与费用同源条件挂载:输入条目无该字段(旧形数据/未配价)时哨兵同样不带,防零值假数据
|
|
564
571
|
models.push({
|
|
565
572
|
model: OTHER_MODEL,
|
|
566
573
|
tokens: rest.reduce((sum, item) => sum + item.tokens, 0),
|
|
567
|
-
|
|
574
|
+
...(rest.some(hasTokenBuckets) ? { ...bucketSumsOf(rest) } : {}),
|
|
575
|
+
...(rest.some((item) => item.cost !== undefined) ? { cost: rest.reduce((sum, item) => sum + (item.cost ?? 0), 0) } : {}),
|
|
568
576
|
items: rest,
|
|
569
577
|
})
|
|
570
578
|
}
|
|
@@ -820,7 +828,7 @@ function ttftTipText(ttft, t) {
|
|
|
820
828
|
return ttft === undefined ? TOOLTIP_MISSING : formatDuration(ttft, t)
|
|
821
829
|
}
|
|
822
830
|
|
|
823
|
-
// 语言中立短时长:60 秒内一位小数秒,以上整秒折分秒(
|
|
831
|
+
// 语言中立短时长:60 秒内一位小数秒,以上整秒折分秒(模型列表 meta 行用,禁本地化)
|
|
824
832
|
function formatDurationShort(ms) {
|
|
825
833
|
const seconds = ms / MS_PER_SECOND
|
|
826
834
|
if (seconds < DURATION_MINUTE_SECONDS) return `${Math.round(seconds * NUMBER_ONE_DECIMAL) / NUMBER_ONE_DECIMAL}s`
|
|
@@ -834,6 +842,39 @@ function modelTtftText(ttft) {
|
|
|
834
842
|
return `TTFT ${formatDurationShort(ttft)}`
|
|
835
843
|
}
|
|
836
844
|
|
|
845
|
+
// 模型条目四桶齐备判定:生产端同源恒齐,旧形数据缺桶即视为不可渲染
|
|
846
|
+
const hasTokenBuckets = (item) => TOKEN_BUCKET_KEYS.every((key) => typeof item?.[key] === 'number')
|
|
847
|
+
|
|
848
|
+
// 逐桶求和:按共享桶键列表产出四桶聚合形态
|
|
849
|
+
const bucketSumsOf = (items) => Object.fromEntries(TOKEN_BUCKET_KEYS.map((key) => [
|
|
850
|
+
key,
|
|
851
|
+
items.reduce((sum, item) => sum + (item[key] ?? 0), 0),
|
|
852
|
+
]))
|
|
853
|
+
|
|
854
|
+
// 模型输入输出占比构成:缓存/输入/输出 三段各占该模型 token 总量百分比(缓存=读+写,
|
|
855
|
+
// 三段合计恒 100%);四桶缺失(旧数据)或零总量为空串
|
|
856
|
+
function modelIoPercentText(item, t) {
|
|
857
|
+
if (!hasTokenBuckets(item) || !(item.tokens > 0)) return ''
|
|
858
|
+
const share = (value) => formatPercent((value / item.tokens) * PERCENT_SCALE)
|
|
859
|
+
return `${t('modelCache')} ${share(item.cacheReadTokens + item.cacheWriteTokens)} · ${t('modelInput')} ${share(item.inputTokens)} · ${t('modelOutput')} ${share(item.outputTokens)}`
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// 趋势 tooltip 内容门:全零槽(无任何用量)不渲染 tooltip
|
|
863
|
+
const hasTipContent = (hoverSlot) => Boolean(hoverSlot) && hoverSlot.total > 0
|
|
864
|
+
|
|
865
|
+
// 趋势 tooltip 模型行数据:主行 = 可见且当前时段有用量的模型;OTHER 子行 = OTHER 可见
|
|
866
|
+
// 且当前时段有用量的其余模型明细,按 tokens 降序由本函数保证
|
|
867
|
+
const tipModelEntries = (hoverSlot, visibleSet) => {
|
|
868
|
+
if (!hasTipContent(hoverSlot)) return { main: [], other: [] }
|
|
869
|
+
const main = Object.entries(hoverSlot.byModel)
|
|
870
|
+
.filter(([model, tokens]) => visibleSet.has(model) && tokens > 0)
|
|
871
|
+
.map(([model, tokens]) => ({ model, tokens }))
|
|
872
|
+
const other = visibleSet.has(OTHER_MODEL)
|
|
873
|
+
? Object.entries(hoverSlot.otherByModel ?? {}).filter(([, tokens]) => tokens > 0).sort((a, b) => b[1] - a[1])
|
|
874
|
+
: []
|
|
875
|
+
return { main, other }
|
|
876
|
+
}
|
|
877
|
+
|
|
837
878
|
// 热力图:窗口固定 26 周,与所选范围无关
|
|
838
879
|
const HEAT_WEEKS = 26
|
|
839
880
|
const HEAT_ROW_COUNT = 7
|
|
@@ -1316,6 +1357,9 @@ const BUCKET_PRICE_KEYS = [
|
|
|
1316
1357
|
{ tokens: 'cacheWriteTokens', price: 'cacheWrite' },
|
|
1317
1358
|
]
|
|
1318
1359
|
|
|
1360
|
+
// 展示侧四桶键单一来源:模型分组求和与齐备判定共用,禁止各处手工复制键名
|
|
1361
|
+
const TOKEN_BUCKET_KEYS = BUCKET_PRICE_KEYS.map(({ tokens }) => tokens)
|
|
1362
|
+
|
|
1319
1363
|
const toFiniteNumber = (value) => (Number.isFinite(value) ? value : 0)
|
|
1320
1364
|
|
|
1321
1365
|
// 费用 = Σ(桶 token × 桶单价) / 每百万;缺桶或非法值按 0,原始浮点不圆整(展示层负责)
|
|
@@ -1383,15 +1427,20 @@ const TURN_COST_CHIP_ORDER = 20 + 10
|
|
|
1383
1427
|
// messageId 反查:节点表为 chat 节点仓库(values() 可枚举,Map/仓库两态兼容);
|
|
1384
1428
|
// 回合级用量优先取回合位置数据(location.turn.data.get('turn-tail')).tokenUsage(官方 tokenUsage 聚合,
|
|
1385
1429
|
// 分页窗口缺 turn/start 时缺席),回退视图节点 data.closing.usage(末步用量采样,输入侧已含缓存,计费口径同源)
|
|
1386
|
-
//
|
|
1387
|
-
//
|
|
1430
|
+
// 索引缓存:键取节点集数组身份 —— 官方 chat store 实例身份跨回合恒定,values() 返回的缓存数组
|
|
1431
|
+
// 随节点集更替换新引用:未变时全芯片共享一次扫描,新增回合节点经 values() 换引用自动失效重建
|
|
1432
|
+
// (按 store 实例为键会让新节点永远查不到旧索引,表现为回合费用芯片刷新后才出现);
|
|
1433
|
+
// Map/迭代器形态(测试兼容)仍按容器为键
|
|
1388
1434
|
const TURN_USAGE_INDEX_CACHE = new WeakMap()
|
|
1389
1435
|
|
|
1390
1436
|
function turnUsageIndexOf(nodes) {
|
|
1391
|
-
|
|
1437
|
+
const source = nodes && typeof nodes.values === 'function' ? nodes.values() : nodes
|
|
1438
|
+
const snapshotForm = Array.isArray(source)
|
|
1439
|
+
const cacheKey = snapshotForm ? source : nodes
|
|
1440
|
+
let index = TURN_USAGE_INDEX_CACHE.get(cacheKey)
|
|
1392
1441
|
if (index !== undefined) return index
|
|
1393
1442
|
index = new Map()
|
|
1394
|
-
const list =
|
|
1443
|
+
const list = snapshotForm ? source : typeof source?.[Symbol.iterator] === 'function' ? [...source] : source
|
|
1395
1444
|
if (Array.isArray(list)) {
|
|
1396
1445
|
for (const node of list) {
|
|
1397
1446
|
try {
|
|
@@ -1402,13 +1451,13 @@ function turnUsageIndexOf(nodes) {
|
|
|
1402
1451
|
} catch { /* 单节点形状残缺跳过 */ }
|
|
1403
1452
|
}
|
|
1404
1453
|
}
|
|
1405
|
-
TURN_USAGE_INDEX_CACHE.set(
|
|
1454
|
+
TURN_USAGE_INDEX_CACHE.set(cacheKey, index)
|
|
1406
1455
|
return index
|
|
1407
1456
|
}
|
|
1408
1457
|
|
|
1409
1458
|
function turnTokenUsageOfMessage(nodes, messageId) {
|
|
1410
1459
|
// 非对象(含 null/undefined)直接 null:WeakMap 键要求对象,展开校验收进索引构建,
|
|
1411
|
-
//
|
|
1460
|
+
// 每份节点集只物化一次节点序列
|
|
1412
1461
|
if (nodes === null || typeof nodes !== 'object') return null
|
|
1413
1462
|
const node = turnUsageIndexOf(nodes).get(messageId)
|
|
1414
1463
|
if (node === undefined) return null
|
|
@@ -1507,6 +1556,7 @@ function turnCostTitleText(t, tokenUsage) {
|
|
|
1507
1556
|
// ===== 定价编辑器纯函数(校验/规整/默认值) =====
|
|
1508
1557
|
const PRICE_KEYS = ['input', 'output', 'cacheRead', 'cacheWrite']
|
|
1509
1558
|
const HHMM_PATTERN = /^\d{1,2}:\d{2}$/
|
|
1559
|
+
const HHMM_PLACEHOLDER = 'HH:MM'
|
|
1510
1560
|
const ISO_DAY_PATTERN_CLIENT = /^\d{4}-\d{2}-\d{2}$/
|
|
1511
1561
|
// 时刻分量界:小时/分钟均双闭
|
|
1512
1562
|
const HOUR_MAX = 23
|
|
@@ -1516,6 +1566,8 @@ const WEEKDAY_MAX = 6
|
|
|
1516
1566
|
const MONTH_DAY_MIN = 1
|
|
1517
1567
|
const MONTH_DAY_MAX = 31
|
|
1518
1568
|
const FULL_DAY_WINDOW = { from: '00:00', to: '23:59' }
|
|
1569
|
+
// HH:MM 输入框内联宽(size 属性字符数):占位符容量 + 光标余量,配合 .ud-cond-fields 的 width:auto
|
|
1570
|
+
const HHMM_INPUT_SIZE = HHMM_PLACEHOLDER.length + 2
|
|
1519
1571
|
const CONDITION_KIND_OPTIONS = ['dailyWindow', 'weekdays', 'monthDays', 'dateRange']
|
|
1520
1572
|
const CONDITION_KIND_LABEL_KEYS = {
|
|
1521
1573
|
dailyWindow: 'condDailyWindow',
|
|
@@ -1531,6 +1583,27 @@ const parseHHMM = (value) => {
|
|
|
1531
1583
|
return hours >= 0 && hours <= HOUR_MAX && minutes >= 0 && minutes <= MINUTE_MAX ? value : null
|
|
1532
1584
|
}
|
|
1533
1585
|
|
|
1586
|
+
// HH:MM 输入净化:自绘文本框永不出现秒段。成型仅限两种形态——纯数字满四位补冒号(连续键入/纯数字秒串粘贴),
|
|
1587
|
+
// 带冒号三段时刻截取前两段(带秒串粘贴自愈);其余形态(中间插字/杂串)原样放行交保存校验显式报错,防静默重排出错误合法时刻
|
|
1588
|
+
const HHMM_INPUT_DIGITS = 4
|
|
1589
|
+
const HHMM_PURE_DIGITS_PATTERN = /^\d+$/
|
|
1590
|
+
const HHMM_SECONDS_TAIL_PATTERN = /:\d{2}$/
|
|
1591
|
+
const HHMM_THREE_SEGMENT_PATTERN = /^\d{1,2}:\d{2}:\d{2}$/
|
|
1592
|
+
const FULLWIDTH_DIGIT_PATTERN = /[0-9]/g
|
|
1593
|
+
const FULLWIDTH_COLON = ':'
|
|
1594
|
+
const FULLWIDTH_ZERO = '0'.codePointAt(0)
|
|
1595
|
+
const normalizeHHMMInput = (raw) => {
|
|
1596
|
+
if (typeof raw !== 'string') return ''
|
|
1597
|
+
const half = raw.replace(FULLWIDTH_DIGIT_PATTERN, (char) => String(char.codePointAt(0) - FULLWIDTH_ZERO)).replaceAll(FULLWIDTH_COLON, ':')
|
|
1598
|
+
if (HHMM_PURE_DIGITS_PATTERN.test(half) && half.length >= HHMM_INPUT_DIGITS) {
|
|
1599
|
+
return `${half.slice(0, 2)}:${half.slice(2, 4)}`
|
|
1600
|
+
}
|
|
1601
|
+
if (HHMM_THREE_SEGMENT_PATTERN.test(half)) {
|
|
1602
|
+
return half.replace(HHMM_SECONDS_TAIL_PATTERN, '')
|
|
1603
|
+
}
|
|
1604
|
+
return half
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1534
1607
|
const isValidMonthDay = (value) => Number.isInteger(value) && value >= MONTH_DAY_MIN && value <= MONTH_DAY_MAX
|
|
1535
1608
|
|
|
1536
1609
|
const isValidWeekday = (value) => Number.isInteger(value) && value >= WEEKDAY_MIN && value <= WEEKDAY_MAX
|
|
@@ -1934,11 +2007,11 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
1934
2007
|
.ud-heat-l5{fill:var(--dsw-heat-5);background:var(--dsw-heat-5)}
|
|
1935
2008
|
.ud-heat-legend{display:inline-flex;align-items:center;gap:${HEAT_GAP}px;margin-left:auto;flex:none;font-size:11px;color:var(--dsw-alias-label-secondary)}
|
|
1936
2009
|
.ud-heat-legend i{display:inline-block;width:${HEAT_BASE}px;height:${HEAT_BASE}px;border-radius:${HEAT_RX}px;flex:none}
|
|
1937
|
-
.ud-tip{position:fixed;z-index:${TIP_Z_INDEX};visibility:hidden;pointer-events:none;white-space:nowrap;border:1px solid var(--dsw-alias-border-l2);border-radius:
|
|
1938
|
-
.ud-tip-title{font-weight:600}
|
|
1939
|
-
.ud-tip-row{display:flex;align-items:center;gap:
|
|
1940
|
-
.ud-tip-row--sub{padding-left:
|
|
1941
|
-
.ud-tip-breakdown{display:flex;flex-direction:column;gap:
|
|
2010
|
+
.ud-tip{position:fixed;z-index:${TIP_Z_INDEX};visibility:hidden;pointer-events:none;white-space:nowrap;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-overlay);box-shadow:0 1px 2px var(--dsw-alias-bg-mask-2),0 6px 20px var(--dsw-alias-bg-mask-2);color:var(--dsw-alias-label-primary);font-size:13px;padding:10px 12px;font-variant-numeric:tabular-nums}
|
|
2011
|
+
.ud-tip-title{font-weight:600;margin-bottom:6px}
|
|
2012
|
+
.ud-tip-row{display:flex;align-items:center;gap:8px;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary)}
|
|
2013
|
+
.ud-tip-row--sub{padding-left:14px;color:var(--dsw-alias-label-tertiary);font-size:11px}
|
|
2014
|
+
.ud-tip-breakdown{display:flex;flex-direction:column;gap:3px;border-top:1px solid var(--dsw-alias-border-l1);margin-top:8px;padding-top:8px}
|
|
1942
2015
|
.ud-axis-rate{fill:var(--dsw-alias-label-tertiary);font-size:11px;font-variant-numeric:tabular-nums}
|
|
1943
2016
|
.ud-bar{transform-box:fill-box;transform-origin:center}
|
|
1944
2017
|
.ud-bar-hit{fill:transparent;pointer-events:all}
|
|
@@ -1958,17 +2031,17 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
1958
2031
|
.ud-donut-center{font-size:18px;font-weight:600;fill:var(--dsw-alias-label-primary)}
|
|
1959
2032
|
.ud-donut-label{font-size:11px;fill:var(--dsw-alias-label-tertiary)}
|
|
1960
2033
|
.ud-models{flex:1 1 260px;min-width:240px;display:flex;flex-direction:column}
|
|
1961
|
-
.ud-model-row{display:flex;align-items:center;gap:8px;min-height:
|
|
2034
|
+
.ud-model-row{display:flex;align-items:center;gap:8px;min-height:48px;padding:2px 4px;border-bottom:1px solid var(--dsw-alias-border-l1)}
|
|
1962
2035
|
.ud-model-row--expand{cursor:pointer}
|
|
1963
2036
|
.ud-model-row--expand:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
1964
2037
|
.ud-model-swatch{width:10px;height:10px;border-radius:2px;flex:none}
|
|
1965
2038
|
.ud-model-id{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}
|
|
1966
|
-
.ud-model-name{font-size:
|
|
1967
|
-
.ud-model-provider{font-size:
|
|
2039
|
+
.ud-model-name{font-size:14px;font-weight:500;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
2040
|
+
.ud-model-provider{font-size:12px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
1968
2041
|
.ud-model-values{display:flex;flex-direction:column;align-items:flex-end;gap:1px;font-variant-numeric:tabular-nums;flex:none}
|
|
1969
2042
|
.ud-model-tokens{font-size:12px;color:var(--dsw-alias-label-secondary)}
|
|
1970
|
-
.ud-model-
|
|
1971
|
-
.ud-model-
|
|
2043
|
+
.ud-model-io{font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
|
|
2044
|
+
.ud-model-meta{font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
|
|
1972
2045
|
.ud-model-toggle{border:none;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;padding:2px 6px;font-size:14px;line-height:1;transition:transform .2s ease}
|
|
1973
2046
|
.ud-model-toggle[aria-expanded="true"]{transform:rotate(90deg)}
|
|
1974
2047
|
.ud-model-toggle:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary);border-radius:4px}
|
|
@@ -2178,9 +2251,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2178
2251
|
const hoverTtftPoint = hoverSlot ? ttftPoints.find((point) => point.day === hoverSlot.day) : null
|
|
2179
2252
|
const pick = (index) => (event) => setHover({ index, anchor: pointerAt(event) })
|
|
2180
2253
|
const clear = () => setHover(null)
|
|
2181
|
-
const
|
|
2182
|
-
? Object.entries(hoverSlot.otherByModel ?? {}).sort((a, b) => b[1] - a[1])
|
|
2183
|
-
: []
|
|
2254
|
+
const tipEntries = hasTipContent(hoverSlot) ? tipModelEntries(hoverSlot, visibleSet) : null
|
|
2184
2255
|
return h('div', { className: 'ud-section' },
|
|
2185
2256
|
h('div', { className: 'ud-section-head' },
|
|
2186
2257
|
h('span', { className: 'ud-section-title' }, title),
|
|
@@ -2249,15 +2320,15 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2249
2320
|
onItem: (key, ctrl) => setVisibleKeys(legendToggle(visibleKeys, key, ctrl)),
|
|
2250
2321
|
}),
|
|
2251
2322
|
h(ChartTip, { anchor: hover ? hover.anchor : null, panelRef },
|
|
2252
|
-
|
|
2323
|
+
tipEntries
|
|
2253
2324
|
? [
|
|
2254
2325
|
h('div', { key: 'title', className: 'ud-tip-title' }, slotLabelFor ? slotLabelFor(hoverSlot.day) : hoverSlot.day),
|
|
2255
2326
|
h('div', { key: 'total', className: 'ud-tip-row' }, `${t('total')}: ${formatTokens(hoverSlot.total)}`),
|
|
2256
|
-
...
|
|
2257
|
-
h('i', { className: 'ud-legend-swatch', style: { background: colorFor(
|
|
2258
|
-
`${
|
|
2259
|
-
...
|
|
2260
|
-
`${model}: ${formatTokens(tokens)}`))
|
|
2327
|
+
...tipEntries.main.map((row) => h('div', { key: `m-${row.model}`, className: 'ud-tip-row' },
|
|
2328
|
+
h('i', { className: 'ud-legend-swatch', style: { background: colorFor(row.model) } }),
|
|
2329
|
+
`${row.model === OTHER_MODEL ? t('other') : row.model}: ${formatTokens(row.tokens)}`)),
|
|
2330
|
+
...tipEntries.other.map(([model, tokens]) => h('div', { key: `om-${model}`, className: 'ud-tip-row ud-tip-row--sub' },
|
|
2331
|
+
`${model}: ${formatTokens(tokens)}`)),
|
|
2261
2332
|
showRate ? h('div', { key: 'rate', className: 'ud-tip-row' }, `${t('cacheHitRate')}: ${cacheRateText(hoverSlot.cacheHit, hoverSlot.cacheMiss)}`) : null,
|
|
2262
2333
|
showSpeed ? h('div', { key: 'speed', className: 'ud-tip-row' }, `${t('avgSpeed')}: ${speedTipText(hoverSlot.speed)}`) : null,
|
|
2263
2334
|
showTtft ? h('div', { key: 'ttft', className: 'ud-tip-row' }, `${t('ttftLegend')}: ${ttftTipText(hoverSlot.ttft, t)}`) : null,
|
|
@@ -2301,15 +2372,17 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2301
2372
|
observer.disconnect()
|
|
2302
2373
|
}
|
|
2303
2374
|
}, [place])
|
|
2375
|
+
// 空内容(无子节点或空数组)不显示悬浮盒,防零尺寸守卫下出现纯背景空壳
|
|
2376
|
+
const show = Boolean(anchor && children != null && (!Array.isArray(children) || children.length > 0))
|
|
2304
2377
|
const element = h('div', {
|
|
2305
2378
|
className: 'ud-tip',
|
|
2306
2379
|
ref: tipRef,
|
|
2307
2380
|
style: {
|
|
2308
2381
|
left: pos ? pos.left : 0,
|
|
2309
2382
|
top: pos ? pos.top : 0,
|
|
2310
|
-
visibility:
|
|
2383
|
+
visibility: show && pos ? 'visible' : 'hidden',
|
|
2311
2384
|
},
|
|
2312
|
-
},
|
|
2385
|
+
}, show ? children : null)
|
|
2313
2386
|
return createPortal && typeof document !== 'undefined' && document.body
|
|
2314
2387
|
? createPortal(element, document.body)
|
|
2315
2388
|
: element
|
|
@@ -2422,6 +2495,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2422
2495
|
const isOther = item.model === OTHER_MODEL
|
|
2423
2496
|
const speedText = modelSpeedText(item.speed)
|
|
2424
2497
|
const ttftText = modelTtftText(item.ttft)
|
|
2498
|
+
const ioText = modelIoPercentText(item, t)
|
|
2425
2499
|
const metaParts = [
|
|
2426
2500
|
item.cost !== undefined ? `≈ ${formatCost(item.cost, costCurrency)}` : null,
|
|
2427
2501
|
ttftText,
|
|
@@ -2436,8 +2510,12 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2436
2510
|
},
|
|
2437
2511
|
h('i', { className: 'ud-model-swatch', style: { background: colorFor(item.model) } }),
|
|
2438
2512
|
h('div', { className: 'ud-model-id' },
|
|
2439
|
-
|
|
2440
|
-
|
|
2513
|
+
isOther
|
|
2514
|
+
? h('span', { className: 'ud-model-name' }, displayName(item.model))
|
|
2515
|
+
: [
|
|
2516
|
+
h('span', { className: 'ud-model-provider' }, providerOf(item.model)),
|
|
2517
|
+
h('span', { className: 'ud-model-name' }, modelNameOf(item.model)),
|
|
2518
|
+
]),
|
|
2441
2519
|
isOther
|
|
2442
2520
|
? h('button', {
|
|
2443
2521
|
className: 'ud-model-toggle', 'aria-expanded': expandedOther, 'aria-label': t('other'),
|
|
@@ -2450,7 +2528,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2450
2528
|
h('div', { className: 'ud-model-values' },
|
|
2451
2529
|
h('span', { className: 'ud-model-tokens' },
|
|
2452
2530
|
`${formatTokens(item.tokens)} (${formatPercent((item.tokens / total) * PERCENT_SCALE)})`),
|
|
2453
|
-
h('span', { className: 'ud-model-
|
|
2531
|
+
ioText ? h('span', { className: 'ud-model-io' }, ioText) : null,
|
|
2532
|
+
h('span', { className: 'ud-model-meta' }, metaParts.join(' · ')))),
|
|
2454
2533
|
isOther
|
|
2455
2534
|
? h('div', { className: cx('ud-model-other', expandedOther && 'ud-model-other--open') },
|
|
2456
2535
|
h('div', { className: 'ud-model-other-list' },
|
|
@@ -2469,6 +2548,14 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2469
2548
|
h('div', { key: 'title', className: 'ud-tip-title' }, displayName(tipSegment.model)),
|
|
2470
2549
|
h('div', { key: 'tokens', className: 'ud-tip-row' }, `${t('total')}: ${formatTokens(tipSegment.tokens)}`),
|
|
2471
2550
|
h('div', { key: 'pct', className: 'ud-tip-row' }, `${t('percent')}: ${formatPercent(tipSegment.percent)}`),
|
|
2551
|
+
hasTokenBuckets(tipSegment)
|
|
2552
|
+
? [
|
|
2553
|
+
h('div', { key: 'input', className: 'ud-tip-row' }, `${t('priceInput')}: ${formatTokens(tipSegment.inputTokens)}`),
|
|
2554
|
+
h('div', { key: 'cacheRead', className: 'ud-tip-row' }, `${t('priceCacheRead')}: ${formatTokens(tipSegment.cacheReadTokens)}`),
|
|
2555
|
+
h('div', { key: 'cacheWrite', className: 'ud-tip-row' }, `${t('priceCacheWrite')}: ${formatTokens(tipSegment.cacheWriteTokens)}`),
|
|
2556
|
+
h('div', { key: 'output', className: 'ud-tip-row' }, `${t('priceOutput')}: ${formatTokens(tipSegment.outputTokens)}`),
|
|
2557
|
+
]
|
|
2558
|
+
: null,
|
|
2472
2559
|
tipSegment.model === OTHER_MODEL && (tipSegment.items ?? []).length > 0
|
|
2473
2560
|
? h('div', { key: 'breakdown', className: 'ud-tip-breakdown' },
|
|
2474
2561
|
tipSegment.items.map((detail) => h('div', { key: detail.model, className: 'ud-tip-row ud-tip-row--sub' },
|
|
@@ -2670,6 +2757,17 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2670
2757
|
onChange: (event) => patchCondition({ [field]: event.target.value }),
|
|
2671
2758
|
}),
|
|
2672
2759
|
errorTextOf(`${condPath}.${field}`))
|
|
2760
|
+
// 时段自绘 HH:MM 输入:原生 time 控件段位由浏览器按 locale 决定,部分环境渲染秒段,产出带秒值被校验拒绝
|
|
2761
|
+
const hhmmInput = (field, labelKey) => h('label', { key: field, className: 'ud-field' },
|
|
2762
|
+
h('span', { className: 'ud-field-label' }, t(labelKey)),
|
|
2763
|
+
h('input', {
|
|
2764
|
+
type: 'text', className: 'ud-input', inputMode: 'numeric', autoComplete: 'off',
|
|
2765
|
+
spellCheck: false, placeholder: HHMM_PLACEHOLDER,
|
|
2766
|
+
size: HHMM_INPUT_SIZE,
|
|
2767
|
+
value: condition[field] ?? '',
|
|
2768
|
+
onChange: (event) => patchCondition({ [field]: normalizeHHMMInput(event.target.value) }),
|
|
2769
|
+
}),
|
|
2770
|
+
errorTextOf(`${condPath}.${field}`))
|
|
2673
2771
|
// 号段 from/to 同域 1~31(双闭),统一上限
|
|
2674
2772
|
const numberInput = (field, labelKey) => h('label', { key: field, className: 'ud-field' },
|
|
2675
2773
|
h('span', { className: 'ud-field-label' }, t(labelKey)),
|
|
@@ -2694,7 +2792,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2694
2792
|
}, t(`weekday.${day}`))
|
|
2695
2793
|
}))
|
|
2696
2794
|
const fields = {
|
|
2697
|
-
dailyWindow: [
|
|
2795
|
+
dailyWindow: [hhmmInput('from', 'condFrom'), hhmmInput('to', 'condTo')],
|
|
2698
2796
|
weekdays: [weekdaysPills()],
|
|
2699
2797
|
monthDays: [numberInput('from', 'condFrom'), numberInput('to', 'condTo')],
|
|
2700
2798
|
dateRange: [textInput('from', 'date', 'condFrom'), textInput('to', 'date', 'condTo')],
|
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
|
+
}
|
package/test/client.test.mjs
CHANGED
|
@@ -67,6 +67,9 @@ const {
|
|
|
67
67
|
matchPrice,
|
|
68
68
|
maxSlotsFor,
|
|
69
69
|
minuteTickLabel,
|
|
70
|
+
modelIoPercentText,
|
|
71
|
+
hasTipContent,
|
|
72
|
+
tipModelEntries,
|
|
70
73
|
modelSegmentLabel,
|
|
71
74
|
modelSpeedText,
|
|
72
75
|
modelNameOf,
|
|
@@ -88,6 +91,7 @@ const {
|
|
|
88
91
|
resolvePointQuery,
|
|
89
92
|
formatDateTimeInput,
|
|
90
93
|
parseLocalDateTime,
|
|
94
|
+
normalizeHHMMInput,
|
|
91
95
|
shortDay,
|
|
92
96
|
smoothPath,
|
|
93
97
|
tipPlace,
|
|
@@ -810,6 +814,56 @@ test('donutSegments 全零 total 回落下限防除零', () => {
|
|
|
810
814
|
assert.equal(segments[0].percent, 0)
|
|
811
815
|
})
|
|
812
816
|
|
|
817
|
+
test('groupStats 哨兵折叠四桶求和,top 模型四桶透传', () => {
|
|
818
|
+
// Given 超 top 上限模型条目带四桶 When 分组 Then 哨兵四桶为 rest 求和,top 条目原字段保留
|
|
819
|
+
const stats = {
|
|
820
|
+
models: [
|
|
821
|
+
{ model: 'p/m1', tokens: 500, inputTokens: 300, outputTokens: 200, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
|
822
|
+
{ model: 'p/m2', tokens: 400, inputTokens: 100, outputTokens: 300, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
|
823
|
+
{ model: 'p/m3', tokens: 300, inputTokens: 0, outputTokens: 100, cacheReadTokens: 150, cacheWriteTokens: 50 },
|
|
824
|
+
{ model: 'p/m4', tokens: 200, inputTokens: 80, outputTokens: 120, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
|
825
|
+
{ model: 'p/m5', tokens: 100, inputTokens: 60, outputTokens: 40, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
|
826
|
+
{ model: 'p/m6', tokens: 50, inputTokens: 10, outputTokens: 30, cacheReadTokens: 5, cacheWriteTokens: 5 },
|
|
827
|
+
],
|
|
828
|
+
daily: [],
|
|
829
|
+
}
|
|
830
|
+
const grouped = groupStats(stats)
|
|
831
|
+
assert.equal(grouped.models[0].inputTokens, 300)
|
|
832
|
+
assert.equal(grouped.models[0].outputTokens, 200)
|
|
833
|
+
assert.deepEqual(grouped.models[5], {
|
|
834
|
+
model: OTHER_MODEL,
|
|
835
|
+
tokens: 50,
|
|
836
|
+
inputTokens: 10,
|
|
837
|
+
outputTokens: 30,
|
|
838
|
+
cacheReadTokens: 5,
|
|
839
|
+
cacheWriteTokens: 5,
|
|
840
|
+
items: [stats.models[5]],
|
|
841
|
+
})
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
test('groupStats 哨兵费用同源:无 cost 输入不挂 cost,有 cost 求和', () => {
|
|
845
|
+
// Given rest 条目无 cost When 折叠 Then 哨兵无 cost 字段;Given rest 带 cost When 折叠 Then 哨兵求和
|
|
846
|
+
const bare = groupStats({ models: [
|
|
847
|
+
{ model: 'p/m1', tokens: 500 },
|
|
848
|
+
{ model: 'p/m2', tokens: 400 },
|
|
849
|
+
{ model: 'p/m3', tokens: 300 },
|
|
850
|
+
{ model: 'p/m4', tokens: 200 },
|
|
851
|
+
{ model: 'p/m5', tokens: 100 },
|
|
852
|
+
{ model: 'p/m6', tokens: 50 },
|
|
853
|
+
], daily: [] })
|
|
854
|
+
assert.equal('cost' in bare.models[5], false)
|
|
855
|
+
const priced = groupStats({ models: [
|
|
856
|
+
{ model: 'p/m1', tokens: 500, cost: 1 },
|
|
857
|
+
{ model: 'p/m2', tokens: 400, cost: 2 },
|
|
858
|
+
{ model: 'p/m3', tokens: 300, cost: undefined },
|
|
859
|
+
{ model: 'p/m4', tokens: 200, cost: 4 },
|
|
860
|
+
{ model: 'p/m5', tokens: 100, cost: 8 },
|
|
861
|
+
{ model: 'p/m6', tokens: 50, cost: 16 },
|
|
862
|
+
{ model: 'p/m7', tokens: 25, cost: 32 },
|
|
863
|
+
], daily: [] })
|
|
864
|
+
assert.equal(priced.models[5].cost, 16 + 32)
|
|
865
|
+
})
|
|
866
|
+
|
|
813
867
|
test('groupStats 哨兵保留其他模型明细供展开与提示', () => {
|
|
814
868
|
const stats = {
|
|
815
869
|
models: [
|
|
@@ -824,6 +878,8 @@ test('groupStats 哨兵保留其他模型明细供展开与提示', () => {
|
|
|
824
878
|
}
|
|
825
879
|
const grouped = groupStats(stats)
|
|
826
880
|
assert.deepEqual(grouped.models[5].items, [{ model: 'p/m6', tokens: 50 }])
|
|
881
|
+
// Given 旧形条目无四桶 When 折叠 Then 哨兵不合成零值四桶,与 top 条目降级形态一致
|
|
882
|
+
assert.equal('inputTokens' in grouped.models[5], false)
|
|
827
883
|
})
|
|
828
884
|
|
|
829
885
|
test('groupStats 逐日保留其他明细映射供 tooltip', () => {
|
|
@@ -856,6 +912,45 @@ test('模型速度文本:官方吞吐口径格式化,无速度为空串', () =>
|
|
|
856
912
|
assert.equal(modelSpeedText(undefined), '')
|
|
857
913
|
})
|
|
858
914
|
|
|
915
|
+
test('模型输入输出占比构成:缓存/输入/输出 三段各占该模型 token 总量', () => {
|
|
916
|
+
// Given 四桶齐备的模型条目 When 格式化 Then 三段占比相对 tokens 总量,缓存=读+写,合计 100%,顺序缓存/输入/输出
|
|
917
|
+
const zhT = createTranslator(MESSAGES_ZH)
|
|
918
|
+
const enT = createTranslator(MESSAGES_EN)
|
|
919
|
+
const item = { tokens: 1000, inputTokens: 31, cacheReadTokens: 954, cacheWriteTokens: 10, outputTokens: 5 }
|
|
920
|
+
assert.equal(modelIoPercentText(item, zhT), '缓存 96.4% · 输入 3.1% · 输出 0.5%')
|
|
921
|
+
assert.equal(modelIoPercentText(item, enT), 'Cache 96.4% · Input 3.1% · Output 0.5%')
|
|
922
|
+
})
|
|
923
|
+
|
|
924
|
+
test('趋势 tooltip 模型行:仅含可见且当前时段有用量的模型', () => {
|
|
925
|
+
// Given 槽内 byModel 含零值与非零值,OTHER 明细含零值与无序多元素 When 取行数据 Then 主行与子行均过滤零用量,子行按 tokens 降序,OTHER 不可见时子行为空
|
|
926
|
+
const hoverSlot = {
|
|
927
|
+
total: 300,
|
|
928
|
+
byModel: { 'p/a': 200, 'p/b': 0, [OTHER_MODEL]: 100 },
|
|
929
|
+
otherByModel: { 'p/y': 0, 'p/x': 20, 'p/z': 50 },
|
|
930
|
+
}
|
|
931
|
+
const visible = new Set(['p/a', 'p/b', OTHER_MODEL])
|
|
932
|
+
const rows = tipModelEntries(hoverSlot, visible)
|
|
933
|
+
assert.deepEqual(rows.main, [{ model: 'p/a', tokens: 200 }, { model: OTHER_MODEL, tokens: 100 }])
|
|
934
|
+
assert.deepEqual(rows.other, [['p/z', 50], ['p/x', 20]])
|
|
935
|
+
const hiddenOther = tipModelEntries(hoverSlot, new Set(['p/a']))
|
|
936
|
+
assert.deepEqual(hiddenOther.main, [{ model: 'p/a', tokens: 200 }])
|
|
937
|
+
assert.deepEqual(hiddenOther.other, [])
|
|
938
|
+
})
|
|
939
|
+
|
|
940
|
+
test('趋势 tooltip 内容门:仅 total>0 的槽渲染', () => {
|
|
941
|
+
// Given 全零槽/常规槽 When 判定 Then 全零槽无内容,常规槽有内容,null 槽无内容
|
|
942
|
+
assert.equal(hasTipContent(null), false)
|
|
943
|
+
assert.equal(hasTipContent({ total: 0 }), false)
|
|
944
|
+
assert.equal(hasTipContent({ total: 5 }), true)
|
|
945
|
+
})
|
|
946
|
+
|
|
947
|
+
test('模型输入输出占比构成:缺四桶或零总量为空串', () => {
|
|
948
|
+
const zhT = createTranslator(MESSAGES_ZH)
|
|
949
|
+
assert.equal(modelIoPercentText({ tokens: 100 }, zhT), '')
|
|
950
|
+
assert.equal(modelIoPercentText(undefined, zhT), '')
|
|
951
|
+
assert.equal(modelIoPercentText({ tokens: 0, inputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0 }, zhT), '')
|
|
952
|
+
})
|
|
953
|
+
|
|
859
954
|
test('模型首字文本:语言中立短时长,无 ttft 为空串', () => {
|
|
860
955
|
assert.equal(modelTtftText(200), 'TTFT 0.2s')
|
|
861
956
|
assert.equal(modelTtftText(162000), 'TTFT 2m42s')
|
|
@@ -971,6 +1066,40 @@ test('validatePricingRules 拒绝非法时刻与时刻字段缺失', () => {
|
|
|
971
1066
|
assert.equal(errors.has('4.conditions.0'), false)
|
|
972
1067
|
})
|
|
973
1068
|
|
|
1069
|
+
test('normalizeHHMMInput 满四位纯数字补冒号成型', () => {
|
|
1070
|
+
// Given 值为纯数字串且位数达四位 When 净化 Then 末两位前插冒号;纯数字秒串(140000)粘贴同路径自愈
|
|
1071
|
+
assert.equal(normalizeHHMMInput('0830'), '08:30')
|
|
1072
|
+
assert.equal(normalizeHHMMInput('140000'), '14:00')
|
|
1073
|
+
assert.equal(normalizeHHMMInput('12345'), '12:34')
|
|
1074
|
+
})
|
|
1075
|
+
|
|
1076
|
+
test('normalizeHHMMInput 三段时刻截取前两段', () => {
|
|
1077
|
+
// Given 粘贴带秒串(HH:MM:SS)When 净化 Then 截取时:分两段自愈
|
|
1078
|
+
assert.equal(normalizeHHMMInput('14:00:00'), '14:00')
|
|
1079
|
+
assert.equal(normalizeHHMMInput('8:05:59'), '8:05')
|
|
1080
|
+
assert.equal(normalizeHHMMInput('14:00:0'), '14:00:0')
|
|
1081
|
+
})
|
|
1082
|
+
|
|
1083
|
+
test('normalizeHHMMInput 其余形态原样放行', () => {
|
|
1084
|
+
// Given 中间插字重排(08:530)或杂串或未满四位 When 净化 Then 不插手,交保存校验显式报错而非静默重排
|
|
1085
|
+
assert.equal(normalizeHHMMInput('08:530'), '08:530')
|
|
1086
|
+
assert.equal(normalizeHHMMInput('2026-03-15 14:00'), '2026-03-15 14:00')
|
|
1087
|
+
assert.equal(normalizeHHMMInput('083'), '083')
|
|
1088
|
+
assert.equal(normalizeHHMMInput('8:3'), '8:3')
|
|
1089
|
+
assert.equal(normalizeHHMMInput('14:00'), '14:00')
|
|
1090
|
+
assert.equal(normalizeHHMMInput(''), '')
|
|
1091
|
+
assert.equal(normalizeHHMMInput(null), '')
|
|
1092
|
+
assert.equal(normalizeHHMMInput(undefined), '')
|
|
1093
|
+
})
|
|
1094
|
+
|
|
1095
|
+
test('normalizeHHMMInput 全角数字折半角后走同规则', () => {
|
|
1096
|
+
// Given IME 全角数字输入 When 净化 Then 先折半角再走成型规则,输出恒为半角
|
|
1097
|
+
assert.equal(normalizeHHMMInput('14:00'), '14:00')
|
|
1098
|
+
assert.equal(normalizeHHMMInput('14:00:00'), '14:00')
|
|
1099
|
+
assert.equal(normalizeHHMMInput('1400'), '14:00')
|
|
1100
|
+
assert.equal(normalizeHHMMInput('083'), '083')
|
|
1101
|
+
})
|
|
1102
|
+
|
|
974
1103
|
test('validatePricingRules 接受 from===to 单点与单日', () => {
|
|
975
1104
|
// Given 双闭下时段/号段/日期段 from===to When 校验 Then 全部合法
|
|
976
1105
|
const rules = [
|
package/test/query.test.mjs
CHANGED
|
@@ -117,8 +117,26 @@ test('D 粒度聚合总量排序与占比正确', () => {
|
|
|
117
117
|
assert.equal(out.topModel, 'deepseek/deepseek-chat')
|
|
118
118
|
assert.equal(out.topProvider, 'deepseek')
|
|
119
119
|
assert.deepEqual(out.models, [
|
|
120
|
-
{
|
|
121
|
-
|
|
120
|
+
{
|
|
121
|
+
model: 'deepseek/deepseek-chat',
|
|
122
|
+
provider: 'deepseek',
|
|
123
|
+
tokens: 350,
|
|
124
|
+
percent: (350 / 410) * 100,
|
|
125
|
+
inputTokens: 100,
|
|
126
|
+
outputTokens: 50,
|
|
127
|
+
cacheReadTokens: 200,
|
|
128
|
+
cacheWriteTokens: 0,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
model: 'deepseek-chat',
|
|
132
|
+
provider: 'default',
|
|
133
|
+
tokens: 60,
|
|
134
|
+
percent: (60 / 410) * 100,
|
|
135
|
+
inputTokens: 10,
|
|
136
|
+
outputTokens: 20,
|
|
137
|
+
cacheReadTokens: 0,
|
|
138
|
+
cacheWriteTokens: 30,
|
|
139
|
+
},
|
|
122
140
|
])
|
|
123
141
|
assert.deepEqual(out.providers, [
|
|
124
142
|
{ provider: 'deepseek', tokens: 350, percent: (350 / 410) * 100 },
|
|
@@ -351,6 +369,38 @@ test('ttft 模型级聚合:加权平均,无 ttft 行不参与,无数据不挂字
|
|
|
351
369
|
assert.equal('ttft' in m2, false)
|
|
352
370
|
})
|
|
353
371
|
|
|
372
|
+
test('模型级四桶聚合:models 条目带输入输出与缓存拆分,按该模型行累加', () => {
|
|
373
|
+
// Given 两模型多行四桶用量 When D 粒度聚合 Then models 每项四桶为该模型行累加,排序与占比不变
|
|
374
|
+
const rows = [
|
|
375
|
+
makeRow({ bucket: '2020-01-01', model: 'm1', provider: 'p1', inputTokens: 100, outputTokens: 50, cacheReadTokens: 200, cacheWriteTokens: 10 }),
|
|
376
|
+
makeRow({ bucket: '2020-01-02', model: 'm1', provider: 'p1', inputTokens: 30, outputTokens: 70 }),
|
|
377
|
+
makeRow({ bucket: '2020-01-01', model: 'm2', provider: 'p2', inputTokens: 5, outputTokens: 25, cacheWriteTokens: 8 }),
|
|
378
|
+
]
|
|
379
|
+
const out = aggregateRange(rows, 'D', '2020-01-01', '2020-01-02')
|
|
380
|
+
assert.deepEqual(out.models, [
|
|
381
|
+
{
|
|
382
|
+
model: 'm1',
|
|
383
|
+
provider: 'p1',
|
|
384
|
+
tokens: 460,
|
|
385
|
+
percent: (460 / 498) * 100,
|
|
386
|
+
inputTokens: 130,
|
|
387
|
+
outputTokens: 120,
|
|
388
|
+
cacheReadTokens: 200,
|
|
389
|
+
cacheWriteTokens: 10,
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
model: 'm2',
|
|
393
|
+
provider: 'p2',
|
|
394
|
+
tokens: 38,
|
|
395
|
+
percent: (38 / 498) * 100,
|
|
396
|
+
inputTokens: 5,
|
|
397
|
+
outputTokens: 25,
|
|
398
|
+
cacheReadTokens: 0,
|
|
399
|
+
cacheWriteTokens: 8,
|
|
400
|
+
},
|
|
401
|
+
])
|
|
402
|
+
})
|
|
403
|
+
|
|
354
404
|
test('槽级 ttft 聚合:同槽配对,无 ttft 槽不挂字段,attachCosts 保留', () => {
|
|
355
405
|
const rows = [
|
|
356
406
|
makeRow({ bucket: '2020-01-01', model: 'm1', provider: 'p1', outputTokens: 20, ttftMs: 2000, ttftSteps: 1 }),
|
|
@@ -373,7 +423,7 @@ test('attachCosts H 槽按桶起点计价并归集 totals 与 models', () => {
|
|
|
373
423
|
assert.equal(out.daily[1].cost, 2)
|
|
374
424
|
assert.equal(out.cost, 2)
|
|
375
425
|
assert.equal(out.unpriced, 0)
|
|
376
|
-
assert.deepEqual(out.models, [{ model: 'm1', provider: 'p1', tokens: 1500000, percent: 100, cost: 2 }])
|
|
426
|
+
assert.deepEqual(out.models, [{ model: 'm1', provider: 'p1', tokens: 1500000, inputTokens: 1000000, outputTokens: 500000, cacheReadTokens: 0, cacheWriteTokens: 0, percent: 100, cost: 2 }])
|
|
377
427
|
assert.equal(out.from, '2020-01-01T00')
|
|
378
428
|
assert.equal(out.tokens, 1500000)
|
|
379
429
|
// 纯函数:入参 result 不被修改
|
package/test/turn-tail.test.mjs
CHANGED
|
@@ -151,6 +151,22 @@ test('反查:容器换引用(快照更替)后新节点可见', () => {
|
|
|
151
151
|
assert.equal(turnTokenUsageOfMessage(oldNodes, 'm-1'), oldUsage)
|
|
152
152
|
})
|
|
153
153
|
|
|
154
|
+
test('反查:仓库身份恒定而节点集更替时新增回合可见', () => {
|
|
155
|
+
// Given 官方 chat 仓库语义:store 实例身份跨回合恒定,values() 返回的节点集数组随 upsert 换新引用
|
|
156
|
+
const usage1 = { uncachedInputTokens: 1, outputTokens: 1, totalTokens: 2 }
|
|
157
|
+
const usage2 = { uncachedInputTokens: 7, outputTokens: 7, totalTokens: 14 }
|
|
158
|
+
let snapshot = [tailNode('m-1', usage1)]
|
|
159
|
+
const store = { values: () => snapshot }
|
|
160
|
+
assert.equal(turnUsageSourceOfMessage(store, 'm-1'), usage1)
|
|
161
|
+
// When 第二回合 turn-tail 节点入库(同一 store,节点集数组换引用)
|
|
162
|
+
snapshot = [...snapshot, tailNode('m-2', usage2)]
|
|
163
|
+
// Then 新回合反查命中,不依赖页面刷新重建索引
|
|
164
|
+
assert.equal(turnUsageSourceOfMessage(store, 'm-2'), usage2)
|
|
165
|
+
assert.equal(turnTokenUsageOfMessage(store, 'm-2'), usage2)
|
|
166
|
+
// 旧回合反查不受重建影响
|
|
167
|
+
assert.equal(turnUsageSourceOfMessage(store, 'm-1'), usage1)
|
|
168
|
+
})
|
|
169
|
+
|
|
154
170
|
test('计价模型键:双全拼两段,仅 model 用裸名,双缺回退全通配键', () => {
|
|
155
171
|
assert.equal(turnModelOf({ routes: [{ provider: 'p', model: 'm' }] }), 'p/m')
|
|
156
172
|
assert.equal(turnModelOf({ routes: [{ model: 'm' }] }), 'm')
|