dsh-spendingframe 0.0.0-stage → 0.2.2

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/lib/summary.js ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * 汇总一次请求需要的全部数字:当前高峰/空闲状态、当前会话花费、今日花费。
3
+ *
4
+ * “今日”按北京时间自然日切分;每一笔调用用它自己发生时刻的时段计价,
5
+ * 而不是用此刻的时段——跨过 9:00 / 12:00 / 14:00 / 18:00 边界时这才准确。
6
+ *
7
+ * 会话花费自 v0.2.0 起**包含子智能体**:DSH 把每个子智能体跑在独立会话里,
8
+ * 只统计本会话会把子智能体的开销藏起来(要点开子会话才看得见)。这里按会话头
9
+ * 的 `parentSession` / `origin` 递归汇总所有派生会话,并同时给出拆分值。
10
+ */
11
+
12
+ import { beijingDayStart, costOfUsage, isPeak, msUntilNextChange, priceTableFor, windowLabel } from './pricing.js'
13
+ import { collectSessions, resolveDshHome } from './session-logs.js'
14
+
15
+ function emptyTokens() {
16
+ return { hit: 0, miss: 0, out: 0 }
17
+ }
18
+
19
+ function addTokens(target, usage) {
20
+ target.hit += safeToken(usage?.cacheReadTokens)
21
+ target.miss += safeToken(usage?.inputTokens)
22
+ target.out += safeToken(usage?.outputTokens)
23
+ }
24
+
25
+ function safeToken(value) {
26
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0
27
+ }
28
+
29
+ /** 保留 6 位小数,抹掉浮点噪声后再进 JSON。 */
30
+ function round(value) {
31
+ return Math.round(value * 1e6) / 1e6
32
+ }
33
+
34
+ function emptyBucket(id) {
35
+ return { id, cost: 0, calls: 0, tokens: emptyTokens(), lastAt: null }
36
+ }
37
+
38
+ function absorb(bucket, cost, usage, time) {
39
+ bucket.cost += cost
40
+ bucket.calls += 1
41
+ addTokens(bucket.tokens, usage)
42
+ if (time !== null && (bucket.lastAt === null || time > bucket.lastAt)) bucket.lastAt = time
43
+ }
44
+
45
+ /**
46
+ * 判断一个会话是不是“子智能体会话”。
47
+ *
48
+ * DSH 写入会话头的标记(本机实测):子智能体是 `origin: "subagent"` 且
49
+ * `delegationDepth >= 1`;普通会话与 fork 出来的分支都是 depth 0 且没有 origin,
50
+ * 因此分支不会被算进父会话——它是一条独立的对话,不是父会话的一部分。
51
+ */
52
+ function isSubagentSession(session) {
53
+ return session.origin === 'subagent' || (typeof session.delegationDepth === 'number' && session.delegationDepth > 0)
54
+ }
55
+
56
+ /** 父会话 id → 直接子智能体会话 id 列表。 */
57
+ export function subagentChildren(sessions) {
58
+ const children = new Map()
59
+ for (const session of sessions) {
60
+ if (session.parentSession === null || session.parentSession === undefined) continue
61
+ if (!isSubagentSession(session)) continue
62
+ const list = children.get(session.parentSession) ?? []
63
+ list.push(session.sessionId)
64
+ children.set(session.parentSession, list)
65
+ }
66
+ return children
67
+ }
68
+
69
+ /** 广度优先收集全部派生会话 id(子、孙……)。 */
70
+ function descendantIds(rootId, children) {
71
+ const found = []
72
+ const seen = new Set([rootId])
73
+ const queue = [...(children.get(rootId) ?? [])]
74
+ while (queue.length > 0) {
75
+ const id = queue.shift()
76
+ if (seen.has(id)) continue // 血缘数据异常时不要死循环
77
+ seen.add(id)
78
+ found.push(id)
79
+ queue.push(...(children.get(id) ?? []))
80
+ }
81
+ return found
82
+ }
83
+
84
+ /**
85
+ * 纯汇总:给定会话列表与目标会话,算出全部数字。
86
+ *
87
+ * 与文件系统无关,测试可以直接喂合成会话。
88
+ *
89
+ * @param sessions - `{ sessionId, createdAt, parentSession, origin, delegationDepth, records }` 列表。
90
+ * @param options.sessionId - 目标会话 id;空串表示只统计今日。
91
+ * @param options.now - 注入的“现在”,便于测试。
92
+ */
93
+ export function summarize(sessions, options = {}) {
94
+ const now = typeof options.now === 'number' ? options.now : Date.now()
95
+ const sessionId = typeof options.sessionId === 'string' ? options.sessionId.trim() : ''
96
+ const todayStart = beijingDayStart(now)
97
+ const children = subagentChildren(sessions)
98
+ // 目标会话 + 它的全部派生会话:会话消耗的统计范围。
99
+ const scope = new Set(sessionId === '' ? [] : [sessionId, ...descendantIds(sessionId, children)])
100
+
101
+ const own = emptyBucket(sessionId)
102
+ const derived = emptyBucket('')
103
+ const todayAgg = { cost: 0, calls: 0, tokens: emptyTokens(), sessions: new Set() }
104
+ const models = new Map()
105
+ const assumedPricing = new Set()
106
+ let matched = false
107
+ let derivedSessions = 0
108
+
109
+ for (const session of sessions) {
110
+ const isTarget = sessionId !== '' && session.sessionId === sessionId
111
+ if (isTarget) matched = true
112
+ const isDerived = scope.has(session.sessionId) && !isTarget
113
+ if (isDerived) derivedSessions += 1
114
+ for (const record of session.records) {
115
+ // 会话头之前的记录不属于本会话(fork/继承),一律不计。
116
+ if (record.time !== null && session.createdAt !== null && record.time < session.createdAt) continue
117
+ const { table, assumed } = priceTableFor(record.model)
118
+ if (assumed && record.model !== '') assumedPricing.add(record.model)
119
+ const peak = record.time === null ? isPeak(now) : isPeak(record.time)
120
+ const cost = costOfUsage(record.usage, table, peak)
121
+
122
+ if (isTarget) absorb(own, cost, record.usage, record.time)
123
+ else if (isDerived) absorb(derived, cost, record.usage, record.time)
124
+
125
+ if (scope.has(session.sessionId)) {
126
+ const modelKey = record.model === '' ? '(未记录模型)' : record.model
127
+ const modelAgg = models.get(modelKey) ?? { id: modelKey, calls: 0, cost: 0 }
128
+ modelAgg.calls += 1
129
+ modelAgg.cost += cost
130
+ models.set(modelKey, modelAgg)
131
+ }
132
+
133
+ if (record.time !== null && record.time >= todayStart) {
134
+ todayAgg.cost += cost
135
+ todayAgg.calls += 1
136
+ addTokens(todayAgg.tokens, record.usage)
137
+ todayAgg.sessions.add(session.sessionId)
138
+ }
139
+ }
140
+ }
141
+
142
+ const peak = isPeak(now)
143
+ const totalTokens = {
144
+ hit: own.tokens.hit + derived.tokens.hit,
145
+ miss: own.tokens.miss + derived.tokens.miss,
146
+ out: own.tokens.out + derived.tokens.out,
147
+ }
148
+ const totalCalls = own.calls + derived.calls
149
+ const lastAt = own.lastAt === null ? derived.lastAt : derived.lastAt === null ? own.lastAt : Math.max(own.lastAt, derived.lastAt)
150
+
151
+ return {
152
+ ok: true,
153
+ at: now,
154
+ peak,
155
+ window: windowLabel(peak),
156
+ nextChangeInMs: msUntilNextChange(now),
157
+ session: {
158
+ id: sessionId,
159
+ found: matched,
160
+ // 含子智能体的总消耗:面板上显示的就是这个数。
161
+ cost: round(own.cost + derived.cost),
162
+ calls: totalCalls,
163
+ tokens: totalTokens,
164
+ lastAt,
165
+ // 拆分:本会话自身 / 全部派生会话(子、孙……)。
166
+ own: { cost: round(own.cost), calls: own.calls, tokens: own.tokens },
167
+ subagents: {
168
+ cost: round(derived.cost),
169
+ calls: derived.calls,
170
+ sessions: derivedSessions,
171
+ tokens: derived.tokens,
172
+ },
173
+ },
174
+ today: {
175
+ cost: round(todayAgg.cost),
176
+ calls: todayAgg.calls,
177
+ sessions: todayAgg.sessions.size,
178
+ tokens: todayAgg.tokens,
179
+ },
180
+ models: [...models.values()].map((model) => ({ ...model, cost: round(model.cost) })),
181
+ // 退回 flash 价目计价的模型:数字仍是估算,明确列出来而不是藏起来。
182
+ assumedPricing: [...assumedPricing],
183
+ }
184
+ }
185
+
186
+ /**
187
+ * 构造 `/dsh-spendingframe/summary` 的响应体:读盘 + {@link summarize}。
188
+ *
189
+ * @param options.sessionId - 需要单独统计的会话;空串表示只统计今日。
190
+ * @param options.now - 注入的“现在”,便于测试。
191
+ * @param options.home - DSH home;默认按 `DSH_HOME` / `~/.dsh` 解析。
192
+ */
193
+ export function buildSummary(options = {}) {
194
+ const home = typeof options.home === 'string' && options.home !== '' ? options.home : resolveDshHome()
195
+ const collected = options.sessions !== undefined
196
+ ? { sessions: options.sessions, errors: [], fileCount: options.sessions.length }
197
+ : collectSessions(home)
198
+ const summary = summarize(collected.sessions, options)
199
+ return {
200
+ ...summary,
201
+ version: VERSION,
202
+ home,
203
+ scan: { files: collected.fileCount, errors: collected.errors.length },
204
+ errors: collected.errors.slice(0, 3),
205
+ }
206
+ }
207
+
208
+ /** 与 package.json 保持一致的插件版本,随响应返回便于排查。 */
209
+ export const VERSION = '0.2.2'
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "dsh-spendingframe",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.2",
4
+ "description": "DSHBalance:DSH 插件,在会话输入框下方、上下文占用右侧显示 DeepSeek API 高峰/空闲状态、会话消耗(含子智能体)与今日花费。",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "registry": "https://registry.npmjs.org/",
8
+ "access": "public"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/JackyWadih14/DSHB.git"
13
+ },
14
+ "homepage": "https://github.com/JackyWadih14/DSHB#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/JackyWadih14/DSHB/issues"
17
+ },
18
+ "keywords": [
19
+ "dsh",
20
+ "deepseek-harness",
21
+ "deepseek",
22
+ "plugin",
23
+ "token-usage",
24
+ "cost"
25
+ ],
26
+ "engines": {
27
+ "node": ">=22"
28
+ },
29
+ "scripts": {
30
+ "test": "node tests/balance-probe.mjs && node tests/client-harness-test.mjs",
31
+ "test:real": "node tests/balance-probe.mjs --real && node tests/client-harness-test.mjs"
32
+ },
33
+ "type": "module",
34
+ "main": "lib/index.js",
35
+ "exports": {
36
+ ".": {
37
+ "default": "./lib/index.js"
38
+ },
39
+ "./client": "./client/client.js",
40
+ "./cordis.patch.yml": "./cordis.patch.yml",
41
+ "./package.json": "./package.json"
42
+ },
43
+ "dsh": {
44
+ "bundle": {
45
+ "patch": "./cordis.patch.yml"
46
+ },
47
+ "client": {
48
+ "platform": "web",
49
+ "inject": []
50
+ }
51
+ },
52
+ "files": [
53
+ "lib",
54
+ "client",
55
+ "tests",
56
+ "cordis.patch.yml",
57
+ "README.md",
58
+ "LICENSE"
59
+ ]
60
+ }
@@ -0,0 +1,132 @@
1
+ // Validation probe for the dsh-spendingframe host half: peak/off-peak rules, the
2
+ // official price table and the subagent lineage arithmetic.
3
+ //
4
+ // The default run is hermetic — synthetic data only, so it works on any machine
5
+ // and in CI. Pass --real to also read this machine's DSH session logs and assert
6
+ // against live numbers.
7
+ //
8
+ // Usage: node tests/balance-probe.mjs [--real]
9
+ import { readFileSync } from 'node:fs'
10
+ import { costOfUsage, isPeak, msUntilNextChange, priceTableFor, beijingDayStart } from '../lib/pricing.js'
11
+ import { VERSION, buildSummary, subagentChildren, summarize } from '../lib/summary.js'
12
+ import { collectSessions, resolveDshHome } from '../lib/session-logs.js'
13
+
14
+ /** 默认只跑合成用例;--real 才读本机会话日志。 */
15
+ const REAL = process.argv.includes('--real')
16
+
17
+ const failures = []
18
+ function check(label, actual, expected) {
19
+ // 基本类型比 ===,数组等结构比 JSON(断言里会出现子会话 id 列表)。
20
+ const ok = actual === expected || JSON.stringify(actual) === JSON.stringify(expected)
21
+ if (!ok) failures.push(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
22
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${label} = ${JSON.stringify(actual)}${ok ? '' : ` (expected ${JSON.stringify(expected)})`}`)
23
+ }
24
+
25
+ // --- peak / off-peak windows (Beijing time) -------------------------------
26
+ const bj = (iso) => Date.parse(iso) // e.g. 2026-10-12T10:00:00+08:00
27
+ check('周一 09:00 高峰', isPeak(bj('2026-10-12T09:00:00+08:00')), true)
28
+ check('周一 11:59 高峰', isPeak(bj('2026-10-12T11:59:00+08:00')), true)
29
+ check('周一 12:00 空闲', isPeak(bj('2026-10-12T12:00:00+08:00')), false)
30
+ check('周一 13:59 空闲', isPeak(bj('2026-10-12T13:59:00+08:00')), false)
31
+ check('周一 14:00 高峰', isPeak(bj('2026-10-12T14:00:00+08:00')), true)
32
+ check('周一 17:59 高峰', isPeak(bj('2026-10-12T17:59:00+08:00')), true)
33
+ check('周一 18:00 空闲', isPeak(bj('2026-10-12T18:00:00+08:00')), false)
34
+ check('周一 08:59 空闲', isPeak(bj('2026-10-12T08:59:00+08:00')), false)
35
+ check('周六 10:00 空闲(周末)', isPeak(bj('2026-10-10T10:00:00+08:00')), false)
36
+ check('周日 10:00 空闲(周末)', isPeak(bj('2026-10-11T10:00:00+08:00')), false)
37
+ check('国庆 10-01 周四 10:00 空闲(节假日)', isPeak(bj('2026-10-01T10:00:00+08:00')), false)
38
+ check('国庆 10-07 周三 10:00 空闲(节假日)', isPeak(bj('2026-10-07T10:00:00+08:00')), false)
39
+ check('10-08 周四 10:00 高峰(假期结束)', isPeak(bj('2026-10-08T10:00:00+08:00')), true)
40
+ check('春节 02-16 周一 10:00 空闲(节假日)', isPeak(bj('2026-02-16T10:00:00+08:00')), false)
41
+ check('02-24 周二 10:00 高峰(春节后)', isPeak(bj('2026-02-24T10:00:00+08:00')), true)
42
+ check('元旦 01-01 周四 10:00 空闲(节假日)', isPeak(bj('2026-01-01T10:00:00+08:00')), false)
43
+ check('2025 国庆 10-06 周一 10:00 空闲(节假日)', isPeak(bj('2025-10-06T10:00:00+08:00')), false)
44
+ check('2025 端午 06-02 周一 10:00 空闲(节假日)', isPeak(bj('2025-06-02T10:00:00+08:00')), false)
45
+ check('2025 05-06 周二 10:00 高峰(端午后)', isPeak(bj('2025-05-06T10:00:00+08:00')), true)
46
+
47
+ // --- price table (RMB per 1M tokens) --------------------------------------
48
+ const flash = priceTableFor('deepseek-flash')
49
+ const pro = priceTableFor('deepseek-v4-pro')
50
+ console.log('priceTableFor(deepseek-flash).assumed =', flash.assumed, '| pro.assumed =', pro.assumed)
51
+ const million = { inputTokens: 1_000_000, cacheReadTokens: 1_000_000, outputTokens: 1_000_000 }
52
+ check('flash 高峰 1M+1M+1M = 2 + 0.04 + 8', Number(costOfUsage(million, flash.table, true).toFixed(6)), 10.04)
53
+ check('flash 空闲 1M+1M+1M = 1 + 0.02 + 4', Number(costOfUsage(million, flash.table, false).toFixed(6)), 5.02)
54
+ check('pro 高峰 1M+1M+1M = 9 + 0.30 + 27', Number(costOfUsage(million, pro.table, true).toFixed(6)), 36.3)
55
+ check('pro 空闲 1M+1M+1M = 4.5 + 0.15 + 13.5', Number(costOfUsage(million, pro.table, false).toFixed(6)), 18.15)
56
+
57
+ // --- day boundary ---------------------------------------------------------
58
+ check('北京时间当天 00:00', new Date(beijingDayStart(bj('2026-10-12T10:00:00+08:00'))).toISOString(), '2026-10-11T16:00:00.000Z')
59
+ const until = msUntilNextChange(bj('2026-10-12T11:30:00+08:00'))
60
+ check('周一 11:30 距下一次切换 = 30 分钟', until, 30 * 60 * 1000)
61
+ console.log('周一 17:00 距下一次切换 =', msUntilNextChange(bj('2026-10-12T17:00:00+08:00')) / 60000, '分钟')
62
+
63
+ // --- 版本号与 package.json 一致 -------------------------------------------
64
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
65
+ check('VERSION 与 package.json 一致', VERSION, pkg.version)
66
+ check('package.json 已是 v0.2.2', pkg.version, '0.2.2')
67
+
68
+ // --- 本机真实数据(--real 才跑)-------------------------------------------
69
+ const now = Date.now()
70
+ if (REAL) {
71
+ const summary = buildSummary({ now })
72
+ console.log('\n=== 当前 =', new Date(now).toISOString(), '(北京时间', new Date(now + 8 * 3600e3).toISOString().slice(0, 16).replace('T', ' '), ')')
73
+ console.log('时段:', summary.window, '| peak =', summary.peak, '| 距切换', summary.nextChangeInMs, 'ms')
74
+ console.log('今日:', JSON.stringify(summary.today))
75
+ console.log('扫描:', JSON.stringify(summary.scan), '| 模型:', JSON.stringify(summary.models), '| 估价回退:', JSON.stringify(summary.assumedPricing))
76
+ check('响应里的版本号与 package.json 一致', summary.version, pkg.version)
77
+ } else {
78
+ console.log('\n(跳过本机真实数据段:加 --real 启用)')
79
+ }
80
+
81
+ // --- 子智能体血缘(合成数据:R 有子 A,A 又有子 A1,另有 R 的 fork F)------
82
+ const at = Date.parse('2026-10-11T10:00:00+08:00') // 周日 → 空闲时段
83
+ const sample = { inputTokens: 1e6, cacheReadTokens: 0, outputTokens: 0 }
84
+ const oneCall = [{ time: at, model: 'deepseek-flash', usage: sample }]
85
+ check('合成样本单价 = 1 元(空闲 1 元/百万 未命中输入)', Number(costOfUsage(sample, priceTableFor('deepseek-flash').table, isPeak(at)).toFixed(6)), 1)
86
+ const synthetic = [
87
+ { sessionId: 'R', createdAt: at, parentSession: null, origin: null, delegationDepth: 0, records: oneCall },
88
+ { sessionId: 'A', createdAt: at, parentSession: 'R', origin: 'subagent', delegationDepth: 1, records: oneCall },
89
+ { sessionId: 'A1', createdAt: at, parentSession: 'A', origin: 'subagent', delegationDepth: 2, records: oneCall },
90
+ { sessionId: 'F', createdAt: at, parentSession: 'R', origin: null, delegationDepth: 0, records: oneCall },
91
+ ]
92
+ check('血缘:R 的直接子会话', subagentChildren(synthetic).get('R'), ['A'])
93
+ const rootView = summarize(synthetic, { now: at, sessionId: 'R' })
94
+ check('会话消耗 = 本会话 + 全部派生会话(孙会话也在内)', rootView.session.cost, 3)
95
+ check('拆分:本会话', rootView.session.own.cost, 1)
96
+ check('拆分:子智能体', [rootView.session.subagents.cost, rootView.session.subagents.sessions], [2, 2])
97
+ check('fork 不并入父会话', summarize(synthetic, { now: at, sessionId: 'F' }).session.cost, 1)
98
+ check('今日仍统计全部会话', summarize(synthetic, { now: at }).today.cost, 4)
99
+ check('子会话自己看时只算自己的派生', summarize(synthetic, { now: at, sessionId: 'A' }).session.cost, 2)
100
+
101
+ // --- 真实数据里的子智能体会话(--real 才跑)--------------------------------
102
+ if (REAL) {
103
+ const real = collectSessions(resolveDshHome())
104
+ const realChildren = subagentChildren(real.sessions)
105
+ console.log('\n=== 真实血缘 ===')
106
+ for (const [parent, kids] of realChildren) console.log(` ${parent} → ${kids.join(', ')}`)
107
+ const parentIds = [...realChildren.keys()]
108
+ if (parentIds.length > 0) {
109
+ const parentId = parentIds[0]
110
+ const view = summarize(real.sessions, { now, sessionId: parentId })
111
+ const seen = new Set()
112
+ const stack = [...realChildren.get(parentId)]
113
+ while (stack.length > 0) {
114
+ const id = stack.pop()
115
+ if (seen.has(id)) continue
116
+ seen.add(id)
117
+ stack.push(...(realChildren.get(id) ?? []))
118
+ }
119
+ check('真实父会话:总量 = 本会话 + 子智能体', Number(view.session.cost.toFixed(6)), Number((view.session.own.cost + view.session.subagents.cost).toFixed(6)))
120
+ check('真实父会话:子智能体消耗 > 0', view.session.subagents.cost > 0, true)
121
+ check('真实父会话:派生的会话数正确', view.session.subagents.sessions, seen.size)
122
+ console.log(` 父会话 ${parentId.slice(-6)}:总 ${view.session.cost} = 本会话 ${view.session.own.cost} + 子智能体 ${view.session.subagents.cost}(${view.session.subagents.sessions} 个会话)`)
123
+ const kid = realChildren.get(parentId)[0]
124
+ const kidView = summarize(real.sessions, { now, sessionId: kid })
125
+ console.log(` 子会话 ${kid.slice(-6)}:总 ${kidView.session.cost}(其下派生 ${kidView.session.subagents.sessions} 个)`)
126
+ } else {
127
+ console.log(' (本机当前没有子智能体会话,跳过真实血缘断言)')
128
+ }
129
+ }
130
+
131
+ console.log(failures.length === 0 ? '\nALL CHECKS PASSED' : `\n${failures.length} CHECK(S) FAILED:\n- ${failures.join('\n- ')}`)
132
+ process.exitCode = failures.length === 0 ? 0 : 1