Merge remote-tracking branch 'origin/master' into feat/web-search-card
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 packages/compact/compact-basic/README.md
|
||||
README.md: 775355f1ac1a7c79c16f66a5b2489d73df7b960d
|
||||
README.zh.md: bfa139596b5ef61c23d29575bdea5534fa82b158
|
||||
README.md: b35e5dc110e908047338054337b309a77b7e0f68
|
||||
README.zh.md: 9c5f83d987b584d58ffacadce4e469bb6ba81aa2
|
||||
|
||||
@@ -136,7 +136,7 @@ Output EXACTLY the Markdown structure below: keep every section, in order. Use t
|
||||
- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]
|
||||
|
||||
Rules:
|
||||
- Preserve exact file paths, commands, error strings, identifiers, and function signatures.
|
||||
- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.
|
||||
- Capture user feedback and explicit instructions faithfully, especially corrections.
|
||||
- Do NOT mention this summarization request or that the context was compacted.
|
||||
- Output only the checkpoint text: do not call any tool or take any other action.
|
||||
|
||||
@@ -136,7 +136,7 @@ Output EXACTLY the Markdown structure below: keep every section, in order. Use t
|
||||
- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]
|
||||
|
||||
Rules:
|
||||
- Preserve exact file paths, commands, error strings, identifiers, and function signatures.
|
||||
- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.
|
||||
- Capture user feedback and explicit instructions faithfully, especially corrections.
|
||||
- Do NOT mention this summarization request or that the context was compacted.
|
||||
- Output only the checkpoint text: do not call any tool or take any other action.
|
||||
|
||||
@@ -58,7 +58,7 @@ const COMPACTION_INSTRUCTION = [
|
||||
'- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]',
|
||||
'',
|
||||
'Rules:',
|
||||
'- Preserve exact file paths, commands, error strings, identifiers, and function signatures.',
|
||||
'- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.',
|
||||
'- Capture user feedback and explicit instructions faithfully, especially corrections.',
|
||||
'- Do NOT mention this summarization request or that the context was compacted.',
|
||||
'- Output only the checkpoint text: do not call any tool or take any other action.',
|
||||
|
||||
@@ -1224,7 +1224,8 @@ describe('default one-shot summarizer', () => {
|
||||
expect(messages[0]).toEqual(prefix)
|
||||
const last = messages.at(-1)?.content[0]
|
||||
const lastText = last?.type === 'text' ? last.text : ''
|
||||
expect(lastText).toContain('Condense the conversation ABOVE')
|
||||
expect(lastText).toContain('Write concise English engineering prose.')
|
||||
expect(lastText).toContain('numeric values, function signatures, and syntax fragments.')
|
||||
expect(lastText).toContain('## Primary Request and Intent')
|
||||
})
|
||||
|
||||
|
||||
@@ -342,6 +342,11 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
|
||||
expect(adapter.conversationRequests).toHaveLength(2)
|
||||
expect(adapter.summaryRequests).toHaveLength(1)
|
||||
const instruction = adapter.summaryRequests[0]!.messages.at(-1)?.content
|
||||
.map(block => (block.type === 'text' ? block.text : ''))
|
||||
.join('') ?? ''
|
||||
expect(instruction).toContain('Write concise English engineering prose.')
|
||||
expect(instruction).toContain('numeric values, function signatures, and syntax fragments.')
|
||||
expect(JSON.stringify(adapter.conversationRequests[0]!.messages)).toContain('OLD HISTORY SENTINEL')
|
||||
const retry = JSON.stringify(adapter.conversationRequests[1]!.messages)
|
||||
expect(retry).toContain('RECOVERY CHECKPOINT')
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import type { AgentLlmTarget, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent'
|
||||
import { errorChain, type ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { errorChain, LlmError, type ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { TuiOverlaySession } from '../extension/types.ts'
|
||||
import { displayText } from '../components/text.ts'
|
||||
import {
|
||||
@@ -37,6 +37,8 @@ export interface ModelController {
|
||||
resetContextResolution(): void
|
||||
/** Forget the tracked selector overlay (shutdown). */
|
||||
clearOverlay(): void
|
||||
/** Remove the adapter-registration listener (channel detach). */
|
||||
detach(): void
|
||||
}
|
||||
|
||||
type ContextResolution =
|
||||
@@ -55,8 +57,15 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
|
||||
let modelOverlay: TuiOverlaySession | undefined
|
||||
let modelCommands = Promise.resolve()
|
||||
|
||||
// A route whose adapter has not registered yet. Loader activation order is
|
||||
// service-driven, so the TUI can mount before a configured adapter plugin
|
||||
// activates; that transient NO_ADAPTER is not an error — the resolution
|
||||
// waits for the next `llm/adapters-updated` commit instead of surfacing it.
|
||||
let awaitingAdapter = false
|
||||
|
||||
const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
|
||||
contextWindow = undefined
|
||||
awaitingAdapter = false
|
||||
const resolution: Promise<ContextResolution> = selected === undefined
|
||||
? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const)
|
||||
: ctx.llm.resolveModelInfo(selected.provider, selected.model).then(
|
||||
@@ -67,6 +76,10 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
|
||||
void resolution.then((result) => {
|
||||
if (contextResolution !== resolution) return
|
||||
if (result.kind === 'error') {
|
||||
if (selected !== undefined && result.error instanceof LlmError && result.error.code === 'NO_ADAPTER') {
|
||||
awaitingAdapter = true
|
||||
return
|
||||
}
|
||||
deps.appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error')
|
||||
return
|
||||
}
|
||||
@@ -74,6 +87,15 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
|
||||
deps.requestRender()
|
||||
})
|
||||
}
|
||||
// The wait cannot go stale against `target.current`: every target change
|
||||
// re-enters resolveContextWindow, which clears it. A commit that still
|
||||
// lacks the route parks the resolution again rather than erroring, so
|
||||
// unrelated topology changes stay silent. The disposer rides the channel's
|
||||
// detachListeners() through detach(), matching the sibling listeners.
|
||||
const disposeAdapterListener = ctx.on('llm/adapters-updated', () => {
|
||||
if (deps.isDisposed() || !awaitingAdapter) return
|
||||
resolveContextWindow(target.current)
|
||||
})
|
||||
resolveContextWindow(target.current)
|
||||
|
||||
const selectModel = (
|
||||
@@ -187,5 +209,8 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
|
||||
clearOverlay(): void {
|
||||
modelOverlay = undefined
|
||||
},
|
||||
detach(): void {
|
||||
disposeAdapterListener()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1563,6 +1563,7 @@ export function createTuiChat(
|
||||
disposeAgent()
|
||||
disposeSchemeListener()
|
||||
disposeTargetListeners()
|
||||
modelController.detach()
|
||||
}
|
||||
|
||||
// Sweep reveal of the whole banner: the header wipes in left-to-right over
|
||||
|
||||
@@ -10,6 +10,7 @@ import AgentRegistry, {
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage,
|
||||
createToolResultMessage,
|
||||
LlmError,
|
||||
ReasoningEffortId,
|
||||
type LlmCallConfig,
|
||||
type LlmModelReasoningInfo,
|
||||
@@ -3630,6 +3631,96 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
await dispose(reasoningFailed)
|
||||
})
|
||||
|
||||
it('defers a NO_ADAPTER context resolution until the provider registers instead of surfacing an error', async () => {
|
||||
// Loader activation order is service-driven: the TUI can mount before a
|
||||
// configured adapter plugin activates, so the initial resolveModelInfo
|
||||
// fails with NO_ADAPTER. That transient state must not print an error;
|
||||
// the resolution retries on llm/adapters-updated.
|
||||
const adapters = new Set<string>()
|
||||
const result = await setup({
|
||||
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },
|
||||
contextTokens: 50_000,
|
||||
catalog: {
|
||||
providers: [],
|
||||
models: [],
|
||||
resolveModelInfo: () => adapters.has('openai-codex')
|
||||
? Promise.resolve({ context: { contextWindow: 100_000 } })
|
||||
: Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')),
|
||||
},
|
||||
})
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('Could not resolve model context')
|
||||
|
||||
// A topology commit that still lacks the route parks the wait again.
|
||||
result.ctx.emit('llm/adapters-updated')
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('% context')
|
||||
expect(result.terminal.output).not.toContain('Could not resolve model context')
|
||||
|
||||
adapters.add('openai-codex')
|
||||
result.ctx.emit('llm/adapters-updated')
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('% context')
|
||||
})
|
||||
expect(result.terminal.output).not.toContain('Could not resolve model context')
|
||||
|
||||
// A commit after satisfaction is a no-op for the resolved value.
|
||||
result.ctx.emit('llm/adapters-updated')
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('Could not resolve model context')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('stops listening for adapter registrations after channel detach', async () => {
|
||||
// The listener disposer rides detachListeners() through the controller's
|
||||
// detach(): after dispose, a registry commit must not re-enter resolution
|
||||
// at all (the isDisposed() guard is a fallback, not the removal).
|
||||
const calls: string[] = []
|
||||
const result = await setup({
|
||||
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },
|
||||
catalog: {
|
||||
providers: [],
|
||||
models: [],
|
||||
resolveModelInfo: (provider) => {
|
||||
calls.push(provider)
|
||||
return Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER'))
|
||||
},
|
||||
},
|
||||
})
|
||||
await tick()
|
||||
const callsAtDetach = calls.length
|
||||
await result.controller.dispose()
|
||||
result.ctx.emit('llm/adapters-updated')
|
||||
await tick()
|
||||
expect(calls.length).toBe(callsAtDetach)
|
||||
await result.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('drops a deferred NO_ADAPTER resolution when the target moved before the adapter registered', async () => {
|
||||
const result = await setup({
|
||||
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },
|
||||
catalog: {
|
||||
providers: [{ id: 'alpha', name: 'Alpha' }],
|
||||
models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }],
|
||||
resolveModelInfo: provider => provider === 'alpha'
|
||||
? Promise.resolve({ context: { contextWindow: 64_000 } })
|
||||
: Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')),
|
||||
},
|
||||
})
|
||||
await tick()
|
||||
// Switching the model re-resolves and clears the deferred wait, so the
|
||||
// stale route's adapter arriving afterwards must be a no-op.
|
||||
result.terminal.send('/model alpha/a1')
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('Model selected: alpha/a1')
|
||||
})
|
||||
result.ctx.emit('llm/adapters-updated')
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('Could not resolve model context')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('does not render a model catalog that resolves after TUI disposal', async () => {
|
||||
const deferred = Promise.withResolvers<never[]>()
|
||||
const result = await setup({
|
||||
|
||||
@@ -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/tool-web/README.md
|
||||
README.md: 7bee0d2d30fbbcf582fd7b60eb5d9130b6bdf888
|
||||
README.zh.md: 3d708839c9ffbdd89df08678fd6997fc6c45ee07
|
||||
README.md: 12f5c806db66b2109888c1ec642d117f3432d0df
|
||||
README.zh.md: cfbf47219f160af706912ca53f85cff535c381ec
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and the UI presentation projection — `presentCall`, `presentResult` (a `card: 'web'` result card discriminated by `kind: 'search' | 'fetch'`), and the `output.presentationMeta` that carries the structured search sources or the fetch summary the lossy render text cannot (see the [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md)). All web access goes through `ctx.web`; this package never imports a concrete provider. Neither tool exposes a model-facing timeout — each tool's cooperative tool-call budget is declared here via config (`fetchTimeoutMs`/`searchTimeoutMs`, attached as `ToolDefinition.timeoutMs`) and enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) (a `tools/execute` wrapper); each tool just forwards `exec.signal` to the seam.
|
||||
|
||||
Each tool is registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`).
|
||||
Each tool is registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`). Search guidance mentions `web_fetch` only when fetch is also config-enabled; a search-only composition instead tells the model to use returned snippets and cite their URLs.
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -47,14 +47,20 @@ The tool never calls a provider's `available()` and never enumerates providers
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Search and fetch contribute the web-search and web-fetch guidance below. A scoped tool restriction does not remove these independently registered sections.
|
||||
Search and fetch contribute the web-search and web-fetch guidance below. Search chooses its fetch-enabled or search-only text from config at registration time. A scoped tool restriction does not remove these independently registered sections.
|
||||
|
||||
##### Web search guidance
|
||||
##### Web search guidance with fetch enabled
|
||||
|
||||
```markdown
|
||||
Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.
|
||||
```
|
||||
|
||||
##### Web search-only guidance
|
||||
|
||||
```markdown
|
||||
Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.
|
||||
```
|
||||
|
||||
##### Web fetch guidance
|
||||
|
||||
```markdown
|
||||
@@ -63,11 +69,11 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed guidance cost per request for each config-enabled tool, even when a restriction hides its schema.
|
||||
Fixed guidance cost per request for each config-enabled tool, even when a restriction hides its schema. Toggling fetch changes the search guidance as well as registering or removing the fetch section.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while enabled tools, scope, and guidance text are unchanged. Config enablement or plugin lifecycle may invalidate reuse from the first changed prompt section; scoped schema restrictions do not remove it.
|
||||
Prefix-stable while enabled tools, scope, and guidance text are unchanged. Config enablement—including toggling fetch's search-guidance branch—or plugin lifecycle may invalidate reuse from the first changed prompt section; scoped schema restrictions do not remove it.
|
||||
|
||||
### Tool schemas
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
面向模型的 web 工具套件 `web_search` 与 `web_fetch`,构建于 [web 能力 seam](../web/README.md)(`ctx.web`)之上。它只负责面向模型的事项:工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 UI 呈现投影——`presentCall`、`presentResult`(以 `kind: 'search' | 'fetch'` 区分的 `card: 'web'` 结果卡片),以及承载有损渲染文本无法携带的结构化搜索来源或抓取摘要的 `output.presentationMeta`(见 [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md))。所有 web 访问都通过 `ctx.web`;该包(package)绝不导入具体提供方。两个工具都不公开面向模型的超时:每个工具的协作式工具调用超时预算通过配置在此声明(`fetchTimeoutMs`/`searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md)(`tools/execute` 包装层)强制执行;每个工具只把 `exec.signal` 转发给 seam。
|
||||
|
||||
每个工具独立注册;只需要其中一个工具的产品可以通过配置禁用另一个(`{ search: false }`/`{ fetch: false }`)。
|
||||
每个工具独立注册;只需要其中一个工具的产品可以通过配置禁用另一个(`{ search: false }`/`{ fetch: false }`)。仅当抓取也通过配置启用时,搜索指引才会提及 `web_fetch`;仅启用搜索的组合则会要求模型使用返回的 snippet 并引用其 URL。
|
||||
|
||||
## 工具
|
||||
|
||||
@@ -47,14 +47,20 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
搜索与抓取分别贡献以下 web-search 和 web-fetch 指引。scope 工具限制不会移除这些独立注册的区段。
|
||||
搜索与抓取分别贡献以下 web-search 和 web-fetch 指引。搜索会在注册时根据配置选用启用抓取或仅搜索的文本。scope 工具限制不会移除这些独立注册的区段。
|
||||
|
||||
##### Web 搜索指引
|
||||
##### 启用抓取时的 Web 搜索指引
|
||||
|
||||
```markdown
|
||||
Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.
|
||||
```
|
||||
|
||||
##### 仅搜索时的 Web 搜索指引
|
||||
|
||||
```markdown
|
||||
Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.
|
||||
```
|
||||
|
||||
##### Web 抓取指引
|
||||
|
||||
```markdown
|
||||
@@ -63,11 +69,11 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每个通过配置启用的工具都会为每次请求增加固定的指引 token 开销,即使限制隐藏了其 schema。
|
||||
每个通过配置启用的工具都会为每次请求增加固定的指引 token 开销,即使限制隐藏了其 schema。切换抓取状态不仅会注册或移除抓取区段,也会更改搜索指引。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只要启用工具、scope 与指引文本不变,前缀就保持稳定。配置启用状态或插件生命周期可能使从第一个变化的提示词区段起的复用失效;scope schema 限制不会移除该区段。
|
||||
只要启用工具、scope 与指引文本不变,前缀就保持稳定。配置启用状态(包括因切换抓取状态而改变搜索指引分支)或插件生命周期可能使从第一个变化的提示词区段起的复用失效;scope schema 限制不会移除该区段。
|
||||
|
||||
### 工具 schema
|
||||
|
||||
|
||||
@@ -84,6 +84,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
assertPositiveInteger('fetchTimeoutMs', resolved.fetchTimeoutMs)
|
||||
assertPositiveInteger('searchTimeoutMs', resolved.searchTimeoutMs)
|
||||
assertPositiveInteger('fetchMaxOutputChars', resolved.fetchMaxOutputChars)
|
||||
if (resolved.search) applyWebSearchTool(ctx, resolved.searchMaxResults, resolved.searchTimeoutMs)
|
||||
if (resolved.search) {
|
||||
applyWebSearchTool(ctx, resolved.searchMaxResults, resolved.searchTimeoutMs, resolved.fetch)
|
||||
}
|
||||
if (resolved.fetch) applyWebFetchTool(ctx, resolved.fetchTimeoutMs, resolved.fetchMaxOutputChars)
|
||||
}
|
||||
|
||||
@@ -204,12 +204,21 @@ export function presentSearchResult(args: { query: string }, result: ToolResult)
|
||||
* request's `maxResults`.
|
||||
* @param timeoutMs - the cooperative tool-call budget (ms) attached as the tool's
|
||||
* `ToolDefinition.timeoutMs` for `@deepseek-ai/dsh-timeout-policy` to enforce.
|
||||
* @param fetchEnabled - whether the same composition exposes `web_fetch`, which
|
||||
* controls whether search guidance may recommend that follow-up tool.
|
||||
*/
|
||||
export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs: number): void {
|
||||
export function applyWebSearchTool(
|
||||
ctx: Context,
|
||||
maxResults: number,
|
||||
timeoutMs: number,
|
||||
fetchEnabled: boolean,
|
||||
): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:web_search',
|
||||
order: 110,
|
||||
text: 'Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.',
|
||||
text: fetchEnabled
|
||||
? 'Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.'
|
||||
: 'Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
|
||||
@@ -483,8 +483,17 @@ describe('tool-web registration', () => {
|
||||
const { fiber, ctx } = await mountTools()
|
||||
const prompt = await ctx.systemPrompt.assemble()
|
||||
const text = prompt.sections.map(s => s.text).join('\n')
|
||||
expect(text).toContain('web_search')
|
||||
expect(text).toContain('web_fetch')
|
||||
expect(text).toContain('Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.')
|
||||
expect(text).toContain('Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL')
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not advertise web_fetch in search-only prompt guidance', async () => {
|
||||
const { fiber, ctx } = await mountTools({ config: { search: true, fetch: false } })
|
||||
const prompt = await ctx.systemPrompt.assemble()
|
||||
const text = prompt.sections.map(s => s.text).join('\n')
|
||||
expect(text).toContain('Use the returned source snippets when available')
|
||||
expect(text).not.toContain('web_fetch')
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/web/web-search-deepseek/README.md
|
||||
README.md: 54eb7561b9d81a9e2da565e3870abe094dbe984d
|
||||
README.zh.md: 8862b8a9c1ba69d247942bb6e41289214826c682
|
||||
README.md: 9046934de209ed0787efa50332e5be16bfdf55c6
|
||||
README.zh.md: 94e01daba69cecd2f5c3c6680979ee5fd66d7cdd
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
A [DeepSeek](https://deepseek.com)-backed `WebSearchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It calls DeepSeek's **Anthropic-compatible Messages API** (`POST {baseURL}/messages`) with the native `web_search_20250305` server tool enabled, and maps the structured `web_search_tool_result` blocks DeepSeek returns into the seam's normalized `WebSearchResult`.
|
||||
|
||||
This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the key and it does not register a model-facing tool. Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`). The Anthropic wire shape is a provider-private detail — it does **not** make this provider depend on `ctx.llm`.
|
||||
This is an **implementation** package: it registers a provider into `ctx.web`, resolves its credential for each search through the optional `ctx.credentials` seam, records the auxiliary request in the initiating Agent session when one exists, and does not register a model-facing tool. Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`). The Anthropic wire shape is a provider-private detail — it does **not** make this provider depend on `ctx.llm`.
|
||||
|
||||
## How it differs from a dedicated search endpoint
|
||||
|
||||
@@ -12,13 +12,14 @@ Exa and Perplexity expose dedicated search endpoints; DeepSeek does not. Instead
|
||||
|
||||
**Strict mode**: if the response carries no `web_search_tool_result` block (native search did not trigger), the provider throws `WebError` `WEB_PROVIDER_ERROR` rather than degrading to prose-scraping — honest and debuggable.
|
||||
|
||||
It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`: the search endpoint is the Anthropic-compatible base (`https://api.deepseek.com/anthropic/v1`), distinct from the chat-completions base (`https://api.deepseek.com`) the LLM adapter uses.
|
||||
It reuses the `DEEPSEEK_API_KEY` credential reference (no new secret) but **not** `$DEEPSEEK_BASE_URL`: the search endpoint is the Anthropic-compatible base (`https://api.deepseek.com/anthropic/v1`), distinct from the chat-completions base (`https://api.deepseek.com`) the LLM adapter uses. A mounted credentials service is authoritative; without one, the provider falls back to the launching process environment. The reference is resolved for each search, so a key stored or rotated by the Web Models page reaches the next call without a restart.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API key. Empty/absent makes the provider unavailable. Sent as both `x-api-key` and `Authorization: Bearer` (official vs Anthropic-compatible proxy). |
|
||||
| `apiKey` | omitted | Literal DeepSeek API key. Prefer `apiKeyEnv` so no secret enters configuration; a non-empty literal wins. |
|
||||
| `apiKeyEnv` | `DEEPSEEK_API_KEY` | Credential reference resolved for each search through `ctx.credentials`, or from the process environment when that seam is absent. A missing value fails the call as `WEB_PROVIDER_CREDENTIAL_MISSING`. |
|
||||
| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes the provider unavailable. |
|
||||
| `model` | `deepseek-v4-flash` | Anthropic-format model name. |
|
||||
| `apiVersion` | `2023-06-01` | `anthropic-version` header value. |
|
||||
@@ -29,7 +30,7 @@ It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`:
|
||||
- id: web-search-deepseek
|
||||
name: '@deepseek-ai/dsh-web-search-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
|
||||
```
|
||||
|
||||
@@ -41,6 +42,10 @@ Results are deduplicated by URL because one request may surface the same page ac
|
||||
|
||||
Provider failures become `WEB_PROVIDER_ERROR`; caller cancellation becomes `WEB_ABORTED`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`.
|
||||
|
||||
## Request logging
|
||||
|
||||
Immediately before dispatch, a search running under an initiating Agent appends the log-only `web/deepseek-search-llm-request` session event. It contains the resolved endpoint, API version, and exact secret-free JSON body sent to DeepSeek; headers and credentials are excluded. Credential failures and cancellations before dispatch create no event, while later HTTP or response failures leave the attempted request durable. Direct programmatic provider calls outside an Agent have no initiating session to log.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Auxiliary DeepSeek search request
|
||||
@@ -61,7 +66,7 @@ Independent of the conversation request cache. The auxiliary instruction and nat
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Through [`dsh-tool-web`](../tool-web/README.md), the conversation model sees deduplicated URLs, titles, dates, and citation snippets from structured search blocks; provider prose is not trusted as an answer. This provider's exact failures are `DeepSeek search aborted`, `DeepSeek search request failed: <error>`, `DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search`, and `DeepSeek returned an unprocessable response body: <error>`; HTTP failures preserve the provider message. The consumer owns the error wrapper.
|
||||
Through [`dsh-tool-web`](../tool-web/README.md), the conversation model sees deduplicated URLs, titles, dates, and citation snippets from structured search blocks; provider prose is not trusted as an answer. This provider's exact failures include the actionable missing-credential message, `DeepSeek search credential resolution failed: <error>`, `DeepSeek search aborted`, `DeepSeek search request failed: <error>`, `DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search`, and `DeepSeek returned an unprocessable response body: <error>`; HTTP failures preserve the provider message. The consumer owns the error wrapper.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -74,6 +79,6 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One search costs a full Messages model turn** — latency plus generated tokens, with up to `maxUses` server-side searches; DeepSeek exposes no dedicated retrieval endpoint.
|
||||
- **Dynamic credential availability resolves inside the operation** — the synchronous `available()` contract can establish that a resolver exists but cannot query an asynchronous credential store. A selected keyless provider therefore fails the search with `WEB_PROVIDER_CREDENTIAL_MISSING`; the stable `web_search` schema remains registered. Caller cancellation races this preflight locally, but cannot force an arbitrary credential backend itself to stop work.
|
||||
- **Over-returned sources still cost tokens** — with no result-count knob on the wire, `maxResults` is enforced only post-hoc by seam truncation.
|
||||
- **Uncited results carry no `snippet`** — a source gains one only when a `text` block citation (`cited_text`) matches its URL.
|
||||
- **Abort classification is error-shape-based** — only a `DOMException` named `AbortError` maps to `WEB_ABORTED`; an abort carrying a custom reason (e.g. `dsh-timeout`'s `TimeoutReason`) surfaces as `WEB_PROVIDER_ERROR`.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
由 [DeepSeek](https://deepseek.com) 支持的 `WebSearchProvider`,用于 harness [web 能力 seam](../web/README.md)(`ctx.web`)。它调用 DeepSeek 的 **Anthropic 兼容 Messages API**(`POST {baseURL}/messages`),启用原生 `web_search_20250305` 服务器工具,并把 DeepSeek 返回的结构化 `web_search_tool_result` 块映射为 seam 规范化的 `WebSearchResult`。
|
||||
|
||||
这是一个**实现**包(package):它向 `ctx.web` 注册提供方,不拥有该键,也不注册面向模型的工具。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`)。Anthropic 协议格式(wire format)是提供方私有细节,并**不**使该提供方依赖 `ctx.llm`。
|
||||
这是一个**实现**包(package):它向 `ctx.web` 注册提供方,通过可选的 `ctx.credentials` seam 为每次搜索解析凭据,若存在发起请求的 agent(智能体)会话,还会在其中记录该辅助请求,且不注册面向模型的工具。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`)。Anthropic 协议格式(wire format)是提供方私有细节,并**不**使该提供方依赖 `ctx.llm`。
|
||||
|
||||
## 与专用搜索端点的区别
|
||||
|
||||
@@ -12,13 +12,14 @@ Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方
|
||||
|
||||
**严格模式**:如果响应不含 `web_search_tool_result` 块(未触发原生搜索),提供方会抛出 `WebError` `WEB_PROVIDER_ERROR`,而非降级为文本抓取;这种行为诚实且可诊断。
|
||||
|
||||
它复用 `$DEEPSEEK_API_KEY`(不增加密钥),但**不会**复用 `$DEEPSEEK_BASE_URL`:搜索端点使用 Anthropic 兼容基址(`https://api.deepseek.com/anthropic/v1`),不同于大语言模型(LLM)适配器使用的 chat-completions 基址(`https://api.deepseek.com`)。
|
||||
它复用 `DEEPSEEK_API_KEY` 凭据引用(不增加密钥),但**不会**复用 `$DEEPSEEK_BASE_URL`:搜索端点使用 Anthropic 兼容基址(`https://api.deepseek.com/anthropic/v1`),不同于大语言模型(LLM)适配器使用的 chat-completions 基址(`https://api.deepseek.com`)。已挂载的凭据服务具有权威性;没有该服务时,提供方会回退到启动进程的环境变量。每次搜索都会解析该引用,因此在 Web 的 Models 页中存储或轮换的密钥无需重启,即可用于下一次调用。
|
||||
|
||||
## 配置
|
||||
|
||||
| 配置键 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API 密钥。为空或缺失时提供方不可用。同时通过 `x-api-key` 和 `Authorization: Bearer` 发送(分别用于官方接口与 Anthropic 兼容代理)。 |
|
||||
| `apiKey` | 未设置 | DeepSeek API 密钥字面值。优先使用 `apiKeyEnv`,避免密钥进入配置;非空字面值优先。 |
|
||||
| `apiKeyEnv` | `DEEPSEEK_API_KEY` | 每次搜索都会通过 `ctx.credentials` 解析该凭据引用;没有该 seam 时则从进程环境解析。值缺失时,调用以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败。 |
|
||||
| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic 兼容端点基址;追加 `/messages`。覆盖时使用 `$DEEPSEEK_SEARCH_BASE_URL` 等独立环境变量;禁止复用属于 chat-completions LLM 适配器的 `$DEEPSEEK_BASE_URL`。无法解析时提供方不可用。 |
|
||||
| `model` | `deepseek-v4-flash` | Anthropic 格式模型名称。 |
|
||||
| `apiVersion` | `2023-06-01` | `anthropic-version` 标头值。 |
|
||||
@@ -29,7 +30,7 @@ Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方
|
||||
- id: web-search-deepseek
|
||||
name: '@deepseek-ai/dsh-web-search-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
|
||||
```
|
||||
|
||||
@@ -41,6 +42,10 @@ DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案
|
||||
|
||||
提供方失败变为 `WEB_PROVIDER_ERROR`;调用方取消变为 `WEB_ABORTED`。HTTP 重定向会在接触 `Location` 目标前被拒绝,并以 `WEB_PROVIDER_ERROR` 呈现。
|
||||
|
||||
## 请求日志
|
||||
|
||||
由 agent 发起的搜索会在发出请求前一刻,向相应会话追加仅用于日志的 `web/deepseek-search-llm-request` 会话事件。其中包含已解析端点、API 版本,以及发送给 DeepSeek 且不含密钥的精确 JSON 请求体;不包含标头和凭据。发出请求前发生凭据处理失败或取消时不会创建事件;发出请求后才发生 HTTP 或响应失败时,本次请求尝试仍保留持久记录。在 agent 之外通过程序直接调用提供方时,没有发起会话可供记录。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 辅助 DeepSeek 搜索请求
|
||||
@@ -61,7 +66,7 @@ DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
通过 [`dsh-tool-web`](../tool-web/README.md),会话模型会看到结构化搜索块中去重后的 URL、标题、日期与引用 snippet;提供方文本不会作为答案受到信任。该提供方的具体错误消息为 `DeepSeek search aborted`、`DeepSeek search request failed: <error>`、`DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search` 和 `DeepSeek returned an unprocessable response body: <error>`;HTTP 失败保留提供方消息。错误包装属于消费方。
|
||||
通过 [`dsh-tool-web`](../tool-web/README.md),会话模型会看到结构化搜索块中去重后的 URL、标题、日期与引用 snippet;提供方文本不会作为答案受到信任。该提供方的具体错误消息包括带有处理指引的凭据缺失消息、`DeepSeek search credential resolution failed: <error>`、`DeepSeek search aborted`、`DeepSeek search request failed: <error>`、`DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search` 和 `DeepSeek returned an unprocessable response body: <error>`;HTTP 失败保留提供方消息。错误包装属于消费方。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -74,6 +79,6 @@ DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **一次搜索需要完整的 Messages 模型轮次**:会产生延迟与生成 token,并且最多执行 `maxUses` 次服务器侧搜索;DeepSeek 不公开专用检索端点。
|
||||
- **动态凭据的可用性在操作内部解析**:同步的 `available()` 契约可以确认解析器存在,但无法查询异步凭据存储。因此,选中的无密钥提供方会使搜索以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败;稳定的 `web_search` schema 仍保持注册。调用方取消在本地与该预检存在竞态,但无法强制任意凭据后端自行停止工作。
|
||||
- **超量返回的源仍消耗 token**:协议没有结果数量旋钮,`maxResults` 只能由 seam 在事后截断。
|
||||
- **未引用的结果没有 `snippet`**:只有 `text` 块中的引用(`cited_text`)匹配其 URL 时,源才会获得 snippet。
|
||||
- **中止分类基于错误结构**:只有 `DOMException` 且名为 `AbortError` 时才映射为 `WEB_ABORTED`;携带自定义原因的中止(例如 `dsh-timeout` 的 `TimeoutReason`)会呈现为 `WEB_PROVIDER_ERROR`。
|
||||
|
||||
@@ -27,7 +27,10 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-credentials": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-web": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
@@ -35,7 +38,11 @@
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-web": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import type {} from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-web'
|
||||
import {
|
||||
DeepSeekSearchProvider,
|
||||
@@ -26,7 +29,7 @@ export {
|
||||
DEEPSEEK_DEFAULT_MODEL,
|
||||
DEEPSEEK_PROVIDER_ID,
|
||||
} from './provider.ts'
|
||||
export type { DeepSeekSearchProviderOptions } from './provider.ts'
|
||||
export type { DeepSeekSearchLlmRequest, DeepSeekSearchProviderOptions } from './provider.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'web-search-deepseek'
|
||||
@@ -34,10 +37,14 @@ export const name = 'web-search-deepseek'
|
||||
/** The web seam this provider registers into. */
|
||||
export const inject = ['web']
|
||||
|
||||
const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY'
|
||||
|
||||
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
|
||||
export interface Config {
|
||||
/** DeepSeek API key. Falls back to `$DEEPSEEK_API_KEY`. Empty → unavailable. */
|
||||
/** Literal DeepSeek API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
|
||||
apiKey?: string
|
||||
/** Credential reference resolved for each search; defaults to `DEEPSEEK_API_KEY`. */
|
||||
apiKeyEnv?: string
|
||||
/** Anthropic-compatible endpoint base; `/messages` is appended. */
|
||||
baseURL?: string
|
||||
/** Anthropic-format model name. Defaults to `deepseek-v4-flash`. */
|
||||
@@ -51,7 +58,8 @@ export interface Config {
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
apiKey: z.string(),
|
||||
apiKey: z.string().role('secret'),
|
||||
apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
|
||||
baseURL: z.string(),
|
||||
model: z.string(),
|
||||
apiVersion: z.string(),
|
||||
@@ -63,12 +71,29 @@ export const Config: z<Config> = z.object({
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const maxTokens = config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS
|
||||
const maxUses = config.maxUses ?? DEEPSEEK_DEFAULT_MAX_USES
|
||||
const apiKeyEnv = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV)
|
||||
const literalApiKey = config.apiKey !== undefined && config.apiKey.length > 0
|
||||
? config.apiKey
|
||||
: undefined
|
||||
ctx.web.registerSearchProvider(new DeepSeekSearchProvider({
|
||||
apiKey: config.apiKey ?? process.env.DEEPSEEK_API_KEY ?? '',
|
||||
...literalApiKey === undefined ? {} : { apiKey: literalApiKey },
|
||||
resolveApiKey: async () => {
|
||||
const credentials = ctx.get('credentials')
|
||||
if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value
|
||||
const ambient = process.env[apiKeyEnv]
|
||||
return ambient !== undefined && ambient.length > 0 ? ambient : undefined
|
||||
},
|
||||
apiKeyEnv,
|
||||
baseURL: config.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL,
|
||||
model: config.model ?? DEEPSEEK_DEFAULT_MODEL,
|
||||
apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION,
|
||||
maxTokens,
|
||||
maxUses,
|
||||
recordRequest: (request) => {
|
||||
ctx.get('agents')?.currentInitiator()?.session.append(
|
||||
'web/deepseek-search-llm-request',
|
||||
request,
|
||||
)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@ export const name = 'web-search-deepseek-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
* No runtime invariant: the package emits a pre-dispatch log event but owns no
|
||||
* later authoritative dispatch event to relate it to. Exact envelope equality
|
||||
* is pinned at the provider boundary instead.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
WebSearchResult,
|
||||
WebSearchSource,
|
||||
} from '@deepseek-ai/dsh-web'
|
||||
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import type {} from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
AnthropicError,
|
||||
AnthropicResponse,
|
||||
@@ -47,10 +49,49 @@ export const DEEPSEEK_DEFAULT_MAX_USES = 5
|
||||
/** Attribution header sent on every request. Bump with the package version. */
|
||||
const USER_AGENT = 'deepseek-harness/0.0.1'
|
||||
|
||||
/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
|
||||
/**
|
||||
* Exact secret-free DeepSeek Messages request recorded immediately before one
|
||||
* auxiliary search dispatch.
|
||||
*/
|
||||
export interface DeepSeekSearchLlmRequest {
|
||||
/** Fully resolved Messages endpoint. */
|
||||
readonly endpoint: string
|
||||
/** `anthropic-version` header value. */
|
||||
readonly apiVersion: string
|
||||
/** Exact JSON body sent to the provider. */
|
||||
readonly body: {
|
||||
readonly model: string
|
||||
readonly max_tokens: number
|
||||
readonly messages: readonly [{
|
||||
readonly role: 'user'
|
||||
readonly content: readonly [{
|
||||
readonly type: 'text'
|
||||
readonly text: string
|
||||
}]
|
||||
}]
|
||||
readonly tools: readonly [{
|
||||
readonly type: 'web_search_20250305'
|
||||
readonly name: 'web_search'
|
||||
readonly max_uses: number
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/** Secret-free auxiliary DeepSeek search request recorded before dispatch. */
|
||||
'web/deepseek-search-llm-request': DeepSeekSearchLlmRequest
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolved provider options (the plugin's `apply` supplies credential and constant defaults). */
|
||||
export interface DeepSeekSearchProviderOptions {
|
||||
/** DeepSeek API key. Empty/absent makes the provider unavailable. */
|
||||
apiKey: string
|
||||
/** Literal DeepSeek API key; when present it wins over {@link resolveApiKey}. */
|
||||
apiKey?: string
|
||||
/** Resolve the current DeepSeek API key for one search operation. */
|
||||
resolveApiKey?: () => Promise<string | undefined>
|
||||
/** Credential reference named by missing-credential diagnostics. */
|
||||
apiKeyEnv?: CredentialRef
|
||||
/** Endpoint base; `/messages` is appended. */
|
||||
baseURL: string
|
||||
/** Anthropic-format model name. */
|
||||
@@ -61,6 +102,11 @@ export interface DeepSeekSearchProviderOptions {
|
||||
maxTokens: number
|
||||
/** Maximum `web_search` server-tool uses per request. */
|
||||
maxUses: number
|
||||
/**
|
||||
* Record the exact secret-free request immediately before dispatch. A throw
|
||||
* prevents dispatch so model-visible auxiliary input cannot escape logging.
|
||||
*/
|
||||
recordRequest?: (request: DeepSeekSearchLlmRequest) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,41 +180,51 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
|
||||
constructor(private readonly options: DeepSeekSearchProviderOptions) {}
|
||||
|
||||
available(): boolean {
|
||||
return this.options.apiKey.length > 0
|
||||
return ((this.options.apiKey?.length ?? 0) > 0 || this.options.resolveApiKey !== undefined)
|
||||
&& URL.canParse(this.options.baseURL)
|
||||
&& isPositiveInteger(this.options.maxTokens)
|
||||
&& isPositiveInteger(this.options.maxUses)
|
||||
}
|
||||
|
||||
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
|
||||
const apiKey = await this.apiKey(signal)
|
||||
throwIfSearchAborted(signal)
|
||||
const endpoint = `${this.options.baseURL}/messages`
|
||||
const body: DeepSeekSearchLlmRequest['body'] = {
|
||||
model: this.options.model,
|
||||
max_tokens: this.options.maxTokens,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: `Perform a web search for the query: ${request.query}` }],
|
||||
}],
|
||||
tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }],
|
||||
}
|
||||
this.options.recordRequest?.({
|
||||
endpoint,
|
||||
apiVersion: this.options.apiVersion,
|
||||
body,
|
||||
})
|
||||
throwIfSearchAborted(signal)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${this.options.baseURL}/messages`, {
|
||||
response = await fetch(endpoint, {
|
||||
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': this.options.apiKey,
|
||||
'authorization': `Bearer ${this.options.apiKey}`,
|
||||
'x-api-key': apiKey,
|
||||
'authorization': `Bearer ${apiKey}`,
|
||||
'anthropic-version': this.options.apiVersion,
|
||||
'content-type': 'application/json',
|
||||
'accept': 'application/json',
|
||||
'user-agent': USER_AGENT,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.options.model,
|
||||
max_tokens: this.options.maxTokens,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: `Perform a web search for the query: ${request.query}` }],
|
||||
}],
|
||||
tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }],
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
...signal !== undefined ? { signal } : {},
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
|
||||
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error)
|
||||
throw new WebError(`DeepSeek search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
|
||||
}
|
||||
|
||||
@@ -183,7 +239,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
|
||||
// An abort fired mid-body must surface as WEB_ABORTED, not be swallowed
|
||||
// into a generic HTTP-error message — cancellation is not a provider
|
||||
// error (the seam's cancellation contract).
|
||||
if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
|
||||
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error)
|
||||
// Otherwise: the HTTP status is already captured in `message` above; a
|
||||
// malformed/non-JSON error body (normal for gateway 5xx/429s) can only
|
||||
// cost a richer provider message, never the real error.
|
||||
@@ -195,11 +251,72 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
|
||||
const payload = await response.json() as AnthropicResponse
|
||||
return mapAnthropicResponse(payload)
|
||||
} catch (error: unknown) {
|
||||
if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
|
||||
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error)
|
||||
if (error instanceof WebError) throw error
|
||||
throw new WebError(`DeepSeek returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve one operation's credential without retaining it on the provider. */
|
||||
private async apiKey(signal?: AbortSignal): Promise<string> {
|
||||
throwIfSearchAborted(signal)
|
||||
if (this.options.apiKey !== undefined && this.options.apiKey.length > 0) return this.options.apiKey
|
||||
let resolved: string | undefined
|
||||
try {
|
||||
resolved = await abortable(this.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 },
|
||||
)
|
||||
}
|
||||
if (resolved !== undefined && resolved.length > 0) return resolved
|
||||
const ref = this.options.apiKeyEnv ?? 'DEEPSEEK_API_KEY'
|
||||
throw new WebError(
|
||||
`DeepSeek search has no API key for "${ref}"; store it through the credentials service`
|
||||
+ ' (the web Models page writes it), export it in the launching environment, or set a literal'
|
||||
+ ' "apiKey" in the web-search-deepseek config',
|
||||
'WEB_PROVIDER_CREDENTIAL_MISSING',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Race a same-process asynchronous preflight against caller cancellation. The
|
||||
* attached settlement handlers keep observing an uncooperative operation after
|
||||
* abort so a later rejection cannot become unhandled.
|
||||
*/
|
||||
function abortable<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (signal === undefined) return operation
|
||||
if (signal.aborted) return Promise.reject(searchAborted(signal))
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = (): void => { reject(searchAborted(signal)) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void operation.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
reject(new Error(String(error).replace(/^Error: /u, ''), { cause: error }))
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** Throw the provider's stable cancellation error when the caller already aborted. */
|
||||
function throwIfSearchAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted === true) throw searchAborted(signal)
|
||||
}
|
||||
|
||||
/** Build the provider's stable cancellation error while retaining the caller's reason. */
|
||||
function searchAborted(signal?: AbortSignal, fallback?: unknown): WebError {
|
||||
return new WebError('DeepSeek search aborted', 'WEB_ABORTED', {
|
||||
cause: signal?.aborted === true ? signal.reason : fallback,
|
||||
})
|
||||
}
|
||||
|
||||
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import CredentialsLocal from '@deepseek-ai/dsh-credentials-local'
|
||||
import WebService from '@deepseek-ai/dsh-web'
|
||||
import {
|
||||
DeepSeekSearchProvider,
|
||||
@@ -156,10 +161,11 @@ describe('DeepSeekSearchProvider availability', () => {
|
||||
})
|
||||
|
||||
describe('DeepSeekSearchProvider request mapping', () => {
|
||||
it('posts an Anthropic Messages request enabling the web_search server tool', async () => {
|
||||
it('records and posts the same Anthropic Messages request with the web_search server tool', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse(searchResponse()))
|
||||
const recordRequest = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await new DeepSeekSearchProvider(options).search({ query: 'hello' })
|
||||
await new DeepSeekSearchProvider({ ...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' })
|
||||
@@ -167,12 +173,20 @@ describe('DeepSeekSearchProvider request mapping', () => {
|
||||
expect(headers['x-api-key']).toBe('ds-key')
|
||||
expect(headers['authorization']).toBe('Bearer ds-key')
|
||||
expect(headers['anthropic-version']).toBe('2023-06-01')
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
const body = {
|
||||
model: 'deepseek-chat',
|
||||
max_tokens: 4096,
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'Perform a web search for the query: hello' }] }],
|
||||
tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 5 }],
|
||||
}
|
||||
expect(JSON.parse(init.body as string)).toEqual(body)
|
||||
expect(recordRequest).toHaveBeenCalledOnce()
|
||||
expect(recordRequest).toHaveBeenCalledWith({
|
||||
endpoint: url,
|
||||
apiVersion: '2023-06-01',
|
||||
body,
|
||||
})
|
||||
expect(recordRequest.mock.invocationCallOrder[0]).toBeLessThan(fetchMock.mock.invocationCallOrder[0] ?? 0)
|
||||
})
|
||||
|
||||
it('forwards the abort signal', async () => {
|
||||
@@ -186,6 +200,91 @@ describe('DeepSeekSearchProvider request mapping', () => {
|
||||
})
|
||||
|
||||
describe('DeepSeekSearchProvider error handling', () => {
|
||||
it('does not start credential resolution or dispatch for a pre-aborted call', async () => {
|
||||
const resolveApiKey = vi.fn(async () => 'late-key')
|
||||
const recordRequest = vi.fn()
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('caller stopped'))
|
||||
await expect(new DeepSeekSearchProvider({
|
||||
...options,
|
||||
apiKey: '',
|
||||
resolveApiKey,
|
||||
recordRequest,
|
||||
}).search({ query: 'q' }, controller.signal))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
|
||||
expect(resolveApiKey).not.toHaveBeenCalled()
|
||||
expect(recordRequest).not.toHaveBeenCalled()
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aborts while an uncooperative credential resolver remains pending', async () => {
|
||||
const resolveApiKey = vi.fn(() => new Promise<string>(() => {}))
|
||||
const recordRequest = vi.fn()
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const controller = new AbortController()
|
||||
const search = new DeepSeekSearchProvider({
|
||||
...options,
|
||||
apiKey: '',
|
||||
resolveApiKey,
|
||||
recordRequest,
|
||||
}).search({ query: 'q' }, controller.signal)
|
||||
controller.abort(new Error('deadline'))
|
||||
await expect(search).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
|
||||
expect(resolveApiKey).toHaveBeenCalledOnce()
|
||||
expect(recordRequest).not.toHaveBeenCalled()
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves credentials under an active cancellation signal', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse(searchResponse()))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const controller = new AbortController()
|
||||
await expect(new DeepSeekSearchProvider({
|
||||
...options,
|
||||
apiKey: '',
|
||||
resolveApiKey: async () => 'resolved-key',
|
||||
}).search({ query: 'q' }, controller.signal)).resolves.toMatchObject({ truncated: false })
|
||||
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect((init.headers as Record<string, string>)['x-api-key']).toBe('resolved-key')
|
||||
})
|
||||
|
||||
it('maps a credential resolver rejection under an active signal to WEB_PROVIDER_ERROR', async () => {
|
||||
const controller = new AbortController()
|
||||
await expect(new DeepSeekSearchProvider({
|
||||
...options,
|
||||
apiKey: '',
|
||||
resolveApiKey: () => Promise.reject(new Error('credential backend failed')),
|
||||
}).search({ query: 'q' }, controller.signal))
|
||||
.rejects.toThrow(expect.objectContaining({
|
||||
code: 'WEB_PROVIDER_ERROR',
|
||||
message: 'DeepSeek search credential resolution failed: Error: credential backend failed',
|
||||
}))
|
||||
})
|
||||
|
||||
it('uses the default credential reference when no resolver is configured', async () => {
|
||||
await expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).search({ query: 'q' }))
|
||||
.rejects.toThrow('DeepSeek search has no API key for "DEEPSEEK_API_KEY"')
|
||||
})
|
||||
|
||||
it('observes cancellation triggered synchronously by credential resolution', async () => {
|
||||
const controller = new AbortController()
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await expect(new DeepSeekSearchProvider({
|
||||
...options,
|
||||
apiKey: '',
|
||||
resolveApiKey: () => {
|
||||
controller.abort(new Error('resolver cancelled caller'))
|
||||
return Promise.resolve('unused-key')
|
||||
},
|
||||
}).search({ query: 'q' }, controller.signal))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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' }))
|
||||
@@ -216,6 +315,17 @@ describe('DeepSeekSearchProvider error handling', () => {
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
|
||||
})
|
||||
|
||||
it('maps a custom abort reason to WEB_ABORTED', async () => {
|
||||
const controller = new AbortController()
|
||||
vi.stubGlobal('fetch', vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) =>
|
||||
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)
|
||||
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' }))
|
||||
@@ -323,28 +433,66 @@ describe('web-search-deepseek plugin registration', () => {
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(deepseekPlugin, {})
|
||||
deepseekPlugin.apply(ctx, {})
|
||||
await ctx.web.search({ query: 'q' })
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(url).toBe('https://api.deepseek.com/anthropic/v1/messages')
|
||||
expect((init.headers as Record<string, string>)['x-api-key']).toBe('env-key')
|
||||
expect(JSON.parse(init.body as string)).toMatchObject({ model: 'deepseek-v4-flash' })
|
||||
await fiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.DEEPSEEK_API_KEY
|
||||
else process.env.DEEPSEEK_API_KEY = prev
|
||||
}
|
||||
})
|
||||
|
||||
it('is unavailable when neither config nor env supplies a key', async () => {
|
||||
it('resolves the credential for each search so a stored or rotated key needs no restart', async () => {
|
||||
const previous = process.env.DEEPSEEK_API_KEY
|
||||
delete process.env.DEEPSEEK_API_KEY
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-web-search-credentials-'))
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => jsonResponse(searchResponse()))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
|
||||
await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
|
||||
await ctx.plugin(deepseekPlugin, { baseURL: 'https://api.deepseek.test/anthropic/v1' })
|
||||
|
||||
await expect(ctx.web.search({ query: 'missing' }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CREDENTIAL_MISSING' }))
|
||||
|
||||
const ref = credentialRef('DEEPSEEK_API_KEY')
|
||||
await ctx.credentials.set(ref, 'stored-key')
|
||||
await ctx.web.search({ query: 'stored' })
|
||||
await ctx.credentials.set(ref, 'rotated-key')
|
||||
await ctx.web.search({ query: 'rotated' })
|
||||
|
||||
const headers = fetchMock.mock.calls.map(([, init]) => (init as RequestInit).headers as Record<string, string>)
|
||||
expect(headers.map(value => value['x-api-key'])).toEqual(['stored-key', 'rotated-key'])
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
if (previous === undefined) delete process.env.DEEPSEEK_API_KEY
|
||||
else process.env.DEEPSEEK_API_KEY = previous
|
||||
}
|
||||
})
|
||||
|
||||
it('reports an actionable credential error when neither config nor env supplies a key', async () => {
|
||||
const prev = process.env.DEEPSEEK_API_KEY
|
||||
delete process.env.DEEPSEEK_API_KEY
|
||||
try {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
|
||||
await ctx.plugin(deepseekPlugin, {})
|
||||
await expect(ctx.web.search({ query: 'q' }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' }))
|
||||
let caught: unknown
|
||||
try {
|
||||
await ctx.web.search({ query: 'q' })
|
||||
} catch (error: unknown) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toMatchObject({ code: 'WEB_PROVIDER_CREDENTIAL_MISSING' })
|
||||
if (!(caught instanceof Error)) throw new Error('search did not throw an Error')
|
||||
expect(caught.message).toMatch(/store it through the credentials service.*Models page/s)
|
||||
} finally {
|
||||
if (prev !== undefined) process.env.DEEPSEEK_API_KEY = prev
|
||||
}
|
||||
|
||||
@@ -20,6 +20,15 @@
|
||||
{
|
||||
"path": "../web"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/credentials"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user