DeepSeek Harness 报 request extension 失败:一个坏插件包拖垮官方线路
症状很有辨识度:只有 DeepSeek 官方线路(deepseek-official/*)的每一轮都失败,报 DeepSeek request extension preparation failed(REQUEST_EXTENSION),切到 pi-ai 承载的路由(例如 opencode-go)却完全正常——而且没有任何请求到达网络。根因与网络、模型、鉴权都无关:在请求准备阶段采集 dsh_plugin_packages 这个纯诊断字段时,只要有一个活跃插件包解析不出可读的 package.json,或某个被向上找到的 manifest「有 name 却缺 version」,解析器就会抛错;这个错误经注册表的 Promise.all 放大,变成整轮请求失败,发生在 fetch 之前。两条现实触发路径分别是「插件目录被更新器掏空」和「initProfile() 生成的 profile manifest 天生缺 version」——后者让新用户第一次用官方线路就会中招。
先分诊:这不是网络问题,是请求准备阶段被本地元数据拦下
结论先行:先用「零网络流量」这一个特征把范围缩小。 REQUEST_EXTENSION 抛出的位置在适配器里、fetch 之前,所以出现这个错误码时,网络、代理、证书、鉴权、配额全都可以先排除——请求根本没发出去(#6497)。
确认它属于这一类,按下面四步:
- 确认错误码是
REQUEST_EXTENSION,错误文案是DeepSeek request extension preparation failed。这是适配器把注册表准备阶段的拒绝包装后的结果。 - 确认只有官方线路受影响。同一个 profile 下换到 pi-ai 承载的路由就正常——因为
llm-pi-ai根本不消费这个注册表,只有llm-deepseek注册并依赖它。 - 确认它是每轮都失败、而不是只在第一轮。整条链上没有结果级缓存,失败的解析也不会被缓存,所以每一轮都会重新抛一次(#7678)。
- 去读
cause。真正可操作的包名 / manifest 路径 / 该补哪一行都在LlmError.cause里,只是它没有被任何界面渲染出来——这一步往往需要你直接看宿主日志或临时加打印。
完整的失败链(每一环都有对应的源码位置):
某一轮请求
└─ adapter 在 fetch 之前 await 注册表的 prepare()
└─ prepare() 用 Promise.all 并发跑所有贡献者
└─ dsh_plugin_packages 贡献者调用 collectActivePluginPackages()
└─ 逐包 resolver.resolve(entry) —— 没有 per-package try/catch
└─ 某个包解析失败 → throw
↑ 这个 throw 让 Promise.all 整体拒绝
└─ adapter 包成 LlmError(..., 'REQUEST_EXTENSION', { cause })
└─ fetch 从未发生 → 每一轮都失败
值得单独点出的一处设计错配:注册表在**接受(acceptance)阶段本来用的是「全部 settle、互不影响」的宽容语义(acceptAll),但准备(prepare)**阶段却用了 Promise.all——一个贡献者拒绝就拖垮整批。有确认者认为,把准备阶段改成与接受阶段一致才更自然(#6497)。
触发路径一:任意活跃包解析不出可读的 package.json
只要有一个活跃插件包无法在磁盘上解析出可读的 package.json,PackageIdentityResolver.resolve 就会抛错;collectActivePluginPackages 在循环里没有 per-package try/catch,于是整批采集失败,进而整轮请求失败。 这不是偶发环境问题,而是已经发生过的一次真实事故。
问题代码的形态(packages/llm/plugin-package-inventory-deepseek/src/index.ts):
const packageName = barePackageName(entry.options.name)
let manifest: string | undefined
if (packageName !== undefined) {
manifest = barePackageManifest(packageName, anchors)
if (manifest === undefined) {
throw new Error(`plugin-package-inventory-deepseek: cannot resolve active package ${JSON.stringify(packageName)}`)
}
}
barePackageManifest() 用 existsSync 在 Node 的包搜索路径里找 package.json;找不到就抛上面那句。同样的 resolve 还会在「找到了但 manifest 不合法」时抛错:JSON 无法解析,或缺少非空的 name 与 version(#6497)。
真实事故的完整样子(这比理论更值得记住):某部署上的第三方插件更新器对 @memtensor/memos-local-plugin 执行了「先 rm -rf、再 cp」,而 cp 在复制 better_sqlite3.node 时被运行中的宿主占用、以 EPERM 中止。结果是这个包只剩一个空目录、没有 package.json。从那一刻起,每一条 deepseek-official 轮次都失败,LlmError.cause 精确点名了那个包。把包从源目录恢复后,不需要重启宿主——因为失败的解析结果从未被缓存,下一个请求就恢复了(#6497)。
一个容易忽略的细节:并不是所有挂载形态都会触发这个 throw。 相对路径、绝对路径、file URL 入口会向上寻找「最近的 manifest」,找不到时是静默省略,不会抛;只有 bare-name(或包内子路径)入口才会走到上面那个 throw,另外加上「已有的 manifest 畸形或缺少 name/version」这一种(#6497)。
这也解释了为什么这个行为长期没被当成 bug:它是文档写明的契约,不是回归。docs/subsystems/llm-streaming.md 写着「准备、冲突与接受失败使用 REQUEST_EXTENSION 并让模型请求失败」,插件 README 也写着「畸形的包元数据会让请求准备失败」;仓库自己的测试 inventory.spec.ts 还把「畸形元数据 → 拒绝」钉成期望行为。所以修复它必须同时改文档和测试,这也是它能一直留在这里的原因。
触发路径二:initProfile() 生成的 profile manifest 缺 version
第二条路径更严重,因为它不是「你弄坏了什么」,而是「开箱即坏」:initProfile() 创建 profile 时写出的 package.json 没有 version 字段;只要该 profile 里存在相对路径模块入口,采集器向上找到的「最近 manifest」就是这个 profile 自己的 package.json,于是立刻命中「有 name 必须有 version」的校验。
initProfile() 写出的 manifest 形态(packages/boot/app-boot/src/profile.ts):
const manifest = {
name: `dsh-profile-${basename(dir)}`,
private: true,
dependencies: {},
dsh: { profile: { bundles: [...bundles] } }
};
没有 version。 对应到采集器的校验:
if (typeof manifest.name !== "string" || manifest.name.length === 0
|| typeof manifest.version !== "string" || manifest.version.length === 0)
throw new Error(`plugin-package-inventory-deepseek: ${path} must declare non-empty name and version`);
这里有一个不对称,正是问题的关键:采集器对 loose module 分支传了 allowAnonymous: true,于是「完全没有 name」的 manifest 被容忍(返回 undefined、不贡献 identity);但「有 name 却没有 version」不在容忍范围内,会直接抛错。而相对路径入口(例如 name: './x.mjs')恰恰走 loose module 分支、恰恰会向上落到 profile 的 manifest 上——profile 里唯一的那份 manifest,正好落在容忍范围之外(#7678)。
用四步最小复现(不需要任何第三方插件):
-
初始化一个全新 profile,让
initProfile()生成 manifest:bashdsh headless --help cat ~/.dsh/profiles/headless/package.json # => { "name": "dsh-profile-headless", "private": true, ... } 没有 version -
在该 profile 的
cordis.patch.yml里加一个相对路径模块入口:yaml- insert: - id: my-plugin name: './my-plugin.mjs' -
用该 profile 的 DeepSeek 官方线路发一条消息。
-
观察结果:
本轮运行失败 / DeepSeek request extension preparation failed。
一个特别有说服力的对照实验(同一台机器上的两个 profile):
| profile | manifest | 相对路径入口 | 结果 |
|---|---|---|---|
~/.dsh/profiles/web | 有 "version": "0.0.0" | ./eli-host2.mjs | 不触发 |
~/.dsh/profiles/desktop | 无 version | 新加一个本地 ./x.mjs | 加完立刻每轮 REQUEST_EXTENSION |
两者喂给 identityFromManifest() 的唯一差别就是 version:有则返回 identity,无则落到 throw。补上 version 后不需要重启,下一个请求就恢复——因为 throw 位于 cache.set 之前,失败的 key 从未被缓存(#7678)。
还有三点容易误判的细节:
- 改成绝对路径没用。
barePackageName()对./x.mjs和/abs/x.mjs都返回undefined,两者都会走nearestManifest(),从入口所在目录向上找。 - 桌面端不会自愈。 桌面端的
DesktopProjectManager.applyRelease()→createPluginProfile()→initProfile(),而initProfile()只在!existsSync(manifestPath)时才写 manifest,整条路径没有任何补version的迁移。所以只改initProfile()只能救「以后新建」的 profile,存量 profile 必须手工改。 - 这是家族里的第八例。 同一种形态(某个活跃入口解析失败 → 整轮请求失败)此前已有六种不同成因:pnpm 隔离布局(#5172 / #5173 / #5196)、过期的 fallback 符号链接(#5439)、干净构建(#5683)、绝对文件路径模块(#5844)、相对目录模块(#6064)——而
initProfile()这一例的不同之处在于,它把这条链从「历史残留才会中招」推进成了「首次运行即坏」(#7678)。
三条修复与本地绕过
修复的优先顺序很明确:真正能关闭这个问题族的是「采集器逐包容忍 + 告警」;给 initProfile() 补 version 是正确的配套,但它只救新建的 profile;在此之前的当务之急是让存量用户有一条立刻可用的恢复路径。
修法一(推荐,窄改):采集器逐包 warn-and-omit。 在 collectActivePluginPackages 里把 resolver.resolve(activeEntry) 包进 try/catch,记一条 ctx.logger.warn(...) 然后 continue,让其余可解析的包照常采集。仓库内已有同类先例(受控监听器模式)。采用这条修法时,需要同步把 inventory.spec.ts 里「畸形元数据 → 拒绝」的断言改成「告警 + 省略」,并软化 README 那句表述。上报者按这个补丁在本地验证过:一个坏掉的 bare-name 入口现在只产生一条告警并被省略,其余包仍被采集(例如仍能收集到 ["dsh-better-sidebar@0.19.1"])(#6497)。
修法二:注册表级隔离。 把 prepare() 的 Promise.all 改成 Promise.allSettled,把被拒绝的贡献者的字段省略掉(可选加告警)。好处是让准备阶段与接受阶段语义一致。代价是这是文档写明的严格契约,且会改变所有贡献者(包括 dsh_session_log)的行为,被多个测试钉住——真要动,应当做成「按贡献者显式选择加入」,而不是一刀切地全局放宽(#6497)。
修法三(配套):让 initProfile() 写入 version: "0.0.0"。 这是问题链最上游的一环,属一行改动:
const manifest: ProfileManifest & { private: boolean } = {
name: `dsh-profile-${basename(dir)}`,
+ version: '0.0.0',
private: true,
dependencies: {},
dsh: { profile: { bundles: [...bundles] } },
}
并加断言 expect(manifest.version).toBe('0.0.0')。但要注意它只对之后新建的 profile 生效——这正是「修法一必须排在修法三之前」的理由(#7678)。
当下立刻可用的三条绕过:
-
给 profile 的
package.json补一行版本号(立刻生效、无需重启):json{ "name": "dsh-profile-web", "version": "0.0.0", "private": true } -
把本地插件放进自己的子目录,并给它一份带
name+version的package.json(例如profiles/desktop/my-plugin/{package.json,index.mjs},入口写./my-plugin/index.mjs)。因为nearestManifest()先看入口自身所在目录,这样就永远不会落到 profile manifest 上。注意这只是绕开解析器,不是修复——给 profile manifest 补version仍然是更正确的做法(#7678)。 -
在
cordis.patch.yml里关掉采集器(丢掉整个字段的取舍):yaml- id: plugin-package-inventory-deepseek name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' config: enabled: false这一行在基础 bundle 里默认是开启的;关掉它是绕行而不是修复,真正需要这个诊断字段的人不应选它。
排查与注意事项
- 报错不给你包名,是当前最大的可用性缺口。 终端、会话记录、Web UI 三处都只显示最外层那句
DeepSeek request extension preparation failed,而cause里才有包名与 manifest 路径。桌面端尤其麻烦:DesktopHostProcess对宿主 stderr 只做slice(-65536)累积,并且只在宿主退出/失败时上报——请求级失败永远不会触发上报,所以「把 cause 渲染出来」只能落在会话 / Web UI 侧,宿主 stderr 不是可用通道(#7678)。 - 修好之后通常不需要重启。 解析失败的结果不会被缓存,所以修好目录或补上
version,下一个请求就恢复。唯一的例外是「把一个已缓存的包 identity 换成另一个版本」——那需要重启。 - 别把「相对路径入口」当成免疫。 相对 / 绝对 / file URL 入口只在「找不到 manifest」时静默省略;一旦向上找到的 manifest 存在但畸形(含「有
name无version」),照样抛错。这也是为什么 profile 自己的 manifest 会成为元凶。 - 排查顺序建议:先确认错误码与「零网络流量」→ 再看是不是只有官方线路 → 再检查最近有没有插件被更新 / 删除 / 目录被掏空 → 最后检查 profile 的
package.json有没有version。 - 社区已有一个针对这个接缝的 stopgap 插件(
@argszero/cordis-plugin-request-extension-guard):它包住每个已注册 provider 的prepare,把「一个字段抛错 = 整批失败」改成「该字段在本次请求中被跳过、并把它自己的消息按原样记录一次」;可选项repairProfileManifest: true会给缺失的version做纯追加式补写并在同一次请求内重试,让字段不是「幸存」而是「回来」。它明确声明这不等于修复:被跳过的字段确实缺席于那次请求,持久修复仍应在 harness 内完成(#7678)。 - 顺带纠正一个常见误解:
cordis.patch.yml对已存在入口的config是整体替换、不是深合并。共享实现applyEntryPatches对非insert补丁就是逐键覆盖,所以只写defaultPreset会让基础 bundle 里整张presets表消失。这是有意的设计(深合并会改变「刻意替换整份 config」的行),但头注释里「id-targeted config overrides」的措辞容易让人以为会合并——写补丁时记得把整份 config 写全。
来源:
- #6497 — one unresolvable active package fails every deepseek-official request (REQUEST_EXTENSION)
- #7678 — initProfile() 生成的 profile manifest 缺少 version,导致官方线路所有请求失败
这类「明明报的是请求失败、其实是本地元数据不合法」的坑,最麻烦的地方在于报错把可操作的线索吞掉了。如果你想让插件环境本身变得可查,可以装一个 DSH Plugin Hub——DeepSeek Harness 桌面端内置的官方插件市场,除了浏览、安装、卸载与更新插件,还有「自定义安装」让第三方来源一目了然,装完能立刻在已安装列表里核对版本,避免出现「目录被掏空却没人发现」的情况:

把插件来源与版本集中管起来,比在报错里猜哪个包坏了要省事得多。
常见问题
因为出问题的是官方线路独有的一个请求扩展贡献者。deepseek-official 的唯一路由由 llm-deepseek 注册,它会在发请求前采集 dsh_plugin_packages 这个诊断字段;而 llm-pi-ai 完全不消费这个注册表。所以同一个坏插件包只会毒掉官方线路,pi-ai 承载的路由(例如 opencode-go)照常工作。
真正可操作的信息在 LlmError 的 cause 里,但目前终端、会话记录、Web UI 三处都只渲染最外层那句 DeepSeek request extension preparation failed,把 cause 丢掉了——这是上报里被反复抱怨的一点。可以按报错形态反推:plugin-package-inventory-deepseek: cannot resolve active package "<name>" 指向一个 bare-name 入口解析不到包;… must declare non-empty name and version 指向某个被向上找到的 manifest(很可能是 profile 自己的 package.json)缺 version;JSON 解析错误会带 manifest 路径。
最常见的是「插件目录被某个更新器掏空」:先 rm -rf 再 cp,而 cp 因为文件被占用(例如 EPERM on better_sqlite3.node)中途失败,于是这个包只剩一个空目录、没有 package.json。从上报的实际事故看,那一刻起每一条官方线路的轮次都失败,LlmError.cause 精确点名了那个包;把包从源目录恢复后,**下一个请求就恢复**,不需要重启宿主——因为失败的解析结果从未被缓存。
因为 initProfile() 写 profile 的 package.json 时没有写 version,而采集器要求「有 name 的 manifest 必须同时声明非空 version」。profile 里只要有相对路径模块入口(例如 ./x.mjs),采集器会从入口向上找「最近的 manifest」,落到的正是这个 profile 自己的 package.json,于是当场命中校验。这已不是「历史残留」,而是一条开箱即坏的首次运行路径;桌面端尤其明显,因为桌面 profile 就是由它创建的。
截至本文所引报告,master 上这条链仍在,而且被仓库自己的测试与文档钉住(inventory.spec.ts、adapter.spec.ts、registry.spec.ts 与 llm-streaming.md 都把严格失败当作既定契约)。当下可按优先级做三件事:① 给 profile 的 package.json 补 version: "0.0.0"(立刻生效、无需重启);② 把坏掉的插件包修好,或在组合里把它的插件行移除;③ 实在要立刻解封,就在 cordis.patch.yml 里把 plugin-package-inventory-deepseek 这一行设为 enabled: false——但这会丢掉整个诊断字段,是绕行不是修复。
相关术语
- REQUEST_EXTENSION
- LLM 适配器上「请求扩展准备失败」的错误码。适配器在发起 HTTP 之前会 await 注册表的 `prepare()`,任何拒绝都会被包成 `LlmError('DeepSeek request extension preparation failed', 'REQUEST_EXTENSION', { cause })`。因为发生在 `fetch` 之前,所以这类失败的典型特征是「零网络流量」——用它可以把网络问题、鉴权问题一次性排除。— https://github.com/deepseek-ai/deepseek-harness/discussions/6497
- dsh_plugin_packages
- 每次请求前采集的**诊断性**扩展字段,列出当前活跃插件包的标识。它由 `plugin-package-inventory-deepseek` 贡献,本意是给模型一点环境上下文。讽刺之处正在这里:一个纯诊断字段,却因为「采集失败会抛错」而具备了让整轮对话硬失败的能力。— https://github.com/deepseek-ai/deepseek-harness/discussions/7678
- loose module 与 allowAnonymous
- 无法归到某个具名包的模块入口(相对路径、绝对路径、file URL)称为 loose module。采集器在解析时对这条分支传 `allowAnonymous: true`,因此「完全没有 `name`」的 manifest 会被容忍、返回 `undefined`(不贡献 identity);但「有 `name` 却缺 `version`」不在容忍范围内,会直接抛错。这个不对称正是 #7678 的成因。— https://github.com/deepseek-ai/deepseek-harness/discussions/7678
- nearestManifest
- 采集器为某个模块入口寻找「最近的 package.json」的规则:从入口自身所在目录开始向上逐级查找。它决定了相对路径入口会落到哪个 manifest 上——也正因如此,profile 自己的 `package.json` 会成为「最近的那一个」。反过来,把本地插件放进自己的子目录并配一份带 `name` + `version` 的 `package.json`,就能让解析器不再落到 profile manifest 上。— https://github.com/deepseek-ai/deepseek-harness/discussions/7678
来源
- #6497 — Bug: dsh_plugin_packages — one unresolvable active package fails every deepseek-official request (REQUEST_EXTENSION)· deepseek-ai(GitHub Discussions)
- #7678 — initProfile() 生成的 profile manifest 缺少 version,导致 DeepSeek 官方线路所有请求失败(REQUEST_EXTENSION)· deepseek-ai(GitHub Discussions)