dsh-llm-trace
Wire-level LLM inspector: captures the raw HTTP request and response bodies of model provider calls and attributes each capture to the session that caused it through the llm/stream waterfall. The request pane renders the body as a collapsible tree with per-node sizes — one measured request was 98.6%
Install
dsh plugin --profile web add dsh-llm-trace
Sandbox test results
| Stage | Result |
|---|---|
| 1 install | ✓ |
| 2 list | ✓ |
| 3 mount | ✓ |
| 4 boot | — |
Four stages run in an isolated sandbox: install (network on) → list → mount → boot (last three with no network).
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)
Rendered from the README published on npm. Formatted only, not edited.
Versions
Published on npm::5,最后更新 2026-09-21