dsh-dfy-pet 2.7.0 → 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/index.js CHANGED
@@ -7,13 +7,16 @@
7
7
  * filesystem work happens here so the browser never needs CORS access or a
8
8
  * writable local directory.
9
9
  */
10
- import { performCheck, readHistory, readStatus, KEY_REF } from './monitor.js'; // 监控实现与凭据引用
10
+ import { performCheck, performUsageCheck, readHistory, readStatus, normalizeWebOrigin, KEY_REF } from './monitor.js'; // 监控实现、额度查询与凭据引用
11
+ import { collectTokenUsage } from './token-usage.js'; // 会话日志里的 Token 用量聚合
11
12
 
12
13
  export const inject = ['connection', 'credentials']; // 认证路由与只读凭据服务
13
14
 
14
15
  const CHECK_PATH = '/api/deepseek-pet/opencode/check'; // 检查路由绝对路径
15
16
  const HISTORY_PATH = '/api/deepseek-pet/opencode/history'; // 历史路由绝对路径
16
17
  const STATUS_PATH = '/api/deepseek-pet/opencode/status'; // 当前状态路由绝对路径
18
+ const USAGE_PATH = '/api/deepseek-pet/opencode/usage'; // OpenCode Go 剩余额度路由绝对路径
19
+ const TOKEN_USAGE_PATH = '/api/deepseek-pet/tokens/usage'; // Token 用量统计路由绝对路径
17
20
  const MAX_KEY_LENGTH = 4096; // 限制请求体里的 Key 长度
18
21
 
