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:
@@ -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/client/ui-plugin-config/README.md
|
||||
README.md: 51d86e8fbfd19d3d37f68650163180751fb27061
|
||||
README.zh.md: 48a68900a20aaaabcc9763b5aa61bc23cf3d16d8
|
||||
README.md: 9297379a940d004acc27e2dcb1bb879fa2142f30
|
||||
README.zh.md: 2b3d73bc6e1ccd72cad9fe86acb357c2ab269a41
|
||||
|
||||
@@ -34,4 +34,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
- **Only host-plane plugins appear** — a plugin an agent preset mounts carries its configuration inline in that preset's `agent.cordis.yml` and cannot register a settings namespace at all (a second session mounting the same preset would fail on a duplicate registration), so this section lists nothing for it. Editing those values remains the preset editor's job.
|
||||
- **Exposure is a Host allowlist, not a plugin declaration** — a namespace absent from the api-proxy's allowlist answers `settings-not-exposed` even when its owner registered it, so a plugin distributed outside this repository cannot surface its own configuration here without a change in `packages/host/apiproxy`.
|
||||
- **The shell card follows the composed executor** — the POSIX and PowerShell executor families share the `bash` namespace because a host composes exactly one of them, so the card's fields differ by platform and a deployment composing neither shows no card.
|
||||
- **The shell card follows the composed executor** — the POSIX and PowerShell executor families share the `bash` namespace because a host composes exactly one of them, so the served schema differs by platform (PowerShell adds `pwshPath`) even though the card edits the same two fields on both, and a deployment composing neither shows no card.
|
||||
- **The empty line counts registered cards, not visible ones** — a card whose namespace this deployment does not expose renders nothing, but still counts, so a deployment that exposes none shows an empty list rather than the empty line. The count is also read once, because the renderer caches a root entry's inject face; a card registered later does not raise it.
|
||||
|
||||
@@ -34,4 +34,5 @@
|
||||
|
||||
- **只有宿主平面的插件会出现**——由 agent preset 挂载的插件把配置内联在该 preset 的 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间(同一 preset 挂载第二个会话时会因重复注册而失败),因此本分区不会列出它。编辑那些值仍是 preset 编辑器的职责。
|
||||
- **暴露是 Host 的白名单,而非插件的声明**——不在 api-proxy 白名单中的命名空间,即便其拥有方已注册,也只会得到 `settings-not-exposed`,因此在本仓库之外分发的插件无法在不改动 `packages/host/apiproxy` 的前提下让自己的配置出现在这里。
|
||||
- **shell 卡片跟随被组装的执行器**——POSIX 与 PowerShell 两个执行器家族共用 `bash` 命名空间,因为一个宿主只组装其中之一,所以该卡片的字段随平台不同,而两者都不组装的部署不会显示这张卡片。
|
||||
- **shell 卡片跟随被组装的执行器**——POSIX 与 PowerShell 两个执行器家族共用 `bash` 命名空间,因为一个宿主只组装其中之一,所以被服务的 schema 随平台不同(PowerShell 多出 `pwshPath`),尽管卡片在两者下编辑的都是同样两个字段;而两者都不组装的部署不会显示这张卡片。
|
||||
- **空态数的是已注册卡片,不是可见卡片**——命名空间未被本部署暴露的卡片什么都不渲染,但仍计入数量,因此一个都不暴露的部署看到的是空列表而非那行空态文案。该计数还只读取一次,因为渲染器会缓存根级 entry 的 inject face;之后注册的卡片不会让它变大。
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
/** The agent loop's card: how many tool calls one step may run at once. */
|
||||
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { ValueField } from './fields.tsx'
|
||||
import { PluginCard } from './PluginCard.tsx'
|
||||
import type { CardActions } from './card-store.ts'
|
||||
import type { AgentLoopCardState } from './agent-loop-store.ts'
|
||||
import type { AgentLoopCardFace } from './agent-loop-store.ts'
|
||||
import type {} from './slot-contract.ts'
|
||||
|
||||
/** Registration-side business face for the agent-loop card. */
|
||||
export interface AgentLoopCardInjected extends CardActions {
|
||||
hooks: {
|
||||
/** Card snapshot bound by the renderer as useAgentLoopCard. */
|
||||
agentLoopCard: SnapshotStore<AgentLoopCardState>
|
||||
}
|
||||
}
|
||||
|
||||
/** Props the renderer binds for the agent-loop card. */
|
||||
export type AgentLoopCardProps =
|
||||
PropsRuntime<'settings.plugin.item'>
|
||||
& PropsLocale<'settings.pluginConfig'>
|
||||
& InjectFace<AgentLoopCardInjected>
|
||||
& InjectFace<AgentLoopCardFace>
|
||||
|
||||
/**
|
||||
* Render the agent-loop card.
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
/** The shell plugin's card: the limits every command the agent runs is bound by. */
|
||||
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { ValueField } from './fields.tsx'
|
||||
import { PluginCard } from './PluginCard.tsx'
|
||||
import type { CardActions } from './card-store.ts'
|
||||
import type { BashCardState } from './bash-store.ts'
|
||||
import type { BashCardFace } from './bash-store.ts'
|
||||
import type {} from './slot-contract.ts'
|
||||
|
||||
/** Registration-side business face for the shell card. */
|
||||
export interface BashCardInjected extends CardActions {
|
||||
hooks: {
|
||||
/** Card snapshot bound by the renderer as useBashCard. */
|
||||
bashCard: SnapshotStore<BashCardState>
|
||||
}
|
||||
}
|
||||
|
||||
/** Props the renderer binds for the shell card. */
|
||||
export type BashCardProps =
|
||||
PropsRuntime<'settings.plugin.item'>
|
||||
& PropsLocale<'settings.pluginConfig'>
|
||||
& InjectFace<BashCardInjected>
|
||||
& InjectFace<BashCardFace>
|
||||
|
||||
/**
|
||||
* Render the shell card.
|
||||
|
||||
@@ -4,27 +4,17 @@
|
||||
* the settings section, so the literal never rides a response.
|
||||
*/
|
||||
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SecretField, ValueField } from './fields.tsx'
|
||||
import { PluginCard } from './PluginCard.tsx'
|
||||
import type { CardActions } from './card-store.ts'
|
||||
import type { WebSearchCardState } from './web-search-store.ts'
|
||||
import type { WebSearchCardFace } from './web-search-store.ts'
|
||||
import type {} from './slot-contract.ts'
|
||||
|
||||
/** Registration-side business face for the web-search card. */
|
||||
export interface WebSearchCardInjected extends CardActions {
|
||||
hooks: {
|
||||
/** Card snapshot bound by the renderer as useWebSearchCard. */
|
||||
webSearchCard: SnapshotStore<WebSearchCardState>
|
||||
}
|
||||
}
|
||||
|
||||
/** Props the renderer binds for the web-search card. */
|
||||
export type WebSearchCardProps =
|
||||
PropsRuntime<'settings.plugin.item'>
|
||||
& PropsLocale<'settings.pluginConfig'>
|
||||
& InjectFace<WebSearchCardInjected>
|
||||
& InjectFace<WebSearchCardFace>
|
||||
|
||||
/**
|
||||
* Render the web-search card.
|
||||
@@ -50,7 +40,9 @@ export function WebSearchCard(props: WebSearchCardProps) {
|
||||
hint={t('webSearchApiKeyHint')}
|
||||
// The credentials domain accepts a key even when the settings document
|
||||
// itself is read-only; they are separate stores with separate refusals.
|
||||
disabled={false}
|
||||
// Its own writability is what disables this control — a key sourced
|
||||
// from the process environment cannot be written from here.
|
||||
disabled={!state.apiKeyWritable}
|
||||
text={state.apiKey.text}
|
||||
configured={state.apiKeyConfigured}
|
||||
stateLabel={state.apiKeyConfigured ? t('webSearchApiKeySet') : t('webSearchApiKeyUnset')}
|
||||
|
||||
@@ -28,14 +28,13 @@ import { en, zh } from './locales.ts'
|
||||
export type { PluginConfigSectionInjected, PluginConfigSectionProps } from './PluginConfigSection.tsx'
|
||||
export type { PluginCardProps } from './PluginCard.tsx'
|
||||
export type { SettingsPluginItemOwnerProps } from './slot-contract.ts'
|
||||
export { SecretField, ValueField, type FieldProps } from './fields.tsx'
|
||||
export {
|
||||
CardForm, numberField, textField,
|
||||
type CardActions, type CardFieldSpec, type CardFieldState, type CardSecretSpec, type CardShell,
|
||||
export type { FieldProps } from './fields.tsx'
|
||||
export type {
|
||||
CardActions, CardFieldSpec, CardFieldState, CardSecretSpec, CardShell,
|
||||
} from './card-store.ts'
|
||||
export { AGENT_LOOP_NS, AgentLoopCardController, type AgentLoopCardState } from './agent-loop-store.ts'
|
||||
export { BASH_NS, BashCardController, type BashCardState } from './bash-store.ts'
|
||||
export { WEB_SEARCH_NS, WebSearchCardController, type WebSearchCardState } from './web-search-store.ts'
|
||||
export type { AgentLoopCardFace, AgentLoopCardState } from './agent-loop-store.ts'
|
||||
export type { BashCardFace, BashCardState } from './bash-store.ts'
|
||||
export type { WebSearchCardFace, WebSearchCardState } from './web-search-store.ts'
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
const NS = 'settings.pluginConfig'
|
||||
@@ -56,9 +55,13 @@ export function apply(ctx: ClientContext): void {
|
||||
const agentLoop = new AgentLoopCardController(bindSettingsScope(ctx, { namespace: AGENT_LOOP_NS }))
|
||||
const webSearch = new WebSearchCardController(bindSettingsScope(ctx, { namespace: WEB_SEARCH_NS }), api)
|
||||
|
||||
// The section renders the empty line rather than an empty list when no card
|
||||
// is registered; the ledger is read at render time so a card arriving later
|
||||
// (or leaving with its plugin) is reflected without the section subscribing.
|
||||
// The section renders the empty line rather than an empty list when no plugin
|
||||
// contributed a card. The count is read once: the renderer caches a root
|
||||
// entry's inject face per registration, so this reports what was registered
|
||||
// when the section mounted, not what is visible now. Both gaps are bounded by
|
||||
// this deployment always registering the three cards below — a card that
|
||||
// arrives later would not raise the count, and a namespace this deployment
|
||||
// does not expose leaves its card rendering nothing inside a non-empty list.
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'plugins',
|
||||
|
||||
@@ -38,6 +38,16 @@ export interface WebSearchSettings {
|
||||
maxUses?: number
|
||||
}
|
||||
|
||||
/** What the credentials domain last reported, and for which reference. */
|
||||
interface CredentialState {
|
||||
/** Reference this answer describes; a stale response for another one is dropped. */
|
||||
ref: string
|
||||
/** Whether any layer supplies a value for it. */
|
||||
configured: boolean
|
||||
/** Whether `credentials.set` can affect it; false disables the control. */
|
||||
writable: boolean
|
||||
}
|
||||
|
||||
/** What the web-search card renders. */
|
||||
export interface WebSearchCardState extends CardShell {
|
||||
/** Provider endpoint. */
|
||||
@@ -48,6 +58,8 @@ export interface WebSearchCardState extends CardShell {
|
||||
apiKey: CardFieldState
|
||||
/** Whether the Host reports a credential configured for the referenced key. */
|
||||
apiKeyConfigured: boolean
|
||||
/** Whether the credentials domain accepts a write for it; false disables the control. */
|
||||
apiKeyWritable: boolean
|
||||
}
|
||||
|
||||
/** The registration-side face the web-search card's slot entry injects. */
|
||||
@@ -62,7 +74,7 @@ export interface WebSearchCardFace extends CardActions {
|
||||
export class WebSearchCardController {
|
||||
private readonly form: CardForm<WebSearchSettings>
|
||||
private readonly store: SnapshotStore<WebSearchCardState>
|
||||
private configured = false
|
||||
private credential: CredentialState = { ref: '', configured: false, writable: true }
|
||||
|
||||
/**
|
||||
* @param scope - the bound settings scope for the `web-search-deepseek` namespace.
|
||||
@@ -88,13 +100,27 @@ export class WebSearchCardController {
|
||||
baseURL: this.form.field('baseURL'),
|
||||
maxUses: this.form.field('maxUses'),
|
||||
apiKey: this.form.field(API_KEY_FIELD),
|
||||
apiKeyConfigured: this.configured,
|
||||
apiKeyConfigured: this.credential.configured,
|
||||
apiKeyWritable: this.credential.writable,
|
||||
}
|
||||
}
|
||||
|
||||
/** Ask the credentials domain whether the referenced key exists. */
|
||||
/**
|
||||
* Ask the credentials domain about the reference the section currently names.
|
||||
*
|
||||
* The answer is stored with the reference it describes: `apiKeyEnv` can
|
||||
* change between the request and its response, and two reads can settle out
|
||||
* of order, so a response is published only while it still answers for the
|
||||
* reference in force.
|
||||
*/
|
||||
private async readCredential(): Promise<void> {
|
||||
const ref = refOf(this.scope.getSnapshot())
|
||||
if (ref !== this.credential.ref) {
|
||||
// A new reference knows nothing yet; keeping the old answer would claim
|
||||
// the key is configured under a name nobody has checked.
|
||||
this.credential = { ref, configured: false, writable: true }
|
||||
this.store.set(this.projection())
|
||||
}
|
||||
let response: Awaited<ReturnType<IApiClient['credentials']['describe']>>
|
||||
try {
|
||||
response = await this.api.credentials.describe({ refs: [ref] })
|
||||
@@ -103,10 +129,17 @@ export class WebSearchCardController {
|
||||
// last state it knew, and a write still reaches the Host.
|
||||
return
|
||||
}
|
||||
if (!response.result.ok) return
|
||||
const next = response.result.value.credentials[ref]?.configured ?? false
|
||||
if (next === this.configured) return
|
||||
this.configured = next
|
||||
if (!response.result.ok || ref !== refOf(this.scope.getSnapshot())) return
|
||||
const view = response.result.value.credentials[ref]
|
||||
const next: CredentialState = {
|
||||
ref,
|
||||
configured: view?.configured ?? false,
|
||||
// An unknown reference is treated as writable: the control stays usable
|
||||
// and the Host is what refuses, rather than the card guessing a refusal.
|
||||
writable: view?.writable ?? true,
|
||||
}
|
||||
if (next.configured === this.credential.configured && next.writable === this.credential.writable) return
|
||||
this.credential = next
|
||||
this.store.set(this.projection())
|
||||
}
|
||||
|
||||
@@ -131,7 +164,7 @@ export class WebSearchCardController {
|
||||
// authority on whether the key now exists.
|
||||
}
|
||||
await this.readCredential()
|
||||
return this.configured
|
||||
return this.credential.configured
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -263,6 +263,7 @@ describe('WebSearchCard', () => {
|
||||
maxUses: field('5'),
|
||||
apiKey: field(''),
|
||||
apiKeyConfigured: false,
|
||||
apiKeyWritable: true,
|
||||
...state,
|
||||
})
|
||||
const actions = cardActions()
|
||||
@@ -292,6 +293,16 @@ describe('WebSearchCard', () => {
|
||||
expect(actions.edit).toHaveBeenCalledWith('apiKey', 'ds-secret')
|
||||
})
|
||||
|
||||
it('disables the key control when the reference itself is not writable', () => {
|
||||
// A key coming from the process environment: the settings document is
|
||||
// writable, the credential is not.
|
||||
renderWebSearch({ apiKeyConfigured: true, apiKeyWritable: false })
|
||||
fireEvent.click(screen.getByText(en.webSearchTitle))
|
||||
|
||||
expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('disabled', true)
|
||||
expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', false)
|
||||
})
|
||||
|
||||
it('stages the endpoint, the search budget, and their resets', () => {
|
||||
const actions = renderWebSearch({
|
||||
baseURL: field('https://search.test/v1', { overridden: true }),
|
||||
|
||||
@@ -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: 3e8b631b5775793e6e6e542c810edce9d92a0d59
|
||||
README.zh.md: 9bd59000c2a0054265444aa90fc46ff5f34fd607
|
||||
README.md: 1e90f947b4a58288722307aaed50c4889cfe7cb4
|
||||
README.zh.md: 21962bcd390f253f899000be7273a79485821018
|
||||
|
||||
@@ -34,7 +34,7 @@ It reuses the `DEEPSEEK_API_KEY` credential reference (no new secret) but **not*
|
||||
baseURL: https://gateway.internal/anthropic/v1
|
||||
```
|
||||
|
||||
The entry above is the base layer of the `web-search-deepseek` Settings section: a user layer over it reaches the NEXT search, because the provider projects the section per call rather than capturing it at registration. The seam's provider selection therefore never flickers when an endpoint or model changes. `apiKey` carries `role('secret')`, so it never rides a `describe()` response in any layer — a configuration surface learns only that a key is set.
|
||||
The entry above is the base layer of the `web-search-deepseek` Settings section: a user layer over it reaches the NEXT search, because the provider projects the section per call rather than capturing it at registration. The seam's provider selection therefore never flickers when an endpoint or model changes. `apiKey` carries `role('secret')`, so it never rides a `describe()` response in any layer — a configuration surface learns only whether the credentials domain holds a value for the reference `apiKeyEnv` names, never whether a layer carries a literal key.
|
||||
|
||||
## Mapping
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方
|
||||
baseURL: https://gateway.internal/anthropic/v1
|
||||
```
|
||||
|
||||
上面的条目是 `web-search-deepseek` Settings 段的 base 层:叠加其上的用户层会作用于**下一次**搜索,因为提供方是按次投影该段,而不是在注册时固化它。因此端点或模型变化时,seam 的提供方选择不会闪断。`apiKey` 带有 `role('secret')`,所以它在任何一层都不会出现在 `describe()` 响应中——配置表层只能知道密钥是否已设置。
|
||||
上面的条目是 `web-search-deepseek` Settings 段的 base 层:叠加其上的用户层会作用于**下一次**搜索,因为提供方是按次投影该段,而不是在注册时固化它。因此端点或模型变化时,seam 的提供方选择不会闪断。`apiKey` 带有 `role('secret')`,所以它在任何一层都不会出现在 `describe()` 响应中——配置表层只能知道 credentials 领域是否为 `apiKeyEnv` 所命名的引用持有值,而无从知道某一层是否带着字面密钥。
|
||||
|
||||
## 映射
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -205,6 +205,34 @@ describe('DeepSeekSearchProvider request mapping', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('DeepSeekSearchProvider settings changes mid-search', () => {
|
||||
it('serves one search from one section even when settings land during credential resolution', async () => {
|
||||
// The section the search starts on, and the one a user commits while the
|
||||
// credential is still resolving.
|
||||
const before = { ...options, apiKey: '', baseURL: 'https://before.test/v1', model: 'model-before', maxUses: 2 }
|
||||
const after = { ...options, apiKey: '', baseURL: 'https://after.test/v1', model: 'model-after', maxUses: 9 }
|
||||
let current = before
|
||||
let commitSettings = () => {}
|
||||
const resolveApiKey = () => new Promise<string>((resolve) => {
|
||||
commitSettings = () => { current = after; resolve('key-from-before') }
|
||||
})
|
||||
const fetchMock = vi.fn(async () => jsonResponse(searchResponse()))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const provider = new DeepSeekSearchProvider(() => ({ ...current, resolveApiKey }))
|
||||
const search = provider.search({ query: 'q' })
|
||||
await vi.waitFor(() => { expect(typeof commitSettings).toBe('function') })
|
||||
commitSettings()
|
||||
await search
|
||||
|
||||
const [endpoint, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
// The key resolved from `before` must never reach `after`'s origin.
|
||||
expect(endpoint).toBe('https://before.test/v1/messages')
|
||||
expect((init.headers as Record<string, string>)['x-api-key']).toBe('key-from-before')
|
||||
expect(JSON.parse(String(init.body))).toMatchObject({ model: 'model-before' })
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
Reference in New Issue
Block a user