Files
deepseek-harness/packages/web/web/src/index.ts
T
Yudong HanandLegGasai fda98dadd2 feat(web): add DeepSeek Search and Fetch APIs behind a settings switch (#6103)
* fix(web-search-deepseek): disable thinking for auxiliary search requests

The provider discards the generated text of the auxiliary Messages turn,
so reasoning tokens have no consumer. DeepSeek enables thinking by default,
which adds latency and output tokens to every search.

Fixes #6099.

* feat(web): add opt-in traditional Search and Fetch APIs

* test(web): verify API settings persistence and refresh Session evidence

* fix(web): require an explicit Search API base with its route prefix

* docs(web): document direct Search and Fetch API decisions

* fix(web): keep Messages search default behind the new Web Search API switch

- Rename useTraditionalApi/traditionalBaseURL to useSearchApi/searchApiBaseURL and
  label the switch "Use the new Web Search API"; Messages search stays the default.
- Default the Search / Fetch API base to https://api.deepseek.com/v1/tools.
- Authenticate the Search API with an API key only: DeepSeek Account tokens
  returned an empty HTTP 404 on /v1/tools/* when probed on 2026-10-16.
- Remove the count setting, which tool-web's maxResults always overrides, and
  the undocumented DeepSeek-API-Version header.
- Name the active API's base URL field and the current settings path in
  endpoint guidance; re-record the endpoint guidance Session.
- Keep WebFetchResult.statusCode required: DeepSeek Fetch reports HTTP 200 and
  drops the page title, so the fetch-metadata widening and its upgrade guide
  are removed.

* fix(web): accept account sign-in on the live Search API

The public Search and Fetch routes went live on 2026-10-16 and accept the
DeepSeek Account token, so account-route Sessions send it to the Search API
as they do for Messages search, and the settings copy no longer says account
sign-in does not apply.

Record the live behavior in the README and Agent Note: summaries of
1,000-2,300 characters, an HTTP 500 for an unresolvable Fetch target, and
migration notes for gateways without /v1/tools routes. Test doubles now use
the live response fields and RFC 3339 offsets.

* feat(web): configure Search API result count and send the triggering question

- Add web-search-deepseek.count (1-50, default 10, the service default) and
  show it as "Results per search" in Search API mode, where "Max searches per
  request" applies only to Messages; a smaller request maxResults still wins.
- Send the initiating Session's latest human prompt as ori_question, with
  whitespace collapsed and cut to the API's 128-character limit; blank or
  absent prompts omit it, and sendOriginalQuestion: false disables it. The
  settings hint states that the question is sent.
- Match the web search double on the request's query field, since
  ori_question now carries every query in the prompt.

* fix(client): unify web search endpoint override controls

* feat(web): configure registered search and fetch providers through shared settings

* feat(web): include Exa and Perplexity in provider settings

* test(web): cover provider settings branches and refresh plugin snapshots

* revert(web): drop the provider dropdown, schema-driven form, and Exa/Perplexity settings

Revert aed8c5cb609, d88c47dcd80, and 036a5a337d3. The Web search settings
page returns to the DeepSeek toggle; the DeepSeek Search and Fetch API split
is reintroduced as one plugin in the following commit.

* feat(web): move the DeepSeek Search and Fetch APIs into web-deepseek-api

The new web-deepseek-api plugin registers deepseek-search-api and
deepseek-fetch-api over one credential and API base, and exports the
authentication and redirect-rejecting transport that web-search-deepseek now
reuses for Messages search only. The web service reads searchProvider and
fetchProvider for each operation, so the Web search settings switch selects
both APIs together on save and restores Messages search with local HTTP fetch
when turned off. The switch hint links to the DeepSeek search and fetch guide.

* refactor(client): keep the web search endpoint fields on the shared override controls

Drop the always-visible reset and the inherited-address placeholder from the
Web search page, restoring ui-primitives to its base. Each endpoint field shows
the selected section's effective value and offers Reset to default only for an
override, like every other settings field.

* refactor(web): keep Messages search independent of web-deepseek-api

Restore dsh-web-search-deepseek to its own credential and transport code,
keeping only the disabled-thinking request and the current settings guidance,
so the Messages plugin no longer depends on the Search and Fetch API package.
dsh-web-deepseek-api keeps its transport internal; the regions that repeat the
Messages helpers carry jscpd:ignore markers, with the rationale recorded in the
Search and Fetch API Agent Note.

* refactor(web): keep the web-deepseek-api request type module-private

---------

Co-authored-by: LegGasai <[email protected]>
2026-10-17 15:08:08 +08:00

207 lines
8.3 KiB
TypeScript

/**
* Service Definition for the web access capability seam (`ctx.web`): registries and provider-selecting execution for search and
* fetch. Duplicate ids are rejected. At execution time, a configured provider must exist and
* be usable; without one, exactly one usable provider is required, so selection never depends
* on registration order.
* @module @deepseek-ai/dsh-web
*/
import { Context, Service, type Volatile } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type {
WebFetchProvider,
WebFetchRequest,
WebFetchResult,
WebSearchProvider,
WebSearchRequest,
WebSearchResult,
} from './types.ts'
import { WebError } from './types.ts'
export {
WebError,
} from './types.ts'
export type {
WebFetchBody,
WebFetchProvider,
WebFetchRequest,
WebFetchResult,
WebSearchProvider,
WebSearchRequest,
WebSearchResult,
WebSearchSource,
} from './types.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
web: WebRuntime
}
}
/** Selection inputs for execution-time provider resolution. */
interface Selection<P> {
/** The configured provider id for this capability, if any. */
readonly configuredId?: string
/** Providers registered for this capability kind. */
readonly providers: ReadonlyMap<string, P>
}
/**
* Config for the web seam. `searchProvider` / `fetchProvider` pin which provider
* wins for each capability; both are optional (a single registered usable
* provider auto-selects). Each operation reads the current value, so a settings
* change applies to the next search or fetch without remounting the service.
* Operational overrides such as environment variables must feed these same
* fields rather than introduce a hidden priority chain.
*/
export interface WebRuntimeConfig {
/** Explicit search provider id. Omitted = auto-select when exactly one usable. */
readonly searchProvider: Volatile<string | undefined>
/** Explicit fetch provider id. Omitted = auto-select when exactly one usable. */
readonly fetchProvider: Volatile<string | undefined>
}
/**
* The web access service. Registered as `ctx.web` (one instance per context).
*
* Selection semantics (resolved at execution time, never order-dependent):
* - A configured id that is registered and `available()` → that provider.
* - A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`.
* - A configured id registered but unavailable →
* `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.
* - No id configured, exactly one registered usable provider → that provider.
* - No id configured, multiple usable providers → `WEB_PROVIDER_AMBIGUOUS`.
* - No id configured, no usable provider → `WEB_PROVIDER_UNAVAILABLE`.
*/
export class WebRuntime extends Service {
/**
* Provider selection config. Operational env overrides feed the SAME fields:
* `$DSH_WEB_SEARCH_PROVIDER` / `$DSH_WEB_FETCH_PROVIDER` are equivalent to
* `searchProvider` / `fetchProvider` and are NOT a hidden priority chain.
*/
static Config = z.object({
searchProvider: z.string().volatile(),
fetchProvider: z.string().volatile(),
})
private searchProviders = new Map<string, WebSearchProvider>()
private fetchProviders = new Map<string, WebFetchProvider>()
private readonly searchProviderEnv: string | undefined
private readonly fetchProviderEnv: string | undefined
constructor(ctx: Context, private readonly config: WebRuntimeConfig) {
super(ctx, 'web')
this.searchProviderEnv = process.env.DSH_WEB_SEARCH_PROVIDER
this.fetchProviderEnv = process.env.DSH_WEB_FETCH_PROVIDER
}
/**
* Register a search provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER`
* if its id is already registered for search. Returns a disposer; disposed
* with the calling fiber.
* @param provider - the provider; its `id` is the registry key.
* @returns the disposer that unregisters the provider.
*/
registerSearchProvider(provider: WebSearchProvider): () => void {
return this.registerProvider(this.searchProviders, provider)
}
/**
* Register a fetch provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER`
* if its id is already registered for fetch. Returns a disposer; disposed
* with the calling fiber.
* @param provider - the provider; its `id` is the registry key.
* @returns the disposer that unregisters the provider.
*/
registerFetchProvider(provider: WebFetchProvider): () => void {
return this.registerProvider(this.fetchProviders, provider)
}
private registerProvider<P extends { readonly id: string }>(store: Map<string, P>, provider: P): () => void {
if (store.has(provider.id)) {
throw new WebError(`a web provider with id "${provider.id}" is already registered`, 'WEB_DUPLICATE_PROVIDER')
}
const dispose = this.ctx.effect(function* () {
store.set(provider.id, provider)
yield () => store.delete(provider.id)
}, 'web.registerProvider()')
// ctx.effect's disposer returns Promise<void>; our disposer API is
// synchronous fire-and-forget — discard the (always-resolved) promise.
return () => void dispose()
}
/**
* Run one search through the selected provider. Resolves the provider at call
* time with the selection rules above; throws {@link WebError} when the
* capability cannot run. The seam enforces `request.maxResults` on the result:
* if the provider over-returns, `sources[]` is truncated and `truncated` set.
* @param request - the query and optional result limit.
* @param signal - optional cancellation signal forwarded to the provider.
* @returns the provider's results, capped to `request.maxResults`.
*/
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
const configuredId = this.config.searchProvider.get() ?? this.searchProviderEnv
const provider = resolveProvider({
providers: this.searchProviders,
...configuredId !== undefined ? { configuredId } : {},
})
const result = await provider.search(request, signal)
return capSources(result, request.maxResults)
}
/**
* Retrieve one URL through the selected provider. Resolves the provider at
* call time with the selection rules above; throws {@link WebError} when the
* capability cannot run. A non-2xx response is a result, not a throw.
* @param request - the URL plus retrieval options.
* @param signal - optional cancellation signal forwarded to the provider.
* @returns the retrieval outcome; non-2xx responses resolve descriptively.
*/
async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult> {
const configuredId = this.config.fetchProvider.get() ?? this.fetchProviderEnv
const provider = resolveProvider({
providers: this.fetchProviders,
...configuredId !== undefined ? { configuredId } : {},
})
return provider.fetch(request, signal)
}
}
interface ResolvableProvider {
readonly id: string
available(): boolean
}
/** Resolve the selected provider or throw the matching {@link WebError}. */
function resolveProvider<P extends ResolvableProvider>(selection: Selection<P>): P {
const { configuredId, providers } = selection
if (configuredId !== undefined) {
const provider = providers.get(configuredId)
if (!provider) {
throw new WebError(`configured web provider "${configuredId}" is not registered`, 'WEB_PROVIDER_CONFIGURED_MISSING')
}
if (!provider.available()) {
throw new WebError(`configured web provider "${configuredId}" is registered but unavailable`, 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE')
}
return provider
}
const usable = [...providers.values()].filter(provider => provider.available())
const [single] = usable
if (single === undefined) {
throw new WebError('no usable web provider is registered', 'WEB_PROVIDER_UNAVAILABLE')
}
if (usable.length > 1) {
const ids = usable.map(provider => provider.id).join(', ')
throw new WebError(`multiple usable web providers are registered (${ids}); configure one explicitly`, 'WEB_PROVIDER_AMBIGUOUS')
}
return single
}
/** Enforce `maxResults` on a search result: truncate `sources[]` and flag it. */
function capSources(result: WebSearchResult, maxResults: number | undefined): WebSearchResult {
if (maxResults === undefined || result.sources.length <= maxResults) return result
return { ...result, sources: result.sources.slice(0, maxResults), truncated: true }
}
export default WebRuntime