DSH plugin: one broken package fails the official route
The symptom is highly recognizable: only the official DeepSeek route (deepseek-official/*) fails every single turn with DeepSeek request extension preparation failed (REQUEST_EXTENSION), while switching to a pi-ai-hosted route (for example opencode-go) works perfectly — and no request ever reaches the network. The root cause has nothing to do with the network, the model, or auth: during the request-preparation stage, while collecting the purely diagnostic dsh_plugin_packages field, if any active plugin package cannot resolve a readable package.json, or some manifest found by walking up "has a name but lacks version", the resolver throws; that error is amplified by the registry's Promise.all into a whole-turn request failure, before fetch. The two real-world trigger paths are "a plugin directory hollowed out by an updater" and "the profile manifest generated by initProfile() inherently lacks version" — the latter means a new user hits this the first time they use the official route.
Triage first: this is not a network problem, it is local metadata blocking the preparation stage
Bottom line up front: narrow the scope first using the single signal "zero network traffic". REQUEST_EXTENSION is thrown inside the adapter, before fetch, so once you see this code you can rule out network, proxy, certificates, auth, and quota entirely — the request was never sent (#6497).
To confirm it belongs to this class, follow these four steps:
- Confirm the error code is
REQUEST_EXTENSIONand the message isDeepSeek request extension preparation failed. This is the adapter's wrapping of a rejection during the registry preparation stage. - Confirm only the official route is affected. On the same profile, switching to a pi-ai-hosted route works — because
llm-pi-aidoes not consume this registry at all; onlyllm-deepseekregisters and depends on it. - Confirm it fails every turn, not just the first. There is no result-level cache anywhere in the chain, and failed resolutions are not cached either, so it re-throws on every turn (#7678).
- Go read the
cause. The actionable package name / manifest path / which line to fix all live inLlmError.cause— it is simply not rendered by any interface, so this step usually means reading the host logs directly or temporarily adding a print.
The complete failure chain (each link has a corresponding source location):
one request turn
└─ adapter awaits the registry's prepare() before fetch
└─ prepare() runs all contributors concurrently with Promise.all
└─ the dsh_plugin_packages contributor calls collectActivePluginPackages()
└─ per-package resolver.resolve(entry) —— no per-package try/catch
└─ one package fails to resolve → throw
↑ this throw rejects the whole Promise.all
└─ adapter wraps it as LlmError(..., 'REQUEST_EXTENSION', { cause })
└─ fetch never happens → every turn fails
One design mismatch worth calling out on its own: the registry's acceptance stage already uses tolerant "settle everything, no cross-interference" semantics (acceptAll), but the preparation stage uses Promise.all — one contributor rejecting drags down the whole batch. A confirmer argues that making the preparation stage consistent with the acceptance stage would be more natural (#6497).
Trigger path one: any active package that cannot resolve a readable package.json
As soon as one active plugin package cannot resolve a readable package.json on disk, PackageIdentityResolver.resolve throws; collectActivePluginPackages has no per-package try/catch in its loop, so the whole batch fails to collect, and therefore the whole turn's request fails. This is not a sporadic environment problem — it is a real incident that already happened.
The shape of the offending code (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() uses existsSync to look for package.json in Node's package search path; if it does not find one, it throws the line above. The same resolve also throws when "it was found but the manifest is invalid": JSON that cannot be parsed, or missing a non-empty name and version (#6497).
What the real incident looked like in full (this is more worth remembering than the theory): on some deployment, a third-party plugin updater ran "first rm -rf, then cp" against @memtensor/memos-local-plugin, and the cp was aborted with EPERM because better_sqlite3.node was in use by the running host. The result was a package left as an empty directory with no package.json. From that moment, every deepseek-official turn failed, and LlmError.cause named that package exactly. After restoring the package from the source directory, no host restart was needed — because the failed resolution result was never cached, and the next request recovered (#6497).
An easily overlooked detail: not every mounting form triggers that throw. Relative-path, absolute-path, and file-URL entries walk upward for the "nearest manifest" and silently omit when none is found, without throwing; only bare-name (or in-package subpath) entries reach the throw above — plus the case of "an existing manifest that is malformed or missing name/version" (#6497).
This also explains why the behavior went unclassified as a bug for so long: it is a documented contract, not a regression. docs/subsystems/llm-streaming.md states that "preparation, conflict, and acceptance failures use REQUEST_EXTENSION and fail the model request", and the plugin README says "malformed package metadata will make request preparation fail"; the repo's own test inventory.spec.ts even pins "malformed metadata → reject" as expected behavior. So fixing it requires changing the docs and tests at the same time — which is also why it is still here.
Trigger path two: the profile manifest generated by initProfile() lacks version
The second path is more serious, because it is not "you broke something" but "broken out of the box": the package.json that initProfile() writes when creating a profile has no version field; as long as the profile contains a relative-path module entry, the "nearest manifest" the collector finds by walking up is the profile's own package.json, immediately hitting the "a name requires a version" check.
The manifest written by initProfile() (packages/boot/app-boot/src/profile.ts):
const manifest = {
name: `dsh-profile-${basename(dir)}`,
private: true,
dependencies: {},
dsh: { profile: { bundles: [...bundles] } }
};
No version. Matching it in the collector's check:
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`);
Here there is an asymmetry, and it is the key to the problem: the collector passes allowAnonymous: true for the loose-module branch, so a manifest with no name at all is tolerated (returns undefined, contributes no identity); but "has a name yet lacks version" is not tolerated and throws directly. And a relative-path entry (for example name: './x.mjs') takes exactly the loose-module branch and lands exactly on the profile's manifest — the single manifest in the profile, which falls exactly outside the tolerated range (#7678).
A minimal four-step reproduction (no third-party plugins needed):
-
Initialize a brand-new profile so
initProfile()generates the manifest:bashdsh headless --help cat ~/.dsh/profiles/headless/package.json # => { "name": "dsh-profile-headless", "private": true, ... } no version -
Add a relative-path module entry in that profile's
cordis.patch.yml:yaml- insert: - id: my-plugin name: './my-plugin.mjs' -
Send a message using that profile's DeepSeek official route.
-
Observe the result:
turn failed / DeepSeek request extension preparation failed.
A particularly convincing controlled experiment (two profiles on the same machine):
| profile | manifest | relative-path entry | result |
|---|---|---|---|
~/.dsh/profiles/web | has "version": "0.0.0" | ./eli-host2.mjs | not triggered |
~/.dsh/profiles/desktop | no version | a newly added local ./x.mjs | every turn becomes REQUEST_EXTENSION right after adding it |
The only difference fed to identityFromManifest() is version: present returns an identity, absent falls through to the throw. After adding version, no restart is needed and the next request recovers — because the throw occurs before cache.set, so the failing key was never cached (#7678).
Three more details that are easy to misjudge:
- Switching to an absolute path does not help.
barePackageName()returnsundefinedfor both./x.mjsand/abs/x.mjs, so both go throughnearestManifest(), walking upward from the entry's directory. - The desktop build does not self-heal. Its
DesktopProjectManager.applyRelease()→createPluginProfile()→initProfile(), andinitProfile()only writes the manifest when!existsSync(manifestPath); there is no migration anywhere on that path to addversion. So patchinginitProfile()only rescues profiles created in the future; existing profiles must be edited by hand. - This is the eighth case in the family. The same shape (an active entry fails to resolve → the whole turn's request fails) already had six different causes: pnpm isolated layout (#5172 / #5173 / #5196), a stale fallback symlink (#5439), a clean build (#5683), an absolute file-path module (#5844), and a relative directory module (#6064) — and what makes the
initProfile()case different is that it pushes the chain from "only historical residue gets hit" to "broken on first run" (#7678).
Three fixes and local workarounds
The priority order is clear: what actually closes this problem family is "per-package tolerance + warning in the collector"; adding version to initProfile() is the correct companion change, but it only rescues newly created profiles; until then, the immediate priority is giving existing users a recovery path that works right away.
Fix one (recommended, narrow): per-package warn-and-omit in the collector. In collectActivePluginPackages, wrap resolver.resolve(activeEntry) in try/catch, log a ctx.logger.warn(...), and continue, letting the remaining resolvable packages be collected as usual. There is already precedent in the repo (the controlled-listener pattern). Adopting this fix also means changing the "malformed metadata → reject" assertion in inventory.spec.ts to "warn + omit", and softening that README sentence. The reporter verified this patch locally: a broken bare-name entry now produces only one warning and is omitted, while the rest are still collected (for example it still collects ["dsh-better-sidebar@0.19.1"]) (#6497).
Fix two: registry-level isolation. Change prepare()'s Promise.all to Promise.allSettled, omitting the fields of rejected contributors (optionally with a warning). The benefit is making the preparation stage semantically consistent with the acceptance stage. The cost is that this is a documented strict contract, it changes the behavior of every contributor (including dsh_session_log), and it is pinned by multiple tests — if you do move it, make it explicit per-contributor opt-in rather than a blanket global relaxation (#6497).
Fix three (companion): make initProfile() write version: "0.0.0". This is the most upstream link in the chain and is a one-line change:
const manifest: ProfileManifest & { private: boolean } = {
name: `dsh-profile-${basename(dir)}`,
+ version: '0.0.0',
private: true,
dependencies: {},
dsh: { profile: { bundles: [...bundles] } },
}
and add the assertion expect(manifest.version).toBe('0.0.0'). But note it only takes effect for profiles created afterward — which is exactly why "fix one must come before fix three" (#7678).
Three workarounds available right now:
-
Add one version line to the profile's
package.json(effective immediately, no restart):json{ "name": "dsh-profile-web", "version": "0.0.0", "private": true } -
Put the local plugin in its own subdirectory with a
package.jsoncarryingname+version(for exampleprofiles/desktop/my-plugin/{package.json,index.mjs}, with the entry written as./my-plugin/index.mjs). BecausenearestManifest()looks first at the entry's own directory, it will then never land on the profile manifest. Note this only bypasses the resolver, it is not a fix — addingversionto the profile manifest remains the more correct move (#7678). -
Turn the collector off in
cordis.patch.yml(accepting the loss of the whole field):yaml- id: plugin-package-inventory-deepseek name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' config: enabled: falseThis line is enabled by default in the base bundle; turning it off is a workaround rather than a fix, and anyone who genuinely needs the diagnostic field should not choose it.
Troubleshooting and notes
- The biggest current usability gap is that the error does not give you the package name. All three of the terminal, the session record, and the Web UI show only the outermost
DeepSeek request extension preparation failed, while the package name and manifest path live incause. Desktop is especially awkward:DesktopHostProcessaccumulates host stderr with onlyslice(-65536)and reports it only when the host exits/fails — a request-level failure never triggers a report, so "render the cause" can only land on the session / Web UI side; host stderr is not a usable channel (#7678). - After fixing it, a restart is usually not needed. The failed resolution result is not cached, so repairing the directory or adding
versionmakes the next request recover. The one exception is "swapping an already-cached package identity for another version" — that needs a restart. - Do not treat a "relative-path entry" as immune. Relative / absolute / file-URL entries silently omit only when no manifest is found; once the manifest found by walking up exists but is malformed (including "has
name, noversion"), it throws just the same. That is also why the profile's own manifest becomes the culprit. - Suggested troubleshooting order: first confirm the error code and "zero network traffic" → then check whether only the official route is affected → then check whether a plugin was recently updated / deleted / had its directory hollowed out → finally check whether the profile's
package.jsonhasversion. - The community already has a stopgap plugin for this seam (
@argszero/cordis-plugin-request-extension-guard): it wraps every registered provider'sprepare, turning "one field throws = whole batch fails" into "that field is skipped for this request, and its own message is logged once verbatim"; the optionalrepairProfileManifest: trueperforms a purely additive write of a missingversionand retries within the same request, so the field does not merely "survive" but "comes back". It explicitly states this is not a fix: the skipped field is genuinely absent from that request, and the durable fix still belongs in the harness (#7678). - Correcting a common misconception while we are here: in
cordis.patch.yml,configfor an existing entry is replaced wholesale, not deep-merged. The shared implementationapplyEntryPatchesoverwrites key by key for non-insertpatches, so writing onlydefaultPresetmakes the entirepresetstable from the base bundle disappear. This is intentional (a deep merge would change the line that deliberately replaces a whole config), but the header comment's phrasing "id-targeted config overrides" makes it easy to assume it merges — remember to write the entire config when patching.
Troubleshooting this kind of trap — "the error says the request failed, but it is really invalid local metadata" — is hardest precisely because the error swallows the actionable clue. If you want the plugin environment itself to be inspectable, install DSH Plugin Hub — the official plugin marketplace built into the DeepSeek Harness desktop app. Beyond browsing, installing, uninstalling, and updating plugins, it offers "Custom install" so third-party sources are obvious at a glance, and after installing you can immediately verify the version in the installed list, avoiding the "directory hollowed out with nobody noticing" situation:

Managing plugin sources and versions in one place saves far more trouble than guessing which package is broken from an error message.
Source: Discussion #6497, Discussion #7678.
FAQ
Because the failing piece is a request-extension contributor unique to the official route. The deepseek-official route is registered by llm-deepseek, which collects the dsh_plugin_packages diagnostic field before sending a request; llm-pi-ai does not consume that registry at all. So the same broken plugin package only poisons the official route, and a pi-ai-hosted route (for example opencode-go) keeps working.
The actionable information is in the LlmError cause, but the terminal, the session record, and the Web UI all render only the outermost DeepSeek request extension preparation failed and drop the cause — a point repeatedly complained about in the reports. You can work backwards from the error shape: plugin-package-inventory-deepseek: cannot resolve active package "<name>" points to a bare-name entry whose package cannot be resolved; … must declare non-empty name and version points to some manifest found by walking up (very likely the profile's own package.json) missing version; a JSON parse error carries the manifest path.
The most common case is "a plugin directory hollowed out by an updater": it did rm -rf then cp, and the cp aborted partway because a file was in use (for example EPERM on better_sqlite3.node), leaving the package as an empty directory with no package.json. In the actual incident reported, from that moment every single turn on the official route failed, and LlmError.cause named the offending package exactly; after restoring the package from the source directory, **the very next request recovered** with no host restart — because the failed resolution result was never cached.
Because initProfile() does not write version when it writes the profile's package.json, while the collector requires that any manifest with a name must also declare a non-empty version. As long as the profile has a relative-path module entry (for example ./x.mjs), the collector walks up from the entry to the "nearest manifest", which lands on the profile's own package.json — hitting the check immediately. This is no longer "historical residue" but a broken-out-of-the-box first-run path; it is especially visible on desktop, because the desktop profile is created by exactly this code.
As of the reports cited here, this chain is still present on master, and it is pinned down by the repo's own tests and docs (inventory.spec.ts, adapter.spec.ts, registry.spec.ts, and llm-streaming.md all treat strict failure as the established contract). Right now, do three things in priority order: (1) add version: "0.0.0" to the profile's package.json (takes effect immediately, no restart); (2) repair the broken plugin package, or remove its plugin line from the composition; (3) if you must unblock immediately, set the plugin-package-inventory-deepseek line to enabled: false in cordis.patch.yml — but that loses the whole diagnostic field, so it is a workaround, not a fix.
Related Terms
- REQUEST_EXTENSION
- The error code for "request extension preparation failed" on the LLM adapter. The adapter awaits the registry's `prepare()` before making the HTTP call, and any rejection is wrapped as `LlmError('DeepSeek request extension preparation failed', 'REQUEST_EXTENSION', { cause })`. Because this happens before `fetch`, the signature of these failures is "zero network traffic" — which lets you rule out network, auth, and quota problems in one step.— https://github.com/deepseek-ai/deepseek-harness/discussions/6497
- dsh_plugin_packages
- A **diagnostic** extension field collected before every request, listing the identifiers of the currently active plugin packages. It is contributed by `plugin-package-inventory-deepseek` and is meant to give the model a bit of environment context. The irony is right here: a purely diagnostic field, yet because "a collection failure throws" it has the power to hard-fail an entire conversation turn.— https://github.com/deepseek-ai/deepseek-harness/discussions/7678
- loose module and allowAnonymous
- A module entry that cannot be attributed to a named package (relative path, absolute path, file URL) is called a loose module. When resolving this branch the collector passes `allowAnonymous: true`, so a manifest with no `name` at all is tolerated and returns `undefined` (contributing no identity); but "has a `name` yet lacks `version`" is outside that tolerance and throws directly. This asymmetry is exactly what causes #7678.— https://github.com/deepseek-ai/deepseek-harness/discussions/7678
- nearestManifest
- The rule by which the collector finds the "nearest package.json" for a module entry: starting from the directory containing the entry itself and walking upward. It determines which manifest a relative-path entry lands on — which is precisely why the profile's own `package.json` becomes "the nearest one". Conversely, putting a local plugin in its own subdirectory with a `package.json` carrying `name` + `version` stops the resolver from landing on the profile manifest.— https://github.com/deepseek-ai/deepseek-harness/discussions/7678
Sources
- #6497 — Bug: dsh_plugin_packages — one unresolvable active package fails every deepseek-official request (REQUEST_EXTENSION)· deepseek-ai (GitHub Discussions)
- #7678 — initProfile()'s generated profile manifest lacks version, causing every request on the DeepSeek official route to fail (REQUEST_EXTENSION)· deepseek-ai (GitHub Discussions)