diff --git a/docs/config-catalog.md b/docs/config-catalog.md index df067525d7..cf43f07cb8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -4142,7 +4142,7 @@ export interface Config { - `inject`: `web` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/web/web-search-deepseek/src/index.ts:46`](../packages/web/web-search-deepseek/src/index.ts) +- `source`: [`packages/web/web-search-deepseek/src/index.ts:49`](../packages/web/web-search-deepseek/src/index.ts) ```ts config-catalog /** Plugin config (all optional — `apply` fills env-var and constant defaults). */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 52a94c7f54..e9e22b4c7f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -4144,7 +4144,7 @@ export interface Config { - `inject`: `web` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/web/web-search-deepseek/src/index.ts:46`](../packages/web/web-search-deepseek/src/index.ts) +- `source`: [`packages/web/web-search-deepseek/src/index.ts:49`](../packages/web/web-search-deepseek/src/index.ts) ```ts config-catalog /** Plugin config (all optional — `apply` fills env-var and constant defaults). */ diff --git a/docs/module-graph.md b/docs/module-graph.md index bda8dc9b51..0da940458a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -633,11 +633,6 @@ flowchart TD pkg_goal --> pkg_session pkg_goal --> pkg_session_projection pkg_goal --> pkg_typert_protocol - pkg_web_search_deepseek --> pkg_agent - pkg_web_search_deepseek --> pkg_credentials - pkg_web_search_deepseek --> pkg_launch_environment - pkg_web_search_deepseek --> pkg_session - pkg_web_search_deepseek --> pkg_web pkg_file_reference --> pkg_agent pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants @@ -725,6 +720,12 @@ flowchart TD pkg_fs_sandbox --> pkg_fs_local pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy + pkg_web_search_deepseek --> pkg_agent + pkg_web_search_deepseek --> pkg_credentials + pkg_web_search_deepseek --> pkg_deepseek_account + pkg_web_search_deepseek --> pkg_launch_environment + pkg_web_search_deepseek --> pkg_session + pkg_web_search_deepseek --> pkg_web pkg_api_account_controller --> pkg_agent pkg_api_account_controller --> pkg_deepseek_account pkg_api_account_controller --> pkg_typert_protocol @@ -1564,7 +1565,6 @@ flowchart TD | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | @@ -1584,6 +1584,7 @@ flowchart TD | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 566b51ba41..e023865c6b 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -635,11 +635,6 @@ flowchart TD pkg_goal --> pkg_session pkg_goal --> pkg_session_projection pkg_goal --> pkg_typert_protocol - pkg_web_search_deepseek --> pkg_agent - pkg_web_search_deepseek --> pkg_credentials - pkg_web_search_deepseek --> pkg_launch_environment - pkg_web_search_deepseek --> pkg_session - pkg_web_search_deepseek --> pkg_web pkg_file_reference --> pkg_agent pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants @@ -727,6 +722,12 @@ flowchart TD pkg_fs_sandbox --> pkg_fs_local pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy + pkg_web_search_deepseek --> pkg_agent + pkg_web_search_deepseek --> pkg_credentials + pkg_web_search_deepseek --> pkg_deepseek_account + pkg_web_search_deepseek --> pkg_launch_environment + pkg_web_search_deepseek --> pkg_session + pkg_web_search_deepseek --> pkg_web pkg_api_account_controller --> pkg_agent pkg_api_account_controller --> pkg_deepseek_account pkg_api_account_controller --> pkg_typert_protocol @@ -1566,7 +1567,6 @@ flowchart TD | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | @@ -1586,6 +1586,7 @@ flowchart TD | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) | diff --git a/packages/client/ui-settings-web-search/README.i18n.yaml b/packages/client/ui-settings-web-search/README.i18n.yaml index 76a8d25bce..e63cb11abd 100644 --- a/packages/client/ui-settings-web-search/README.i18n.yaml +++ b/packages/client/ui-settings-web-search/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-settings-web-search/use-this-package: - en: b9c740855d0886d8 - zh: f6e14f6bd01ffd23 + en: 12351542785ad120 + zh: db1b53c71301d1dd /deepseek-ai-dsh-client-ui-settings-web-search/understand-the-implementation: en: 90a3d55da6722763 zh: 8fc0463c6543f100 diff --git a/packages/client/ui-settings-web-search/README.md b/packages/client/ui-settings-web-search/README.md index 21c21e736e..01fa9c24c4 100644 --- a/packages/client/ui-settings-web-search/README.md +++ b/packages/client/ui-settings-web-search/README.md @@ -25,7 +25,7 @@ Open **Plugins** in the sidebar and select **Web search** in the Official group ## Use this package -The **Web search** card in the Official group opens the page. **API key** starts blank on every load and reports only whether a key is configured; a blank draft keeps the stored key, and the control is disabled when the credential cannot be written from here, such as a key the process environment supplies. **Endpoint** and **Max searches per request** render the effective value, carry an **Overridden** badge with **Reset to default** once overridden, and save as a reset when emptied. Nothing is written until **Save**; leaving the page drops the drafts. +The **Web search** card in the Official group opens the page. **API key** starts blank on every load and reports only whether a key is configured; without one, it says that only conversations using a DeepSeek Account model can search, through the default endpoint, because those searches authenticate with the account sign-in; a blank draft keeps the stored key, and the control is disabled when the credential cannot be written from here, such as a key the process environment supplies. **Endpoint** and **Max searches per request** render the effective value, carry an **Overridden** badge with **Reset to default** once overridden, and save as a reset when emptied. Nothing is written until **Save**; leaving the page drops the drafts. ----- diff --git a/packages/client/ui-settings-web-search/README.zh.md b/packages/client/ui-settings-web-search/README.zh.md index 257d1eddaa..fd46f2f687 100644 --- a/packages/client/ui-settings-web-search/README.zh.md +++ b/packages/client/ui-settings-web-search/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -官方分组里的**网页搜索**卡片打开这一页。**API Key** 每次加载都是空的,只报告是否已配置密钥;留空保存等于保留现有密钥,而当凭据不能从这里写入时(例如由进程环境变量提供的密钥)该控件会被禁用。**接口地址**和**单次请求最多搜索次数**显示生效值,一旦被覆盖就带**已覆盖**标签和**恢复默认**,清空后保存等于重置。点击**保存**之前不会写入任何内容;离开页面即丢弃草稿。 +官方分组里的**网页搜索**卡片打开这一页。**API Key** 每次加载都是空的,只报告是否已配置密钥;未配置时提示仅使用 DeepSeek 账号模型的对话可以通过默认接口地址搜索,因为这类搜索使用账号登录鉴权;留空保存等于保留现有密钥,而当凭据不能从这里写入时(例如由进程环境变量提供的密钥)该控件会被禁用。**接口地址**和**单次请求最多搜索次数**显示生效值,一旦被覆盖就带**已覆盖**标签和**恢复默认**,清空后保存等于重置。点击**保存**之前不会写入任何内容;离开页面即丢弃草稿。 ----- diff --git a/packages/client/ui-settings-web-search/src/client/locales.ts b/packages/client/ui-settings-web-search/src/client/locales.ts index f699958c92..e4ec8d4586 100644 --- a/packages/client/ui-settings-web-search/src/client/locales.ts +++ b/packages/client/ui-settings-web-search/src/client/locales.ts @@ -17,7 +17,7 @@ export const en: Record = { apiKey: 'API key', apiKeyHint: 'Stored outside the settings file. Leave blank to keep the current key.', apiKeySet: 'A key is configured.', - apiKeyUnset: 'No key is configured; search is unavailable until one is.', + apiKeyUnset: 'No key is configured; only conversations using a DeepSeek Account model can search, through the default endpoint.', baseUrl: 'Endpoint', baseUrlHint: 'Leave blank to use the provider default.', maxUses: 'Max searches per request', @@ -39,7 +39,7 @@ export const zh: Record = { apiKey: 'API Key', apiKeyHint: '不写入设置文件。留空表示保持当前密钥。', apiKeySet: '已配置密钥。', - apiKeyUnset: '未配置密钥;配置之前搜索不可用。', + apiKeyUnset: '未配置密钥;仅使用 DeepSeek 账号模型的对话可以通过默认接口地址搜索。', baseUrl: '接口地址', baseUrlHint: '留空则使用提供方默认地址。', maxUses: '单次请求最多搜索次数', diff --git a/packages/web/web-search-deepseek/README.i18n.yaml b/packages/web/web-search-deepseek/README.i18n.yaml index d6192fd142..c6c339e567 100644 --- a/packages/web/web-search-deepseek/README.i18n.yaml +++ b/packages/web/web-search-deepseek/README.i18n.yaml @@ -9,8 +9,8 @@ en: d2c50aae9d869902 zh: ee9f3f0ff575cbf9 /deepseek-ai-dsh-web-search-deepseek/summary: - en: b5aa65b385b55fac - zh: 8ca37926bec97052 + en: 1810224f58c8d39b + zh: 2e253cb447ee49c4 /deepseek-ai-dsh-web-search-deepseek/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -18,11 +18,14 @@ en: e0d352d6402d9d3b zh: a449234557ef8bd6 /deepseek-ai-dsh-web-search-deepseek/use-this-package/when-to-choose-it: - en: 942798c9aa6ba90f - zh: 800ddd760ff4ae0b + en: 0187c8af8d2d98c5 + zh: 1c9607b3a662bcaa /deepseek-ai-dsh-web-search-deepseek/use-this-package/minimal-configuration: - en: edd0d2c2f18d0277 - zh: e3921e7e080a16a5 + en: 0553b6c926c44cf4 + zh: 5cd3221d3e8eebd9 +/deepseek-ai-dsh-web-search-deepseek/use-this-package/authentication: + en: 3e49592f15e94825 + zh: c1bcf6dbb946149a /deepseek-ai-dsh-web-search-deepseek/use-this-package/what-a-search-returns: en: e30442c90365357b zh: 9c9e2f1182963a8a @@ -36,14 +39,14 @@ en: 9842c69ad3636bb3 zh: 3cdc3f816cbdd0cb /deepseek-ai-dsh-web-search-deepseek/understand-the-implementation/design-philosophy: - en: 39d77f6c18cc791c - zh: d03a8d0a4e4addb2 + en: 1b1760efbf4568c1 + zh: fca06c959e51ce16 /deepseek-ai-dsh-web-search-deepseek/understand-the-implementation/source-map: en: d354ae3f0f21f7c6 zh: 95eb16d30d9c7a9c /deepseek-ai-dsh-web-search-deepseek/understand-the-implementation/request-flow: - en: e2462cb33430dc4c - zh: c31579cf224dcda2 + en: d6469ab9ac915762 + zh: 07ddf7b12c748eaf /deepseek-ai-dsh-web-search-deepseek/further-exploration: en: 59a97f197c044508 zh: de191dd060c31801 @@ -66,8 +69,8 @@ en: 666f5753c99783ab zh: b44328e99bcc45c9 /deepseek-ai-dsh-web-search-deepseek/model-experience/conversation-tool-result-indirectly/what-the-model-sees: - en: d438b6d9bc0dcc3c - zh: 93a311af5a178113 + en: 0764d19c9b82aa98 + zh: 88b0ba22ef64225b /deepseek-ai-dsh-web-search-deepseek/model-experience/conversation-tool-result-indirectly/token-effect: en: 01ec43a9b616139d zh: 0a5b7c28fde34bcf diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md index 6ac35d899e..6feddeec26 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -With `dsh-web-search-deepseek`, the harness searches the web through DeepSeek's native search using an existing `DEEPSEEK_API_KEY`. Choose it when a deployment wants DeepSeek native search and accepts that one search costs a full model turn in latency and tokens, because DeepSeek exposes no dedicated search endpoint. Results come from the structured search blocks DeepSeek returns, never from scraping text out of a reply. A missing credential fails the call with a structured error; a response without a search-result block fails loudly rather than degrading. The model-facing `web_search` tool lives in `dsh-tool-web`. +With `dsh-web-search-deepseek`, the harness searches the web through DeepSeek's native search using the DeepSeek account sign-in or an existing `DEEPSEEK_API_KEY`. Choose it when a deployment wants DeepSeek native search and accepts that one search costs a full model turn in latency and tokens, because DeepSeek exposes no dedicated search endpoint. Results come from the structured search blocks DeepSeek returns, never from scraping text out of a reply. A missing credential fails the call with a structured error; a response without a search-result block fails loudly rather than degrading. The model-facing `web_search` tool lives in `dsh-tool-web`. ## Table of Contents @@ -29,7 +29,7 @@ Mount the provider in a composition that already loads the web service; it regis ### When to choose it -Choose this backend when a deployment wants DeepSeek's native server-side web search and already holds a `DEEPSEEK_API_KEY` — the provider reuses that credential reference. One search is heavier than a dedicated retrieval endpoint: DeepSeek runs the search inside a full model turn, so expect one Messages call's latency and generated tokens per search, with up to `maxUses` server-side searches per request. Avoid it when per-search cost or latency dominates. +Choose this backend when a deployment wants DeepSeek's native server-side web search and its users either sign in to a DeepSeek account or hold a `DEEPSEEK_API_KEY` — the provider reuses those credentials as [Authentication](#authentication) describes. One search is heavier than a dedicated retrieval endpoint: DeepSeek runs the search inside a full model turn, so expect one Messages call's latency and generated tokens per search, with up to `maxUses` server-side searches per request. Avoid it when per-search cost or latency dominates. ### Minimal configuration @@ -45,8 +45,8 @@ Load the web service and the provider; the key resolves from `ctx.credentials` w | Field | Default | Meaning | |---|---|---| -| `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 service is absent. A missing value fails the call as `WEB_PROVIDER_CREDENTIAL_MISSING` | +| `apiKey` | omitted | Literal DeepSeek API key; prefer `apiKeyEnv` so no secret enters configuration. A non-empty literal wins over `apiKeyEnv`; an account token wins over both | +| `apiKeyEnv` | `DEEPSEEK_API_KEY` | Credential reference resolved for each search through `ctx.credentials`, or from the process environment when that service is absent. A search that needs an API key and finds none fails as `WEB_PROVIDER_CREDENTIAL_MISSING` | | `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Falls back to `$DEEPSEEK_SEARCH_BASE_URL`; an unparseable value makes the provider unavailable | | `model` | `deepseek-v4-flash` | Anthropic-format model name | | `apiVersion` | `2023-06-01` | `anthropic-version` header value | @@ -55,6 +55,11 @@ Load the web service and the provider; the key resolves from `ctx.credentials` w The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-search-deepseek) lists every accepted field. Each search captures options from the live Config references. + +### Authentication + +A search authenticates with the DeepSeek account when the latest `request/context` event of the initiating Session names the `deepseek-account` provider route and `ctx.deepseekAccount` resolves a token for the search endpoint. The account service resolves one only while signed in and only for its deployment-configured inference origin, `https://api.deepseek.com` by default. That search sends only `x-dsh-auth-token`, even when an API key is configured. Every other search, including a call without an initiating Session and a search whose endpoint has another origin, sends the API key as both `x-api-key` and `Authorization: Bearer`. An HTTP 401 response to an account-authenticated search fails as `WEB_PROVIDER_ERROR` with sign-in guidance instead of endpoint guidance, and leaves the account signed in. + ### What a search returns `content` is always omitted: DeepSeek's provider prose is not trusted as an answer. `sources[]` comes from `web_search_result` items inside `web_search_tool_result` blocks — `url` and `title` directly, and `publishedAt` from `page_age` — with snippets joined from URL-keyed `cited_text` entries where an excerpt exists. Results are deduplicated by URL, and because DeepSeek exposes no result-count knob, the service enforces `maxResults` by truncating and flagging. @@ -82,7 +87,7 @@ This section explains the design decisions behind the provider; the observable b The provider is built on two commitments: - **Structured blocks only.** DeepSeek runs the search server-side and returns structured `web_search_tool_result` blocks; the provider parses those blocks and never scrapes URLs out of model prose. In strict mode, a response with no such block throws `WEB_PROVIDER_ERROR` instead of degrading. -- **One credential, resolved per search.** The provider reuses the `DEEPSEEK_API_KEY` reference (no new secret) but keeps its auxiliary request endpoint independent through `$DEEPSEEK_SEARCH_BASE_URL`. A mounted credentials service is authoritative; without one the provider falls back to the launching process environment. Resolving per call means a key stored or rotated in the Web Models page reaches the next search without a restart. +- **Conversation credentials, resolved per search.** The provider adds no secret: a search from a Session on the account route uses that account's token, and every other search reuses the `DEEPSEEK_API_KEY` reference. The auxiliary request endpoint stays independent through `$DEEPSEEK_SEARCH_BASE_URL`. A mounted credentials service is authoritative; without one the provider falls back to the launching process environment. Resolving per call means a key stored or rotated in the Web Models page, or an account sign-in, reaches the next search without a restart. ### Source map @@ -95,7 +100,7 @@ The provider is built on two commitments: ### Request flow -Each search captures the current Config values into provider options — endpoint, model, key reference, limits — then resolves the credential reference through `ctx.credentials` (or the environment), appends the log-only session event, and dispatches the Messages request with the native `web_search` server tool. The response's `web_search_tool_result` blocks become `sources[]`; `cited_text` entries from text blocks are joined to their URLs as snippets; results are deduplicated by URL; and the service enforces the requested source bound on the way back. +Each search captures the current Config values into provider options — endpoint, model, key reference, limits — then asks `ctx.deepseekAccount` for a token when the initiating Session uses the account route, otherwise resolves the credential reference through `ctx.credentials` (or the environment), appends the log-only session event, and dispatches the Messages request with the native `web_search` server tool. The response's `web_search_tool_result` blocks become `sources[]`; `cited_text` entries from text blocks are joined to their URLs as snippets; results are deduplicated by URL; and the service enforces the requested source bound on the way back. @@ -136,7 +141,7 @@ Independent of the conversation request cache. The auxiliary instruction and nat #### What the model sees -Through `dsh-tool-web`, 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: `, and `DeepSeek search aborted`. Request, HTTP, native-search, and response-body failures append the resolved endpoint and the conditional configuration instruction described above. The consumer owns the error wrapper. +Through `dsh-tool-web`, 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, which also names DeepSeek Account sign-in, `DeepSeek search credential resolution failed: `, and `DeepSeek search aborted`. An HTTP 401 to an account-authenticated search appends an instruction to guide the user to sign in to DeepSeek again. Other request, HTTP, native-search, and response-body failures append the resolved endpoint and the conditional configuration instruction described above. The consumer owns the error wrapper. #### Token effect diff --git a/packages/web/web-search-deepseek/README.zh.md b/packages/web/web-search-deepseek/README.zh.md index 7693a74708..40579e7bb9 100644 --- a/packages/web/web-search-deepseek/README.zh.md +++ b/packages/web/web-search-deepseek/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -有了 `dsh-web-search-deepseek`,harness 可以通过 DeepSeek 原生搜索检索 web,使用部署已有的 `DEEPSEEK_API_KEY`。当部署希望使用 DeepSeek 原生搜索、并接受一次搜索在延迟与 token 上消耗一个完整模型轮次时选择它,因为 DeepSeek 不提供专用搜索端点。结果来自 DeepSeek 返回的结构化搜索块,绝不会从回复文本中抓取。凭据缺失时调用以结构化错误失败;响应缺少搜索结果块时会明确报错,而非降级。面向模型的 `web_search` 工具位于 `dsh-tool-web`。 +有了 `dsh-web-search-deepseek`,harness 可以通过 DeepSeek 原生搜索检索 web,使用 DeepSeek 账号登录或部署已有的 `DEEPSEEK_API_KEY`。当部署希望使用 DeepSeek 原生搜索、并接受一次搜索在延迟与 token 上消耗一个完整模型轮次时选择它,因为 DeepSeek 不提供专用搜索端点。结果来自 DeepSeek 返回的结构化搜索块,绝不会从回复文本中抓取。凭据缺失时调用以结构化错误失败;响应缺少搜索结果块时会明确报错,而非降级。面向模型的 `web_search` 工具位于 `dsh-tool-web`。 ## 目录 @@ -29,7 +29,7 @@ kind: "package-reference" ### 何时选择 -当部署希望使用 DeepSeek 原生服务端 web 搜索、且已持有 `DEEPSEEK_API_KEY` 时选择此后端——提供方复用该凭据引用。一次搜索比专用检索端点更重:DeepSeek 在完整模型轮次内执行搜索,因此每次搜索都要预期一次 Messages 调用的延迟与生成 token,每次请求最多 `maxUses` 次服务端搜索。当单次搜索的成本或延迟占主导时避免使用它。 +当部署希望使用 DeepSeek 原生服务端 web 搜索、且其用户登录 DeepSeek 账号或持有 `DEEPSEEK_API_KEY` 时选择此后端——提供方按[鉴权](#authentication)所述复用这些凭据。一次搜索比专用检索端点更重:DeepSeek 在完整模型轮次内执行搜索,因此每次搜索都要预期一次 Messages 调用的延迟与生成 token,每次请求最多 `maxUses` 次服务端搜索。当单次搜索的成本或延迟占主导时避免使用它。 ### 最小配置 @@ -45,8 +45,8 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| -| `apiKey` | 未设置 | DeepSeek API 密钥字面值;优先使用 `apiKeyEnv`,避免密钥进入配置。非空字面值优先 | -| `apiKeyEnv` | `DEEPSEEK_API_KEY` | 每次搜索通过 `ctx.credentials` 解析的凭据引用;没有该服务时从进程环境解析。值缺失时调用以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败 | +| `apiKey` | 未设置 | DeepSeek API 密钥字面值;优先使用 `apiKeyEnv`,避免密钥进入配置。非空字面值优先于 `apiKeyEnv`;账号 token 优先于两者 | +| `apiKeyEnv` | `DEEPSEEK_API_KEY` | 每次搜索通过 `ctx.credentials` 解析的凭据引用;没有该服务时从进程环境解析。需要 API 密钥却解析不到时,搜索以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败 | | `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic 兼容端点基址;追加 `/messages`。缺省时回退到 `$DEEPSEEK_SEARCH_BASE_URL`;无法解析时提供方不可用 | | `model` | `deepseek-v4-flash` | Anthropic 格式模型名称 | | `apiVersion` | `2023-06-01` | `anthropic-version` 标头值 | @@ -55,6 +55,11 @@ kind: "package-reference" 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-search-deepseek) 列出所有接受的字段。每次搜索从即时 Config 引用捕获选项。 + +### 鉴权 + +当发起会话最近的 `request/context` 事件指明 `deepseek-account` 提供方路由,且 `ctx.deepseekAccount` 为搜索端点解析出 token 时,搜索使用 DeepSeek 账号鉴权。账号服务仅在已登录时、且仅对部署配置的推理 origin(默认 `https://api.deepseek.com`)解析 token。这类搜索只发送 `x-dsh-auth-token`,即使配置了 API 密钥也是如此。其余所有搜索,包括没有发起会话的调用以及端点属于其他 origin 的搜索,都把 API 密钥同时作为 `x-api-key` 与 `Authorization: Bearer` 发送。账号鉴权的搜索收到 HTTP 401 时以 `WEB_PROVIDER_ERROR` 失败,附带登录指引而非端点指引,账号保持登录。 + ### 搜索返回什么 `content` 始终省略:DeepSeek 的提供方文本不作为答案受到信任。`sources[]` 来自 `web_search_tool_result` 块内的 `web_search_result` 条目——`url` 和 `title` 直接取自同名字段,`publishedAt` 取自 `page_age`——snippet 在存在摘录时按 URL 关联的 `cited_text` 条目拼接。结果按 URL 去重,且由于 DeepSeek 不公开结果数量旋钮,服务通过截断并标记来强制执行 `maxResults`。 @@ -82,7 +87,7 @@ kind: "package-reference" 本提供方建立在两项承诺之上: - **只取结构化块。** DeepSeek 在服务端执行搜索并返回结构化的 `web_search_tool_result` 块;提供方解析这些块,绝不从模型文本中抓取 URL。严格模式下,没有此类块的响应会抛出 `WEB_PROVIDER_ERROR`,而非降级。 -- **一个凭据,逐次解析。** 提供方复用 `DEEPSEEK_API_KEY` 引用(不新增密钥),但通过 `$DEEPSEEK_SEARCH_BASE_URL` 保持辅助请求端点独立。已挂载的凭据服务具有权威性;没有该服务时回退到启动进程的环境。按次解析意味着在 Web 的 Models 页中存储或轮换的密钥无需重启,即可用于下一次搜索。 +- **会话凭据,逐次解析。** 提供方不新增密钥:来自账号路由会话的搜索使用该账号的 token,其余搜索复用 `DEEPSEEK_API_KEY` 引用。辅助请求端点通过 `$DEEPSEEK_SEARCH_BASE_URL` 保持独立。已挂载的凭据服务具有权威性;没有该服务时回退到启动进程的环境。按次解析意味着在 Web 的 Models 页中存储或轮换的密钥,或一次账号登录,无需重启即可用于下一次搜索。 ### 源码地图 @@ -95,7 +100,7 @@ kind: "package-reference" ### 请求流程 -每次搜索先把当前 Config 段投影为提供方选项——端点、模型、密钥引用、上限——然后通过 `ctx.credentials`(或环境)解析凭据引用,追加仅用于日志的会话事件,并以原生 `web_search` 服务器工具分发 Messages 请求。响应中的 `web_search_tool_result` 块变为 `sources[]`;文本块中的 `cited_text` 条目按其 URL 拼接为 snippet;结果按 URL 去重;服务在返回路径上强制执行请求的来源上限。 +每次搜索先把当前 Config 段投影为提供方选项——端点、模型、密钥引用、上限——然后在发起会话使用账号路由时向 `ctx.deepseekAccount` 请求 token,否则通过 `ctx.credentials`(或环境)解析凭据引用,追加仅用于日志的会话事件,并以原生 `web_search` 服务器工具分发 Messages 请求。响应中的 `web_search_tool_result` 块变为 `sources[]`;文本块中的 `cited_text` 条目按其 URL 拼接为 snippet;结果按 URL 去重;服务在返回路径上强制执行请求的来源上限。 @@ -136,7 +141,7 @@ kind: "package-reference" #### 模型看到的内容 -通过 `dsh-tool-web`,会话模型会看到结构化搜索块中去重后的 URL、标题、日期与引用 snippet;提供方文本不会作为答案受到信任。该提供方的具体失败消息包括带有处理指引的凭据缺失消息、`DeepSeek search credential resolution failed: ` 和 `DeepSeek search aborted`。请求、HTTP、原生搜索和响应正文失败会追加已解析端点及前述条件式配置指引。错误包装属于消费方。 +通过 `dsh-tool-web`,会话模型会看到结构化搜索块中去重后的 URL、标题、日期与引用 snippet;提供方文本不会作为答案受到信任。该提供方的具体失败消息包括带有处理指引的凭据缺失消息(其中也提到 DeepSeek 账号登录)、`DeepSeek search credential resolution failed: ` 和 `DeepSeek search aborted`。账号鉴权的搜索收到 HTTP 401 时,会追加引导用户重新登录 DeepSeek 的指引。其他请求、HTTP、原生搜索和响应正文失败会追加已解析端点及前述条件式配置指引。错误包装属于消费方。 #### Token 影响 diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index f13509fd9b..6123f01e50 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -29,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-credentials": "workspace:*", + "@deepseek-ai/dsh-deepseek-account": "workspace:*", "@deepseek-ai/dsh-launch-environment": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-web": "workspace:*", @@ -41,10 +42,16 @@ "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-credentials": "workspace:*", "@deepseek-ai/dsh-credentials-local": "workspace:*", + "@deepseek-ai/dsh-deepseek-account": "workspace:*", "@deepseek-ai/dsh-launch-environment": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-web": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-http-proxy": "workspace:*" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-deepseek-account": { + "optional": true + } } } diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts index f404a404ad..8f5012fd49 100644 --- a/packages/web/web-search-deepseek/src/index.ts +++ b/packages/web/web-search-deepseek/src/index.ts @@ -1,7 +1,9 @@ /** * Register a DeepSeek-backed provider in `ctx.web`. It calls the Anthropic-compatible Messages API - * with native `web_search_20250305`. The provider reuses `DEEPSEEK_API_KEY` but not - * `DEEPSEEK_BASE_URL`; auxiliary search has its own endpoint configuration. + * with native `web_search_20250305`. A search initiated by a Session on the DeepSeek account route + * authenticates with the account token when the account service allows the search endpoint; + * every other search reuses `DEEPSEEK_API_KEY`. The provider does not reuse `DEEPSEEK_BASE_URL`; + * auxiliary search has its own endpoint configuration. * @module @deepseek-ai/dsh-web-search-deepseek */ import type { Volatile } from '@deepseek-ai/cordis' @@ -10,6 +12,7 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent' import { credentialRef } from '@deepseek-ai/dsh-credentials' +import type {} from '@deepseek-ai/dsh-deepseek-account' import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' import type {} from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-web' @@ -79,6 +82,9 @@ export const Config = z.object({ */ const SEARCH_BASE_URL_ENV = 'DEEPSEEK_SEARCH_BASE_URL' +/** Provider route id `dsh-llm-deepseek-account` registers; `request/context` events record it per Session. */ +const ACCOUNT_PROVIDER = 'deepseek-account' + /** Settings namespace carrying this provider's endpoint, model, and key reference. */ export const WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE = 'web-search-deepseek' @@ -99,6 +105,13 @@ function resolveOptions( : undefined return { ...literalApiKey === undefined ? {} : { apiKey: literalApiKey }, + resolveAccountToken: async (endpoint) => { + // The latest request context names the route that served the model + // request which called this search, as account sign-out reads it. + const provider = ctx.get('agents')?.currentInitiator()?.session.requestContext()?.provider + if (provider !== ACCOUNT_PROVIDER) return undefined + return await ctx.get('deepseekAccount')?.resolveToken(endpoint) + }, resolveApiKey: async () => { const credentials = ctx.get('credentials') if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts index 562c0f36a8..b6c87f6929 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -85,6 +85,12 @@ declare module '@deepseek-ai/dsh-session/types' { /** Resolved provider options (the plugin's `apply` supplies credential and constant defaults). */ export interface DeepSeekSearchProviderOptions { + /** + * Resolve the DeepSeek account token for one search endpoint. A token takes + * precedence over every API key and is sent only as `x-dsh-auth-token`; + * `undefined` selects API-key authentication. + */ + resolveAccountToken?: (endpoint: string) => Promise /** Literal DeepSeek API key; when present it wins over {@link resolveApiKey}. */ apiKey?: string /** Resolve the current DeepSeek API key for one search operation. */ @@ -172,6 +178,14 @@ export function mapAnthropicResponse(response: AnthropicResponse): WebSearchResu return { sources, truncated: false } } +/** Authentication headers for one search, tagged by the credential that produced them. */ +interface SearchAuth { + /** `account` for a DeepSeek account token, `api-key` for an API key. */ + readonly kind: 'account' | 'api-key' + /** Headers carrying the credential. */ + readonly headers: Readonly> +} + /** * The DeepSeek-backed search provider. HTTP redirects fail as `WEB_PROVIDER_ERROR`; * failures after dispatch name the endpoint and tell the model how the user can configure it. @@ -190,7 +204,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider { available(): boolean { const options = this.resolveOptions() - return ((options.apiKey?.length ?? 0) > 0 || options.resolveApiKey !== undefined) + return ((options.apiKey?.length ?? 0) > 0 || options.resolveApiKey !== undefined || options.resolveAccountToken !== undefined) && URL.canParse(options.baseURL) && isPositiveInteger(options.maxTokens) && isPositiveInteger(options.maxUses) @@ -201,9 +215,9 @@ export class DeepSeekSearchProvider implements WebSearchProvider { // settings write landing inside that await must not send the key resolved // from the old section to the endpoint named by the new one. const options = this.resolveOptions() - const apiKey = await this.apiKey(options, signal) - throwIfSearchAborted(signal) const endpoint = `${options.baseURL}/messages` + const auth = await this.authHeaders(options, endpoint, signal) + throwIfSearchAborted(signal) const body: DeepSeekSearchLlmRequest['body'] = { model: options.model, max_tokens: options.maxTokens, @@ -225,10 +239,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider { method: 'POST', redirect: 'error', headers: { - // Official DeepSeek expects `x-api-key`; an Anthropic-compatible proxy - // may expect `Authorization: Bearer` — send both so either resolves. - 'x-api-key': apiKey, - 'authorization': `Bearer ${apiKey}`, + ...auth.headers, 'anthropic-version': options.apiVersion, 'content-type': 'application/json', 'accept': 'application/json', @@ -262,6 +273,9 @@ export class DeepSeekSearchProvider implements WebSearchProvider { // malformed/non-JSON error body (normal for gateway 5xx/429s) can only // cost a richer provider message, never the real error. } + // The account service released this token only for its inference + // origin, so the endpoint is not what the user must change. + if (status === 401 && auth.kind === 'account') throw accountRejectedError(message) throw searchEndpointError(endpoint, message) } @@ -278,7 +292,28 @@ export class DeepSeekSearchProvider implements WebSearchProvider { } /** - * Resolve one operation's credential without retaining it on the provider. + * Resolve one operation's authentication headers without retaining a credential on the provider. + * @param options - the caller's snapshot, so the credential and the endpoint it is sent to come from one section. + * @param endpoint - the Messages endpoint this operation dispatches to. + * @param signal - abort signal for the surrounding search. + * @returns the account-token header when one resolves, otherwise the API-key headers, tagged by credential kind. + */ + private async authHeaders( + options: DeepSeekSearchProviderOptions, endpoint: string, signal?: AbortSignal, + ): Promise { + const { resolveAccountToken } = options + const token = resolveAccountToken === undefined + ? undefined + : await resolveCredential(() => resolveAccountToken(endpoint), signal) + if (token !== undefined && token.length > 0) return { kind: 'account', headers: { 'x-dsh-auth-token': token } } + const apiKey = await this.apiKey(options, signal) + // Official DeepSeek expects `x-api-key`; an Anthropic-compatible proxy + // may expect `Authorization: Bearer` — send both so either resolves. + return { kind: 'api-key', headers: { 'x-api-key': apiKey, 'authorization': `Bearer ${apiKey}` } } + } + + /** + * Resolve one operation's API key without retaining it on the provider. * @param options - the caller's snapshot, so the key and the endpoint it is sent to come from one section. * @param signal - abort signal for the surrounding search. * @returns the resolved key. @@ -286,28 +321,53 @@ export class DeepSeekSearchProvider implements WebSearchProvider { private async apiKey(options: DeepSeekSearchProviderOptions, signal?: AbortSignal): Promise { throwIfSearchAborted(signal) if (options.apiKey !== undefined && options.apiKey.length > 0) return options.apiKey - let resolved: string | undefined - try { - resolved = await abortable(options.resolveApiKey?.() ?? Promise.resolve(undefined), signal) - } catch (error: unknown) { - if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error) - throw new WebError( - `DeepSeek search credential resolution failed: ${String(error)}`, - 'WEB_PROVIDER_ERROR', - { cause: error }, - ) - } + const { resolveApiKey } = options + const resolved = resolveApiKey === undefined ? undefined : await resolveCredential(resolveApiKey, signal) if (resolved !== undefined && resolved.length > 0) return resolved const ref = 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', + + ' "apiKey" in the web-search-deepseek config; a conversation using a DeepSeek Account model' + + ' searches with the account sign-in instead', 'WEB_PROVIDER_CREDENTIAL_MISSING', ) } } +/** + * Run one credential resolver under the search's cancellation signal. An + * already-cancelled search never starts the resolver. + * @param resolve - the resolver; a synchronous throw is mapped like a rejection. + * @param signal - abort signal for the surrounding search. + * @returns the resolved credential, or undefined when the resolver supplied none. + * @throws {@link WebError} `WEB_ABORTED` on cancellation, `WEB_PROVIDER_ERROR` when the resolver fails. + */ +async function resolveCredential( + resolve: () => Promise, signal?: AbortSignal, +): Promise { + throwIfSearchAborted(signal) + try { + return await abortable(resolve(), signal) + } catch (error: unknown) { + if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error) + throw new WebError( + `DeepSeek search credential resolution failed: ${String(error)}`, + 'WEB_PROVIDER_ERROR', + { cause: error }, + ) + } +} + +/** Replace endpoint guidance with sign-in guidance when DeepSeek rejects the account token. */ +function accountRejectedError(message: string): WebError { + return new WebError( + `${message}\n\nDeepSeek rejected the account sign-in used for this web search. ` + + 'Guide the user to sign in to DeepSeek again; the search endpoint does not need changing.', + 'WEB_PROVIDER_ERROR', + ) +} + /** Add endpoint recovery instructions to failures that occur after request dispatch begins. */ function searchEndpointError(endpoint: string, message: string, cause?: unknown): WebError { return new WebError( diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index 77654ea851..09406f8a68 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -4,8 +4,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import { credentialRef } from '@deepseek-ai/dsh-credentials' import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local' +import type { DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import WebRuntime, { WebError } from '@deepseek-ai/dsh-web' import { DeepSeekSearchProvider, @@ -166,6 +170,10 @@ describe('DeepSeekSearchProvider availability', () => { expect(searchProvider(options).available()).toBe(true) }) + it('is available with only an account token resolver', () => { + expect(searchProvider({ ...options, apiKey: '', resolveAccountToken: async () => 'account-token' }).available()).toBe(true) + }) + it('is misconfigured when the base URL is unparseable', () => { expect(searchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false) }) @@ -216,6 +224,132 @@ describe('DeepSeekSearchProvider request mapping', () => { }) }) +describe('DeepSeekSearchProvider account authentication', () => { + /** Stub fetch and read back the endpoint and headers of its first call. */ + function captureFetch() { + // The provider always passes the endpoint as a string. + const fetchMock = vi.fn(async (_input: string, _init?: RequestInit) => jsonResponse(searchResponse())) + vi.stubGlobal('fetch', fetchMock) + return { + fetchMock, + first: () => { + const [url, init] = fetchMock.mock.calls[0] ?? [] + return { url: url ?? '', headers: (init?.headers ?? {}) as Record } + }, + } + } + + it('sends only the account token for the dispatched endpoint, ahead of a configured key', async () => { + const { first } = captureFetch() + const resolveAccountToken = vi.fn(async (_endpoint: string) => 'account-token') + const resolveApiKey = vi.fn(async () => 'resolved-key') + await searchProvider({ ...options, resolveAccountToken, resolveApiKey }).search({ query: 'q' }) + const { url, headers } = first() + expect(resolveAccountToken).toHaveBeenCalledWith(url) + expect(resolveApiKey).not.toHaveBeenCalled() + expect(headers['x-dsh-auth-token']).toBe('account-token') + expect(headers).not.toHaveProperty('x-api-key') + expect(headers).not.toHaveProperty('authorization') + }) + + it.each([undefined, ''])('falls back to the API key when the account resolves %j', async (token) => { + const { first } = captureFetch() + await searchProvider({ ...options, resolveAccountToken: async () => token }).search({ query: 'q' }) + const { headers } = first() + expect(headers['x-api-key']).toBe('ds-key') + expect(headers).not.toHaveProperty('x-dsh-auth-token') + }) + + it('maps an account resolver rejection to WEB_PROVIDER_ERROR without dispatching', async () => { + const { fetchMock } = captureFetch() + await expect(searchProvider({ + ...options, + resolveAccountToken: () => Promise.reject(new Error('account storage failed')), + }).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ + code: 'WEB_PROVIDER_ERROR', + message: 'DeepSeek search credential resolution failed: Error: account storage failed', + })) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('replaces endpoint guidance with sign-in guidance when DeepSeek rejects the account token', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: { message: 'invalid token' } }, { status: 401 }))) + const error = await rejectedWebError(searchProvider({ + ...options, + resolveAccountToken: async () => 'account-token', + }).search({ query: 'q' })) + expect(error).toMatchObject({ + code: 'WEB_PROVIDER_ERROR', + message: 'DeepSeek API error (HTTP 401): invalid token\n\n' + + 'DeepSeek rejected the account sign-in used for this web search. ' + + 'Guide the user to sign in to DeepSeek again; the search endpoint does not need changing.', + }) + }) + + it('keeps endpoint guidance for other account-authenticated HTTP failures', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({}, { status: 503 }))) + const error = await rejectedWebError(searchProvider({ + ...options, + resolveAccountToken: async () => 'account-token', + }).search({ query: 'q' })) + expect(error.message).toContain('Search endpoint configuration is separate from chat.') + }) + + it('maps a synchronous resolver throw to WEB_PROVIDER_ERROR', async () => { + const { fetchMock } = captureFetch() + await expect(searchProvider({ + ...options, + resolveAccountToken: () => { throw new Error('account service threw') }, + }).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ + code: 'WEB_PROVIDER_ERROR', + message: 'DeepSeek search credential resolution failed: Error: account service threw', + })) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('starts neither resolver for a pre-aborted call', async () => { + const resolveAccountToken = vi.fn(async () => 'account-token') + const resolveApiKey = vi.fn(async () => 'resolved-key') + const controller = new AbortController() + controller.abort(new Error('caller stopped')) + await expect(searchProvider({ ...options, apiKey: '', resolveAccountToken, resolveApiKey }) + .search({ query: 'q' }, controller.signal)) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + expect(resolveAccountToken).not.toHaveBeenCalled() + expect(resolveApiKey).not.toHaveBeenCalled() + }) + + it('does not start API-key resolution after cancellation during account resolution', async () => { + const controller = new AbortController() + const resolveApiKey = vi.fn(() => Promise.reject(new Error('must not run'))) + await expect(searchProvider({ + ...options, + apiKey: '', + resolveAccountToken: async () => { + controller.abort(new Error('caller stopped')) + return undefined + }, + resolveApiKey, + }).search({ query: 'q' }, controller.signal)) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + expect(resolveApiKey).not.toHaveBeenCalled() + }) + + it('aborts while the account resolver remains pending', async () => { + const { fetchMock } = captureFetch() + const controller = new AbortController() + const search = searchProvider({ + ...options, + resolveAccountToken: () => new Promise(() => {}), + }).search({ query: 'q' }, controller.signal) + controller.abort(new Error('deadline')) + await expect(search).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + describe('DeepSeekSearchProvider settings changes mid-search', () => { it('serves one search from one section even when settings land during credential resolution', async () => { // The section the search starts on, and the one a user commits while the @@ -554,3 +688,52 @@ describe('web-search-deepseek plugin registration', () => { } }) }) + +describe('web-search-deepseek account route selection', () => { + /** + * Mount the provider with a signed-in account and run one search inside an + * initiator whose latest request context names `provider`. + * @param provider - route recorded by the initiating Session's request context; undefined runs the + * search without an initiator while a Session on the account route exists. + * @returns the headers the search sent and the URLs the account was asked about. + */ + async function searchAs(provider: string | undefined): Promise<{ headers: Record; asked: string[] }> { + const fetchMock = vi.fn(async (_input: string, _init?: RequestInit) => jsonResponse(searchResponse())) + vi.stubGlobal('fetch', fetchMock) + const asked: string[] = [] + const ctx = new Context() + try { + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(WebRuntime, { searchProvider: DEEPSEEK_PROVIDER_ID }) + ctx.provide('deepseekAccount', { + resolveToken: async (url: string) => { asked.push(url); return 'account-token' }, + } as DeepSeekAccount) + await ctx.plugin(deepseekPlugin, { apiKey: 'ds-key' }) + const session = ctx.sessions.create(SessionId(`web-search-account-${provider ?? 'none'}`)) + session.append('turn/start', { turn: 1 }) + session.append('request/context', { provider: provider ?? 'deepseek-account', model: 'deepseek-v4-flash' }) + const agent = { session } as Agent + const search = () => ctx.web.search({ query: 'q' }) + await (provider === undefined ? search() : ctx.agents.withInitiator(agent, search)) + const [, init] = fetchMock.mock.calls[0] ?? [] + return { headers: (init?.headers ?? {}) as Record, asked } + } finally { + await ctx.fiber.dispose() + } + } + + it('authenticates with the account token when the initiating Session uses the account route', async () => { + const { headers, asked } = await searchAs('deepseek-account') + expect(asked).toEqual(['https://api.deepseek.com/anthropic/v1/messages']) + expect(headers['x-dsh-auth-token']).toBe('account-token') + expect(headers).not.toHaveProperty('x-api-key') + }) + + it.each(['deepseek-official', undefined])('keeps API-key authentication for route %j', async (provider) => { + const { headers, asked } = await searchAs(provider) + expect(asked).toEqual([]) + expect(headers['x-api-key']).toBe('ds-key') + expect(headers).not.toHaveProperty('x-dsh-auth-token') + }) +}) diff --git a/packages/web/web-search-deepseek/tsconfig.json b/packages/web/web-search-deepseek/tsconfig.json index 998420ec9e..423c1fc570 100644 --- a/packages/web/web-search-deepseek/tsconfig.json +++ b/packages/web/web-search-deepseek/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../../credentials/credentials" }, + { + "path": "../../credentials/deepseek-account" + }, { "path": "../../util/http-proxy" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7c476a630..244692dda7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13570,6 +13570,9 @@ importers: '@deepseek-ai/dsh-credentials-local': specifier: workspace:* version: link:../../credentials/credentials-local + '@deepseek-ai/dsh-deepseek-account': + specifier: workspace:* + version: link:../../credentials/deepseek-account '@deepseek-ai/dsh-http-proxy': specifier: workspace:* version: link:../../util/http-proxy