Bundle
dsh-request-error-dump
DSH plugin: when an upstream model API request fails, dump the exact raw request body and the raw upstream error response to disk.
- Source
- exynos967
- License
- MIT
- Updated
- Updated 2 days ago
Readme
# dsh-request-error-dump
DSH 插件:**模型请求报错时,把这一次的完整请求体 raw JSON 与上游返回的 raw JSON 一起落盘。**
上游 401 / 403 / 429 / 400 到底回了什么,DSH 界面通常只给你一句被归一化过的
"本轮运行失败"。本插件把原始报文留下来,方便事后对照。
---
## 它记录什么
每次失败写一个 JSON 文件,包含四块内容:
| 区块 | 内容 |
| --- | --- |
| `request` | 请求 URL、方法、请求头(默认脱敏)、**请求体原文** `body.raw` 与解析后的 `body.json` |
| `response` | 状态码、状态文案、响应头、**上游响应原文** `body.raw` 与 `body.json` |
| `transportError` | 连接被拒、DNS、TLS、超时等传输层异常(此时 `response` 为 `null`) |
| `dsh` | DSH 侧上下文:`turn` / `step` / `provider` / `failure.{message,code,status,requestId}` / agent 与会话 id / **`correlation`(本次配对靠哪条信号,见下)** |
真实样例(截断):
```json
{
"schema": "dsh-request-error-dump/v1",
"kind": "http-error",
"capturedAt": "2026-09-22T05:27:46.919Z",
"durationMs": 15,
"request": {
"url": "https://api.deepseek.com/chat/completions",
"method": "POST",
"headers": { "content-type": "application/json", "authorization": "<redacted:30 chars>" },
"body": {
"present": true,
"bytes": 83,
"truncated": false,
"raw": "{\"model\":\"deepseek-chat\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"stream\":true}",
"json": { "model": "deepseek-chat", "messages": [{ "role": "user", "content": "hi" }], "stream": true },
"source": "string"
}
},
"response": {
"status": 401,
"statusText": "Unauthorized",
"headers": { "content-type": "application/json", "x-request-id": "a1b2c3d4" },
"body": {
"present": true,
"bytes": 130,
"truncated": false,
"raw": "{\"error\":{\"message\":\"Authentication Fails, Your api key is invalid\",\"type\":\"authentication_error\",\"code\":\"invalid_request_error\"}}",
"json": { "error": { "message": "Authentication Fails, Your api key is invalid", "type": "authentication_error", "code": "invalid_request_error" } }
}
},
"transportError": null,
"dsh": {
"turn": 4,
"step": 1,
"provider": "deepseek-official",
"agent": { "id": "main", "sessionId": "main-session-abc" },
"failure": { "message": "Authentication Fails", "code": "AUTH", "status": 401, "requestId": "a1b2c3d4" }
}
}
```
`body.raw` 是**逐字节的原文**,`body.json` 只是同一份文本的解析视图,方便直接阅读。
用 `bodyFormat: raw` 可以只留原文,用 `bodyFormat: parsed` 可以只留解析结果。
---
## 开箱即用:默认只抓模型请求
**零配置**。装好重启后,只有**模型(LLM)请求**失败才会 dump 到 `~/.dsh/request-error-dump/`。
### 怎么判定"是模型请求"
两条信号,**取并集**(宁可多抓,不可漏抓):
1. **权威信号 —— `agent/request-error` 认领**:DSH 循环在每次模型请求尝试失败时派发该事件。
被认领 = 确定是模型请求,无论 URL 长什么样;
2. **兜底信号 —— 请求结构**:`POST` 且 JSON body 里带字符串 `model` / `modelId`,
或 URL 命中少数"模型写在路径里"的形态(Gemini `:generateContent`、Bedrock
`/model/<id>/converse`)。这一条用来救**不走 agent 循环**的模型调用(比如会话标题生成)。
为什么不用 URL 白名单?因为**会静默漏抓**——自定义网关的路径五花八门,调试工具最怕漏。
所以这里刻意反过来:URL 只列了少数几类,其余全靠 body 里的 `model` 字段和事件认领。
### 两个时序细节
- **不是瞬间落盘**:默认延迟 `flushDelayMs: 1500`(1.5 秒)。这个窗口用来等
`agent/request-error` 来认领并补上 `turn/step/provider/failure`;窗口内没被认领、
又不像模型请求的,直接丢弃(这就是过滤噪音的机制)。设 `0` 则立即写、不等上下文;
- **只抓走到 HTTP 的失败**:密钥没配、路由没有适配器(`NO_ADAPTER`)这类**发请求之前**
就失败的错误不会产生 dump。
### 默认排除的端点
`excludeUrls` 默认含 `['/user/balance']`:DSH 桌面版的余额组件每分钟轮询一次它,
密钥失效时一分钟一个 401,50 个保留位约 50 分钟就被占满。模型门禁本来就会挡掉它,
这里再列一次是**双保险**——万一你设了 `modelRequestsOnly: false`,它仍然不会被记录。
```yaml
- id: request-error-dump
config:
excludeUrls: ['/user/balance'] # 默认值,可按需增删
```
### 为什么监听器要 prepend
`agent/request-error` 是 waterfall:**不调用 `next()` 的监听器会否决整条链**。
`dsh-llm-retry` 在接管重试时正是返回 `{ kind: 'retry' }` 而**不调 `next()`**。
如果本插件按默认顺序注册在它之后,**被重试的失败就永远轮不到我们记录**。
所以监听器用 `{ prepend: true }` 注册在最前,自己再 `return next()` 把控制权交回去——
既不会漏,也不会干扰别人的恢复逻辑(已用真实 Cordis 跑过否决场景验证)。
## 为什么必须在 wire 层做
DSH 的适配器抛出的错误会被 `normalizeLlmFailure` 归一化,**只保留
`message / code / status / providerRetryAfterMs / requestId`** —— 上游响应原文在这一步就
被丢掉了。所以任何只挂在 `agent/request-error` 或 `llm/stream` 上的观察者都拿不到
raw 报文。
真正还同时存在"请求体原文"和"上游响应原文"的地方,只有适配器发起 HTTP 请求的那一刻。
本插件因此包装进程全局的 `fetch`:
- **成功路径完全透明**:2xx 直接原样返回,响应体一个字都不读,零开销;
- **失败路径**:非 2xx 时先 `response.clone()` 再读取,**调用方自己的响应体仍然可读**;
传输层抛错时记录异常并**原样重抛**;
- 捕获回调里的任何异常都会被吞掉——记录失败绝不等于请求失败。
---
## 安装
本插件零运行时依赖,可直接从目录安装。
```bash
# 从 GitHub 安装(推荐)
dsh plugin --profile desktop add github:exynos967/dsh-request-error-dump
# 本地目录(开发用)
dsh plugin --profile desktop add link:D:/dsh-workdir/test/dsh-request-error-dump
```
装完需要重启 DSH(`dsh.profile.bundles` 在启动时读取)。
> 版本提醒:本插件已在 **Cordis 4.0.2**(DSH 桌面版内置的 `@deepseek-ai/cordis`)上
> 完成挂载验证。它只使用 `ctx.on`、`ctx.effect` 与进程全局 `fetch`,不导入任何
> `@deepseek-ai/*` 包,因此不挑 profile,也不受 DSH 小版本升级影响。
---
## 配置
所有配置项都有可用默认值,不配也能跑。写在 profile 的 `cordis.patch.yml` 里覆盖该行:
```yaml
- id: request-error-dump
config:
outDir: ~/.dsh/request-error-dump
maxFiles: 50
maxCaptureBytes: 0
bodyFormat: both
redactHeaders: ['authorization', 'api-key', 'cookie']
flushDelayMs: 1500
ignoreAborted: true
modelRequestsOnly: true
excludeUrls: ['/user/balance']
log: true
```
| 选项 | 默认值 | 说明 |
| --- | --- | --- |
| `outDir` | `$DSH_HOME/request-error-dump`(即 `~/.dsh/request-error-dump`) | 落盘目录,支持 `~` |
| `maxFiles` | `50` | 保留最新的 N 个 dump,更早的自动清理;`0` 表示不清理 |
| `maxCaptureBytes` | `0` | 单个体积上限(字节);**默认 0 = 不截断,保持完整**。截断时会标记 `truncated: true` 并跳过 `json` 视图 |
| `bodyFormat` | `both` | `raw` 只留原文 / `parsed` 只留解析结果 / `both` 都留 |
| `redactHeaders` | `authorization`、`proxy-authorization`、`api-key`、`x-api-key`、`x-auth-token`、`x-goog-api-key`、`cookie`、`set-cookie` | 要脱敏的请求头/响应头名;设为 `[]` 即完全原样 |
| `flushDelayMs` | `1500` | 延迟落盘窗口,用来等 `agent/request-error` 补上 DSH 上下文;`0` 表示立即写、不等上下文 |
| `modelRequestsOnly` | `true` | **只记录模型请求**。`true` 时:被 `agent/request-error` 认领的,或请求结构像模型调用的(POST + body 带 `model`/`modelId`,或 URL 命中 Gemini/Bedrock 形态)才写;其余在窗口结束时丢弃。设 `false` 恢复"抓所有失败的 HTTP" |
| `excludeUrls` | `['/user/balance']` | 正则列表,对 `"METHOD URL"` 求值,**命中即不记录**(在模型门禁之前生效)。设为 `[]` 取消默认排除 |
| `ignoreAborted` | `true` | 用户主动取消(AbortError)不记录 |
| `log` | `true` | 每次落盘在 host 日志里打一行提示 |
---
## 落盘与保留
- 文件名形如 `2026-09-22T05-27-46-919Z-0001.json`,时间戳前缀保证字典序即时间序;
- 先写 `.tmp` 再 `rename`,**不会出现半截文件**;
- 超过 `maxFiles` 后按时间从旧到新清理;并发写入的清理串行化,不会误删新文件;
- 插件卸载/重载时会把还在等待窗口里的记录刷完,不会丢最后一次报错。
---
## ⚠️ 隐私边界
**请求体里就是你的完整上下文**:系统提示词、源码、工具结果、对话历史,可能还有你贴进去的
密钥。插件默认只脱敏请求头里的凭据,**正文一律原样保存**。
- 别在不可信机器上长期开启,也别把 `outDir` 指向同步盘;
- 分享 dump 给他人或提交 issue 前请人工检查;
- 想连请求头也原样保留,设 `redactHeaders: []`(危险,默认不这么做)。
---
## 它不做什么(诚实边界)
- **"模型请求"的判定不是 100% 精确**:包装层是进程全局的,判定靠事件认领 + 请求结构。
极端情况下(自定义网关路径 + body 里没有 `model` 字段 + 该调用又不走 agent 循环)
可能漏抓,此时把 `modelRequestsOnly` 设为 `false` 可退化为全抓;
- **不抓 provider 原生 HTTP 报文的全貌**:抓到的是适配器真正 `fetch` 出去的那一份
body 与真正回来的那一份响应体,但不含 DNS/TLS 层信息、重定向链、以及适配器内部的
重试次数;
- **不抓流中途失败**:HTTP 200 之后 SSE 流中断/解析失败不会触发落盘(那需要缓冲整条
流,代价太高);
- **不抓 FormData / Blob / ReadableStream 请求体**:这些 body 一旦读取就被消费,dump 里
会标记 `unavailable` 说明原因;
- **`dsh` 区块的关联是启发式的**:按 `requestId` → 状态码+模型形态 → 模型形态 → 状态码 →
最近一条 的顺序配对,**并把实际命中的那条信号写进 `dsh.correlation`**
(`requestId` / `status+model` / `model` / `status` / `newest`),不假装确定。
命中 `newest` 时说明只是兜底猜测;dump 里的 URL 与时间戳始终是准的;
- **不改动任何请求**:纯观察者,不重试、不修改、不注入。
---
## 工作原理
监听器注册在插件自己的上下文上(无 scope 标记)。核对过 `@deepseek-ai/dsh-scope`
的 `scopeTarget` 过滤器:`scopeOf(ctx) === undefined` 时一律放行,因此根级监听器能收到
**每个** agent 的 `agent/request-error`,不需要 `inject` 任何服务。同时 `agent/request-error`
是 waterfall —— Cordis 明确规定"不调用 `next()` 的监听器会否决整条链",所以本插件
**永远 `return next()`**,绝不干扰 `dsh-llm-retry` 这类拥有恢复权的监听器。
```
适配器 fetch(init.body = JSON.stringify(payload))
│
▼
globalThis.fetch 包装层 ← 只在此处两段原文同时存在
│ 2xx → 原样返回(不读 body)
│ 非 2xx / 抛错 → 克隆响应、组装 record
▼
dump-store 待写队列(flushDelayMs 窗口)
│ agent/request-error 到达 → 按 requestId/status 认领并补上 dsh 区块
▼
原子写入 $DSH_HOME/request-error-dump/*.json → 按 maxFiles 清理
```
模块划分(每个文件一个职责):
| 文件 | 职责 |
| --- | --- |
| `index.js` | Cordis 插件入口:配置合并、装配、卸载 |
| `src/paths.js` | `DSH_HOME` / `~` 路径解析 |
| `src/record.js` | 请求头归一化与脱敏、body 读取/截断/解析、record 组装 |
| `src/dump-store.js` | 待写队列、关联认领、原子写入、保留清理 |
| `src/fetch-capture.js` | `globalThis.fetch` 包装层(可重复安装、可还原) |
| `src/annotate.js` | `agent/request-error` 监听器(纯观察者,始终 `return next()`) |
---
## 测试
```bash
npm test # 32 个单元/端到端用例,零依赖
node test/cordis-integration.mjs # 在真实 @deepseek-ai/cordis 上挂载验证
```
`cordis-integration.mjs` 会在本机找到 DSH 安装目录时,用真实 Cordis 运行时加载插件、
触发一次真实 waterfall 派发并校验 dump 内容;找不到时自动跳过。可用
`DSH_APP_ROOT` 指定安装路径。
---
## License
MIT
Install
dsh plugin --profile web add github:exynos967/dsh-request-error-dump
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-request-error-dump from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.