dsh-llm-trace

devtested: passnpmApache-2.0

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%

下载量 / Downloads
806
依赖体积 / node_modules
0.43 MB
测试时间 / Tested
2026-11-07
DSH 版本 / Version
0.2.2-rc.1

Install

dsh plugin --profile web add dsh-llm-trace

Sandbox test results

StageResult
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.mjs measures 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 — Copy always 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 / Raw switches 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

Links

GitHub · Homepage · npm

Same category