From 902926c7bb357cecbd483d26789fec76f04aa701 Mon Sep 17 00:00:00 2001 From: kingwl Date: Fri, 31 Jul 2026 12:58:06 +0800 Subject: [PATCH] feat(web): enable default search and fetch --- .../2026-07-31-web-default-search.i18n.yaml | 6 + .../feature/2026-07-31-web-default-search.md | 35 ++++ .../2026-07-31-web-default-search.zh.md | 35 ++++ apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 + apps/cli/README.zh.md | 2 + apps/cli/config/web.cordis.yml | 24 +++ apps/cli/package.json | 4 + apps/web/tests/scaffold.ts | 20 ++ apps/web/tests/smoke-real.e2e.ts | 18 +- .../snapshots/web-search-round/session.jsonl | 12 ++ .../snapshots/web-search-round/ui.expected.md | 33 +++ apps/web/tests/web-search-round.e2e.ts | 195 ++++++++++++++++++ apps/web/tsconfig.json | 1 + docs/config-catalog.md | 6 +- docs/module-graph.md | 3 +- .../web/web-search-deepseek/README.i18n.yaml | 4 +- packages/web/web-search-deepseek/README.md | 12 +- packages/web/web-search-deepseek/README.zh.md | 12 +- packages/web/web-search-deepseek/package.json | 3 + packages/web/web-search-deepseek/src/index.ts | 23 ++- .../web/web-search-deepseek/src/provider.ts | 41 +++- .../tests/deepseek.spec.ts | 49 ++++- .../web/web-search-deepseek/tsconfig.json | 3 + pnpm-lock.yaml | 18 ++ tsconfig.host.json | 1 + 26 files changed, 534 insertions(+), 32 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-31-web-default-search.md create mode 100644 .agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md create mode 100644 apps/web/tests/snapshots/web-search-round/session.jsonl create mode 100644 apps/web/tests/snapshots/web-search-round/ui.expected.md create mode 100644 apps/web/tests/web-search-round.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml new file mode 100644 index 0000000000..db559b6ec5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-default-search.md +2026-07-31-web-default-search.md: f50180a5509e5f3f26500b2e60e7bc3db0aef20b +2026-07-31-web-default-search.zh.md: a93c4fa46d3f1b566217a19cc954c364d721d4aa diff --git a/.agents/notes/implemented/feature/2026-07-31-web-default-search.md b/.agents/notes/implemented/feature/2026-07-31-web-default-search.md new file mode 100644 index 0000000000..f50180a550 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-web-default-search.md @@ -0,0 +1,35 @@ +# Agent Note: Default Web search and fetch in the Web/headless composition + +Status: implemented + +English | [中文](2026-07-31-web-default-search.zh.md) + +## Problem + +The harness had a complete Web capability family—provider registry, DeepSeek/Exa/Perplexity search providers, local fetch, stable model tools, and structured result presentation—but the shipped `dsh web` composition mounted none of it. The model could not discover current information or follow a source URL unless a deployment supplied a custom overlay. Merely mounting the existing DeepSeek provider would not complete the WebUI path: the Models page stores `DEEPSEEK_API_KEY` through `ctx.credentials`, while the search provider froze only the process environment at plugin load, so a key entered or rotated in the running UI would not reach search. + +## Decision + +`apps/cli/config/web.cordis.yml` explicitly mounts `dsh-web` with `searchProvider: deepseek-official` and `fetchProvider: local-http`, `dsh-web-search-deepseek`, `dsh-web-fetch-local`, and `dsh-tool-web`. The shared overlay makes `web_search` and `web_fetch` defaults for both browser and headless sessions; the TUI composition remains unchanged. Explicit provider ids keep selection independent of registration order and leave personal or `--config` overlays able to replace or disable the rows. + +DeepSeek search uses the same `DEEPSEEK_API_KEY` credential reference as the official conversation adapter. The provider resolves that reference inside every search through the optional `ctx.credentials` service; only a composition without the seam falls back to the launching process environment, and a non-empty literal `apiKey` remains the programmatic last resort. A stored or rotated Web Models key therefore reaches the next search without restarting or retaining the value on the provider. Because `WebSearchProvider.available()` is synchronous, it treats an installed resolver as locally usable and missing dynamic credentials fail the operation with the provider-specific `WEB_PROVIDER_CREDENTIAL_MISSING` code while the stable tool schema stays registered. + +Search keeps its endpoint distinct from chat completions: `DEEPSEEK_SEARCH_BASE_URL` overrides the Anthropic-compatible base, while `DEEPSEEK_BASE_URL` continues to configure conversation requests. Each `web_search` performs an auxiliary DeepSeek Messages call with the native search server tool. `web_fetch` uses the existing anonymous local HTTP(S) provider so a search result can be retrieved without another vendor account. + +The default mount does not create a Web-specific permission policy. These tools execute outside the bash/filesystem sandbox and approval presets, following `dsh-tool-web`'s existing contract. The shipped deployment already defaults to `danger-full-access`; a future restricted-network product stance must add a `tools/pre-execute` policy or capability-specific network confinement rather than implying that filesystem access mode governs Web calls. + +## Alternatives considered + +**Mount only `dsh-tool-web`.** Rejected because stable schemas without registered providers would make every default call fail; enablement and backend availability are deliberately separate, but a shipped default must supply its intended implementations. + +**Read `$DSH_HOME/.env` from `cordis.yml` or hoist it into `process.env`.** Rejected because the credential provider owns that document, environment values are read-only overrides, and hoisting would make stored keys unrotatable while bypassing the audited secret boundary. + +**Freeze `process.env.DEEPSEEK_API_KEY` at provider load.** Rejected because the Web Models page writes through `ctx.credentials`; the product's documented first-run path must make the next operation work without a restart. + +**Mount Web tools in `base.cordis.yml`.** Rejected because that would also change the TUI deployment. The browser and headless entries already share `web.cordis.yml`; they gain the capability together while TUI remains an explicit later decision. + +**Enable search without fetch.** Rejected because search snippets are discovery context, not page bodies, and the stable search guidance directs the model to fetch a relevant result before relying on its full content. + +## Consequences + +Web/headless model requests carry the `web_search` and `web_fetch` schemas plus their fixed prompt guidance in native mode; Code Mode exposes the same capabilities beneath `run_code`. Search adds a complete auxiliary model call and may use the native server tool multiple times, while fetch adds anonymous outbound HTTP(S) access subject to the local provider's redirect, size, timeout, and content-type rules. The Web snapshot lane boots the shipped tree, drives a replayed `web_search` call through the real DeepSeek provider against a local Messages fixture, asserts the durable structured result, and pins the settled browser presentation. Provider tests pin missing, stored, and rotated credential behavior plus literal and ambient compatibility. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md new file mode 100644 index 0000000000..a93c4fa46d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Web/无头组合中的默认 Web 搜索与抓取 + +Status: implemented + +[English](2026-07-31-web-default-search.md) | 中文 + +## 问题 + +该 harness 已具备完整的 Web 能力体系:提供方注册表、DeepSeek、Exa 和 Perplexity 搜索提供方、本地抓取、稳定的面向模型工具,以及结构化结果呈现,但已交付的 `dsh web` 组合没有挂载其中任何一项。除非部署提供自定义覆盖层,否则模型无法发现最新信息,也无法沿来源 URL 读取内容。仅挂载现有 DeepSeek 提供方仍无法打通 WebUI 链路:Models 页面通过 `ctx.credentials` 存储 `DEEPSEEK_API_KEY`,而搜索提供方只会在插件加载时固定读取进程环境,因此在运行中的 UI 输入或轮换的密钥无法用于搜索。 + +## 决策 + +`apps/cli/config/web.cordis.yml` 明确挂载 `dsh-web`,并配置 `searchProvider: deepseek-official` 与 `fetchProvider: local-http`,同时挂载 `dsh-web-search-deepseek`、`dsh-web-fetch-local` 和 `dsh-tool-web`。共享覆盖层使 `web_search` 和 `web_fetch` 成为浏览器与无头会话的默认工具;TUI 组合保持不变。显式提供方 id 使选择不受注册顺序影响,同时个人覆盖层或 `--config` 覆盖层仍可替换或禁用这些配置项。 + +DeepSeek 搜索使用与官方会话适配器相同的 `DEEPSEEK_API_KEY` 凭据引用。提供方在每次搜索内部通过可选的 `ctx.credentials` 服务解析该引用;只有未挂载该 seam 的组合才会回退到启动进程的环境变量,非空的 `apiKey` 字面值仍作为程序化配置的最后兜底。因此,由 Web 的 Models 页存储或轮换的密钥无需重启即可用于下一次搜索,提供方也无需保留该值。由于 `WebSearchProvider.available()` 是同步方法,它会将已安装解析器视为本地可用;若动态凭据缺失,操作会以提供方专属错误码 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败,而稳定的工具 schema 仍保持注册。 + +搜索端点与 chat completions 保持独立:`DEEPSEEK_SEARCH_BASE_URL` 覆盖 Anthropic 兼容基址,`DEEPSEEK_BASE_URL` 则继续配置会话请求。每次 `web_search` 都会发起一次辅助 DeepSeek Messages 调用,并携带原生搜索服务器工具。`web_fetch` 使用现有的匿名本地 HTTP(S) 提供方,因此无需其他厂商账号即可获取搜索结果。 + +默认挂载不会创建 Web 专用权限策略。这些工具在 bash/文件系统沙箱及审批预设之外执行,并遵循 `dsh-tool-web` 的现有契约。已交付部署的默认值本就是 `danger-full-access`;未来如果产品采取受限网络策略,必须添加 `tools/pre-execute` 策略或按能力限制网络访问,而不能暗示文件系统访问模式会管辖 Web 调用。 + +## 考虑过的替代方案 + +**仅挂载 `dsh-tool-web`。** 不予采纳:稳定的 schema 如果没有已注册提供方,每次默认调用都会失败。启用状态与后端可用性刻意分离,但已交付的默认配置必须提供其预期实现。 + +**从 `cordis.yml` 读取 `$DSH_HOME/.env`,或将其提升到 `process.env`。** 不予采纳:凭据提供方拥有该文件,环境变量值是只读覆盖;提升后存储的密钥将无法轮换,还会绕过经审计的密钥边界。 + +**在提供方加载时固定读取 `process.env.DEEPSEEK_API_KEY`。** 不予采纳:Web Models 页面通过 `ctx.credentials` 写入密钥;产品文档规定的首次运行路径必须保证下一次操作无需重启即可生效。 + +**在 `base.cordis.yml` 中挂载 Web 工具。** 不予采纳:这也会改变 TUI 部署。浏览器与无头入口已经共享 `web.cordis.yml`;两者会一同获得该能力,是否为 TUI 启用则仍留作后续显式决策。 + +**启用搜索但不启用抓取。** 不予采纳:搜索 snippet 是用于发现内容的上下文,而不是页面正文;稳定的搜索指引会要求模型先抓取相关结果,再依赖其完整内容。 + +## 后果 + +Web/无头模型请求在原生模式下会携带 `web_search` 和 `web_fetch` schema 及其固定提示词指引;Code Mode 通过 `run_code` 公开相同能力。搜索会增加一次完整的辅助模型调用,并可能多次使用原生服务器工具;抓取会增加匿名出站 HTTP(S) 访问,并受本地提供方的重定向、大小、超时和内容类型规则约束。Web 快照通道会启动已交付配置树,使用本地 Messages fixture(测试前置数据),经由真实 DeepSeek 提供方驱动一次回放的 `web_search` 调用,断言持久化的结构化结果,并固定最终浏览器呈现。提供方测试固定缺失、已存储及已轮换凭据的行为,以及字面值与环境变量的兼容性。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index b2fe93b91a..9efd533d55 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: c36a75fc61fd7118f48c9b68be3144177df19534 -README.zh.md: e926fa99c4e483351f52ca4e76b668e26b34d02f +README.md: 4437a9f980c689c1b11df09f00b3681a3d7af459 +README.zh.md: 46c447443b2d47ded685ebce7578aa712c43cf11 diff --git a/apps/cli/README.md b/apps/cli/README.md index c36a75fc61..4437a9f980 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -21,6 +21,8 @@ The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment. +The Web/headless composition also registers `web_search` and `web_fetch`. Search uses DeepSeek's Anthropic-compatible Messages endpoint, resolves the same `DEEPSEEK_API_KEY` reference for every call, and accepts the separate `DEEPSEEK_SEARCH_BASE_URL` endpoint override; each search is an auxiliary model request with its own latency and token cost. Fetch retrieves public HTTP(S) pages through the local provider so the model can follow a search result to its full content. The TUI composition does not mount these Web tools by default. The deployment decision and its security boundary live in the [default Web search Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md). + `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). Every `dsh` surface — TUI, Web, and headless — reports session telemetry by default (the row lives in the shared `base.cordis.yml`): every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index e926fa99c4..46c447443b 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -21,6 +21,8 @@ Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。 +Web/无头组合还会注册 `web_search` 和 `web_fetch`。搜索使用 DeepSeek 的 Anthropic 兼容 Messages 端点,每次调用都会解析同一个 `DEEPSEEK_API_KEY` 凭据引用,并接受独立的 `DEEPSEEK_SEARCH_BASE_URL` 端点覆盖;每次搜索都是一次辅助模型请求,会产生独立的延迟与 token 成本。抓取通过本地提供方获取公开 HTTP(S) 页面,使模型可以沿搜索结果读取完整内容。TUI 组合默认不挂载这些 Web 工具。部署决策及其安全边界见[默认 Web 搜索 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md)。 + `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 每个 `dsh` 界面——TUI、Web 与无头——都默认上报会话遥测(该行位于共享的 `base.cordis.yml`):每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs`。`DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector;将 `DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0` 或 `false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)。 diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml index a67d95ff6b..ef82780aa9 100644 --- a/apps/cli/config/web.cordis.yml +++ b/apps/cli/config/web.cordis.yml @@ -80,6 +80,30 @@ - id: fs-local disabled: true +# The Web/headless product enables the stable web_search/web_fetch model +# surface explicitly. DeepSeek search resolves the same DEEPSEEK_API_KEY +# credential the Models page manages for chat, at each search; its Messages +# endpoint is separate from the chat-completions endpoint. The local fetch +# provider supplies full-page follow-up for search results. +- insert: + - id: web + name: '@deepseek-ai/dsh-web' + config: + searchProvider: deepseek-official + fetchProvider: local-http + + - id: web-search-deepseek + name: '@deepseek-ai/dsh-web-search-deepseek' + config: + apiKeyEnv: DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL + + - id: web-fetch-local + name: '@deepseek-ai/dsh-web-fetch-local' + + - id: tool-web + name: '@deepseek-ai/dsh-tool-web' + # ── web-only host rows, the transport layer, and the browser roster ───────── # `dshClient` rows are the browser roster the modules node half scans into diff --git a/apps/cli/package.json b/apps/cli/package.json index 787682ce04..47b9c982ea 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -112,11 +112,15 @@ "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", + "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tui": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", + "@deepseek-ai/dsh-web": "workspace:^", + "@deepseek-ai/dsh-web-fetch-local": "workspace:^", + "@deepseek-ai/dsh-web-search-deepseek": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index ef60146d7d..fac21510be 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -135,6 +135,17 @@ export interface LaunchOptions { * keyless first-run configuration lane; the default disables the adapter. */ deepSeekMissingCredential?: boolean + /** + * Patch the shipped DeepSeek search row to a deterministic endpoint and + * credential reference. Browser search scenarios keep the real provider and + * credentials seam while avoiding external search traffic and ambient keys. + */ + deepSeekSearch?: { + /** Anthropic-compatible base URL; the provider appends `/messages`. */ + baseURL: string + /** Credential reference resolved by the shipped search provider. */ + apiKeyEnv: string + } } /** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */ @@ -241,6 +252,15 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { mkdirSync(join(workspace, '.git')) writeFileSync(join(workspace, 'AGENTS.md'), 'web-workspace-context-probe\n') - let resolveProviderRequest!: (request: { messages?: { role?: string; content?: string }[] }) => void - const providerRequest = new Promise<{ messages?: { role?: string; content?: string }[] }>((resolve) => { + interface NativeProviderRequest { + messages?: { role?: string; content?: string }[] + tools?: { function?: { name?: string } }[] + } + let resolveProviderRequest!: (request: NativeProviderRequest) => void + const providerRequest = new Promise((resolve) => { resolveProviderRequest = resolve }) const provider = createServer((request, response) => { @@ -197,7 +201,7 @@ describe('dsh web keyless CLI smoke', () => { request.setEncoding('utf8') request.on('data', (chunk: string) => { body += chunk }) request.on('end', () => { - resolveProviderRequest(JSON.parse(body) as { messages?: { role?: string; content?: string }[] }) + resolveProviderRequest(JSON.parse(body) as NativeProviderRequest) response.writeHead(200, { 'content-type': 'text/event-stream' }) response.end([ 'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}', @@ -256,6 +260,14 @@ describe('dsh web keyless CLI smoke', () => { "role": "user", } `) + expect(captured.tools?.map(tool => tool.function?.name) + .filter(name => name === 'web_search' || name === 'web_fetch')) + .toMatchInlineSnapshot(` + [ + "web_fetch", + "web_search", + ] + `) } finally { const closed = child.exitCode === null ? new Promise((resolveClose) => { child.once('close', () => { resolveClose() }) }) diff --git a/apps/web/tests/snapshots/web-search-round/session.jsonl b/apps/web/tests/snapshots/web-search-round/session.jsonl new file mode 100644 index 0000000000..bb0a15fd0b --- /dev/null +++ b/apps/web/tests/snapshots/web-search-round/session.jsonl @@ -0,0 +1,12 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785456000000,"cwd":"{{cwd}}"} +{"type":"user/message","seq":0,"time":1785456000001,"data":{"content":[{"type":"text","text":"Use web_search to search exactly \"DeepSeek Harness snapshot search\". Then reply exactly SEARCH_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":1,"time":1785456000002,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":2,"time":1785456000003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_web_search","name":"web_search","argumentsDelta":"{\"query\":\"DeepSeek Harness snapshot search\"}"}}} +{"type":"assistant/chunk","seq":3,"time":1785456000004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_web_search","name":"web_search","arguments":"{\"query\":\"DeepSeek Harness snapshot search\"}"}}}} +{"type":"assistant/chunk","seq":4,"time":1785456000005,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":5,"time":1785456000006,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/chunk","seq":6,"time":1785456000007,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":7,"time":1785456000008,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"SEARCH_DONE"}}} +{"type":"assistant/chunk","seq":8,"time":1785456000009,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SEARCH_DONE"}}}} +{"type":"assistant/chunk","seq":9,"time":1785456000010,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":10,"time":1785456000011,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md new file mode 100644 index 0000000000..a0e4dbdea0 --- /dev/null +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -0,0 +1,33 @@ +- banner: + - navigation "Session hierarchy": + - button "Use web_search to search exactly" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Use web_search to search exactly "DeepSeek Harness snapshot search". Then reply exactly SEARCH_DONE and stop. {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- button "Edit": + - img +- button "Search DeepSeek Harness snapshot search": + - img + - img + - text: Search DeepSeek Harness snapshot search +- paragraph: SEARCH_DONE +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} +- textbox "Message the agent" +- button "Add attachment": + - img +- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- button "Plan mode off, press to turn on": Plan off +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Send message" [disabled] +- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 0% Input 22 tok · Output 7 tok diff --git a/apps/web/tests/web-search-round.e2e.ts b/apps/web/tests/web-search-round.e2e.ts new file mode 100644 index 0000000000..efa0955cc8 --- /dev/null +++ b/apps/web/tests/web-search-round.e2e.ts @@ -0,0 +1,195 @@ +// Web e2e scenario for the shipped default search composition. A real browser +// drives `web_search`; the model stream is replayed while the real DeepSeek +// provider calls a deterministic local Anthropic-compatible endpoint through +// the real credentials service. +import { readFile } from 'node:fs/promises' +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/web-search-round', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('./snapshots/web-search-round/session.jsonl', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/web-search-round/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const QUERY = 'DeepSeek Harness snapshot search' +const PROMPT = `Use web_search to search exactly "${QUERY}". Then reply exactly SEARCH_DONE and stop.` +const SEARCH_CREDENTIAL_REF = credentialRef('DSH_WEB_SEARCH_E2E_KEY') +const SEARCH_CREDENTIAL = 'snapshot-search-key' +const RESULT_URL = 'https://docs.example.test/search' + +interface CapturedSearchRequest { + path: string + apiKey: string | undefined + body: unknown +} + +/** Start the deterministic DeepSeek Messages double used by the real provider. */ +async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ server: Server; baseURL: string }> { + const server = createServer((request, response) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + captured.push({ + path: request.url ?? '', + apiKey: typeof request.headers['x-api-key'] === 'string' ? request.headers['x-api-key'] : undefined, + body: JSON.parse(body) as unknown, + }) + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ + content: [ + { + type: 'text', + text: 'Found one source.', + citations: [{ + type: 'web_search_result_location', + url: RESULT_URL, + cited_text: 'Snapshot search excerpt.', + }], + }, + { + type: 'web_search_tool_result', + content: [{ + type: 'web_search_result', + url: RESULT_URL, + title: 'Snapshot Search Result', + page_age: '2026-07-31', + }], + }, + ], + })) + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() as AddressInfo + return { server, baseURL: `http://127.0.0.1:${address.port}` } +} + +describe('web e2e: shipped default web search', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let searchServer: Server + let tripwire: ReturnType + const searchRequests: CapturedSearchRequest[] = [] + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + const search = await startSearchServer(searchRequests) + searchServer = search.server + scaffold = await launchWebScaffold({ + deepSeekSearch: { + baseURL: search.baseURL, + apiKeyEnv: SEARCH_CREDENTIAL_REF, + }, + ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }), + }) + await scaffold.ctx.credentials.set(SEARCH_CREDENTIAL_REF, SEARCH_CREDENTIAL) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + await new Promise((resolve, reject) => { + if (searchServer === undefined) { + resolve() + return + } + searchServer.close((error) => { + if (error === undefined) resolve() + else reject(error) + }) + }) + }) + + it('drives the recorded search to a settled turn (all modes)', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-search-drive')) + if (MODE !== 'record') { + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + } + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + const settled = scaffold.whenTurnSettled() + await input.fill(PROMPT) + await input.press('Enter') + const sessionId = await settled + if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE) + }, 200_000) + + it.skipIf(MODE === 'record')('uses the real provider and persists the structured result', () => { + expect(searchRequests).toHaveLength(1) + expect(searchRequests[0]).toMatchObject({ + path: '/messages', + apiKey: SEARCH_CREDENTIAL, + body: { + messages: [{ + role: 'user', + content: [{ type: 'text', text: `Perform a web search for the query: ${QUERY}` }], + }], + tools: [{ type: 'web_search_20250305', name: 'web_search' }], + }, + }) + + const searchCall = sessionEvents.find( + (event): event is Extract => + event.type === 'tool/call' && event.data.name === 'web_search', + ) + if (searchCall === undefined) throw new Error('the replayed turn did not call web_search') + const searchResult = sessionEvents.find( + (event): event is Extract => + event.type === 'tool/result' && event.data.message.source.callId === searchCall.data.callId, + ) + if (searchResult === undefined) throw new Error('web_search produced no durable result') + const content = searchResult.data.message.content[0] + expect(content.isError).toBe(false) + expect(content.content.filter(block => block.type === 'text').map(block => block.text).join('')) + .toContain(`[Snapshot Search Result](${RESULT_URL})`) + expect(searchResult.data.meta).toMatchObject({ + sources: [{ + url: RESULT_URL, + title: 'Snapshot Search Result', + snippet: 'Snapshot search excerpt.', + publishedAt: '2026-07-31', + }], + truncated: false, + }) + }) + + it.skipIf(MODE === 'record')('matches the settled search card aria golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-search-aria')) + await expect.poll(() => page.getByText('SEARCH_DONE', { exact: true }).count(), { timeout: 15_000 }) + .toBeGreaterThanOrEqual(1) + await page.locator('[data-tool="web_search"]').waitFor({ timeout: 10_000 }) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + }) + + it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md']) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index d7e48e4e6b..ed403e3076 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -41,6 +41,7 @@ "tests/sidebar-scrollbar.e2e.ts", "tests/code-mode-round.e2e.ts", "tests/cordis-tool-round.e2e.ts", + "tests/web-search-round.e2e.ts", "tests/message-actions.e2e.ts", "tests/queue-actions.e2e.ts", "tests/skill-invocation-policy.e2e.ts", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3fb1e027ff..a01d4bdade 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2157,8 +2157,10 @@ Requires: `web` ```ts config-catalog /** Plugin config (all optional — `apply` fills env-var and constant defaults). */ export interface Config { - /** DeepSeek API key. Falls back to `$DEEPSEEK_API_KEY`. Empty → unavailable. */ + /** Literal DeepSeek API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ apiKey?: string + /** Credential reference resolved for each search; defaults to `DEEPSEEK_API_KEY`. */ + apiKeyEnv?: string /** Anthropic-compatible endpoint base; `/messages` is appended. */ baseURL?: string /** Anthropic-format model name. Defaults to `deepseek-v4-flash`. */ @@ -2172,7 +2174,7 @@ export interface Config { } ``` -Source: [`packages/web/web-search-deepseek/src/index.ts:38`](../packages/web/web-search-deepseek/src/index.ts) +Source: [`packages/web/web-search-deepseek/src/index.ts:41`](../packages/web/web-search-deepseek/src/index.ts) ## `@deepseek-ai/dsh-web-search-exa` diff --git a/docs/module-graph.md b/docs/module-graph.md index 45eaddf9e9..bdc1d67551 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -429,6 +429,7 @@ flowchart TD pkg_web_fetch_local --> pkg_invariants pkg_web_fetch_local --> pkg_timeout pkg_web_fetch_local --> pkg_web + pkg_web_search_deepseek --> pkg_credentials pkg_web_search_deepseek --> pkg_invariants pkg_web_search_deepseek --> pkg_web pkg_web_search_exa --> pkg_invariants @@ -1095,7 +1096,7 @@ flowchart TD | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | diff --git a/packages/web/web-search-deepseek/README.i18n.yaml b/packages/web/web-search-deepseek/README.i18n.yaml index 83129cba21..f00ad2bbed 100644 --- a/packages/web/web-search-deepseek/README.i18n.yaml +++ b/packages/web/web-search-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/web-search-deepseek/README.md -README.md: 54eb7561b9d81a9e2da565e3870abe094dbe984d -README.zh.md: 8862b8a9c1ba69d247942bb6e41289214826c682 +README.md: a61b28d0c78b48a173f76fe7601a53a2eefef7f7 +README.zh.md: 1b403ecaaaa6e76a3c6be17c6a8dea27bd2c522b diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md index 54eb7561b9..a61b28d0c7 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) A [DeepSeek](https://deepseek.com)-backed `WebSearchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It calls DeepSeek's **Anthropic-compatible Messages API** (`POST {baseURL}/messages`) with the native `web_search_20250305` server tool enabled, and maps the structured `web_search_tool_result` blocks DeepSeek returns into the seam's normalized `WebSearchResult`. -This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the key and it does not register a model-facing tool. Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`). The Anthropic wire shape is a provider-private detail — it does **not** make this provider depend on `ctx.llm`. +This is an **implementation** package: it registers a provider into `ctx.web`, resolves its credential for each search through the optional `ctx.credentials` seam, and does not register a model-facing tool. Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`). The Anthropic wire shape is a provider-private detail — it does **not** make this provider depend on `ctx.llm`. ## How it differs from a dedicated search endpoint @@ -12,13 +12,14 @@ Exa and Perplexity expose dedicated search endpoints; DeepSeek does not. Instead **Strict mode**: if the response carries no `web_search_tool_result` block (native search did not trigger), the provider throws `WebError` `WEB_PROVIDER_ERROR` rather than degrading to prose-scraping — honest and debuggable. -It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`: the search endpoint is the Anthropic-compatible base (`https://api.deepseek.com/anthropic/v1`), distinct from the chat-completions base (`https://api.deepseek.com`) the LLM adapter uses. +It reuses the `DEEPSEEK_API_KEY` credential reference (no new secret) but **not** `$DEEPSEEK_BASE_URL`: the search endpoint is the Anthropic-compatible base (`https://api.deepseek.com/anthropic/v1`), distinct from the chat-completions base (`https://api.deepseek.com`) the LLM adapter uses. A mounted credentials service is authoritative; without one, the provider falls back to the launching process environment. The reference is resolved for each search, so a key stored or rotated by the Web Models page reaches the next call without a restart. ## Config | Key | Default | Meaning | |---|---|---| -| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API key. Empty/absent makes the provider unavailable. Sent as both `x-api-key` and `Authorization: Bearer` (official vs Anthropic-compatible proxy). | +| `apiKey` | omitted | Literal DeepSeek API key. Prefer `apiKeyEnv` so no secret enters configuration; a non-empty literal wins. | +| `apiKeyEnv` | `DEEPSEEK_API_KEY` | Credential reference resolved for each search through `ctx.credentials`, or from the process environment when that seam is absent. A missing value fails the call as `WEB_PROVIDER_CREDENTIAL_MISSING`. | | `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes the provider unavailable. | | `model` | `deepseek-v4-flash` | Anthropic-format model name. | | `apiVersion` | `2023-06-01` | `anthropic-version` header value. | @@ -29,7 +30,7 @@ It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`: - id: web-search-deepseek name: '@deepseek-ai/dsh-web-search-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY + apiKeyEnv: DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL ``` @@ -61,7 +62,7 @@ Independent of the conversation request cache. The auxiliary instruction and nat #### What the model sees -Through [`dsh-tool-web`](../tool-web/README.md), the conversation model sees deduplicated URLs, titles, dates, and citation snippets from structured search blocks; provider prose is not trusted as an answer. This provider's exact failures are `DeepSeek search aborted`, `DeepSeek search request failed: `, `DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search`, and `DeepSeek returned an unprocessable response body: `; HTTP failures preserve the provider message. The consumer owns the error wrapper. +Through [`dsh-tool-web`](../tool-web/README.md), the conversation model sees deduplicated URLs, titles, dates, and citation snippets from structured search blocks; provider prose is not trusted as an answer. This provider's exact failures include the actionable missing-credential message, `DeepSeek search credential resolution failed: `, `DeepSeek search aborted`, `DeepSeek search request failed: `, `DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search`, and `DeepSeek returned an unprocessable response body: `; HTTP failures preserve the provider message. The consumer owns the error wrapper. #### Token effect @@ -74,6 +75,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **One search costs a full Messages model turn** — latency plus generated tokens, with up to `maxUses` server-side searches; DeepSeek exposes no dedicated retrieval endpoint. +- **Dynamic credential availability resolves inside the operation** — the synchronous `available()` contract can establish that a resolver exists but cannot query an asynchronous credential store. A selected keyless provider therefore fails the search with `WEB_PROVIDER_CREDENTIAL_MISSING`; the stable `web_search` schema remains registered. - **Over-returned sources still cost tokens** — with no result-count knob on the wire, `maxResults` is enforced only post-hoc by seam truncation. - **Uncited results carry no `snippet`** — a source gains one only when a `text` block citation (`cited_text`) matches its URL. - **Abort classification is error-shape-based** — only a `DOMException` named `AbortError` maps to `WEB_ABORTED`; an abort carrying a custom reason (e.g. `dsh-timeout`'s `TimeoutReason`) surfaces as `WEB_PROVIDER_ERROR`. diff --git a/packages/web/web-search-deepseek/README.zh.md b/packages/web/web-search-deepseek/README.zh.md index 8862b8a9c1..1b403ecaaa 100644 --- a/packages/web/web-search-deepseek/README.zh.md +++ b/packages/web/web-search-deepseek/README.zh.md @@ -4,7 +4,7 @@ 由 [DeepSeek](https://deepseek.com) 支持的 `WebSearchProvider`,用于 harness [web 能力 seam](../web/README.md)(`ctx.web`)。它调用 DeepSeek 的 **Anthropic 兼容 Messages API**(`POST {baseURL}/messages`),启用原生 `web_search_20250305` 服务器工具,并把 DeepSeek 返回的结构化 `web_search_tool_result` 块映射为 seam 规范化的 `WebSearchResult`。 -这是一个**实现**包(package):它向 `ctx.web` 注册提供方,不拥有该键,也不注册面向模型的工具。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`)。Anthropic 协议格式(wire format)是提供方私有细节,并**不**使该提供方依赖 `ctx.llm`。 +这是一个**实现**包(package):它向 `ctx.web` 注册提供方,通过可选的 `ctx.credentials` seam 为每次搜索解析凭据,且不注册面向模型的工具。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`)。Anthropic 协议格式(wire format)是提供方私有细节,并**不**使该提供方依赖 `ctx.llm`。 ## 与专用搜索端点的区别 @@ -12,13 +12,14 @@ Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方 **严格模式**:如果响应不含 `web_search_tool_result` 块(未触发原生搜索),提供方会抛出 `WebError` `WEB_PROVIDER_ERROR`,而非降级为文本抓取;这种行为诚实且可诊断。 -它复用 `$DEEPSEEK_API_KEY`(不增加密钥),但**不会**复用 `$DEEPSEEK_BASE_URL`:搜索端点使用 Anthropic 兼容基址(`https://api.deepseek.com/anthropic/v1`),不同于大语言模型(LLM)适配器使用的 chat-completions 基址(`https://api.deepseek.com`)。 +它复用 `DEEPSEEK_API_KEY` 凭据引用(不增加密钥),但**不会**复用 `$DEEPSEEK_BASE_URL`:搜索端点使用 Anthropic 兼容基址(`https://api.deepseek.com/anthropic/v1`),不同于大语言模型(LLM)适配器使用的 chat-completions 基址(`https://api.deepseek.com`)。已挂载的凭据服务具有权威性;没有该服务时,提供方会回退到启动进程的环境变量。每次搜索都会解析该引用,因此在 Web 的 Models 页中存储或轮换的密钥无需重启,即可用于下一次调用。 ## 配置 | 配置键 | 默认值 | 含义 | |---|---|---| -| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API 密钥。为空或缺失时提供方不可用。同时通过 `x-api-key` 和 `Authorization: Bearer` 发送(分别用于官方接口与 Anthropic 兼容代理)。 | +| `apiKey` | 未设置 | DeepSeek API 密钥字面值。优先使用 `apiKeyEnv`,避免密钥进入配置;非空字面值优先。 | +| `apiKeyEnv` | `DEEPSEEK_API_KEY` | 每次搜索都会通过 `ctx.credentials` 解析该凭据引用;没有该 seam 时则从进程环境解析。值缺失时,调用以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败。 | | `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic 兼容端点基址;追加 `/messages`。覆盖时使用 `$DEEPSEEK_SEARCH_BASE_URL` 等独立环境变量;禁止复用属于 chat-completions LLM 适配器的 `$DEEPSEEK_BASE_URL`。无法解析时提供方不可用。 | | `model` | `deepseek-v4-flash` | Anthropic 格式模型名称。 | | `apiVersion` | `2023-06-01` | `anthropic-version` 标头值。 | @@ -29,7 +30,7 @@ Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方 - id: web-search-deepseek name: '@deepseek-ai/dsh-web-search-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY + apiKeyEnv: DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL ``` @@ -61,7 +62,7 @@ DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案 #### 模型看到的内容 -通过 [`dsh-tool-web`](../tool-web/README.md),会话模型会看到结构化搜索块中去重后的 URL、标题、日期与引用 snippet;提供方文本不会作为答案受到信任。该提供方的具体错误消息为 `DeepSeek search aborted`、`DeepSeek search request failed: `、`DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search` 和 `DeepSeek returned an unprocessable response body: `;HTTP 失败保留提供方消息。错误包装属于消费方。 +通过 [`dsh-tool-web`](../tool-web/README.md),会话模型会看到结构化搜索块中去重后的 URL、标题、日期与引用 snippet;提供方文本不会作为答案受到信任。该提供方的具体错误消息包括带有处理指引的凭据缺失消息、`DeepSeek search credential resolution failed: `、`DeepSeek search aborted`、`DeepSeek search request failed: `、`DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search` 和 `DeepSeek returned an unprocessable response body: `;HTTP 失败保留提供方消息。错误包装属于消费方。 #### Token 影响 @@ -74,6 +75,7 @@ DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案 ## 已知限制与暂缓事项 - **一次搜索需要完整的 Messages 模型轮次**:会产生延迟与生成 token,并且最多执行 `maxUses` 次服务器侧搜索;DeepSeek 不公开专用检索端点。 +- **动态凭据的可用性在操作内部解析**:同步的 `available()` 契约可以确认解析器存在,但无法查询异步凭据存储。因此,选中的无密钥提供方会使搜索以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败;稳定的 `web_search` schema 仍保持注册。 - **超量返回的源仍消耗 token**:协议没有结果数量旋钮,`maxResults` 只能由 seam 在事后截断。 - **未引用的结果没有 `snippet`**:只有 `text` 块中的引用(`cited_text`)匹配其 URL 时,源才会获得 snippet。 - **中止分类基于错误结构**:只有 `DOMException` 且名为 `AbortError` 时才映射为 `WEB_ABORTED`;携带自定义原因的中止(例如 `dsh-timeout` 的 `TimeoutReason`)会呈现为 `WEB_PROVIDER_ERROR`。 diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index b38f552957..73e1633876 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -27,6 +27,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-credentials": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -35,6 +36,8 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-credentials-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts index a24bb3f95f..802fab8cb1 100644 --- a/packages/web/web-search-deepseek/src/index.ts +++ b/packages/web/web-search-deepseek/src/index.ts @@ -7,6 +7,7 @@ import type { Context } from 'cordis' import z from 'schemastery' +import { credentialRef } from '@deepseek-ai/dsh-credentials' import type {} from '@deepseek-ai/dsh-web' import { DeepSeekSearchProvider, @@ -34,10 +35,14 @@ export const name = 'web-search-deepseek' /** The web seam this provider registers into. */ export const inject = ['web'] +const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY' + /** Plugin config (all optional — `apply` fills env-var and constant defaults). */ export interface Config { - /** DeepSeek API key. Falls back to `$DEEPSEEK_API_KEY`. Empty → unavailable. */ + /** Literal DeepSeek API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ apiKey?: string + /** Credential reference resolved for each search; defaults to `DEEPSEEK_API_KEY`. */ + apiKeyEnv?: string /** Anthropic-compatible endpoint base; `/messages` is appended. */ baseURL?: string /** Anthropic-format model name. Defaults to `deepseek-v4-flash`. */ @@ -51,7 +56,8 @@ export interface Config { } export const Config: z = z.object({ - apiKey: z.string(), + apiKey: z.string().role('secret'), + apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV), baseURL: z.string(), model: z.string(), apiVersion: z.string(), @@ -63,8 +69,19 @@ export const Config: z = z.object({ export function apply(ctx: Context, config: Config): void { const maxTokens = config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS const maxUses = config.maxUses ?? DEEPSEEK_DEFAULT_MAX_USES + const apiKeyEnv = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV) + const literalApiKey = config.apiKey !== undefined && config.apiKey.length > 0 + ? config.apiKey + : undefined ctx.web.registerSearchProvider(new DeepSeekSearchProvider({ - apiKey: config.apiKey ?? process.env.DEEPSEEK_API_KEY ?? '', + ...literalApiKey === undefined ? {} : { apiKey: literalApiKey }, + resolveApiKey: async () => { + const credentials = ctx.get('credentials') + if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value + const ambient = process.env[apiKeyEnv] + return ambient !== undefined && ambient.length > 0 ? ambient : undefined + }, + apiKeyEnv, baseURL: config.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL, model: config.model ?? DEEPSEEK_DEFAULT_MODEL, apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION, diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts index 871c911deb..b8a16ab841 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -13,6 +13,7 @@ import type { WebSearchResult, WebSearchSource, } from '@deepseek-ai/dsh-web' +import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import type { AnthropicError, AnthropicResponse, @@ -47,10 +48,14 @@ export const DEEPSEEK_DEFAULT_MAX_USES = 5 /** Attribution header sent on every request. Bump with the package version. */ const USER_AGENT = 'deepseek-harness/0.0.1' -/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */ +/** Resolved provider options (the plugin's `apply` supplies credential and constant defaults). */ export interface DeepSeekSearchProviderOptions { - /** DeepSeek API key. Empty/absent makes the provider unavailable. */ - apiKey: string + /** Literal DeepSeek API key; when present it wins over {@link resolveApiKey}. */ + apiKey?: string + /** Resolve the current DeepSeek API key for one search operation. */ + resolveApiKey?: () => Promise + /** Credential reference named by missing-credential diagnostics. */ + apiKeyEnv?: CredentialRef /** Endpoint base; `/messages` is appended. */ baseURL: string /** Anthropic-format model name. */ @@ -134,13 +139,14 @@ export class DeepSeekSearchProvider implements WebSearchProvider { constructor(private readonly options: DeepSeekSearchProviderOptions) {} available(): boolean { - return this.options.apiKey.length > 0 + return ((this.options.apiKey?.length ?? 0) > 0 || this.options.resolveApiKey !== undefined) && URL.canParse(this.options.baseURL) && isPositiveInteger(this.options.maxTokens) && isPositiveInteger(this.options.maxUses) } async search(request: WebSearchRequest, signal?: AbortSignal): Promise { + const apiKey = await this.apiKey() let response: Response try { response = await fetch(`${this.options.baseURL}/messages`, { @@ -149,8 +155,8 @@ export class DeepSeekSearchProvider implements WebSearchProvider { headers: { // Official DeepSeek expects `x-api-key`; an Anthropic-compatible proxy // may expect `Authorization: Bearer` — send both so either resolves. - 'x-api-key': this.options.apiKey, - 'authorization': `Bearer ${this.options.apiKey}`, + 'x-api-key': apiKey, + 'authorization': `Bearer ${apiKey}`, 'anthropic-version': this.options.apiVersion, 'content-type': 'application/json', 'accept': 'application/json', @@ -200,6 +206,29 @@ export class DeepSeekSearchProvider implements WebSearchProvider { throw new WebError(`DeepSeek returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) } } + + /** Resolve one operation's credential without retaining it on the provider. */ + private async apiKey(): Promise { + if (this.options.apiKey !== undefined && this.options.apiKey.length > 0) return this.options.apiKey + let resolved: string | undefined + try { + resolved = await this.options.resolveApiKey?.() + } catch (error: unknown) { + throw new WebError( + `DeepSeek search credential resolution failed: ${String(error)}`, + 'WEB_PROVIDER_ERROR', + { cause: error }, + ) + } + if (resolved !== undefined && resolved.length > 0) return resolved + const ref = this.options.apiKeyEnv ?? 'DEEPSEEK_API_KEY' + throw new WebError( + `DeepSeek search has no API key for "${ref}"; store it through the credentials service` + + ' (the web Models page writes it), export it in the launching environment, or set a literal' + + ' "apiKey" in the web-search-deepseek config', + 'WEB_PROVIDER_CREDENTIAL_MISSING', + ) + } } /** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */ diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index 5a9972cfd6..b4030c9f74 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import CredentialsLocal from '@deepseek-ai/dsh-credentials-local' import WebService from '@deepseek-ai/dsh-web' import { DeepSeekSearchProvider, @@ -336,15 +341,53 @@ describe('web-search-deepseek plugin registration', () => { } }) - it('is unavailable when neither config nor env supplies a key', async () => { + it('resolves the credential for each search so a stored or rotated key needs no restart', async () => { + const previous = process.env.DEEPSEEK_API_KEY + delete process.env.DEEPSEEK_API_KEY + const dir = await mkdtemp(join(tmpdir(), 'dsh-web-search-credentials-')) + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => jsonResponse(searchResponse())) + vi.stubGlobal('fetch', fetchMock) + const ctx = new Context() + try { + await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(deepseekPlugin, { baseURL: 'https://api.deepseek.test/anthropic/v1' }) + + await expect(ctx.web.search({ query: 'missing' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CREDENTIAL_MISSING' })) + + const ref = credentialRef('DEEPSEEK_API_KEY') + await ctx.credentials.set(ref, 'stored-key') + await ctx.web.search({ query: 'stored' }) + await ctx.credentials.set(ref, 'rotated-key') + await ctx.web.search({ query: 'rotated' }) + + const headers = fetchMock.mock.calls.map(([, init]) => (init as RequestInit).headers as Record) + expect(headers.map(value => value['x-api-key'])).toEqual(['stored-key', 'rotated-key']) + } finally { + await ctx.fiber.dispose() + await rm(dir, { recursive: true, force: true }) + if (previous === undefined) delete process.env.DEEPSEEK_API_KEY + else process.env.DEEPSEEK_API_KEY = previous + } + }) + + it('reports an actionable credential error when neither config nor env supplies a key', async () => { const prev = process.env.DEEPSEEK_API_KEY delete process.env.DEEPSEEK_API_KEY try { const ctx = new Context() await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) await ctx.plugin(deepseekPlugin, {}) - await expect(ctx.web.search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' })) + let caught: unknown + try { + await ctx.web.search({ query: 'q' }) + } catch (error: unknown) { + caught = error + } + expect(caught).toMatchObject({ code: 'WEB_PROVIDER_CREDENTIAL_MISSING' }) + if (!(caught instanceof Error)) throw new Error('search did not throw an Error') + expect(caught.message).toMatch(/store it through the credentials service.*Models page/s) } finally { if (prev !== undefined) process.env.DEEPSEEK_API_KEY = prev } diff --git a/packages/web/web-search-deepseek/tsconfig.json b/packages/web/web-search-deepseek/tsconfig.json index e9610ea5c9..cc1b917ddc 100644 --- a/packages/web/web-search-deepseek/tsconfig.json +++ b/packages/web/web-search-deepseek/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../web" }, + { + "path": "../../credentials/credentials" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c244eaa9cd..0cc81f63e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -411,6 +411,9 @@ importers: '@deepseek-ai/dsh-tool-todo': specifier: workspace:^ version: link:../../packages/todo/tool-todo + '@deepseek-ai/dsh-tool-web': + specifier: workspace:^ + version: link:../../packages/web/tool-web '@deepseek-ai/dsh-tool-workflow': specifier: workspace:^ version: link:../../packages/workflow/tool-workflow @@ -426,6 +429,15 @@ importers: '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../../packages/ui/user-interaction + '@deepseek-ai/dsh-web': + specifier: workspace:^ + version: link:../../packages/web/web + '@deepseek-ai/dsh-web-fetch-local': + specifier: workspace:^ + version: link:../../packages/web/web-fetch-local + '@deepseek-ai/dsh-web-search-deepseek': + specifier: workspace:^ + version: link:../../packages/web/web-search-deepseek '@deepseek-ai/dsh-workflow-workerthread': specifier: workspace:^ version: link:../../packages/workflow/workflow-workerthread @@ -5749,6 +5761,12 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../credentials/credentials + '@deepseek-ai/dsh-credentials-local': + specifier: workspace:^ + version: link:../../credentials/credentials-local '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants diff --git a/tsconfig.host.json b/tsconfig.host.json index 98a6a68536..e510e0acb1 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -28,6 +28,7 @@ "apps/web/tests/sidebar-scrollbar.e2e.ts", "apps/web/tests/code-mode-round.e2e.ts", "apps/web/tests/cordis-tool-round.e2e.ts", + "apps/web/tests/web-search-round.e2e.ts", "apps/web/tests/message-actions.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/skill-invocation-policy.e2e.ts",