Files
deepseek-harness/packages/web/web-deepseek-api/src/index.ts
T

390 lines
22 KiB
TypeScript

/**
* Register the DeepSeek Search and Fetch APIs in `ctx.web` as `deepseek-search-api`
* and `deepseek-fetch-api`. Each operation is one direct API request without an
* auxiliary model request. `web.searchProvider` and `web.fetchProvider` select them. Each capability's billing setting
* chooses its credential. `auto` follows the conversation: a Session whose latest model request used the
* `deepseek-account` route sends the account token to that route's origin and otherwise the API key; one on the
* `deepseek-official` route sends the API key; any other operation sends the API key and, without a key, the signed-in
* account's token. `account` sends only the account token and `api-key` only the API key. The API key is that of the
* DeepSeek API-key route, sent to its origin, or `DEEPSEEK_API_KEY` sent to the official base without that route. The
* account token goes to the account route's origin, or outside an account conversation without that route to the origin
* of its default base URL. An operation that needs a mounted route whose settings do not resolve fails instead of
* changing its endpoint or credential. A capability with its own `*BaseURL` or `*ApiKeyEnv` uses only that endpoint and
* key instead, whatever its billing
* ([rationale](../../../../.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.md#credential-resolution)).
* Both providers report themselves not ready when their next operation would have no credential.
* @module @deepseek-ai/dsh-web-deepseek-api
*/
import { FiberState, type Context, type Volatile } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import z from '@deepseek-ai/schemastery'
import type {} from '@deepseek-ai/dsh-agent'
import { credentialRef, type CredentialRef } from '@deepseek-ai/dsh-credentials'
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
import type {} from '@deepseek-ai/dsh-llm'
import {
deepSeekApiKeyConfigured, rejectDeepSeekAccountToken, resolveDeepSeekAccountToken, resolveDeepSeekApiKey,
} from '@deepseek-ai/dsh-llm-deepseek'
import {
plainAccountOptions, resolveAccountOptions, type Config as AccountRouteConfig,
} from '@deepseek-ai/dsh-llm-deepseek-account'
import {
plainOptions as plainOfficialOptions, resolveAdapterOptions as resolveOfficialOptions, type Config as OfficialRouteConfig,
} from '@deepseek-ai/dsh-llm-deepseek-api-key'
import type { Session } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-web'
import { DeepSeekFetchApiProvider } from './fetch.ts'
import {
DEEPSEEK_DEFAULT_COUNT, DEEPSEEK_SEARCH_REQUESTS_PER_SECOND, DEEPSEEK_WEB_API_BASE_URL, DeepSeekSearchApiProvider,
} from './search.ts'
import {
isDeepSeekWebAuthReady, type DeepSeekWebAuth, type DeepSeekWebAuthSource, type DeepSeekWebGuidance,
} from './transport.ts'
export { DEEPSEEK_FETCH_API_PROVIDER_ID, DeepSeekFetchApiProvider } from './fetch.ts'
export type { DeepSeekFetchApiOptions } from './fetch.ts'
export {
DEEPSEEK_DEFAULT_COUNT, DEEPSEEK_SEARCH_API_PROVIDER_ID, DEEPSEEK_SEARCH_REQUESTS_PER_SECOND, DEEPSEEK_WEB_API_BASE_URL,
DeepSeekSearchApiProvider, mapSearchResponse,
} from './search.ts'
export type { DeepSeekSearchApiOptions } from './search.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'web-deepseek-api'
/** The web seam both providers register into. */
export const inject = ['web']
/**
* Settings read by the next search or fetch without re-registering either provider. A non-empty `*BaseURL` or
* `*ApiKeyEnv` gives that capability its own endpoint and key; with neither, its `*Billing` chooses its DeepSeek
* credential. Stored fields of earlier settings (`baseURL`, `apiKeyEnv`, `apiKey`) are not schema fields: they load
* without error and have no effect.
*/
export interface Config {
/** Search API results per search, from 1 through 50; a smaller request bound wins. Defaults to 10. */
count: Volatile<number>
/**
* Most Search requests this plugin instance sends in any one-second window; later searches wait,
* and the wait counts against the caller's timeout. Other processes and clients on the same account
* share the account limit without coordination. Defaults to 5.
*/
searchRequestsPerSecond: Volatile<number>
/** Send the triggering user question, bounded to 128 characters, as `ori_question`. Defaults to true. */
sendOriginalQuestion: Volatile<boolean>
/**
* Credential Search sends without {@link searchBaseURL} and {@link searchApiKeyEnv}. `auto` uses the conversation's
* DeepSeek account or API key while it chats on a DeepSeek model route, and otherwise the API key, then the signed-in
* account; `account` uses only the signed-in account; `api-key` uses only the API key. Defaults to `auto`.
*/
searchBilling: Volatile<'auto' | 'account' | 'api-key'>
/** Credential Fetch sends without {@link fetchBaseURL} and {@link fetchApiKeyEnv}, chosen as {@link searchBilling} is. */
fetchBilling: Volatile<'auto' | 'account' | 'api-key'>
/**
* Own Search API base including its path prefix; `/search` is appended. With only {@link searchApiKeyEnv} set, the
* official base applies.
*/
searchBaseURL: Volatile<string | undefined>
/** Own Search API credential reference. With only {@link searchBaseURL} set, `DEEPSEEK_API_KEY` applies. */
searchApiKeyEnv: Volatile<string | undefined>
/**
* Own Fetch API base including its path prefix; `/fetch` is appended. With only {@link fetchApiKeyEnv} set, the
* official base applies.
*/
fetchBaseURL: Volatile<string | undefined>
/** Own Fetch API credential reference. With only {@link fetchBaseURL} set, `DEEPSEEK_API_KEY` applies. */
fetchApiKeyEnv: Volatile<string | undefined>
}
export const Config = z.object({
count: z.number().step(1).min(1).max(50).default(DEEPSEEK_DEFAULT_COUNT).volatile(),
searchRequestsPerSecond: z.number().step(1).min(1).default(DEEPSEEK_SEARCH_REQUESTS_PER_SECOND).volatile(),
sendOriginalQuestion: z.boolean().default(true).volatile(),
searchBilling: z.union(['auto', 'account', 'api-key'] as const).default('auto').volatile(),
fetchBilling: z.union(['auto', 'account', 'api-key'] as const).default('auto').volatile(),
searchBaseURL: z.string().volatile(),
searchApiKeyEnv: z.string().role('credential-ref').volatile(),
fetchBaseURL: z.string().volatile(),
fetchApiKeyEnv: z.string().role('credential-ref').volatile(),
})
/** A capability that resolves its endpoint and credential independently of the other. */
type Capability = 'search' | 'fetch'
/** Credential choice of one capability without its own endpoint settings. */
type Billing = Config['searchBilling'] extends Volatile<infer B> ? B : never
/** Model route whose settings an operation can depend on. */
type RouteName = keyof typeof ROUTE_PACKAGES
/**
* Rule that ordered an operation's credentials: `auto` billing in a conversation on the account route, on the API-key
* route, or anywhere else, or an explicit billing.
*/
type Rule = 'account-conversation' | 'official-conversation' | 'other-conversation' | 'account' | 'api-key'
/** Path prefix of the Search and Fetch APIs under a DeepSeek origin. */
const TOOLS_PATH = '/v1/tools'
/** Credential reference of the official DeepSeek API key. */
const DEEPSEEK_API_KEY = credentialRef('DEEPSEEK_API_KEY')
/** Provider id of the DeepSeek account route. */
const ACCOUNT_PROVIDER = 'deepseek-account'
/** Provider id of the DeepSeek API-key route. */
const OFFICIAL_PROVIDER = 'deepseek-official'
/** Loader package that registers each model route. */
const ROUTE_PACKAGES = {
[OFFICIAL_PROVIDER]: '@deepseek-ai/dsh-llm-deepseek-api-key',
[ACCOUNT_PROVIDER]: '@deepseek-ai/dsh-llm-deepseek-account',
} as const
/** Display names, configuration prefix, and operation path of each capability. */
const CAPABILITY = {
search: { subject: 'Search', prefix: 'search', path: '/search' },
fetch: { subject: 'Fetch', prefix: 'fetch', path: '/fetch' },
} as const satisfies Record<Capability, { subject: DeepSeekWebGuidance['subject']; prefix: string; path: '/search' | '/fetch' }>
/**
* Read the text of the latest human prompt, the question that triggered the
* current turn. Injected user-role context carries another source kind.
* @param session - the initiating Session, when the search runs inside one.
* @returns the prompt's joined text blocks, or undefined without a prompt.
*/
function latestUserQuestion(session: Session | undefined): string | undefined {
const prompt = session?.deriveMessages().findLast(message => message.role === 'user' && message.source.kind === 'user')
if (prompt === undefined) return undefined
return prompt.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n')
}
/**
* Register the Search API and Fetch API providers over one live settings section. Each operation reads one settings
* snapshot and the route of the initiating Session's latest model request; each `ready()` call reads one settings
* snapshot and the route of the model request being prepared. Both resolve the same ordered credentials.
* @param ctx - plugin context owning both registrations.
* @param config - live settings, read once per operation.
*/
export function apply(ctx: Context, config: Config): void {
const own = (capability: Capability) => capability === 'search'
? { baseURL: config.searchBaseURL.get(), ref: config.searchApiKeyEnv.get(), billing: config.searchBilling.get() }
: { baseURL: config.fetchBaseURL.get(), ref: config.fetchApiKeyEnv.get(), billing: config.fetchBilling.get() }
const target = (capability: Capability, llmProvider: string | undefined) =>
resolveTarget(ctx, capability, own(capability), llmProvider)
const current = (capability: Capability) =>
target(capability, ctx.get('agents')?.currentInitiator()?.session.requestContext()?.provider)
const ready = (capability: Capability) => (llmProvider: string | undefined): Promise<boolean> =>
isDeepSeekWebAuthReady(target(capability, llmProvider).auth, CAPABILITY[capability].path)
const searchApi = new DeepSeekSearchApiProvider(() => ({
...current('search'),
count: config.count.get(),
requestsPerSecond: config.searchRequestsPerSecond.get(),
...config.sendOriginalQuestion.get() ? {
originalQuestion: () => latestUserQuestion(ctx.get('agents')?.currentInitiator()?.session),
} : {},
}))
const fetchApi = new DeepSeekFetchApiProvider(() => current('fetch'))
ctx.web.registerSearchProvider({
id: searchApi.id,
description: searchApi.description,
available: () => searchApi.available(),
ready: ready('search'),
search: (request, signal) => searchApi.search(request, signal),
})
ctx.web.registerFetchProvider({
id: fetchApi.id,
description: fetchApi.description,
available: () => fetchApi.available(),
ready: ready('fetch'),
fetch: (request, signal) => fetchApi.fetch(request, signal),
})
}
/**
* Resolve the ordered credentials and endpoint guidance of one capability without reading credential values. Route
* endpoints use the origin of the route's resolved Messages base URL with `/v1/tools`.
* @param ctx - context with the model, loader, credential, and account services.
* @param capability - the capability being resolved.
* @param own - the capability's own base URL and credential reference, where empty values count as unset, and its
* billing.
* @param llmProvider - provider route of the model request the operation serves.
* @returns the authentication sources and the settings endpoint failures name.
*/
function resolveTarget(
ctx: Context, capability: Capability,
own: { baseURL: string | undefined; ref: string | undefined; billing: Billing }, llmProvider: string | undefined,
): { auth: DeepSeekWebAuth; guidance: DeepSeekWebGuidance } {
const { subject, prefix } = CAPABILITY[capability]
const guidance = (custom: boolean): DeepSeekWebGuidance =>
({ subject, plugin: name, custom, baseURLField: `${prefix}BaseURL`, apiKeyEnvField: `${prefix}ApiKeyEnv` })
const ownBaseURL = nonEmpty(own.baseURL), ownRef = nonEmpty(own.ref)
if (ownBaseURL !== undefined || ownRef !== undefined) {
const ref = ownRef === undefined ? DEEPSEEK_API_KEY : credentialRef(ownRef)
const json = JSON.stringify(ref)
return { guidance: guidance(true), auth: {
sources: [apiKeySource(ctx, ownBaseURL ?? DEEPSEEK_WEB_API_BASE_URL, ref)],
missingCredentialMessage: `DeepSeek ${subject} API sends the API key under ${json} to its own endpoint, and no key is`
+ ` configured for ${json}. Guide the user to export ${json} in the launching environment, or to correct`
+ ` ${name}.${prefix}ApiKeyEnv.`,
} }
}
const rule: Rule = own.billing !== 'auto' ? own.billing
: llmProvider === ACCOUNT_PROVIDER ? 'account-conversation'
: llmProvider === OFFICIAL_PROVIDER ? 'official-conversation' : 'other-conversation'
const sources = ruleSources(ctx, capability, rule)
const [ref = DEEPSEEK_API_KEY] = sources.flatMap(row => row.kind === 'api-key' && typeof row.key !== 'string' ? [row.key.ref] : [])
return { guidance: guidance(false), auth: { sources, missingCredentialMessage: missingMessage(capability, rule, ref) } }
}
/**
* The credentials one rule tries, in order.
* @param ctx - context with the model, loader, credential, and account services.
* @param capability - the capability whose diagnostics misconfigured sources name.
* @param rule - the rule.
* @returns the sources; the account source is absent in an account conversation without a mounted account route.
*/
function ruleSources(ctx: Context, capability: Capability, rule: Rule): DeepSeekWebAuthSource[] {
const accountConfig = routeConfig(ctx, ACCOUNT_PROVIDER) as AccountRouteConfig | undefined
switch (rule) {
case 'account-conversation': return accountConfig === undefined
? [officialSource(ctx, capability)] : [accountSource(ctx, capability, accountConfig), officialSource(ctx, capability)]
case 'official-conversation':
case 'api-key': return [officialSource(ctx, capability)]
case 'other-conversation': return [officialSource(ctx, capability), accountSource(ctx, capability, accountConfig)]
case 'account': return [accountSource(ctx, capability, accountConfig)]
/* v8 ignore next -- the switch is exhaustive. */
default: return assertNever(rule)
}
}
/**
* The API key of the active DeepSeek API-key route, sent to its origin, or `DEEPSEEK_API_KEY` sent to the official base
* without that route.
* @returns the source, or a misconfigured source when the route's resolver rejects its settings.
*/
function officialSource(ctx: Context, capability: Capability): DeepSeekWebAuthSource {
const config = routeConfig(ctx, OFFICIAL_PROVIDER) as OfficialRouteConfig | undefined
if (config === undefined) return apiKeySource(ctx, DEEPSEEK_WEB_API_BASE_URL, DEEPSEEK_API_KEY)
let resolved: { baseURL: string; apiKeyEnv: CredentialRef }
try { resolved = resolveOfficialOptions(plainOfficialOptions(config), launchEnvironmentOf(ctx)) }
catch (error: unknown) { return misconfigured(capability, OFFICIAL_PROVIDER, error) }
return apiKeySource(ctx, toolsBase(resolved.baseURL), resolved.apiKeyEnv)
}
/**
* The account token sent to the origin of the account route, or without that route to the origin of its default base
* URL, including `DEEPSEEK_BASE_URL`. The account service releases the token only for destinations it allows.
* @param config - the active account route's settings, or undefined for the defaults.
* @returns the source, or a misconfigured source when the route's resolver rejects its settings.
*/
function accountSource(ctx: Context, capability: Capability, config: AccountRouteConfig | undefined): DeepSeekWebAuthSource {
let baseURL: string
try {
baseURL = resolveAccountOptions(config === undefined ? {} : plainAccountOptions(config), launchEnvironmentOf(ctx)).protocol.baseURL
} catch (error: unknown) { return misconfigured(capability, ACCOUNT_PROVIDER, error) }
return {
kind: 'account', baseURL: toolsBase(baseURL),
resolveToken: endpoint => resolveDeepSeekAccountToken(ctx, endpoint),
rejectToken: token => rejectDeepSeekAccountToken(ctx, token),
}
}
/** The key under `ref`, read through the shared DeepSeek credential helpers, sent to `baseURL`. */
function apiKeySource(ctx: Context, baseURL: string, ref: CredentialRef): DeepSeekWebAuthSource {
return { kind: 'api-key', baseURL, key: {
ref, configured: () => deepSeekApiKeyConfigured(ctx, ref), resolve: () => resolveDeepSeekApiKey(ctx, ref),
} }
}
/**
* The source of a credential whose model route settings do not resolve.
* @param capability - the capability that stops.
* @param route - the route whose settings failed.
* @param error - the route resolver's failure.
* @returns the source whose diagnostic names the route and both recoveries.
*/
function misconfigured(capability: Capability, route: RouteName, error: unknown): DeepSeekWebAuthSource {
const { subject, prefix } = CAPABILITY[capability]
const provider = route === ACCOUNT_PROVIDER ? 'DeepSeek account model provider' : 'DeepSeek API-key model provider'
const credential = route === ACCOUNT_PROVIDER ? 'the DeepSeek account sign-in' : 'that provider\'s API key'
return { kind: 'misconfigured', message: `DeepSeek ${subject} API sends ${credential} to the origin of the ${provider}, and`
+ ` that provider's settings do not resolve: ${errorMessage(error)}. ${subject} did not send the request. Guide the user`
+ ' to correct that provider\'s settings on the Models page or in the deployment configuration, or to set'
+ ` ${name}.${prefix}BaseURL and ${name}.${prefix}ApiKeyEnv so ${subject} uses its own endpoint.` }
}
/**
* Model-facing diagnostic for an operation billed by `rule` that has no credential to send.
* @param capability - the capability that stops.
* @param rule - the rule that ordered its credentials.
* @param keyRef - the reference of the API key the rule tries, or `DEEPSEEK_API_KEY` when it tries none.
* @returns the diagnostic naming the missing credentials and their recoveries.
*/
function missingMessage(capability: Capability, rule: Rule, keyRef: CredentialRef): string {
const { subject, prefix } = CAPABILITY[capability]
const ref = JSON.stringify(keyRef)
const addKey = `add the DeepSeek API key on the Models page or export ${ref} in the launching environment`
const billing = `${name}.${prefix}Billing`
const settings = 'on the Web search and fetch settings page'
switch (rule) {
case 'account-conversation':
return `DeepSeek ${subject} API uses the DeepSeek account sign-in of this conversation, then the DeepSeek API key, and`
+ ` neither is available: no signed-in DeepSeek account is available, and no key is configured for ${ref}. Guide the`
+ ` user to sign in to DeepSeek, or to ${addKey}.`
case 'official-conversation':
return `DeepSeek ${subject} API uses the DeepSeek API key, as this conversation's model provider does, and no key is`
+ ` configured for ${ref}. Guide the user to ${addKey}, or to sign in to DeepSeek and set ${billing} to "account"`
+ ` ${settings}.`
case 'other-conversation':
return `DeepSeek ${subject} API uses the DeepSeek API key, then the DeepSeek account sign-in, and neither is available:`
+ ` no key is configured for ${ref}, and no signed-in DeepSeek account is available. Guide the user to ${addKey}, or`
+ ' to sign in to DeepSeek.'
case 'account':
return `DeepSeek ${subject} API bills only the signed-in DeepSeek account, and no signed-in DeepSeek account is`
+ ` available. ${subject} did not send the request. Guide the user to sign in to DeepSeek, or to set ${billing} to`
+ ` "api-key" or "auto" ${settings}.`
case 'api-key':
return `DeepSeek ${subject} API bills only the DeepSeek API key, and no key is configured for ${ref}. Guide the user to`
+ ` ${addKey}, or to set ${billing} to "account" or "auto" ${settings}.`
/* v8 ignore next -- the switch is exhaustive. */
default: return assertNever(rule)
}
}
/** The message of a thrown value. */
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* Find the effective config of the active loader entry that registers `provider`, as the model provider directory
* names it. A route registered by another package, nested settings, or an inactive entry counts as absent.
*/
function routeConfig(ctx: Context, provider: RouteName): unknown {
const llm = ctx.get('llm'), loader = ctx.get('loader')
if (llm === undefined || loader === undefined || !llm.listProviders().some(row => row.id === provider)) return undefined
const directory = llm.listConfigurableProviders().find(row => row.provider === provider)
if (directory === undefined || directory.settingsPath.length !== 0) return undefined
const entry = [...loader.entries()].find(row => row.options.id === directory.settingsNs && row.options.name === ROUTE_PACKAGES[provider])
if (entry?.fiber?.state !== FiberState.ACTIVE) return undefined
return entry.fiber.effectiveConfig
}
/** The Search and Fetch API base under the origin of a Messages base URL. */
function toolsBase(messagesBaseURL: string): string {
return `${new URL(messagesBaseURL).origin}${TOOLS_PATH}`
}
/** A string with content, or undefined for an absent or empty value. */
function nonEmpty(value: string | undefined): string | undefined {
return value !== undefined && value.length > 0 ? value : undefined
}
/* v8 ignore start -- reached only by a value outside a closed union, which the compiler rejects. */
/** Reject an unhandled member of a closed union. */
function assertNever(value: never): never {
throw new Error(`web-deepseek-api: unexpected value ${String(value)}`)
}
/* v8 ignore stop */