feat(web-search-deepseek): resolve provider options from the settings section

The provider now takes a thunk rather than a value: it projects the
authoritative section per search, so a stored endpoint, model, or key
reference reaches the next call without re-registering the provider — which
would make the seam's provider selection observable as a flicker.

apiKey already carries role('secret'), so the section is safe to describe:
the literal never rides a response in any layer and a configuration surface
learns only that a key is set.
This commit is contained in:
Yichen Jiang
2026-08-10 18:06:27 +08:00
parent fdf9bddbf8
commit f736e6f584
17 changed files with 236 additions and 45 deletions

View File

@@ -9,6 +9,7 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-agent'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { environmentOf } from '@deepseek-ai/dsh-environment'
import type {} from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-web'
@@ -20,6 +21,7 @@ import {
DEEPSEEK_DEFAULT_MAX_USES,
DEEPSEEK_DEFAULT_MODEL,
} from './provider.ts'
import type { DeepSeekSearchProviderOptions } from './provider.ts'
export {
DeepSeekSearchProvider,
@@ -76,15 +78,23 @@ export const Config: z<Config> = z.object({
*/
const SEARCH_BASE_URL_ENV = 'DEEPSEEK_SEARCH_BASE_URL'
/** Register the DeepSeek search provider with `ctx.web`. */
export function apply(ctx: Context, config: Config): void {
const maxTokens = config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS
const maxUses = config.maxUses ?? DEEPSEEK_DEFAULT_MAX_USES
/** Settings namespace carrying this provider's endpoint, model, and key reference. */
export const WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE = settingsNamespace('web-search-deepseek')
/**
* Project one resolved section into the options the provider serves its next
* search with. Environment fallbacks stay here rather than in the provider:
* every value it reads is already fully defaulted.
* @param ctx - plugin context supplying the credential and environment planes.
* @param config - the currently authoritative section.
* @returns options for one search.
*/
function resolveOptions(ctx: Context, config: Config): DeepSeekSearchProviderOptions {
const apiKeyEnv = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV)
const literalApiKey = config.apiKey !== undefined && config.apiKey.length > 0
? config.apiKey
: undefined
ctx.web.registerSearchProvider(new DeepSeekSearchProvider({
return {
...literalApiKey === undefined ? {} : { apiKey: literalApiKey },
resolveApiKey: async () => {
const credentials = ctx.get('credentials')
@@ -99,13 +109,27 @@ export function apply(ctx: Context, config: Config): void {
?? DEEPSEEK_DEFAULT_BASE_URL,
model: config.model ?? DEEPSEEK_DEFAULT_MODEL,
apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION,
maxTokens,
maxUses,
maxTokens: config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS,
maxUses: config.maxUses ?? DEEPSEEK_DEFAULT_MAX_USES,
recordRequest: (request) => {
ctx.get('agents')?.currentInitiator()?.session.append(
'web/deepseek-search-llm-request',
request,
)
},
}))
}
}
/** Register the DeepSeek search provider with `ctx.web`. */
export function apply(ctx: Context, config: Config): void {
let current: () => Config = () => config
installSettingsSection(ctx, WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE, Config, config, {
setSource: (source) => {
current = source
},
// The registration carries no resolved value: the provider projects the
// section per search, so a committed change needs no re-registration.
onChange: () => {},
})
ctx.web.registerSearchProvider(new DeepSeekSearchProvider(() => resolveOptions(ctx, current())))
}

View File

@@ -177,7 +177,18 @@ export function mapAnthropicResponse(response: AnthropicResponse): WebSearchResu
export class DeepSeekSearchProvider implements WebSearchProvider {
readonly id = DEEPSEEK_PROVIDER_ID
constructor(private readonly options: DeepSeekSearchProviderOptions) {}
/**
* @param resolveOptions - the options for the NEXT operation. A thunk rather
* than a value because the plugin's settings section can change between
* searches, and re-registering the provider to carry a new endpoint would
* make the seam's selection observable to the user as a flicker.
*/
constructor(private readonly resolveOptions: () => DeepSeekSearchProviderOptions) {}
/** Options resolved per read, so a committed settings change reaches the next search. */
private get options(): DeepSeekSearchProviderOptions {
return this.resolveOptions()
}
available(): boolean {
return ((this.options.apiKey?.length ?? 0) > 0 || this.options.resolveApiKey !== undefined)