dsh-llm-trace
线路层 LLM 检查器:抓取模型调用的原始 HTTP 请求与响应报文,经 llm/stream 瀑布把每次抓取归属到发起它的会话。请求面板把报文渲染成带每节点大小的可折叠树——实测一次请求有 98.6% 是单个 973 项的数组——响应面板则把 OpenAI 风格的 SSE chunk 流重新组装成推理、正文、工具调用与用量。在对话栏标签页与本地查看页展示。
安装
dsh plugin --profile web add dsh-llm-trace
沙箱测试结果
| 阶段 | 结果 |
|---|---|
| 1 安装 | ✓ |
| 2 列装 | ✓ |
| 3 挂载 | ✓ |
| 4 启动 | — |
四阶段在隔离沙箱里跑:安装(有网)→ 列装 → 挂载 → 启动(后三步断网)。
README
dsh-llm-trace
A DeepSeek Harness plugin that captures the raw HTTP request and response bodies of model provider calls and shows them in a Conversation View tab and on a loopback viewer page.
Why it exists
dsh-context reads the durable session log, so it shows the provider-neutral request the loop assembled (system prompt, tool schemas, messages, tool results) and the provider-reported token actuals. It cannot show the wire payload: the exact JSON POSTed to the provider endpoint and the exact bytes that came back. That gap is what this plugin fills.
The llm/stream waterfall is the wrong seam for the payload. It hands listeners a provider-neutral GenerateOptions and a StreamChunk iterable; the wire JSON is built below it, inside the adapter (@earendil-works/pi-ai → the official openai SDK → globalThis.fetch). The wire layer is therefore only reachable by wrapping globalThis.fetch.
Install
dsh plugin --profile web add dsh-llm-trace
Or straight from GitHub:
dsh plugin --profile web add github:wpeng77/dsh-llm-trace
Then start the Web UI with dsh web. No build step: the plugin is plain ESM, ships no bundler output, and declares no install-time script.
Use it
The Conversation View tab
A LLM Trace tab appears beside Chat in every session. It is session-scoped: the host attributes each captured fetch to the model call that caused it, so another session's or another subagent's traffic never appears in this tab.
sessionId is a plain prop on a conversation.view entry, and the component reads the viewer's own JSON endpoints on the same origin — no Remote API and no generated client assembly are involved.
The composer is hidden while the tab is open, the way the Context tab does it. The composer seat is a sibling of the view area inside the conversation scroll container, so the shell exposes no per-view control over it; the bundle installs a :has() rule keyed on the view root instead. A pending approval, question, or plan review stays visible, because those are answers the agent is blocked on rather than a chat composer.
The HTTP viewer
The same data is served by the Web host, so it needs no second port:
http://127.0.0.1:3080/llm-trace
Endpoint · What it returns
GET /llm-trace · The viewer document, read from lib/page.html.
GET /llm-trace/api/list · Newest-first summaries, retention stats, the client-row diagnostic, and the seen session ids. Accepts ?session=<id>.
GET /llm-trace/api/exchange?id=<id> · One exchange with headers and bodies.
This page is not session-scoped: it shows every provider call the process made, which is what you want when a subagent or the title generator is the suspect.
Both surfaces pass through the Web host's Host/Origin fence and browser-session cookie check.
The request body opens as a tree
A real request is mostly one array. A measured capture was 3,653,601 bytes across 8,787 JSON nodes, of which messages alone was 3,602,024 bytes — 98.6% — in 973 entries. Reading that as pretty-printed text means scrolling a long way to learn one fact.
So the request body renders as a collapsible tree, collapsed to its top level by default:
▼ root Object(6) · model: "deepseek-v4.1-flash" 3.7 MiB ├ model "deepseek-v4.1-flash" 21 B ├ messages ▸ Array(973) 3.4 MiB ├ stream true 4 B ├ stream_options ▸ Object(1) 23 B ├ max_tokens 384000 6 B └ tools ▸ Array(65) 62.7 KiB
- Sizes are measured, not estimated. One bottom-up pass gives every node its compact serialized size, so the root's figure equals the captured body exactly.
- Only expanded nodes render. The default view is seven rows; a collapsed multi-megabyte subtree costs one. A container past 500 children emits a "more" row that raises that one container's limit instead of materializing the rest.
- Previews are generic. A container shows its child count and its first short string field, chosen by position rather than by key name, so a shape this plugin has never seen still reads as something.
- Everything follows the theme. The tab carries no fixed colours: the text block that shows an expanded value inherits the same panel background and label colour the rest of the tree uses, so it stays legible on a light theme as well as a dark one.
test/e2e.mjsmeasures the rendered contrast and fails below WCAG AA. - A long string expands to its full text. The preview is a 72-character teaser; clicking it renders the whole value, newlines and all, so a message body or a tool description reads in place instead of being unreadable. The rendered text is capped at 32 KiB and says so —
Copyalways has the complete value. The expanded block deliberately has no inner scrollbar: the pane owns the scroll, and clicking the header collapses it again. Tree/Rawswitches back to the literal bytes, and a body that does not parse (a truncated capture) falls back to raw with a note.
Why a response body looks enormous
A provider streams one SSE chunk per token, and every chunk repeats the entire JSON envelope:
data: {"choices":[{"delta":{"content":"","reasoning_content":"The","role":"assistant"},"index":0},
"created":...,"id":"chatcmpl-...","model":"...","object":"chat.completion.chunk","usage":null}
Per chunk that envelope costs roughly 230 bytes — id 42, object 23, model 21, created 10, usage:null 4, index/role framing 64 — while carrying a handful of bytes of actual token. On a measured 276,186-byte reasoning-and-tool-call stream the breakdown was:
…(truncated)
以上内容来自 npm 上的 README,我们只做格式化,不改内容。
版本
npm 上共发布:5,最后更新 2026-09-21