dsh-cost-profiler 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +146 -2
- package/README.zh.md +136 -0
- package/cordis.patch.yml +6 -0
- package/icon.svg +8 -0
- package/lib/client.js +321 -0
- package/lib/fold.js +344 -0
- package/lib/history.js +337 -0
- package/lib/i18n.js +197 -0
- package/lib/index.js +154 -0
- package/lib/prices.js +95 -0
- package/lib/report.js +419 -0
- package/package.json +51 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-cost-profiler contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,147 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-cost-profiler
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Turn-level cost & performance profiler for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) — attribution, not just a balance display.
|
|
4
|
+
|
|
5
|
+
The 4000-plugin DSH ecosystem has hundreds of "remaining quota" widgets, but nothing that answers *where* a session's tokens, seconds and cents actually went. This plugin folds the session's own event stream into a per-turn, per-model, per-tool profile and renders it on demand — **zero extra LLM calls, zero extra storage**.
|
|
6
|
+
|
|
7
|
+
[中文说明](./README.zh.md)
|
|
8
|
+
|
|
9
|
+
## What you get
|
|
10
|
+
|
|
11
|
+
- **`/cost`** slash command — instant session report with three granularities:
|
|
12
|
+
- `/cost` / `/cost summary` — totals, per-model breakdown, slowest tools
|
|
13
|
+
- `/cost full` — adds the per-turn timeline (wall / LLM ≈ / tools ≈ / tokens / est. cost per turn)
|
|
14
|
+
- `/cost json` — machine-readable output for scripts and dashboards
|
|
15
|
+
- **Cross-session rollup** — the built-in panels are strictly per-session; this is the only place that answers "what did this week / this workspace cost me across all sessions":
|
|
16
|
+
- `/cost week` / `/cost month` / `/cost all` — per-workspace and per-session tables with tokens and estimated cost (append `json` for machine output)
|
|
17
|
+
- **`cost_report`** tool — the model can pull the same session report mid-session when you ask "how much have we spent so far?"
|
|
18
|
+
- **`costProfiler` session projection** — the live aggregate behind both, also exposed on the client wire for dashboards.
|
|
19
|
+
- **Rendered output** — command results and tool cards render as real Markdown (see [Rendering](#rendering)).
|
|
20
|
+
|
|
21
|
+
### Sample output
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
Session cost $0.04 (est) · 2 turns · 27.6k in · 2.7k out · 88.5% cache hit · 58s wall
|
|
25
|
+
|
|
26
|
+
**Turns** 2 · **Messages** 2 · **Retries** 0
|
|
27
|
+
**Wall** 58s · **LLM ≈** 36.16s · **Tools ≈** 3.84s
|
|
28
|
+
**Tokens** 27.6k in · 213.1k cache hit (88.5%) · 2.7k out · 5.5k reasoning
|
|
29
|
+
**Estimated cost** $0.04 — API list prices (peak), USD
|
|
30
|
+
|
|
31
|
+
| Model route | In (uncached) | Cache hit | Cache write | Out | Reasoning | Msgs | Est. cost |
|
|
32
|
+
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
|
33
|
+
| `deepseek/deepseek-v4-pro` | 15.2k | 115k | 0 | 1.9k | 3.4k | 1 | $0.03 |
|
|
34
|
+
| `deepseek/deepseek-flash` | 12.4k | 98.1k | 0 | 812 | 2.1k | 1 | $0.0053 |
|
|
35
|
+
|
|
36
|
+
**Slowest tools**
|
|
37
|
+
|
|
38
|
+
| Tool | Calls | Total | Max | Errors |
|
|
39
|
+
| --- | ---: | ---: | ---: | ---: |
|
|
40
|
+
| `bash` | 1 | 2.3s | 2.3s | 1 |
|
|
41
|
+
| `read_file` | 1 | 1.54s | 1.54s | 0 |
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Rendering
|
|
45
|
+
|
|
46
|
+
The same report text is rendered as Markdown in the chat, so tables arrive as
|
|
47
|
+
tables instead of as raw pipe characters:
|
|
48
|
+
|
|
49
|
+
- The `/cost` result renders through the chat's Markdown renderer
|
|
50
|
+
(`conversation.chat.commandview`), and a `cost_report` call renders through the
|
|
51
|
+
tool-call slot (`tool.call.toolview`).
|
|
52
|
+
- The row's collapsed label is the report's own first line, which is written as
|
|
53
|
+
plain prose (`Session cost $0.04 (est) · 2 turns · …`) so it reads correctly
|
|
54
|
+
even where Markdown is not rendered.
|
|
55
|
+
- Reports of up to 30 lines open by default; longer ones (`/cost full` on a busy
|
|
56
|
+
session) start collapsed behind that label — click the row to expand.
|
|
57
|
+
- `/cost json` renders as a JSON code block, with the code block's copy button.
|
|
58
|
+
- If the client half is unavailable (or Markdown rendering fails), the card
|
|
59
|
+
degrades to the monospace text the host sent. The report itself is always
|
|
60
|
+
plain text end to end — the model always sees text, not markup.
|
|
61
|
+
|
|
62
|
+
Upgrading from a build without the client half? The plugin composition cache
|
|
63
|
+
holds a package's client declaration for the lifetime of the process, so the
|
|
64
|
+
app needs a full restart (not just a page reload) before the slot entries load.
|
|
65
|
+
|
|
66
|
+
## Install
|
|
67
|
+
|
|
68
|
+
Inside DSH (Plugins page, plugin manager tool, or CLI):
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
dsh plugin --profile <profile> add dsh-cost-profiler
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Manual alternative — add the package to your profile's `package.json` dependencies and its name to `dsh.profile.bundles`, then run `pnpm install` in the profile directory. The bundle ships its own `cordis.patch.yml`, so no manual composition edits are needed.
|
|
75
|
+
|
|
76
|
+
## How it works
|
|
77
|
+
|
|
78
|
+
The plugin registers one pure [session projection](https://github.com/deepseek-ai/deepseek-harness) that folds durable session events (`assistant/message`, `tool/call` → `tool/result`, `turn/*`, `step/*`) into:
|
|
79
|
+
|
|
80
|
+
- token buckets per model route (`{provider}/{model}`), including cache read/write and reasoning tokens — taken from authoritative `assistant/message` settlements only, so streamed samples and aborted retries never double-count (same semantics as the official token-usage projection);
|
|
81
|
+
- tool durations from the `tool/call` → `tool/result` envelope-time delta, paired by `callId`, with per-tool call counts, totals, maxima and error counts;
|
|
82
|
+
- turn walls and an `LLM ≈` estimate (step wall minus tool wall).
|
|
83
|
+
|
|
84
|
+
Per-turn detail is capped (default 100 turns) while session totals always cover everything. State is plain JSON and survives projection checkpointing.
|
|
85
|
+
|
|
86
|
+
## Cost estimates
|
|
87
|
+
|
|
88
|
+
Costs are computed from **API list prices** and marked as estimates — subscription/quota billing may differ. Built-in defaults cover DeepSeek's published models (peak and off-peak schedules; off-peak is half of peak). Models without a configured price show `—` instead of a wrong number.
|
|
89
|
+
|
|
90
|
+
The report language is set by the `language` config key (`en` or `zh`, default `en`) — the host half cannot read the app's UI language, so unlike the card chrome the report body does not switch automatically. `/cost json` values stay English regardless, for script stability.
|
|
91
|
+
|
|
92
|
+
Set `showCosts: false` to omit costs entirely, or override prices per route in your profile's `cordis.patch.yml`:
|
|
93
|
+
|
|
94
|
+
```yaml
|
|
95
|
+
- id: cost-profiler
|
|
96
|
+
name: dsh-cost-profiler
|
|
97
|
+
config:
|
|
98
|
+
showCosts: true
|
|
99
|
+
offPeak: false # estimate with off-peak (half-peak) rates
|
|
100
|
+
currency: USD # display currency label
|
|
101
|
+
detailTurns: 100 # per-turn detail kept for /cost full
|
|
102
|
+
language: en # report language: en | zh (card chrome follows the app UI)
|
|
103
|
+
prices: # currency per 1M tokens
|
|
104
|
+
deepseek/deepseek-flash: { input: 0.3, cacheRead: 0.006, cacheWrite: 0, output: 1.2 }
|
|
105
|
+
# bare-model or "default" keys also work as fallbacks
|
|
106
|
+
default: { input: 1, output: 2 }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Cross-session rollup
|
|
110
|
+
|
|
111
|
+
`/cost week` (also `month`, `all`) reads **locally stored sessions** and rolls them up — per-workspace totals plus a per-session table sorted by estimated cost. Nothing like this exists in the built-in panels, which are strictly per-session.
|
|
112
|
+
|
|
113
|
+
Where the data comes from, in order of preference:
|
|
114
|
+
|
|
115
|
+
1. this plugin's own checkpoint for that session (per-model-route buckets — priced most accurately);
|
|
116
|
+
2. the app's official `tokenUsage` checkpoint (session totals without routes — priced via the session's last-used model, or your `default` price);
|
|
117
|
+
3. replaying the session log directly (no checkpoint needed).
|
|
118
|
+
|
|
119
|
+
Notes:
|
|
120
|
+
|
|
121
|
+
- **Read-only and privacy-bounded**: the scan reads `~/.dsh` (`$DSH_HOME` respected), touches only titles, timestamps and usage/timing numbers — never message content — and writes nothing.
|
|
122
|
+
- The current session's last events may lag by one checkpoint flush (typically seconds).
|
|
123
|
+
- Sessions with no profiled activity are skipped; a window with nothing in it renders a pointer to `/cost all` rather than an empty table.
|
|
124
|
+
- The `cost_report` model tool deliberately stays session-scoped — cross-session tables would bloat model context.
|
|
125
|
+
|
|
126
|
+
## Notes & limitations
|
|
127
|
+
|
|
128
|
+
- `LLM ≈` is derived from step timers, not per-request timings — treat it as an approximation.
|
|
129
|
+
- The in-session profile covers the current session (subagent sessions have their own); cross-session rollup is its own feature (see above).
|
|
130
|
+
- The `/cost` command reports text only; it does not enter model context. `/cost json` is the integration point for dashboards.
|
|
131
|
+
- Card chrome (running/JSON/error labels, code-block buttons) follows the app's UI language (English/Chinese) through the app's locale registry; the report body itself is host-generated English.
|
|
132
|
+
|
|
133
|
+
## Compatibility
|
|
134
|
+
|
|
135
|
+
- DSH `0.2.0-rc.1` and later `0.2.0-rc.x` (peer range `^0.2.0-rc.1`), cordis `^4.0.1`, Node ≥ 20.
|
|
136
|
+
- The host half is plain ESM with no build step and no native modules. The client half is a hand-written classic script (no bundler) that registers two UI slots and degrades to plain text if the chat UI is absent.
|
|
137
|
+
|
|
138
|
+
## Development
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
npm install # (not required for tests)
|
|
142
|
+
node --test # 44 unit tests over the fold/report/prices/history layers and the client half
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT — see [LICENSE](./LICENSE).
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# dsh-cost-profiler
|
|
2
|
+
|
|
3
|
+
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的 turn 级成本/性能剖析器 —— 做归因分析,而不只是余额显示。
|
|
4
|
+
|
|
5
|
+
DSH 插件生态里有几百个"剩余额度"组件,但没有一个能回答:一个会话的 token、时间、费用到底花在了哪里。本插件把会话自身的事件流折叠成按 turn、按模型、按工具的剖析报告,按需渲染 —— **零额外 LLM 调用、零额外存储**。
|
|
6
|
+
|
|
7
|
+
[English](./README.md)
|
|
8
|
+
|
|
9
|
+
## 功能
|
|
10
|
+
|
|
11
|
+
- **`/cost` 斜杠命令** —— 即时会话报告,三档粒度:
|
|
12
|
+
- `/cost` 或 `/cost summary` —— 总量、按模型分解、最慢工具排行
|
|
13
|
+
- `/cost full` —— 附加逐 turn 时间线(每 turn 的墙钟 / LLM ≈ / 工具 ≈ / token / 估算费用)
|
|
14
|
+
- `/cost json` —— 机器可读输出,便于脚本与面板集成
|
|
15
|
+
- **跨会话汇总** —— 内置面板严格限于当前会话;「这周 / 这个工作区所有会话一共花了多少」只有这里能回答:
|
|
16
|
+
- `/cost week` / `/cost month` / `/cost all` —— 按工作区聚合 + 按会话明细(按估算费用排序,附 token 与费用;后缀 `json` 输出机器可读格式)
|
|
17
|
+
- **`cost_report` 工具** —— 当你问"到目前为止花了多少"时,模型可以主动调用获取同一份报告
|
|
18
|
+
- **`costProfiler` 会话投影** —— 上述两者的数据源,同时暴露在客户端 wire 上,供面板类集成使用
|
|
19
|
+
- **渲染输出** —— 命令结果与工具卡片都以真 Markdown 渲染(见[输出渲染](#输出渲染))
|
|
20
|
+
|
|
21
|
+
### 输出示例
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
Session cost $0.04 (est) · 2 turns · 27.6k in · 2.7k out · 88.5% cache hit · 58s wall
|
|
25
|
+
|
|
26
|
+
**Turns** 2 · **Messages** 2 · **Retries** 0
|
|
27
|
+
**Wall** 58s · **LLM ≈** 36.16s · **Tools ≈** 3.84s
|
|
28
|
+
**Tokens** 27.6k in · 213.1k cache hit (88.5%) · 2.7k out · 5.5k reasoning
|
|
29
|
+
**Estimated cost** $0.04 — API list prices (peak), USD
|
|
30
|
+
|
|
31
|
+
| Model route | In (uncached) | Cache hit | Cache write | Out | Reasoning | Msgs | Est. cost |
|
|
32
|
+
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
|
33
|
+
| `deepseek/deepseek-v4-pro` | 15.2k | 115k | 0 | 1.9k | 3.4k | 1 | $0.03 |
|
|
34
|
+
| `deepseek/deepseek-flash` | 12.4k | 98.1k | 0 | 812 | 2.1k | 1 | $0.0053 |
|
|
35
|
+
|
|
36
|
+
**Slowest tools**
|
|
37
|
+
|
|
38
|
+
| Tool | Calls | Total | Max | Errors |
|
|
39
|
+
| --- | ---: | ---: | ---: | ---: |
|
|
40
|
+
| `bash` | 1 | 2.3s | 2.3s | 1 |
|
|
41
|
+
| `read_file` | 1 | 1.54s | 1.54s | 0 |
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## 输出渲染
|
|
45
|
+
|
|
46
|
+
同一份报告文本在聊天界面里以 Markdown 渲染,表格就是表格,而不是裸露的管道符:
|
|
47
|
+
|
|
48
|
+
- `/cost` 的结果走聊天的 Markdown 渲染器(`conversation.chat.commandview` 槽),`cost_report` 调用走工具卡片槽(`tool.call.toolview`)。
|
|
49
|
+
- 折叠行的标签就是报告首行本身,而首行写成纯文本散文(`Session cost $0.04 (est) · 2 turns · …`),因此在任何不渲染 Markdown 的位置也读得通。
|
|
50
|
+
- 30 行以内的报告默认展开;更长的(繁忙会话的 `/cost full`)默认收起为那一行摘要 —— 点击行即可展开。
|
|
51
|
+
- `/cost json` 渲染为 JSON 代码块,可用代码块自带的复制按钮。
|
|
52
|
+
- 若客户端半边不可用(或 Markdown 渲染失败),卡片降级为宿主发出的等宽文本。报告全程都是纯文本 —— 模型看到的始终是文本,不是标记。
|
|
53
|
+
|
|
54
|
+
从没有客户端半边的版本升级上来时:插件组合会按进程缓存包的客户端声明,因此需要**完全重启应用**(仅刷新页面无效)后槽才会加载。
|
|
55
|
+
|
|
56
|
+
## 安装
|
|
57
|
+
|
|
58
|
+
在 DSH 内(Plugins 页面、插件管理工具或 CLI):
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
dsh plugin --profile <profile> add dsh-cost-profiler
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
手动方式:把包加进 profile 的 `package.json` 依赖,并把包名加进 `dsh.profile.bundles`,然后在 profile 目录运行 `pnpm install`。bundle 自带 `cordis.patch.yml`,无需手工改组合配置。
|
|
65
|
+
|
|
66
|
+
## 工作原理
|
|
67
|
+
|
|
68
|
+
插件注册一个纯[会话投影](https://github.com/deepseek-ai/deepseek-harness),把持久化会话事件(`assistant/message`、`tool/call` → `tool/result`、`turn/*`、`step/*`)折叠为:
|
|
69
|
+
|
|
70
|
+
- 按模型路由(`{provider}/{model}`)的 token 桶,含缓存读/写与推理 token —— 只取 `assistant/message` 的权威结算值,流式采样与中止的重试不会重复计数(与官方 token-usage 投影语义一致);
|
|
71
|
+
- 由 `tool/call` → `tool/result` 事件时间差(按 `callId` 配对)得到的工具耗时,以及每个工具的调用次数、总耗时、最大耗时与错误数;
|
|
72
|
+
- turn 墙钟与 `LLM ≈` 估算(step 墙钟减去工具耗时)。
|
|
73
|
+
|
|
74
|
+
逐 turn 明细有上限(默认 100 turn),会话总量始终覆盖全程。状态为纯 JSON,可随投影 checkpoint 持久化。
|
|
75
|
+
|
|
76
|
+
## 费用估算
|
|
77
|
+
|
|
78
|
+
费用按 **API 牌价**计算并标注为估算值 —— 订阅/配额计费可能不同。内置 DeepSeek 公开模型的默认价目(峰值与低谷时段;低谷为峰值一半)。未配置价格的模型显示 `—`,而不是给出错误数字。
|
|
79
|
+
|
|
80
|
+
报告语言由 `language` 配置键控制(`en` 或 `zh`,默认 `en`)——宿主半边读不到应用界面语言,因此与卡片小文案不同,报告正文不会自动切换。`/cost json` 的字段值始终为英文,保证脚本解析稳定。
|
|
81
|
+
|
|
82
|
+
设置 `showCosts: false` 可完全关闭费用列,或在 profile 的 `cordis.patch.yml` 中按路由覆盖价格:
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
- id: cost-profiler
|
|
86
|
+
name: dsh-cost-profiler
|
|
87
|
+
config:
|
|
88
|
+
showCosts: true
|
|
89
|
+
offPeak: false # 用低谷价(峰值一半)估算
|
|
90
|
+
currency: USD # 费用显示币种
|
|
91
|
+
detailTurns: 100 # /cost full 保留的逐 turn 明细条数
|
|
92
|
+
language: en # 报告语言:en | zh(卡片小文案自动跟随应用界面语言)
|
|
93
|
+
prices: # 每百万 token 的价格
|
|
94
|
+
deepseek/deepseek-flash: { input: 0.3, cacheRead: 0.006, cacheWrite: 0, output: 1.2 }
|
|
95
|
+
# 也支持裸模型名或 "default" 键作为兜底
|
|
96
|
+
default: { input: 1, output: 2 }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## 跨会话汇总
|
|
100
|
+
|
|
101
|
+
`/cost week`(还有 `month`、`all`)读取**本地已存储的会话**做汇总 —— 按工作区聚合 + 按会话明细(按估算费用排序)。内置面板严格限于当前会话,这类跨会话归因没有任何官方入口。
|
|
102
|
+
|
|
103
|
+
数据来源(按优先级):
|
|
104
|
+
|
|
105
|
+
1. 本插件在该会话自己的 checkpoint(逐模型路由桶,定价最准);
|
|
106
|
+
2. 应用的官方 `tokenUsage` checkpoint(只有会话总量、无路由 —— 按该会话最近使用的模型或你的 `default` 价定价);
|
|
107
|
+
3. 直接重放会话日志(不依赖任何 checkpoint)。
|
|
108
|
+
|
|
109
|
+
说明:
|
|
110
|
+
|
|
111
|
+
- **只读且隐私有界**:扫描 `~/.dsh`(尊重 `$DSH_HOME`),只读标题、时间戳与用量/计时数字 —— 不碰消息正文 —— 且不写任何文件。
|
|
112
|
+
- 当前会话最后几条事件可能滞后一个 checkpoint 刷新周期(通常几秒)。
|
|
113
|
+
- 无剖析活动的会话会被跳过;窗口内没有数据时给出指向 `/cost all` 的提示,而不是空表。
|
|
114
|
+
- `cost_report` 模型工具刻意保持会话内范围 —— 跨会话大表会撑爆模型上下文。
|
|
115
|
+
|
|
116
|
+
## 说明与限制
|
|
117
|
+
|
|
118
|
+
- `LLM ≈` 由 step 计时推导,非逐请求精确计时 —— 视为近似值即可。
|
|
119
|
+
- 会话内剖析范围为当前会话(子代理会话各自独立);跨会话汇总见上文。
|
|
120
|
+
- `/cost` 命令只输出文本,不进入模型上下文;`/cost json` 是面板类集成的接入点。
|
|
121
|
+
- 卡片小文案(运行态/JSON 标签/错误提示/代码块按钮)通过应用的 locale 注册表跟随界面语言(中/英);报告正文由宿主半边生成,为英文。
|
|
122
|
+
|
|
123
|
+
## 兼容性
|
|
124
|
+
|
|
125
|
+
- DSH `0.2.0-rc.1` 及之后的 `0.2.0-rc.x`(peer 范围 `^0.2.0-rc.1`)、cordis `^4.0.1`、Node ≥ 20。
|
|
126
|
+
- 宿主半边为纯 ESM,无构建步骤、无原生模块。客户端半边是手写的 classic script(不走打包器),注册两个 UI 槽;聊天界面缺失时自动降级为纯文本。
|
|
127
|
+
|
|
128
|
+
## 开发
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
node --test # 44 个单元测试:fold/report/prices/history 层 + 客户端半边
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## 许可证
|
|
135
|
+
|
|
136
|
+
MIT —— 见 [LICENSE](./LICENSE)。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# dsh-cost-profiler bundle patch: mounts the plugin into the profile's
|
|
2
|
+
# composition tree. The profile's package.json must list this package under
|
|
3
|
+
# `dsh.profile.bundles` (the `dsh plugin add` flow does this automatically).
|
|
4
|
+
- insert:
|
|
5
|
+
- id: cost-profiler
|
|
6
|
+
name: dsh-cost-profiler
|
package/icon.svg
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
2
|
+
<rect width="64" height="64" rx="14" fill="#1b1f27"/>
|
|
3
|
+
<rect x="12" y="34" width="8" height="16" rx="2" fill="#4d6bfe"/>
|
|
4
|
+
<rect x="24" y="26" width="8" height="24" rx="2" fill="#7b9bff"/>
|
|
5
|
+
<rect x="36" y="18" width="8" height="32" rx="2" fill="#a9c1ff"/>
|
|
6
|
+
<circle cx="50" cy="18" r="7" fill="none" stroke="#ffd166" stroke-width="3"/>
|
|
7
|
+
<path d="M50 18 L54 14" stroke="#ffd166" stroke-width="2" stroke-linecap="round"/>
|
|
8
|
+
</svg>
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client half of dsh-cost-profiler: renders the /cost report as Markdown.
|
|
3
|
+
*
|
|
4
|
+
* The host half delivers the report as plain text (see ./report.js) and the
|
|
5
|
+
* chat UI would otherwise show it in the generic command/tool card, which is a
|
|
6
|
+
* monospace <pre>. This module claims both render slots for the cost report and
|
|
7
|
+
* renders the same text through the chat Markdown renderer instead.
|
|
8
|
+
*
|
|
9
|
+
* Hand-written classic script: no imports, no exports, no JSX, no build step.
|
|
10
|
+
* `id` must equal the package name — the client module loader keys the factory
|
|
11
|
+
* by it and rejects a bundle that registers under a different id.
|
|
12
|
+
*/
|
|
13
|
+
window.__ModuleLoader__.load({
|
|
14
|
+
id: "dsh-cost-profiler",
|
|
15
|
+
factory(require) {
|
|
16
|
+
const React = require("react");
|
|
17
|
+
const h = React.createElement;
|
|
18
|
+
|
|
19
|
+
// Platform seed modules. Missing primitives degrade this card to monospace
|
|
20
|
+
// text; a throwing require() would fail the whole client entry instead.
|
|
21
|
+
let primitives = null;
|
|
22
|
+
try {
|
|
23
|
+
primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
24
|
+
} catch (error) {
|
|
25
|
+
primitives = null;
|
|
26
|
+
}
|
|
27
|
+
// Accepts exotic component types: both primitives used here are memo()
|
|
28
|
+
// wrapped, and memo() returns an object rather than a function.
|
|
29
|
+
const pick = (name) => {
|
|
30
|
+
const value = primitives === null ? null : primitives[name];
|
|
31
|
+
return value === undefined || value === null ? null : value;
|
|
32
|
+
};
|
|
33
|
+
const MarkdownText = pick("MarkdownText");
|
|
34
|
+
const DisclosureRow = pick("DisclosureRow");
|
|
35
|
+
const RowIcon = pick("IconGaugeOutlineRegular") ?? pick("IconApiOutlineRegular");
|
|
36
|
+
|
|
37
|
+
const COMMAND_SLOT = "conversation.chat.commandview";
|
|
38
|
+
const TOOL_SLOT = "tool.call.toolview";
|
|
39
|
+
// Slot keys are dispatched verbatim from the command/tool name — no
|
|
40
|
+
// normalization on either side, so these strings must match lib/index.js.
|
|
41
|
+
const COMMAND_KEYS = ["cost", "cost-report"];
|
|
42
|
+
const TOOL_KEY = "cost_report";
|
|
43
|
+
|
|
44
|
+
// Reports longer than this start collapsed, so `/cost full` does not push
|
|
45
|
+
// the conversation away while `/cost` stays readable at a glance.
|
|
46
|
+
const OPEN_LINE_LIMIT = 30;
|
|
47
|
+
|
|
48
|
+
// Card chrome follows the app's UI language through the app's locale
|
|
49
|
+
// registry — the same mechanism the built-in rows use. The report body
|
|
50
|
+
// itself stays host-generated English; only these labels translate.
|
|
51
|
+
const NS = "cost-profiler";
|
|
52
|
+
const DICTS = Object.freeze({
|
|
53
|
+
en: Object.freeze({
|
|
54
|
+
running: "Running…",
|
|
55
|
+
jsonReport: "JSON report",
|
|
56
|
+
line: "line",
|
|
57
|
+
lines: "lines",
|
|
58
|
+
commandFailed: "The command failed.",
|
|
59
|
+
reportFailed: "The cost report failed.",
|
|
60
|
+
copy: "Copy",
|
|
61
|
+
copied: "Copied",
|
|
62
|
+
code: "Code",
|
|
63
|
+
wrap: "Wrap",
|
|
64
|
+
unwrap: "Unwrap",
|
|
65
|
+
footnotes: "Footnotes",
|
|
66
|
+
}),
|
|
67
|
+
zh: Object.freeze({
|
|
68
|
+
running: "运行中…",
|
|
69
|
+
jsonReport: "JSON 报告",
|
|
70
|
+
line: "行",
|
|
71
|
+
lines: "行",
|
|
72
|
+
commandFailed: "命令执行失败。",
|
|
73
|
+
reportFailed: "费用报告执行失败。",
|
|
74
|
+
copy: "复制",
|
|
75
|
+
copied: "已复制",
|
|
76
|
+
code: "代码",
|
|
77
|
+
wrap: "自动换行",
|
|
78
|
+
unwrap: "不换行",
|
|
79
|
+
footnotes: "脚注",
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/** The slot only receives `t` when the locale face was live at assembly;
|
|
84
|
+
* fall back to the English dictionary rather than throwing into the
|
|
85
|
+
* framework's error boundary. */
|
|
86
|
+
function translate(t) {
|
|
87
|
+
return typeof t === "function" ? t : (key) => DICTS.en[key] ?? key;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function makeLabels(tr) {
|
|
91
|
+
return Object.freeze({
|
|
92
|
+
code: Object.freeze({
|
|
93
|
+
copyLabel: tr("copy"),
|
|
94
|
+
copiedLabel: tr("copied"),
|
|
95
|
+
toolbarLabels: Object.freeze({ codeLabel: tr("code"), wrapLabel: tr("wrap"), unwrapLabel: tr("unwrap") }),
|
|
96
|
+
}),
|
|
97
|
+
footnotes: tr("footnotes"),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const PLAIN_STYLE = {
|
|
102
|
+
whiteSpace: "pre-wrap",
|
|
103
|
+
wordBreak: "break-word",
|
|
104
|
+
fontFamily: "monospace",
|
|
105
|
+
fontSize: 12,
|
|
106
|
+
margin: 0,
|
|
107
|
+
maxHeight: 320,
|
|
108
|
+
overflow: "auto",
|
|
109
|
+
};
|
|
110
|
+
const ERROR_STYLE = { ...PLAIN_STYLE, color: "#d64545" };
|
|
111
|
+
const BODY_STYLE = { padding: "2px 0 4px 20px" };
|
|
112
|
+
const LABEL_STYLE = { marginLeft: 6, opacity: 0.75, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" };
|
|
113
|
+
const ERROR_LABEL_STYLE = { ...LABEL_STYLE, color: "#d64545" };
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Inner boundary for the Markdown subtree. A slot entry that throws during
|
|
117
|
+
* render is abdicated and then leaves a bare error cell in its place — the
|
|
118
|
+
* framework does not fall back to the generic card — so Markdown failures
|
|
119
|
+
* must degrade to the raw report here instead of escaping.
|
|
120
|
+
*/
|
|
121
|
+
class SafeMarkdown extends React.Component {
|
|
122
|
+
constructor(props) {
|
|
123
|
+
super(props);
|
|
124
|
+
this.state = { failed: false };
|
|
125
|
+
}
|
|
126
|
+
static getDerivedStateFromError() {
|
|
127
|
+
return { failed: true };
|
|
128
|
+
}
|
|
129
|
+
componentDidCatch(error) {
|
|
130
|
+
console.error("dsh-cost-profiler: markdown render failed, showing raw report", error);
|
|
131
|
+
}
|
|
132
|
+
render() {
|
|
133
|
+
if (this.state.failed) return h("pre", { style: PLAIN_STYLE }, this.props.text);
|
|
134
|
+
return h(MarkdownText, { text: this.props.text, streaming: false, labels: this.props.labels });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function lineCount(text) {
|
|
139
|
+
return text === "" ? 0 : text.split("\n").length;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function firstLine(text) {
|
|
143
|
+
const end = text.indexOf("\n");
|
|
144
|
+
return end === -1 ? text : text.slice(0, end);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function fenced(text, lang) {
|
|
148
|
+
return "```" + lang + "\n" + text.replace(/\s+$/, "") + "\n```";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** `/cost json` and `cost_report {detail:"json"}` both end at the same body. */
|
|
152
|
+
function isJsonText(text) {
|
|
153
|
+
const trimmed = text.replace(/^\s+/, "");
|
|
154
|
+
return trimmed.charAt(0) === "{" || trimmed.charAt(0) === "[";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isJsonArg(value) {
|
|
158
|
+
return typeof value === "string" && value.trim().toLowerCase() === "json";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function jsonArgMode(argsRaw) {
|
|
162
|
+
if (typeof argsRaw !== "string" || argsRaw === "") return false;
|
|
163
|
+
try {
|
|
164
|
+
const parsed = JSON.parse(argsRaw);
|
|
165
|
+
return parsed !== null && typeof parsed === "object" && parsed.detail === "json";
|
|
166
|
+
} catch (error) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Flatten a tool result's content parts the way the built-in rows do. */
|
|
172
|
+
function contentText(content) {
|
|
173
|
+
if (!Array.isArray(content)) return "";
|
|
174
|
+
const parts = [];
|
|
175
|
+
for (const part of content) {
|
|
176
|
+
if (part === null || typeof part !== "object") continue;
|
|
177
|
+
if (part.type === "text" && typeof part.text === "string") parts.push(part.text);
|
|
178
|
+
else {
|
|
179
|
+
try {
|
|
180
|
+
parts.push(JSON.stringify(part, null, 2));
|
|
181
|
+
} catch (error) {
|
|
182
|
+
// Skip parts that cannot be serialized rather than losing the report.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return parts.join("\n");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function errorText(block, tr) {
|
|
190
|
+
const error = block.error;
|
|
191
|
+
if (error !== null && typeof error === "object") {
|
|
192
|
+
const name = typeof error.name === "string" ? error.name : "";
|
|
193
|
+
const message = typeof error.message === "string" ? error.message : "";
|
|
194
|
+
const code = typeof error.code === "string" ? error.code : "";
|
|
195
|
+
const text = [name, message].filter(Boolean).join(": ") || code;
|
|
196
|
+
if (text !== "") return text;
|
|
197
|
+
}
|
|
198
|
+
return tr("reportFailed");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function bodyNode(text, json, error, labels) {
|
|
202
|
+
if (!MarkdownText) return h("pre", { style: error ? ERROR_STYLE : PLAIN_STYLE }, text);
|
|
203
|
+
return h(SafeMarkdown, { text: json ? fenced(text, "json") : text, labels });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function plainCard(title, text, error) {
|
|
207
|
+
return h(
|
|
208
|
+
"div",
|
|
209
|
+
{ style: { fontSize: 12 } },
|
|
210
|
+
h("div", { style: { opacity: 0.7 } }, title),
|
|
211
|
+
h("pre", { style: error ? ERROR_STYLE : PLAIN_STYLE }, text),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* One report card: a disclosure row whose label is the report's own first
|
|
217
|
+
* line and whose body is the report as Markdown.
|
|
218
|
+
*
|
|
219
|
+
* `open` is derived rather than stored, so a row that starts as "running"
|
|
220
|
+
* and settles into a short report still opens by default; the first click
|
|
221
|
+
* takes over from the derivation.
|
|
222
|
+
*/
|
|
223
|
+
function ReportView(props) {
|
|
224
|
+
const title = props.title;
|
|
225
|
+
const text = typeof props.text === "string" ? props.text : "";
|
|
226
|
+
const running = props.running === true;
|
|
227
|
+
const error = props.error === true;
|
|
228
|
+
const json = props.json === true;
|
|
229
|
+
// An error with no text falls back to a translated generic line.
|
|
230
|
+
const body = error && text === "" ? translate(props.t)("commandFailed") : text;
|
|
231
|
+
const lines = lineCount(body);
|
|
232
|
+
const expandable = !running && body !== "";
|
|
233
|
+
const defaultOpen = json || lines <= OPEN_LINE_LIMIT;
|
|
234
|
+
const [userOpen, setUserOpen] = React.useState(null);
|
|
235
|
+
const open = expandable && (userOpen === null ? defaultOpen : userOpen);
|
|
236
|
+
|
|
237
|
+
// The locale seat's identity is not guaranteed stable, so derive the
|
|
238
|
+
// translator and the MarkdownText labels from it per render pass.
|
|
239
|
+
const tr = React.useMemo(() => translate(props.t), [props.t]);
|
|
240
|
+
const labels = React.useMemo(() => makeLabels(tr), [tr]);
|
|
241
|
+
const label = running
|
|
242
|
+
? tr("running")
|
|
243
|
+
: json
|
|
244
|
+
? `${tr("jsonReport")} · ${lines} ${tr(lines === 1 ? "line" : "lines")}`
|
|
245
|
+
: firstLine(body);
|
|
246
|
+
|
|
247
|
+
if (!DisclosureRow) return plainCard(title, running ? "" : body, error);
|
|
248
|
+
|
|
249
|
+
return h(
|
|
250
|
+
DisclosureRow,
|
|
251
|
+
{
|
|
252
|
+
icon: RowIcon ? h(RowIcon, { size: 14 }) : undefined,
|
|
253
|
+
title,
|
|
254
|
+
open,
|
|
255
|
+
running,
|
|
256
|
+
expandable,
|
|
257
|
+
expandOnRowClick: true,
|
|
258
|
+
keepContentWhenOpen: true,
|
|
259
|
+
onToggle: () => setUserOpen(!open),
|
|
260
|
+
collapsedContent: label ? h("span", { style: error ? ERROR_LABEL_STYLE : LABEL_STYLE }, label) : undefined,
|
|
261
|
+
},
|
|
262
|
+
expandable ? h("div", { style: BODY_STYLE }, bodyNode(body, json, error, labels)) : null,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** `/cost [summary|full|json]` command row. */
|
|
267
|
+
function CostCommandCard(props) {
|
|
268
|
+
const node = props.node;
|
|
269
|
+
if (node === null || typeof node !== "object") return null;
|
|
270
|
+
const title = typeof node.name === "string" && node.name !== "" ? node.name : "cost";
|
|
271
|
+
const outcome = node.outcome;
|
|
272
|
+
if (outcome === null || outcome === undefined) return h(ReportView, { title, running: true, t: props.t });
|
|
273
|
+
const text = typeof outcome.text === "string" ? outcome.text : "";
|
|
274
|
+
if (outcome.kind === "error") return h(ReportView, { title, error: true, t: props.t, text });
|
|
275
|
+
return h(ReportView, { title, text, t: props.t, json: isJsonArg(node.args) || isJsonText(text) });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** `cost_report` tool row. */
|
|
279
|
+
function CostReportRow(props) {
|
|
280
|
+
const title = typeof props.toolName === "string" && props.toolName !== "" ? props.toolName : "cost_report";
|
|
281
|
+
const block = props.block;
|
|
282
|
+
const settled = props.phase === "result" && block !== null && typeof block === "object" && block.kind === "tool-result";
|
|
283
|
+
if (!settled) return h(ReportView, { title, running: true, t: props.t });
|
|
284
|
+
const text = contentText(block.content);
|
|
285
|
+
const error = block.isError === true;
|
|
286
|
+
if (error && text === "") return h(ReportView, { title, error: true, t: props.t, text: errorText(block, translate(props.t)) });
|
|
287
|
+
const argsRaw = block.call !== null && typeof block.call === "object" ? block.call.argsRaw : "";
|
|
288
|
+
return h(ReportView, { title, text, t: props.t, error, json: jsonArgMode(argsRaw) || isJsonText(text) });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function registerEntry(ctx, slotName, key, component) {
|
|
292
|
+
try {
|
|
293
|
+
return ctx.slots.register({ name: slotName, key, locale: NS }, component);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
// An occupied key means the built-in card keeps rendering this command
|
|
296
|
+
// or tool; that is a working fallback, not a failure worth crashing on.
|
|
297
|
+
console.warn(`dsh-cost-profiler: slot entry "${key}" was not registered in ${slotName}`, error);
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function registerAll(ctx, slotName, entries) {
|
|
303
|
+
const disposers = [];
|
|
304
|
+
for (const [key, component] of entries) {
|
|
305
|
+
const dispose = registerEntry(ctx, slotName, key, component);
|
|
306
|
+
if (dispose) disposers.push(dispose);
|
|
307
|
+
}
|
|
308
|
+
return disposers;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function apply(ctx) {
|
|
312
|
+
ctx.effect(() => ctx.locale.register(NS, DICTS), "cost-profiler: dictionaries");
|
|
313
|
+
ctx.slots.inject(COMMAND_SLOT, () =>
|
|
314
|
+
registerAll(ctx, COMMAND_SLOT, COMMAND_KEYS.map((key) => [key, CostCommandCard])),
|
|
315
|
+
);
|
|
316
|
+
ctx.slots.inject(TOOL_SLOT, () => registerAll(ctx, TOOL_SLOT, [[TOOL_KEY, CostReportRow]]));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return { inject: ["slots", "locale"], apply };
|
|
320
|
+
},
|
|
321
|
+
});
|