dsh-dfy-pet 2.6.1 → 2.9.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/token-usage.js ADDED
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Host 侧的 Token 用量聚合。
3
+ *
4
+ * 数据来自 DSH 自己的会话日志:每次模型调用都会在 `assistant/message` 事件里
5
+ * 记下 `data.usage`(inputTokens / cacheReadTokens / outputTokens / totalTokens)
6
+ * 与 `data.message.source`(provider / model)。这里把这些事件汇总成
7
+ * 「本日 / 本周 / 本月」三个周期,以及每个周期内各模型的用量。
8
+ *
9
+ * 关键点是去重:DSH 的「种子会话」(isSeeded)会把父会话的整段历史复制进子会话,
10
+ * 父子两份日志里同一批事件各出现一次,直接相加会翻倍。日志里 `session/end-seed`
11
+ * 事件标出了继承与自有的分界,据此可以只认领「自有事件」;但当父会话已被删除时,
12
+ * 继承来的那段历史就没人认领了,这时必须由子会话代为计入,否则用量会凭空少一截。
13
+ */
14
+
15
+ /** 三个统计周期的键,顺序即界面上的展示顺序。 */
16
+ export const TOKEN_PERIODS = ['day', 'week', 'month'];
17
+
18
+ /** 聚合结果的默认缓存时长:会话日志重放不便宜,短时间内重复请求直接复用缓存。 */
19
+ const DEFAULT_TTL_MS = 60_000;
20
+
21
+ /** 超过这个天数的会话不再读取,避免历史会话把每次刷新拖慢。 */
22
+ const MAX_SESSION_AGE_DAYS = 120;
23
+
24
+ /** 每个周期回溯的天数:本日=0、本周=6(周一到今天共 7 天)、本月=29。 */
25
+ const PERIOD_SPAN_DAYS = { day: 0, week: 6, month: 29 };
26
+
27
+ /**
28
+ * 把毫秒时间戳换算成指定时区的日历日期。
29
+ * @param {number} ms - 毫秒时间戳。
30
+ * @param {string} timeZone - IANA 时区名,例如 Asia/Shanghai。
31
+ * @returns {{ y: number, m: number, d: number }} - 该时区下的年月日。
32
+ */
33
+ export function localDate(ms, timeZone) {
34
+ const parts = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date(ms)); // 取本地年月日各段
35
+ const lookup = {}; // 段类型到取值的映射
36
+ for (const part of parts) lookup[part.type] = part.value; // 逐段收集
37
+ return { y: Number(lookup.year), m: Number(lookup.month), d: Number(lookup.day) }; // 返回年月日
38
+ }
39
+
40
+ /**
41
+ * 把毫秒时间戳换算成指定时区的小时。
42
+ * @param {number} ms - 毫秒时间戳。
43
+ * @param {string} timeZone - IANA 时区名。
44
+ * @returns {number} - 0 到 23 的小时数。
45
+ */
46
+ export function localHour(ms, timeZone) {
47
+ const parts = new Intl.DateTimeFormat('en-US', { timeZone, hour12: false, hour: '2-digit' }).formatToParts(new Date(ms)); // 取本地小时
48
+ const lookup = {}; // 段类型到取值的映射
49
+ for (const part of parts) lookup[part.type] = part.value; // 逐段收集
50
+ return Number(lookup.hour) % 24; // 返回小时数
51
+ }
52
+
53
+ /**
54
+ * 求某个时区在给定瞬间相对 UTC 的偏移毫秒数。
55
+ * @param {number} ms - 毫秒时间戳。
56
+ * @param {string} timeZone - IANA 时区名。
57
+ * @returns {number} - 偏移毫秒,东八区为 -8 小时。
58
+ */
59
+ function zoneOffset(ms, timeZone) {
60
+ const parts = new Intl.DateTimeFormat('en-US', { timeZone, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).formatToParts(new Date(ms)); // 取本地时间各段
61
+ const lookup = {}; // 段类型到取值的映射
62
+ for (const part of parts) lookup[part.type] = part.value; // 逐段收集
63
+ const asUtc = Date.UTC(Number(lookup.year), Number(lookup.month) - 1, Number(lookup.day), Number(lookup.hour) % 24, Number(lookup.minute), Number(lookup.second)); // 把本地时间当作 UTC 拼出毫秒
64
+ return asUtc - ms; // 与真实 UTC 的差值就是时区偏移
65
+ }
66
+
67
+ /**
68
+ * 求指定时区里某个日历日期的零点时刻。
69
+ * @param {{ y: number, m: number, d: number }} date - 日历日期。
70
+ * @param {string} timeZone - IANA 时区名。
71
+ * @returns {number} - 该日零点的毫秒时间戳。
72
+ */
73
+ export function startOfLocalDate(date, timeZone) {
74
+ const base = Date.UTC(date.y, date.m - 1, date.d); // 先把日历日期当成 UTC 零点,得到一个初值
75
+ let guess = base; // 当前猜测的零点时刻
76
+ for (let round = 0; round < 3; round += 1) { // 最多校正三轮,跨夏令时也能收敛
77
+ const offset = zoneOffset(guess, timeZone); // 该猜测时刻下的时区偏移
78
+ const next = base - offset; // 用偏移修正出新的零点
79
+ if (next === guess) break; // 已经收敛,无需继续
80
+ guess = next; // 继续用新猜测
81
+ } // 结束校正
82
+ return guess; // 返回本地零点
83
+ }
84
+
85
+ /**
86
+ * 在日历日期上加减天数,避免夏令时导致固定 24 小时步长偏移。
87
+ * @param {{ y: number, m: number, d: number }} date - 起始日期。
88
+ * @param {number} days - 要加的天数,可为负。
89
+ * @returns {{ y: number, m: number, d: number }} - 结果日期。
90
+ */
91
+ export function addCalendarDays(date, days) {
92
+ const shifted = new Date(Date.UTC(date.y, date.m - 1, date.d) + days * 86_400_000); // 按 UTC 做日历加减
93
+ return { y: shifted.getUTCFullYear(), m: shifted.getUTCMonth() + 1, d: shifted.getUTCDate() }; // 转回年月日
94
+ }
95
+
96
+ /**
97
+ * 计算三个周期的起点。
98
+ * @param {number} now - 当前毫秒时间戳。
99
+ * @param {string} timeZone - IANA 时区名。
100
+ * @returns {Record<string, { start: number, startDate: { y: number, m: number, d: number } }>} - 各周期的起点。
101
+ */
102
+ export function periodBounds(now, timeZone) {
103
+ const today = localDate(now, timeZone); // 今天的日历日期
104
+ const todayStart = startOfLocalDate(today, timeZone); // 今天零点的时刻
105
+ // 星期几必须按「本地日历日期」算:本地零点的那个瞬间在东八区落在前一个 UTC 日,
106
+ // 直接取 getUTCDay() 会把周一算成周日、周起点整整差一天。
107
+ const weekday = new Date(Date.UTC(today.y, today.m - 1, today.d)).getUTCDay(); // 用日历日期本身求星期几
108
+ const daysSinceMonday = (weekday + 6) % 7; // 距本周一过了几天
109
+ const weekStartDate = addCalendarDays(today, -daysSinceMonday); // 本周一
110
+ const monthStartDate = { y: today.y, m: today.m, d: 1 }; // 本月 1 号
111
+ return { // 三个周期的起点
112
+ day: { start: todayStart, startDate: today }, // 本日零点
113
+ week: { start: startOfLocalDate(weekStartDate, timeZone), startDate: weekStartDate }, // 本周周一零点
114
+ month: { start: startOfLocalDate(monthStartDate, timeZone), startDate: monthStartDate }, // 本月 1 号零点
115
+ }; // 结束返回
116
+ }
117
+
118
+ /**
119
+ * 造一个空的序列点位。
120
+ * @param {string} key - 点的标识(小时序号或日期)。
121
+ * @param {string} label - 界面上的标签。
122
+ * @returns {object} - 空的点位。
123
+ */
124
+ function emptyPoint(key, label) {
125
+ return { key, label, input: 0, output: 0, cacheRead: 0, total: 0, calls: 0 }; // 各计数从零开始
126
+ }
127
+
128
+ /**
129
+ * 造一个与所选周期匹配的时间序列:本日按小时,本周与本月按天。
130
+ * 序列会补齐没有用量的时段,界面上才看得出空档。
131
+ * @param {Array<object>} records - 扁平的用量记录。
132
+ * @param {{ start: number, startDate: object }} bounds - 周期起点。
133
+ * @param {{ period: string, timeZone: string, now: number }} options - 周期、时区与当前时刻。
134
+ * @returns {{ unit: string, points: Array<object> }} - 序列单位与点位。
135
+ */
136
+ function buildSeries(records, bounds, options) {
137
+ const { period, timeZone, now } = options; // 解出周期、时区与当前时刻
138
+ const points = []; // 序列点位
139
+ const index = new Map(); // 点位键到点位对象的映射
140
+ if (period === 'day') { // 本日按小时切:一天只有一根柱子看不出起伏
141
+ for (let hour = 0; hour < 24; hour += 1) { // 铺满 24 个小时
142
+ const point = emptyPoint(String(hour), `${hour}`); // 标签就是小时数
143
+ points.push(point); // 依次加入
144
+ index.set(point.key, point); // 登记索引
145
+ } // 结束铺满
146
+ } else { // 本周与本月按天切
147
+ const today = localDate(now, timeZone); // 今天的日历日期
148
+ let cursor = bounds.startDate; // 从周期起始日期开始
149
+ for (let guard = 0; guard < 400; guard += 1) { // 最多铺 400 天,防御异常起点
150
+ const key = `${cursor.y}-${String(cursor.m).padStart(2, '0')}-${String(cursor.d).padStart(2, '0')}`; // 日期键
151
+ const point = emptyPoint(key, `${cursor.m}/${cursor.d}`); // 标签是月/日
152
+ points.push(point); // 依次加入
153
+ index.set(key, point); // 登记索引
154
+ if (cursor.y === today.y && cursor.m === today.m && cursor.d === today.d) break; // 到今天为止
155
+ cursor = addCalendarDays(cursor, 1); // 往后一天
156
+ } // 结束铺日
157
+ } // 结束切分方式
158
+ for (const record of records) { // 逐条记录落到对应点位
159
+ if (record.time < bounds.start) continue; // 周期起点之前的记录不计入
160
+ const point = period === 'day' // 本日落到小时点位
161
+ ? index.get(String(localHour(record.time, timeZone))) // 按小时找点位
162
+ : index.get(`${localDate(record.time, timeZone).y}-${String(localDate(record.time, timeZone).m).padStart(2, '0')}-${String(localDate(record.time, timeZone).d).padStart(2, '0')}`); // 按日期找点位
163
+ if (point === undefined) continue; // 找不到对应点位就跳过
164
+ point.input += record.input; // 累加未缓存输入
165
+ point.output += record.output; // 累加输出
166
+ point.cacheRead += record.cacheRead; // 累加缓存读取
167
+ point.total += record.total; // 累加总量
168
+ point.calls += 1; // 累加调用次数
169
+ } // 结束逐条落点
170
+ return { unit: period === 'day' ? 'hour' : 'day', points }; // 返回序列
171
+ }
172
+
173
+ /**
174
+ * 把若干条用量记录按模型与周期汇总。
175
+ * @param {Array<object>} records - 扁平的用量记录。
176
+ * @param {{ start: number, startDate: object }} bounds - 周期起点。
177
+ * @param {{ period: string, timeZone: string, now: number }} options - 周期、时区与当前时刻。
178
+ * @returns {object} - 该周期的汇总结果。
179
+ */
180
+ function summarize(records, bounds, options) {
181
+ const since = bounds.start; // 周期起点时刻
182
+ const models = new Map(); // 模型键到计数的映射
183
+ let input = 0; // 未缓存输入合计
184
+ let output = 0; // 输出合计
185
+ let cacheRead = 0; // 缓存读取合计
186
+ let total = 0; // 总量合计
187
+ let calls = 0; // 调用次数合计
188
+ for (const record of records) { // 逐条记录累加
189
+ if (record.time < since) continue; // 周期起点之前的记录不计入
190
+ input += record.input; // 累加未缓存输入
191
+ output += record.output; // 累加输出
192
+ cacheRead += record.cacheRead; // 累加缓存读取
193
+ total += record.total; // 累加总量
194
+ calls += 1; // 累加调用次数
195
+ const key = `${record.provider}::${record.model}`; // 模型键:提供方加模型名
196
+ let bucket = models.get(key); // 该模型的累计桶
197
+ if (bucket === undefined) { // 首次遇到这个模型
198
+ bucket = { provider: record.provider, model: record.model, calls: 0, input: 0, output: 0, cacheRead: 0, total: 0 }; // 新建桶
199
+ models.set(key, bucket); // 登记到映射里
200
+ } // 结束首次判断
201
+ bucket.calls += 1; // 累加该模型的调用次数
202
+ bucket.input += record.input; // 累加该模型的未缓存输入
203
+ bucket.output += record.output; // 累加该模型的输出
204
+ bucket.cacheRead += record.cacheRead; // 累加该模型的缓存读取
205
+ bucket.total += record.total; // 累加该模型的总量
206
+ } // 结束逐条累加
207
+ const modelList = [...models.values()].sort((a, b) => b.total - a.total); // 按总量降序
208
+ for (const bucket of modelList) { // 补上占比
209
+ bucket.share = total > 0 ? bucket.total / total : 0; // 该模型占本周期总量的比例
210
+ } // 结束补占比
211
+ const series = buildSeries(records, bounds, options); // 该周期自己的时间序列
212
+ return { since, totals: { input, output, cacheRead, total, calls }, models: modelList, series }; // 返回该周期汇总
213
+ }
214
+
215
+ /**
216
+ * 从一条会话日志里抽出带用量的模型调用。
217
+ * @param {Array<object>} events - 会话事件列表。
218
+ * @param {number} minSeq - 只认领 seq 大于该值的事件;传 -1 表示全认领。
219
+ * @returns {Array<object>} - 抽出的用量记录。
220
+ */
221
+ function extractUsage(events, minSeq) {
222
+ const rows = []; // 抽出的用量记录
223
+ for (const event of events) { // 逐个事件检查
224
+ if (event === null || typeof event !== 'object') continue; // 跳过异常事件
225
+ if (event.type !== 'assistant/message') continue; // 只有助手消息带用量
226
+ const data = event.data; // 事件正文
227
+ if (data === null || typeof data !== 'object') continue; // 跳过没有正文的
228
+ const usage = data.usage; // 用量对象
229
+ if (usage === null || typeof usage !== 'object') continue; // 没用量说明这步没真正调用模型
230
+ const seq = typeof event.seq === 'number' ? event.seq : 0; // 事件序号
231
+ if (seq <= minSeq) continue; // 超出认领范围
232
+ const source = (data.message === null || typeof data.message !== 'object' ? {} : data.message.source) || {}; // 该步实际调用的供应商与模型
233
+ rows.push({ // 记一条用量
234
+ time: typeof event.time === 'number' ? event.time : 0, // 事件时间戳
235
+ provider: typeof source.provider === 'string' ? source.provider : 'unknown', // 供应商标识
236
+ model: typeof source.model === 'string' ? source.model : 'unknown', // 模型标识
237
+ input: usage.inputTokens || 0, // 未命中缓存的输入 token
238
+ output: usage.outputTokens || 0, // 输出 token
239
+ cacheRead: usage.cacheReadTokens || 0, // 命中缓存读取的 token
240
+ total: usage.totalTokens || 0, // 该次调用总量
241
+ }); // 结束记录
242
+ } // 结束逐事件检查
243
+ return rows; // 返回用量记录
244
+ }
245
+
246
+ /**
247
+ * 找到日志里继承与自有的分界序号。
248
+ * @param {Array<object>} events - 会话事件列表。
249
+ * @returns {number} - `session/end-seed` 事件的序号;没有则返回 -1 表示全部自有。
250
+ */
251
+ function seedBoundary(events) {
252
+ let boundary = -1; // 默认没有继承
253
+ for (const event of events) { // 逐个事件检查
254
+ if (event === null || typeof event !== 'object') continue; // 跳过异常事件
255
+ if (event.type !== 'session/end-seed') continue; // 只认分界标记
256
+ if (typeof event.seq === 'number' && event.seq > boundary) boundary = event.seq; // 取最大的一个作为分界
257
+ } // 结束逐事件检查
258
+ return boundary; // 返回分界序号
259
+ }
260
+
261
+ /**
262
+ * 读取全部会话并汇总 Token 用量。
263
+ * @param {object} sessionQuery - DSH 的会话查询服务。
264
+ * @param {{ now?: number, timeZone?: string, ttlMs?: number, force?: boolean }} options - 可选项。
265
+ * @returns {Promise<object>} - 聚合结果。
266
+ */
267
+ export async function collectTokenUsage(sessionQuery, options = {}) {
268
+ const now = typeof options.now === 'number' ? options.now : Date.now(); // 当前时刻
269
+ const timeZone = typeof options.timeZone === 'string' && options.timeZone.length > 0 ? options.timeZone : 'Asia/Shanghai'; // 统计时区
270
+ const ttlMs = typeof options.ttlMs === 'number' ? options.ttlMs : DEFAULT_TTL_MS; // 缓存时长
271
+ const force = options.force === true; // 是否跳过缓存
272
+ const state = collectTokenUsage.state; // 模块级缓存,跨请求复用
273
+ if (!force && state.cache !== null && now - state.cachedAt < ttlMs) { // 命中新鲜缓存
274
+ return state.cache; // 直接返回缓存结果
275
+ } // 结束缓存判断
276
+ const listed = await sessionQuery.listSessions(); // 列出全部逻辑会话,最新的在前
277
+ const records = Array.isArray(listed) ? listed : []; // 归一成数组
278
+ const listedIds = new Set(); // 已登记的会话 id 集合
279
+ for (const record of records) { // 逐条登记
280
+ const id = record?.header?.id; // 会话 id
281
+ if (typeof id === 'string' && id.length > 0) listedIds.add(id); // 登记 id
282
+ } // 结束登记
283
+ const oldestAllowed = now - MAX_SESSION_AGE_DAYS * 86_400_000; // 允许读取的最老会话时间
284
+ const rows = []; // 全部用量记录
285
+ let scanned = 0; // 实际读取的会话数
286
+ let skipped = 0; // 跳过的会话数
287
+ let failed = 0; // 读取失败的会话数
288
+ for (const record of records) { // 逐个会话处理
289
+ const header = record?.header; // 会话头
290
+ const id = header?.id; // 会话 id
291
+ if (typeof id !== 'string' || id.length === 0) { // 没有可用 id
292
+ skipped += 1; // 计入跳过
293
+ continue; // 继续下一个
294
+ } // 结束 id 检查
295
+ const createdAt = typeof header.createdAt === 'number' ? header.createdAt : 0; // 创建时间
296
+ if (createdAt > 0 && createdAt < oldestAllowed) { // 太老的会话不读
297
+ skipped += 1; // 计入跳过
298
+ continue; // 继续下一个
299
+ } // 结束时效检查
300
+ let events = null; // 读到的会话事件
301
+ let inheritedCount = 0; // 会话声明的继承事件条数
302
+ try { // 读取会话日志
303
+ const snapshot = await sessionQuery.readSession(id); // 重放整份日志
304
+ events = Array.isArray(snapshot?.events) ? snapshot.events : []; // 归一成事件数组
305
+ inheritedCount = typeof snapshot?.inheritedEventCount === 'number' ? snapshot.inheritedEventCount : 0; // 继承条数
306
+ } catch (error) { // 读取失败
307
+ failed += 1; // 计入失败
308
+ continue; // 继续下一个会话
309
+ } // 结束读取
310
+ scanned += 1; // 计入已扫描
311
+ const boundary = seedBoundary(events); // 继承与自有的分界
312
+ const parentId = typeof header.parentSession === 'string' ? header.parentSession : ''; // 父会话 id
313
+ const parentPresent = parentId.length > 0 && listedIds.has(parentId); // 父会话是否还在
314
+ const parentReadable = parentPresent && !state.unreadable.has(parentId); // 父会话是否读得动
315
+ // 父会话还在就只认自有事件;父会话没了就由子会话把继承的一段也认领,否则这段用量会凭空消失。
316
+ // 分界优先用日志里的 session/end-seed 标记;标记缺失时退回会话声明的继承条数。
317
+ let minSeq = -1; // 默认整份日志都认领
318
+ if (parentPresent && parentReadable) { // 只有父会话还在时才要剔除继承段
319
+ minSeq = boundary >= 0 ? boundary : (inheritedCount > 0 ? inheritedCount - 1 : -1); // 取分界序号
320
+ } // 结束剔除判断
321
+ for (const row of extractUsage(events, minSeq)) rows.push(row); // 抽出并汇总用量
322
+ if (parentPresent && !parentReadable) state.unreadable.add(id); // 记下这个会话自己也读不动,供后续判断
323
+ } // 结束逐会话处理
324
+ const bounds = periodBounds(now, timeZone); // 三个周期的起点
325
+ const periods = {}; // 各周期汇总
326
+ for (const period of TOKEN_PERIODS) periods[period] = summarize(rows, bounds[period], { period, timeZone, now }); // 逐周期汇总,各自带上匹配的时间序列
327
+ const daily = new Map(); // 日期到当日合计的映射
328
+ for (const row of rows) { // 逐条记录归到日期
329
+ const day = localDate(row.time, timeZone); // 该条记录的本地日期
330
+ const key = `${day.y}-${String(day.m).padStart(2, '0')}-${String(day.d).padStart(2, '0')}`; // 日期键
331
+ let bucket = daily.get(key); // 当天的合计桶
332
+ if (bucket === undefined) { // 首次见到这天
333
+ bucket = { day: key, input: 0, output: 0, cacheRead: 0, total: 0, calls: 0 }; // 新建桶
334
+ daily.set(key, bucket); // 登记
335
+ } // 结束首次判断
336
+ bucket.input += row.input; // 累加当天未缓存输入
337
+ bucket.output += row.output; // 累加当天输出
338
+ bucket.cacheRead += row.cacheRead; // 累加当天缓存读取
339
+ bucket.total += row.total; // 累加当天总量
340
+ bucket.calls += 1; // 累加当天调用次数
341
+ } // 结束按日归并
342
+ const dailyList = [...daily.values()].sort((a, b) => (a.day < b.day ? -1 : 1)).slice(-30); // 按日期升序,只保留最近 30 天给界面画趋势
343
+ const result = { // 组装返回结果
344
+ fetchedAt: now, // 抓取时间
345
+ timeZone, // 统计时区
346
+ sessions: { listed: records.length, scanned, skipped, failed }, // 会话统计
347
+ periods, // 三个周期的汇总
348
+ daily: dailyList, // 近 N 天的每日趋势
349
+ spanDays: PERIOD_SPAN_DAYS, // 周期跨度说明,供界面标注
350
+ }; // 结束组装
351
+ state.cache = result; // 写入缓存
352
+ state.cachedAt = now; // 记录缓存时间
353
+ state.unreadable = new Set(); // 重置读不动的会话集合
354
+ return result; // 返回结果
355
+ }
356
+
357
+ /** 模块级缓存:跨请求复用聚合结果,避免每次都重放会话日志。 */
358
+ collectTokenUsage.state = { cache: null, cachedAt: 0, unreadable: new Set() };
359
+
360
+ /**
361
+ * 清空聚合缓存,供调试或强制刷新使用。
362
+ * @returns {void}
363
+ */
364
+ export function resetTokenUsageCache() {
365
+ collectTokenUsage.state.cache = null; // 丢掉缓存结果
366
+ collectTokenUsage.state.cachedAt = 0; // 把缓存时间归零
367
+ }