mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
124
packages/web/web-search-deepseek/tests/settings.spec.ts
Normal file
124
packages/web/web-search-deepseek/tests/settings.spec.ts
Normal file
@@ -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
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user