19
22
  function jsonResponse(body, status = 200) { // 统一的 JSON 响应
@@ -29,6 +32,8 @@ function errorResponse(error, status) { // 统一的错误响应
29
32
 
30
33
  export function apply(ctx) { // 注册监控路由
31
34
  const credentials = ctx.credentials; // 凭据服务
35
+ // 会话查询服务不写进 inject:万一宿主没挂载这个服务,也只是用量面板不可用,桌宠本身照常工作。
36
+ const readSessionQuery = () => (typeof ctx.get === 'function' ? ctx.get('sessionQuery') : ctx.sessionQuery); // 延迟取会话查询服务
32
37
  const routes = ctx.connection.fetch; // Host 认证路由注册表
33
38
  let queue = Promise.resolve(); // 串行化检查,避免并发写快照
34
39
  const enqueue = (task) => { // 把任务接入串行队列
@@ -105,4 +110,53 @@ export function apply(ctx) { // 注册监控路由
105
110
  } // 结束异常
106
111
  }, // 结束状态处理器
107
112
  }); // 结束状态路由
113
+
114
+ routes.register({ // 剩余额度路由
115
+ path: USAGE_PATH, // 绝对注册路径
116
+ methods: ['POST'], // 用 POST 才能接收可选的 Key 与来源覆盖
117
+ requestBody: 'buffered', // 缓冲读取请求体
118
+ fetch: async (request) => { // 处理一次额度查询
119
+ try { // 统一捕获失败
120
+ const body = await readBody(request); // 读取请求体
121
+ const provided = typeof body.apiKey === 'string' ? body.apiKey.trim() : ''; // 提取请求中的 Key
122
+ if (provided.length > MAX_KEY_LENGTH) return errorResponse(new Error('API Key 过长'), 400); // 拒绝超长 Key
123
+ await storeKey(provided); // 保存非空 Key,与模型检查共用同一凭据
124
+ const apiKey = provided.length > 0 ? provided : await resolveKey(); // 本次使用的 Key
125
+ const usageUrl = typeof body.usageUrl === 'string' ? body.usageUrl.trim() : ''; // 自定义用量来源
126
+ const fallbackUrl = typeof body.fallbackUrl === 'string' ? body.fallbackUrl.trim() : ''; // 备用用量来源
127
+ const rawOrgId = typeof body.orgId === 'string' ? body.orgId.trim() : ''; // 团队工作区 id
128
+ const orgId = /^[A-Za-z0-9_-]{1,64}$/.test(rawOrgId) ? rawOrgId : ''; // 只接受安全的组织 id
129
+ if (usageUrl.length > MAX_KEY_LENGTH || fallbackUrl.length > MAX_KEY_LENGTH) return errorResponse(new Error('接口地址过长'), 400); // 限制地址长度
130
+ for (const candidate of [usageUrl, fallbackUrl]) { // 逐个校验地址
131
+ if (candidate.length === 0) continue; // 未填写就跳过
132
+ normalizeWebOrigin(candidate); // 非法地址直接抛 400
133
+ } // 结束地址校验
134
+ const result = await enqueue(() => performUsageCheck({ apiKey, signal: request.signal, usageUrl, fallbackUrl, orgId })); // 串行查询,避免与检查同时写目录
135
+ return jsonResponse({ ok: true, ...result }); // 返回三窗口额度与来源
136
+ } catch (error) { // 查询失败
137
+ const status = error instanceof Error && /接口地址|API Key/.test(error.message) ? 400 : 502; // 参数错误用 400,上游错误用 502
138
+ return errorResponse(error, status); // 返回可读原因
139
+ } // 结束异常
140
+ }, // 结束额度处理器
141
+ }); // 结束剩余额度路由
142
+
143
+ routes.register({ // Token 用量路由
144
+ path: TOKEN_USAGE_PATH, // 绝对注册路径
145
+ methods: ['GET'], // 只读
146
+ requestBody: 'buffered', // 与注册协议保持一致
147
+ fetch: async (request) => { // 处理一次用量统计
148
+ try { // 统一捕获失败
149
+ const sessionQuery = readSessionQuery(); // 取会话查询服务
150
+ if (sessionQuery === undefined) return errorResponse(new Error('当前 Harness 没有启用会话查询服务'), 501); // 没有数据源就直说
151
+ const url = new URL(request.url); // 解析查询参数
152
+ const rawTimeZone = url.searchParams.get('timeZone') ?? ''; // 客户端所在时区
153
+ const timeZone = /^[A-Za-z0-9_+-]+(?:\/[A-Za-z0-9_+-]+){0,2}$/.test(rawTimeZone) ? rawTimeZone : 'Asia/Shanghai'; // 只接受形状安全的时区名
154
+ const force = url.searchParams.get('force') === '1'; // 是否强制重算,跳过缓存
155
+ const result = await collectTokenUsage(sessionQuery, { timeZone, force }); // 聚合会话日志里的用量
156
+ return jsonResponse({ ok: true, ...result }); // 返回统计结果
157
+ } catch (error) { // 统计失败
158
+ return errorResponse(error, 500); // 以 500 返回原因
159
+ } // 结束异常
160
+ }, // 结束用量处理器
161
+ }); // 结束 Token 用量路由
108
162
  } // 结束 apply
package/monitor.js CHANGED
@@ -3,6 +3,8 @@ import path from 'node:path'; // 路径拼接
3
3
  import os from 'node:os'; // 用户主目录
4
4
 
5
5
  export const MODELS_URL = 'https://opencode.ai/zen/go/v1/models'; // OpenCode Go 实时模型端点
6
+ export const USAGE_URL = 'https://opencode.ai/zen/go/v1/usage'; // OpenCode Go 剩余额度端点,与桌面版同源
7
+ export const CONSOLE_ORIGIN = 'https://opencode.ai/console'; // Console 站点根地址,用于替换接口来源与拼接用量页
6
8
  export const DOCS_URL = 'https://opencode.ai/docs/go/'; // 定价与月度配额文档
7
9
  export const SNAPSHOT_VERSION = 1; // 快照格式版本
8
10
  export const MAX_SNAPSHOTS = 50; // 本地最多保留的检查次数
@@ -398,3 +400,149 @@ export async function performCheck(options = {}) { // 执行一次检查
398
400
  const file = await writeSnapshot(snapshot, dir); // 原子写入
399
401
  return { ok: true, checkedAt: snapshot.checkedAt, baseline: diff.baseline, counts: diff.counts, added: diff.added, removed: diff.removed, changed: diff.changed, warnings: snapshot.warnings, models: snapshot.models, file, plans: (docs?.plans ?? []).map((plan) => plan.label) }; // 返回检查结果与当前模型
400
402
  } // 结束检查
