Skip to content
dsh.fish
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

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source