feat(web): enable default search and fetch

This commit is contained in:
kingwl
2026-07-31 12:58:06 +08:00
parent c0b2b20b2f
commit 902926c7bb
26 changed files with 534 additions and 32 deletions

View File

@@ -7,6 +7,7 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import type {} from '@deepseek-ai/dsh-web'
import {
DeepSeekSearchProvider,
@@ -34,10 +35,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 +56,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,8 +69,19 @@ 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,

View File

@@ -13,6 +13,7 @@ import type {
WebSearchResult,
WebSearchSource,
} from '@deepseek-ai/dsh-web'
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import type {
AnthropicError,
AnthropicResponse,
@@ -47,10 +48,14 @@ 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). */
/** 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. */
@@ -134,13 +139,14 @@ 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()
let response: Response
try {
response = await fetch(`${this.options.baseURL}/messages`, {
@@ -149,8 +155,8 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
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',
@@ -200,6 +206,29 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
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> {
if (this.options.apiKey !== undefined && this.options.apiKey.length > 0) return this.options.apiKey
let resolved: string | undefined
try {
resolved = await this.options.resolveApiKey?.()
} catch (error: unknown) {
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',
)
}
}
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */