dsh-web-search-exa
已验证@tonydua/dsh-web-search-exa · v0.2.2 · MIT
Zero-config Exa web search provider for DeepSeek Harness (dsh): keyless anonymous MCP fallback (mcp.exa.ai/mcp) plus keyed REST search — a drop-in WebSearchProvider for the ctx.web seam, no API key required.
安装
dsh plugin add @tonydua/dsh-web-search-exa 用 dsh --profile default --dump-config 确认 layer 已生效 —— 参见安装指南。
源码
标签
作者
说明文档
@tonydua/dsh-web-search-exa
English | 简体中文
给 DeepSeek Harness(dsh)加上 Exa 网页搜索。
dsh plugin --profile web add @tonydua/dsh-web-search-exa
重启 dsh web 就能用。不用配 API key,不用改配置,不用选 provider。
背景,了解即可:
- Exa 是一个搜索 API。它按关键词或语义检索网页,返回可引用的来源和摘要,不生成答案。它提供 REST API,也运营一个免认证的公共 MCP 服务器。
- 官方的
dsh-web-search-exa是 dsh 的 Exa 搜索提供方。它走 Exa 的 REST API,必须配置 API key 才有用。 - 本包基于官方包改的。 REST 路径的实现与官方一致,补充了一条免 key 通道:没有 key 时改走 Exa 的公共 MCP 服务器,配了 key 仍走 REST。匿名接入方式参考了 oh-my-pi 项目,见致谢。
默认情况下不用管这几件事。只有同时用官方包,或 dsh 报错说 provider 有歧义时,才需要看选中提供方。
使用 deepseek-v4-flash 在 DeepSeek Harness(dsh)内开发。
特性
- 免 key 可用。搜索经由 Exa 的公共 MCP 服务器(
mcp.exa.ai/mcp),不携带任何凭据。 - 这条免 key 通道返回结构化结果。它调用
web_search_advanced_exa,输出是采用 REST 字段词表的 JSON,因此 source 直接带上真实的 highlight 摘要,无需文本解析。 - 配 key 自动升级。设置
EXA_API_KEY后自动切到 ExaPOST /searchREST API,额度更高,行为不变。 - 即插即用。注册进 dsh
ctx.webseam,模型侧的web_search和web_fetch工具、提示词区段、结果卡片都无需改动。 - 装上就能用。不装官方包时不需要选 provider,默认自动生效。
- 失败时能退让。匿名通道连续失败后,插件会把自己标记为不可用,让 dsh 有机会换别的 provider,而不是每次搜索都硬失败,详见搜索失败时会发生什么。
安装
三种方式选一种。方式只决定代码从哪来,装完都一样。
从 npm 安装。 dsh.bundle manifest 自带 bundle patch,会自动插入 provider 行,无需手动改 patch。
dsh plugin --profile web add @tonydua/dsh-web-search-exa
从 GitHub Release 安装。 同一份 tarball,npm 不可达时用。
dsh plugin --profile web add https://github.com/TonyDua/dsh-web-search-exa/releases/latest/download/dsh-web-search-exa.tgz
从仓库安装。 跟随 main,包含尚未发布的改动。
dsh plugin --profile web add github:TonyDua/dsh-web-search-exa
本地开发目录的装法相同,把包名换成路径即可:dsh plugin --profile web add ../plugins/dsh-web-search-exa。
装完重启 dsh web。多数情况下这就是全部步骤。
选中提供方
不装官方包时不用看这一节。
dsh 的 seam 每次搜索前会挑一个可用 provider。只有一个可用时自动选中,多于一个时抛 WEB_PROVIDER_AMBIGUOUS,要求你指定。所以只有在下面两种情况才需要动手:
- 同时装了官方包:两个包都注册 provider id
exa,dsh web启动就会报WEB_DUPLICATE_PROVIDER。必须先给本包改一个 id,见与官方包共存。 - 报
WEB_PROVIDER_AMBIGUOUS:说明有另一个可用 provider。指定一个即可。
指定方式二选一:
# $DSH_HOME/profiles/web/cordis.patch.yml
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: exa
或用环境变量 $DSH_WEB_SEARCH_PROVIDER=exa。
改完重启 dsh web。模型侧的 web_search 工具会自动走选中的 provider,不用改工具配置。
发布产物与安装告警(一般不用看)
发布产物。 CI 打包本版本的 tarball,在每一个受支持的 dsh 版本上验证,挂到 GitHub Release,并把这个产物本身发布到 npm。所以 Release 附件和 npm 上的 tarball 是同一个文件,而不是两次恰好一致的构建。
profile 安装告警。 dsh profile 默认 autoInstallPeers: false,而 harness 自身的服务由 dsh 宿主在运行时提供,不经 pnpm 解析。如果 dsh plugin add 报 peer 警告,把下面这段加进 profile 的 pnpm-workspace.yaml:
peerDependencyRules:
ignoreMissing:
- '@deepseek-ai/cordis'
- '@deepseek-ai/dsh-*'
配置
| 配置键 | 默认值 | 含义 |
|---|---|---|
apiKey |
未设置 | Exa API 密钥字面值。为空或缺失时启用匿名 MCP 路径。 |
apiKeyEnv |
EXA_API_KEY |
未设置字面 apiKey 时读取的环境变量名。 |
baseURL |
https://api.exa.ai |
Exa API 基础 URL。带 key 的 REST 路径会追加 /search,与官方 dsh 提供方一致。 |
apiURL |
未设置 | 已弃用的完整 REST 端点别名,设置后优先于 baseURL。 |
mcpURL |
https://mcp.exa.ai/mcp?tools=web_search_exa,web_search_advanced_exa |
Exa 托管 MCP 端点,匿名路径使用。默认值里的 tools 查询参数是必需的:结构化工具没有它就无法被调用。你自己的 URL 里不写这个参数,插件会补上。 |
mcpTool |
web_search_advanced_exa |
匿名路径调用哪个 MCP 工具:默认是返回结构化 JSON 的那个;填 web_search_exa 则回到旧的 Title: 分节文本。见工作原理。 |
searchType |
auto |
REST 检索模式:auto、keyword 或 neural。只在 REST 路径上读取。 |
numResults |
未设置 | 请求未携带 maxResults 时的默认结果数。 |
highlightsPerResult |
1 |
REST 路径每个结果请求的 highlight 句子数。 |
providerId |
exa |
注册进 ctx.web 的提供方 id。仅当本包与官方包同时安装时才需要改,见与官方包共存。 |
配置写在哪里:编辑 $DSH_HOME/profiles/web/cordis.patch.yml 里本插件的 config,然后重启 dsh web。也可以用环境变量 EXA_API_KEY 和 $DSH_WEB_SEARCH_PROVIDER。apiKey 标记了 role('secret'),任何 describe() 响应都不会暴露它的值。
在 Web 面板中的呈现
当前版本的配置入口在 profile 补丁层,不在 Web UI,没有可编辑的界面入口。Settings UI 只渲染客户端插件为固定命名空间(shell、agent-loop、web-search-deepseek)手工注册的卡片,对任意插件命名空间没有通用表单。当前实际情况:
- 插件清单(Settings → Plugins):启用后自动出现
web-search-exa条目。清单直接读取 Cordis loader 的实时条目,无需额外代码。 - 设置命名空间(服务端):插件通过
ctx.settings.installSectionAPI 注册了web-search-exa段,数据层可写。但没有任何客户端卡片绑定它,所以界面上不显示。内置的 Web search 卡片编辑的是官方web-search-deepseek命名空间,与本插件无关。 - 搜索结果卡片:
web_search调用经dsh-tool-web照常渲染web结果卡片(来源、摘要、日期),与提供方无关。匿名 Exa 的结果和 DeepSeek 搜索显示一致。
路线图:下一版本会新增注册到 settings.plugin.item slot 的客户端卡片,绑定 web-search-exa 命名空间,让上表所有字段可以在 Settings → Plugins 里实时编辑。
工作原理
| 条件 | 路径 | 端点 |
|---|---|---|
配置了 apiKey / EXA_API_KEY |
REST POST /search,Authorization: Bearer |
https://api.exa.ai/search(可用 baseURL 配置) |
| 未配置任何 key | 匿名 MCP tools/call web_search_advanced_exa(JSON-RPC 2.0,无凭据) |
https://mcp.exa.ai/mcp?tools=…(可配置) |
匿名 MCP 路径不发送任何凭据,来源标识通过 x-exa-source: dsh-anything 头携带。结果按 seam 的 WebSearchSource 形状规范化(url、title、snippet、publishedAt),maxResults 由 seam 在返回路径上强制执行。
匿名路径默认调用 web_search_advanced_exa。它的文本内容是一份结构化的 JSON 搜索结果,条目的字段名与 REST API 一致,因此可以直接映射成 source,全程不涉及 Title: 分节文本解析。有两点需要知道:
- 只有 URL 带上
?tools=…时,这个结构化工具才会被服务。 裸端点会返回MCP error -32602: Tool web_search_advanced_exa not found,这就是该查询参数写进mcpURL默认值的原因,也是插件会把它补进任何缺少该参数的mcpURL的原因。 - 它每条结果都返回整页正文,而 highlights 需要我们单独索取。 不传
enableHighlights时端点只返回纯文本条目,每条结果都拿不到摘要,搜索会整体返回空。正文随后被丢弃:摘要永远是真实的 highlight 句子,既不生成、也不从正文里截取。
这里不会转发 searchType。本插件的这个配置用的是 REST 词表(auto、keyword、neural),而该工具只接受自己的词表(auto、fast、instant);转发会让配置了 keyword 或 neural 的用户触发参数校验失败,把整条匿名路径一起打死。工具的默认行为本就等同于 auto。
把 mcpTool 固定为 web_search_exa 可以回到旧的文本块路径,同样结果以 Title: 开头的分节形式返回。若 Exa 改动了结构化工具的输出形状,可以这样切换;而返回体若不是预期的 JSON,插件本身就会自动回退到分节解析。
由于结构化工具每条结果都带整页正文,响应体可能很大。匿名响应上限为 256 KiB:会先检查声明的 content-length 再读取正文,正文若持续增长则在传输中途中断。超限响应按可重试错误失败,而不是被截断或静默解析。
限流
匿名通道是 Exa 提供的公共端点,有限流。触发时搜索会失败,错误码是 WEB_RATE_LIMITED,错误信息里写明要配 EXA_API_KEY。这个码是本插件定的,方便你和模型区分“被限流”和“网络坏了”。
配置 key 后走 REST 路径,不受这个限制。
搜索失败时会发生什么
你会看到什么:
- 匿名通道被限流:错误码
WEB_RATE_LIMITED,提示配置 key。 - 匿名通道连续失败 3 次:本插件会把自己标记为不可用,冷却 5 分钟。这期间
available()返回false。 - 冷却期内你写死了
searchProvider: exa:搜索报WEB_PROVIDER_CONFIGURED_UNAVAILABLE。 - 冷却期内你没写
searchProvider:seam 跳过本插件,去找别的 provider。没有别的可用 provider 时,报WEB_PROVIDER_UNAVAILABLE。 - 配了 key 走 REST 路径:不受上面任何一条影响,失败会照常抛给你。
为什么会这样。 seam 每次搜索前会调用 available() 决定用哪个 provider。如果本插件永远回答“可用”,端点挂掉时每次搜索都会硬失败,用户看到的是一个坏掉的 dsh。所以本插件加了一个熔断器:连续 3 次瞬时失败就承认自己暂时不可用,让 seam 有机会选别人。这是本插件的设计,Exa 没有这个机制。
计数规则。 只统计重试可能成功的失败:5xx、429、网络错误、响应体无法解析。满 3 次后冷却 5 分钟,任意一次成功搜索立即清零。
429 以外的 4xx 不计入。那是配置错误,重试多少次都一样,藏进冷却期只会把同一个错误推迟 5 分钟再报给你。
这是有代价的取舍。 Exa 挂掉的 5 分钟里,写死了 searchProvider: exa 的 profile 会直接报错,而不是继续尝试。插件无法替你选:
- 写死
searchProvider: exa:平时行为确定,但熔断打开时没有退路。 - 不写
searchProvider:熔断时能退到别的 provider,代价是多个 provider 同时可用时,seam 会报WEB_PROVIDER_AMBIGUOUS,需要你再显式指定一个。
想要回退能力就选后者,并且只装一个备选 provider。
与官方包比较
DeepSeek Harness 有一个官方 Exa 提供方 @deepseek-ai/dsh-web-search-exa,需要单独安装,dsh 默认不带。本包是它的零配置变体:补上了官方没有的匿名 MCP 兜底,同时保留配置 key 后的相同 REST 行为。
官方 @deepseek-ai/dsh-web-search-exa |
本包 @tonydua/dsh-web-search-exa |
|
|---|---|---|
REST 路径(POST /search) |
✅ 唯一路径 | ✅ 配置 key 时使用 |
| 必须有 API key | ✅ 是,key 为空则不可用 | ❌ 不需要,无 key 走匿名 MCP 兜底 |
匿名 MCP(mcp.exa.ai/mcp) |
❌ 未实现 | ✅ 无 key 时的默认路径 |
| 零配置安装 | ❌ | ✅ |
| Provider id | exa(固定) |
默认 exa,可用 providerId 配置 |
| Cordis 插件名 | web-search-exa |
web-search-exa |
| 配置键 | apiKey、baseURL、searchType、numResults、highlightsPerResult |
apiKey、apiKeyEnv、baseURL、apiURL(旧版)、mcpURL、mcpTool、searchType、numResults、highlightsPerResult、providerId |
该用哪个:
- 你有
EXA_API_KEY,且想用官方维护的包:用官方包,它是标准实现。 - 想零配置、免 key 试用 Exa 搜索:用本包。默认走匿名 MCP,出现 key 后自动走 REST。
- 两个都想要:一起装,用
providerId区分,见下节。
与官方包共存
两个包默认在 ctx.web 下注册相同的 provider id(exa),cordis 插件名也都是 web-search-exa。seam 会拒绝重复 id,报 WEB_DUPLICATE_PROVIDER。所以不改配置就把两个包装进同一个 profile,会在启动时报错。
共存必须显式配置,通过 providerId 开关完成:
- 官方包保持
exa,它的 id 固定。 - 给本包一个不同 id。在本插件的
config里设providerId: exa-anon,任意唯一字符串即可。 - 在
webseam 上显式选中一个。用searchProvider: exa-anon选匿名变体,或用searchProvider: exa选官方包。也可以用环境变量$DSH_WEB_SEARCH_PROVIDER。
- insert:
- id: web-search-exa
name: '@tonydua/dsh-web-search-exa'
config:
providerId: exa-anon
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: exa-anon
最简单的替代方案是每个 profile 只装其中一个包,默认配置即可直接用。
排查
dsh web 启动时报 duplicate loader entry id: web。 这是 0.1.2 的 bug,0.1.4 起已修复,升级本插件即可。如果已在 0.1.4 或更新版本上遇到,请带上 dsh --version 和你的 cordis.patch.yml 提 issue,因为用户补丁里插入 web 行也会产生同样的错误。
启动时报 Cannot read properties of undefined (reading 'prepare')。 @deepseek-ai/dsh-tools 是 dsh 的运行时单例包,一个 profile 中必须解析到同一份物理包实例。本插件不依赖它。常见原因是 profile 里其他第三方插件把它声明成了普通嵌套依赖,而不是 peer dependency。先修正那个插件的依赖声明,或让 profile 的包管理器统一解析到共享实例,再排查搜索错误。
搜索报 WEB_PROVIDER_AMBIGUOUS。 同时存在多个可用 provider。按选中提供方显式指定一个。
搜索报 WEB_PROVIDER_CONFIGURED_UNAVAILABLE。 你写死的 provider 当前不可用。免 key 通道熔断时会这样,见搜索失败时会发生什么。
Web UI 里找不到设置入口。 本版本没有 UI 卡片,用 cordis.patch.yml 或环境变量配置,见在 Web 面板中的呈现。
版本兼容性
0.1.2-alpha.2 到 0.2.1-alpha.1 之间每一个已发布的 dsh 版本都实测过。实测包含三件事:独立安装该版本、用该版本自己的类型声明做类型检查、用 npm 严格安装一次本插件。最后一步最容易失败,因为 npm 的 peer 规则比 pnpm 严。复现命令:bash scripts/compat-matrix.sh。
| dsh 版本线 | 实测 | 说明 |
|---|---|---|
0.1.2-alpha.2 … 0.1.2-alpha.5 |
✅ | 最老的受支持基线 |
0.1.2-rc.1 |
✅ | |
0.1.3-alpha.2 |
✅ | |
0.1.5-alpha.1、0.1.5-alpha.2 |
✅ | |
0.1.5-rc.1、0.1.5-rc.2、0.1.5-rc.3 |
✅ | 0.1.5-rc.2 另有端到端验证:无 API key 时用真实的 dsh --profile headless 走通匿名 MCP |
0.1.6-alpha.1、0.1.6-alpha.2 |
✅ | |
0.1.7-alpha.1 |
✅ | settings 服务换了形态,见下 |
0.2.0-rc.1、0.2.0-rc.2 |
✅ | 在宿主旁做 npm 严格安装,再跑一次真实的免 key 搜索 |
0.2.1-alpha.1 |
✅ | 同上;也是唯一需要 @deepseek-ai/[email protected] 的版本 |
从 0.2.0-rc.1 这条线起,各版本还在 package.json 的 dsh.compatibility.dshReleases 里逐个完整版本号声明。这个键是目录元数据:dsh 运行期从不读取它,也不影响依赖解析——DSH STORE 这类目录要求逐版本精确记录,光有 peer 范围不构成可安装证据。目前声明覆盖 0.2.0-rc.1、0.2.0-rc.2、0.2.1-alpha.1,均为 compatible。
各版本之间差在哪
我逐个探测了各版本的真实导出面。结论是 ctx.web seam 完全稳定:WebError 始终由 dsh-web 导出且继承 HarnessError,launchEnvironmentOf 始终存在,ctx.settings 在每个版本都被挂载。真正有差异的只有两处。
其一,0.1.7-alpha.1 换掉了 settings API。SettingsProvider.installSection 被移除,服务变成 SettingsForms,它直接从 Loader 已持有的 Config schema 派生配置页(SettingsDescriptor.schema、autoGenerate)。旧代码无条件调用该方法,会在这个版本上抛 TypeError:插件能加载,但会失败。现在改为先探测方法,存在才调用,不存在则什么都不做。在 0.1.7+ 上由 Loader 的 schema 驱动表单,插件无需注册任何东西。
其二,@deepseek-ai/cordis 跟着 dsh 走,而且是一路穿过 prerelease 走的:0.1.5/0.1.6 是 ^4.0.2(0.1.5-rc.3 为精确 4.0.2),0.1.7 是 ^4.0.3,0.2.0 是 ~4.0.4,0.2.1-alpha.1 是 ~4.0.5-alpha.1。宿主要求哪个就装哪个,但钉法要当心:^4.0.2 会解析到 4.0.4,而 0.1.5 那几个版本并非针对它发布的,本插件随后就无法严格安装在它们旁边。矩阵脚本读取该范围后,把它的下界作为具体版本钉住。本插件自己的 peer 范围也需要补上 >=4.0.5-alpha.1 比较器,否则 0.2.1-alpha.1 根本装不上。
同样支持 @deepseek-ai/dsh-web、dsh-settings(可选)和 dsh-launch-environment,覆盖上述整个范围。Node.js 需要 >=22.19.0,与 harness 自身的下限一致。
为什么 peer 范围长这样
"@deepseek-ai/dsh-web": ">=0.1.2-alpha.2 || >=0.1.3-alpha.2 || >=0.1.4-0 || >=0.1.5-alpha.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.1.8 || >=0.2.0-rc.1 || >=0.2.1-alpha.1"
这串枚举是在 pnpm 和 npm 下都能装遍所有已发布版本的唯一写法。原因是 semver 的一条规则:
prerelease 版本要满足某个范围,该范围中必须有一个比较器,它的 prerelease 落在相同的
major.minor.patch三段上。
所以 >=0.1.2-rc.1 匹配不到 0.1.5-rc.2,两者三段不同。单一开区间下界覆盖不了“以一串 prerelease 发布的项目”,而 * 会连未来的破坏性 1.0 一起放行。
这个写法有两点很容易搞错,而这两点我都踩过:
- 这里的
||不是拓宽范围,而是在挑下界。 每个比较器都是开区间的>=,所以整个表达式等于“大于等于各自 X”的并集——也就是“大于等于最大的那个 X”。为更低版本追加比较器是白费:在一个以>=0.1.8结尾的范围后再接|| >=0.2.0-rc.1,会把0.1.8以及它到0.2.0-rc.1之间的所有版本一起踢出去,因为按上面那条三段规则,此时已经没有 0.1.8 的比较器可匹配。这一点是拿范围去跑真实发布列表才发现的,读是读不出来的。 - 每条 prerelease 线都需要落在自己三段上的比较器。
0.2.0-rc.2满足>=0.2.0-rc.1,但不满足>=0.2.1-alpha.1,所以0.2.1-alpha.1需要第二个条目。同一条规则也适用于@deepseek-ai/cordis——它自己的线走到了4.0.5-alpha.1,而>=4.0.2把它排除在外,所以实际发布的范围是>=4.0.2 || >=4.0.5-alpha.1。这一条不是好看不好看的问题:没有它,在0.2.1-alpha.1宿主旁用npm install装本插件会直接ERESOLVE失败。
在真实发布物上实测的结果:
| 范围 | npm 可安装 |
|---|---|
>=0.1.2-rc.1(最早的写法) |
它想覆盖的 14 个版本里只有 1 个 |
以 0.1.8 结尾的枚举 |
14 / 14 |
| 当前枚举 | 从 0.1.2-alpha.2 到 0.2.1-alpha.1 全部已发布版本 20 / 20 |
这个结论是测出来的。开区间在 pnpm 下没问题,而 dsh plugin add 用的正是 pnpm。但在 npm 下,它会让最初那 14 个版本中的 13 个报 ERESOLVE。如果你用 npm 安装旧版本的本插件时遇到该错误,升级即可,或临时加 --legacy-peer-deps。
能解析不等于被测过:上面的表格是 17 行,而这串枚举能在 20 个已发布版本上解析成功。差值就是 0.1.7-alpha.2、0.1.7-rc.1、0.1.7-rc.2——它们能装、也预期可用,但实际跑过的只有 0.1.7-alpha.1。
从源码构建
pnpm install
pnpm run build # tsdown -> lib/index.js + lib/index.d.ts
pnpm run typecheck # tsc --noEmit
pnpm test # 先构建,再对 lib/ 跑 node:test 套件
src/ 是唯一的源文件目录。lib/ 仍然提交进仓库,因为 npm 发布包和基于 git 的安装都依赖它。
致谢
匿名 MCP 接入方式参考了 can1357/oh-my-pi 的 web_search 实现(packages/coding-agent/src/web/search/providers/exa.ts 和 src/exa/mcp-client.ts)以及 @oh-my-pi/exa 插件:同样的“有 key 走 REST、无 key 走免凭据 mcp.exa.ai/mcp”策略、同样的 x-exa-source 来源头、同样的 Title: 分节响应解析。感谢 oh-my-pi(omp)项目最先做出零配置的 Exa 接入。
同时感谢 Exa 提供并运营这个免费、免认证的托管 MCP 服务器(mcp.exa.ai/mcp),正是它让本包的零配置默认路径成为可能。Exa 托管 MCP 是 Exa 的官方产品,匿名使用有限流,见限流。
感谢 @kahlos(PR #1):他发现 web_search_advanced_exa 返回的是结构化结果,并且端点在没有 ?tools= 查询参数时根本不会服务这个工具。这两点都没有文档记载,只能靠实测得出。匿名路径现在默认走这个工具,旧的 Title: 分节路径保留为回退。
更新日志
所有变更见 CHANGELOG.md。
许可证
MIT,见 LICENSE。