403
+
404
+ export const USAGE_WINDOWS = [ // 服务端返回的三个额度窗口,顺序即展示顺序
405
+ { key: 'rolling', labelKey: 'ocWindowRolling' }, // 5 小时滚动窗口
406
+ { key: 'weekly', labelKey: 'ocWindowWeekly' }, // 每周窗口
407
+ { key: 'monthly', labelKey: 'ocWindowMonthly' }, // 每月窗口
408
+ ]; // 结束窗口定义
409
+ const MAX_CONSOLE_ORIGIN = 'https://console.opencode.ai'; // 旧版 Console 域名,需要改写到 opencode.ai 才能取到用量
410
+ const USAGE_TIMEOUT_MS = 15000; // 用量接口超时,超时后仍要报出余额为空而不是整块失败
411
+
412
+ function clampPercent(value) { // 把百分比收敛到 0-100
413
+ const number = numberOrNull(value); // 只接受有限数字
414
+ return number === null ? null : Math.max(0, Math.min(100, number)); // 越界值截断,非数字返回 null
415
+ } // 结束百分比收敛
416
+
417
+ function windowFromPayload(item) { // 解析单个额度窗口
418
+ const percent = clampPercent(item?.percent); // 已用百分比
419
+ if (percent === null) return null; // 缺字段视为该窗口无数据
420
+ const resetsAt = typeof item?.resetsAt === 'string' && Number.isFinite(Date.parse(item.resetsAt)) ? item.resetsAt : null; // 重置时刻,非法日期置空
421
+ return { percent, remaining: Math.max(0, Math.round((100 - percent) * 10) / 10), limited: item?.status === 'rate-limited', status: typeof item?.status === 'string' ? item.status : 'ok', resetsAt }; // 剩余百分比由已用推算
422
+ } // 结束窗口解析
423
+
424
+ export function parseUsagePayload(payload) { // 把 /zen/go/v1/usage 的响应转成内部结构
425
+ const usage = payload?.usage ?? {}; // 响应用 usage 包裹(实测为字符串百分比)
426
+ const windows = {}; // 三个窗口的结果
427
+ for (const window of USAGE_WINDOWS) { // 逐个窗口解析
428
+ windows[window.key] = windowFromPayload(usage[window.key]); // 缺数据时为 null
429
+ } // 结束窗口遍历
430
+ return { windows, fetchedAt: new Date().toISOString() }; // 附带抓取时间
431
+ } // 结束用量解析
432
+
433
+ export async function fetchUsage(apiKey, signal, options = {}) { // 读取 OpenCode Go 剩余额度
434
+ const key = typeof apiKey === 'string' ? apiKey.trim() : ''; // 统一 Key 输入
435
+ if (key.length === 0) throw new Error('还没有保存 OpenCode API Key,无法查询用量'); // 无 Key 直接给出可行动提示
436
+ const url = options.url ?? USAGE_URL; // 允许测试传入替身地址
437
+ const headers = { 'user-agent': 'deepseek-harness-opencode-go-monitor/1.0', accept: 'application/json', authorization: `Bearer ${key}` }; // 与桌面版相同的鉴权头
438
+ if (options.orgId) headers['x-opencode-org-id'] = options.orgId; // 团队工作区需要带上组织 id 才能看到本成员额度
439
+ const response = await fetchWithRetry(url, { headers, signal }, { timeoutMs: USAGE_TIMEOUT_MS, retries: 1 }); // 超时与一次重试
440
+ if (response.status === 401 || response.status === 403) { // 权限问题
441
+ await response.body?.cancel(); // 释放响应体
442
+ throw new Error(`用量查询认证失败(HTTP ${response.status}):这个 Key 可能不是 Console 的 oc_sk_ 密钥,或当前 IP 被限制`); // 指向真正的失败原因
443
+ } // 结束权限分支
444
+ if (!response.ok) { // 其他状态码
445
+ await response.body?.cancel(); // 释放响应体
446
+ throw new Error(`用量接口返回 HTTP ${response.status}`); // 保留状态便于排查
447
+ } // 结束状态判断
448
+ const payload = await response.json().catch(() => null); // 解析 JSON,坏体按空处理
449
+ if (payload === null) throw new Error('用量接口返回了无法解析的内容'); // 明确失败而不是显示空白
450
+ const parsed = parseUsagePayload(payload); // 规范化结构
451
+ if (USAGE_WINDOWS.every((window) => parsed.windows[window.key] === null)) throw new Error('用量接口没有返回任何额度窗口'); // 结构变化时提示
452
+ return parsed; // 返回解析结果
453
+ } // 结束用量抓取
454
+
455
+ export function normalizeWebOrigin(raw) { // 校验并规范化用户填写的接口地址
456
+ const text = typeof raw === 'string' ? raw.trim() : ''; // 统一成字符串
457
+ if (text.length === 0) return null; // 空值表示未填写
458
+ let parsed; // 解析结果
459
+ try { // 解析 URL
460
+ parsed = new URL(text); // 构造 URL
461
+ } catch (error) { // 非法地址
462
+ throw new Error('接口地址不是合法的 URL'); // 明确错误
463
+ } // 结束解析
464
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') throw new Error('接口地址必须是 http 或 https'); // 只接受网页协议
465
+ if (parsed.username.length > 0 || parsed.password.length > 0) throw new Error('接口地址不能包含账号密码'); // 拒绝把凭据塞进地址
466
+ return parsed.origin.replace(/^https:\/\/console\.opencode\.ai$/, MAX_CONSOLE_ORIGIN); // 旧域名统一改写到主站
467
+ } // 结束地址规范化
468
+
469
+ export function usagePageUrl(origin) { // 拼接 Console 用量页
470
+ return `${origin ?? MAX_CONSOLE_ORIGIN}/console`; // 没有自定义地址时指向默认 Console
471
+ } // 结束用量页地址
472
+
473
+ function usageFilePath(dir) { // 剩余额度缓存的路径
474
+ return path.join(dir, 'usage.json'); // 与模型快照同目录,便于一起清理
475
+ } // 结束路径解析
476
+
477
+ export async function readUsage(dir = snapshotDir()) { // 读取上一次成功保存的额度
478
+ try { // 容错读取
479
+ const raw = await fs.readFile(usageFilePath(dir), 'utf8'); // 读取文本
480
+ const parsed = JSON.parse(raw); // 解析 JSON
481
+ if (parsed && typeof parsed === 'object' && parsed.windows && typeof parsed.windows === 'object') return parsed; // 只接受有窗口的结构
482
+ return null; // 结构不符视为无缓存
483
+ } catch (error) { // 文件不存在或损坏
484
+ return null; // 缓存缺失不影响主流程
485
+ } // 结束容错
486
+ } // 结束额度读取
487
+
488
+ export async function writeUsage(usage, dir = snapshotDir()) { // 原子写入额度缓存
489
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 }); // 保证目录存在且仅本人可读
490
+ const target = usageFilePath(dir); // 目标文件
491
+ const temp = path.join(dir, `.usage.json.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`); // 临时文件
492
+ try { // 写入过程
493
+ await fs.writeFile(temp, JSON.stringify(usage, null, 2), { mode: 0o600 }); // 只写额度,不含 Key
494
+ await fs.rename(temp, target); // 同目录原子替换
495
+ } catch (error) { // 写入失败
496
+ await fs.rm(temp, { force: true }); // 清理临时文件
497
+ throw error; // 继续抛出
498
+ } // 结束异常
499
+ } // 结束额度写入
500
+
501
+ export async function performUsageCheck(options = {}) { // 查询一次额度并缓存
502
+ const dir = options.dir ?? snapshotDir(); // 缓存目录
503
+ const apiKey = typeof options.apiKey === 'string' ? options.apiKey.trim() : ''; // 本次使用的 Key
504
+ const stored = await readUsage(dir); // 先取上次结果,失败时还能展示
505
+ const errors = []; // 各来源的失败原因
506
+ let usage = null; // 成功的额度
507
+ let source = 'models'; // 默认沿用上方的模型端点来源
508
+ let origin = null; // 实际使用的 Console 地址
509
+ const url = typeof options.usageUrl === 'string' && options.usageUrl.trim().length > 0 ? options.usageUrl.trim() : USAGE_URL; // 默认官方地址,可通过设置覆盖
510
+ try { // 允许使用自定义来源
511
+ origin = normalizeWebOrigin(url); // 自定义地址写入来源提示
512
+ } catch (error) { // 原始地址不是合法来源
513
+ origin = null; // 视为使用默认
514
+ } // 结束来源解析
515
+ if (apiKey.length > 0) { // 有 Key 才请求
516
+ try { // 主来源
517
+ const parsed = await fetchUsage(apiKey, options.signal, { url, orgId: options.orgId }); // 请求官方用量
518
+ usage = parsed; // 记录成功结果
519
+ source = url === USAGE_URL ? 'official' : 'custom'; // 标注来源
520
+ } catch (error) { // 主来源失败
521
+ errors.push(error instanceof Error ? error.message : String(error)); // 收集原因
522
+ if (options.fallbackUrl) { // 配置了备用地址才尝试
523
+ try { // 备用来源
524
+ const parsed = await fetchUsage(apiKey, options.signal, { url: options.fallbackUrl, orgId: options.orgId }); // 请求备用用量
525
+ usage = parsed; // 记录成功结果
526
+ source = 'fallback'; // 标注备用来源
527
+ origin = normalizeWebOrigin(options.fallbackUrl) ?? origin; // 成功来源用于拼接页面地址
528
+ } catch (fallbackError) { // 备用也失败
529
+ errors.push(fallbackError instanceof Error ? fallbackError.message : String(fallbackError)); // 追加原因
530
+ } // 结束备用异常
531
+ } // 结束备用分支
532
+ } // 结束主来源异常
533
+ } else { // 没有 Key
534
+ errors.push('还没有保存 OpenCode API Key,无法查询用量'); // 给出可行动提示
535
+ } // 结束 Key 判断
536
+ if (usage !== null) await writeUsage({ ...usage, source }, dir).catch(() => undefined); // 成功才覆盖缓存,写失败不影响返回
537
+ const current = usage ?? stored; // 本次或上次
538
+ return { // 汇总结果
539
+ windows: current?.windows ?? {}, // 三窗口额度
540
+ fetchedAt: current?.fetchedAt ?? null, // 额度的时间
541
+ stale: usage === null && current !== null, // 展示的是上次结果
542
+ source: usage === null ? (current?.source ?? null) : source, // 数据来源
543
+ origin, // 用于打开用量页的地址来源
544
+ pageUrl: usagePageUrl(origin), // Console 页面地址
545
+ errors, // 失败原因
546
+ hasKey: apiKey.length > 0, // Key 状态
547
+ }; // 结束汇总
548
+ } // 结束额度检查
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-dfy-pet",
3
- "version": "2.7.0",
4
- "description": "A chibi DeepSeek whale-girl desktop pet with right-click AI relay provider configuration and an OpenCode Go model/pricing/quota monitor.",
3
+ "version": "2.9.0",
4
+ "description": "A chibi DeepSeek whale-girl desktop pet with right-click AI relay provider configuration, an OpenCode Go model/pricing/quota monitor, and a remaining-usage panel for the 5-hour, weekly and monthly Go allowance.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
7
7
  "deepseek-harness",
@@ -35,6 +35,7 @@
35
35
  "files": [
36
36
  "index.js",
37
37
  "monitor.js",
38
+ "token-usage.js",
38
39
  "client.js",
39
40
  "cordis.patch.yml",
40
41
  "locale/*.json",
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
+ }