fix(web-plugin-config): address review — one options snapshot per search, no public value exports
Three findings from review survived against the staged-save head: The search provider read its options thunk per property, so a settings write landing inside credential resolution sent the key resolved from the old section to the endpoint named by the new one. Each operation now snapshots once at its entry and threads that snapshot into credential resolution; a regression test drives a commit into the middle of a search and pins that the endpoint, model, and key all come from the section the search started on. The /client entry exported components, controllers, and namespace constants with no consumer, which the client export discipline allows only with sign-off. Only types remain. The duplicate per-card Injected/Face interface pairs are one declaration each now, so a member added to one side cannot silently miss the other. The credential state carries the reference it describes and its writability: a reference change no longer projects the old answer onto the new name, an out-of-order response for a stale reference is dropped, and a key that a deployment sources from the process environment disables the control instead of inviting a write the Host must refuse. Also corrected three prose claims against the code they describe: the card's fields do not differ by platform (the served schema does), the section's empty line counts registered rather than visible cards and is read once, and the search README overstated what a configuration surface learns about a key.
This commit is contained in:
@@ -178,41 +178,42 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
|
||||
readonly id = DEEPSEEK_PROVIDER_ID
|
||||
|
||||
/**
|
||||
* @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.
|
||||
* @param resolveOptions - the options for the NEXT operation, snapshotted
|
||||
* once at each operation's entry so one search never mixes two sections. 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)
|
||||
&& URL.canParse(this.options.baseURL)
|
||||
&& isPositiveInteger(this.options.maxTokens)
|
||||
&& isPositiveInteger(this.options.maxUses)
|
||||
const options = this.resolveOptions()
|
||||
return ((options.apiKey?.length ?? 0) > 0 || options.resolveApiKey !== undefined)
|
||||
&& URL.canParse(options.baseURL)
|
||||
&& isPositiveInteger(options.maxTokens)
|
||||
&& isPositiveInteger(options.maxUses)
|
||||
}
|
||||
|
||||
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
|
||||
const apiKey = await this.apiKey(signal)
|
||||
// One snapshot for the whole operation: credential resolution awaits, and a
|
||||
// settings write landing inside that await must not send the key resolved
|
||||
// from the old section to the endpoint named by the new one.
|
||||
const options = this.resolveOptions()
|
||||
const apiKey = await this.apiKey(options, signal)
|
||||
throwIfSearchAborted(signal)
|
||||
const endpoint = `${this.options.baseURL}/messages`
|
||||
const endpoint = `${options.baseURL}/messages`
|
||||
const body: DeepSeekSearchLlmRequest['body'] = {
|
||||
model: this.options.model,
|
||||
max_tokens: this.options.maxTokens,
|
||||
model: options.model,
|
||||
max_tokens: 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 }],
|
||||
tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: options.maxUses }],
|
||||
}
|
||||
this.options.recordRequest?.({
|
||||
options.recordRequest?.({
|
||||
endpoint,
|
||||
apiVersion: this.options.apiVersion,
|
||||
apiVersion: options.apiVersion,
|
||||
body,
|
||||
})
|
||||
throwIfSearchAborted(signal)
|
||||
@@ -226,7 +227,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
|
||||
// may expect `Authorization: Bearer` — send both so either resolves.
|
||||
'x-api-key': apiKey,
|
||||
'authorization': `Bearer ${apiKey}`,
|
||||
'anthropic-version': this.options.apiVersion,
|
||||
'anthropic-version': options.apiVersion,
|
||||
'content-type': 'application/json',
|
||||
'accept': 'application/json',
|
||||
'user-agent': USER_AGENT,
|
||||
@@ -268,13 +269,18 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve one operation's credential without retaining it on the provider. */
|
||||
private async apiKey(signal?: AbortSignal): Promise<string> {
|
||||
/**
|
||||
* Resolve one operation's credential without retaining it on the provider.
|
||||
* @param options - the caller's snapshot, so the key and the endpoint it is sent to come from one section.
|
||||
* @param signal - abort signal for the surrounding search.
|
||||
* @returns the resolved key.
|
||||
*/
|
||||
private async apiKey(options: DeepSeekSearchProviderOptions, signal?: AbortSignal): Promise<string> {
|
||||
throwIfSearchAborted(signal)
|
||||
if (this.options.apiKey !== undefined && this.options.apiKey.length > 0) return this.options.apiKey
|
||||
if (options.apiKey !== undefined && options.apiKey.length > 0) return options.apiKey
|
||||
let resolved: string | undefined
|
||||
try {
|
||||
resolved = await abortable(this.options.resolveApiKey?.() ?? Promise.resolve(undefined), signal)
|
||||
resolved = await abortable(options.resolveApiKey?.() ?? Promise.resolve(undefined), signal)
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error)
|
||||
throw new WebError(
|
||||
@@ -284,7 +290,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
|
||||
)
|
||||
}
|
||||
if (resolved !== undefined && resolved.length > 0) return resolved
|
||||
const ref = this.options.apiKeyEnv ?? 'DEEPSEEK_API_KEY'
|
||||
const ref = 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'
|
||||
|
||||
Reference in New Issue
Block a user