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(本次配对靠哪条信号,见下) |
真实样例(截断):
{
"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/。
怎么判定"是模型请求"
两条信号,取并集(宁可多抓,不可漏抓):
- 权威信号 ——
agent/request-error认领:DSH 循环在每次模型请求尝试失败时派发该事件。 被认领 = 确定是模型请求,无论 URL 长什么样; - 兜底信号 —— 请求结构:
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,它仍然不会被记录。
- 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()再读取,调用方自己的响应体仍然可读; 传输层抛错时记录异常并原样重抛; - 捕获回调里的任何异常都会被吞掉——记录失败绝不等于请求失败。
安装
本插件零运行时依赖,可直接从目录安装。
# 从 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 里覆盖该行:
- 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()) |
测试
npm test # 32 个单元/端到端用例,零依赖
node test/cordis-integration.mjs # 在真实 @deepseek-ai/cordis 上挂载验证
cordis-integration.mjs 会在本机找到 DSH 安装目录时,用真实 Cordis 运行时加载插件、
触发一次真实 waterfall 派发并校验 dump 内容;找不到时自动跳过。可用
DSH_APP_ROOT 指定安装路径。
License
MIT
No comments yet. Be the first to write one.