dsh-cost-meter 1.8.4 → 1.8.5
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 +6 -6
- package/README.zh-CN.md +6 -6
- package/docs/billing-statistics.md +6 -0
- package/lib/client.statistics.js +14 -10
- package/lib/index.js +2 -0
- package/lib/native-search-billing.js +62 -13
- package/lib/native-search-fetch.js +35 -0
- package/lib/turn-cost.js +20 -4
- package/lib/turn-inspection.js +63 -0
- package/lib/typert.host.js +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
Per-conversation cost · daily totals · OpenCode Go subscription quota display · budget with usage percentage · official account balance · custom provider balance · balance progress bar · history · peak/off-peak pricing hours display (peak hours UTC 01:00–04:00, 06:00–10:00; weekends and Chinese public holidays are off-peak all day, with separate labels) · pre-switch popup & system-notification alerts for peak/off-peak changes (position / lead time / alert type configurable) · one-click price sync from the official docs · Codex-style token usage heat grid · multi-vendor model pricing (built-in 90+ model price catalog with auto-matching) · mainstream Coding Plan quota queries & display (Anthropic / Z.ai / MiniMax / Kimi / OpenRouter / SiliconFlow / CommandCode / SCNet / Volcano Ark / Qwen / Xiaomi MiMo) plan/API dual-track billing (subscription quota vs pay-as-you-go money separated, per-1% & full-window token/equivalent-cost estimates with daily/weekly/monthly curves) · · quota strip above the input box (budget / Go / coding-plan usage in one row, toggleable)
|
|
10
10
|
|
|
11
|
-
[](https://github.com/Han-1413141/dsh-cost-meter)
|
|
12
12
|
|
|
13
|
-
**v1.8.
|
|
13
|
+
**v1.8.5** aligns statistics with DSH's native theme, adds full-selection step shares and expandable turn inputs/tool calls, and captures native-search usage when the fetch transport emits no Undici diagnostics. See the [statistics guide](docs/billing-statistics.md#english) and [release notes](docs/release-notes/v1.8.5.md).
|
|
14
14
|
|
|
15
15
|
Desktop users: follow the [Desktop installation instructions](docs/install-troubleshooting.md#desktop-安装与更新) for the application's own CLI and `desktop` Profile.
|
|
16
16
|
|
|
@@ -320,22 +320,22 @@ On Node.js 20, use `npm install -g pnpm@10` instead. See [pnpm installation and
|
|
|
320
320
|
dsh plugin --profile web add dsh-cost-meter
|
|
321
321
|
```
|
|
322
322
|
|
|
323
|
-
**PowerShell one-click script** (copy the whole line, paste, press Enter; pnpm is provisioned automatically, git is auto-detected — no clone needed; the install chain is **pinned to the release tag `v1.8.
|
|
323
|
+
**PowerShell one-click script** (copy the whole line, paste, press Enter; pnpm is provisioned automatically, git is auto-detected — no clone needed; the install chain is **pinned to the release tag `v1.8.5`** — review the script before running):
|
|
324
324
|
|
|
325
325
|
```powershell
|
|
326
|
-
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.8.
|
|
326
|
+
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.8.5/install.ps1 | iex
|
|
327
327
|
```
|
|
328
328
|
|
|
329
329
|
**Or a plain command line** (the machine must already have pnpm and git; also pinned to the tag):
|
|
330
330
|
|
|
331
331
|
```sh
|
|
332
|
-
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.8.
|
|
332
|
+
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.8.5
|
|
333
333
|
```
|
|
334
334
|
|
|
335
335
|
Without git, use the GitHub tag archive:
|
|
336
336
|
|
|
337
337
|
```sh
|
|
338
|
-
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.8.
|
|
338
|
+
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.8.5.tar.gz
|
|
339
339
|
```
|
|
340
340
|
|
|
341
341
|
After installing, **restart** `dsh web` (plugin rows, the Typert manifest and the client bundle are all scanned at startup):
|
package/README.zh-CN.md
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
本会话费用 · 当日费用 · OpenCode Go 订阅额度显示 · 预算与已用百分比 · 官方账户余额 · 自定义 Provider 余额查询(可配任意 HTTP 端点) · 余额三段进度条 · 历史记录 · 峰谷计价时段显示(UTC 01:00–04:00、06:00–10:00 为峰时段;周末与中国法定假日全天按谷价,分别标注) · 峰/谷切换前弹窗与系统通知提醒(位置/提前量/提醒类型可配) · 官方价格一键同步 · 类 Codex Token 用量热图 · 多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配) · 主流 Coding Plan 额度查询与显示(Anthropic / Z.ai / MiniMax / Kimi / OpenRouter / SiliconFlow / CommandCode / SCNet / 火山方舟 / 千问 / 小米 MiMo 十一家,含 Volcano Ark AK/SK 签名与 MiMo 控制台 Cookie 查询) · Plan/API 双轨计费(订阅额度与按量金额分离统计,每 1% 额度与满窗的 token/等值金额估算及日/周/月曲线) · 输入框上方额度横条(预算/Go/Coding Plan 用量一条横排显示,可开关)
|
|
10
10
|
|
|
11
|
-
[](https://github.com/Han-1413141/dsh-cost-meter)
|
|
12
12
|
|
|
13
|
-
**v1.8.
|
|
13
|
+
**v1.8.5** 按 DSH 原生风格调整统计界面,增加全部调用的步骤占比,以及可展开的每轮输入、工具参数和结果;原生搜索新增不依赖 Undici 诊断事件的用量捕获。详见[计费统计说明](docs/billing-statistics.md#简体中文)及[更新说明](docs/release-notes/v1.8.5.md)。
|
|
14
14
|
|
|
15
15
|
桌面端用户请按 [Desktop 安装说明](docs/install-troubleshooting.md#desktop-安装与更新),使用应用自带的 CLI 和 `desktop` Profile。
|
|
16
16
|
|
|
@@ -320,22 +320,22 @@ Node.js 20 请改用 `npm install -g pnpm@10`。版本要求见 [pnpm 官方安
|
|
|
320
320
|
dsh plugin --profile web add dsh-cost-meter
|
|
321
321
|
```
|
|
322
322
|
|
|
323
|
-
**PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.8.
|
|
323
|
+
**PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.8.5`**,建议先下载审阅再运行):
|
|
324
324
|
|
|
325
325
|
```powershell
|
|
326
|
-
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.8.
|
|
326
|
+
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.8.5/install.ps1 | iex
|
|
327
327
|
```
|
|
328
328
|
|
|
329
329
|
**或直接命令行**(机器上需已有 pnpm 与 git;同样固定到 tag):
|
|
330
330
|
|
|
331
331
|
```sh
|
|
332
|
-
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.8.
|
|
332
|
+
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.8.5
|
|
333
333
|
```
|
|
334
334
|
|
|
335
335
|
没有 git 时可用 GitHub tag 打包直链:
|
|
336
336
|
|
|
337
337
|
```sh
|
|
338
|
-
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.8.
|
|
338
|
+
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.8.5.tar.gz
|
|
339
339
|
```
|
|
340
340
|
|
|
341
341
|
安装后**重启** `dsh web`(插件行、Typert 清单与客户端 bundle 均在启动时扫描):
|
|
@@ -10,6 +10,9 @@ Open a conversation and click **Cost details** in the same row as turns, speed,
|
|
|
10
10
|
- **Conversation detail:** Input, output, cache read, cache write, and reasoning costs; individual model, compaction and native-search calls; tokens × price per million; long-context rates and unavailable prices. Small amounts use at least eight decimal places. Fifty calls per page remain part of the complete detail total.
|
|
11
11
|
|
|
12
12
|
- **Turns and call types:** Full-selection totals by logged turn number and by model / compaction / native-search call. Turn pages contain 25 complete groups, independently of the 50-call pages. Calls without a turn number remain in an explicit unassigned group. Expand a call to see its logged turn and step, routing and price formula.
|
|
13
|
+
- **Step shares:** Horizontal bars show cost components, call-type counts and each logged step's share. Switch between costs and call counts. The denominator covers all filtered calls, across pages; the top 12 steps plus a combined remainder preserve the full amount. Tools are not counted as additional model calls. No recorded step stays explicitly unassigned.
|
|
14
|
+
- **Turn inspection:** Expand a turn to load its original user inputs, then expand each tool to see arguments, result and completion/error status. Tool records paginate 20 at a time; each long text field shows up to 16,000 characters with an explicit notice. Attachments show their type; system prompts, request headers and binary data are excluded. This is the whole turn, independent of the billing filters. Opening the overview does not load these contents.
|
|
15
|
+
- **Appearance:** Native DSH theme tokens, neutral surfaces, light borders, compact segmented controls and collapsible explanations replace the earlier colored dashboard cards. Light/dark themes and narrow windows share the same layout rules.
|
|
13
16
|
|
|
14
17
|
### What the numbers mean
|
|
15
18
|
|
|
@@ -33,6 +36,9 @@ The statistics screen uses DSH's package-local asynchronous module loader. Since
|
|
|
33
36
|
- **单对话:**输入、输出、缓存读取、缓存写入、推理费用,以及模型调用、上下文压缩、原生搜索的逐次明细。每次调用列出 Token × 每百万 Token 单价、金额、长上下文档位及缺价提示。小额费用至少保留八位小数。每页显示 50 次调用,分页不影响完整明细合计。
|
|
34
37
|
|
|
35
38
|
- **轮次和调用类型:**按日志中的轮次编号,以及模型调用、上下文压缩、原生搜索汇总。每页 25 个完整轮次,与每页 50 次调用分别翻页;一轮跨多个调用页时仍显示完整合计。日志没有轮次编号的调用单列为「未标明轮次」。逐次明细标明轮次、步骤、路由和价格公式。
|
|
39
|
+
- **步骤占比:**横向条形图展示费用构成、调用类型次数和各步骤占比,可切换费用或调用次数。分母覆盖筛选后的全部调用,跨页合计;显示前 12 个步骤,其余合并,保留完整金额与次数。没有步骤编号的调用单列;工具执行不额外算作模型调用。
|
|
40
|
+
- **展开轮次:**点击一轮才读取其用户输入和工具调用,继续展开工具可查看参数、结果、完成或失败状态。工具每页 20 条,单段文本最多显示 16,000 个字符并标明截断;附件只显示类型,不读取系统提示、请求头或二进制内容。这里显示整轮原始记录,不受计费筛选影响。打开统计首页不会加载这些正文。
|
|
41
|
+
- **界面:**使用 DSH 的主题颜色、轻边框、紧凑分段按钮和可折叠说明,替换原来的彩色顶部卡片;支持浅色、深色和窄窗口。
|
|
36
42
|
|
|
37
43
|
### 统计口径
|
|
38
44
|
|
package/lib/client.statistics.js
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
|
-
window.__ModuleLoader__.load({id:"dsh-cost-meter",chunk:"client.statistics.js",factory:
|
|
2
|
-
.cm-stat{--cm-accent
|
|
3
|
-
.cm-stat *{box-sizing:border-box}.cm-stat h2,.cm-stat h3,.cm-stat p{margin:0}.cm-stat h2{font-size:
|
|
4
|
-
.cm-stat-head{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:
|
|
1
|
+
window.__ModuleLoader__.load({id:"dsh-cost-meter",chunk:"client.statistics.js",factory:xt=>{const kt=xt("react"),{createElement:t,useState:y,useEffect:Nt,Fragment:Y}=kt,Ct=["input","output","cacheRead","cacheWrite","reasoning","calls","cost","apiCost"],T=(s,r)=>r==="total"?s.cost:r==="plan"?Math.max(0,s.cost-s.apiCost):s.apiCost,Z=s=>s.input+s.cacheRead+s.cacheWrite+s.output,j=(s,r)=>r>0?(100*s/r).toFixed(1)+"%":"—",wt=(s,r)=>new Date(Date.parse(s)+r*864e5).toISOString().slice(0,10),tt=s=>r=>{if(typeof r!==s||s==="number"&&!Number.isFinite(r))throw new Error("Invalid statistics "+s);return r},g=tt("number"),p=tt("string"),N=tt("boolean"),D=s=>s===null?null:g(s),I=s=>r=>{if(!Array.isArray(r))throw new Error("Invalid statistics array");return r.map(s)},k=s=>r=>{if(!r||typeof r!="object"||Array.isArray(r))throw new Error("Invalid statistics object");return Object.fromEntries(Object.entries(s).map(([i,m])=>[i,m(r[i])]))},q=Object.fromEntries(Ct.map(s=>[s,g])),it=k(q),ot=k({provider:p,model:p,bucket:p,tokens:g,rate:g,cost:g,priced:N,plan:N}),St=k({kind:p,turn:D,step:D,provider:p,model:p,atMs:g,cost:g,apiCost:g,plan:N,priced:N,longContext:N,rows:I(ot)}),at=k({from:p,to:p,retainedFrom:p,retainedTo:p,totals:it,days:I(k({...q,date:p})),models:I(k({...q,key:p,provider:p,model:p,priced:N})),sessions:I(k({...q,id:p,title:p})),providers:I(p),modelOptions:I(p),sessionCount:g,offset:g,unassignedCost:g,unmodeledCost:g}),et=k({found:N,cost:g,apiCost:g,rows:I(ot),calls:I(St),totalCalls:g,offset:g,recorded:it,kinds:I(k({...q,kind:p,unpriced:N})),turns:I(k({...q,turn:D,unpriced:N})),totalTurns:g,turnOffset:g,stepShares:k(Object.fromEntries(["cost","calls"].map(s=>[s,I(k({...q,turn:D,step:D,other:N,unpriced:N}))])))}),st=k({found:N,turn:g,input:p,inputTruncated:N,totalTools:g,offset:g,tools:I(k({seq:g,step:D,name:p,callId:p,atMs:g,arguments:p,result:p,truncated:N,status:p}))}),mt=s=>{const r=k({sessionId:p,turn:g,offset:g})({...s,offset:s.offset??0});if(!r.sessionId||r.sessionId.length>512||![r.turn,r.offset].every(i=>Number.isSafeInteger(i)&&i>=0)||r.offset>1e7)throw new Error("Invalid turn inspection query");return r},dt=s=>{const r=k({from:p,to:p,provider:p,model:p,sessionId:p,basis:p,offset:g})(s);if(r.turnOffset=g(s.turnOffset??0),!["api","plan","total"].includes(r.basis)||![r.offset,r.turnOffset].every(i=>Number.isSafeInteger(i)&&i>=0&&i<=1e7))throw new Error("Invalid statistics query");return r},pt=(s,r)=>{const i={parse:r};return{mode:"strict",typeSymbol:"dsh-cost-meter#"+s,schema:i,create:()=>i}},ut={package:"dsh-cost-meter/statistics",descriptors:[["getBillingStatistics","BillingStatistics",at],["getSessionBilling","SessionBilling",et],["getTurnInspection","TurnInspection",st,mt]].map(([s,r,i,m])=>({id:"dsh-cost-meter#costMeter/"+s,service:"costMeter",namespace:"costMeter",method:s,invocation:{kind:"direct"},parameters:[{name:"query",wire:"query",source:"json",codec:pt(m?"TurnInspectionQuery":"StatisticsQuery",m??dt)}],result:pt(r,i)}))},Tt=`
|
|
2
|
+
.cm-stat{--cm-accent:var(--dsw-alias-state-business-primary,#4d6bfe);--cm-plan:var(--dsw-alias-label-tertiary,#8b9099);--cm-muted:var(--dsw-alias-label-tertiary,#858a94);--cm-border:var(--dsw-alias-border-l3,#e9eaed);--cm-surface:var(--dsw-alias-bg-layer-1,#f7f8fa);color:var(--dsw-alias-label-primary,#25262b);font-family:var(--ds-font-family-sans,inherit);font-size:13px;line-height:1.55;container-type:inline-size}
|
|
3
|
+
.cm-stat *{box-sizing:border-box}.cm-stat h2,.cm-stat h3,.cm-stat p{margin:0}.cm-stat h2{font-size:18px;font-weight:600;letter-spacing:0}.cm-stat h3{font-size:14px;font-weight:500}
|
|
4
|
+
.cm-stat-head{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0 0 18px}.cm-stat-sub{font-size:12px;color:var(--cm-muted)}
|
|
5
5
|
.cm-stat button,.cm-stat select,.cm-stat input{font:inherit;color:inherit}.cm-stat button{cursor:pointer}.cm-stat button:disabled{cursor:default;opacity:.45}.cm-stat button:focus-visible,.cm-stat select:focus-visible,.cm-stat input:focus-visible{outline:2px solid var(--cm-accent);outline-offset:3px}
|
|
6
6
|
.cm-stat-controls{display:flex;flex-wrap:wrap;gap:10px;align-items:end;margin:14px 0}.cm-stat-controls label{display:grid;gap:4px;font-size:11px;color:var(--cm-muted)}
|
|
7
|
-
.cm-stat input,.cm-stat select{background:var(--dsw-alias-bg-
|
|
8
|
-
.cm-stat-periods{display:flex;flex-wrap:wrap;gap:
|
|
9
|
-
.cm-stat-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:
|
|
10
|
-
.cm-stat-value{font-variant-numeric:tabular-nums;font-size:clamp(
|
|
11
|
-
.cm-stat-panel{border:1px solid var(--
|
|
7
|
+
.cm-stat input,.cm-stat select{background:var(--dsw-alias-bg-base,#fff);border:.5px solid var(--cm-border);border-radius:var(--dsw-radius-sm,8px);padding:6px 9px;max-width:210px;min-width:0}
|
|
8
|
+
.cm-stat-periods{display:flex;flex-wrap:wrap;gap:2px;background:var(--cm-surface);border-radius:var(--dsw-radius-lg,12px);padding:3px}.cm-stat-btn{border:.5px solid var(--cm-border);border-radius:var(--dsw-radius-sm,8px);background:transparent;padding:5px 10px}.cm-stat-btn:hover{background:var(--dsw-alias-interactive-bg-hover,#f0f1f4)}.cm-stat-periods .cm-stat-btn{border-color:transparent;color:var(--cm-muted)}.cm-stat-btn[aria-pressed=true]{background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#25262b);border-color:var(--cm-border);box-shadow:0 1px 2px #00000005}
|
|
9
|
+
.cm-stat-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:0;margin:20px 0;padding:16px 0;border-block:1px solid var(--cm-border)}.cm-stat-metric{padding:0 18px;border-right:1px solid var(--cm-border)}.cm-stat-metric:first-child{padding-left:0}.cm-stat-metric:last-child{border:0}
|
|
10
|
+
.cm-stat-value{font-variant-numeric:tabular-nums;font-size:clamp(18px,2.5cqw,26px);font-weight:500;letter-spacing:-.5px;margin:5px 0;overflow-wrap:anywhere}.cm-stat-metric .cm-stat-sub{font-size:11px}
|
|
11
|
+
.cm-stat-panel{border:1px solid var(--cm-border);border-radius:var(--dsw-radius-lg,12px);padding:16px;margin:16px 0;min-width:0}.cm-stat-panel-head{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px}
|
|
12
12
|
.cm-stat-chart{height:170px;display:flex;align-items:end;gap:clamp(2px,.5cqw,7px);border-bottom:1px solid var(--dsw-alias-border-l1,#dce3ec);background:repeating-linear-gradient(to top,transparent 0,transparent 41px,var(--dsw-alias-border-l1,#e9edf3) 42px,transparent 43px);padding:0 2px}
|
|
13
13
|
.cm-stat-bar{height:100%;flex:1;min-width:1px;display:flex;align-items:end;justify-content:center;background:transparent;border:0;padding:0;position:relative}.cm-stat-bar>span{display:block;width:100%;max-width:45px;border-radius:4px 4px 0 0;background:var(--cm-accent);min-height:2px;opacity:.83}.cm-stat-bar:hover>span,.cm-stat-bar[aria-pressed=true]>span{opacity:1;background:var(--cm-plan)}
|
|
14
14
|
.cm-stat-axis{display:flex;justify-content:space-between;font-size:11px;color:var(--cm-muted);margin-top:6px}.cm-stat-grid{display:grid;grid-template-columns:minmax(0,1.3fr) minmax(0,1fr);gap:14px}.cm-stat-grid>.cm-stat-panel{margin:0}
|
|
15
15
|
.cm-stat-rank{list-style:none;padding:0;margin:0}.cm-stat-rank li{margin-top:14px}.cm-stat-rank button{width:100%;border:0;background:transparent;text-align:left;padding:0;color:inherit}.cm-stat-rankline{display:flex;justify-content:space-between;gap:10px}.cm-stat-rankline>span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cm-stat-track{height:5px;margin-top:7px;background:var(--dsw-alias-bg-layer-2,#f0f3f7);border-radius:4px;overflow:hidden}.cm-stat-track>span{display:block;height:100%;background:var(--cm-accent);border-radius:4px}.cm-stat-number{font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
16
16
|
.cm-stat-table{width:100%;border-collapse:collapse;font-size:12px}.cm-stat-table th,.cm-stat-table td{padding:11px 9px;border-bottom:1px solid var(--dsw-alias-border-l1,#e0e5ed);text-align:right;font-variant-numeric:tabular-nums}.cm-stat-table th{font-weight:500;color:var(--cm-muted)}.cm-stat-table th:first-child,.cm-stat-table td:first-child{text-align:left}.cm-stat-table button{color:var(--cm-accent);border:0;background:transparent;text-align:left;padding:0;max-width:300px;overflow-wrap:anywhere}.cm-stat-scroll{overflow:auto}
|
|
17
17
|
.cm-stat-page{display:flex;gap:12px;align-items:center;justify-content:flex-end;margin-top:14px}.cm-stat-note{padding:10px 12px;background:var(--dsw-alias-bg-layer-2,#f5f7fa);border-radius:8px;margin:12px 0!important;font-size:12px;color:var(--cm-muted)}.cm-stat-error{color:var(--dsw-alias-state-error-primary,#c75040);white-space:pre-wrap}.cm-stat-empty{padding:30px;text-align:center;color:var(--cm-muted)}.cm-stat-call{padding:11px 0;border-bottom:1px solid var(--dsw-alias-border-l1,#dce3ec)}.cm-stat-call summary{cursor:pointer;overflow-wrap:anywhere}.cm-stat-call p{overflow-wrap:anywhere;margin-top:6px}
|
|
18
|
+
.cm-stat summary{cursor:pointer}.cm-stat-help{font-size:12px;color:var(--cm-muted);margin:10px 0}.cm-stat-help>p{margin-top:8px}.cm-stat-shares{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px;margin:20px 0}.cm-stat-share-list{list-style:none;margin:12px 0 0;padding:0;display:grid;gap:11px}.cm-stat-share-head{display:flex;justify-content:space-between;gap:12px;font-size:12px}.cm-stat-share-head>span:first-child{min-width:0;overflow-wrap:anywhere}.cm-stat-share-value{white-space:nowrap;font-variant-numeric:tabular-nums}.cm-stat-share-track{height:6px;background:var(--cm-surface);border-radius:99px;overflow:hidden;margin-top:5px}.cm-stat-share-fill{display:block;height:100%;background:var(--cm-accent);border-radius:99px;opacity:.68}.cm-stat-share-list li:nth-child(even) .cm-stat-share-fill{opacity:.42}.cm-stat-share-list li[data-other=true] .cm-stat-share-fill{background:var(--cm-plan)}
|
|
19
|
+
.cm-stat-breakdown{padding-block:18px;border-block:1px solid var(--cm-border);margin-block:18px}.cm-stat-turns{margin-top:24px}.cm-stat-turn{border-top:1px solid var(--cm-border)}.cm-stat-turn-toggle{display:flex;align-items:center;gap:10px;justify-content:space-between;width:100%;text-align:left;padding:12px 4px;background:transparent;border:0}.cm-stat-turn-toggle:hover{background:var(--dsw-alias-interactive-bg-hover,#f5f6f8)}.cm-stat-turn-toggle>span:first-child{font-weight:500}.cm-stat-turn-toggle>span:last-child{color:var(--cm-muted);font-size:12px;text-align:right;font-variant-numeric:tabular-nums}.cm-stat-inspection{background:var(--cm-surface);padding:16px;border-radius:10px;margin:0 0 12px}.cm-stat-inspection h4{font-size:12px;font-weight:500;margin:0 0 8px}.cm-stat-pre{font:12px/1.6 var(--ds-font-family-code,monospace);white-space:pre-wrap;overflow-wrap:anywhere;max-height:280px;overflow:auto;margin:8px 0 16px;padding:12px;border:1px solid var(--cm-border);background:var(--dsw-alias-bg-base,#fff);border-radius:8px}.cm-stat-tool{border-top:1px solid var(--cm-border);padding:10px 0}.cm-stat-tool summary{display:flex;flex-wrap:wrap;gap:8px;align-items:center;font-size:12px}.cm-stat-tool summary::before{content:'›';color:var(--cm-muted)}.cm-stat-tool[open] summary::before{content:'⌄'}.cm-stat-tool .cm-stat-sub{margin-left:auto}.cm-stat-truncated{color:var(--cm-muted);font-size:12px}.cm-stat-step-list{columns:2;column-gap:28px}.cm-stat-step-list li{break-inside:avoid;margin-bottom:12px}.cm-stat-step-list.cm-stat-share-list{display:block}
|
|
20
|
+
dialog:has(.cm-stat){padding:24px!important;border-color:var(--dsw-alias-border-l3,#e9eaed)!important;box-shadow:var(--dsw-elevation-prominent,0 20px 90px #0002)}dialog:has(.cm-stat)::backdrop{background:#0005}dialog:has(.cm-stat)>.cm-btn{border:0;border-radius:8px;background:transparent;font-size:20px;line-height:24px;width:28px;height:28px;padding:0;margin-left:12px}
|
|
21
|
+
@container(max-width:700px){.cm-stat-shares{grid-template-columns:1fr;gap:20px}.cm-stat-step-list{columns:1}.cm-stat-turn-toggle{align-items:flex-start}.cm-stat-turn-toggle>span:last-child{max-width:60%}.cm-stat-metric{padding:10px!important}.cm-stat-metric:nth-child(2){border:0}.cm-stat-page{gap:8px}}
|
|
18
22
|
@container(max-width:700px){.cm-stat-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.cm-stat-grid{grid-template-columns:1fr}.cm-stat-panel{padding:12px}.cm-stat-head{align-items:start}.cm-stat-controls label{flex:1}.cm-stat input,.cm-stat select{max-width:100%;width:100%}.cm-stat-value{font-size:23px}}
|
|
19
|
-
`;function it(s,l,o,d){const[m,c]=k({value:null,error:"",loading:!0}),a=JSON.stringify(o);return vt(()=>{let i=!0;return c({value:null,error:"",loading:!0}),s[l](JSON.parse(a)).then(y=>{i&&c({value:y,error:"",loading:!1})},y=>{i&&c({value:null,error:String(y.message??y),loading:!1})}),()=>{i=!1}},[s,l,a,d]),m}const B=(s,l,o={})=>t("button",{type:"button",className:"cm-stat-btn",onClick:l,...o},s);function X({offset:s,count:l,size:o,onChange:d,text:m}){return t("div",{className:"cm-stat-page"},t("span",{className:"cm-stat-sub"},l?`${s+1}–${Math.min(s+o,l)} / ${l}`:"0"),B(m("上一页","Previous"),()=>d(Math.max(0,s-o)),{disabled:!s}),B(m("下一页","Next"),()=>d(s+o),{disabled:s+o>=l}))}function rt({rows:s,money:l,text:o,onSelect:d,label:m}){const c=Math.max(0,...s.map(i=>i.value)),a=s.reduce((i,y)=>i+y.value,0);return t("div",null,t("div",{className:"cm-stat-panel-head"},t("h3",null,m),t("span",{className:"cm-stat-sub"},o("最高 ","Peak ")+l(c)+" · "+o("合计 ","Total ")+l(a))),t("div",{className:"cm-stat-chart",role:"group","aria-label":m},s.map((i,y)=>t("button",{key:y,type:"button",className:"cm-stat-bar",title:i.label+" · "+l(i.value),"aria-label":i.label+" · "+l(i.value),onClick:()=>d(i)},t("span",{style:{height:c?Math.max(1,i.value/c*100)+"%":"0%",opacity:i.value?void 0:.2}})))),t("div",{className:"cm-stat-axis"},t("span",null,s[0]?.label),t("span",null,s.at(-1)?.label)))}function dt(s,l){const o=Math.max(1,Math.ceil(s.length/60)),d=[];for(let m=0;m<s.length;m+=o){const c=s.slice(m,m+o),a=c[0].date,i=c.at(-1).date;d.push({from:a,to:i,label:a===i?a:a+" – "+i,value:c.reduce((y,O)=>y+S(O,l),0)})}return d}function mt({api:s,query:l,revision:o,money:d,formatTokens:m,text:c}){const[a,i]=k(0),[y,O]=k(-1),[W,C]=k(0),E=it(s,"getSessionBilling",{...l,offset:a,turnOffset:W},o),f=E.value,M=l.basis,R={input:c("输入","Input"),output:c("输出","Output"),cacheRead:c("缓存读取","Cache read"),cacheWrite:c("缓存写入","Cache write"),reasoning:c("推理","Reasoning")},j={model:c("模型调用","Model call"),compaction:c("上下文压缩","Compaction"),search:c("原生搜索","Native search")},x=n=>n==null?c("未标明轮次","Turn not recorded"):c("轮次 ","Turn ")+n;if(E.loading)return t("p",{className:"cm-stat-empty",role:"status"},c("正在读取本对话的用量明细…","Loading this conversation’s usage records…"));if(E.error)return t("p",{className:"cm-stat-error",role:"alert"},E.error);if(!f.found)return t("p",{className:"cm-stat-note"},c("没有可用的调用日志。上方账本统计仍然有效;明细不会按零费用处理。","Call logs are unavailable. The ledger totals above remain valid; missing details do not mean zero cost."));const Y=n=>M==="total"||(M==="plan"?n.plan:!n.plan),F=Object.keys(R).map(n=>{const u=f.rows.filter(v=>v.bucket===n&&Y(v));return{bucket:n,tokens:u.reduce((v,g)=>v+g.tokens,0),cost:u.reduce((v,g)=>v+g.cost,0),unpriced:u.some(v=>!v.priced)}}),T=S(f,M),L=S(f.recorded,M),Z=Math.abs(T-L)>Math.max(1e-8,Math.abs(L)*1e-8)||f.totalCalls!==f.recorded.calls,U=(n,u)=>t("p",{key:u,className:"cm-stat-sub"},R[n.bucket]+": "+n.tokens.toLocaleString()+" × "+(n.priced?d(n.rate):"?")+" / 1,000,000 = "+(n.priced?d(n.cost):"?")),_=(n,u,v)=>t("div",{className:"cm-stat-scroll",style:{marginTop:20}},t("h3",null,n),t("table",{className:"cm-stat-table"},t("thead",null,t("tr",null,...[n,c("调用次数","Calls"),c("输入 / 缓存 / 输出 Tokens","Input / cache / output tokens"),"API",c("Plan 等值","Plan equivalent")].map(g=>t("th",{key:g},g)))),t("tbody",null,u.map((g,D)=>t("tr",{key:D},t("td",null,v(g)),t("td",null,g.calls),t("td",null,[g.input,g.cacheRead+g.cacheWrite,g.output].map(m).join(" / ")),t("td",null,d(g.apiCost)+(g.unpriced?" + ?":"")),t("td",null,d(Math.max(0,g.cost-g.apiCost))+(g.unpriced?" + ?":"")))))));return t("section",{className:"cm-stat-panel"},t("div",{className:"cm-stat-panel-head"},t("h3",null,c("单对话费用明细","Conversation cost details")),t("span",{className:"cm-stat-sub"},c("只统计本对话自身的调用","Own calls only; child conversations are separate"))),t("p",{className:"cm-stat-note"},c("明细按日志中的调用时间、用量和当前配置的历史价格规则计算。上方汇总采用已入账金额;调整价格后两者可能不同。","Details use logged usage and call times with the currently configured historical price rules. The summary above uses recorded ledger amounts; changing prices can produce a difference.")),Z?t("p",{className:"cm-stat-note"},c("账本与可用明细不同:账本 ","Ledger and available details differ: ledger ")+d(L)+" / "+f.recorded.calls+c(" 次;明细 "," calls; details ")+d(T)+" / "+f.totalCalls+c(" 次。"," calls.")):null,t("div",{className:"cm-stat-scroll"},t("table",{className:"cm-stat-table"},t("thead",null,t("tr",null,...[c("费用构成","Cost component"),"Tokens",c("费用","Cost"),c("费用占比","Cost share")].map(n=>t("th",{key:n},n)))),t("tbody",null,F.map(n=>t("tr",{key:n.bucket},t("td",null,R[n.bucket]),t("td",null,n.tokens.toLocaleString()),t("td",null,d(n.cost)+(n.unpriced?" + ?":"")),t("td",null,z(n.cost,T))))))),t("p",{className:"cm-stat-sub",style:{marginTop:10}},c("推理 Token 可能包含在输出中;单价为 0 时不另收推理费。未定价用量显示 ?。","Reasoning tokens may overlap output; a zero reasoning rate adds no separate charge. Unpriced usage is marked ?.")),_(c("按调用类型统计","Cost by call type"),f.kinds,n=>j[n.kind]??n.kind),_(c("按轮次统计","Cost by turn"),f.turns,n=>x(n.turn)),t("p",{className:"cm-stat-sub"},c("轮次沿用日志编号;未记录轮次的调用单列。每轮包含其全部调用,跨调用分页也不会拆分。","Turn numbers come from the log; calls without one are listed separately. Each turn includes all its calls, across call pages.")),t(X,{offset:W,count:f.totalTurns,size:25,onChange:C,text:c}),t("div",{style:{marginTop:24}},t(rt,{rows:f.calls.map((n,u)=>({label:"#"+(a+u+1),index:u,value:S(n,M)})),money:d,text:c,label:c("逐次调用费用 · 当前页","Cost per call · current page"),onSelect:n=>{O(n.index),document.getElementById("cm-stat-call-"+n.index)?.scrollIntoView({block:"nearest",behavior:"smooth"})}})),f.calls.map((n,u)=>t("details",{key:u,id:"cm-stat-call-"+u,className:"cm-stat-call",open:y===u,onToggle:v=>{!v.currentTarget.open&&y===u&&O(-1)}},t("summary",null,"#"+(a+u+1)+" · "+x(n.turn)+(n.step==null?"":c(" / 步骤 "," / step ")+n.step)+" · "+(j[n.kind]??n.kind)+" · "+n.provider+" / "+n.model+" · "+(n.plan?"Plan ":"API ")+(n.priced?d(n.cost):"?")+(n.longContext?c(" · 长上下文价"," · long-context rate"):"")),t("p",{className:"cm-stat-sub"},new Date(n.atMs).toLocaleString()),n.rows.map(U))),t(X,{offset:a,count:f.totalCalls,size:50,onChange:n=>{i(n),O(-1)},text:c}))}function pt({state:s,api:l,sessionId:o="",formatMoneyUsd:d,formatTokens:m}){const c=s.config.locale==="en"||s.config.locale==="auto"&&!/^zh\b/i.test(navigator.language),a=(e,N)=>c?N:e,[i,y]=k(o?"all":"week"),[O,W]=k(null),[C,E]=k({id:o,title:o}),[f,M]=k(""),[R,j]=k(""),[x,Y]=k(o?"total":"api"),[F,T]=k(0),[L,Z]=k(0),[U,_]=k(!1),n=s.meta.dayKey||new Date().toISOString().slice(0,10),u=i==="all"?"":i==="custom"?O?.from||n:xt(n,i==="week"?-6:i==="month"?-29:0),v=i==="custom"&&O?.to||n,g={from:u,to:v,provider:f,model:R,basis:x,sessionId:C.id,offset:F},D=it(l,"getBillingStatistics",g,L+":"+s.meta.now),p=D.value,I=e=>d(e,{...s.config,decimals:Math.max(e!==0&&Math.abs(e)<.01?8:4,s.config.decimals??2)}),ut=e=>{y(e),T(0)},gt=e=>{E(e),T(0),M(""),j("")},wt=x==="total"?a("API + Plan 等值","API + Plan equivalent"):x==="plan"?a("Plan 等值","Plan equivalent"):a("API 费用","API cost"),tt=(e,N,at,Pt)=>t("label",null,e,t("select",{value:N,onChange:J=>{at(J.target.value),T(0)}},Pt.map(([J,Tt])=>t("option",{key:J,value:J},Tt)))),St={input:a("未缓存输入","Uncached input"),cacheRead:a("缓存读取","Cache read"),cacheWrite:a("缓存写入","Cache write"),output:a("输出","Output")},bt=(e,N)=>t("section",{className:"cm-stat-panel"},t("div",{className:"cm-stat-panel-head"},t("h3",null,e)),N),h=p?.totals,$=(e,N,at)=>t("div",{className:"cm-stat-metric",key:e},t("div",{className:"cm-stat-sub"},e),t("div",{className:"cm-stat-value"},N),t("div",{className:"cm-stat-sub"},at));return t("div",{className:"cm-stat"},t("style",null,Nt),t("header",{className:"cm-stat-head"},t("div",null,t("h2",null,a("计费统计","Cost statistics")),t("p",{className:"cm-stat-sub"},C.id?a("单对话 · ","Conversation · ")+(C.title===C.id&&p?.sessions[0]?.title||C.title):a("全部对话 · 从总额查看每一笔调用","All conversations · from totals to individual calls"))),B(a("刷新","Refresh"),()=>Z(e=>e+1))),C.id?B(a("← 全部对话","← All conversations"),()=>gt({id:"",title:""})):null,t("div",{className:"cm-stat-controls"},t("div",{className:"cm-stat-periods"},...[["today",a("今天","Today")],["week",a("近 7 天","Last 7 days")],["month",a("近 30 天","Last 30 days")],["all",a("全部记录","All retained")],["custom",a("自定义","Custom")]].map(([e,N])=>B(N,()=>ut(e),{key:e,"aria-pressed":i===e})))),i==="custom"?t("div",{className:"cm-stat-controls"},...["from","to"].map(e=>t("label",{key:e},e==="from"?a("开始日期","From"):a("结束日期","To"),t("input",{type:"date",value:e==="from"?u:v,onChange:N=>{W({from:u,to:v,[e]:N.target.value}),T(0)}})))):null,t("div",{className:"cm-stat-controls"},tt(a("计费口径","Cost basis"),x,Y,[["api",a("API 费用","API cost")],["plan",a("Plan 等值","Plan equivalent")],["total",a("API + Plan 等值","API + Plan equivalent")]]),tt(a("提供商","Provider"),f,e=>{M(e),j("")},[["",a("全部提供商","All providers")],...[...new Set([f,...p?.providers??[]])].filter(Boolean).map(e=>[e,e])]),tt(a("模型","Model"),R,j,[["",a("全部模型","All models")],...[...new Set([R,...p?.modelOptions??[]])].filter(Boolean).map(e=>[e,e])])),t("p",{className:"cm-stat-sub"},a("按宿主时区归日:","Days use the host timezone: ")+(s.meta.timezone||"UTC")+" · "+a("API 金额是按已记录用量和配置单价计算的估算;Plan 为订阅用量的 API 等值,不是订阅账单。外部导入用量在原概览中单列。","API amounts estimate recorded usage at configured rates; Plan is API-equivalent usage, not the subscription invoice. External usage remains separate in Overview.")),D.loading?t("p",{className:"cm-stat-empty",role:"status"},a("加载统计…","Loading statistics…")):D.error?t("p",{className:"cm-stat-error",role:"alert"},D.error):p?t(Q,null,t("p",{className:"cm-stat-sub",style:{marginTop:8}},p.from+" – "+p.to+(p.retainedFrom?" · "+a("账本保留范围 ","Retained ledger ")+p.retainedFrom+" – "+p.retainedTo:"")),t("div",{className:"cm-stat-metrics"},$(a("API 费用","API cost"),I(h.apiCost),a("已入账估算","Recorded estimate")),$(a("Plan 等值费用","Plan equivalent"),I(Math.max(0,h.cost-h.apiCost)),a("不代表实际扣款","Not an actual debit")),$(a("调用次数","Calls"),h.calls.toLocaleString(),p.sessionCount+a(" 个对话 · 平均 "," conversations · average ")+I(h.calls?S(h,x)/h.calls:0)),$(a("缓存命中率","Cache hit rate"),z(h.cacheRead,h.input+h.cacheRead+h.cacheWrite),m(et(h))+" Tokens")),C.id?t(mt,{key:JSON.stringify([C.id,u,v,f,R,x]),api:l,query:{...g,from:p.from,to:p.to,offset:0},revision:L,money:I,formatTokens:m,text:a}):null,p.models.some(e=>!e.priced)?t("p",{className:"cm-stat-note"},a("部分模型当前未配置价格,金额可能不完整;未定价不等于免费。","Some models have no configured price. Amounts may be incomplete; unpriced usage is not free.")):null,!h.calls&&!h.cost?t("p",{className:"cm-stat-empty"},a("所选范围没有已记录的用量。","No recorded usage in this range.")):null,t("section",{className:"cm-stat-panel"},t(rt,{rows:dt(p.days,x),money:I,text:a,label:a("费用趋势 · ","Cost over time · ")+wt,onSelect:e=>{W({from:e.from,to:e.to}),ut("custom")}}),t("p",{className:"cm-stat-sub",style:{marginTop:12}},a("点击柱形查看该日期范围。长区间按相邻日期合并,保留全部金额。","Select a bar to inspect its date range. Long ranges group adjacent days without dropping costs."))),t("div",{className:"cm-stat-grid"},bt(a("模型费用排行","Cost by model"),t(Q,null,t("ol",{className:"cm-stat-rank"},(U?p.models:p.models.slice(0,8)).map(e=>t("li",{key:e.key},t("button",{type:"button",onClick:()=>{M(e.provider),j(e.model),T(0)},title:e.key},t("div",{className:"cm-stat-rankline"},t("span",null,e.model),t("span",{className:"cm-stat-number"},I(S(e,x))+(e.priced?"":" + ?"))),t("div",{className:"cm-stat-sub"},e.provider+" · "+e.calls+a(" 次 · "," calls · ")+z(S(e,x),S(h,x))),t("div",{className:"cm-stat-track"},t("span",{style:{width:z(S(e,x),S(h,x))}}))))),p.models.length>8?B(U?a("收起","Show less"):a("显示全部模型","Show all models"),()=>_(e=>!e)):null,p.unmodeledCost>1e-8?t("p",{className:"cm-stat-note"},a("历史未分配到模型的费用:","Historical cost without a model: ")+I(p.unmodeledCost)):null)),bt(a("Token 构成","Token composition"),t(Q,null,t("ol",{className:"cm-stat-rank"},Object.entries(St).map(([e,N])=>t("li",{key:e},t("div",{className:"cm-stat-rankline"},t("span",null,N),t("span",{className:"cm-stat-number"},m(h[e]))),t("div",{className:"cm-stat-track"},t("span",{style:{width:z(h[e],et(h)),background:e.startsWith("cache")?"var(--cm-plan)":void 0}}))))),t("p",{className:"cm-stat-note"},a("这里展示 Token 数量占比,不是费用占比。缓存写入计入输入分母,不算缓存命中。","These are token shares, not cost shares. Cache writes count as input, not cache hits.")),t("p",{className:"cm-stat-sub"},a("单独上报的推理 Token:","Reported reasoning tokens: ")+m(h.reasoning))))),C.id?null:t("section",{className:"cm-stat-panel"},t("div",{className:"cm-stat-panel-head"},t("h3",null,a("对话费用排行","Cost by conversation")),t("span",{className:"cm-stat-sub"},a("点击对话查看明细 · 子代理分别计入,不重复相加","Select a conversation for details · each agent counted once"))),t("div",{className:"cm-stat-scroll"},t("table",{className:"cm-stat-table"},t("thead",null,t("tr",null,...[a("对话","Conversation"),a("调用","Calls"),"API",a("Plan 等值","Plan equivalent"),a("缓存命中","Cache hits")].map(e=>t("th",{key:e},e)))),t("tbody",null,p.sessions.map(e=>t("tr",{key:e.id},t("td",null,t("button",{type:"button",title:e.id,onClick:()=>gt({id:e.id,title:e.title})},e.title)),t("td",null,e.calls.toLocaleString()),t("td",null,I(e.apiCost)),t("td",null,I(Math.max(0,e.cost-e.apiCost))),t("td",null,z(e.cacheRead,e.input+e.cacheRead+e.cacheWrite))))))),p.unassignedCost>1e-8?t("p",{className:"cm-stat-note"},a("未关联对话的费用:","Cost not linked to a conversation: ")+I(p.unassignedCost)):null,t(X,{offset:F,count:p.sessionCount,size:25,onChange:T,text:a})))):null)}async function Ct(s){const l=await s.get("remote").$mount(ot);s.effect(()=>()=>l(),"cost-meter: statistics contribution");const o=s.get("remote.costMeter"),d=Object.fromEntries(["getBillingStatistics","getSessionBilling"].map(m=>[m,async c=>{const a=await o[m](lt(c));if(!a?.ok)throw new Error(a?.error?.message||"Statistics request failed");return(m==="getBillingStatistics"?V:G)(a.value)}]));return m=>t(pt,{...m,api:d})}return{mount:Ct,Statistics:pt,SessionDetail:mt,CONTRIBUTION:ot,parseStatistics:V,parseDetail:G,dailyChartRows:dt}}});
|
|
23
|
+
`;function nt(s,r,i,m){const[c,l]=y({value:null,error:"",loading:!0}),e=JSON.stringify(i);return Nt(()=>{let d=!0;return l({value:null,error:"",loading:!0}),s[r](JSON.parse(e)).then(x=>{d&&l({value:x,error:"",loading:!1})},x=>{d&&l({value:null,error:String(x.message??x),loading:!1})}),()=>{d=!1}},[s,r,e,m]),c}const R=(s,r,i={})=>t("button",{type:"button",className:"cm-stat-btn",onClick:r,...i},s);function J({offset:s,count:r,size:i,onChange:m,text:c}){return t("div",{className:"cm-stat-page"},t("span",{className:"cm-stat-sub"},r?`${s+1}–${Math.min(s+i,r)} / ${r}`:"0"),R(c("上一页","Previous"),()=>m(Math.max(0,s-i)),{disabled:!s}),R(c("下一页","Next"),()=>m(s+i),{disabled:s+i>=r}))}function gt({rows:s,money:r,text:i,onSelect:m,label:c}){const l=Math.max(0,...s.map(d=>d.value)),e=s.reduce((d,x)=>d+x.value,0);return t("div",null,t("div",{className:"cm-stat-panel-head"},t("h3",null,c),t("span",{className:"cm-stat-sub"},i("最高 ","Peak ")+r(l)+" · "+i("合计 ","Total ")+r(e))),t("div",{className:"cm-stat-chart",role:"group","aria-label":c},s.map((d,x)=>t("button",{key:x,type:"button",className:"cm-stat-bar",title:d.label+" · "+r(d.value),"aria-label":d.label+" · "+r(d.value),onClick:()=>m(d)},t("span",{style:{height:l?Math.max(1,d.value/l*100)+"%":"0%",opacity:d.value?void 0:.2}})))),t("div",{className:"cm-stat-axis"},t("span",null,s[0]?.label),t("span",null,s.at(-1)?.label)))}function ht(s,r){const i=Math.max(1,Math.ceil(s.length/60)),m=[];for(let c=0;c<s.length;c+=i){const l=s.slice(c,c+i),e=l[0].date,d=l.at(-1).date;m.push({from:e,to:d,label:e===d?e:e+" – "+d,value:l.reduce((x,w)=>x+T(w,r),0)})}return m}function H({title:s,rows:r,total:i,format:m,text:c,columns:l=!1}){return t("section",{"aria-label":s},t("h3",null,s),i<=0?t("p",{className:"cm-stat-sub"},c("暂无可计算的占比","No measurable share yet")):t("ol",{className:"cm-stat-share-list"+(l?" cm-stat-step-list":"")},r.map((e,d)=>t("li",{key:d,"data-other":e.other||void 0},t("div",{className:"cm-stat-share-head"},t("span",null,e.label),t("span",{className:"cm-stat-share-value"},m(e.value)+(e.unpriced?" + ?":"")+" · "+j(e.value,i))),t("div",{className:"cm-stat-share-track","aria-hidden":!0},t("span",{className:"cm-stat-share-fill",style:{width:Math.min(100,100*e.value/i)+"%"}}))))))}function bt({api:s,sessionId:r,turn:i,revision:m,text:c}){const[l,e]=y(0),[d,x]=y(0),w=nt(s,"getTurnInspection",{sessionId:r,turn:i,offset:l},m+":"+d);if(w.loading)return t("p",{className:"cm-stat-empty",role:"status"},c("读取这一轮的输入和工具调用…","Loading this turn’s input and tools…"));if(w.error)return t("div",{className:"cm-stat-inspection"},t("p",{className:"cm-stat-error",role:"alert"},w.error),R(c("重试","Retry"),()=>x(h=>h+1)));const C=w.value;return C.found?t("div",{className:"cm-stat-inspection"},t("h4",null,c("本轮用户输入","User input for this turn")),C.input?t("pre",{className:"cm-stat-pre"},C.input):t("p",{className:"cm-stat-sub"},c("日志未记录本轮用户输入。","No user input is recorded for this turn.")),C.inputTruncated?t("p",{className:"cm-stat-truncated"},c("输入过长,此处显示前 16,000 个字符。","Showing the first 16,000 characters of this input.")):null,t("h4",null,c("工具调用","Tool calls")+" · "+C.totalTools),C.totalTools?null:t("p",{className:"cm-stat-sub"},c("这一轮没有工具调用记录。","No tool calls are recorded in this turn.")),C.tools.map(h=>t("details",{key:h.seq,className:"cm-stat-tool"},t("summary",null,t("span",null,(h.step==null?"":c("步骤 ","Step ")+h.step+" · ")+h.name),t("span",{className:"cm-stat-sub"},{complete:c("已完成","Completed"),error:c("调用失败","Failed"),pending:c("未记录结果","No recorded result")}[h.status])),t("h4",{style:{marginTop:12}},c("调用参数","Arguments")),t("pre",{className:"cm-stat-pre"},h.arguments),t("h4",null,c("返回结果","Result")),t("pre",{className:"cm-stat-pre"},h.result||c("没有可显示的文本结果","No text result is available")),h.truncated?t("p",{className:"cm-stat-truncated"},c("长内容显示前 16,000 个字符;完整内容保留在会话轨迹中。","Long content is limited to 16,000 characters here; the full content remains in the conversation trajectory.")):null)),t(J,{offset:l,count:C.totalTools,size:20,onChange:e,text:c}),t("p",{className:"cm-stat-sub"},c("这里显示整轮原始记录,不受上方模型或计费筛选影响。附件仅显示类型。","Shows the whole turn, independent of model and cost filters above. Attachments are represented by type."))):t("p",{className:"cm-stat-note"},c("这一轮的原始日志不可用。","Original records for this turn are unavailable."))}function ft({api:s,query:r,revision:i,money:m,formatTokens:c,text:l}){const[e,d]=y(0),[x,w]=y(-1),[C,h]=y(0),[_,E]=y(null),[P,F]=y("cost"),A=nt(s,"getSessionBilling",{...r,offset:e,turnOffset:C},i),u=A.value,O=r.basis,L={input:l("输入","Input"),output:l("输出","Output"),cacheRead:l("缓存读取","Cache read"),cacheWrite:l("缓存写入","Cache write"),reasoning:l("推理","Reasoning")},M={model:l("模型调用","Model call"),compaction:l("上下文压缩","Compaction"),search:l("原生搜索","Native search")},W=a=>a==null?l("未标明轮次","Turn not recorded"):l("轮次 ","Turn ")+a;if(A.loading)return t("p",{className:"cm-stat-empty",role:"status"},l("正在读取本对话的用量明细…","Loading this conversation’s usage records…"));if(A.error)return t("p",{className:"cm-stat-error",role:"alert"},A.error);if(!u.found)return t("p",{className:"cm-stat-note"},l("没有可用的调用日志。上方账本统计仍然有效;明细不会按零费用处理。","Call logs are unavailable. The ledger totals above remain valid; missing details do not mean zero cost."));const lt=a=>O==="total"||(O==="plan"?a.plan:!a.plan),$=Object.keys(L).map(a=>{const o=u.rows.filter(b=>b.bucket===a&<(b));return{bucket:a,tokens:o.reduce((b,f)=>b+f.tokens,0),cost:o.reduce((b,f)=>b+f.cost,0),unpriced:o.some(b=>!b.priced)}}),B=T(u,O),z=T(u.recorded,O),U=Math.abs(B-z)>Math.max(1e-8,Math.abs(z)*1e-8)||u.totalCalls!==u.recorded.calls,Q=(a,o)=>t("p",{key:o,className:"cm-stat-sub"},L[a.bucket]+": "+a.tokens.toLocaleString()+" × "+(a.priced?m(a.rate):"?")+" / 1,000,000 = "+(a.priced?m(a.cost):"?")),K=(a,o,b)=>t("div",{className:"cm-stat-scroll",style:{marginTop:20}},t("h3",null,a),t("table",{className:"cm-stat-table"},t("thead",null,t("tr",null,...[a,l("调用次数","Calls"),l("输入 / 缓存 / 输出 Tokens","Input / cache / output tokens"),"API",l("Plan 等值","Plan equivalent")].map(f=>t("th",{key:f},f)))),t("tbody",null,o.map((f,V)=>t("tr",{key:V},t("td",null,b(f)),t("td",null,f.calls),t("td",null,[f.input,f.cacheRead+f.cacheWrite,f.output].map(c).join(" / ")),t("td",null,m(f.apiCost)+(f.unpriced?" + ?":"")),t("td",null,m(Math.max(0,f.cost-f.apiCost))+(f.unpriced?" + ?":"")))))));return t("section",{className:"cm-stat-panel"},t("div",{className:"cm-stat-panel-head"},t("h3",null,l("单对话费用明细","Conversation cost details")),t("span",{className:"cm-stat-sub"},l("只统计本对话自身的调用","Own calls only; child conversations are separate"))),t("details",{className:"cm-stat-help"},t("summary",null,l("统计口径","How these amounts are calculated")),t("p",null,l("明细按日志中的调用时间、用量和当前配置的历史价格规则计算。上方汇总采用已入账金额;调整价格后两者可能不同。","Details use logged usage and call times with the currently configured historical price rules. The summary above uses recorded ledger amounts; changing prices can produce a difference."))),U?t("p",{className:"cm-stat-note"},l("账本与可用明细不同:账本 ","Ledger and available details differ: ledger ")+m(z)+" / "+u.recorded.calls+l(" 次;明细 "," calls; details ")+m(B)+" / "+u.totalCalls+l(" 次。"," calls.")):null,t("div",{className:"cm-stat-shares"},t(H,{title:l("费用构成","Cost composition"),rows:$.map(a=>({label:L[a.bucket],value:a.cost,unpriced:a.unpriced})),total:B,format:m,text:l}),t(H,{title:l("调用类型占比 · 次数","Call type share · count"),rows:u.kinds.map(a=>({label:M[a.kind]??a.kind,value:a.calls})),total:u.totalCalls,format:a=>a.toLocaleString()+l(" 次"," calls"),text:l})),t("section",{className:"cm-stat-breakdown"},t("div",{className:"cm-stat-panel-head"},t("h3",null,l("各步骤占比","Share by step")),t("div",{className:"cm-stat-periods"},...[["cost",l("费用","Cost")],["calls",l("调用次数","Calls")]].map(([a,o])=>R(o,()=>F(a),{key:a,"aria-pressed":P===a})))),t(H,{title:P==="cost"?l("费用占比 · 全部调用","Cost share · all calls"):l("调用次数占比 · 全部调用","Call count share · all calls"),rows:(u.stepShares?.[P]??[]).map(a=>({label:a.other?l("其余步骤合计","All other steps"):W(a.turn)+(a.step==null?"":l(" · 步骤 "," · step ")+a.step),value:P==="cost"?T(a,O):a.calls,unpriced:P==="cost"&&a.unpriced,other:a.other})),total:P==="cost"?B:u.totalCalls,format:P==="cost"?m:a=>a.toLocaleString(),text:l,columns:!0}),t("p",{className:"cm-stat-sub",style:{marginTop:12}},l("分母包含所选范围的全部调用,跨页合计。显示前 12 项,其余合并;无步骤编号的调用单列。工具本身不额外算作模型调用。","Shares use all calls in the selected range, across pages. Top 12 entries are shown; the rest are combined. Missing step IDs remain explicit. Tools are not counted as extra model calls."))),t("details",{className:"cm-stat-help"},t("summary",null,l("单价与 Token 明细","Rates and token details")),t("div",{className:"cm-stat-scroll"},t("table",{className:"cm-stat-table"},t("thead",null,t("tr",null,...[l("费用构成","Cost component"),"Tokens",l("费用","Cost"),l("费用占比","Cost share")].map(a=>t("th",{key:a},a)))),t("tbody",null,$.map(a=>t("tr",{key:a.bucket},t("td",null,L[a.bucket]),t("td",null,a.tokens.toLocaleString()),t("td",null,m(a.cost)+(a.unpriced?" + ?":"")),t("td",null,j(a.cost,B))))))),t("p",{className:"cm-stat-sub",style:{marginTop:10}},l("推理 Token 可能包含在输出中;单价为 0 时不另收推理费。未定价用量显示 ?。","Reasoning tokens may overlap output; a zero reasoning rate adds no separate charge. Unpriced usage is marked ?.")),K(l("按调用类型统计","Cost by call type"),u.kinds,a=>M[a.kind]??a.kind)),t("section",{className:"cm-stat-turns"},t("div",{className:"cm-stat-panel-head"},t("h3",null,l("按轮次统计","Cost by turn")),t("span",{className:"cm-stat-sub"},l("展开查看输入和工具调用","Expand to inspect input and tool calls"))),u.turns.map(a=>t("div",{key:a.turn??"unknown",className:"cm-stat-turn"},t("button",{type:"button",className:"cm-stat-turn-toggle",disabled:a.turn==null,"aria-expanded":a.turn!=null&&_===a.turn,onClick:()=>E(o=>o===a.turn?null:a.turn)},t("span",null,(a.turn==null?"":_===a.turn?"⌄ ":"› ")+W(a.turn)),t("span",null,a.calls+l(" 次调用"," calls")+" · "+c(Z(a))+" tok · "+m(T(a,O))+(a.unpriced?" + ?":""))),a.turn!=null&&_===a.turn?t(bt,{key:a.turn,api:s,sessionId:r.sessionId,turn:a.turn,revision:i,text:l}):null))),t("p",{className:"cm-stat-sub"},l("轮次沿用日志编号;未记录轮次的调用单列。每轮包含其全部调用,跨调用分页也不会拆分。","Turn numbers come from the log; calls without one are listed separately. Each turn includes all its calls, across call pages.")),t(J,{offset:C,count:u.totalTurns,size:25,onChange:a=>{h(a),E(null)},text:l}),t("div",{style:{marginTop:24}},t(gt,{rows:u.calls.map((a,o)=>({label:"#"+(e+o+1),index:o,value:T(a,O)})),money:m,text:l,label:l("逐次调用费用 · 当前页","Cost per call · current page"),onSelect:a=>{w(a.index),document.getElementById("cm-stat-call-"+a.index)?.scrollIntoView({block:"nearest",behavior:"smooth"})}})),u.calls.map((a,o)=>t("details",{key:o,id:"cm-stat-call-"+o,className:"cm-stat-call",open:x===o,onToggle:b=>{!b.currentTarget.open&&x===o&&w(-1)}},t("summary",null,"#"+(e+o+1)+" · "+W(a.turn)+(a.step==null?"":l(" / 步骤 "," / step ")+a.step)+" · "+(M[a.kind]??a.kind)+" · "+a.provider+" / "+a.model+" · "+(a.plan?"Plan ":"API ")+(a.priced?m(a.cost):"?")+(a.longContext?l(" · 长上下文价"," · long-context rate"):"")),t("p",{className:"cm-stat-sub"},new Date(a.atMs).toLocaleString()),a.rows.map(Q))),t(J,{offset:e,count:u.totalCalls,size:50,onChange:a=>{d(a),w(-1)},text:l}))}function vt({state:s,api:r,sessionId:i="",formatMoneyUsd:m,formatTokens:c}){const l=s.config.locale==="en"||s.config.locale==="auto"&&!/^zh\b/i.test(navigator.language),e=(n,S)=>l?S:n,[d,x]=y(i?"all":"week"),[w,C]=y(null),[h,_]=y({id:i,title:i}),[E,P]=y(""),[F,A]=y(""),[u,O]=y(i?"total":"api"),[L,M]=y(0),[W,lt]=y(0),[$,B]=y(!1),z=s.meta.dayKey||new Date().toISOString().slice(0,10),U=d==="all"?"":d==="custom"?w?.from||z:wt(z,d==="week"?-6:d==="month"?-29:0),Q=d==="custom"&&w?.to||z,K={from:U,to:Q,provider:E,model:F,basis:u,sessionId:h.id,offset:L},a=nt(r,"getBillingStatistics",K,W+":"+s.meta.now),o=a.value,b=n=>m(n,{...s.config,decimals:Math.max(n!==0&&Math.abs(n)<.01?8:4,s.config.decimals??2)}),f=n=>{x(n),M(0)},V=n=>{_(n),M(0),P(""),A("")},Pt=u==="total"?e("API + Plan 等值","API + Plan equivalent"):u==="plan"?e("Plan 等值","Plan equivalent"):e("API 费用","API cost"),rt=(n,S,ct,At)=>t("label",null,n,t("select",{value:S,onChange:X=>{ct(X.target.value),M(0)}},At.map(([X,Ot])=>t("option",{key:X,value:X},Ot)))),Mt={input:e("未缓存输入","Uncached input"),cacheRead:e("缓存读取","Cache read"),cacheWrite:e("缓存写入","Cache write"),output:e("输出","Output")},yt=(n,S)=>t("section",{className:"cm-stat-panel"},t("div",{className:"cm-stat-panel-head"},t("h3",null,n)),S),v=o?.totals,G=(n,S,ct)=>t("div",{className:"cm-stat-metric",key:n},t("div",{className:"cm-stat-sub"},n),t("div",{className:"cm-stat-value"},S),t("div",{className:"cm-stat-sub"},ct));return t("div",{className:"cm-stat"},t("style",null,Tt),t("header",{className:"cm-stat-head"},t("div",null,t("h2",null,e("计费统计","Cost statistics")),t("p",{className:"cm-stat-sub"},h.id?e("单对话 · ","Conversation · ")+(h.title===h.id&&o?.sessions[0]?.title||h.title):e("全部对话 · 从总额查看每一笔调用","All conversations · from totals to individual calls"))),R(e("刷新","Refresh"),()=>lt(n=>n+1))),h.id?R(e("← 全部对话","← All conversations"),()=>V({id:"",title:""})):null,t("div",{className:"cm-stat-controls"},t("div",{className:"cm-stat-periods"},...[["today",e("今天","Today")],["week",e("近 7 天","Last 7 days")],["month",e("近 30 天","Last 30 days")],["all",e("全部记录","All retained")],["custom",e("自定义","Custom")]].map(([n,S])=>R(S,()=>f(n),{key:n,"aria-pressed":d===n})))),d==="custom"?t("div",{className:"cm-stat-controls"},...["from","to"].map(n=>t("label",{key:n},n==="from"?e("开始日期","From"):e("结束日期","To"),t("input",{type:"date",value:n==="from"?U:Q,onChange:S=>{C({from:U,to:Q,[n]:S.target.value}),M(0)}})))):null,t("div",{className:"cm-stat-controls"},rt(e("计费口径","Cost basis"),u,O,[["api",e("API 费用","API cost")],["plan",e("Plan 等值","Plan equivalent")],["total",e("API + Plan 等值","API + Plan equivalent")]]),rt(e("提供商","Provider"),E,n=>{P(n),A("")},[["",e("全部提供商","All providers")],...[...new Set([E,...o?.providers??[]])].filter(Boolean).map(n=>[n,n])]),rt(e("模型","Model"),F,A,[["",e("全部模型","All models")],...[...new Set([F,...o?.modelOptions??[]])].filter(Boolean).map(n=>[n,n])])),t("details",{className:"cm-stat-help"},t("summary",null,e("计费说明","About billing")),t("p",null,e("按宿主时区归日:","Days use the host timezone: ")+(s.meta.timezone||"UTC")+" · "+e("API 金额是按已记录用量和配置单价计算的估算;Plan 为订阅用量的 API 等值,不是订阅账单。外部导入用量在原概览中单列。","API amounts estimate recorded usage at configured rates; Plan is API-equivalent usage, not the subscription invoice. External usage remains separate in Overview."))),a.loading?t("p",{className:"cm-stat-empty",role:"status"},e("加载统计…","Loading statistics…")):a.error?t("p",{className:"cm-stat-error",role:"alert"},a.error):o?t(Y,null,t("p",{className:"cm-stat-sub",style:{marginTop:8}},o.from+" – "+o.to+(o.retainedFrom?" · "+e("账本保留范围 ","Retained ledger ")+o.retainedFrom+" – "+o.retainedTo:"")),t("div",{className:"cm-stat-metrics"},G(e("API 费用","API cost"),b(v.apiCost),e("已入账估算","Recorded estimate")),G(e("Plan 等值费用","Plan equivalent"),b(Math.max(0,v.cost-v.apiCost)),e("不代表实际扣款","Not an actual debit")),G(e("调用次数","Calls"),v.calls.toLocaleString(),o.sessionCount+e(" 个对话 · 平均 "," conversations · average ")+b(v.calls?T(v,u)/v.calls:0)),G(e("缓存命中率","Cache hit rate"),j(v.cacheRead,v.input+v.cacheRead+v.cacheWrite),c(Z(v))+" Tokens")),h.id?t(ft,{key:JSON.stringify([h.id,U,Q,E,F,u]),api:r,query:{...K,from:o.from,to:o.to,offset:0},revision:W,money:b,formatTokens:c,text:e}):null,o.models.some(n=>!n.priced)?t("p",{className:"cm-stat-note"},e("部分模型当前未配置价格,金额可能不完整;未定价不等于免费。","Some models have no configured price. Amounts may be incomplete; unpriced usage is not free.")):null,!v.calls&&!v.cost?t("p",{className:"cm-stat-empty"},e("所选范围没有已记录的用量。","No recorded usage in this range.")):null,t("section",{className:"cm-stat-panel"},t(gt,{rows:ht(o.days,u),money:b,text:e,label:e("费用趋势 · ","Cost over time · ")+Pt,onSelect:n=>{C({from:n.from,to:n.to}),f("custom")}}),t("p",{className:"cm-stat-sub",style:{marginTop:12}},e("点击柱形查看该日期范围。长区间按相邻日期合并,保留全部金额。","Select a bar to inspect its date range. Long ranges group adjacent days without dropping costs."))),t("div",{className:"cm-stat-grid"},yt(e("模型费用排行","Cost by model"),t(Y,null,t("ol",{className:"cm-stat-rank"},($?o.models:o.models.slice(0,8)).map(n=>t("li",{key:n.key},t("button",{type:"button",onClick:()=>{P(n.provider),A(n.model),M(0)},title:n.key},t("div",{className:"cm-stat-rankline"},t("span",null,n.model),t("span",{className:"cm-stat-number"},b(T(n,u))+(n.priced?"":" + ?"))),t("div",{className:"cm-stat-sub"},n.provider+" · "+n.calls+e(" 次 · "," calls · ")+j(T(n,u),T(v,u))),t("div",{className:"cm-stat-track"},t("span",{style:{width:j(T(n,u),T(v,u))}}))))),o.models.length>8?R($?e("收起","Show less"):e("显示全部模型","Show all models"),()=>B(n=>!n)):null,o.unmodeledCost>1e-8?t("p",{className:"cm-stat-note"},e("历史未分配到模型的费用:","Historical cost without a model: ")+b(o.unmodeledCost)):null)),yt(e("Token 构成","Token composition"),t(Y,null,t("ol",{className:"cm-stat-rank"},Object.entries(Mt).map(([n,S])=>t("li",{key:n},t("div",{className:"cm-stat-rankline"},t("span",null,S),t("span",{className:"cm-stat-number"},c(v[n]))),t("div",{className:"cm-stat-track"},t("span",{style:{width:j(v[n],Z(v)),background:n.startsWith("cache")?"var(--cm-plan)":void 0}}))))),t("p",{className:"cm-stat-note"},e("这里展示 Token 数量占比,不是费用占比。缓存写入计入输入分母,不算缓存命中。","These are token shares, not cost shares. Cache writes count as input, not cache hits.")),t("p",{className:"cm-stat-sub"},e("单独上报的推理 Token:","Reported reasoning tokens: ")+c(v.reasoning))))),h.id?null:t("section",{className:"cm-stat-panel"},t("div",{className:"cm-stat-panel-head"},t("h3",null,e("对话费用排行","Cost by conversation")),t("span",{className:"cm-stat-sub"},e("点击对话查看明细 · 子代理分别计入,不重复相加","Select a conversation for details · each agent counted once"))),t("div",{className:"cm-stat-scroll"},t("table",{className:"cm-stat-table"},t("thead",null,t("tr",null,...[e("对话","Conversation"),e("调用","Calls"),"API",e("Plan 等值","Plan equivalent"),e("缓存命中","Cache hits")].map(n=>t("th",{key:n},n)))),t("tbody",null,o.sessions.map(n=>t("tr",{key:n.id},t("td",null,t("button",{type:"button",title:n.id,onClick:()=>V({id:n.id,title:n.title})},n.title)),t("td",null,n.calls.toLocaleString()),t("td",null,b(n.apiCost)),t("td",null,b(Math.max(0,n.cost-n.apiCost))),t("td",null,j(n.cacheRead,n.input+n.cacheRead+n.cacheWrite))))))),o.unassignedCost>1e-8?t("p",{className:"cm-stat-note"},e("未关联对话的费用:","Cost not linked to a conversation: ")+b(o.unassignedCost)):null,t(J,{offset:L,count:o.sessionCount,size:25,onChange:M,text:e})))):null)}async function It(s){const r=await s.get("remote").$mount(ut);s.effect(()=>()=>r(),"cost-meter: statistics contribution");const i=s.get("remote.costMeter"),m=Object.fromEntries(["getBillingStatistics","getSessionBilling","getTurnInspection"].map(c=>[c,async l=>{const e=await i[c]((c==="getTurnInspection"?mt:dt)(l));if(!e?.ok)throw new Error(e?.error?.message||"Statistics request failed");return{getBillingStatistics:at,getSessionBilling:et,getTurnInspection:st}[c](e.value)}]));return c=>t(vt,{...c,api:m})}return{mount:It,Statistics:vt,SessionDetail:ft,TurnInspection:bt,ShareChart:H,CONTRIBUTION:ut,parseStatistics:at,parseDetail:et,parseInspection:st,dailyChartRows:ht}}});
|
package/lib/index.js
CHANGED
|
@@ -38,6 +38,7 @@ import { queryQwenCli } from './qwen-cli.js'
|
|
|
38
38
|
import { queryBailianCli } from './bailian-cli.js'
|
|
39
39
|
import { getSessionCost as readSessionCost } from './session-tree.js'
|
|
40
40
|
import { getTurnCost, getSessionBilling } from './turn-cost.js'
|
|
41
|
+
import { getTurnInspection } from './turn-inspection.js'
|
|
41
42
|
import { billingStatistics } from './billing-statistics.js'
|
|
42
43
|
|
|
43
44
|
export const name = 'cost-meter'
|
|
@@ -2398,6 +2399,7 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
2398
2399
|
},
|
|
2399
2400
|
async getBillingStatistics(query) { return billingStatistics(ledger, query) },
|
|
2400
2401
|
async getSessionBilling(query) { return getSessionBilling(ledger, ctx, query) },
|
|
2402
|
+
async getTurnInspection(query) { return getTurnInspection(ctx, query) },
|
|
2401
2403
|
async getTopSessions(limit, sort = 'cost', dir = 'desc') {
|
|
2402
2404
|
const n = Math.max(1, Math.min(500, Math.floor(Number(limit)) || 100))
|
|
2403
2405
|
const sortKey = sort === 'time' || sort === 'recent' ? sort : 'cost'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DeepSeek 原生搜索不经过 llm/stream,且宿主会丢弃 Messages 响应的 usage。
|
|
3
|
-
*
|
|
3
|
+
* 在搜索作用域内观察官方 Messages 响应诊断及 fetch 的已解析 JSON。
|
|
4
4
|
* 不读取请求 headers/body,也不改变搜索返回值、取消或错误语义。
|
|
5
5
|
*/
|
|
6
6
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
@@ -10,6 +10,7 @@ import { readFileSync, writeFileSync, renameSync, mkdirSync, statSync } from 'no
|
|
|
10
10
|
import { dirname } from 'node:path'
|
|
11
11
|
import { gunzipSync, inflateSync, brotliDecompressSync } from 'node:zlib'
|
|
12
12
|
import { recordNativeSearchUsage } from './native-search-history.js'
|
|
13
|
+
import { observeSearchFetch } from './native-search-fetch.js'
|
|
13
14
|
export { NATIVE_SEARCH_USAGE_EVENT, isNativeSearchUsageEvent } from './native-search-events.js'
|
|
14
15
|
|
|
15
16
|
const MAX_BODY_BYTES = 4 * 1024 * 1024
|
|
@@ -70,9 +71,10 @@ function parseBody(request) {
|
|
|
70
71
|
* 可注入诊断通道/目标判定仅用于不付费的本地回归;生产固定使用官方端点。
|
|
71
72
|
* 每个 HTTP request 独立计数,相同 token 的并发/重复搜索不会相互去重。
|
|
72
73
|
*/
|
|
73
|
-
export function createNativeSearchBilling({ account, record = () => {}, uncovered = () => {}, diagnose = () => {}, now = Date.now, targetRequest = officialRequest, diagnostics = channel }) {
|
|
74
|
+
export function createNativeSearchBilling({ account, record = () => {}, uncovered = () => {}, diagnose = () => {}, now = Date.now, targetRequest = officialRequest, diagnostics = channel, fetchTarget = globalThis }) {
|
|
74
75
|
const scope = new AsyncLocalStorage()
|
|
75
76
|
const searchSignal = new AsyncLocalStorage()
|
|
77
|
+
const fetchScope = new AsyncLocalStorage()
|
|
76
78
|
const requests = new WeakMap()
|
|
77
79
|
const pending = new Set()
|
|
78
80
|
const signals = new Set()
|
|
@@ -87,6 +89,7 @@ export function createNativeSearchBilling({ account, record = () => {}, uncovere
|
|
|
87
89
|
subscriptions.push(() => source.unsubscribe(safe))
|
|
88
90
|
}
|
|
89
91
|
const miss = (item, reason) => {
|
|
92
|
+
item = item.attempt?.item ?? item
|
|
90
93
|
if (item.coverageReported) return
|
|
91
94
|
item.coverageReported = true
|
|
92
95
|
try { uncovered({ startedAtMs: item.startedAtMs, reason }) } catch {}
|
|
@@ -115,16 +118,64 @@ export function createNativeSearchBilling({ account, record = () => {}, uncovere
|
|
|
115
118
|
pending.delete(item)
|
|
116
119
|
requests.delete(request)
|
|
117
120
|
}
|
|
121
|
+
const commit = (item, parsed) => {
|
|
122
|
+
if (!active || item.attempt?.accounted) return
|
|
123
|
+
const event = { ...parsed, provider: 'deepseek-official', startedAtMs: item.startedAtMs, requestId: item.requestId }
|
|
124
|
+
if (item.attempt) item.attempt.accounted = true
|
|
125
|
+
try { account(event, item.session) } catch { miss(item, 'account-failed'); return }
|
|
126
|
+
seen('accounted')
|
|
127
|
+
if (item.session) {
|
|
128
|
+
try { record(item.session, event) } catch { miss(item, 'history-unavailable') }
|
|
129
|
+
} else miss(item, 'history-unavailable')
|
|
130
|
+
}
|
|
131
|
+
const currentSearch = () => {
|
|
132
|
+
const signal = searchSignal.getStore()
|
|
133
|
+
const current = scope.getStore()
|
|
134
|
+
return current && !current.closed ? current : signal?.pending && !signal.owner?.closed && now() - signal.at < 60_000 ? signal : null
|
|
135
|
+
}
|
|
136
|
+
const restoreFetch = observeSearchFetch({ target: fetchTarget, seen,
|
|
137
|
+
context: () => active ? currentSearch() : null,
|
|
138
|
+
run(attempt, operation) {
|
|
139
|
+
const signal = searchSignal.getStore()
|
|
140
|
+
if (signal) finishSignal(signal)
|
|
141
|
+
const item = { session: attempt.current.session, startedAtMs: now(), requestId: randomUUID(), attempt, done: false }
|
|
142
|
+
item.release = () => { clearTimeout(item.timer); pending.delete(item) }
|
|
143
|
+
item.timer = setTimeout(() => { item.done = true; if (!attempt.accounted) miss(item, 'response-incomplete'); item.release() }, 60_000)
|
|
144
|
+
item.timer.unref?.()
|
|
145
|
+
pending.add(item)
|
|
146
|
+
attempt.item = item
|
|
147
|
+
attempt.current.requests.push(item)
|
|
148
|
+
try { return fetchScope.run(attempt, operation) } catch (error) { item.done = true; item.release(); miss(item, 'response-incomplete'); throw error }
|
|
149
|
+
},
|
|
150
|
+
complete(attempt, response) {
|
|
151
|
+
if (attempt.item.done) return
|
|
152
|
+
attempt.item.done = true
|
|
153
|
+
attempt.item.release()
|
|
154
|
+
if (!active || attempt.accounted) return
|
|
155
|
+
seen('fetchResponse')
|
|
156
|
+
const parsed = nativeSearchUsage(response)
|
|
157
|
+
if (parsed) commit(attempt.item, parsed)
|
|
158
|
+
else miss(attempt.item, 'usage-unavailable')
|
|
159
|
+
},
|
|
160
|
+
ignored(attempt) { attempt.item.done = true; attempt.item.release() },
|
|
161
|
+
failed(attempt) { if (attempt.item.done) return; attempt.item.done = true; attempt.item.release(); if (active && !attempt.accounted) miss(attempt.item, 'response-incomplete') },
|
|
162
|
+
})
|
|
118
163
|
subscribe('create', ({ request }) => {
|
|
164
|
+
seen('requestCreateAny')
|
|
165
|
+
if (request?.method !== 'POST') seen('requestMethodMismatch')
|
|
166
|
+
else if (String(request.origin) !== 'https://api.deepseek.com') seen('requestOriginMismatch')
|
|
167
|
+
else if (request.path !== '/anthropic/v1/messages') seen('requestPathMismatch')
|
|
119
168
|
if (!targetRequest(request)) return
|
|
120
169
|
seen('officialRequest')
|
|
121
170
|
const signal = searchSignal.getStore()
|
|
122
171
|
const scoped = scope.getStore()
|
|
123
|
-
const current =
|
|
172
|
+
const current = currentSearch() ?? fetchScope.getStore()?.current
|
|
173
|
+
if (current?.closed) return
|
|
124
174
|
if (!current) { seen('unscopedRequest'); return }
|
|
125
175
|
seen('matchedRequest')
|
|
126
176
|
if (signal) finishSignal(signal)
|
|
127
177
|
const item = { session: current.session, startedAtMs: now(), requestId: randomUUID(), bytes: 0, chunks: [], encoding: '', status: 0, done: false }
|
|
178
|
+
item.attempt = fetchScope.getStore()
|
|
128
179
|
requests.set(request, item)
|
|
129
180
|
current.requests.push(item)
|
|
130
181
|
pending.add(item)
|
|
@@ -172,13 +223,8 @@ export function createNativeSearchBilling({ account, record = () => {}, uncovere
|
|
|
172
223
|
try {
|
|
173
224
|
if (item.status < 200 || item.status >= 300) return
|
|
174
225
|
const parsed = item.tooLarge || item.bytes === 0 ? null : parseBody(item)
|
|
175
|
-
if (!parsed) { miss(item, 'usage-unavailable'); return }
|
|
176
|
-
|
|
177
|
-
try { account(event, item.session) } catch { miss(item, 'account-failed'); return }
|
|
178
|
-
seen('accounted')
|
|
179
|
-
if (item.session) {
|
|
180
|
-
try { record(item.session, event) } catch { miss(item, 'history-unavailable') }
|
|
181
|
-
} else miss(item, 'history-unavailable')
|
|
226
|
+
if (!parsed) { if (!item.attempt) miss(item, 'usage-unavailable'); return }
|
|
227
|
+
commit(item, parsed)
|
|
182
228
|
} catch {
|
|
183
229
|
miss(item, 'usage-unavailable')
|
|
184
230
|
} finally {
|
|
@@ -213,11 +259,12 @@ export function createNativeSearchBilling({ account, record = () => {}, uncovere
|
|
|
213
259
|
}
|
|
214
260
|
},
|
|
215
261
|
async run(operation, session) {
|
|
216
|
-
if (!active || scope.getStore()) return operation()
|
|
262
|
+
if (!active || scope.getStore() && !scope.getStore().closed) return operation()
|
|
217
263
|
seen('searchScope')
|
|
218
264
|
const current = { session, requests: [] }
|
|
219
265
|
try { return await scope.run(current, operation) }
|
|
220
266
|
finally {
|
|
267
|
+
current.closed = true
|
|
221
268
|
// A host event without any matching request used to leave no evidence.
|
|
222
269
|
// Only official search events imply a gap; other providers also use web.search.
|
|
223
270
|
for (const signal of signals) if (signal.owner === current) {
|
|
@@ -227,7 +274,7 @@ export function createNativeSearchBilling({ account, record = () => {}, uncovere
|
|
|
227
274
|
for (const item of current.requests) {
|
|
228
275
|
if (!item.done) {
|
|
229
276
|
item.done = true
|
|
230
|
-
if (active) miss(item, 'response-incomplete')
|
|
277
|
+
if (active && !item.attempt?.accounted) miss(item, 'response-incomplete')
|
|
231
278
|
item.release()
|
|
232
279
|
}
|
|
233
280
|
}
|
|
@@ -241,6 +288,8 @@ export function createNativeSearchBilling({ account, record = () => {}, uncovere
|
|
|
241
288
|
pending.clear()
|
|
242
289
|
scope.disable()
|
|
243
290
|
searchSignal.disable()
|
|
291
|
+
fetchScope.disable()
|
|
292
|
+
restoreFetch()
|
|
244
293
|
},
|
|
245
294
|
}
|
|
246
295
|
}
|
|
@@ -276,7 +325,7 @@ export function createSearchCoverage(path, now = Date.now) {
|
|
|
276
325
|
schedule()
|
|
277
326
|
return {
|
|
278
327
|
observe(name) {
|
|
279
|
-
if (!['searchScope', 'searchEvent', 'otherEndpoint', 'officialRequest', 'unscopedRequest', 'matchedRequest', 'responseHeaders', 'responseComplete', 'accounted', 'webObserved'].includes(name)) return
|
|
328
|
+
if (!['searchScope', 'searchEvent', 'otherEndpoint', 'officialRequest', 'unscopedRequest', 'matchedRequest', 'responseHeaders', 'responseComplete', 'accounted', 'webObserved', 'fetchRequest', 'fetchResponse', 'requestCreateAny', 'requestMethodMismatch', 'requestOriginMismatch', 'requestPathMismatch'].includes(name)) return
|
|
280
329
|
increment(runtime.counters, name); schedule()
|
|
281
330
|
},
|
|
282
331
|
add({ startedAtMs, reason }) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Observe the parsed response of a scoped official search, including non-Undici fetch transports. */
|
|
2
|
+
export function observeSearchFetch({ context, run, complete, failed, ignored, seen, target = globalThis }) {
|
|
3
|
+
const original = target.fetch
|
|
4
|
+
if (typeof original !== 'function' || Object.getOwnPropertyDescriptor(target, 'fetch')?.writable === false) return () => {}
|
|
5
|
+
const wrapped = function (...args) {
|
|
6
|
+
const [input, init] = args
|
|
7
|
+
let url, method
|
|
8
|
+
try {
|
|
9
|
+
url = typeof input === 'string' ? input : input instanceof URL ? input.href : input?.url
|
|
10
|
+
method = init?.method ?? input?.method ?? 'GET'
|
|
11
|
+
} catch { return Reflect.apply(original, this, args) }
|
|
12
|
+
const current = url === 'https://api.deepseek.com/anthropic/v1/messages' && String(method).toUpperCase() === 'POST' ? context() : null
|
|
13
|
+
if (!current) return Reflect.apply(original, this, args)
|
|
14
|
+
const attempt = { current, accounted: false }
|
|
15
|
+
seen('fetchRequest')
|
|
16
|
+
// Observe the same promise and parsed value; do not tee/consume streams, read
|
|
17
|
+
// credentials or queries, alter the dispatcher, or delay the caller's result.
|
|
18
|
+
const promise = run(attempt, () => Reflect.apply(original, this, args))
|
|
19
|
+
Promise.resolve(promise).then(response => {
|
|
20
|
+
if (!response?.ok) { ignored(attempt); return }
|
|
21
|
+
if (typeof response.json !== 'function') return
|
|
22
|
+
const own = Object.getOwnPropertyDescriptor(response, 'json'), json = response.json
|
|
23
|
+
if (own?.configurable === false || !Object.isExtensible(response)) return
|
|
24
|
+
Object.defineProperty(response, 'json', { configurable: true, writable: true, value: function (...jsonArgs) {
|
|
25
|
+
let result
|
|
26
|
+
try { result = Reflect.apply(json, this, jsonArgs) } catch (error) { failed(attempt); throw error }
|
|
27
|
+
Promise.resolve(result).then(value => complete(attempt, value), () => failed(attempt)).catch(() => {})
|
|
28
|
+
return result
|
|
29
|
+
} })
|
|
30
|
+
}, () => failed(attempt)).catch(() => {})
|
|
31
|
+
return promise
|
|
32
|
+
}
|
|
33
|
+
target.fetch = wrapped
|
|
34
|
+
return () => { if (target.fetch === wrapped) target.fetch = original }
|
|
35
|
+
}
|
package/lib/turn-cost.js
CHANGED
|
@@ -6,6 +6,7 @@ import { nativeSearchRecordsFor } from './native-search-history.js'
|
|
|
6
6
|
import { tierFor, usdFromCost } from './pricing.js'
|
|
7
7
|
import { isNativeSearchUsageEvent } from './native-search-events.js'
|
|
8
8
|
import { ledgerStatisticsQuery, matchesStats, selectedStats, emptyStats, STAT_FIELDS } from './billing-statistics.js'
|
|
9
|
+
import { localDayKey } from './store.js'
|
|
9
10
|
|
|
10
11
|
const cache = new Map()
|
|
11
12
|
const fields = { input: 'cacheMiss', output: 'output', cacheRead: 'cacheHit', cacheWrite: 'cacheWrite', reasoning: 'reasoning' }
|
|
@@ -94,9 +95,10 @@ export async function getSessionBilling(ledger, ctx, raw) {
|
|
|
94
95
|
const detail = costFromSamples(selected, ledger.config)
|
|
95
96
|
// Aggregate the complete selection before paging. Unknown round identities stay
|
|
96
97
|
// explicit; timing alone cannot reliably assign a search or compaction to a turn.
|
|
97
|
-
const kinds = new Map(), turns = new Map()
|
|
98
|
+
const kinds = new Map(), turns = new Map(), steps = new Map()
|
|
98
99
|
for (const sample of selected) {
|
|
99
|
-
for (const [map, key, identity] of [[kinds, sample.kind, { kind: sample.kind }], [turns, sample.turn, { turn: sample.turn }]
|
|
100
|
+
for (const [map, key, identity] of [[kinds, sample.kind, { kind: sample.kind }], [turns, sample.turn, { turn: sample.turn }],
|
|
101
|
+
[steps, JSON.stringify([sample.turn, sample.step]), { turn: sample.turn ?? null, step: sample.step ?? null, other: false }]]) {
|
|
100
102
|
const row = map.get(key) ?? { ...identity, ...emptyStats(), unpriced: false }
|
|
101
103
|
for (const bucket of Object.keys(fields)) row[bucket] += sample.buckets[bucket]
|
|
102
104
|
row.calls++; row.cost += sample.cost; row.apiCost += sample.apiCost
|
|
@@ -104,6 +106,19 @@ export async function getSessionBilling(ledger, ctx, raw) {
|
|
|
104
106
|
map.set(key, row)
|
|
105
107
|
}
|
|
106
108
|
}
|
|
109
|
+
const share = metric => {
|
|
110
|
+
const value = row => metric === 'calls' ? row.calls : query.basis === 'plan' ? row.cost - row.apiCost : query.basis === 'api' ? row.apiCost : row.cost
|
|
111
|
+
const all = [...steps.values()].sort((a, b) => value(b) - value(a) || b.calls - a.calls)
|
|
112
|
+
if (all.length <= 12) return all
|
|
113
|
+
const rest = { turn: null, step: null, other: true, ...emptyStats(), unpriced: false }
|
|
114
|
+
for (const row of all.slice(12)) { for (const key of STAT_FIELDS) rest[key] += row[key]; rest.unpriced ||= row.unpriced }
|
|
115
|
+
return [...all.slice(0, 12), rest]
|
|
116
|
+
}
|
|
117
|
+
if (!query.provider && !query.model) for (const row of records) {
|
|
118
|
+
if (row.type !== 'turn/start' || !Number.isSafeInteger(row.data?.turn) || row.data.turn < 0 || !Number.isFinite(row.time)) continue
|
|
119
|
+
const date = localDayKey(row.time)
|
|
120
|
+
if (date >= query.from && date <= query.to && !turns.has(row.data.turn)) turns.set(row.data.turn, { turn: row.data.turn, ...emptyStats(), unpriced: false })
|
|
121
|
+
}
|
|
107
122
|
const orderedTurns = [...turns.values()].sort((a, b) => (a.turn ?? Infinity) - (b.turn ?? Infinity))
|
|
108
123
|
const recorded = emptyStats()
|
|
109
124
|
for (const [date, day] of Object.entries(ledger.days)) {
|
|
@@ -112,6 +127,7 @@ export async function getSessionBilling(ledger, ctx, raw) {
|
|
|
112
127
|
for (const key of STAT_FIELDS) recorded[key] += row[key]
|
|
113
128
|
}
|
|
114
129
|
// Limit transport size, not the totals or category breakdown. Every call is reachable by paging.
|
|
115
|
-
return { ...detail, calls: detail.calls.slice(query.offset, query.offset + 50), totalCalls: detail.calls.length, offset: query.offset, recorded,
|
|
116
|
-
kinds: [...kinds.values()], turns: orderedTurns.slice(query.turnOffset, query.turnOffset + 25), totalTurns: orderedTurns.length, turnOffset: query.turnOffset
|
|
130
|
+
return { ...detail, found: detail.found || orderedTurns.length > 0, calls: detail.calls.slice(query.offset, query.offset + 50), totalCalls: detail.calls.length, offset: query.offset, recorded,
|
|
131
|
+
kinds: [...kinds.values()], turns: orderedTurns.slice(query.turnOffset, query.turnOffset + 25), totalTurns: orderedTurns.length, turnOffset: query.turnOffset,
|
|
132
|
+
stepShares: { cost: share('cost'), calls: share('calls') } }
|
|
117
133
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Read a single turn's user input and tools on demand. Never include system prompts or request headers. */
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
|
4
|
+
import { listSessionLogs, iterateSessionRecords } from './backfill.js'
|
|
5
|
+
|
|
6
|
+
const LIMIT = 16000
|
|
7
|
+
const identity = n => Number.isSafeInteger(n) && n >= 0
|
|
8
|
+
const clipped = value => { const text = typeof value === 'string' ? value : JSON.stringify(value ?? '', null, 2); return { text: text.slice(0, LIMIT), truncated: text.length > LIMIT } }
|
|
9
|
+
const contentText = content => typeof content === 'string' ? content : Array.isArray(content) ? content.map(block => {
|
|
10
|
+
if (block?.type === 'text') return String(block.text ?? '')
|
|
11
|
+
// Binary attachments and remote URLs are never fetched for this view.
|
|
12
|
+
return `[${String(block?.type ?? 'attachment')}]`
|
|
13
|
+
}).join('\n') : ''
|
|
14
|
+
|
|
15
|
+
export async function inspectTurn(records, turn, offset = 0) {
|
|
16
|
+
let active = null, found = false, input = '', inputTruncated = false, totalTools = 0, scanned = 0
|
|
17
|
+
const tools = [], selected = new Map(), inputIds = new Set()
|
|
18
|
+
for await (const event of records) {
|
|
19
|
+
if (++scanned > 500000) throw new Error('Session exceeds the turn inspection limit')
|
|
20
|
+
const data = event.data ?? {}
|
|
21
|
+
if (event.type === 'turn/start') { active = data.turn; if (active === turn) found = true }
|
|
22
|
+
const belongs = (identity(data.turn) ? data.turn : active) === turn
|
|
23
|
+
if (belongs) {
|
|
24
|
+
found = true
|
|
25
|
+
if (event.type === 'user/message' && (!event.surfaceOp || event.surfaceOp === 'append')) {
|
|
26
|
+
const id = data.id ?? event.seq
|
|
27
|
+
if (!inputIds.has(id)) {
|
|
28
|
+
inputIds.add(id)
|
|
29
|
+
const next = clipped(input + (input ? '\n\n' : '') + contentText(data.content))
|
|
30
|
+
input = next.text; inputTruncated ||= next.truncated
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (event.type === 'tool/call') {
|
|
34
|
+
const index = totalTools++
|
|
35
|
+
if (index >= offset && index < offset + 20) {
|
|
36
|
+
const args = clipped(data.arguments)
|
|
37
|
+
const row = { seq: event.seq ?? index, step: identity(data.step) ? data.step : null, name: String(data.name ?? ''), callId: String(data.callId ?? ''),
|
|
38
|
+
atMs: Number.isFinite(event.time) ? event.time : 0, arguments: args.text, result: '', truncated: args.truncated, status: 'pending' }
|
|
39
|
+
tools.push(row); selected.set(row.callId, row)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (event.type === 'tool/result') {
|
|
43
|
+
const message = data.message ?? {}, id = message.source?.callId ?? message.toolCallId ?? data.callId
|
|
44
|
+
const row = selected.get(id)
|
|
45
|
+
if (row) {
|
|
46
|
+
const result = clipped(contentText(message.content))
|
|
47
|
+
row.result = result.text; row.truncated ||= result.truncated; row.status = message.isError ? 'error' : 'complete'
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (event.type === 'turn/end') active = null
|
|
52
|
+
}
|
|
53
|
+
return { found, turn, input, inputTruncated, tools, totalTools, offset }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function getTurnInspection(ctx, query) {
|
|
57
|
+
if (!query || typeof query.sessionId !== 'string' || !query.sessionId || query.sessionId.length > 512 || !identity(query.turn)
|
|
58
|
+
|| !identity(query.offset ?? 0) || (query.offset ?? 0) > 1e7) throw new Error('invalid turn inspection query')
|
|
59
|
+
const live = ctx?.get?.('sessions')?.get?.(query.sessionId)?.snapshotEvents?.()
|
|
60
|
+
if (Array.isArray(live)) return inspectTurn(live, query.turn, query.offset ?? 0)
|
|
61
|
+
const paths = listSessionLogs(join(resolveDshHome(), 'sessions'), new Set([query.sessionId]))
|
|
62
|
+
return inspectTurn(paths.length ? iterateSessionRecords(paths[0]) : [], query.turn, query.offset ?? 0)
|
|
63
|
+
}
|
package/lib/typert.host.js
CHANGED
|
@@ -86,7 +86,11 @@ export const sessionBillingSchema = turnCostSchema.extend({ totalCalls: num, off
|
|
|
86
86
|
kinds: z.array(statisticsBuckets.extend({ kind: z.string(), unpriced: z.boolean() })),
|
|
87
87
|
turns: z.array(statisticsBuckets.extend({ turn: num.nullable(), unpriced: z.boolean() })),
|
|
88
88
|
totalTurns: num, turnOffset: num,
|
|
89
|
+
stepShares: z.object(Object.fromEntries(['cost', 'calls'].map(key => [key, z.array(statisticsBuckets.extend({ turn: num.nullable(), step: num.nullable(), other: z.boolean(), unpriced: z.boolean() }))]))),
|
|
89
90
|
})
|
|
91
|
+
export const turnInspectionQuerySchema = z.object({ sessionId: z.string().min(1).max(512), turn: z.number().int().min(0), offset: z.number().int().min(0).max(1e7).optional() })
|
|
92
|
+
export const turnInspectionSchema = z.object({ found: z.boolean(), turn: num, input: z.string(), inputTruncated: z.boolean(), totalTools: num, offset: num,
|
|
93
|
+
tools: z.array(z.object({ seq: num, step: num.nullable(), name: z.string(), callId: z.string(), atMs: num, arguments: z.string(), result: z.string(), truncated: z.boolean(), status: z.enum(['pending', 'complete', 'error']) })) })
|
|
90
94
|
|
|
91
95
|
const longContextSchema = z.object({
|
|
92
96
|
aboveInputTokens: z.number().positive(),
|
|
@@ -718,6 +722,8 @@ export const TYPERT = {
|
|
|
718
722
|
parameters: [{ name: 'query', wire: 'query', source: 'json', codec: strictCodec('StatisticsQuery', statisticsQuerySchema) }],
|
|
719
723
|
result: strictCodec(name, schema),
|
|
720
724
|
})),
|
|
725
|
+
{ id: 'dsh-cost-meter#costMeter/getTurnInspection', service: 'costMeter', namespace: 'costMeter', method: 'getTurnInspection', invocation: { kind: 'direct' },
|
|
726
|
+
parameters: [{ name: 'query', wire: 'query', source: 'json', codec: strictCodec('TurnInspectionQuery', turnInspectionQuerySchema) }], result: strictCodec('TurnInspection', turnInspectionSchema) },
|
|
721
727
|
{
|
|
722
728
|
// 写入一枚密钥到 DSH 凭据库(v1.6.8):密钥不再经 updateConfig 传递,值只进凭据库。
|
|
723
729
|
id: 'dsh-cost-meter#costMeter/setCredential',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-cost-meter",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.5",
|
|
4
4
|
"description": "DeepSeek Harness 会话费用统计插件:本会话成本、当日费用、历史记录与官方价格同步,支持多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配)、主流 Coding Plan 订阅额度查询与显示(11 家,含小米 MiMo Token Plan 控制台 Cookie 查询、SCNet Token Plan 本地 Credits 计量与火山方舟 Volcano Ark Coding Plan AK/SK 签名查询)、自定义 Provider 余额查询(可配任意 HTTP 端点)与余额进度条、峰谷计价时段显示与切换前弹窗/系统通知提醒(位置/提前量/类型可配),界面中英双语。Session cost tracking plugin for DeepSeek Harness: per-conversation cost, daily totals, history and official price sync, with multi-vendor model pricing (built-in 90+ model catalog with auto-matching), Coding Plan quota queries & display for 11 vendors (incl. Xiaomi MiMo Token Plan via console cookie, local credits metering for the SCNet Token Plan and Volcano Ark Coding Plan via AK/SK signature), plan/API dual-track billing with per-1% & full-window Token Plan usage estimates, custom provider balance lookup (configurable HTTP endpoint) and balance progress bar, plus peak/off-peak pricing display with pre-switch popup & system-notification alerts (position / lead time / type configurable), in a bilingual (Chinese/English) UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|