feat(web-search-deepseek): resolve provider options from the settings section

The provider now takes a thunk rather than a value: it projects the
authoritative section per search, so a stored endpoint, model, or key
reference reaches the next call without re-registering the provider — which
would make the seam's provider selection observable as a flicker.

apiKey already carries role('secret'), so the section is safe to describe:
the literal never rides a response in any layer and a configuration surface
learns only that a key is set.
This commit is contained in:
Yichen Jiang
2026-08-10 18:06:27 +08:00
parent fdf9bddbf8
commit f736e6f584
17 changed files with 236 additions and 45 deletions
+1 -1
View File
@@ -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 docs/config-catalog.md
config-catalog.md: b588a80d6ee773fde52d9c3050b9d76438810d02
config-catalog.md: 36053fd205923e207224a01f3f948c1977004c1c
config-catalog.zh.md: d092947d31cfe4b24cae5d0ee8570dda39d7a287
+1 -1
View File
@@ -2595,7 +2595,7 @@ export interface Config {
}
```
Source: [`packages/web/web-search-deepseek/src/index.ts:44`](../packages/web/web-search-deepseek/src/index.ts)
Source: [`packages/web/web-search-deepseek/src/index.ts:46`](../packages/web/web-search-deepseek/src/index.ts)
## `@deepseek-ai/dsh-web-search-exa`
+2 -2
View File
@@ -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 docs/module-graph.md
module-graph.md: 949f8813f1d8f49e5a433049eb2f61c7235bf571
module-graph.zh.md: 70085ab7301cf3ec81494189aea065c9a487b04c
module-graph.md: a9d4def424c875860781b4b7d46aea2e9af903dd
module-graph.zh.md: 759ded070a2991689ee18ca679cfa57213abc49d
+2 -1
View File
@@ -559,6 +559,7 @@ flowchart TD
pkg_web_search_deepseek --> pkg_environment
pkg_web_search_deepseek --> pkg_invariants
pkg_web_search_deepseek --> pkg_session
pkg_web_search_deepseek --> pkg_settings
pkg_web_search_deepseek --> pkg_web
pkg_spill_local --> pkg_invariants
pkg_spill_local --> pkg_spill
@@ -1331,7 +1332,7 @@ flowchart TD
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) |
| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) |
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) |
| [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
+2 -1
View File
@@ -561,6 +561,7 @@ flowchart TD
pkg_web_search_deepseek --> pkg_environment
pkg_web_search_deepseek --> pkg_invariants
pkg_web_search_deepseek --> pkg_session
pkg_web_search_deepseek --> pkg_settings
pkg_web_search_deepseek --> pkg_web
pkg_spill_local --> pkg_invariants
pkg_spill_local --> pkg_spill
@@ -1333,7 +1334,7 @@ flowchart TD
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) |
| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) |
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) |
| [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
@@ -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: fb9e528633954b8eb9fd0fec8d19e24381a63f12
README.zh.md: 4e761aa6ef1a67ce17fbc1cabf595af7f24e15e6
README.md: 3e8b631b5775793e6e6e542c810edce9d92a0d59
README.zh.md: 9bd59000c2a0054265444aa90fc46ff5f34fd607
@@ -34,6 +34,8 @@ It reuses the `DEEPSEEK_API_KEY` credential reference (no new secret) but **not*
baseURL: https://gateway.internal/anthropic/v1
```
The entry above is the base layer of the `web-search-deepseek` Settings section: a user layer over it reaches the NEXT search, because the provider projects the section per call rather than capturing it at registration. The seam's provider selection therefore never flickers when an endpoint or model changes. `apiKey` carries `role('secret')`, so it never rides a `describe()` response in any layer — a configuration surface learns only that a key is set.
## Mapping
DeepSeek returns no provider-generated answer surface this provider trusts as `content`, so `content` is omitted. `sources[]` comes from `web_search_result` items inside `web_search_tool_result` blocks: `url``url`, `title``title`, and `publishedAt``page_age`. Snippets live separately as URL-keyed `cited_text` entries in a text block's `citations[]`; the provider joins them, leaving `snippet` absent when no excerpt exists.
@@ -34,6 +34,8 @@ Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方
baseURL: https://gateway.internal/anthropic/v1
```
上面的条目是 `web-search-deepseek` Settings 段的 base 层:叠加其上的用户层会作用于**下一次**搜索,因为提供方是按次投影该段,而不是在注册时固化它。因此端点或模型变化时,seam 的提供方选择不会闪断。`apiKey` 带有 `role('secret')`,所以它在任何一层都不会出现在 `describe()` 响应中——配置表层只能知道密钥是否已设置。
## 映射
DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案表层,因此省略 `content``sources[]` 来自 `web_search_result` 配置项,这些配置项位于 `web_search_tool_result` 块内:`url``url``title``title``publishedAt``page_age``cited_text` 配置项按 URL 标识,单独位于文本块的 `citations[]` 中;提供方会将其作为 snippet 连接,没有摘录时省略 `snippet`
@@ -30,6 +30,7 @@
"@deepseek-ai/dsh-environment": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
"@deepseek-ai/dsh-web": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -43,6 +44,7 @@
"@deepseek-ai/dsh-environment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-web": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
+32 -8
View File
@@ -9,6 +9,7 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-agent'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { environmentOf } from '@deepseek-ai/dsh-environment'
import type {} from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-web'
@@ -20,6 +21,7 @@ import {
DEEPSEEK_DEFAULT_MAX_USES,
DEEPSEEK_DEFAULT_MODEL,
} from './provider.ts'
import type { DeepSeekSearchProviderOptions } from './provider.ts'
export {
DeepSeekSearchProvider,
@@ -76,15 +78,23 @@ export const Config: z<Config> = z.object({
*/
const SEARCH_BASE_URL_ENV = 'DEEPSEEK_SEARCH_BASE_URL'
/** Register the DeepSeek search provider with `ctx.web`. */
export function apply(ctx: Context, config: Config): void {
const maxTokens = config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS
const maxUses = config.maxUses ?? DEEPSEEK_DEFAULT_MAX_USES
/** Settings namespace carrying this provider's endpoint, model, and key reference. */
export const WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE = settingsNamespace('web-search-deepseek')
/**
* Project one resolved section into the options the provider serves its next
* search with. Environment fallbacks stay here rather than in the provider:
* every value it reads is already fully defaulted.
* @param ctx - plugin context supplying the credential and environment planes.
* @param config - the currently authoritative section.
* @returns options for one search.
*/
function resolveOptions(ctx: Context, config: Config): DeepSeekSearchProviderOptions {
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({
return {
...literalApiKey === undefined ? {} : { apiKey: literalApiKey },
resolveApiKey: async () => {
const credentials = ctx.get('credentials')
@@ -99,13 +109,27 @@ export function apply(ctx: Context, config: Config): void {
?? DEEPSEEK_DEFAULT_BASE_URL,
model: config.model ?? DEEPSEEK_DEFAULT_MODEL,
apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION,
maxTokens,
maxUses,
maxTokens: config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS,
maxUses: config.maxUses ?? DEEPSEEK_DEFAULT_MAX_USES,
recordRequest: (request) => {
ctx.get('agents')?.currentInitiator()?.session.append(
'web/deepseek-search-llm-request',
request,
)
},
}))
}
}
/** Register the DeepSeek search provider with `ctx.web`. */
export function apply(ctx: Context, config: Config): void {
let current: () => Config = () => config
installSettingsSection(ctx, WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE, Config, config, {
setSource: (source) => {
current = source
},
// The registration carries no resolved value: the provider projects the
// section per search, so a committed change needs no re-registration.
onChange: () => {},
})
ctx.web.registerSearchProvider(new DeepSeekSearchProvider(() => resolveOptions(ctx, current())))
}
@@ -177,7 +177,18 @@ export function mapAnthropicResponse(response: AnthropicResponse): WebSearchResu
export class DeepSeekSearchProvider implements WebSearchProvider {
readonly id = DEEPSEEK_PROVIDER_ID
constructor(private readonly options: DeepSeekSearchProviderOptions) {}
/**
* @param resolveOptions - the options for the NEXT operation. A thunk rather
* than a value because the plugin's settings section can change between
* searches, and re-registering the provider to carry a new endpoint would
* make the seam's selection observable to the user as a flicker.
*/
constructor(private readonly resolveOptions: () => DeepSeekSearchProviderOptions) {}
/** Options resolved per read, so a committed settings change reaches the next search. */
private get options(): DeepSeekSearchProviderOptions {
return this.resolveOptions()
}
available(): boolean {
return ((this.options.apiKey?.length ?? 0) > 0 || this.options.resolveApiKey !== undefined)
@@ -8,6 +8,12 @@ import {
DEEPSEEK_DEFAULT_MODEL,
} from '@deepseek-ai/dsh-web-search-deepseek'
/** Construct the provider over a fixed options value; production passes a live thunk. */
import type { DeepSeekSearchProviderOptions } from '@deepseek-ai/dsh-web-search-deepseek'
const searchProvider = (options: DeepSeekSearchProviderOptions): DeepSeekSearchProvider =>
new DeepSeekSearchProvider(() => options)
/**
* Disabled real-API probe for the DeepSeek search provider. The live endpoint
* can complete without structured source blocks, so this is not a reliable
@@ -18,7 +24,7 @@ const maybe = apiKey !== undefined && apiKey.length > 0 ? describe : describe.sk
maybe('DeepSeekSearchProvider real API', () => {
it.skip('returns citeable sources for a live query via native web_search', async () => {
const provider = new DeepSeekSearchProvider({
const provider = searchProvider({
apiKey: apiKey!,
baseURL: process.env.DEEPSEEK_SEARCH_BASE_URL ?? DEEPSEEK_DEFAULT_BASE_URL,
model: process.env.DEEPSEEK_SEARCH_MODEL ?? DEEPSEEK_DEFAULT_MODEL,
@@ -15,6 +15,12 @@ import * as deepseekPlugin from '@deepseek-ai/dsh-web-search-deepseek'
import { citationSnippets, mapAnthropicResponse } from '../src/provider.ts'
import type { AnthropicResponse } from '@deepseek-ai/dsh-web-search-deepseek/src/types.ts'
/** Construct the provider over a fixed options value; production passes a live thunk. */
import type { DeepSeekSearchProviderOptions } from '@deepseek-ai/dsh-web-search-deepseek'
const searchProvider = (options: DeepSeekSearchProviderOptions): DeepSeekSearchProvider =>
new DeepSeekSearchProvider(() => options)
const options = {
apiKey: 'ds-key',
baseURL: 'https://api.deepseek.test/anthropic/v1',
@@ -142,21 +148,21 @@ describe('mapAnthropicResponse', () => {
describe('DeepSeekSearchProvider availability', () => {
it('is unavailable without a key', () => {
expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).available()).toBe(false)
expect(searchProvider({ ...options, apiKey: '' }).available()).toBe(false)
})
it('is available with a key', () => {
expect(new DeepSeekSearchProvider(options).available()).toBe(true)
expect(searchProvider(options).available()).toBe(true)
})
it('is misconfigured when the base URL is unparseable', () => {
expect(new DeepSeekSearchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false)
expect(searchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false)
})
it('is misconfigured when request limits are not positive integers', () => {
expect(new DeepSeekSearchProvider({ ...options, maxTokens: 0 }).available()).toBe(false)
expect(new DeepSeekSearchProvider({ ...options, maxUses: 0 }).available()).toBe(false)
expect(new DeepSeekSearchProvider({ ...options, maxUses: 1.5 }).available()).toBe(false)
expect(searchProvider({ ...options, maxTokens: 0 }).available()).toBe(false)
expect(searchProvider({ ...options, maxUses: 0 }).available()).toBe(false)
expect(searchProvider({ ...options, maxUses: 1.5 }).available()).toBe(false)
})
})
@@ -165,7 +171,7 @@ describe('DeepSeekSearchProvider request mapping', () => {
const fetchMock = vi.fn(async () => jsonResponse(searchResponse()))
const recordRequest = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await new DeepSeekSearchProvider({ ...options, recordRequest }).search({ query: 'hello' })
await searchProvider({ ...options, recordRequest }).search({ query: 'hello' })
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(url).toBe('https://api.deepseek.test/anthropic/v1/messages')
expect(init).toMatchObject({ method: 'POST', redirect: 'error' })
@@ -193,7 +199,7 @@ describe('DeepSeekSearchProvider request mapping', () => {
const fetchMock = vi.fn(async () => jsonResponse(searchResponse()))
vi.stubGlobal('fetch', fetchMock)
const controller = new AbortController()
await new DeepSeekSearchProvider(options).search({ query: 'q' }, controller.signal)
await searchProvider(options).search({ query: 'q' }, controller.signal)
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(init.signal).toBe(controller.signal)
})
@@ -207,7 +213,7 @@ describe('DeepSeekSearchProvider error handling', () => {
vi.stubGlobal('fetch', fetchMock)
const controller = new AbortController()
controller.abort(new Error('caller stopped'))
await expect(new DeepSeekSearchProvider({
await expect(searchProvider({
...options,
apiKey: '',
resolveApiKey,
@@ -225,7 +231,7 @@ describe('DeepSeekSearchProvider error handling', () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const controller = new AbortController()
const search = new DeepSeekSearchProvider({
const search = searchProvider({
...options,
apiKey: '',
resolveApiKey,
@@ -242,7 +248,7 @@ describe('DeepSeekSearchProvider error handling', () => {
const fetchMock = vi.fn(async () => jsonResponse(searchResponse()))
vi.stubGlobal('fetch', fetchMock)
const controller = new AbortController()
await expect(new DeepSeekSearchProvider({
await expect(searchProvider({
...options,
apiKey: '',
resolveApiKey: async () => 'resolved-key',
@@ -253,7 +259,7 @@ describe('DeepSeekSearchProvider error handling', () => {
it('maps a credential resolver rejection under an active signal to WEB_PROVIDER_ERROR', async () => {
const controller = new AbortController()
await expect(new DeepSeekSearchProvider({
await expect(searchProvider({
...options,
apiKey: '',
resolveApiKey: () => Promise.reject(new Error('credential backend failed')),
@@ -265,7 +271,7 @@ describe('DeepSeekSearchProvider error handling', () => {
})
it('uses the default credential reference when no resolver is configured', async () => {
await expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).search({ query: 'q' }))
await expect(searchProvider({ ...options, apiKey: '' }).search({ query: 'q' }))
.rejects.toThrow('DeepSeek search has no API key for "DEEPSEEK_API_KEY"')
})
@@ -273,7 +279,7 @@ describe('DeepSeekSearchProvider error handling', () => {
const controller = new AbortController()
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(new DeepSeekSearchProvider({
await expect(searchProvider({
...options,
apiKey: '',
resolveApiKey: () => {
@@ -287,31 +293,31 @@ describe('DeepSeekSearchProvider error handling', () => {
it('maps an HTTP error to WEB_PROVIDER_ERROR with the provider message', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: { message: 'rate limited' } }, { status: 429 })))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
await expect(searchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR', message: 'rate limited' }))
})
it('handles a string-form error body', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: 'bad request' }, { status: 400 })))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
await expect(searchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ message: 'bad request' }))
})
it('keeps a status-line message when the error body is not JSON', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('upstream error', { status: 503 })))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
await expect(searchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ message: 'DeepSeek API error (HTTP 503)' }))
})
it('keeps the status-line message when the JSON error body carries no detail', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({}, { status: 500 })))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
await expect(searchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ message: 'DeepSeek API error (HTTP 500)' }))
})
it('maps an abort to WEB_ABORTED', async () => {
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new DOMException('aborted', 'AbortError'))))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
await expect(searchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
})
@@ -321,46 +327,46 @@ describe('DeepSeekSearchProvider error handling', () => {
await new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => { reject(new Error('custom abort reason')) }, { once: true })
})))
const search = new DeepSeekSearchProvider(options).search({ query: 'q' }, controller.signal)
const search = searchProvider(options).search({ query: 'q' }, controller.signal)
controller.abort(new Error('timeout reason'))
await expect(search).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
})
it('maps an unparseable success body to WEB_PROVIDER_ERROR', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('not json', { status: 200 })))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
await expect(searchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
})
it('maps a well-formed body of the wrong shape to WEB_PROVIDER_ERROR, not a raw TypeError', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ content: {} }, { status: 200 })))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
await expect(searchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
})
it('surfaces an abort during success-body parse as WEB_ABORTED', async () => {
const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: true, status: 200 }
vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
await expect(searchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
})
it('surfaces an abort during error-body parse as WEB_ABORTED', async () => {
const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: false, status: 500 }
vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
await expect(searchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
})
it('maps a network failure to WEB_PROVIDER_ERROR', async () => {
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new TypeError('connection refused'))))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
await expect(searchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
})
it('strict mode flows through search(): a prose-only response throws WEB_PROVIDER_ERROR', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ content: [{ type: 'text', text: 'no search happened' }] })))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
await expect(searchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
})
})
@@ -8,6 +8,12 @@ import { createServer, type IncomingMessage, type Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import { DeepSeekSearchProvider } from '@deepseek-ai/dsh-web-search-deepseek'
/** Construct the provider over a fixed options value; production passes a live thunk. */
import type { DeepSeekSearchProviderOptions } from '@deepseek-ai/dsh-web-search-deepseek'
const searchProvider = (options: DeepSeekSearchProviderOptions): DeepSeekSearchProvider =>
new DeepSeekSearchProvider(() => options)
const TEST_API_KEY = 'redirect-test-key'
const TEST_QUERY = 'private redirect query'
const targetRequests: ReceivedRequest[] = []
@@ -46,7 +52,7 @@ afterAll(async () => {
describe('DeepSeekSearchProvider redirect policy', () => {
it.each([301, 302, 303, 307, 308])('rejects HTTP %i before contacting Location', async (status) => {
targetRequests.length = 0
const provider = new DeepSeekSearchProvider({
const provider = searchProvider({
apiKey: TEST_API_KEY,
baseURL: `${redirectOrigin}/${status}`,
model: 'deepseek-chat',
@@ -0,0 +1,124 @@
/** The `web-search-deepseek` settings section layered over the composition entry. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Fiber } from 'cordis'
import { Settings } from '@deepseek-ai/dsh-settings'
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
import WebService from '@deepseek-ai/dsh-web'
import * as deepseekPlugin from '@deepseek-ai/dsh-web-search-deepseek'
import { WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-web-search-deepseek'
/** The smallest real provider: one in-memory document, always writable. */
class MemorySettings extends Settings {
doc: Record<string, unknown> = {}
get writable(): boolean {
return true
}
protected load(): Promise<Record<string, unknown>> {
return Promise.resolve(structuredClone(this.doc))
}
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
this.doc = { ...this.doc, [ns]: structuredClone(section) }
return Promise.resolve()
}
}
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
/** The smallest Anthropic-shaped answer the provider accepts — enough to observe the request. */
const ONE_RESULT = {
content: [
{ type: 'text', text: 'ok' },
{
type: 'web_search_tool_result',
content: [{ type: 'web_search_result', url: 'https://a.test', title: 'A' }],
},
],
}
async function boot(): Promise<{ ctx: Context; settingsFiber: Fiber; pluginFiber: Fiber }> {
const ctx = new Context()
await ctx.plugin(WebService, {})
const settingsFiber = ctx.plugin(MemorySettings)
await settingsFiber.await()
const pluginFiber = ctx.plugin(deepseekPlugin, { apiKey: 'ds-key', baseURL: 'https://search.entry.test/v1' })
await pluginFiber.await()
return { ctx, settingsFiber, pluginFiber }
}
afterEach(() => {
vi.restoreAllMocks()
})
/**
* Run one search and answer the endpoint it reached. A fresh `Response` per
* call because a body can only be read once, and the call history is cleared
* because repeated `spyOn` returns the same spy.
* @param ctx - context whose `ctx.web` serves the search.
* @returns the URL the provider fetched.
*/
async function searchOnce(ctx: Context): Promise<string> {
const fetchSpy = vi.spyOn(globalThis, 'fetch')
.mockImplementation(() => Promise.resolve(jsonResponse(ONE_RESULT)))
fetchSpy.mockClear()
await ctx.web.search({ query: 'anything' })
return String((fetchSpy.mock.calls.at(-1)?.[0] as URL | string | undefined) ?? '')
}
describe('web-search-deepseek settings section', () => {
it('serves a stored endpoint to the next search without re-registering the provider', async () => {
const bench = await boot()
expect(await searchOnce(bench.ctx)).toContain('https://search.entry.test/v1')
await bench.ctx.settings.update(WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE, {
baseURL: 'https://search.stored.test/v1',
})
expect(await searchOnce(bench.ctx)).toContain('https://search.stored.test/v1')
await bench.ctx.fiber.dispose()
})
it('keeps the literal key out of every described layer', async () => {
const bench = await boot()
await bench.ctx.settings.update(WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE, { apiKey: 'ds-stored-secret' })
const [descriptor] = bench.ctx.settings.describe({ redactSecrets: true })
.filter(row => String(row.ns) === 'web-search-deepseek')
expect(JSON.stringify(descriptor)).not.toContain('ds-stored-secret')
expect(descriptor?.secrets).toEqual([{ path: ['apiKey'], set: true }])
await bench.ctx.fiber.dispose()
})
it('falls back to the composition entry when the settings provider detaches', async () => {
const bench = await boot()
await bench.ctx.settings.update(WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE, {
baseURL: 'https://search.stored.test/v1',
})
expect(await searchOnce(bench.ctx)).toContain('https://search.stored.test/v1')
await bench.settingsFiber.dispose()
expect(await searchOnce(bench.ctx)).toContain('https://search.entry.test/v1')
await bench.ctx.fiber.dispose()
})
it('releases the namespace when the plugin unloads', async () => {
const bench = await boot()
expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('web-search-deepseek')
await bench.pluginFiber.dispose()
expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('web-search-deepseek')
await bench.ctx.fiber.dispose()
})
})
@@ -32,6 +32,9 @@
{
"path": "../../credentials/credentials"
},
{
"path": "../../settings/settings"
},
{
"path": "../../support/invariants"
}
+3
View File
@@ -7324,6 +7324,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-settings':
specifier: workspace:^
version: link:../../settings/settings
'@deepseek-ai/dsh-web':
specifier: workspace:^
version: link:../web