@loommii/dsh-provider-usage 0.6.1 → 0.7.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-provider-usage — DSH「用量中心」
2
2
 
3
- > 当前版本 **v0.6.1** · npm `@loommii/dsh-provider-usage` · [更新历史](docs/CHANGELOG.md)
3
+ > 当前版本 **v0.7.0** · npm `@loommii/dsh-provider-usage` · [更新历史](docs/CHANGELOG.md)
4
4
 
5
5
  DSH Web GUI 插件 **「用量中心」**:一个常驻右下角的用量卡片,实时显示你的 AI 服务用量 / 余额,不用再打开网页查;附带本地 Token 统计页,不联网也能看自己今天用了多少。
6
6
 
@@ -19,14 +19,14 @@ DSH Web GUI 插件 **「用量中心」**:一个常驻右下角的用量卡片
19
19
  |---|---|
20
20
  | OpenCode Go(订阅) | 5 小时 / 7 天 / 月度三个窗口的已用百分比 + 进度条 + 重置倒计时 |
21
21
  | DeepSeek(余额) | 账户余额(CNY) |
22
- | Command Code(订阅+余额,自定义) | 5 小时 / 周 / 月窗口使用量 + 月度剩余额度(USD),主数字为月已用百分比 |
22
+ | Command Code(订阅+余额,自定义) | 5 小时 / 周 / 月窗口使用量 + 月度剩余额度(USD),主数字为本月剩余百分比(3 位小数向下取整) |
23
23
 
24
24
  ## 功能
25
25
 
26
26
  - **常驻用量卡片**:右下角悬浮,一眼看到剩余量;可随意拖动,位置自动记住
27
27
  - **自动刷新**:每 30s 自动更新;想看最新数据点卡片上的 ↻ 立即重新查询
28
28
  - **多实例**:可以同时配置多个提供方(如多个 OpenCode Go 账号),点卡片 logo / 名称切换查看
29
- - **本地 Token 统计**:自读本机 `$DSH_HOME/sessions` 会话文件,按天聚合今天的 Token 用量(总量 + 按模型汇总),**只读本地、不联网**;数据落盘 `$DSH_HOME/provider-usage/daily-stats/`,历史天封存只算今天
29
+ - **本地 Token 统计**:自读本机 `$DSH_HOME/sessions` 会话文件,按天聚合今天的 Token 用量(按提供方分类 + 按模型汇总,汇总表跟随所选提供方),**只读本地、不联网**;数据落盘 `$DSH_HOME/provider-usage/daily-stats/`,历史天封存只算今天;设置页可一键重置统计缓存(不影响会话记录)
30
30
  - **状态提示**:正常 / 未更新 / 注意 / 错误,请求中显示「查询中」动画
31
31
  - **Key 安全**:密钥只在 DSH 进程内解析使用,界面上一律打码显示;自定义 Key 可加密保存在本机(AES-256 加密),不落明文
32
32
 
package/docs/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.7.0(2026-09-01)
4
+
5
+ ### 小功能
6
+ - **Command Code 卡片主数字改为「本月剩余百分比」**:原大数字为月已用百分比(整数、四舍五入),现改为月剩余百分比,保留 3 位小数向下取整(如 62.9432148907/70 = 89.91887…% → 显示 89.918%);标签「已用(每月)」→「剩余(每月)」
7
+ - host:`monthly` 新增 `remainingPct` 字段(剩余/总额 ×100,钳 [0,100] 后 `floor` 到 3 位小数);`usedPct` 保留(月窗口行仍显示已用口径)
8
+ - client:大数字读 `remainingPct`;连旧版服务端(无该字段)时按 `100 − usedPct` 同公式兜底;降级(无计划/未知计划)仍显示 `--`
9
+ - 回归:场景 20b/20c/20d 补断言,新增场景 20g(向下取整边界:1/70 → 1.428 非 1.429、69.999/70 → 99.998、满额 100、用完 0、超额钳 0)
10
+ - **用量统计页新增「重置统计」按钮**:「刷新」旁两段式确认(4s 未确认自动解除)后删除按天物化的派生缓存(天文件/游标/哨兵)并清空 30s 查询缓存,下次查询自动从会话原文全量重算;**会话原文只读不动**;天文件损坏 / 迁移异常 / 想强制重建时的自助兜底;新端点 `POST /api/provider-usage/reset-local-stats`(仅回环,POST-only)
11
+
12
+ ### 修复
13
+ - **用量统计页「按模型汇总」不随提供方选择切换(恒显示全部提供方的合并数据)**:chips/totals 按 `provider` 过滤,但 `byModel` 恒为全量聚合,且扁平 `{ 模型: 计数 }` 结构无提供方维度、服务端无法过滤
14
+ - host:`byModel` 升级为按提供方嵌套 `{ 提供方: { 模型: 计数 } }`(新会话折叠与天文件落盘同步新形态);响应按选中提供方过滤模型汇总,与 chips/totals 语义对齐;选中提供方恒返回嵌套形态(无数据 = 空对象)
15
+ - 存储迁移(验收回归修复):天文件格式升为 v2(`DAILY_VERSION = 2`);v0.6.1 旧扁平天文件按 deps 重折恢复真实提供方归属(会话文件仍是事实源,重写为嵌套形态);deps 缺失无法重折时才兜底迁入 `unknown` 桶;`.backfilled` 哨兵按版本判定,v1 哨兵触发一次性全量重折
16
+ - client:按选中提供方渲染模型表;「全部」时同名模型跨提供方合并求和展示;按响应形态自动判别、兼容 v0.6.1 旧服务端(无提供方维度时行为同旧版)
17
+ - 回归:host 场景 35(跨提供方同名模型、切换提供方模型表跟随)、场景 36a/36b(旧扁平天文件重折恢复归属 / 无 deps 兜底 unknown)、场景 37(v1 哨兵触发一次性全量重折)、场景 38(重置统计:清缓存重建 + 会话原文完好)
18
+ - 重置统计未清空天文件内存缓存(`dayCache`):重置后立即重查仍以旧天数据做种子,导致「重置对损坏天文件无效」且旧数字残留;现删盘上文件时一并清空(host 场景 39:投毒天文件在重置后从会话原文重建)
19
+ - 重置交互:确认重置后旧数据仍展示到新查询完成(沿用静默刷新策略,与「清空 → 重建」的用户预期相悖);现确认后立即清空展示进入「查询中」重建态。重置完成提示语此前无清除路径会常驻页面;现查询完成后自动消失
20
+ - 测试:user-agent 断言由硬编码 0.6.0 改为动态读取 `package.json` 版本(v0.6.1 升版时漏改,main 上存量失败)
21
+
3
22
  ## v0.6.1(2026-08-31)
4
23
 
5
24
  ### 修复
