refactor: prune unused web seam fields
This commit is contained in:
@@ -8,7 +8,7 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
|
||||
|
||||
The provider owns **safe resource retrieval**: URL validation, HTTP transport, redirect policy, a resource-backstop timeout, abort propagation, byte caps, charset decoding, content-type classification, and binary rejection. `@deepseek-ai/dsh-tool-web` owns **presentation** (HTML→markdown, truncation formatting). A non-2xx HTTP response is a *result* (status code + decoded body), not an error; `WebError` is reserved for failures to safely retrieve or represent the resource.
|
||||
|
||||
The provider's `timeoutMs`/`maxTimeoutMs` is a **resource backstop** for direct `ctx.web.fetch()` callers and misconfigured deployments — it is NOT the model-facing tool-call budget. The tool-call budget for `web_fetch` is deployment policy owned by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md), which arms a per-call deadline on `exec.signal`. A shipped web-tool deployment sets the provider backstop **above** the `tool-timeout` budget, so the tool-call policy normally wins for model calls (returning `TOOL_TIMEOUT`); when the outer deadline signal reaches this provider first, it classifies as `WEB_ABORTED` and the outer wrapper replaces the result with `TOOL_TIMEOUT`. The provider's own `WEB_FETCH_TIMEOUT` only fires for a direct seam caller whose own budget elapsed.
|
||||
The provider's configured `timeoutMs` is a **resource backstop** for direct `ctx.web.fetch()` callers and misconfigured deployments — it is NOT the model-facing tool-call budget. The tool-call budget for `web_fetch` is deployment policy owned by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md), which arms a per-call deadline on `exec.signal`. A shipped web-tool deployment sets the provider backstop **above** the `tool-timeout` budget, so the tool-call policy normally wins for model calls (returning `TOOL_TIMEOUT`); when the outer deadline signal reaches this provider first, it classifies as `WEB_ABORTED` and the outer wrapper replaces the result with `TOOL_TIMEOUT`. The provider's own `WEB_FETCH_TIMEOUT` fires when its configured backstop elapses.
|
||||
|
||||
## Transport hygiene
|
||||
|
||||
@@ -26,8 +26,7 @@ The provider's `timeoutMs`/`maxTimeoutMs` is a **resource backstop** for direct
|
||||
| `maxUrlLength` | `2048` | Maximum accepted request URL length. |
|
||||
| `maxResponseBytes` | `5_000_000` | Maximum response body size in bytes. |
|
||||
| `maxBodyChars` | `100_000` | Maximum decoded body length in characters. |
|
||||
| `timeoutMs` | `30_000` | Default fetch timeout — a resource backstop for direct `ctx.web.fetch()` callers, not the model-facing tool-call budget (that is `dsh-timeout-policy`). |
|
||||
| `maxTimeoutMs` | `120_000` | Upper bound for a per-request timeout override (direct callers). |
|
||||
| `timeoutMs` | `30_000` | Fetch timeout — a resource backstop for direct `ctx.web.fetch()` callers, not the model-facing tool-call budget (that is `dsh-timeout-policy`). |
|
||||
| `maxRedirects` | `5` | Maximum same-origin redirect hops (`0` follows none). |
|
||||
| `userAgent` | `deepseek-harness/…` | `User-Agent` header. |
|
||||
|
||||
|
||||
@@ -40,8 +40,6 @@ export interface Config {
|
||||
maxBodyChars?: number
|
||||
/** Default fetch timeout in milliseconds. */
|
||||
timeoutMs?: number
|
||||
/** Upper bound for a per-request timeout override. */
|
||||
maxTimeoutMs?: number
|
||||
/** Maximum number of same-origin redirect hops to follow. */
|
||||
maxRedirects?: number
|
||||
/** `User-Agent` header sent on every request. */
|
||||
@@ -53,7 +51,6 @@ export const Config: z<Config> = z.object({
|
||||
maxResponseBytes: z.number().default(5_000_000),
|
||||
maxBodyChars: z.number().default(100_000),
|
||||
timeoutMs: z.number().default(30_000),
|
||||
maxTimeoutMs: z.number().default(120_000),
|
||||
maxRedirects: z.number().default(5),
|
||||
userAgent: z.string().default(DEFAULT_USER_AGENT),
|
||||
})
|
||||
@@ -83,14 +80,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
assertPositiveFinite('maxResponseBytes', resolved.maxResponseBytes)
|
||||
assertPositiveFinite('maxBodyChars', resolved.maxBodyChars)
|
||||
assertPositiveFinite('timeoutMs', resolved.timeoutMs)
|
||||
assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs)
|
||||
assertNonNegativeInteger('maxRedirects', resolved.maxRedirects)
|
||||
const limits: LocalFetchLimits = {
|
||||
maxUrlLength: resolved.maxUrlLength,
|
||||
maxResponseBytes: resolved.maxResponseBytes,
|
||||
maxBodyChars: resolved.maxBodyChars,
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
maxTimeoutMs: resolved.maxTimeoutMs,
|
||||
maxRedirects: resolved.maxRedirects,
|
||||
userAgent: resolved.userAgent,
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
|
||||
import { WebError } from '@deepseek-ai/dsh-web'
|
||||
import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult, WebProviderStatus } from '@deepseek-ai/dsh-web'
|
||||
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult } from '@deepseek-ai/dsh-web'
|
||||
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from './policy.ts'
|
||||
|
||||
/** Resolved provider limits (the plugin's schemastery Config supplies defaults). */
|
||||
@@ -34,8 +34,6 @@ export interface LocalFetchLimits {
|
||||
maxBodyChars: number
|
||||
/** Default fetch timeout in milliseconds. */
|
||||
timeoutMs: number
|
||||
/** Upper bound for a per-request timeout override. */
|
||||
maxTimeoutMs: number
|
||||
/** Maximum number of (same-origin) redirect hops to follow. */
|
||||
maxRedirects: number
|
||||
/** `User-Agent` header sent on every request. */
|
||||
@@ -52,20 +50,19 @@ export class LocalFetchProvider implements WebFetchProvider {
|
||||
constructor(private readonly limits: LocalFetchLimits) {}
|
||||
|
||||
/** No credentials to check — an anonymous public fetcher is always usable. */
|
||||
status(): WebProviderStatus {
|
||||
return { available: true }
|
||||
available(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
async fetch(request: WebFetchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebFetchResult> {
|
||||
if (exec?.signal?.aborted) throw new WebError('web fetch aborted', 'WEB_ABORTED')
|
||||
const timeoutMs = clampTimeout(request.timeoutMs, this.limits.timeoutMs, this.limits.maxTimeoutMs)
|
||||
async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult> {
|
||||
if (signal?.aborted) throw new WebError('web fetch aborted', 'WEB_ABORTED')
|
||||
|
||||
// One deadline signal fuses the caller's abort with our own timeout, so the
|
||||
// network request and the streaming read both stop on either. The timeout
|
||||
// abort carries a TimeoutReason we recover afterward to classify the cause
|
||||
// (translateAbortOrNetwork), instead of hand-rolling a controller + timer +
|
||||
// reason-recovery dance.
|
||||
using d = deadline(exec?.signal, timeoutMs, 'WEB_FETCH_TIMEOUT')
|
||||
using d = deadline(signal, this.limits.timeoutMs, 'WEB_FETCH_TIMEOUT')
|
||||
return await this.followAndRead(request.url, d.signal)
|
||||
}
|
||||
|
||||
@@ -161,7 +158,6 @@ export class LocalFetchProvider implements WebFetchProvider {
|
||||
const body: WebFetchBody = kind === 'html' ? { kind: 'html', content } : { kind: 'text', content }
|
||||
|
||||
return {
|
||||
providerId: this.id,
|
||||
url: finalUrl.toString(),
|
||||
statusCode: response.status,
|
||||
body,
|
||||
|
||||
@@ -12,7 +12,6 @@ const limits: LocalFetchLimits = {
|
||||
maxResponseBytes: 5_000_000,
|
||||
maxBodyChars: 100_000,
|
||||
timeoutMs: 5_000,
|
||||
maxTimeoutMs: 10_000,
|
||||
maxRedirects: 5,
|
||||
userAgent: 'test-agent/1.0',
|
||||
}
|
||||
@@ -82,7 +81,7 @@ describe('LocalFetchProvider success', () => {
|
||||
it('fetches a text body', async () => {
|
||||
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('hello world') }
|
||||
const result = await provider().fetch({ url: base })
|
||||
expect(result.providerId).toBe(LOCAL_FETCH_PROVIDER_ID)
|
||||
expect(provider().available()).toBe(true)
|
||||
expect(result.statusCode).toBe(200)
|
||||
expect(result.body).toEqual({ kind: 'text', content: 'hello world' })
|
||||
expect(result.truncated).toBe(false)
|
||||
@@ -287,14 +286,14 @@ describe('LocalFetchProvider invalid URLs and abort', () => {
|
||||
it('honors a pre-aborted signal', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(provider().fetch({ url: base }, { signal: controller.signal }))
|
||||
await expect(provider().fetch({ url: base }, controller.signal))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
|
||||
})
|
||||
|
||||
it('aborts an in-flight fetch via the signal', async () => {
|
||||
handler = (_req, _res) => { /* never responds */ }
|
||||
const controller = new AbortController()
|
||||
const promise = provider().fetch({ url: base }, { signal: controller.signal })
|
||||
const promise = provider().fetch({ url: base }, controller.signal)
|
||||
controller.abort()
|
||||
await expect(promise).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
|
||||
})
|
||||
@@ -325,11 +324,6 @@ describe('LocalFetchProvider invalid URLs and abort', () => {
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
|
||||
})
|
||||
|
||||
it('caps the per-request timeout at maxTimeoutMs', async () => {
|
||||
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('ok') }
|
||||
const result = await provider({ maxTimeoutMs: 10_000 }).fetch({ url: base, timeoutMs: 999_999 })
|
||||
expect(result.statusCode).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalFetchProvider body cancellation on error paths', () => {
|
||||
@@ -378,7 +372,7 @@ describe('web-fetch-local plugin registration', () => {
|
||||
await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(fetchPlugin, {})
|
||||
await expect(ctx.web.fetch({ url: `${base}/` }))
|
||||
.resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 })
|
||||
.resolves.toMatchObject({ statusCode: 200 })
|
||||
await fiber.dispose()
|
||||
await expect(ctx.web.fetch({ url: `${base}/` }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
|
||||
@@ -421,7 +415,7 @@ describe('web-fetch-local plugin registration', () => {
|
||||
await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(fetchPlugin, { maxRedirects: 0 })
|
||||
await expect(ctx.web.fetch({ url: `${base}/` }))
|
||||
.resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 })
|
||||
.resolves.toMatchObject({ statusCode: 200 })
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user