@mzzsfy/dsh-usage-dash 0.7.0 → 0.8.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 +5 -5
- package/package.json +1 -1
- package/src/client.js +160 -37
- package/src/collector.js +14 -2
- package/src/query.js +27 -15
- package/test/client.test.mjs +140 -0
- package/test/collector.test.mjs +24 -0
- package/test/query.test.mjs +50 -0
package/README.md
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
|
|
9
9
|
- 三粒度视图,全部支持自定义时间范围:天(7/30/90 天/自定义日期段)、小时(24 小时/3 天/7 天/15 天/自定义时刻段)、分钟(3 小时/24 小时/3 天/7 天/自定义时刻段,10 分钟桶粒度),预设挡为滚动窗口;自定义时刻段为起止 datetime 双输入,两端归一到桶边界(小时取所在整点、分钟起点对齐 10 分钟且终点保留原分钟,均闭区间),跨度上限与数据保留期一致(小时 15 天、分钟 7 天,超限自动钳起点),首次切到自定义挡预填最近 24 小时,时/分两视图共享同一对自定义输入;已知限制:自定义输入残缺(任一端为空)时面板不发请求且图表静默空白,与天视图行为一致,交互反馈留待后续增强
|
|
10
10
|
- 汇总卡六张:Tokens 用量(服务商总口径)、会话数量、请求数量、最常用模型、平均缓存命中率、活跃天数(恒按天口径,不随视图切换);配置定价规则后 Tokens 卡头部行右侧显示估算费用
|
|
11
|
-
- 活动热力图:GitHub 风格周列×星期行,26
|
|
11
|
+
- 活动热力图:GitHub 风格周列×星期行,26 周窗口,五档色阶,悬停明细(配置定价规则后附「费用 ≈」估算行,受「费用显示」开关;费用数据依赖小时桶保留期,仅最近 15 天的天显示,更早的天无该行);仅按天视图展示
|
|
12
12
|
- 缓存命中率曲线:日粒度命中率 + 右侧副轴,并叠加平均生成速度曲线与首 token 延迟曲线(各自单独颜色,读数走悬停;仅含配对数据的槽参与,整图无数据不绘制),悬停显示当前时段命中率、平均生成速度、首 token 延迟与 token 明细(配置定价规则后附当前时段估算费用行,受「费用显示」开关;三粒度趋势图通用)
|
|
13
13
|
- 模型 donut 与列表:按 token 前 5 模型占比环形图(中心为总量);每行名称两行展示(厂商/模型名,其余折叠为「其他」),右侧三排:第一排 token 数·占比,第二排输入输出占比构成(缓存/输入/输出 三段各占该模型 token 总量,缓存=读+写,三段合计 100%,无四桶数据不显示),第三排估算费用·TTFT·生成速度(费用需配置定价规则,TTFT/速度无配对数据不显示),悬停明细含输入/缓存读/缓存写/输出四桶 token 拆分并联动
|
|
14
|
-
- 三粒度堆叠柱状趋势图(数据量大时裁最旧并提示);图例可点击切换显隐:点击单选(仅显示该项,左轴刻度按可见项归一)/再点恢复全部,Ctrl+点击多选,隐藏最后一项无效;悬停明细按当前时段数据展示,零用量模型不显示,全零时段不显示悬浮框
|
|
15
|
-
- 回扫状态行默认隐藏:首次启用自动回扫历史会话,运行中显示进度(常显);右上角为折叠箭头与刷新图标,折叠层内展开扫描异常日志块(逐条:时间/类型/明细,计数即明细条数,上限 200 条超限丢最旧,重建时清空)与「重建」入口(二次确认 3 秒后清库重扫)
|
|
14
|
+
- 三粒度堆叠柱状趋势图(数据量大时裁最旧并提示);节头「金额」开关把柱状图从 token 消耗切到金额消耗——柱按模型堆叠估算费用,标题与左轴同步切金额读数(悬停明细保持 token 口径与估算费用行不变,命中率/速度/首 token 延迟曲线不受影响),三粒度通用;开关仅在存在正计价数据时显示(未配置规则或全量未计价时不渲染,避免全零金额柱误导);图例可点击切换显隐:点击单选(仅显示该项,左轴刻度按可见项归一)/再点恢复全部,Ctrl+点击多选,隐藏最后一项无效;悬停明细按当前时段数据展示,零用量模型不显示,全零时段不显示悬浮框
|
|
15
|
+
- 回扫状态行默认隐藏:首次启用自动回扫历史会话,运行中显示进度(常显);右上角为折叠箭头与刷新图标,折叠层内展开扫描异常日志块(逐条:时间/类型/明细,计数即明细条数,上限 200 条超限丢最旧,重建时清空)与「重建」入口(二次确认 3 秒后清库重扫),采集错误常显;折叠且有待读信息(异常日志或写入失败)时箭头带红点提醒,展开后消失;工具栏分主区与右侧操作区,窄宽度时挡位组在主区内换行
|
|
16
16
|
- 会话底栏接管:替换官方信息行为增强版,偏好卡三个开关默认全开——精确缓存命中率(两位小数)、会话 Token 明细(总/命中缓存/未命中缓存/输出)、费用显示(底栏费用项、趋势 tooltip 费用行与回合费用芯片);三开关全关时与官方逐字节一致
|
|
17
17
|
- 回合费用芯片:经官方 `conversation.chat.assistant-actions` 槽注入动作行(复制与分支图标之间,官方赞/踩与上下文跳转同排),每轮对话结束后显示该轮估算费用(悬停 title 带 token 摘要与估算口径;显隐节奏随官方动作行——最新一轮常显,历史轮悬停显现;受「费用显示」开关;官方用量芯片弹窗已有 token 明细,芯片只承载费用)。旧宿主无该插槽时告警禁用
|
|
18
18
|
- 定价规则编辑器:设置面板内按**模型分组**编辑,组头 = 模型键(整组一次改名,改后即时重新聚合)+ 删除整组;组内 = **默认价**槽(价格四桶,不设条件——附加规则全不命中时兜底,禁排序/删除)+ **附加计费规则**列表(四桶单价 + 条件组合,「+添加条件」追加条件行,行内下拉切换类型,规则带 ↑/↓ 调序与删除),「添加额外计费规则」追加一条(默认带全天时段条件);货币为全局切换(「定价规则」标题右侧,仅 ¥/$ 两档,整表统一),显式「保存」整表写入
|
|
@@ -31,12 +31,12 @@
|
|
|
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 号自然不触发
|
|
38
38
|
- `dateRange`:`{ from: 'YYYY-MM-DD', to: 'YYYY-MM-DD' }` 零填充字典序双侧包含,from===to 单日,from>to 倒序不成立(不可保存)
|
|
39
|
-
- 费用精度:**小时级**——聚合按小时桶起点时刻匹配价格,分钟槽费用由其所属小时桶价格导出;改价即时生效,历史费用下次查询按新规则重算(不回溯账单)
|
|
39
|
+
- 费用精度:**小时级**——聚合按小时桶起点时刻匹配价格,分钟槽费用由其所属小时桶价格导出;改价即时生效,历史费用下次查询按新规则重算(不回溯账单)。定价激活时聚合响应在槽级携带 `cost` 与逐模型拆分 `costByModel`(金额柱状图与悬浮读数的数据源)
|
|
40
40
|
- 时区口径:匹配与聚合均用 host 进程本地时区;client 侧注入点(底栏费用项/回合费用芯片)按**浏览器本地时区**的当前时刻评估条件,跨时区访问时与面板费用存在预期内偏差;所有费用均为按当前费率的估算值(标注「≈」与「估算」),不构成账单
|
|
41
41
|
|
|
42
42
|
## 与 dsh-usage-statistics-panel 的关系
|
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.8.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
package/src/client.js
CHANGED
|
@@ -203,6 +203,10 @@ const MESSAGES_ZH = {
|
|
|
203
203
|
heatMore: '较多',
|
|
204
204
|
dailyTrend: '按天 Token 趋势',
|
|
205
205
|
trendLimited: '仅显示最近 {n} 天',
|
|
206
|
+
dailyCostTrend: '按天金额趋势',
|
|
207
|
+
hourCostTrend: '按小时金额趋势',
|
|
208
|
+
minuteCostTrend: '按分钟金额趋势',
|
|
209
|
+
costTrend: '金额',
|
|
206
210
|
modelUsage: '模型用量',
|
|
207
211
|
modelCache: '缓存',
|
|
208
212
|
modelInput: '输入',
|
|
@@ -289,7 +293,7 @@ const MESSAGES_ZH = {
|
|
|
289
293
|
'weekday.4': '四',
|
|
290
294
|
'weekday.5': '五',
|
|
291
295
|
'weekday.6': '六',
|
|
292
|
-
condTime: '需 HH:MM',
|
|
296
|
+
condTime: '需 24 小时制时刻 HH:MM(时:分,如 09:30)',
|
|
293
297
|
condWeekday: '需 0-6 整数',
|
|
294
298
|
condMonthDay: '需 1-31 整数',
|
|
295
299
|
condDate: '需 YYYY-MM-DD',
|
|
@@ -346,6 +350,10 @@ const MESSAGES_EN = {
|
|
|
346
350
|
heatMore: 'More',
|
|
347
351
|
dailyTrend: 'Daily token trend',
|
|
348
352
|
trendLimited: 'Showing only the last {n} days',
|
|
353
|
+
dailyCostTrend: 'Daily cost trend',
|
|
354
|
+
hourCostTrend: 'Hourly cost trend',
|
|
355
|
+
minuteCostTrend: 'Per-minute cost trend',
|
|
356
|
+
costTrend: 'Cost',
|
|
349
357
|
modelUsage: 'Model usage',
|
|
350
358
|
modelCache: 'Cache',
|
|
351
359
|
modelInput: 'Input',
|
|
@@ -432,7 +440,7 @@ const MESSAGES_EN = {
|
|
|
432
440
|
'weekday.4': 'Th',
|
|
433
441
|
'weekday.5': 'Fr',
|
|
434
442
|
'weekday.6': 'Sa',
|
|
435
|
-
condTime: 'Requires HH:MM',
|
|
443
|
+
condTime: 'Requires 24-hour HH:MM time (hours:minutes, e.g. 09:30)',
|
|
436
444
|
condWeekday: 'Requires integer 0-6',
|
|
437
445
|
condMonthDay: 'Requires integer 1-31',
|
|
438
446
|
condDate: 'Requires YYYY-MM-DD',
|
|
@@ -545,24 +553,53 @@ function logTimeOf(ms) {
|
|
|
545
553
|
const toRankedModels = (totals) =>
|
|
546
554
|
[...totals.entries()].map(([model, tokens]) => ({ model, tokens })).sort((a, b) => b.tokens - a.tokens)
|
|
547
555
|
|
|
548
|
-
//
|
|
556
|
+
// 逐槽把非 top 模型并入哨兵桶,token 与费用双度量同折,原明细留 otherByModel/otherCostByModel 供
|
|
557
|
+
// tooltip;模型顺序 = 图例序(哨兵恒最后)
|
|
558
|
+
const foldMetricByTop = (entries, top) => {
|
|
559
|
+
const kept = {}
|
|
560
|
+
const other = {}
|
|
561
|
+
for (const [model, value] of Object.entries(entries)) {
|
|
562
|
+
if (top.has(model)) {
|
|
563
|
+
kept[model] = (kept[model] ?? 0) + value
|
|
564
|
+
continue
|
|
565
|
+
}
|
|
566
|
+
kept[OTHER_MODEL] = (kept[OTHER_MODEL] ?? 0) + value
|
|
567
|
+
other[model] = (other[model] ?? 0) + value
|
|
568
|
+
}
|
|
569
|
+
return { kept, other }
|
|
570
|
+
}
|
|
571
|
+
|
|
549
572
|
const foldSlotsByTop = (slots, topModels) => {
|
|
550
573
|
const top = new Set(topModels)
|
|
551
574
|
return slots.map((slot) => {
|
|
552
|
-
const
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
otherByModel[model] = (otherByModel[model] ?? 0) + tokens
|
|
575
|
+
const tokens = foldMetricByTop(slot.byModel, top)
|
|
576
|
+
const costs = foldMetricByTop(slot.costByModel ?? {}, top)
|
|
577
|
+
return {
|
|
578
|
+
...slot,
|
|
579
|
+
byModel: tokens.kept,
|
|
580
|
+
otherByModel: tokens.other,
|
|
581
|
+
byCostModel: costs.kept,
|
|
582
|
+
otherCostByModel: costs.other,
|
|
561
583
|
}
|
|
562
|
-
return { ...slot, byModel, otherByModel }
|
|
563
584
|
})
|
|
564
585
|
}
|
|
565
586
|
|
|
587
|
+
// 金额槽投影:total/byModel/otherByModel 切换为费用口径(调用方保证费用字段在场)
|
|
588
|
+
const costSlotsOf = (slots) => slots.map((slot) => ({
|
|
589
|
+
...slot,
|
|
590
|
+
total: slot.cost,
|
|
591
|
+
byModel: slot.byCostModel,
|
|
592
|
+
otherByModel: slot.otherCostByModel,
|
|
593
|
+
}))
|
|
594
|
+
|
|
595
|
+
// 金额视图派生:存在正计价槽才可用(零计价规则/未配价的金额图全零有误导),
|
|
596
|
+
// 开关开且可用才投影金额口径,否则原槽透传(引用不变)
|
|
597
|
+
const moneyViewOf = (slots, costView) => {
|
|
598
|
+
const costReady = !!slots && slots.some((slot) => slot.cost > 0)
|
|
599
|
+
const money = costView && costReady
|
|
600
|
+
return { costReady, money, chartSlots: money ? costSlotsOf(slots) : slots }
|
|
601
|
+
}
|
|
602
|
+
|
|
566
603
|
const topWithOther = (ranked) => {
|
|
567
604
|
const models = ranked.slice(0, GROUP_TOP_COUNT)
|
|
568
605
|
if (ranked.length > GROUP_TOP_COUNT) {
|
|
@@ -610,6 +647,8 @@ const SPEED_SCALE_FLOOR = 1
|
|
|
610
647
|
const TTFT_SCALE_FLOOR = 1
|
|
611
648
|
// 轴上限留白系数:数据峰不顶满绘图区,顶部留出标注空间
|
|
612
649
|
const AXIS_SCALE_HEADROOM = 1.1
|
|
650
|
+
// 金额刻度下限:仅防零值退化,亚分峰值不参与轴标定失真
|
|
651
|
+
const COST_TOTAL_FLOOR = 0.0001
|
|
613
652
|
// 图例键:折线项与模型项共处同一显隐集合
|
|
614
653
|
const LEGEND_KEY_RATE = 'rate'
|
|
615
654
|
const LEGEND_KEY_SPEED = 'speed'
|
|
@@ -639,26 +678,26 @@ function legendToggle(current, key, ctrl) {
|
|
|
639
678
|
return solo ? null : new Set([key])
|
|
640
679
|
}
|
|
641
680
|
|
|
642
|
-
// 左轴刻度:速度刻度存在时左轴标定速度(tok/s)
|
|
681
|
+
// 左轴刻度:速度刻度存在时左轴标定速度(tok/s),否则标定柱度量(token 或金额,格式化由调用方注入);
|
|
643
682
|
// 刻度值统一换算为绘图区高度占比
|
|
644
|
-
function leftAxisTicks(tokenTicks, tokenMax, speedTicks, speedMax) {
|
|
683
|
+
function leftAxisTicks(tokenTicks, tokenMax, speedTicks, speedMax, format = formatCompact) {
|
|
645
684
|
const useSpeed = speedTicks.length > 0
|
|
646
685
|
return (useSpeed ? speedTicks : tokenTicks).map((tick) => ({
|
|
647
|
-
label: formatCompact(tick),
|
|
686
|
+
label: (useSpeed ? formatCompact : format)(tick),
|
|
648
687
|
ratio: tick / (useSpeed ? speedMax : tokenMax),
|
|
649
688
|
}))
|
|
650
689
|
}
|
|
651
690
|
|
|
652
691
|
// 堆叠柱几何:模型序即堆叠序(哨兵最后画柱顶),输出槽分段与左轴刻度;
|
|
653
|
-
// maxTotal
|
|
654
|
-
function trendLayout(slots, modelOrder, avail, labelMinPitch) {
|
|
692
|
+
// maxTotal 按可见模型求和,单选模型时刻度跟随归一;totalFloor 为量纲下限(token 个位数/金额亚分)
|
|
693
|
+
function trendLayout(slots, modelOrder, avail, labelMinPitch, totalFloor = 1) {
|
|
655
694
|
const plotHeight = CHART_HEIGHT - CHART_PAD.top - CHART_PAD.bottom
|
|
656
695
|
const innerWidth = Math.max(1, avail - CHART_PAD.left - CHART_PAD.right)
|
|
657
696
|
const count = slots.length
|
|
658
697
|
const step = count > 1 ? innerWidth / (count - 1) : innerWidth
|
|
659
698
|
const barWidth = Math.max(BAR_MIN_WIDTH, Math.min(BAR_MAX_WIDTH, step * BAR_WIDTH_RATIO))
|
|
660
699
|
const slotVisibleTotal = (slot) => modelOrder.reduce((sum, model) => sum + (slot.byModel[model] ?? 0), 0)
|
|
661
|
-
const maxTotal = Math.max(
|
|
700
|
+
const maxTotal = Math.max(totalFloor, ...slots.map(slotVisibleTotal))
|
|
662
701
|
// 轴上限 = 数据峰 × 留白系数,柱高分母与左轴刻度分母同源
|
|
663
702
|
const scaleMax = maxTotal * AXIS_SCALE_HEADROOM
|
|
664
703
|
// 可见模型无任何数据时左轴无标定对象,空刻度防钳底值漏成假刻度
|
|
@@ -862,6 +901,10 @@ function modelIoPercentText(item, t) {
|
|
|
862
901
|
// 趋势 tooltip 内容门:全零槽(无任何用量)不渲染 tooltip
|
|
863
902
|
const hasTipContent = (hoverSlot) => Boolean(hoverSlot) && hoverSlot.total > 0
|
|
864
903
|
|
|
904
|
+
// 折叠提醒判定:折叠层内有异常日志条目或写入失败即值得提醒
|
|
905
|
+
const hasAnomalyNews = (status) => !!status
|
|
906
|
+
&& ((status.log?.length ?? 0) > 0 || (status.recordFailures ?? 0) > 0)
|
|
907
|
+
|
|
865
908
|
// 趋势 tooltip 模型行数据:主行 = 可见且当前时段有用量的模型;OTHER 子行 = OTHER 可见
|
|
866
909
|
// 且当前时段有用量的其余模型明细,按 tokens 降序由本函数保证
|
|
867
910
|
const tipModelEntries = (hoverSlot, visibleSet) => {
|
|
@@ -1385,6 +1428,17 @@ function formatCost(value, currency) {
|
|
|
1385
1428
|
return `${currency}${whole.replace(THOUSANDS_PATTERN, '$1,')}.${fraction}`
|
|
1386
1429
|
}
|
|
1387
1430
|
|
|
1431
|
+
const COST_CENT_SCALE = 100
|
|
1432
|
+
const COST_MICRO_SCALE = COST_CENT_SCALE * COST_CENT_SCALE
|
|
1433
|
+
|
|
1434
|
+
// 金额轴紧凑读数:千以上 k/M 一位小数,分上取两位内,分下四位(微观阈值与 formatCost 同源)
|
|
1435
|
+
function formatCostCompact(value, currency) {
|
|
1436
|
+
if (value >= COMPACT_BASE ** 2) return `${currency}${(value / COMPACT_BASE ** 2).toFixed(DECIMAL_DIGITS)}M`
|
|
1437
|
+
if (value >= COMPACT_BASE) return `${currency}${(value / COMPACT_BASE).toFixed(DECIMAL_DIGITS)}k`
|
|
1438
|
+
if (value >= COST_MICRO_THRESHOLD) return `${currency}${Math.round(value * COST_CENT_SCALE) / COST_CENT_SCALE}`
|
|
1439
|
+
return `${currency}${Math.round(value * COST_MICRO_SCALE) / COST_MICRO_SCALE}`
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1388
1442
|
// 全局显示货币:规则表首个非空 currency,所有费用显示点统一取此值(全局价格定位);
|
|
1389
1443
|
// 无则空串即不带符号。数值仍按命中规则单价计算,符号不随命中规则变化
|
|
1390
1444
|
function aggregateCurrencyOf(rules) {
|
|
@@ -1556,6 +1610,7 @@ function turnCostTitleText(t, tokenUsage) {
|
|
|
1556
1610
|
// ===== 定价编辑器纯函数(校验/规整/默认值) =====
|
|
1557
1611
|
const PRICE_KEYS = ['input', 'output', 'cacheRead', 'cacheWrite']
|
|
1558
1612
|
const HHMM_PATTERN = /^\d{1,2}:\d{2}$/
|
|
1613
|
+
const HHMM_PLACEHOLDER = 'HH:MM'
|
|
1559
1614
|
const ISO_DAY_PATTERN_CLIENT = /^\d{4}-\d{2}-\d{2}$/
|
|
1560
1615
|
// 时刻分量界:小时/分钟均双闭
|
|
1561
1616
|
const HOUR_MAX = 23
|
|
@@ -1565,6 +1620,8 @@ const WEEKDAY_MAX = 6
|
|
|
1565
1620
|
const MONTH_DAY_MIN = 1
|
|
1566
1621
|
const MONTH_DAY_MAX = 31
|
|
1567
1622
|
const FULL_DAY_WINDOW = { from: '00:00', to: '23:59' }
|
|
1623
|
+
// HH:MM 输入框内联宽(size 属性字符数):占位符容量 + 光标余量,配合 .ud-cond-fields 的 width:auto
|
|
1624
|
+
const HHMM_INPUT_SIZE = HHMM_PLACEHOLDER.length + 2
|
|
1568
1625
|
const CONDITION_KIND_OPTIONS = ['dailyWindow', 'weekdays', 'monthDays', 'dateRange']
|
|
1569
1626
|
const CONDITION_KIND_LABEL_KEYS = {
|
|
1570
1627
|
dailyWindow: 'condDailyWindow',
|
|
@@ -1580,6 +1637,27 @@ const parseHHMM = (value) => {
|
|
|
1580
1637
|
return hours >= 0 && hours <= HOUR_MAX && minutes >= 0 && minutes <= MINUTE_MAX ? value : null
|
|
1581
1638
|
}
|
|
1582
1639
|
|
|
1640
|
+
// HH:MM 输入净化:自绘文本框永不出现秒段。成型仅限两种形态——纯数字满四位补冒号(连续键入/纯数字秒串粘贴),
|
|
1641
|
+
// 带冒号三段时刻截取前两段(带秒串粘贴自愈);其余形态(中间插字/杂串)原样放行交保存校验显式报错,防静默重排出错误合法时刻
|
|
1642
|
+
const HHMM_INPUT_DIGITS = 4
|
|
1643
|
+
const HHMM_PURE_DIGITS_PATTERN = /^\d+$/
|
|
1644
|
+
const HHMM_SECONDS_TAIL_PATTERN = /:\d{2}$/
|
|
1645
|
+
const HHMM_THREE_SEGMENT_PATTERN = /^\d{1,2}:\d{2}:\d{2}$/
|
|
1646
|
+
const FULLWIDTH_DIGIT_PATTERN = /[0-9]/g
|
|
1647
|
+
const FULLWIDTH_COLON = ':'
|
|
1648
|
+
const FULLWIDTH_ZERO = '0'.codePointAt(0)
|
|
1649
|
+
const normalizeHHMMInput = (raw) => {
|
|
1650
|
+
if (typeof raw !== 'string') return ''
|
|
1651
|
+
const half = raw.replace(FULLWIDTH_DIGIT_PATTERN, (char) => String(char.codePointAt(0) - FULLWIDTH_ZERO)).replaceAll(FULLWIDTH_COLON, ':')
|
|
1652
|
+
if (HHMM_PURE_DIGITS_PATTERN.test(half) && half.length >= HHMM_INPUT_DIGITS) {
|
|
1653
|
+
return `${half.slice(0, 2)}:${half.slice(2, 4)}`
|
|
1654
|
+
}
|
|
1655
|
+
if (HHMM_THREE_SEGMENT_PATTERN.test(half)) {
|
|
1656
|
+
return half.replace(HHMM_SECONDS_TAIL_PATTERN, '')
|
|
1657
|
+
}
|
|
1658
|
+
return half
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1583
1661
|
const isValidMonthDay = (value) => Number.isInteger(value) && value >= MONTH_DAY_MIN && value <= MONTH_DAY_MAX
|
|
1584
1662
|
|
|
1585
1663
|
const isValidWeekday = (value) => Number.isInteger(value) && value >= WEEKDAY_MIN && value <= WEEKDAY_MAX
|
|
@@ -1738,6 +1816,12 @@ function removeRulesAt(rules, indexes) {
|
|
|
1738
1816
|
if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
1739
1817
|
window.__ModuleLoader__.load({ id: '@mzzsfy/dsh-usage-dash', factory })
|
|
1740
1818
|
|
|
1819
|
+
// 导航分区图标:表盘(dash 概念);键覆盖中英分区名 + 市场短名,语言切换下声明不漂移
|
|
1820
|
+
const NAV_ICON = { '使用统计': 'gauge', 'Usage': 'gauge', 'dsh-usage-dash': 'gauge' }
|
|
1821
|
+
if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
|
|
1822
|
+
else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
|
|
1823
|
+
else window.__navicIconQueue = [NAV_ICON]
|
|
1824
|
+
|
|
1741
1825
|
function factory(require) {
|
|
1742
1826
|
let React = null
|
|
1743
1827
|
try {
|
|
@@ -1931,10 +2015,11 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
1931
2015
|
.ud-empty{border:1px dashed var(--dsw-alias-border-l2);border-radius:8px;color:var(--dsw-alias-label-tertiary);text-align:center;padding:24px 16px;font-size:12px}
|
|
1932
2016
|
.ud-foot{color:var(--dsw-alias-label-tertiary);font-size:11px}
|
|
1933
2017
|
.ud-status{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin-top:4px;font-size:12px;color:var(--dsw-alias-label-tertiary)}
|
|
1934
|
-
.ud-status-fold{display:inline-flex;align-items:center;justify-content:center;width:28px;height:100%;min-height:28px;border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1;color:var(--dsw-alias-label-tertiary);cursor:pointer;border-radius:6px}
|
|
2018
|
+
.ud-status-fold{position:relative;display:inline-flex;align-items:center;justify-content:center;width:28px;height:100%;min-height:28px;border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1;color:var(--dsw-alias-label-tertiary);cursor:pointer;border-radius:6px}
|
|
1935
2019
|
.ud-status-fold:hover{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}
|
|
1936
2020
|
.ud-status-fold-caret{display:block;width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-top:5px solid currentColor;transition:transform .15s ease}
|
|
1937
2021
|
.ud-status-fold[aria-expanded="true"] .ud-status-fold-caret{transform:rotate(180deg)}
|
|
2022
|
+
.ud-status-fold-dot{position:absolute;top:4px;right:4px;width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-error-primary)}
|
|
1938
2023
|
.ud-status-track{display:inline-block;width:120px;height:2px;border-radius:1px;background:var(--dsw-alias-border-l1);overflow:hidden}
|
|
1939
2024
|
.ud-status-fill{display:block;height:100%;background:var(--dsw-alias-state-business-primary)}
|
|
1940
2025
|
.ud-status-err{color:var(--dsw-alias-state-error-primary)}
|
|
@@ -1963,6 +2048,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
1963
2048
|
.ud-section-head{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
|
|
1964
2049
|
.ud-section-title{font-size:15px;font-weight:600}
|
|
1965
2050
|
.ud-trend-note{font-size:11px;color:var(--dsw-alias-label-tertiary)}
|
|
2051
|
+
.ud-trend-metric{display:inline-flex;align-items:center;gap:6px;margin-left:auto}
|
|
2052
|
+
.ud-trend-metric-label{font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
|
|
1966
2053
|
.ud-chart-wrap{width:100%;min-width:0}
|
|
1967
2054
|
.ud-chart{display:block;width:100%}
|
|
1968
2055
|
.ud-grid{stroke:var(--dsw-alias-border-l1);stroke-width:1}
|
|
@@ -2178,7 +2265,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2178
2265
|
return `var(--ud-chart-${rank})`
|
|
2179
2266
|
}
|
|
2180
2267
|
|
|
2181
|
-
function TrendChart({ title, notes, slots, modelOrder, colorFor, labelFor, slotLabelFor, labelMinPitch, busy, legendModels, panelRef, costCurrency = '', costEnabled = false, t = defaultT }) {
|
|
2268
|
+
function TrendChart({ title, notes, slots, tipSlots, modelOrder, colorFor, labelFor, slotLabelFor, labelMinPitch, busy, legendModels, panelRef, costCurrency = '', costEnabled = false, money = false, costAvailable = false, onMetricToggle, t = defaultT }) {
|
|
2269
|
+
const metricId = React.useId()
|
|
2182
2270
|
const wrapRef = useRef(null)
|
|
2183
2271
|
const [avail, setAvail] = useState(CHART_NOMINAL_WIDTH)
|
|
2184
2272
|
const [hover, setHover] = useState(null)
|
|
@@ -2208,7 +2296,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2208
2296
|
const showRate = visibleSet.has(LEGEND_KEY_RATE)
|
|
2209
2297
|
const showSpeed = hasSpeed && visibleSet.has(LEGEND_KEY_SPEED)
|
|
2210
2298
|
const showTtft = hasTtft && visibleSet.has(LEGEND_KEY_TTFT)
|
|
2211
|
-
const layout = trendLayout(slots, visibleModels, avail, labelMinPitch)
|
|
2299
|
+
const layout = trendLayout(slots, visibleModels, avail, labelMinPitch, money ? COST_TOTAL_FLOOR : 1)
|
|
2212
2300
|
const plotRight = CHART_PAD.left + (slots.length - 1) * layout.step + layout.barWidth
|
|
2213
2301
|
const ratePoints = trendRatePoints(slots, layout.bars, layout.plotHeight)
|
|
2214
2302
|
const speedMax = speedScaleMax(slots)
|
|
@@ -2220,8 +2308,11 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2220
2308
|
// 左轴标定:可见柱有数据标 token;无柱数据且速度线可见标速度(tok/s);否则空
|
|
2221
2309
|
// (ttft 不设轴,读数走 tooltip,毫秒域与速度轴不同单位不混轴)
|
|
2222
2310
|
const speedAxisTicks = layout.ticks.length === 0 && showSpeed ? niceTicks(speedMax, AXIS_TICK_COUNT) : []
|
|
2223
|
-
|
|
2224
|
-
const
|
|
2311
|
+
// 金额视图左轴走紧凑金额读数,速度接管时仍为 tok/s 紧凑读数
|
|
2312
|
+
const yTicks = leftAxisTicks(layout.ticks, layout.scaleMax, speedAxisTicks, speedAxisMax,
|
|
2313
|
+
money ? (value) => formatCostCompact(value, costCurrency) : formatCompact)
|
|
2314
|
+
// 悬浮明细恒用 token 槽渲染:金额模式只切柱几何与左轴,悬浮文本不随口径变化
|
|
2315
|
+
const hoverSlot = hover ? (tipSlots ?? slots)[hover.index] : null
|
|
2225
2316
|
const hoverRatePoint = showRate && hoverSlot ? ratePoints.find((point) => point.day === hoverSlot.day) : null
|
|
2226
2317
|
const hoverSpeedPoint = hoverSlot ? speedPoints.find((point) => point.day === hoverSlot.day) : null
|
|
2227
2318
|
const hoverTtftPoint = hoverSlot ? ttftPoints.find((point) => point.day === hoverSlot.day) : null
|
|
@@ -2231,15 +2322,18 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2231
2322
|
return h('div', { className: 'ud-section' },
|
|
2232
2323
|
h('div', { className: 'ud-section-head' },
|
|
2233
2324
|
h('span', { className: 'ud-section-title' }, title),
|
|
2234
|
-
notes.length > 0 ? h('span', { className: 'ud-trend-note' }, notes.join(NOTE_SEPARATOR)) : null
|
|
2325
|
+
notes.length > 0 ? h('span', { className: 'ud-trend-note' }, notes.join(NOTE_SEPARATOR)) : null,
|
|
2326
|
+
costAvailable ? h('span', { className: 'ud-trend-metric' },
|
|
2327
|
+
h('span', { className: 'ud-trend-metric-label', id: metricId }, t('costTrend')),
|
|
2328
|
+
h(Switch, { checked: money, onChange: onMetricToggle, describedbyId: metricId, labelledById: metricId })) : null),
|
|
2235
2329
|
h('div', { className: 'ud-chart-wrap', ref: wrapRef, style: busy ? { opacity: CHART_BUSY_OPACITY } : undefined },
|
|
2236
2330
|
h('svg', {
|
|
2237
2331
|
className: 'ud-chart', viewBox: `0 0 ${avail} ${CHART_HEIGHT}`, width: '100%', role: 'img',
|
|
2238
2332
|
'aria-label': title, onMouseLeave: clear,
|
|
2239
2333
|
},
|
|
2240
|
-
yTicks.map((tick) => {
|
|
2334
|
+
yTicks.map((tick, tickIndex) => {
|
|
2241
2335
|
const y = CHART_PAD.top + layout.plotHeight - tick.ratio * layout.plotHeight
|
|
2242
|
-
return h('g', { key:
|
|
2336
|
+
return h('g', { key: tickIndex },
|
|
2243
2337
|
h('line', { className: 'ud-grid', x1: CHART_PAD.left, x2: plotRight, y1: y, y2: y }),
|
|
2244
2338
|
h('text', { className: 'ud-axis', x: CHART_PAD.left - AXIS_LABEL_GAP, y: y + AXIS_LABEL_BASELINE, textAnchor: 'end' }, tick.label))
|
|
2245
2339
|
}),
|
|
@@ -2364,10 +2458,13 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2364
2458
|
: element
|
|
2365
2459
|
}
|
|
2366
2460
|
|
|
2367
|
-
function HeatSection({ days, panelRef, t = defaultT }) {
|
|
2461
|
+
function HeatSection({ days, panelRef, costCurrency = '', t = defaultT }) {
|
|
2368
2462
|
const wrapRef = useRef(null)
|
|
2369
2463
|
const [width, setWidth] = useState(0)
|
|
2370
2464
|
const [hover, setHover] = useState(null)
|
|
2465
|
+
// 费用行显隐随「费用显示」偏好,经 ref 读取:开关切换不触发本组件重渲染
|
|
2466
|
+
const prefsRef = useRef(statsLineState.get())
|
|
2467
|
+
useEffect(() => statsLineState.subscribe(() => { prefsRef.current = statsLineState.get() }), [])
|
|
2371
2468
|
useEffect(() => {
|
|
2372
2469
|
const element = wrapRef.current
|
|
2373
2470
|
const observer = new ResizeObserver((entries) => {
|
|
@@ -2416,6 +2513,9 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2416
2513
|
h('div', { key: 'tokens', className: 'ud-tip-row' }, `${t('tokens')}: ${formatTokens(hover.tokens)}`),
|
|
2417
2514
|
h('div', { key: 'requests', className: 'ud-tip-row' }, `${t('requests')}: ${hover.requests}`),
|
|
2418
2515
|
h('div', { key: 'rate', className: 'ud-tip-row' }, `${t('cacheHitRate')}: ${cacheRateText(hover.cacheHit, hover.cacheMiss)}`),
|
|
2516
|
+
prefsRef.current.costDisplay && hover.cost > 0
|
|
2517
|
+
? h('div', { key: 'cost', className: 'ud-tip-row' }, t('stats.cost', { cost: formatCost(hover.cost, costCurrency) }))
|
|
2518
|
+
: null,
|
|
2419
2519
|
]
|
|
2420
2520
|
: null))
|
|
2421
2521
|
}
|
|
@@ -2543,13 +2643,14 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2543
2643
|
}
|
|
2544
2644
|
|
|
2545
2645
|
// 规约形态开关:原生 checkbox 保语义并视觉隐藏,track/thumb 呈现选中态
|
|
2546
|
-
function Switch({ checked, onChange, disabled, describedbyId }) {
|
|
2646
|
+
function Switch({ checked, onChange, disabled, describedbyId, labelledById }) {
|
|
2547
2647
|
return h('label', { className: 'ud-switch' },
|
|
2548
2648
|
h('input', {
|
|
2549
2649
|
type: 'checkbox',
|
|
2550
2650
|
checked,
|
|
2551
2651
|
disabled,
|
|
2552
2652
|
'aria-describedby': describedbyId,
|
|
2653
|
+
'aria-labelledby': labelledById,
|
|
2553
2654
|
onChange: (event) => onChange(event.target.checked),
|
|
2554
2655
|
}),
|
|
2555
2656
|
h('span', { className: 'ud-switch__track' },
|
|
@@ -2733,6 +2834,17 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2733
2834
|
onChange: (event) => patchCondition({ [field]: event.target.value }),
|
|
2734
2835
|
}),
|
|
2735
2836
|
errorTextOf(`${condPath}.${field}`))
|
|
2837
|
+
// 时段自绘 HH:MM 输入:原生 time 控件段位由浏览器按 locale 决定,部分环境渲染秒段,产出带秒值被校验拒绝
|
|
2838
|
+
const hhmmInput = (field, labelKey) => h('label', { key: field, className: 'ud-field' },
|
|
2839
|
+
h('span', { className: 'ud-field-label' }, t(labelKey)),
|
|
2840
|
+
h('input', {
|
|
2841
|
+
type: 'text', className: 'ud-input', inputMode: 'numeric', autoComplete: 'off',
|
|
2842
|
+
spellCheck: false, placeholder: HHMM_PLACEHOLDER,
|
|
2843
|
+
size: HHMM_INPUT_SIZE,
|
|
2844
|
+
value: condition[field] ?? '',
|
|
2845
|
+
onChange: (event) => patchCondition({ [field]: normalizeHHMMInput(event.target.value) }),
|
|
2846
|
+
}),
|
|
2847
|
+
errorTextOf(`${condPath}.${field}`))
|
|
2736
2848
|
// 号段 from/to 同域 1~31(双闭),统一上限
|
|
2737
2849
|
const numberInput = (field, labelKey) => h('label', { key: field, className: 'ud-field' },
|
|
2738
2850
|
h('span', { className: 'ud-field-label' }, t(labelKey)),
|
|
@@ -2757,7 +2869,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2757
2869
|
}, t(`weekday.${day}`))
|
|
2758
2870
|
}))
|
|
2759
2871
|
const fields = {
|
|
2760
|
-
dailyWindow: [
|
|
2872
|
+
dailyWindow: [hhmmInput('from', 'condFrom'), hhmmInput('to', 'condTo')],
|
|
2761
2873
|
weekdays: [weekdaysPills()],
|
|
2762
2874
|
monthDays: [numberInput('from', 'condFrom'), numberInput('to', 'condTo')],
|
|
2763
2875
|
dateRange: [textInput('from', 'date', 'condFrom'), textInput('to', 'date', 'condTo')],
|
|
@@ -2951,14 +3063,16 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2951
3063
|
}, t('addGroup')))
|
|
2952
3064
|
}
|
|
2953
3065
|
|
|
2954
|
-
//
|
|
2955
|
-
|
|
3066
|
+
// 扫描异常日志入口:仅箭头标识,明细在展开的日志块中展示;
|
|
3067
|
+
// 折叠且有待读信息时箭头带小点,展开后消失
|
|
3068
|
+
function AnomalyChip({ open, news, onToggle, t = defaultT }) {
|
|
2956
3069
|
return h('button', {
|
|
2957
3070
|
type: 'button', className: 'ud-status-fold', 'aria-expanded': open,
|
|
2958
3071
|
'aria-label': t('anomalyLog'), title: t('anomalyLog'),
|
|
2959
3072
|
onClick: onToggle,
|
|
2960
3073
|
},
|
|
2961
|
-
h('span', { className: 'ud-status-fold-caret', 'aria-hidden': 'true' })
|
|
3074
|
+
h('span', { className: 'ud-status-fold-caret', 'aria-hidden': 'true' }),
|
|
3075
|
+
news && !open ? h('span', { className: 'ud-status-fold-dot', 'aria-hidden': 'true' }) : null)
|
|
2962
3076
|
}
|
|
2963
3077
|
|
|
2964
3078
|
// 回扫进度与采集错误是运行状态,常显;异常明细走日志块
|
|
@@ -3069,6 +3183,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
3069
3183
|
|
|
3070
3184
|
const viewLabel = (t, id) => (id === 'day' ? t('viewDay') : id === 'hour' ? t('viewHour') : t('viewMinute'))
|
|
3071
3185
|
const trendTitle = (t, id) => (id === 'day' ? t('dailyTrend') : id === 'hour' ? t('hourTrend') : t('minuteTrend'))
|
|
3186
|
+
const trendCostTitle = (t, id) => (id === 'day' ? t('dailyCostTrend') : id === 'hour' ? t('hourCostTrend') : t('minuteCostTrend'))
|
|
3072
3187
|
const trendLimitedText = (t, id, count) => (id === 'day'
|
|
3073
3188
|
? t('trendLimited', { n: count })
|
|
3074
3189
|
: id === 'hour' ? t('trendLimitedHour', { n: count }) : t('trendLimitedMinute', { n: count }))
|
|
@@ -3078,6 +3193,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
3078
3193
|
|
|
3079
3194
|
function UsageDashPanel({ t = defaultT }) {
|
|
3080
3195
|
const [view, setView] = useState('day')
|
|
3196
|
+
// 趋势图金额口径开关:会话态,与视图/挡位同为面板内临时状态
|
|
3197
|
+
const [costView, setCostView] = useState(false)
|
|
3081
3198
|
const [range, setRange] = useState(DEFAULT_RANGE)
|
|
3082
3199
|
const [customFrom, setCustomFrom] = useState('')
|
|
3083
3200
|
const [customTo, setCustomTo] = useState('')
|
|
@@ -3128,6 +3245,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
3128
3245
|
requests: slot ? slot.requests : 0,
|
|
3129
3246
|
cacheHit: slot ? slot.cacheHit : 0,
|
|
3130
3247
|
cacheMiss: slot ? slot.cacheMiss : 0,
|
|
3248
|
+
cost: slot ? slot.cost : 0,
|
|
3131
3249
|
}
|
|
3132
3250
|
}))
|
|
3133
3251
|
})
|
|
@@ -3261,6 +3379,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
3261
3379
|
: (grouped ? { slots: grouped.daily, models: grouped.models, value: stats } : null)
|
|
3262
3380
|
const maxSlots = pointActive ? maxSlotsFor(view, rawPreset) : DAY_MAX_SLOTS
|
|
3263
3381
|
const trimmedSlots = trendSource ? trimSlots(trendSource.slots, maxSlots) : null
|
|
3382
|
+
const { costReady, money, chartSlots } = moneyViewOf(trimmedSlots, costView)
|
|
3264
3383
|
const notes = []
|
|
3265
3384
|
if (trendSource && trendSource.slots.length > trimmedSlots.length) notes.push(trendLimitedText(t, view, trimmedSlots.length))
|
|
3266
3385
|
if (trendSource?.value?.truncated) notes.push(t('trendTruncated'))
|
|
@@ -3335,7 +3454,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
3335
3454
|
}))
|
|
3336
3455
|
: null)),
|
|
3337
3456
|
h('div', { className: 'ud-toolbar-side' },
|
|
3338
|
-
h(AnomalyChip, { open: detailsOpen, onToggle: () => setDetailsOpen((v) => !v), t }),
|
|
3457
|
+
h(AnomalyChip, { open: detailsOpen, news: hasAnomalyNews(status), onToggle: () => setDetailsOpen((v) => !v), t }),
|
|
3339
3458
|
h('button', {
|
|
3340
3459
|
className: 'ud-btn ud-btn--text ud-icon-btn', 'aria-label': t('refresh'), title: t('refresh'),
|
|
3341
3460
|
disabled: busy, onClick: refresh,
|
|
@@ -3352,13 +3471,14 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
3352
3471
|
loadingVisible ? h('div', { className: 'ud-loading' }, `${t('loading')}…`) : null,
|
|
3353
3472
|
stats ? h(StatCards, { key: 'cards', stats, costCurrency, t }) : null,
|
|
3354
3473
|
// 活跃热力图仅按天视图展示:热力图口径为日桶,时/分视图无对应语义
|
|
3355
|
-
view === 'day' ? h(HeatSection, { key: 'heat', days: heatDays, panelRef, t }) : null,
|
|
3474
|
+
view === 'day' ? h(HeatSection, { key: 'heat', days: heatDays, panelRef, costCurrency, t }) : null,
|
|
3356
3475
|
trimmedSlots
|
|
3357
3476
|
? h(TrendChart, {
|
|
3358
3477
|
key: 'trend',
|
|
3359
|
-
title: trendTitle(t, view),
|
|
3478
|
+
title: money ? trendCostTitle(t, view) : trendTitle(t, view),
|
|
3360
3479
|
notes,
|
|
3361
|
-
slots:
|
|
3480
|
+
slots: chartSlots,
|
|
3481
|
+
tipSlots: trimmedSlots,
|
|
3362
3482
|
modelOrder: trendSource.models.map((item) => item.model),
|
|
3363
3483
|
colorFor,
|
|
3364
3484
|
labelFor: tickLabelFor(view),
|
|
@@ -3369,6 +3489,9 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
3369
3489
|
panelRef,
|
|
3370
3490
|
costCurrency,
|
|
3371
3491
|
costEnabled: view === 'day',
|
|
3492
|
+
money,
|
|
3493
|
+
costAvailable: costReady,
|
|
3494
|
+
onMetricToggle: setCostView,
|
|
3372
3495
|
t,
|
|
3373
3496
|
})
|
|
3374
3497
|
: null,
|
package/src/collector.js
CHANGED
|
@@ -12,6 +12,8 @@ const SEQ_UNKNOWN = -1
|
|
|
12
12
|
const DEFAULT_BACKFILL_CONCURRENCY = 4
|
|
13
13
|
const MARK_BATCH = 32
|
|
14
14
|
const SCAN_LOG_MAX_ENTRIES = 200
|
|
15
|
+
// 单条日志 detail 上限:flush 底层行错误拼接的截断保护
|
|
16
|
+
const LOG_DETAIL_MAX_CHARS = 500
|
|
15
17
|
|
|
16
18
|
// 不可读会话分类:宿主可见性/可读性边界的三代实测形态(见 README 存档兼容)
|
|
17
19
|
const SKIP_KINDS = { descriptor: 'descriptor', corrupt: 'corrupt', legacy: 'legacy', other: 'other' }
|
|
@@ -31,6 +33,15 @@ function classifySkip(detail) {
|
|
|
31
33
|
return SKIP_KINDS.other
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
// flush 失败日志文本:包装文案在前,聚合的底层行错误逐条展开,超长截断
|
|
37
|
+
function flushErrorText(error) {
|
|
38
|
+
const head = error instanceof Error ? error.message : String(error)
|
|
39
|
+
if (!(error instanceof AggregateError) || error.errors.length === 0) return head
|
|
40
|
+
const detail = error.errors.map((err) => (err instanceof Error ? err.message : String(err))).join('; ')
|
|
41
|
+
const text = `${head}: ${detail}`
|
|
42
|
+
return text.length > LOG_DETAIL_MAX_CHARS ? `${text.slice(0, LOG_DETAIL_MAX_CHARS)}…` : text
|
|
43
|
+
}
|
|
44
|
+
|
|
34
45
|
function emptySkipCounts() {
|
|
35
46
|
return { [SKIP_KINDS.descriptor]: 0, [SKIP_KINDS.corrupt]: 0, [SKIP_KINDS.legacy]: 0, [SKIP_KINDS.other]: 0 }
|
|
36
47
|
}
|
|
@@ -231,9 +242,10 @@ export class UsageCollector {
|
|
|
231
242
|
constructor(ctx, store) {
|
|
232
243
|
this.ctx = ctx
|
|
233
244
|
this.store = store
|
|
234
|
-
// store
|
|
245
|
+
// store 写合并周期落盘的失败行接入扫描异常日志,面板可观测;
|
|
246
|
+
// AggregateError 展开底层行错误,只记包装文案无法归因
|
|
235
247
|
store.onFlushError = (error) => {
|
|
236
|
-
this.pushLog('record',
|
|
248
|
+
this.pushLog('record', flushErrorText(error))
|
|
237
249
|
}
|
|
238
250
|
this.folds = new Map()
|
|
239
251
|
this.routes = new Map()
|
package/src/query.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { costOf, matchPrice } from './pricing.js'
|
|
1
|
+
import { costOf, matchPrice } from './pricing.js'
|
|
2
2
|
|
|
3
3
|
export const MAX_SLOTS = 2000
|
|
4
4
|
|
|
@@ -14,7 +14,7 @@ const DAY_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
|
|
14
14
|
const HOUR_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}$/
|
|
15
15
|
const MINUTE_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/
|
|
16
16
|
|
|
17
|
-
//
|
|
17
|
+
// 妗朵覆鍓嶇紑瀹?D 娈靛畾浣嶆棩妗?M 琛屾埅鍙栫埗 H 妗?
|
|
18
18
|
const DAY_KEY_WIDTH = 'YYYY-MM-DD'.length
|
|
19
19
|
const HOUR_KEY_WIDTH = 'YYYY-MM-DDTHH'.length
|
|
20
20
|
|
|
@@ -39,10 +39,10 @@ const nextMinute = (date) => {
|
|
|
39
39
|
return next
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
//
|
|
42
|
+
// 鍒嗛挓妗剁矑搴?鏋氫妇涓庢《閿叡鐢ㄥ悓涓€姝ラ暱,from 蹇呴』瀵归綈妗惰竟鐣?
|
|
43
43
|
const MINUTE_STEP_MINUTES = 10
|
|
44
44
|
|
|
45
|
-
// pattern
|
|
45
|
+
// pattern 閿氬畾妗朵覆澶栧舰,suffix 琛ュ叏涓烘湰鍦版椂鍖哄彲瑙f瀽鏃ユ湡涓?format 鍥炶鏍¢獙鍒嗛噺鍚堟硶鎬?
|
|
46
46
|
const BUCKET_FORMS = {
|
|
47
47
|
[GRANULARITY_DAILY]: { pattern: DAY_KEY_PATTERN, suffix: 'T00:00:00', format: formatDate, step: nextDay },
|
|
48
48
|
[GRANULARITY_HOURLY]: { pattern: HOUR_KEY_PATTERN, suffix: ':00:00', format: formatHour, step: nextHour },
|
|
@@ -95,8 +95,8 @@ const percentOf = (part, total) => (total === 0 ? 0 : (part / total) * PERCENT_S
|
|
|
95
95
|
|
|
96
96
|
const rowTokens = (row) => row.inputTokens + row.outputTokens + row.cacheReadTokens + row.cacheWriteTokens
|
|
97
97
|
|
|
98
|
-
//
|
|
99
|
-
//
|
|
98
|
+
// 閫熷害閰嶅鍒嗗瓙:decode 鍙e緞鍙?decodeTokens;瀛橀噺鏃ф牸寮忚(甯︽椂闀挎棤 decodeTokens)
|
|
99
|
+
// 鍥炶惤 outputTokens,鑱氬悎闅忔柊鏁版嵁鑷劧鏀舵暃
|
|
100
100
|
const speedTokensOf = (row) => (row.durationMs ? row.decodeTokens ?? row.outputTokens : 0)
|
|
101
101
|
|
|
102
102
|
export function aggregateRange(rows, g, from, to) {
|
|
@@ -106,16 +106,16 @@ export function aggregateRange(rows, g, from, to) {
|
|
|
106
106
|
const modelTotals = new Map()
|
|
107
107
|
const providerTotals = new Map()
|
|
108
108
|
const activeBuckets = new Set()
|
|
109
|
-
//
|
|
109
|
+
// 妲界骇閰嶅:妗朵覆 鈫?閫熷害瀵?{decodeTokens, durationMs} 涓庨瀛楀 {ttftMs, ttftSteps},
|
|
110
110
|
const slotSpeeds = new Map()
|
|
111
111
|
const slotTtfts = new Map()
|
|
112
112
|
for (const row of rows) {
|
|
113
113
|
const slot = slotByKey.get(row.bucket)
|
|
114
|
-
//
|
|
114
|
+
// 妗朵覆鏈惤鍦ㄦ灇涓惧簭鍒?濡傛敼绮掑害鍓嶇殑鍘嗗彶娈嬭)涓嶅彲褰掑睘,璺宠繃闃插穿
|
|
115
115
|
if (!slot) continue
|
|
116
116
|
const tokens = rowTokens(row)
|
|
117
117
|
addRowToSlot(slot, row, tokens)
|
|
118
|
-
//
|
|
118
|
+
// 绾?timing 琛?闆?token 妗?+ decode 閰嶅)涓嶅弬涓庡綊灞?浣嗕粛杩涢厤瀵硅仛鍚?
|
|
119
119
|
if (tokens > 0) {
|
|
120
120
|
activeBuckets.add(row.bucket)
|
|
121
121
|
slot.byModel[row.model] = (slot.byModel[row.model] ?? 0) + tokens
|
|
@@ -174,9 +174,9 @@ export function aggregateRange(rows, g, from, to) {
|
|
|
174
174
|
totals.cacheHit += slot.cacheHit
|
|
175
175
|
totals.cacheMiss += slot.cacheMiss
|
|
176
176
|
}
|
|
177
|
-
// speed = decode
|
|
178
|
-
//
|
|
179
|
-
//
|
|
177
|
+
// speed = decode 閰嶅鍙e緞(decodeTokens 梅 鏃堕暱绉?;ttft = 棣?token 寤惰繜
|
|
178
|
+
// 鍔犳潈骞冲潎(姣);浠呴厤瀵规暟鎹瓨鍦ㄧ殑鏉$洰鎸傚瓧娈?鏃犳暟鎹潯鐩笉鎸?
|
|
179
|
+
// 绾?timing 琛屽彲鑳戒骇鐢?0-token 鏉$洰,鍒楄〃淇濇寔鍙惈 token 琛?瀛橀噺濂戠害)
|
|
180
180
|
const models = [...modelTotals.entries()]
|
|
181
181
|
.filter(([, agg]) => agg.tokens > 0)
|
|
182
182
|
.map(([model, agg]) => ({
|
|
@@ -218,18 +218,20 @@ export function aggregateRange(rows, g, from, to) {
|
|
|
218
218
|
return result
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
//
|
|
221
|
+
// 鑱氬悎璁′环鐨勬Ы瀹氫綅:D 鎶樺彔鍒版棩妲?H/M 鍗虫湰妲?璁′环涓€寰嬪彇琛屾墍灞?H 妲借捣鐐?
|
|
222
222
|
const COST_SLOT_KEYS = {
|
|
223
223
|
[GRANULARITY_DAILY]: (bucket) => bucket.slice(0, DAY_KEY_WIDTH),
|
|
224
224
|
[GRANULARITY_HOURLY]: (bucket) => bucket,
|
|
225
225
|
[GRANULARITY_MINUTE]: (bucket) => bucket,
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
//
|
|
228
|
+
// 鑱氬悎璁′环:浠ュ彲瑙佹Ы涓哄敮涓€鍙e緞,cost 琛屾寜 H 妲借捣鐐瑰尮閰嶄环鏍煎悗绱姞;
|
|
229
|
+
// 妲界骇閫愭ā鍨嬫媶鍒?costByModel 渚涢噾棰濇煴鐘跺浘鎸夋ā鍨嬪爢鍙?
|
|
229
230
|
export function attachCosts(result, costRows, granularity, rules) {
|
|
230
231
|
const slotKeyOf = COST_SLOT_KEYS[granularity]
|
|
231
232
|
const slotOfDay = new Map(result.daily.map((slot) => [slot.day, slot]))
|
|
232
233
|
const slotCosts = new Map()
|
|
234
|
+
const slotCostsByModel = new Map()
|
|
233
235
|
const modelCosts = new Map(result.models.map((entry) => [entry.model, 0]))
|
|
234
236
|
const unpricedHours = new Set()
|
|
235
237
|
for (const row of costRows) {
|
|
@@ -245,9 +247,19 @@ export function attachCosts(result, costRows, granularity, rules) {
|
|
|
245
247
|
}
|
|
246
248
|
const cost = costOf(price, row)
|
|
247
249
|
slotCosts.set(slot.day, (slotCosts.get(slot.day) ?? 0) + cost)
|
|
250
|
+
let perModel = slotCostsByModel.get(slot.day)
|
|
251
|
+
if (perModel === undefined) {
|
|
252
|
+
perModel = new Map()
|
|
253
|
+
slotCostsByModel.set(slot.day, perModel)
|
|
254
|
+
}
|
|
255
|
+
perModel.set(row.model, (perModel.get(row.model) ?? 0) + cost)
|
|
248
256
|
if (modelCosts.has(row.model)) modelCosts.set(row.model, modelCosts.get(row.model) + cost)
|
|
249
257
|
}
|
|
250
|
-
const daily = result.daily.map((slot) => ({
|
|
258
|
+
const daily = result.daily.map((slot) => ({
|
|
259
|
+
...slot,
|
|
260
|
+
cost: slotCosts.get(slot.day) ?? 0,
|
|
261
|
+
costByModel: Object.fromEntries(slotCostsByModel.get(slot.day) ?? []),
|
|
262
|
+
}))
|
|
251
263
|
const cost = daily.reduce((sum, slot) => sum + slot.cost, 0)
|
|
252
264
|
const models = result.models.map((entry) => ({ ...entry, cost: modelCosts.get(entry.model) }))
|
|
253
265
|
return { ...result, daily, models, cost, unpriced: unpricedHours.size }
|
package/test/client.test.mjs
CHANGED
|
@@ -40,12 +40,14 @@ const {
|
|
|
40
40
|
coerceConditions,
|
|
41
41
|
coercePricingRules,
|
|
42
42
|
costOf,
|
|
43
|
+
costSlotsOf,
|
|
43
44
|
costTitleText,
|
|
44
45
|
createTranslator,
|
|
45
46
|
daysInRange,
|
|
46
47
|
defaultCondition,
|
|
47
48
|
defaultPricingRule,
|
|
48
49
|
donutSegments,
|
|
50
|
+
formatCostCompact,
|
|
49
51
|
pointStatsMatches,
|
|
50
52
|
formatCompact,
|
|
51
53
|
formatCost,
|
|
@@ -68,7 +70,9 @@ const {
|
|
|
68
70
|
maxSlotsFor,
|
|
69
71
|
minuteTickLabel,
|
|
70
72
|
modelIoPercentText,
|
|
73
|
+
moneyViewOf,
|
|
71
74
|
hasTipContent,
|
|
75
|
+
hasAnomalyNews,
|
|
72
76
|
tipModelEntries,
|
|
73
77
|
modelSegmentLabel,
|
|
74
78
|
modelSpeedText,
|
|
@@ -91,6 +95,7 @@ const {
|
|
|
91
95
|
resolvePointQuery,
|
|
92
96
|
formatDateTimeInput,
|
|
93
97
|
parseLocalDateTime,
|
|
98
|
+
normalizeHHMMInput,
|
|
94
99
|
shortDay,
|
|
95
100
|
smoothPath,
|
|
96
101
|
tipPlace,
|
|
@@ -1065,6 +1070,40 @@ test('validatePricingRules 拒绝非法时刻与时刻字段缺失', () => {
|
|
|
1065
1070
|
assert.equal(errors.has('4.conditions.0'), false)
|
|
1066
1071
|
})
|
|
1067
1072
|
|
|
1073
|
+
test('normalizeHHMMInput 满四位纯数字补冒号成型', () => {
|
|
1074
|
+
// Given 值为纯数字串且位数达四位 When 净化 Then 末两位前插冒号;纯数字秒串(140000)粘贴同路径自愈
|
|
1075
|
+
assert.equal(normalizeHHMMInput('0830'), '08:30')
|
|
1076
|
+
assert.equal(normalizeHHMMInput('140000'), '14:00')
|
|
1077
|
+
assert.equal(normalizeHHMMInput('12345'), '12:34')
|
|
1078
|
+
})
|
|
1079
|
+
|
|
1080
|
+
test('normalizeHHMMInput 三段时刻截取前两段', () => {
|
|
1081
|
+
// Given 粘贴带秒串(HH:MM:SS)When 净化 Then 截取时:分两段自愈
|
|
1082
|
+
assert.equal(normalizeHHMMInput('14:00:00'), '14:00')
|
|
1083
|
+
assert.equal(normalizeHHMMInput('8:05:59'), '8:05')
|
|
1084
|
+
assert.equal(normalizeHHMMInput('14:00:0'), '14:00:0')
|
|
1085
|
+
})
|
|
1086
|
+
|
|
1087
|
+
test('normalizeHHMMInput 其余形态原样放行', () => {
|
|
1088
|
+
// Given 中间插字重排(08:530)或杂串或未满四位 When 净化 Then 不插手,交保存校验显式报错而非静默重排
|
|
1089
|
+
assert.equal(normalizeHHMMInput('08:530'), '08:530')
|
|
1090
|
+
assert.equal(normalizeHHMMInput('2026-03-15 14:00'), '2026-03-15 14:00')
|
|
1091
|
+
assert.equal(normalizeHHMMInput('083'), '083')
|
|
1092
|
+
assert.equal(normalizeHHMMInput('8:3'), '8:3')
|
|
1093
|
+
assert.equal(normalizeHHMMInput('14:00'), '14:00')
|
|
1094
|
+
assert.equal(normalizeHHMMInput(''), '')
|
|
1095
|
+
assert.equal(normalizeHHMMInput(null), '')
|
|
1096
|
+
assert.equal(normalizeHHMMInput(undefined), '')
|
|
1097
|
+
})
|
|
1098
|
+
|
|
1099
|
+
test('normalizeHHMMInput 全角数字折半角后走同规则', () => {
|
|
1100
|
+
// Given IME 全角数字输入 When 净化 Then 先折半角再走成型规则,输出恒为半角
|
|
1101
|
+
assert.equal(normalizeHHMMInput('14:00'), '14:00')
|
|
1102
|
+
assert.equal(normalizeHHMMInput('14:00:00'), '14:00')
|
|
1103
|
+
assert.equal(normalizeHHMMInput('1400'), '14:00')
|
|
1104
|
+
assert.equal(normalizeHHMMInput('083'), '083')
|
|
1105
|
+
})
|
|
1106
|
+
|
|
1068
1107
|
test('validatePricingRules 接受 from===to 单点与单日', () => {
|
|
1069
1108
|
// Given 双闭下时段/号段/日期段 from===to When 校验 Then 全部合法
|
|
1070
1109
|
const rules = [
|
|
@@ -1233,3 +1272,104 @@ test('removeRulesAt 批量移除组内槽位', () => {
|
|
|
1233
1272
|
const rest = removeRulesAt(rules, [0, 2])
|
|
1234
1273
|
assert.deepEqual(rest.map((rule) => rule.model), ['b/1'])
|
|
1235
1274
|
})
|
|
1275
|
+
|
|
1276
|
+
test('groupStats 折叠金额双映射:top 模型费用透传,其余归哨兵并保留明细', () => {
|
|
1277
|
+
// Given 逐槽 costByModel 含 top 与非 top 模型 When 折叠 Then byCostModel 归并哨兵,otherCostByModel 保留非 top 明细
|
|
1278
|
+
const stats = {
|
|
1279
|
+
models: [
|
|
1280
|
+
{ model: 'p/m1', tokens: 500 },
|
|
1281
|
+
{ model: 'p/m2', tokens: 400 },
|
|
1282
|
+
{ model: 'p/m3', tokens: 300 },
|
|
1283
|
+
{ model: 'p/m4', tokens: 200 },
|
|
1284
|
+
{ model: 'p/m5', tokens: 100 },
|
|
1285
|
+
{ model: 'p/m6', tokens: 50 },
|
|
1286
|
+
],
|
|
1287
|
+
daily: [{
|
|
1288
|
+
day: 'd',
|
|
1289
|
+
total: 110,
|
|
1290
|
+
byModel: { 'p/m1': 50, 'p/m2': 40, 'p/m6': 20 },
|
|
1291
|
+
costByModel: { 'p/m1': 2, 'p/m2': 2, 'p/m6': 0.5 },
|
|
1292
|
+
}],
|
|
1293
|
+
}
|
|
1294
|
+
const grouped = groupStats(stats)
|
|
1295
|
+
assert.deepEqual(grouped.daily[0].byCostModel, { 'p/m1': 2, 'p/m2': 2, [OTHER_MODEL]: 0.5 })
|
|
1296
|
+
assert.deepEqual(grouped.daily[0].otherCostByModel, { 'p/m6': 0.5 })
|
|
1297
|
+
})
|
|
1298
|
+
|
|
1299
|
+
test('groupStats 无费用槽金额映射为空表', () => {
|
|
1300
|
+
// Given 槽无 costByModel When 折叠 Then byCostModel/otherCostByModel 恒在场为空表
|
|
1301
|
+
const stats = {
|
|
1302
|
+
models: [{ model: 'p/m1', tokens: 500 }],
|
|
1303
|
+
daily: [{ day: 'd', total: 10, byModel: { 'p/m1': 10 } }],
|
|
1304
|
+
}
|
|
1305
|
+
const grouped = groupStats(stats)
|
|
1306
|
+
assert.deepEqual(grouped.daily[0].byCostModel, {})
|
|
1307
|
+
assert.deepEqual(grouped.daily[0].otherCostByModel, {})
|
|
1308
|
+
})
|
|
1309
|
+
|
|
1310
|
+
test('costSlotsOf 槽投影切金额口径:total/byModel/otherByModel 取费用字段', () => {
|
|
1311
|
+
// Given 折叠后带费用字段的槽 When 投影 Then total=cost、byModel=byCostModel、otherByModel=otherCostByModel,其余字段保留
|
|
1312
|
+
const slots = [
|
|
1313
|
+
{ day: 'd1', total: 100, cost: 2.5, byModel: { a: 60, b: 40 }, byCostModel: { a: 1.5, b: 1 }, otherByModel: {}, otherCostByModel: {} },
|
|
1314
|
+
{ day: 'd2', total: 10, cost: 0, byModel: { a: 10 }, byCostModel: {}, otherByModel: {}, otherCostByModel: {} },
|
|
1315
|
+
]
|
|
1316
|
+
const out = costSlotsOf(slots)
|
|
1317
|
+
assert.equal(out[0].total, 2.5)
|
|
1318
|
+
assert.deepEqual(out[0].byModel, { a: 1.5, b: 1 })
|
|
1319
|
+
assert.deepEqual(out[0].otherByModel, {})
|
|
1320
|
+
assert.equal(out[0].day, 'd1')
|
|
1321
|
+
assert.equal(out[1].total, 0)
|
|
1322
|
+
assert.deepEqual(out[1].byModel, {})
|
|
1323
|
+
})
|
|
1324
|
+
|
|
1325
|
+
test('moneyViewOf 门控:存在正计价槽才可用,关或不可用保持 token 口径', () => {
|
|
1326
|
+
// Given 槽含正计价与零计价 When 开关开 Then 可用且切金额;Given 全零计价/开关关/无槽 When 派生 Then 不可用且不投影
|
|
1327
|
+
const slots = [
|
|
1328
|
+
{ day: 'd1', total: 10, cost: 0.5, byModel: { a: 10 }, byCostModel: { a: 0.5 }, otherByModel: {}, otherCostByModel: {} },
|
|
1329
|
+
{ day: 'd2', total: 5, cost: 0, byModel: { a: 5 }, byCostModel: {}, otherByModel: {}, otherCostByModel: {} },
|
|
1330
|
+
]
|
|
1331
|
+
const on = moneyViewOf(slots, true)
|
|
1332
|
+
assert.equal(on.costReady, true)
|
|
1333
|
+
assert.equal(on.money, true)
|
|
1334
|
+
assert.equal(on.chartSlots[0].total, 0.5)
|
|
1335
|
+
const zeroOnly = [slots[1]]
|
|
1336
|
+
const allZero = moneyViewOf(zeroOnly, true)
|
|
1337
|
+
assert.equal(allZero.costReady, false)
|
|
1338
|
+
assert.equal(allZero.money, false)
|
|
1339
|
+
assert.equal(allZero.chartSlots, zeroOnly)
|
|
1340
|
+
assert.equal(allZero.chartSlots[0].total, 5)
|
|
1341
|
+
const off = moneyViewOf(slots, false)
|
|
1342
|
+
assert.equal(off.costReady, true)
|
|
1343
|
+
assert.equal(off.money, false)
|
|
1344
|
+
assert.equal(off.chartSlots, slots)
|
|
1345
|
+
const none = moneyViewOf(null, true)
|
|
1346
|
+
assert.deepEqual(none, { costReady: false, money: false, chartSlots: null })
|
|
1347
|
+
})
|
|
1348
|
+
|
|
1349
|
+
test('formatCostCompact 金额轴紧凑读数:千以上 k/M 一位小数,分上两位内,分下四位', () => {
|
|
1350
|
+
assert.equal(formatCostCompact(1234567, '¥'), '¥1.2M')
|
|
1351
|
+
assert.equal(formatCostCompact(1234, '¥'), '¥1.2k')
|
|
1352
|
+
assert.equal(formatCostCompact(12.345, '$'), '$12.35')
|
|
1353
|
+
assert.equal(formatCostCompact(0.1, '¥'), '¥0.1')
|
|
1354
|
+
assert.equal(formatCostCompact(0.005, '¥'), '¥0.005')
|
|
1355
|
+
assert.equal(formatCostCompact(0, ''), '0')
|
|
1356
|
+
})
|
|
1357
|
+
|
|
1358
|
+
test('leftAxisTicks 金额格式化:柱刻度走传入格式化,速度刻度仍紧凑 token', () => {
|
|
1359
|
+
// Given 金额格式化器 When 柱刻度标定 Then 标签走金额格式;Given 速度刻度接管 Then 标签仍 token 紧凑
|
|
1360
|
+
const money = leftAxisTicks([50, 100], 100, [], 0, (value) => `¥${value}`)
|
|
1361
|
+
assert.deepEqual(money.map((tick) => tick.label), ['¥50', '¥100'])
|
|
1362
|
+
const speed = leftAxisTicks([], 100, [5, 10], 10, (value) => `¥${value}`)
|
|
1363
|
+
assert.deepEqual(speed.map((tick) => tick.label), ['5', '10'])
|
|
1364
|
+
})
|
|
1365
|
+
|
|
1366
|
+
test('hasAnomalyNews 折叠提醒判定:有异常日志或写入失败即提醒', () => {
|
|
1367
|
+
// Given 日志有条目 When 判定 Then true;Given 仅写入失败计数 When 判定 Then true
|
|
1368
|
+
assert.equal(hasAnomalyNews({ log: [{ time: 0, kind: 'record', detail: 'x' }] }), true)
|
|
1369
|
+
assert.equal(hasAnomalyNews({ log: [], recordFailures: 1 }), true)
|
|
1370
|
+
assert.equal(hasAnomalyNews({ log: [{ time: 0, kind: 'skipped', detail: 'y' }], recordFailures: 0 }), true)
|
|
1371
|
+
// Given 全空/字段缺席/null When 判定 Then false
|
|
1372
|
+
assert.equal(hasAnomalyNews({ log: [], recordFailures: 0 }), false)
|
|
1373
|
+
assert.equal(hasAnomalyNews({}), false)
|
|
1374
|
+
assert.equal(hasAnomalyNews(null), false)
|
|
1375
|
+
})
|
package/test/collector.test.mjs
CHANGED
|
@@ -766,6 +766,30 @@ test('采集侧 store 失败计入 recordFailures 且不抛断采集', async ()
|
|
|
766
766
|
assert.equal(store.samples.length, 0)
|
|
767
767
|
})
|
|
768
768
|
|
|
769
|
+
test('flush 聚合失败日志展开底层行错误,不只剩包装文案', async () => {
|
|
770
|
+
const { store, collector } = liveCollector()
|
|
771
|
+
// Given store 上抛含两行底层失败的 AggregateError When 接入日志 Then detail 展开各原因
|
|
772
|
+
store.onFlushError(new AggregateError(
|
|
773
|
+
[new Error('boom A'), new Error('boom B')],
|
|
774
|
+
'usage store flush failed',
|
|
775
|
+
))
|
|
776
|
+
const entry = collector.status().log.at(-1)
|
|
777
|
+
assert.equal(entry.kind, 'record')
|
|
778
|
+
assert.equal(entry.detail, 'usage store flush failed: boom A; boom B')
|
|
779
|
+
assert.equal(collector.status().recordFailures, 1)
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
test('flush 聚合失败日志截断保护:超长底层错误不撑爆日志行', async () => {
|
|
783
|
+
const { store, collector } = liveCollector()
|
|
784
|
+
store.onFlushError(new AggregateError(
|
|
785
|
+
[new Error('x'.repeat(2000))],
|
|
786
|
+
'usage store flush failed',
|
|
787
|
+
))
|
|
788
|
+
const entry = collector.status().log.at(-1)
|
|
789
|
+
assert.ok(entry.detail.length <= 600)
|
|
790
|
+
assert.ok(entry.detail.startsWith('usage store flush failed'))
|
|
791
|
+
})
|
|
792
|
+
|
|
769
793
|
test('session/disposed 后该会话去重状态重置', async () => {
|
|
770
794
|
const { ctx, store } = liveCollector()
|
|
771
795
|
const t = local(2026, 8, 2, 10, 0)
|
package/test/query.test.mjs
CHANGED
|
@@ -574,3 +574,53 @@ test('attachCosts 计价时间恒为 H 桶起点(非分钟/日起点)', () => {
|
|
|
574
574
|
const dayOut = attachCosts(dayResult, hourRows, 'D', [dayWindow, ruleOf({ price: inputPrice(3) })])
|
|
575
575
|
assert.deepEqual(dayOut.daily.map((slot) => slot.cost), [1, 3])
|
|
576
576
|
})
|
|
577
|
+
|
|
578
|
+
test('attachCosts 槽级 costByModel 按模型拆分费用', () => {
|
|
579
|
+
// Given 同桶两模型不同单价 When H 槽计价 Then 槽 costByModel 为逐模型费用,合计等于槽 cost
|
|
580
|
+
const rows = [
|
|
581
|
+
makeRow({ bucket: '2020-01-01T01', model: 'm1', inputTokens: 1000000 }),
|
|
582
|
+
makeRow({ bucket: '2020-01-01T01', model: 'm2', inputTokens: 1000000 }),
|
|
583
|
+
]
|
|
584
|
+
const result = aggregateRange(rows, 'H', '2020-01-01T00', '2020-01-01T01')
|
|
585
|
+
const out = attachCosts(result, rows, 'H', [ruleOf({ price: inputPrice(1) })])
|
|
586
|
+
assert.deepEqual(out.daily[1].costByModel, { m1: 1, m2: 1 })
|
|
587
|
+
assert.equal(out.daily[1].cost, 2)
|
|
588
|
+
// 纯函数:入参 result 不被修改
|
|
589
|
+
assert.equal('costByModel' in result.daily[1], false)
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
test('attachCosts D 折叠按日归集 costByModel', () => {
|
|
593
|
+
// Given 同模型 H 行落同日多桶 When D 计价 Then 日槽 costByModel 为该模型桶累加
|
|
594
|
+
const costRows = [
|
|
595
|
+
makeRow({ bucket: '2020-01-01T22', model: 'm1', inputTokens: 1000000 }),
|
|
596
|
+
makeRow({ bucket: '2020-01-01T23', model: 'm1', inputTokens: 500000 }),
|
|
597
|
+
]
|
|
598
|
+
const aggregateRows = [makeRow({ bucket: '2020-01-01', model: 'm1', inputTokens: 1500000 })]
|
|
599
|
+
const result = aggregateRange(aggregateRows, 'D', '2020-01-01', '2020-01-01')
|
|
600
|
+
const out = attachCosts(result, costRows, 'D', [ruleOf({ price: inputPrice(2) })])
|
|
601
|
+
assert.deepEqual(out.daily[0].costByModel, { m1: 3 })
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
test('attachCosts 无规则或无费用槽 costByModel 为空表', () => {
|
|
605
|
+
// Given 无命中规则 When 计价 Then 全部槽挂空表,unpriced 照常计数
|
|
606
|
+
const rows = [
|
|
607
|
+
makeRow({ bucket: '2020-01-01T01', model: 'm1', inputTokens: 5 }),
|
|
608
|
+
makeRow({ bucket: '2020-01-01T02', model: 'm1', inputTokens: 5 }),
|
|
609
|
+
]
|
|
610
|
+
const result = aggregateRange(rows, 'H', '2020-01-01T00', '2020-01-01T02')
|
|
611
|
+
const out = attachCosts(result, rows, 'H', [])
|
|
612
|
+
assert.deepEqual(out.daily.map((slot) => slot.costByModel), [{}, {}, {}])
|
|
613
|
+
assert.equal(out.unpriced, 2)
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
test('attachCosts M 槽 costByModel 按模型拆分', () => {
|
|
617
|
+
// Given 同分钟桶两模型 When M 计价 Then 分钟槽 costByModel 为逐模型费用
|
|
618
|
+
const rows = [
|
|
619
|
+
makeRow({ bucket: '2020-01-01T01:10', model: 'm1', inputTokens: 500000 }),
|
|
620
|
+
makeRow({ bucket: '2020-01-01T01:10', model: 'm2', inputTokens: 500000 }),
|
|
621
|
+
]
|
|
622
|
+
const result = aggregateRange(rows, 'M', '2020-01-01T01:10', '2020-01-01T01:10')
|
|
623
|
+
const out = attachCosts(result, rows, 'M', [ruleOf({ price: inputPrice(1) })])
|
|
624
|
+
assert.deepEqual(out.daily[0].costByModel, { m1: 0.5, m2: 0.5 })
|
|
625
|
+
assert.equal(out.daily[0].cost, 1)
|
|
626
|
+
})
|