fix(web): narrow default search review scope
This commit is contained in:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user