package/lib/client.js CHANGED
@@ -62,7 +62,7 @@ window.__ModuleLoader__.load({
62
62
  if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css="' + styleId + '"]') === null) {
63
63
  var css = [
64
64
  // 官方「模型」设置页完整样式(1:1 注入,dsh-client-ui-settings-models 同款)
65
- ".zGbnIq_section{max-width:720px;color:var(--dsw-alias-label-primary);flex-direction:column;gap:12px;display:flex}.zGbnIq_title{color:var(--dsw-alias-label-primary);margin:0;font-size:16px;font-weight:500;line-height:24px}.zGbnIq_intro{color:var(--dsw-alias-label-tertiary);margin:0;font-size:14px;line-height:22px}.zGbnIq_notice{color:var(--dsw-alias-state-warn-label);margin:0;font-size:12px;line-height:18px}.zGbnIq_savedNotice{color:var(--dsw-alias-state-success-primary);margin:0;font-size:12px;line-height:18px}.zGbnIq_rows{flex-direction:column;gap:8px;margin:12px 0 0;padding:0;list-style:none;display:flex}.zGbnIq_rowCard{border:1px solid var(--dsw-alias-border-l2);border-radius:12px;flex-direction:column;gap:12px;padding:12px 14px;display:flex}.zGbnIq_rowHead{align-items:center;gap:10px;display:flex}.zGbnIq_rowIdentity{align-items:center;gap:6px;min-width:0;display:inline-flex}.zGbnIq_rowName{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:22px}.zGbnIq_rowTag{border:1px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-secondary);border-radius:4px;flex:none;padding:1px 6px;font-size:11px;line-height:16px}.zGbnIq_credentialDot{box-sizing:border-box;border-radius:50%;flex:none;width:8px;height:8px;display:inline-block}.zGbnIq_credentialDotConfigured{background:var(--dsw-alias-state-success-primary)}.zGbnIq_credentialDotMissing{background:var(--dsw-alias-state-error-primary)}.zGbnIq_rowActions{align-items:center;gap:4px;margin-left:auto;display:inline-flex}.zGbnIq_primaryButton,.zGbnIq_secondaryButton,.zGbnIq_addButton{box-sizing:border-box;height:36px;font:inherit;cursor:pointer;border:none;border-radius:18px;justify-content:center;align-items:center;gap:4px;padding:0 14px;font-size:14px;line-height:22px;display:inline-flex}.zGbnIq_primaryButton{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}.zGbnIq_primaryButton:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}.zGbnIq_secondaryButton,.zGbnIq_addButton{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);background:0 0}.zGbnIq_secondaryButton:hover:not(:disabled),.zGbnIq_addButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.zGbnIq_secondaryButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-solid)}.zGbnIq_dangerButton{box-sizing:border-box;height:36px;color:var(--dsw-alias-state-error-primary);font:inherit;cursor:pointer;background:0 0;border:none;border-radius:18px;justify-content:center;align-items:center;padding:0 14px;font-size:14px;line-height:22px;display:inline-flex}.zGbnIq_dangerButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}.zGbnIq_rowActions .zGbnIq_secondaryButton,.zGbnIq_rowActions .zGbnIq_dangerButton{border-radius:14px;height:28px;padding:0 10px;font-size:12px;line-height:18px}.zGbnIq_primaryButton:disabled,.zGbnIq_secondaryButton:disabled,.zGbnIq_dangerButton:disabled,.zGbnIq_addButton:disabled,.zGbnIq_linkButton:disabled,.zGbnIq_addModelButton:disabled{opacity:.4;cursor:default}.zGbnIq_primaryButton:focus-visible,.zGbnIq_secondaryButton:focus-visible,.zGbnIq_dangerButton:focus-visible,.zGbnIq_addButton:focus-visible,.zGbnIq_linkButton:focus-visible,.zGbnIq_addModelButton:focus-visible,.zGbnIq_iconButton:focus-visible,.zGbnIq_customizedSummary:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-border-l3);outline:none}.zGbnIq_editor{background:var(--dsw-alias-bg-module-platform);border-radius:12px;flex-direction:column;gap:14px;padding:14px 16px;display:flex}.zGbnIq_editorHeader{align-items:baseline;gap:8px;display:flex}.zGbnIq_editorTitle{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:22px}.zGbnIq_editorRoute{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}.zGbnIq_field{flex-direction:column;gap:6px;display:flex}.zGbnIq_fieldLabel{color:var(--dsw-alias-label-secondary);align-items:center;gap:10px;font-size:12px;font-weight:500;line-height:18px;display:inline-flex}.zGbnIq_linkButton{box-sizing:border-box;height:28px;color:var(--dsw-alias-label-tertiary);font:inherit;cursor:pointer;background:0 0;border:none;border-radius:14px;align-items:center;padding:0 10px;font-size:12px;line-height:18px;display:inline-flex}.zGbnIq_linkButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}.zGbnIq_advancedHint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:18px}.zGbnIq_editorActions{justify-content:flex-end;gap:8px;display:flex}.zGbnIq_addBlock{flex-direction:column;gap:12px;display:flex}.zGbnIq_addActions{flex-wrap:wrap;gap:10px;display:flex}.zGbnIq_addButton{border:1px dashed var(--dsw-alias-border-l3);border-radius:12px;flex:1 1 0;gap:6px;min-width:180px;height:44px}.zGbnIq_addCard,.zGbnIq_setupCard{background:var(--dsw-alias-bg-module-platform);border-radius:12px;flex-direction:column;gap:14px;padding:14px 16px;list-style:none;display:flex}.zGbnIq_addCard .zGbnIq_editor,.zGbnIq_setupCard .zGbnIq_editor{background:0 0;padding:0}.zGbnIq_customized{border-top:1px solid var(--dsw-alias-border-l2);padding-top:10px}.zGbnIq_customizedSummary{cursor:pointer;width:fit-content;color:var(--dsw-alias-label-secondary);border-radius:6px;align-items:center;gap:6px;margin-left:-4px;padding:2px 4px;font-size:12px;font-weight:500;line-height:18px;list-style:none;display:flex}.zGbnIq_customizedSummary::-webkit-details-marker{display:none}.zGbnIq_customizedSummary:before{content:\"\";border-bottom:1.5px solid;border-right:1.5px solid;width:5px;height:5px;transition:transform .12s;transform:rotate(-45deg)translate(-1px,-1px)}.zGbnIq_customized[open]>.zGbnIq_customizedSummary:before{transform:rotate(45deg)translate(-1px,-1px)}.zGbnIq_customizedSummary:hover{color:var(--dsw-alias-label-primary)}.zGbnIq_customizedBody{flex-direction:column;gap:12px;padding-top:12px;display:flex}.zGbnIq_modelCatalog{border-top:1px solid var(--dsw-alias-border-l2);flex-direction:column;gap:10px;padding-top:12px;display:flex}.zGbnIq_modelCatalogHeading{flex-direction:column;gap:2px;display:flex}.zGbnIq_modelCatalogTitle{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:500;line-height:18px}.zGbnIq_modelListHead{justify-content:space-between;align-items:flex-start;gap:12px;display:flex}.zGbnIq_modelEntry{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px}.zGbnIq_iconButton{box-sizing:border-box;width:28px;height:28px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:none;border-radius:6px;justify-content:center;align-items:center;display:inline-flex}.zGbnIq_iconButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.zGbnIq_iconButton:disabled{cursor:default;opacity:.4}.zGbnIq_iconButtonDanger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary)}.zGbnIq_modelAdvanced{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:8px;padding:8px 4px 2px;display:grid}.zGbnIq_modelField{flex-direction:column;gap:4px;display:flex}.zGbnIq_modelFieldLabel{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}.zGbnIq_modelEmpty{border:1px dashed var(--dsw-alias-border-l3);text-align:center;border-radius:8px;padding:12px}.zGbnIq_addModelButton{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);height:28px;color:var(--dsw-alias-label-primary);font:inherit;cursor:pointer;background:0 0;border-radius:14px;align-self:flex-start;align-items:center;gap:4px;padding:0 10px;font-size:12px;line-height:18px;display:inline-flex}.zGbnIq_addModelButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.zGbnIq_input{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);width:100%;height:32px;font:inherit;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:14px;line-height:22px}select.zGbnIq_input{cursor:pointer;max-width:240px}.zGbnIq_input:focus{border-color:var(--dsw-alias-brand-primary);outline:none}.zGbnIq_input::placeholder{color:var(--dsw-alias-label-dimmed)}.zGbnIq_input:disabled{opacity:.6;cursor:default}.zGbnIq_selectInput{appearance:none;background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\");background-position:right 12px center;background-repeat:no-repeat;background-size:12px 12px;padding-right:32px}.zGbnIq_error{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px;line-height:18px}.zGbnIq_deleteDialog{width:min(480px,100%)}.zGbnIq_deleteConfirm:not(:disabled){border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}.zGbnIq_deleteConfirm:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}.zGbnIq_hiddenLabel{clip:rect(0 0 0 0);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden}@media (prefers-reduced-motion:reduce){.zGbnIq_customizedSummary:before{transition:none}}.zGbnIq_fetchDialog{--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);max-width:520px}.zGbnIq_candidateActions{justify-content:flex-end;margin-bottom:6px;display:flex}.zGbnIq_candidateList{flex-direction:column;gap:2px;max-height:320px;margin:0;padding:0;list-style:none;display:flex;overflow-y:auto}.zGbnIq_candidate{border-radius:6px}.zGbnIq_candidateLabel{cursor:pointer;align-items:center;gap:8px;padding:6px 8px;display:flex}.zGbnIq_candidateId{font-family:var(--ds-font-family-code);overflow-wrap:anywhere;flex:auto;font-size:13px}",
65
+ ".zGbnIq_section{max-width:720px;color:var(--dsw-alias-label-primary);flex-direction:column;gap:12px;display:flex}.zGbnIq_title{color:var(--dsw-alias-label-primary);margin:0;font-size:16px;font-weight:500;line-height:24px}.zGbnIq_intro{color:var(--dsw-alias-label-tertiary);margin:0;font-size:14px;line-height:22px}.zGbnIq_notice{color:var(--dsw-alias-state-warn-label);margin:0;font-size:12px;line-height:18px}.zGbnIq_savedNotice{color:var(--dsw-alias-state-success-primary);margin:0;font-size:12px;line-height:18px}.zGbnIq_rows{flex-direction:column;gap:8px;margin:12px 0 0;padding:0;list-style:none;display:flex}.zGbnIq_rowCard{border:1px solid var(--dsw-alias-border-l2);border-radius:12px;flex-direction:column;gap:12px;padding:12px 14px;display:flex}.zGbnIq_rowHead{align-items:center;gap:10px;display:flex}.zGbnIq_rowIdentity{align-items:center;gap:6px;min-width:0;display:inline-flex}.zGbnIq_rowName{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:22px}.zGbnIq_rowTag{border:1px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-secondary);border-radius:4px;flex:none;padding:1px 6px;font-size:11px;line-height:16px}.zGbnIq_credentialDot{box-sizing:border-box;border-radius:50%;flex:none;width:8px;height:8px;display:inline-block}.zGbnIq_credentialDotConfigured{background:var(--dsw-alias-state-success-primary)}.zGbnIq_credentialDotMissing{background:var(--dsw-alias-state-error-primary)}.zGbnIq_rowActions{align-items:center;gap:4px;margin-left:auto;display:inline-flex}.zGbnIq_primaryButton,.zGbnIq_secondaryButton,.zGbnIq_addButton{box-sizing:border-box;height:36px;font:inherit;cursor:pointer;border:none;border-radius:18px;justify-content:center;align-items:center;gap:4px;padding:0 14px;font-size:14px;line-height:22px;display:inline-flex}.zGbnIq_primaryButton{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}.zGbnIq_primaryButton:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}.zGbnIq_secondaryButton,.zGbnIq_addButton{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);background:0 0}.zGbnIq_secondaryButton:hover:not(:disabled),.zGbnIq_addButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.zGbnIq_secondaryButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-solid)}.zGbnIq_dangerButton{box-sizing:border-box;height:36px;color:var(--dsw-alias-state-error-primary);font:inherit;cursor:pointer;background:0 0;border:none;border-radius:18px;justify-content:center;align-items:center;padding:0 14px;font-size:14px;line-height:22px;display:inline-flex}.zGbnIq_dangerButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}.zGbnIq_rowActions .zGbnIq_secondaryButton,.zGbnIq_rowActions .zGbnIq_dangerButton{border-radius:14px;height:28px;padding:0 10px;font-size:12px;line-height:18px}.zGbnIq_primaryButton:disabled,.zGbnIq_secondaryButton:disabled,.zGbnIq_dangerButton:disabled,.zGbnIq_addButton:disabled,.zGbnIq_linkButton:disabled,.zGbnIq_addModelButton:disabled{opacity:.4;cursor:default}.zGbnIq_primaryButton:focus-visible,.zGbnIq_secondaryButton:focus-visible,.zGbnIq_dangerButton:focus-visible,.zGbnIq_addButton:focus-visible,.zGbnIq_linkButton:focus-visible,.zGbnIq_addModelButton:focus-visible,.zGbnIq_iconButton:focus-visible,.zGbnIq_customizedSummary:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-border-l3);outline:none}.zGbnIq_editor{background:var(--dsw-alias-bg-module-platform);border-radius:12px;flex-direction:column;gap:14px;padding:14px 16px;display:flex}.zGbnIq_editorHeader{align-items:baseline;gap:8px;display:flex}.zGbnIq_editorTitle{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:22px}.zGbnIq_editorRoute{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}.zGbnIq_field{flex-direction:column;gap:6px;display:flex}.zGbnIq_fieldLabel{color:var(--dsw-alias-label-secondary);align-items:center;gap:10px;font-size:12px;font-weight:500;line-height:18px;display:inline-flex}.zGbnIq_linkButton{box-sizing:border-box;height:28px;color:var(--dsw-alias-label-tertiary);font:inherit;cursor:pointer;background:0 0;border:none;border-radius:14px;align-items:center;padding:0 10px;font-size:12px;line-height:18px;display:inline-flex}.zGbnIq_linkButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}.zGbnIq_dangerButton{box-sizing:border-box;height:28px;color:var(--dsw-alias-state-warn-label);font:inherit;cursor:pointer;background:0 0;border:1px solid transparent;border-radius:14px;align-items:center;padding:0 10px;font-size:12px;line-height:18px;display:inline-flex;transition:border-color .15s,color .15s,background .15s}.zGbnIq_dangerButton:hover:not(:disabled){color:var(--dsw-alias-state-danger-primary,var(--dsw-alias-state-warn-label))}.zGbnIq_dangerButton:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-border-l3);outline:none}.zGbnIq_dangerButton:disabled{opacity:.6;cursor:default}.zGbnIq_dangerButton[data-arm=true]{color:var(--dsw-alias-state-danger-primary,#e5484d);border-color:var(--dsw-alias-state-danger-primary,#e5484d);background:color-mix(in srgb,var(--dsw-alias-state-danger-primary,#e5484d) 8%,transparent)}.zGbnIq_advancedHint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:18px}.zGbnIq_editorActions{justify-content:flex-end;gap:8px;display:flex}.zGbnIq_addBlock{flex-direction:column;gap:12px;display:flex}.zGbnIq_addActions{flex-wrap:wrap;gap:10px;display:flex}.zGbnIq_addButton{border:1px dashed var(--dsw-alias-border-l3);border-radius:12px;flex:1 1 0;gap:6px;min-width:180px;height:44px}.zGbnIq_addCard,.zGbnIq_setupCard{background:var(--dsw-alias-bg-module-platform);border-radius:12px;flex-direction:column;gap:14px;padding:14px 16px;list-style:none;display:flex}.zGbnIq_addCard .zGbnIq_editor,.zGbnIq_setupCard .zGbnIq_editor{background:0 0;padding:0}.zGbnIq_customized{border-top:1px solid var(--dsw-alias-border-l2);padding-top:10px}.zGbnIq_customizedSummary{cursor:pointer;width:fit-content;color:var(--dsw-alias-label-secondary);border-radius:6px;align-items:center;gap:6px;margin-left:-4px;padding:2px 4px;font-size:12px;font-weight:500;line-height:18px;list-style:none;display:flex}.zGbnIq_customizedSummary::-webkit-details-marker{display:none}.zGbnIq_customizedSummary:before{content:\"\";border-bottom:1.5px solid;border-right:1.5px solid;width:5px;height:5px;transition:transform .12s;transform:rotate(-45deg)translate(-1px,-1px)}.zGbnIq_customized[open]>.zGbnIq_customizedSummary:before{transform:rotate(45deg)translate(-1px,-1px)}.zGbnIq_customizedSummary:hover{color:var(--dsw-alias-label-primary)}.zGbnIq_customizedBody{flex-direction:column;gap:12px;padding-top:12px;display:flex}.zGbnIq_modelCatalog{border-top:1px solid var(--dsw-alias-border-l2);flex-direction:column;gap:10px;padding-top:12px;display:flex}.zGbnIq_modelCatalogHeading{flex-direction:column;gap:2px;display:flex}.zGbnIq_modelCatalogTitle{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:500;line-height:18px}.zGbnIq_modelListHead{justify-content:space-between;align-items:flex-start;gap:12px;display:flex}.zGbnIq_modelEntry{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px}.zGbnIq_iconButton{box-sizing:border-box;width:28px;height:28px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:none;border-radius:6px;justify-content:center;align-items:center;display:inline-flex}.zGbnIq_iconButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.zGbnIq_iconButton:disabled{cursor:default;opacity:.4}.zGbnIq_iconButtonDanger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary)}.zGbnIq_modelAdvanced{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:8px;padding:8px 4px 2px;display:grid}.zGbnIq_modelField{flex-direction:column;gap:4px;display:flex}.zGbnIq_modelFieldLabel{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}.zGbnIq_modelEmpty{border:1px dashed var(--dsw-alias-border-l3);text-align:center;border-radius:8px;padding:12px}.zGbnIq_addModelButton{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);height:28px;color:var(--dsw-alias-label-primary);font:inherit;cursor:pointer;background:0 0;border-radius:14px;align-self:flex-start;align-items:center;gap:4px;padding:0 10px;font-size:12px;line-height:18px;display:inline-flex}.zGbnIq_addModelButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.zGbnIq_input{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);width:100%;height:32px;font:inherit;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:14px;line-height:22px}select.zGbnIq_input{cursor:pointer;max-width:240px}.zGbnIq_input:focus{border-color:var(--dsw-alias-brand-primary);outline:none}.zGbnIq_input::placeholder{color:var(--dsw-alias-label-dimmed)}.zGbnIq_input:disabled{opacity:.6;cursor:default}.zGbnIq_selectInput{appearance:none;background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\");background-position:right 12px center;background-repeat:no-repeat;background-size:12px 12px;padding-right:32px}.zGbnIq_error{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px;line-height:18px}.zGbnIq_deleteDialog{width:min(480px,100%)}.zGbnIq_deleteConfirm:not(:disabled){border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}.zGbnIq_deleteConfirm:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}.zGbnIq_hiddenLabel{clip:rect(0 0 0 0);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden}@media (prefers-reduced-motion:reduce){.zGbnIq_customizedSummary:before{transition:none}}.zGbnIq_fetchDialog{--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);max-width:520px}.zGbnIq_candidateActions{justify-content:flex-end;margin-bottom:6px;display:flex}.zGbnIq_candidateList{flex-direction:column;gap:2px;max-height:320px;margin:0;padding:0;list-style:none;display:flex;overflow-y:auto}.zGbnIq_candidate{border-radius:6px}.zGbnIq_candidateLabel{cursor:pointer;align-items:center;gap:8px;padding:6px 8px;display:flex}.zGbnIq_candidateId{font-family:var(--ds-font-family-code);overflow-wrap:anywhere;flex:auto;font-size:13px}",
66
66
  // 官方「插件」设置页二级菜单样式(1:1 注入,dsh-client-ui-settings-plugins PluginsSettingsSection 同款)
67
67
  ".pbvGtq_section{max-width:760px;color:var(--dsw-alias-label-primary);flex-direction:column;gap:12px;display:flex}.pbvGtq_heading{margin:0;font-size:18px;font-weight:600}.pbvGtq_intro{color:var(--dsw-alias-label-tertiary);margin:0;font-size:13px}.pbvGtq_tabs{border-bottom:1px solid var(--dsw-alias-border-l2);align-items:flex-end;gap:22px;margin-top:2px;display:flex}.pbvGtq_tab{color:var(--dsw-alias-label-tertiary);font:inherit;cursor:pointer;background:0 0;border:0;padding:7px 1px 9px;font-size:13px;line-height:20px;position:relative}.pbvGtq_tab:hover,.pbvGtq_tab[data-active=true]{color:var(--dsw-alias-label-primary)}.pbvGtq_tab[data-active=true]:after,.pbvGtq_tab:focus-visible:after{background:var(--dsw-alias-label-primary);content:\"\";border-radius:2px 2px 0 0;height:2px;position:absolute;bottom:-1px;left:0;right:0}.pbvGtq_tab:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px;color:var(--dsw-alias-label-primary);border-radius:2px}.pbvGtq_panel{min-width:0;padding-top:2px}",
68
68
  // 用量统计页样式
@@ -406,10 +406,18 @@ window.__ModuleLoader__.load({
406
406
  var warn = data.isValid === false && data.invalidMessage
407
407
  ? React.createElement('div', { className: 'dsh-pu-error' }, data.invalidMessage)
408
408
  : null;
409
- // Command Code 混合卡:主数字 = 月已用百分比,副行 = 已用/总额 + 剩余额度
409
+ // Command Code 混合卡:主数字 = 月剩余百分比(3 位小数向下取整),副行 = 已用/总额 + 剩余额度
410
410
  var cc = null;
411
411
  if (isCommandCode) {
412
- var usedPct = data.monthly && data.monthly.usedPct;
412
+ var monthlyInfo = data.monthly || null;
413
+ // 新服务端直接给 remainingPct;旧服务端只有 usedPct → 按 100−usedPct 兜底(与 host 公式数学等价)
414
+ var remainPct = monthlyInfo
415
+ ? (monthlyInfo.remainingPct !== undefined && monthlyInfo.remainingPct !== null
416
+ ? monthlyInfo.remainingPct
417
+ : (monthlyInfo.usedPct === undefined || monthlyInfo.usedPct === null
418
+ ? null
419
+ : Math.floor(Math.max(0, Math.min(100, 100 - monthlyInfo.usedPct)) * 1000) / 1000))
420
+ : null;
413
421
  var usedStr = data.used === null ? '--' : Number(data.used).toFixed(2);
414
422
  var quotaStr = data.totalQuota === null ? '--' : Number(data.totalQuota).toFixed(2);
415
423
  var remainStr = data.remaining === null ? '--' : Number(data.remaining).toFixed(2);
@@ -421,8 +429,8 @@ window.__ModuleLoader__.load({
421
429
  var extra = null;
422
430
  body = React.createElement('div', null,
423
431
  React.createElement('div', { className: 'dsh-pu-remain' },
424
- React.createElement('span', { className: 'dsh-pu-remain-big' }, isCommandCode ? (usedPct === null ? '--' : Math.round(usedPct) + '%') : main),
425
- React.createElement('span', { className: 'dsh-pu-remain-label' }, isCommandCode ? ' ' + zh.used + '(' + zh.monthly + ')' : ' ' + mainLabel)),
432
+ React.createElement('span', { className: 'dsh-pu-remain-big' }, isCommandCode ? (remainPct === null ? '--' : remainPct.toFixed(3) + '%') : main),
433
+ React.createElement('span', { className: 'dsh-pu-remain-label' }, isCommandCode ? ' ' + zh.remaining + '(' + zh.monthly + ')' : ' ' + mainLabel)),
426
434
  warn,
427
435
  cc,
428
436
  winRows,
@@ -542,6 +550,9 @@ window.__ModuleLoader__.load({
542
550
  var [provider, setProvider] = useState(''); // '' = 全部
543
551
  var [range, setRange] = useState('1'); // 默认近 1 天;'0'=全部 / '1'=近1天 / '7'=近7天 / '30'=近30天
544
552
  var dataRef = useRef(null); // 轮询静默刷新判定用(避免闭包陈旧)
553
+ var [resetState, setResetState] = useState('idle'); // 重置统计:idle → arm(待确认)→ busy → idle
554
+ var [resetMsg, setResetMsg] = useState(null);
555
+ var resetTimerRef = useRef(null); // 两段式确认的 4s 自动解除
545
556
  function setDataBoth(v) { dataRef.current = v; setData(v); }
546
557
  function load(force) {
547
558
  // 自然日语义:近 1 天 = 今天 00:00 起;近 7/30 天 = 今天 + 前 N-1 个完整自然日(0 点边界)
@@ -560,7 +571,20 @@ window.__ModuleLoader__.load({
560
571
  else { setErr('无法读取本地用量统计'); }
561
572
  })
562
573
  .catch(function () { setErr('读取失败'); })
563
- .finally(function () { setLoading(false); });
574
+ .finally(function () { setLoading(false); setResetMsg(null); });
575
+ }
576
+ function doReset() {
577
+ if (resetState === 'busy') return;
578
+ setResetState('busy'); setResetMsg(null);
579
+ setDataBoth(null); setLoading(true); // 重置语义:立即清空旧数据展示,进入「查询中」重建态
580
+ fetch('/api/provider-usage/reset-local-stats', { method: 'POST', headers: { accept: 'application/json' } })
581
+ .then(function (r) { return r.json().catch(function () { return null; }); })
582
+ .then(function (j) {
583
+ if (j && j.ok) setResetMsg('已重置(清除 ' + (j.removed || 0) + ' 个缓存文件),正在重新统计…');
584
+ else setResetMsg('重置失败' + (j && j.error && j.error.message ? ':' + j.error.message : ''));
585
+ })
586
+ .catch(function () { setResetMsg('重置失败:网络错误'); })
587
+ .finally(function () { setResetState('idle'); load(true); });
564
588
  }
565
589
  useEffect(function () { if (active) load(false); }, [active, provider, range]);
566
590
  useEffect(function () {
@@ -574,7 +598,42 @@ window.__ModuleLoader__.load({
574
598
  var provs = data && Array.isArray(data.providers) ? data.providers.slice() : [];
575
599
  provs.sort();
576
600
  var byModel = (data && data.byModel && typeof data.byModel === 'object') ? data.byModel : {};
577
- var modelNames = Object.keys(byModel).sort();
601
+ // 形态判别:v0.7.0 = 嵌套 { 提供方: { 模型: 计数 } }(顶层值无 requests 数字);
602
+ // v0.6.1 旧服务端 = 扁平 { 模型: 计数 }(顶层值直接是计数条目)。
603
+ function byModelShape(m) {
604
+ for (var k in m) {
605
+ if (!Object.prototype.hasOwnProperty.call(m, k)) continue;
606
+ var v = m[k];
607
+ if (v && typeof v === 'object') return (typeof v.requests === 'number') ? 'flat' : 'nested';
608
+ }
609
+ return 'flat'; // 空对象:无模型行,形态无影响
610
+ }
611
+ var modelShape = byModelShape(byModel);
612
+ var modelData = {}; // 模型名 -> 计数条目(已按当前提供方选择过滤/合并)
613
+ if (modelShape === 'nested') {
614
+ if (provider === '') {
615
+ // 全部:同名模型跨提供方合并求和(unknown 桶=旧归属缺失事件,一并计入)
616
+ var merged = {};
617
+ for (var pk in byModel) {
618
+ if (!Object.prototype.hasOwnProperty.call(byModel, pk)) continue;
619
+ var bucket = byModel[pk];
620
+ if (!bucket || typeof bucket !== 'object' || typeof bucket.requests === 'number') continue;
621
+ for (var mk in bucket) {
622
+ if (!Object.prototype.hasOwnProperty.call(bucket, mk)) continue;
623
+ var e = bucket[mk], g = merged[mk];
624
+ if (!g) g = merged[mk] = { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
625
+ g.requests += e.requests || 0; g.inputTokens += e.inputTokens || 0;
626
+ g.outputTokens += e.outputTokens || 0; g.cacheReadTokens += e.cacheReadTokens || 0;
627
+ }
628
+ }
629
+ modelData = merged;
630
+ } else {
631
+ modelData = (byModel[provider] && typeof byModel[provider] === 'object' && typeof byModel[provider].requests !== 'number') ? byModel[provider] : {};
632
+ }
633
+ } else {
634
+ modelData = byModel; // v0.6.1 旧服务端:无提供方维度,行为同旧版
635
+ }
636
+ var modelNames = Object.keys(modelData).sort();
578
637
  // ── 按模型汇总表 ──
579
638
  function ModelNameCell(props) {
580
639
  var name = props.name;
@@ -637,25 +696,37 @@ window.__ModuleLoader__.load({
637
696
  }
638
697
 
639
698
  var modelRows = modelNames.map(function (mn) {
640
- var md = byModel[mn];
699
+ var md = modelData[mn] || { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
641
700
  var hit = (md.inputTokens + md.cacheReadTokens) > 0 ? md.cacheReadTokens / (md.inputTokens + md.cacheReadTokens) : 0;
642
701
  return { name: mn, requests: md.requests, inputTokens: md.inputTokens, outputTokens: md.outputTokens, cacheReadTokens: md.cacheReadTokens, hitRate: fmtPct(hit) };
643
702
  });
644
703
 
645
704
  return React.createElement("div", null,
646
705
  React.createElement("div", null,
647
- React.createElement("p", { className: "zGbnIq_intro", style: { margin: "0 0 8px" } }, "统计 DSH 会话日志中的 Token 用量(本机解析;历史按天存储,只算当天)。" + (data && data.days ? " 已聚合 " + data.days + " 天数据。" : "")),
706
+ React.createElement("p", { className: "zGbnIq_intro", style: { margin: "0 0 8px" } }, "统计 DSH 会话日志中的 Token 用量(本机解析;历史按天存储,只算当天;下方汇总跟随所选提供方)。" + (data && data.days ? " 已聚合 " + data.days + " 天数据。" : "")),
648
707
  React.createElement("div", { className: "zGbnIq_rowHead", style: { marginBottom: "4px" } },
649
708
  React.createElement("select", { className: "zGbnIq_input zGbnIq_selectInput", style: { width: "auto" }, value: range, onChange: function (e) { setRange(e.target.value); } },
650
709
  React.createElement("option", { value: "1" }, "近 1 天"),
651
710
  React.createElement("option", { value: "7" }, "近 7 天"),
652
711
  React.createElement("option", { value: "30" }, "近 30 天"),
653
712
  React.createElement("option", { value: "0" }, "全部时间")),
654
- React.createElement("button", { type: "button", className: "zGbnIq_linkButton", onClick: function () { load(true); } }, "刷新"))),
713
+ React.createElement("button", { type: "button", className: "zGbnIq_linkButton", onClick: function () { load(true); } }, "刷新"),
714
+ React.createElement("button", {
715
+ type: "button",
716
+ className: "zGbnIq_dangerButton",
717
+ "data-arm": resetState === 'arm' ? "true" : undefined,
718
+ disabled: resetState === 'busy',
719
+ "aria-label": resetState === 'arm' ? "再次点击确认重置本地统计" : "重置本地统计(清除按天缓存并重新统计,不影响会话记录)",
720
+ onClick: function () {
721
+ if (resetState === 'arm') { doReset(); }
722
+ else { setResetState('arm'); setResetMsg(null); window.clearTimeout(resetTimerRef.current); resetTimerRef.current = window.setTimeout(function () { setResetState('idle'); }, 4000); }
723
+ },
724
+ }, resetState === 'arm' ? "确认重置?" : resetState === 'busy' ? "重置中…" : "重置统计"))),
655
725
  loading ? React.createElement("div", null,
656
726
  React.createElement("div", { className: "zGbnIq_progress" }, React.createElement("i", null)),
657
727
  React.createElement("p", { className: "zGbnIq_advancedHint" }, "查询中…")) : null,
658
728
  err ? React.createElement("p", { className: "zGbnIq_error" }, err) : null,
729
+ resetMsg ? React.createElement("p", { className: "zGbnIq_advancedHint" }, resetMsg) : null,
659
730
  total ? React.createElement("div", null,
660
731
  React.createElement("div", { className: "zGbnIq_chips" }, chips),
661
732
  stats,
@@ -663,7 +734,8 @@ window.__ModuleLoader__.load({
663
734
  React.createElement("div", { className: "zGbnIq_modelCatalogHeading" },
664
735
  React.createElement("span", { className: "zGbnIq_modelCatalogTitle" }, "按模型汇总")),
665
736
  React.createElement(ModelSummaryTable, { rows: modelRows }))) : null,
666
- !total && !loading && !err && data ? React.createElement("div", { className: "zGbnIq_modelEmpty" }, "没有匹配的 DSH 会话。") : null);
737
+ !total && !loading && !err && data ? React.createElement("div", { className: "zGbnIq_modelEmpty" }, "没有匹配的 DSH 会话。") : null,
738
+ total && modelRows.length === 0 ? React.createElement("div", { className: "zGbnIq_modelEmpty" }, total.requests > 0 ? "旧版本统计的用量暂无模型归属明细。" : "该时间范围内没有此提供方的模型用量。") : null);
667
739
  }
668
740
  // 提供方状态归一:dot 颜色/标签/可点击性/展开内容都从这里出,6 个 if-else 收敛为 1 个表
669
741
  // level: 'ok' 绿 / 'err' 红 / 'loading' 脉冲灰 / 'unknown' 静默灰
@@ -8,7 +8,9 @@ import { promises as fs } from 'node:fs'
8
8
  import { init as zstdInit, decompress as zstdWasmDec } from '@bokuweb/zstd-wasm'
9
9
  import { decompress as zstdFzDec } from 'fzstd'
10
10
 
11
- export const DAILY_VERSION = 1
11
+ // v2(v0.7.0):byModel 升级为按提供方嵌套 { 提供方: { 模型: 计数 } };v1 为扁平 { 模型: 计数 }。
12
+ // .backfilled 哨兵按版本判定:v1 哨兵会触发一次性全量重折(从会话文件恢复提供方归属)。
13
+ export const DAILY_VERSION = 2
12
14
 
13
15
  /** 本地时区日期键:YYYY-MM-DD。 */
14
16
  export function dayKey(ms) {
@@ -152,7 +154,8 @@ export function parseEvents(text) {
152
154
  /** 把事件流中落在 [dayStart, dayEnd) 的 assistant/message 记账折叠成按天 totals。
153
155
  * 归属:message.source.provider/model,缺失回退 request/header;targetProvider 为空 = 全部 provider;
154
156
  * 事件 time 缺失按当天计入(宽松)。返回 { byProvider, byModel },键值含
155
- * { requests, inputTokens, outputTokens, cacheReadTokens }。 */
157
+ * { requests, inputTokens, outputTokens, cacheReadTokens }
158
+ * byModel 为按提供方嵌套:{ <provider>: { <model>: e } }(v0.7.0 起,支持按提供方过滤模型汇总)。 */
156
159
  /** 内部:把一条记账累加到 byProvider/byModel 桶。 */
157
160
  function bump(map, key, usage) {
158
161
  let e = map[key]
@@ -203,13 +206,16 @@ export function foldEventsByDay(events, dayStart, dayEnd, targetProvider) {
203
206
  const guess = stepGuess(data, headerProvider, headerModel)
204
207
  if (target !== null && guess.stepProvider !== target) continue
205
208
  bump(byProvider, guess.stepProvider || 'unknown', usage)
206
- bump(byModel, guess.stepModel || 'unknown', usage)
209
+ const provKey = guess.stepProvider || 'unknown'
210
+ if (!byModel[provKey]) byModel[provKey] = {}
211
+ bump(byModel[provKey], guess.stepModel || 'unknown', usage)
207
212
  }
208
213
  return { byProvider, byModel }
209
214
  }
210
215
 
211
216
  /** 把事件流按天分组折叠(首次全量回填用):返回 { <YYYY-MM-DD>: { byProvider, byModel },
212
- * unknown: {...} }——time 缺失的事件归 unknown 桶。targetProvider 为空 = 全部 provider。 */
217
+ * unknown: {...} }——time 缺失的事件归 unknown 桶。targetProvider 为空 = 全部 provider。
218
+ * byModel 按提供方嵌套(v0.7.0 起,见 foldEventsByDay)。 */
213
219
  export function foldEventsByDays(events, targetProvider) {
214
220
  const target = (typeof targetProvider === 'string' && targetProvider.trim() !== '') ? targetProvider.trim() : null
215
221
  const days = {}
@@ -236,14 +242,29 @@ export function foldEventsByDays(events, targetProvider) {
236
242
  let bucket = days[key]
237
243
  if (!bucket) { bucket = { byProvider: {}, byModel: {} }; days[key] = bucket }
238
244
  bump(bucket.byProvider, guess.stepProvider || 'unknown', usage)
239
- bump(bucket.byModel, guess.stepModel || 'unknown', usage)
245
+ const provKey = guess.stepProvider || 'unknown'
246
+ if (!bucket.byModel[provKey]) bucket.byModel[provKey] = {}
247
+ bump(bucket.byModel[provKey], guess.stepModel || 'unknown', usage)
240
248
  }
241
249
  return days
242
250
  }
243
251
 
244
- /** src byProvider/byModel 累加进 dst(同键相加)。 */
252
+ /** 判断 byModel 是否为 v1 扁平形态(顶层直接是计数条目,无提供方层)。 */
253
+ export function isFlatByModel(map) {
254
+ for (const k of Object.keys(map || {})) {
255
+ const v = map[k]
256
+ if (v && typeof v === 'object' && typeof v.requests === 'number') return true
257
+ }
258
+ return false
259
+ }
260
+
261
+ /** 把 src 的 byProvider/byModel 累加进 dst(同键相加)。
262
+ * byModel 兼容 v0.6.1 扁平形态({ <model>: e },双向):src 顶层条目并入 unknown 提供方桶,
263
+ * dst 顶层条目迁移到 unknown 桶后再合并(旧天文件被今天的重算复写时保持形态一致)。 */
245
264
  export function mergeTotals(dst, src) {
246
265
  if (!src) return
266
+ if (!dst.byProvider || typeof dst.byProvider !== 'object') dst.byProvider = {}
267
+ if (!dst.byModel || typeof dst.byModel !== 'object') dst.byModel = {}
247
268
  function mergeInto(map, srcMap) {
248
269
  for (const k of Object.keys(srcMap || {})) {
249
270
  const s = srcMap[k], t = map[k] || (map[k] = { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 })
@@ -254,7 +275,21 @@ export function mergeTotals(dst, src) {
254
275
  }
255
276
  }
256
277
  mergeInto(dst.byProvider, src.byProvider)
257
- mergeInto(dst.byModel, src.byModel)
278
+ if (isFlatByModel(dst.byModel)) {
279
+ const legacy = dst.byModel
280
+ dst.byModel = {}
281
+ mergeInto(dst.byModel.unknown || (dst.byModel.unknown = {}), legacy)
282
+ }
283
+ for (const prov of Object.keys(src.byModel || {})) {
284
+ const bucket = src.byModel[prov]
285
+ if (!bucket || typeof bucket !== 'object') continue
286
+ if (typeof bucket.requests === 'number') { mergeInto(dst.byModel.unknown || (dst.byModel.unknown = {}), { [prov]: bucket }); continue }
287
+ if (!dst.byModel[prov] || typeof dst.byModel[prov] !== 'object' || typeof dst.byModel[prov].requests === 'number') {
288
+ const legacy = dst.byModel[prov] && typeof dst.byModel[prov] === 'object' ? dst.byModel[prov] : null
289
+ dst.byModel[prov] = legacy ? { unknown: legacy } : {}
290
+ }
291
+ mergeInto(dst.byModel[prov], bucket)
292
+ }
258
293
  }
259
294
 
260
295
  /** 读某天文件;损坏/缺失返回 null。 */
@@ -289,15 +324,38 @@ export async function saveCursors(dir, cursors) {
289
324
  await fs.rename(tmp, join(dir, 'cursors.json'))
290
325
  }
291
326
 
292
- /** 回填哨兵:标记"全量历史回填已完成"(防止无历史数据时每次请求都重扫)。 */
327
+ /** 回填哨兵:标记"全量历史回填已完成且天文件为当前格式"(防止每次请求都重扫)。
328
+ * v0.7.0 起(DAILY_VERSION=2)按哨兵内容版本判定:旧版写的 version 1 哨兵视为未完成,
329
+ * 触发一次性全量重折,把 v1 扁平 byModel 天文件重算成嵌套形态(会话文件仍是事实源)。 */
293
330
  export async function hasBackfilled(dir) {
294
- try { await fs.access(join(dir, '.backfilled')); return true } catch { return false }
331
+ try {
332
+ const raw = await fs.readFile(join(dir, '.backfilled'), 'utf8')
333
+ const j = JSON.parse(raw)
334
+ return !!(j && typeof j === 'object' && Number(j.version) >= DAILY_VERSION)
335
+ } catch { return false }
295
336
  }
296
337
  export async function writeBackfilled(dir) {
297
338
  await fs.mkdir(dir, { recursive: true, mode: 0o700 })
298
339
  await fs.writeFile(join(dir, '.backfilled'), JSON.stringify({ version: DAILY_VERSION, at: Date.now() }) + '\n', { mode: 0o600 })
299
340
  }
300
341
 
342
+ /** 重置本地统计:删除全部派生数据(天文件/游标/回填哨兵/格式标记),不触碰会话原文。
343
+ * 下一次 local-usage 查询会自动从会话文件重新全量回填。返回删除的天文件数。 */
344
+ export async function resetDailyStats(dir) {
345
+ let removed = 0
346
+ let names
347
+ try { names = await fs.readdir(dir) } catch { return 0 }
348
+ for (const n of names) {
349
+ if (n === 'cursors.json' || n === '.backfilled' || n === '.format' || /^\.\d{4}-\d{2}-\d{2}\.tmp-/.test(n)) {
350
+ try { await fs.rm(join(dir, n)); removed++; continue } catch { /* ignore */ }
351
+ }
352
+ if (/^\d{4}-\d{2}-\d{2}\.json$/.test(n)) {
353
+ try { await fs.rm(join(dir, n)); removed++ } catch { /* ignore */ }
354
+ }
355
+ }
356
+ return removed
357
+ }
358
+
301
359
  /** 校验 deps(path -> mtime):返回变化的与缺失的路径。 */
302
360
  export async function validateDeps(deps) {
303
361
  const changed = []
package/lib/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // balance-json(余额型) :DeepSeek {{baseUrl}}/user/balance → balance_infos[]
5
5
  // 凭证链 per provider:DSH 设置 provider.apiKeyEnv → 凭证服务 → 环境变量。
6
6
  // 路由:/api/provider-usage/opencode-go(兼容旧版)、/api/provider-usage/query?provider=<id>、
7
- // /api/provider-usage/templates / credentials / dsh-providers / credential-refs(仅回环)。
7
+ // /api/provider-usage/templates / credentials / dsh-providers / credential-refs / reset-local-stats(仅回环)。
8
8
  // 本地 Token 统计(v0.5.0):/api/provider-usage/local-usage(累计+按模型汇总,按天物化存储),
9
9
  // 数据源 = 自读 $DSH_HOME/sessions 会话文件(fzstd 解压,assistant/message 事件 data.usage),只读、不联网;
10
10
  // 存储 = $DSH_HOME/provider-usage/daily-stats/YYYY-MM-DD.json:历史天封存(deps mtime 校验),只算今天。
@@ -20,8 +20,8 @@ export const name = 'provider-usage'
20
20
 
21
21
  /** 插件版本单一来源:package.json(UA 标识用;bundle 内嵌等读不到时回退字面量)。 */
22
22
  const PLUGIN_VERSION = (() => {
23
- try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '0.6.0' }
24
- catch { return '0.6.0' }
23
+ try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '0.7.0' }
24
+ catch { return '0.7.0' }
25
25
  })()
26
26
  const USER_AGENT = 'dsh-provider-usage/' + PLUGIN_VERSION
27
27
 
@@ -298,6 +298,15 @@ function commandcodeMonthlyUsedPct(totalQuota, monthlyCredits) {
298
298
  return Math.max(0, Math.min(100, ((totalQuota - remaining) / totalQuota) * 100))
299
299
  }
300
300
 
301
+ /** 月剩余百分比:钳制 [0,100] 后保留 3 位小数向下取整(如 89.918878… → 89.918);无法推算返回 null。 */
302
+ function commandcodeMonthlyRemainingPct(totalQuota, monthlyCredits) {
303
+ if (totalQuota === null || totalQuota === undefined || !(totalQuota > 0)) return null
304
+ const remaining = Number(monthlyCredits)
305
+ if (!Number.isFinite(remaining)) return null
306
+ const pct = Math.max(0, Math.min(100, (remaining / totalQuota) * 100))
307
+ return Math.floor(pct * 1000) / 1000
308
+ }
309
+
301
310
  /** 把 credits + subscriptions 两个端点的响应换算成混合模型(订阅使用量 + 剩余额度)。 */
302
311
  function parseCommandCodeCredits(creditsBody, subBody) {
303
312
  let credits = creditsBody
@@ -334,6 +343,7 @@ function parseCommandCodeCredits(creditsBody, subBody) {
334
343
  ? subData.currentPeriodEnd : null
335
344
 
336
345
  const monthlyUsedPct = commandcodeMonthlyUsedPct(totalQuota, monthlyCredits)
346
+ const monthlyRemainingPct = commandcodeMonthlyRemainingPct(totalQuota, monthlyCredits)
337
347
  const monthlyUsed = (monthlyUsedPct === null || totalQuota === null) ? null
338
348
  : Math.max(0, totalQuota - monthlyCredits)
339
349
 
@@ -367,7 +377,7 @@ function parseCommandCodeCredits(creditsBody, subBody) {
367
377
  fiveHour: windowOf(fiveHourUsed, fiveHourCap, fiveHourResetAt),
368
378
  weekly: windowOf(weeklyUsed, weeklyCap, weeklyResetAt),
369
379
  },
370
- monthly: { usedPct: monthlyUsedPct, used: monthlyUsed, totalQuota, remaining: monthlyCredits, resetsAt: nextResetAt },
380
+ monthly: { usedPct: monthlyUsedPct, remainingPct: monthlyRemainingPct, used: monthlyUsed, totalQuota, remaining: monthlyCredits, resetsAt: nextResetAt },
371
381
  cards: null,
372
382
  isValid: true,
373
383
  invalidMessage: null,
@@ -696,7 +706,7 @@ export function apply(ctx, rawConfig) {
696
706
  if (state.lastGood) {
697
707
  return { ...state.lastGood.payload, cached: true, stale: true, ok: false, error: { type, message, httpStatus: null } }
698
708
  }
699
- return { ok: false, error: { type, message, httpStatus: null }, provider: null, detected: true, days: 0, scanned: 0, totals: null, byModel: null, providers: [], fetchedAt: null, cached: false, stale: false, snapshots: (state.snapshots || []).slice(), parseVersion: LOCAL_PARSE_VERSION }
709
+ return { ok: false, error: { type, message, httpStatus: null }, provider: null, detected: true, days: 0, scanned: 0, totals: null, byModel: { unknown: {} }, providers: [], fetchedAt: null, cached: false, stale: false, snapshots: (state.snapshots || []).slice(), parseVersion: LOCAL_PARSE_VERSION }
700
710
  }
701
711
 
702
712
  /** 校验待查天的 deps:unique 路径并行 stat,按天判定 mtime 变化/缺失,必要时重算该天并刷新缓存。 */
@@ -723,7 +733,15 @@ export function apply(ctx, rawConfig) {
723
733
  if (c.missing) missing.push(p)
724
734
  else if (Math.abs(c.currentMtime - Number(data.deps[p])) > 1) changed.push(p)
725
735
  }
726
- if (changed.length > 0 || missing.length > 0) {
736
+ // v1 扁平 byModel(v0.6.1 落盘):deps 仍全部指向存在的会话文件 重折恢复提供方归属(一次性,重写为 v2 嵌套)
737
+ let flatLegacy = daily.isFlatByModel(data.byModel || {}) && Object.keys(data.deps || {}).length > 0
738
+ if (flatLegacy) {
739
+ for (const p of Object.keys(data.deps)) {
740
+ const c = current.get(p)
741
+ if (!c || c.missing) { flatLegacy = false; break }
742
+ }
743
+ }
744
+ if (changed.length > 0 || missing.length > 0 || flatLegacy) {
727
745
  const repaired = await recomputeDay(day, data.deps || {})
728
746
  data.byProvider = repaired.byProvider
729
747
  data.byModel = repaired.byModel
@@ -799,19 +817,22 @@ export function apply(ctx, rawConfig) {
799
817
  const files = await daily.scanSessionFiles(config.sessionsDir, config.maxSessions, todayStart - 3600000)
800
818
  const cursors = await daily.loadCursors(config.dailyDir)
801
819
  // 今天 totals = 上次累积的天文件 + 本轮新增帧(文件 mtime 未变则完全跳过)
820
+ // 例外:天文件还是 v0.6.1 的扁平 byModel(无提供方维度)→ 不种子、当天全量重折(v2 嵌套),
821
+ // 游标一并重置,避免旧扁平数据进 unknown 桶 / 增量与重折双计
802
822
  const prevDay = await cachedDay(toDay)
803
- const out = prevDay ? {
823
+ const prevFlat = !!(prevDay && daily.isFlatByModel(prevDay.byModel || {}))
824
+ const out = prevDay && !prevFlat ? {
804
825
  byProvider: JSON.parse(JSON.stringify(prevDay.byProvider || {})),
805
826
  byModel: JSON.parse(JSON.stringify(prevDay.byModel || {})),
806
827
  } : { byProvider: {}, byModel: {} }
807
- const deps = prevDay && prevDay.deps ? { ...prevDay.deps } : {}
808
- let anyWork = false
828
+ const deps = prevDay && prevDay.deps && !prevFlat ? { ...prevDay.deps } : {}
829
+ let anyWork = prevFlat
809
830
  for (const f of files) {
810
831
  deps[f.path] = f.mtimeMs
811
832
  const cur = cursors[f.path]
812
- if (cur && cur.mtimeMs === f.mtimeMs) continue // 文件无新写入:本轮零成本跳过
833
+ if (!prevFlat && cur && cur.mtimeMs === f.mtimeMs) continue // 文件无新写入:本轮零成本跳过
813
834
  anyWork = true
814
- if (cur && typeof cur.offset === 'number') {
835
+ if (!prevFlat && cur && typeof cur.offset === 'number') {
815
836
  const inc = await daily.readSessionFileFrom(f.path, cur.offset)
816
837
  if (inc.changed && inc.text) {
817
838
  daily.mergeTotals(out, daily.foldEventsByDay(daily.parseEvents(inc.text), todayStart, todayEnd, null))
@@ -840,6 +861,18 @@ export function apply(ctx, rawConfig) {
840
861
  return { toDay, out, scanned: files.length }
841
862
  }
842
863
 
864
+ /** 按提供方过滤嵌套 byModel(v0.6.1 扁平旧数据已由 mergeTotals 迁到 unknown 桶)。
865
+ * pick 为空 = 全部提供方(原样返回嵌套形态);选中提供方无数据 = 空对象。
866
+ * 恒返回嵌套形态(选中时只含该提供方键),客户端据此统一判别新旧服务端。 */
867
+ function filterModelByProvider(byModel, pick) {
868
+ const src = byModel && typeof byModel === 'object' ? byModel : {}
869
+ if (!pick) return src
870
+ const out = {}
871
+ const inner = src[pick]
872
+ if (inner && typeof inner === 'object' && typeof inner.requests !== 'number') out[pick] = inner
873
+ return out
874
+ }
875
+
843
876
  async function doLocalUsage(target, since, key, state) {
844
877
  let st
845
878
  try { st = await fsStat(config.sessionsDir) } catch { return localFail(state, 'no-sessions', '未找到 DSH 会话目录:' + config.sessionsDir) }
@@ -858,12 +891,15 @@ export function apply(ctx, rawConfig) {
858
891
  await backfillAll(nowMs)
859
892
  await daily.writeBackfilled(config.dailyDir)
860
893
  }
894
+ // agg = 全量聚合(providers/totals 用);modelAgg = 模型维度聚合(按提供方嵌套,v0.7.0)
861
895
  const agg = { byProvider: {}, byModel: {} }
896
+ const modelAgg = { byModel: {} }
862
897
  const dayCount = new Set()
863
898
  dayCount.add(toDay)
864
899
  // 今天:只算今天变过的文件
865
900
  const today = await computeToday(nowMs)
866
901
  daily.mergeTotals(agg, today.out)
902
+ daily.mergeTotals(modelAgg, { byModel: today.out.byModel || {} })
867
903
  // 历史天:读天文件(内存缓存;5 分钟周期内只校验一次 deps;mtime 变化才重算该天)
868
904
  const existing = await daily.listDayFiles(config.dailyDir)
869
905
  const pending = []
@@ -883,14 +919,16 @@ export function apply(ctx, rawConfig) {
883
919
  for (const day of Object.keys(dayData)) {
884
920
  const data = dayData[day]
885
921
  daily.mergeTotals(agg, { byProvider: data.byProvider || {}, byModel: data.byModel || {} })
922
+ daily.mergeTotals(modelAgg, { byModel: data.byModel || {} })
886
923
  dayCount.add(day)
887
924
  }
888
925
  // unknown 桶(time 缺失事件):并入聚合(不占天数)
889
926
  const unknownData = await cachedDay('unknown')
890
927
  if (unknownData) {
891
928
  daily.mergeTotals(agg, { byProvider: unknownData.byProvider || {}, byModel: unknownData.byModel || {} })
929
+ daily.mergeTotals(modelAgg, { byModel: unknownData.byModel || {} })
892
930
  }
893
- // 组装响应(chips 只过滤 totals;byModel 恒为全量)
931
+ // 组装响应(chips/totals/byModel 均按选中提供方过滤;v0.7.0 byModel 按提供方嵌套可过滤)
894
932
  const providers = Object.keys(agg.byProvider).sort()
895
933
  const pick = target ? (agg.byProvider[target] || null) : null
896
934
  const t = pick || { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }
@@ -917,7 +955,7 @@ export function apply(ctx, rawConfig) {
917
955
  ? t.cacheReadTokens / (t.inputTokens + t.cacheReadTokens)
918
956
  : 0,
919
957
  },
920
- byModel: agg.byModel || null,
958
+ byModel: filterModelByProvider(modelAgg.byModel, target || null),
921
959
  providers,
922
960
  fetchedAt: Date.now(), cached: false, stale: false,
923
961
  snapshots: (state.snapshots || []).slice(),
@@ -928,6 +966,18 @@ export function apply(ctx, rawConfig) {
928
966
  return payload
929
967
  }
930
968
 
969
+ /** 重置本地统计:删派生天文件/游标/哨兵(会话原文只读不动),清空 30s 缓存与去重态。 */
970
+ async function resetLocalUsage() {
971
+ const st = await fsStat(config.sessionsDir).then(() => true, () => false)
972
+ if (!st) return { ok: false, error: { type: 'no-sessions', message: '未找到 DSH 会话目录:' + config.sessionsDir, httpStatus: null } }
973
+ const removed = await daily.resetDailyStats(config.dailyDir)
974
+ dayCache.clear() // 天文件内存缓存一并清除:否则重查会用旧天数据做种子,重置对损坏天文件无效
975
+ for (const [k, s] of states) {
976
+ if (k.startsWith('local|')) { s.lastGood = null; s.fetching = null; s.validatedDays = new Set(); s.depsCheckAt = 0 }
977
+ }
978
+ return { ok: true, removed }
979
+ }
980
+
931
981
  async function localUsage(params) {
932
982
  const target = (params && typeof params.provider === 'string' && params.provider.trim() !== '') ? params.provider.trim() : null
933
983
  const since = (params && typeof params.since === 'string' && Number(params.since) > 0) ? Number(params.since) : 0
@@ -1018,6 +1068,12 @@ export function apply(ctx, rawConfig) {
1018
1068
  path: '/api/provider-usage/local-usage',
1019
1069
  handler: jsonRoute((req) => localUsage(queryParamsFromUrl(req))),
1020
1070
  }),
1071
+ // 重置本地统计:删除按天物化的派生数据(天文件/游标/哨兵),下次查询自动全量重算;会话原文只读不动
1072
+ ctx.webServer.register({
1073
+ kind: 'exact',
1074
+ path: '/api/provider-usage/reset-local-stats',
1075
+ handler: jsonRoute(() => resetLocalUsage(), true),
1076
+ }),
1021
1077
 
1022
1078
  // 适配器清单(不含任何 secret;客户端据此渲染"添加供应商"选项)
1023
1079
  ctx.webServer.register({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loommii/dsh-provider-usage",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "DSH 插件(用量中心):常驻用量卡片 · 多提供方用量/余额查询(OpenCode Go 订阅 + DeepSeek 余额 + Command Code 订阅/余额,对齐 cc-switch 查询语义)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",