fix: address web seam review findings

This commit is contained in:
Tianyi Cui
2026-07-01 17:08:53 +08:00
parent 29ce2df315
commit cf71c0b215
9 changed files with 64 additions and 14 deletions

View File

@@ -7,6 +7,7 @@ The web access capability seam: an abstract web interface, search/fetch provider
| `web/` | Abstract web seam (search/fetch provider registries + selection + vocabulary + `WebError`) | `ctx.web` |
| `web-search-exa/` | Exa-backed `WebSearchProvider` | (registers on `ctx.web`) |
| `web-search-perplexity/` | Perplexity-backed `WebSearchProvider` | (registers on `ctx.web`) |
| `web-search-deepseek/` | DeepSeek-backed `WebSearchProvider` using native `web_search` through the Anthropic-compatible API | (registers on `ctx.web`) |
| `web-fetch-local/` | Anonymous public HTTP(S) `WebFetchProvider` | (registers on `ctx.web`) |
| `tool-web/` | Model-facing `web_search`/`web_fetch` tool schemas | (registers on `ctx.tools`) |

View File

@@ -20,8 +20,8 @@ It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`:
| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes `status()` report `misconfigured`. |
| `model` | `deepseek-v4-flash` | Anthropic-format model name. |
| `apiVersion` | `2023-06-01` | `anthropic-version` header value. |
| `maxTokens` | `4096` | Upper bound on generated tokens for the Messages request. |
| `maxUses` | `5` | Maximum `web_search` server-tool uses per request. |
| `maxTokens` | `4096` | Positive-integer upper bound on generated tokens for the Messages request. |
| `maxUses` | `5` | Positive-integer maximum `web_search` server-tool uses per request. |
```yaml
- id: web-search-deepseek

View File

@@ -64,18 +64,20 @@ export const Config: z<Config> = z.object({
baseURL: z.string(),
model: z.string(),
apiVersion: z.string(),
maxTokens: z.natural(),
maxUses: z.natural(),
maxTokens: z.number().step(1).min(1),
maxUses: z.number().step(1).min(1),
})
/** 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
ctx.web.registerSearchProvider(new DeepSeekSearchProvider({
apiKey: config.apiKey ?? process.env.DEEPSEEK_API_KEY ?? '',
baseURL: config.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL,
model: config.model ?? DEEPSEEK_DEFAULT_MODEL,
apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION,
maxTokens: config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS,
maxUses: config.maxUses ?? DEEPSEEK_DEFAULT_MAX_USES,
maxTokens,
maxUses,
}))
}

View File

@@ -147,6 +147,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
status(): WebProviderStatus {
if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' }
if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' }
if (!isPositiveInteger(this.options.maxTokens) || !isPositiveInteger(this.options.maxUses)) return { available: false, reason: 'misconfigured' }
return { available: true }
}
@@ -215,3 +216,8 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError'
}
/** True for DeepSeek request limits that can be sent to the Messages API. */
function isPositiveInteger(value: number): boolean {
return Number.isInteger(value) && value > 0
}

View File

@@ -152,6 +152,15 @@ describe('DeepSeekSearchProvider status', () => {
expect(new DeepSeekSearchProvider({ ...options, baseURL: 'not a url' }).status())
.toEqual({ available: false, reason: 'misconfigured' })
})
it('is misconfigured when request limits are not positive integers', () => {
expect(new DeepSeekSearchProvider({ ...options, maxTokens: 0 }).status())
.toEqual({ available: false, reason: 'misconfigured' })
expect(new DeepSeekSearchProvider({ ...options, maxUses: 0 }).status())
.toEqual({ available: false, reason: 'misconfigured' })
expect(new DeepSeekSearchProvider({ ...options, maxUses: 1.5 }).status())
.toEqual({ available: false, reason: 'misconfigured' })
})
})
describe('DeepSeekSearchProvider request mapping', () => {
@@ -263,6 +272,27 @@ describe('web-search-deepseek plugin registration', () => {
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' })
})
it('rejects maxTokens: 0 at plugin construction', async () => {
const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
await expect(ctx.plugin(deepseekPlugin, { apiKey: 'ds-key', maxTokens: 0 }))
.rejects.toThrow(/maxTokens expected number >= 1/)
})
it('rejects maxUses: 0 at plugin construction', async () => {
const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
await expect(ctx.plugin(deepseekPlugin, { apiKey: 'ds-key', maxUses: 0 }))
.rejects.toThrow(/maxUses expected number >= 1/)
})
it('rejects a fractional maxUses at plugin construction', async () => {
const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
await expect(ctx.plugin(deepseekPlugin, { apiKey: 'ds-key', maxUses: 1.5 }))
.rejects.toThrow(/maxUses expected number multiple of 1/)
})
it('has no default export (namespace plugin export shape)', () => {
expect('default' in deepseekPlugin).toBe(false)
})