fix(web): narrow default search review scope
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/web/web-search-deepseek/README.md
|
||||
README.md: a61b28d0c78b48a173f76fe7601a53a2eefef7f7
|
||||
README.zh.md: 1b403ecaaaa6e76a3c6be17c6a8dea27bd2c522b
|
||||
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`, resolves its credential for each search through the optional `ctx.credentials` seam, and does not register a model-facing tool. Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`). The Anthropic wire shape is a provider-private detail — it does **not** make this provider depend on `ctx.llm`.
|
||||
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
|
||||
|
||||
@@ -42,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
|
||||
@@ -75,7 +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.
|
||||
- **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` 注册提供方,通过可选的 `ctx.credentials` seam 为每次搜索解析凭据,且不注册面向模型的工具。与 `@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`。
|
||||
|
||||
## 与专用搜索端点的区别
|
||||
|
||||
@@ -42,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 搜索请求
|
||||
@@ -75,7 +79,6 @@ DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **一次搜索需要完整的 Messages 模型轮次**:会产生延迟与生成 token,并且最多执行 `maxUses` 次服务器侧搜索;DeepSeek 不公开专用检索端点。
|
||||
- **动态凭据的可用性在操作内部解析**:同步的 `available()` 契约可以确认解析器存在,但无法查询异步凭据存储。因此,选中的无密钥提供方会使搜索以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败;稳定的 `web_search` schema 仍保持注册。
|
||||
- **动态凭据的可用性在操作内部解析**:同步的 `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,8 +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"
|
||||
},
|
||||
@@ -36,9 +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,7 +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,
|
||||
@@ -27,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'
|
||||
@@ -87,5 +89,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
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 = () => {}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
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,
|
||||
@@ -48,6 +49,41 @@ 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'
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
/** Literal DeepSeek API key; when present it wins over {@link resolveApiKey}. */
|
||||
@@ -66,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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,10 +187,27 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
|
||||
}
|
||||
|
||||
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
|
||||
const apiKey = await this.apiKey()
|
||||
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: {
|
||||
@@ -162,19 +220,11 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
|
||||
'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 })
|
||||
}
|
||||
|
||||
@@ -189,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.
|
||||
@@ -201,19 +251,21 @@ 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(): Promise<string> {
|
||||
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 this.options.resolveApiKey?.()
|
||||
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',
|
||||
@@ -231,6 +283,42 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`. */
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === 'AbortError'
|
||||
|
||||
@@ -161,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' })
|
||||
@@ -172,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 () => {
|
||||
@@ -191,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' }))
|
||||
@@ -221,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' }))
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
{
|
||||
"path": "../web"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/credentials"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user