@mzzsfy/dsh-usage-dash 0.9.0 → 0.10.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 -1
- package/package.json +1 -1
- package/src/client.js +14 -12
- package/src/index.js +50 -10
- package/test/client.test.mjs +33 -16
- package/test/settings-inject.test.mjs +237 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
- 汇总卡六张:Tokens 用量(服务商总口径)、会话数量、请求数量、最常用模型、平均缓存命中率、活跃天数(恒按天口径,不随视图切换);配置定价规则后 Tokens 卡头部行右侧显示估算费用
|
|
11
11
|
- 活动热力图:GitHub 风格周列×星期行,26 周窗口,五档色阶,悬停明细(配置定价规则后附「费用 ≈」估算行,受「费用显示」开关;费用数据依赖小时桶保留期,仅最近 15 天的天显示,更早的天无该行);仅按天视图展示
|
|
12
12
|
- 缓存命中率曲线:日粒度命中率 + 右侧副轴,并叠加平均生成速度曲线与首 token 延迟曲线(各自单独颜色,读数走悬停;仅含配对数据的槽参与,整图无数据不绘制),悬停显示当前时段命中率、平均生成速度、首 token 延迟与 token 明细(配置定价规则后附当前时段估算费用行,受「费用显示」开关;三粒度趋势图通用)
|
|
13
|
-
- 模型 donut 与列表:按 token
|
|
13
|
+
- 模型 donut 与列表:按 token 排序的模型占比环形图(中心为总量,模型不超过 5 个时全展示,超过时折叠为前 4 +「其他」,「其他」可展开),每行名称两行展示(厂商/模型名),右侧三排:第一排 token 数·占比,第二排输入输出占比构成(缓存/输入/输出 三段各占该模型 token 总量,缓存=读+写,三段合计 100%,无四桶数据不显示),第三排估算费用·TTFT·生成速度(费用需配置定价规则,TTFT/速度无配对数据不显示),悬停明细含输入/缓存读/缓存写/输出四桶 token 拆分并联动
|
|
14
14
|
- 三粒度堆叠柱状趋势图(数据量大时裁最旧并提示);节头「金额」开关把柱状图从 token 消耗切到金额消耗——柱按模型堆叠估算费用,标题与左轴同步切金额读数(悬停明细保持 token 口径与估算费用行不变,命中率/速度/首 token 延迟曲线不受影响),三粒度通用;开关仅在存在正计价数据时显示(未配置规则或全量未计价时不渲染,避免全零金额柱误导);图例可点击切换显隐:点击单选(仅显示该项,左轴刻度按可见项归一)/再点恢复全部,Ctrl+点击多选,隐藏最后一项无效;悬停明细按当前时段数据展示,零用量模型不显示,全零时段不显示悬浮框
|
|
15
15
|
- 回扫状态行默认隐藏:首次启用自动回扫历史会话,运行中显示进度(常显);右上角为折叠箭头与刷新图标,折叠层内展开扫描异常日志块(逐条:时间/类型/明细,计数即明细条数,上限 200 条超限丢最旧,重建时清空)与「重建」入口(二次确认 3 秒后清库重扫),采集错误常显;折叠且有待读信息(异常日志或写入失败)时箭头带红点提醒,展开后消失;工具栏分主区与右侧操作区,窄宽度时挡位组在主区内换行
|
|
16
16
|
- 会话底栏接管:替换官方信息行为增强版,偏好卡三个开关默认全开——精确缓存命中率(两位小数)、会话 Token 明细(总/命中缓存/未命中缓存/输出)、费用显示(底栏费用项、趋势 tooltip 费用行与回合费用芯片);三开关全关时与官方逐字节一致
|
|
@@ -90,3 +90,7 @@ dsh --profile <profile> --no-open --port 9191
|
|
|
90
90
|
装载本插件需要它在 `dsh.profile.bundles`(`dsh plugin --profile web add @mzzsfy/dsh-usage-dash`);bundles 表变化不支持热重载,需重启 dsh 生效。之后代码改动:宿主半区经 dev-link 自动热重载,client 半区刷新页面即生效。
|
|
91
91
|
|
|
92
92
|
双语能力经宿主 `locale` 服务声明装载(与官方插件同构);极端旧宿主无该服务时插件整体未激活(cordis 门控,升级宿主即自愈)。
|
|
93
|
+
|
|
94
|
+
## dsh 版本兼容
|
|
95
|
+
|
|
96
|
+
三版本(0.1.2-rc.1 / 0.1.5-rc.3 / 0.1.7-rc.1)兼容通过:激活 live;0.1.7-rc.1 依赖会话用量数据夹具(ENV-003),装载与激活面正常。
|
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.10.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
package/src/client.js
CHANGED
|
@@ -20,8 +20,8 @@ const DAY_MAX_SLOTS = 180
|
|
|
20
20
|
const API_PREFIX = '/api/usage-dash/'
|
|
21
21
|
const ENDPOINTS = { range: 'range', hours: 'hours', minutes: 'minutes', status: 'status', reset: 'reset', restore: 'restore', pricing: 'pricing' }
|
|
22
22
|
|
|
23
|
-
//
|
|
24
|
-
const
|
|
23
|
+
// 模型免折叠上限:条目不超它全展示,超过才折叠为 top(上限-1)+Other
|
|
24
|
+
const GROUP_UNFOLDED_MAX = 5
|
|
25
25
|
const OTHER_MODEL = '\u0000other'
|
|
26
26
|
|
|
27
27
|
const PAD_WIDTH = 2
|
|
@@ -603,9 +603,10 @@ const moneyViewOf = (slots, costView) => {
|
|
|
603
603
|
}
|
|
604
604
|
|
|
605
605
|
const topWithOther = (ranked) => {
|
|
606
|
-
const
|
|
607
|
-
|
|
608
|
-
|
|
606
|
+
const folded = ranked.length > GROUP_UNFOLDED_MAX
|
|
607
|
+
const models = ranked.slice(0, folded ? GROUP_UNFOLDED_MAX - 1 : GROUP_UNFOLDED_MAX)
|
|
608
|
+
if (folded) {
|
|
609
|
+
const rest = ranked.slice(models.length)
|
|
609
610
|
// 四桶与费用同源条件挂载:输入条目无该字段(旧形数据/未配价)时哨兵同样不带,防零值假数据
|
|
610
611
|
models.push({
|
|
611
612
|
model: OTHER_MODEL,
|
|
@@ -640,7 +641,7 @@ function groupPointSlots(slots) {
|
|
|
640
641
|
// (预设与自定义挡)用点端点聚合,字段同形;点数据未回为 null,调用方判空不渲染
|
|
641
642
|
const cardsStatsOf = (view, stats, pointView) => (view === 'day' ? stats : pointView)
|
|
642
643
|
|
|
643
|
-
// 模型用量列表:天视图用预折叠分组,时/分视图折叠点窗口模型(
|
|
644
|
+
// 模型用量列表:天视图用预折叠分组,时/分视图折叠点窗口模型(超上限折叠 top(上限-1)+other),点数据未回为 null
|
|
644
645
|
const usageModelsOf = (view, grouped, pointView) => (
|
|
645
646
|
view === 'day' ? grouped?.models ?? null : pointView ? topWithOther(pointView.models) : null
|
|
646
647
|
)
|
|
@@ -2125,14 +2126,14 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2125
2126
|
.ud-donut-center{font-size:18px;font-weight:600;fill:var(--dsw-alias-label-primary)}
|
|
2126
2127
|
.ud-donut-label{font-size:11px;fill:var(--dsw-alias-label-tertiary)}
|
|
2127
2128
|
.ud-models{flex:1 1 260px;min-width:240px;display:flex;flex-direction:column}
|
|
2128
|
-
.ud-model-row{display:flex;align-items:center;gap:8px;min-height:
|
|
2129
|
+
.ud-model-row{display:flex;align-items:center;gap:8px;min-height:46px;padding:1px 4px;border-bottom:1px solid var(--dsw-alias-border-l1)}
|
|
2129
2130
|
.ud-model-row--expand{cursor:pointer}
|
|
2130
2131
|
.ud-model-row--expand:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
2131
2132
|
.ud-model-swatch{width:10px;height:10px;border-radius:2px;flex:none}
|
|
2132
|
-
.ud-model-id{display:flex;flex-direction:column;
|
|
2133
|
+
.ud-model-id{display:flex;flex-direction:column;min-width:0;flex:1}
|
|
2133
2134
|
.ud-model-name{font-size:14px;font-weight:500;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
2134
2135
|
.ud-model-provider{font-size:12px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
2135
|
-
.ud-model-values{display:flex;flex-direction:column;align-items:flex-end;
|
|
2136
|
+
.ud-model-values{display:flex;flex-direction:column;align-items:flex-end;font-variant-numeric:tabular-nums;flex:none}
|
|
2136
2137
|
.ud-model-tokens{font-size:12px;color:var(--dsw-alias-label-secondary)}
|
|
2137
2138
|
.ud-model-io{font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
|
|
2138
2139
|
.ud-model-meta{font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
|
|
@@ -2143,8 +2144,9 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2143
2144
|
.ud-model-other--open{grid-template-rows:1fr}
|
|
2144
2145
|
.ud-model-other-list{overflow:hidden;min-height:0}
|
|
2145
2146
|
.ud-model-row--sub{min-height:0;padding:4px 4px 4px 28px;background:color-mix(in srgb,var(--dsw-alias-bg-layer-2) 55%,transparent);border-bottom:none}
|
|
2146
|
-
.ud-switch{display:inline-flex;align-items:center;cursor:pointer}
|
|
2147
|
-
|
|
2147
|
+
.ud-switch{display:inline-flex;align-items:center;cursor:pointer;position:relative}
|
|
2148
|
+
/* 透明覆盖 label 而非几何隐藏:聚焦位置即点击位置,防聚焦 scroll-into-view 连锁滚动嵌套滚动容器(弹窗导航被滚走整屏空白) */
|
|
2149
|
+
.ud-switch input[type="checkbox"] { position:absolute; inset:0; width:100%; height:100%; margin:0; opacity:0; cursor:pointer; }
|
|
2148
2150
|
.ud-switch__track{position:relative;width:${SWITCH_TRACK_WIDTH}px;height:${SWITCH_TRACK_HEIGHT}px;border-radius:999px;box-sizing:border-box;flex:none;background:var(--dsw-alias-border-l2);transition:background ${SWITCH_TRANSITION_MS}ms var(--ds-ease-in-out)}
|
|
2149
2151
|
.ud-switch__thumb{position:absolute;top:${SWITCH_EDGE_INSET}px;left:${SWITCH_EDGE_INSET}px;width:${SWITCH_THUMB_SIZE}px;height:${SWITCH_THUMB_SIZE}px;border-radius:50%;background:var(--dsw-alias-bg-layer-1);transition:transform ${SWITCH_TRANSITION_MS}ms var(--ds-ease-in-out)}
|
|
2150
2152
|
.ud-switch:not(:has(input[type="checkbox"]:disabled)):hover .ud-switch__track{background:color-mix(in srgb,var(--dsw-alias-border-l2) 85%,var(--dsw-alias-label-tertiary))}
|
|
@@ -2292,7 +2294,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2292
2294
|
const colorForModel = (models) => (model) => {
|
|
2293
2295
|
if (model === OTHER_MODEL) return 'var(--ud-chart-other)'
|
|
2294
2296
|
const slot = models.findIndex((item) => item.model === model)
|
|
2295
|
-
const rank = Math.min(slot < 0 ? 0 : slot,
|
|
2297
|
+
const rank = Math.min(slot < 0 ? 0 : slot, GROUP_UNFOLDED_MAX - 1) + 1
|
|
2296
2298
|
return `var(--ud-chart-${rank})`
|
|
2297
2299
|
}
|
|
2298
2300
|
|
package/src/index.js
CHANGED
|
@@ -55,6 +55,21 @@ const SETTINGS_SCHEMA = schemastery.object({
|
|
|
55
55
|
.description('定价规则(wire 由 /api/usage-dash/pricing 读写)'),
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
+
// 0.1.7 宿主以静态 Config 导出生成设置节表单(maintain 同构);legacy 宿主经
|
|
59
|
+
// settings.register 注册命名空间。根级 volatile 包装:整节 live 表单,节写经
|
|
60
|
+
// loader 原地热更;旧宿主 schemastery 无 volatile 方法,特性检测原样返回
|
|
61
|
+
export const Config = volatileWrap(SETTINGS_SCHEMA)
|
|
62
|
+
|
|
63
|
+
function volatileWrap(schema) {
|
|
64
|
+
return typeof schema.volatile === 'function' ? schema.volatile() : schema
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// volatile ref 动态解包(get 协议):0.1.7 下 apply 入参 config 为整节单 ref;
|
|
68
|
+
// legacy 宿主 config 为普通对象原样透传
|
|
69
|
+
function unwrapVolatile(value) {
|
|
70
|
+
return typeof value?.get === 'function' ? value.get() : value
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
export function apply(ctx, config) {
|
|
59
74
|
const store = sharedStore(ctx.storageDomain)
|
|
60
75
|
const collector = new UsageCollector(ctx, store)
|
|
@@ -70,19 +85,44 @@ export function apply(ctx, config) {
|
|
|
70
85
|
}
|
|
71
86
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
72
87
|
const settings = settingsCtx.settings
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
88
|
+
// legacy(≤0.1.6)方法面:register/get/update/describe 命名空间语义
|
|
89
|
+
if (typeof settings.get === 'function' && typeof settings.register === 'function') {
|
|
90
|
+
settings.register(SETTINGS_NAMESPACE, SETTINGS_SCHEMA, { base: config })
|
|
91
|
+
// 每日本地日首次写入时按保留值清理分钟/小时桶;启动回扫前的触发在下方回扫入口
|
|
92
|
+
store.retentionDays = () =>
|
|
93
|
+
settings.get(SETTINGS_NAMESPACE)?.minuteRetentionDays ?? DEFAULT_MINUTE_RETENTION_DAYS
|
|
94
|
+
pricing.active = true
|
|
95
|
+
pricing.rules = () => settings.get(SETTINGS_NAMESPACE)?.pricing?.rules ?? []
|
|
96
|
+
// revision 取 describe 中本 ns 描述符,settings 任何写入都会自增
|
|
97
|
+
pricing.revision = () =>
|
|
98
|
+
settings.describe().find((descriptor) => descriptor.ns === SETTINGS_NAMESPACE)?.revision ?? 0
|
|
99
|
+
pricing.replace = async (rules) => {
|
|
100
|
+
// update 深合并下数组整体替换,不动 minuteRetentionDays 用户层
|
|
101
|
+
await settings.update(SETTINGS_NAMESPACE, { pricing: { rules } })
|
|
102
|
+
return settings.get(SETTINGS_NAMESPACE)?.pricing?.rules ?? []
|
|
103
|
+
}
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
// 0.1.7+ 形态:静态 Config 导出生成节表单。关闭原生自动页(maintain 同构),
|
|
107
|
+
// 读经 apply 入参 config 整节解包,写经 configEditor.edit(fiber.entry 与命名空间
|
|
108
|
+
// 解耦),revision 读 describe 按条目 id 定位(0.1.7 描述符以 entry.options.id 为 ns)
|
|
109
|
+
if (typeof settings.configure === 'function') {
|
|
110
|
+
settingsCtx.effect(() => settings.configure({ auto: false }, ctx.fiber))
|
|
111
|
+
}
|
|
112
|
+
store.retentionDays = () => unwrapVolatile(config)?.minuteRetentionDays ?? DEFAULT_MINUTE_RETENTION_DAYS
|
|
77
113
|
pricing.active = true
|
|
78
|
-
pricing.rules = () =>
|
|
79
|
-
// revision 取 describe 中本 ns 描述符,settings 任何写入都会自增
|
|
114
|
+
pricing.rules = () => unwrapVolatile(config)?.pricing?.rules ?? []
|
|
80
115
|
pricing.revision = () =>
|
|
81
|
-
settings.describe().find((descriptor) => descriptor.ns ===
|
|
116
|
+
settings.describe?.().find((descriptor) => descriptor.ns === ctx.fiber?.entry?.options?.id)?.revision ?? 0
|
|
82
117
|
pricing.replace = async (rules) => {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
118
|
+
const editor = ctx.get('configEditor')
|
|
119
|
+
const entry = ctx.fiber?.entry
|
|
120
|
+
if (!editor || typeof editor.edit !== 'function' || !entry) {
|
|
121
|
+
throw new Error('usage-dash: configEditor inactive')
|
|
122
|
+
}
|
|
123
|
+
// change 回调整体替换 pricing 节,不动 minuteRetentionDays 用户层
|
|
124
|
+
await editor.edit(entry, (current) => ({ ...current, pricing: { rules } }))
|
|
125
|
+
return unwrapVolatile(config)?.pricing?.rules ?? []
|
|
86
126
|
}
|
|
87
127
|
})
|
|
88
128
|
const bootScan = async () => {
|
package/test/client.test.mjs
CHANGED
|
@@ -420,7 +420,7 @@ test('空态判定仅四项全零成立', () => {
|
|
|
420
420
|
assert.equal(isEmptyRange({ tokens: 0, cacheHit: 0, requests: 0, turns: 3 }), false)
|
|
421
421
|
})
|
|
422
422
|
|
|
423
|
-
test('groupStats
|
|
423
|
+
test('groupStats 折叠超免折叠上限条目为其他哨兵并归并逐日', () => {
|
|
424
424
|
const stats = {
|
|
425
425
|
models: [
|
|
426
426
|
{ model: 'p/m1', tokens: 500 },
|
|
@@ -436,8 +436,8 @@ test('groupStats 折叠 top5 之外为其他哨兵并归并逐日', () => {
|
|
|
436
436
|
],
|
|
437
437
|
}
|
|
438
438
|
const grouped = groupStats(stats)
|
|
439
|
-
assert.deepEqual(grouped.models.map((m) => m.model), ['p/m1', 'p/m2', 'p/m3', 'p/m4',
|
|
440
|
-
assert.equal(grouped.models[
|
|
439
|
+
assert.deepEqual(grouped.models.map((m) => m.model), ['p/m1', 'p/m2', 'p/m3', 'p/m4', OTHER_MODEL])
|
|
440
|
+
assert.equal(grouped.models[4].tokens, 100 + 50)
|
|
441
441
|
assert.deepEqual(grouped.daily[0].byModel, { 'p/m1': 400, 'p/m2': 500, [OTHER_MODEL]: 100 })
|
|
442
442
|
assert.deepEqual(grouped.daily[1].byModel, { 'p/m3': 300, [OTHER_MODEL]: 250 })
|
|
443
443
|
})
|
|
@@ -460,8 +460,8 @@ test('groupPointSlots 窗口模型超上限折叠其他', () => {
|
|
|
460
460
|
]
|
|
461
461
|
const grouped = groupPointSlots(slots)
|
|
462
462
|
assert.equal(grouped.models.at(-1).model, OTHER_MODEL)
|
|
463
|
-
assert.equal(grouped.models.at(-1).tokens, 1)
|
|
464
|
-
assert.deepEqual(grouped.daily[0].byModel, { m1: 1, m2: 1, m3: 1,
|
|
463
|
+
assert.equal(grouped.models.at(-1).tokens, 1 + 1)
|
|
464
|
+
assert.deepEqual(grouped.daily[0].byModel, { m1: 1, m2: 1, m3: 1, m6: 1, [OTHER_MODEL]: 1 + 1 })
|
|
465
465
|
})
|
|
466
466
|
|
|
467
467
|
test('cardsStatsOf 天视图用 range 聚合,时/分视图用点聚合,点未回为 null', () => {
|
|
@@ -485,9 +485,9 @@ test('usageModelsOf 天视图用预折叠分组,时/分视图折叠点窗口模
|
|
|
485
485
|
{ model: 'p/m6', tokens: 1 },
|
|
486
486
|
]
|
|
487
487
|
const folded = usageModelsOf('hour', null, { models: ranked })
|
|
488
|
-
assert.equal(folded.length,
|
|
488
|
+
assert.equal(folded.length, 5)
|
|
489
489
|
assert.equal(folded.at(-1).model, OTHER_MODEL)
|
|
490
|
-
assert.equal(folded.at(-1).tokens, 1)
|
|
490
|
+
assert.equal(folded.at(-1).tokens, 1 + 1)
|
|
491
491
|
// 空窗口契约:返回空数组渲染空环(与天视图空数据行为一致),非 null 隐藏
|
|
492
492
|
assert.deepEqual(usageModelsOf('minute', null, { models: [] }), [])
|
|
493
493
|
assert.equal(usageModelsOf('minute', null, null), null)
|
|
@@ -873,14 +873,14 @@ test('groupStats 哨兵折叠四桶求和,top 模型四桶透传', () => {
|
|
|
873
873
|
const grouped = groupStats(stats)
|
|
874
874
|
assert.equal(grouped.models[0].inputTokens, 300)
|
|
875
875
|
assert.equal(grouped.models[0].outputTokens, 200)
|
|
876
|
-
assert.deepEqual(grouped.models[
|
|
876
|
+
assert.deepEqual(grouped.models[4], {
|
|
877
877
|
model: OTHER_MODEL,
|
|
878
|
-
tokens: 50,
|
|
879
|
-
inputTokens: 10,
|
|
880
|
-
outputTokens: 30,
|
|
878
|
+
tokens: 100 + 50,
|
|
879
|
+
inputTokens: 60 + 10,
|
|
880
|
+
outputTokens: 40 + 30,
|
|
881
881
|
cacheReadTokens: 5,
|
|
882
882
|
cacheWriteTokens: 5,
|
|
883
|
-
items: [stats.models[5]],
|
|
883
|
+
items: [stats.models[4], stats.models[5]],
|
|
884
884
|
})
|
|
885
885
|
})
|
|
886
886
|
|
|
@@ -894,7 +894,7 @@ test('groupStats 哨兵费用同源:无 cost 输入不挂 cost,有 cost 求和',
|
|
|
894
894
|
{ model: 'p/m5', tokens: 100 },
|
|
895
895
|
{ model: 'p/m6', tokens: 50 },
|
|
896
896
|
], daily: [] })
|
|
897
|
-
assert.equal('cost' in bare.models[
|
|
897
|
+
assert.equal('cost' in bare.models[4], false)
|
|
898
898
|
const priced = groupStats({ models: [
|
|
899
899
|
{ model: 'p/m1', tokens: 500, cost: 1 },
|
|
900
900
|
{ model: 'p/m2', tokens: 400, cost: 2 },
|
|
@@ -904,7 +904,7 @@ test('groupStats 哨兵费用同源:无 cost 输入不挂 cost,有 cost 求和',
|
|
|
904
904
|
{ model: 'p/m6', tokens: 50, cost: 16 },
|
|
905
905
|
{ model: 'p/m7', tokens: 25, cost: 32 },
|
|
906
906
|
], daily: [] })
|
|
907
|
-
assert.equal(priced.models[
|
|
907
|
+
assert.equal(priced.models[4].cost, 8 + 16 + 32)
|
|
908
908
|
})
|
|
909
909
|
|
|
910
910
|
test('groupStats 哨兵保留其他模型明细供展开与提示', () => {
|
|
@@ -920,9 +920,26 @@ test('groupStats 哨兵保留其他模型明细供展开与提示', () => {
|
|
|
920
920
|
daily: [],
|
|
921
921
|
}
|
|
922
922
|
const grouped = groupStats(stats)
|
|
923
|
-
|
|
923
|
+
// Given 超过免折叠上限 When 折叠 Then 保留 top4,哨兵在末位并携带其余明细
|
|
924
|
+
assert.deepEqual(grouped.models[4].items, [{ model: 'p/m5', tokens: 100 }, { model: 'p/m6', tokens: 50 }])
|
|
924
925
|
// Given 旧形条目无四桶 When 折叠 Then 哨兵不合成零值四桶,与 top 条目降级形态一致
|
|
925
|
-
assert.equal('inputTokens' in grouped.models[
|
|
926
|
+
assert.equal('inputTokens' in grouped.models[4], false)
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
test('groupStats 条目不超免折叠上限时全展示', () => {
|
|
930
|
+
const stats = {
|
|
931
|
+
models: [
|
|
932
|
+
{ model: 'p/m1', tokens: 500 },
|
|
933
|
+
{ model: 'p/m2', tokens: 400 },
|
|
934
|
+
{ model: 'p/m3', tokens: 300 },
|
|
935
|
+
{ model: 'p/m4', tokens: 200 },
|
|
936
|
+
{ model: 'p/m5', tokens: 100 },
|
|
937
|
+
],
|
|
938
|
+
daily: [],
|
|
939
|
+
}
|
|
940
|
+
const grouped = groupStats(stats)
|
|
941
|
+
assert.equal(grouped.models.length, 5)
|
|
942
|
+
assert.equal(grouped.models.some((item) => item.model === OTHER_MODEL), false)
|
|
926
943
|
})
|
|
927
944
|
|
|
928
945
|
test('groupStats 逐日保留其他明细映射供 tooltip', () => {
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// settings 注入集成测试:apply 的 pricing 门面双形分派(legacy 方法面 / 0.1.7
|
|
2
|
+
// 静态 Config + configEditor 面)。stub ctx 装载真实 src/index.js,经捕获的
|
|
3
|
+
// /api/usage-dash/pricing 路由驱动门面,验证激活与读写链路。
|
|
4
|
+
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import { Readable } from 'node:stream'
|
|
8
|
+
|
|
9
|
+
const { apply } = await import('../src/index.js')
|
|
10
|
+
|
|
11
|
+
const PATH_PRICING = '/api/usage-dash/pricing'
|
|
12
|
+
const DEFAULT_MINUTE_RETENTION_DAYS = 7
|
|
13
|
+
|
|
14
|
+
const RULE = {
|
|
15
|
+
model: 'default/deepseek-chat',
|
|
16
|
+
currency: '¥',
|
|
17
|
+
price: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
|
|
18
|
+
conditions: [],
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// sharedStore 单例按 storageDomain 缓存:每例独立 domain 防串扰;域 open 缺席时
|
|
22
|
+
// store 走内存降级,单测无落盘
|
|
23
|
+
function makeDomain() {
|
|
24
|
+
return { open: async () => ({}) }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeRes() {
|
|
28
|
+
return {
|
|
29
|
+
statusCode: null,
|
|
30
|
+
body: null,
|
|
31
|
+
writeHead(status) { this.statusCode = status },
|
|
32
|
+
end(body) { this.body = body },
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeReq({ url, method = 'POST', headers = {}, body } = {}) {
|
|
37
|
+
const req = new Readable({ read() {} })
|
|
38
|
+
req.method = method
|
|
39
|
+
req.url = url
|
|
40
|
+
req.headers = headers
|
|
41
|
+
if (body !== undefined) req.push(body)
|
|
42
|
+
req.push(null)
|
|
43
|
+
return req
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const invoke = async (routes, path, reqOptions = {}) => {
|
|
47
|
+
const route = routes.get(path)
|
|
48
|
+
assert.ok(route, `route ${path} 未注册`)
|
|
49
|
+
const res = makeRes()
|
|
50
|
+
await route.handler(makeReq({ url: path, ...reqOptions }), res)
|
|
51
|
+
return res
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const invokeJson = (routes, path, { headers, payload, ...rest } = {}) =>
|
|
55
|
+
invoke(routes, path, {
|
|
56
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
57
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
58
|
+
...rest,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// legacy 方法面桩(≤0.1.6 SettingsProvider):register/get/update/describe 命名空间语义
|
|
62
|
+
function makeLegacySettings(store = {}) {
|
|
63
|
+
let revision = 0
|
|
64
|
+
return {
|
|
65
|
+
register() {},
|
|
66
|
+
get: () => store,
|
|
67
|
+
update: async (_ns, patch) => {
|
|
68
|
+
const pricing = patch.pricing
|
|
69
|
+
if (pricing) store.pricing = { ...(store.pricing ?? {}), ...pricing }
|
|
70
|
+
if (patch.minuteRetentionDays !== undefined) store.minuteRetentionDays = patch.minuteRetentionDays
|
|
71
|
+
revision += 1
|
|
72
|
+
},
|
|
73
|
+
describe: () => [{ ns: 'usage-dash', revision }],
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 0.1.7 形态桩:settings 无 register/get(configure/describe 在),configRef 为整节
|
|
78
|
+
// 单 ref 桩,configEditor 桩合并写回 configStore
|
|
79
|
+
function makeRc17({ configStore = {}, configEditorAvailable = true, withRoutes = true } = {}) {
|
|
80
|
+
const routes = new Map()
|
|
81
|
+
const editCalls = []
|
|
82
|
+
const configRef = { get: () => ({ ...configStore }) }
|
|
83
|
+
const settingsService = {
|
|
84
|
+
configure: (presentation, owner) => { return () => {} },
|
|
85
|
+
describe: () => [{ ns: 'usage-dash', revision: configStore.__revision ?? 0 }],
|
|
86
|
+
}
|
|
87
|
+
const configEditor = {
|
|
88
|
+
async edit(entry, change) {
|
|
89
|
+
editCalls.push(entry)
|
|
90
|
+
const next = change({ ...configStore }, {})
|
|
91
|
+
for (const key of Object.keys(next)) configStore[key] = next[key]
|
|
92
|
+
configStore.__revision = (configStore.__revision ?? 0) + 1
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
const pendingInjects = []
|
|
96
|
+
const ctx = {
|
|
97
|
+
on() {},
|
|
98
|
+
logger: { warn() {} },
|
|
99
|
+
fiber: { entry: { options: { id: 'usage-dash', name: '@mzzsfy/dsh-usage-dash' } } },
|
|
100
|
+
get(name) {
|
|
101
|
+
if (name === 'settings') return settingsService
|
|
102
|
+
if (name === 'configEditor') return configEditorAvailable ? configEditor : undefined
|
|
103
|
+
if (name === 'storageDomain') return makeDomain()
|
|
104
|
+
return undefined
|
|
105
|
+
},
|
|
106
|
+
effect(fn) { fn() },
|
|
107
|
+
inject(_deps, fn) { pendingInjects.push(fn) },
|
|
108
|
+
webServer: {
|
|
109
|
+
register(route) {
|
|
110
|
+
routes.set(route.path, route)
|
|
111
|
+
return () => routes.delete(route.path)
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
ctx,
|
|
117
|
+
routes,
|
|
118
|
+
configRef,
|
|
119
|
+
configStore,
|
|
120
|
+
editCalls,
|
|
121
|
+
activateSettings: () => {
|
|
122
|
+
for (const fn of pendingInjects.splice(0)) {
|
|
123
|
+
fn({
|
|
124
|
+
settings: settingsService,
|
|
125
|
+
interval: () => {},
|
|
126
|
+
// 注入上下文的 effect 桩:0.1.7 分支经它注册 configure 装配
|
|
127
|
+
effect(stubEffect) { stubEffect() },
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test('Config 导出契约:根级 volatile 包装,validate 产整节单 ref,默认值对拍', async () => {
|
|
135
|
+
const mod = await import('../src/index.js')
|
|
136
|
+
assert.ok(mod.Config, 'Config 导出必须在场')
|
|
137
|
+
const resolved = mod.Config['~standard'].validate({})
|
|
138
|
+
assert.equal(resolved.issues, undefined)
|
|
139
|
+
const section = resolved.value.get()
|
|
140
|
+
assert.equal(section.minuteRetentionDays, DEFAULT_MINUTE_RETENTION_DAYS)
|
|
141
|
+
// pricing 缺省对象校验补全:rules 数组缺省为空
|
|
142
|
+
assert.deepEqual(section.pricing?.rules ?? [], [])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('0.1.7 形态:settings 激活即门面生效,GET 读 configRef,POST 经 configEditor.edit 整节替换', async () => {
|
|
146
|
+
// Given configRef 携带用户值 + 路由捕获
|
|
147
|
+
const rc = makeRc17({ configStore: { minuteRetentionDays: 7, pricing: { rules: [] } } })
|
|
148
|
+
apply(rc.ctx, rc.configRef)
|
|
149
|
+
// When 激活 settings 注入
|
|
150
|
+
rc.activateSettings()
|
|
151
|
+
// Then GET pricing 回读 configRef 节值
|
|
152
|
+
const read = await invoke(rc.routes, PATH_PRICING, { method: 'GET' })
|
|
153
|
+
const readParsed = JSON.parse(read.body)
|
|
154
|
+
assert.equal(read.statusCode, 200)
|
|
155
|
+
assert.deepEqual(readParsed.value.rules, [])
|
|
156
|
+
// When POST 合法规则
|
|
157
|
+
const saved = await invokeJson(rc.routes, PATH_PRICING, { payload: { rules: [RULE] } })
|
|
158
|
+
// Then edit 定位本条目,pricing 整节替换,回读新值,revision 自增
|
|
159
|
+
const savedParsed = JSON.parse(saved.body)
|
|
160
|
+
assert.equal(saved.statusCode, 200)
|
|
161
|
+
assert.equal(savedParsed.ok, true)
|
|
162
|
+
assert.equal(rc.editCalls.length, 1)
|
|
163
|
+
assert.equal(rc.editCalls[0].options.id, 'usage-dash')
|
|
164
|
+
assert.deepEqual(rc.configStore.pricing.rules, [RULE])
|
|
165
|
+
assert.equal(rc.configStore.minuteRetentionDays, 7, 'minuteRetentionDays 用户层不动')
|
|
166
|
+
assert.deepEqual(savedParsed.value.rules, [RULE])
|
|
167
|
+
assert.equal(savedParsed.value.revision, 1)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('0.1.7 形态:configEditor 缺失即 POST 拒绝,GET 回读不受影响', async () => {
|
|
171
|
+
// Given configEditor 服务缺席
|
|
172
|
+
const rc = makeRc17({ configEditorAvailable: false })
|
|
173
|
+
apply(rc.ctx, rc.configRef)
|
|
174
|
+
rc.activateSettings()
|
|
175
|
+
// When POST 写规则
|
|
176
|
+
const saved = await invokeJson(rc.routes, PATH_PRICING, { payload: { rules: [RULE] } })
|
|
177
|
+
// Then ok:false 错误响应,进程存活(测试走完即证)
|
|
178
|
+
const savedParsed = JSON.parse(saved.body)
|
|
179
|
+
assert.equal(savedParsed.ok, false)
|
|
180
|
+
// When GET 回读
|
|
181
|
+
const read = await invoke(rc.routes, PATH_PRICING, { method: 'GET' })
|
|
182
|
+
// Then 仍回 configRef 当前值
|
|
183
|
+
assert.equal(read.statusCode, 200)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('0.1.7 形态:configure 在场即关闭原生自动页,register 缺失不抛错', () => {
|
|
187
|
+
// Given configure 捕获桩
|
|
188
|
+
const rc = makeRc17()
|
|
189
|
+
const configureCalls = []
|
|
190
|
+
rc.ctx.get('settings').configure = (presentation, owner) => { configureCalls.push({ presentation, owner }); return () => {} }
|
|
191
|
+
// When apply + 激活
|
|
192
|
+
apply(rc.ctx, rc.configRef)
|
|
193
|
+
rc.activateSettings()
|
|
194
|
+
// Then 关闭自动页且 owner 为本插件 fiber
|
|
195
|
+
assert.equal(configureCalls.length, 1)
|
|
196
|
+
assert.deepEqual(configureCalls[0].presentation, { auto: false })
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// 独立 legacy 用例:register/get/update 命名空间面行为与现状全同
|
|
200
|
+
test('legacy 形态:settings 激活即门面生效,replace 经命名空间 update,revision 经 describe', async () => {
|
|
201
|
+
const routes = new Map()
|
|
202
|
+
const store = {}
|
|
203
|
+
const settingsService = makeLegacySettings(store)
|
|
204
|
+
const pendingInjects = []
|
|
205
|
+
const ctx = {
|
|
206
|
+
on() {},
|
|
207
|
+
logger: { warn() {} },
|
|
208
|
+
get(name) {
|
|
209
|
+
if (name === 'settings') return settingsService
|
|
210
|
+
if (name === 'storageDomain') return makeDomain()
|
|
211
|
+
return undefined
|
|
212
|
+
},
|
|
213
|
+
effect(fn) { fn() },
|
|
214
|
+
inject(_deps, fn) { pendingInjects.push(fn) },
|
|
215
|
+
webServer: {
|
|
216
|
+
register(route) {
|
|
217
|
+
routes.set(route.path, route)
|
|
218
|
+
return () => routes.delete(route.path)
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
apply(ctx, {})
|
|
223
|
+
for (const fn of pendingInjects.splice(0)) {
|
|
224
|
+
fn({ settings: settingsService, interval: () => {}, effect(f) { f() } })
|
|
225
|
+
}
|
|
226
|
+
// GET 回读命名空间存储值
|
|
227
|
+
const read = await invoke(routes, PATH_PRICING, { method: 'GET' })
|
|
228
|
+
assert.equal(read.statusCode, 200)
|
|
229
|
+
// POST 合法规则经 update 落命名空间
|
|
230
|
+
const saved = await invokeJson(routes, PATH_PRICING, { payload: { rules: [RULE] } })
|
|
231
|
+
const savedParsed = JSON.parse(saved.body)
|
|
232
|
+
assert.equal(savedParsed.ok, true)
|
|
233
|
+
assert.deepEqual(store.pricing.rules, [RULE])
|
|
234
|
+
assert.deepEqual(savedParsed.value.rules, [RULE])
|
|
235
|
+
assert.equal(savedParsed.value.revision, 1)
|
|
236
|
+
})
|
|
237
|
+
|