| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267 |
- /**
- * Register a {@link DeepSeekAdapter} for the `deepseek-official` provider route on
- * `ctx.llm`, with connection facts resolved per request instead of frozen at
- * load: the plugin layers its `cordis.yml` entry config under the optional
- * `llm-deepseek` user-settings section (`ctx.settings`) and resolves the API
- * key through the optional credential seam (`ctx.credentials`), so a changed
- * base URL, catalog, or key reaches the very next request without restarting
- * anything, while an in-flight stream keeps the facts it started with. The
- * one registration-captured fact — the retry policy — re-registers the route
- * in place when it changes.
- * @module @deepseek-ai/dsh-llm-deepseek
- */
- import type { Context } from 'cordis'
- import z from 'schemastery'
- import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
- import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
- import { credentialRef } from '@deepseek-ai/dsh-credentials'
- import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
- import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
- import {
- DEFAULT_CONTEXT_WINDOW,
- DEFAULT_MAX_TOKENS,
- DEFAULT_STREAM_IDLE_TIMEOUT_MS,
- DeepSeekAdapter,
- } from './adapter.ts'
- import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts'
- export {
- DEFAULT_CONTEXT_WINDOW,
- DEFAULT_MAX_TOKENS,
- DEFAULT_STREAM_IDLE_TIMEOUT_MS,
- DeepSeekAdapter,
- } from './adapter.ts'
- export type { DeepSeekAdapterOptions, DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts'
- export type { RequestDefaults } from './serialize.ts'
- export type * from './types.ts'
- export const name = 'llm-deepseek'
- export const inject = ['llm']
- const NS = settingsNamespace('llm-deepseek')
- const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY'
- /** The single provider route this plugin owns. */
- const PROVIDER = 'deepseek-official'
- const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
- { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: DEFAULT_CONTEXT_WINDOW },
- { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: DEFAULT_CONTEXT_WINDOW },
- ]
- /**
- * Plugin config, validated by the same-named schemastery schema and doubling
- * as the `llm-deepseek` settings-section shape. Every field is optional in
- * yml: a missing API key resolves through {@link Config.apiKeyEnv} at each
- * request (a request without any key fails with `MISSING_CREDENTIAL`, not at
- * plugin load), omitted thinking mode uses the provider default, and omitted
- * reasoning effort resolves to `high`.
- */
- export interface Config {
- /** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
- apiKey?: string
- /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
- apiKeyEnv?: string
- /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
- baseURL?: string
- /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */
- thinking?: 'enabled' | 'disabled'
- /** Default thinking effort (default `high`); `off` disables thinking per request. */
- reasoningEffort?: 'off' | 'high' | 'max'
- /** Default per-request output cap (default 256,000); a model's own cap and explicit request values win. */
- maxTokens?: number
- /** Positive context capacity used when the selected model has no exact value (default 1,000,000). */
- defaultContextWindow?: number
- /** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */
- models?: DeepSeekCatalogModel[]
- /** Maximum provider idle time while one stream read is outstanding (default five minutes). */
- streamIdleTimeoutMs?: number
- /** Provider-owned model-request retry policy; omission uses normal defaults. */
- retryPolicy?: RetryPolicyConfig
- }
- const catalogModel: z<DeepSeekCatalogModel> = z.object({
- id: z.string().required(),
- name: z.string(),
- description: z.string(),
- contextWindow: z.number().step(1).min(1),
- maxTokens: z.number().step(1).min(1),
- })
- export const Config: z<Config> = z.object({
- apiKey: z.string().role('secret'),
- apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
- baseURL: z.string(),
- thinking: z.union(['enabled', 'disabled']),
- reasoningEffort: z.union(['off', 'high', 'max']),
- maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_TOKENS),
- defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW),
- models: z.array(catalogModel).default(DEFAULT_MODELS),
- streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
- retryPolicy: RetryPolicySchema,
- })
- /** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
- export const PUBLIC_BASE_URL = 'https://api.deepseek.com'
- /**
- * One resolution's complete request facts. Connection and credential facts
- * are one value on purpose: a snapshot the resolver rejects keeps the whole
- * previous generation, so a request can never pair a stale endpoint with a
- * newer key.
- */
- export type ResolvedDeepSeekOptions = DeepSeekConnectionOptions
- /** Resolve, validate, and detach the advisory model catalog. */
- function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): DeepSeekCatalogModel[] {
- const seen = new Set<string>()
- return (models ?? DEFAULT_MODELS).map((model) => {
- if (model.id.length === 0) throw new Error('llm-deepseek: catalog model ids must be non-empty')
- if (model.name !== undefined && model.name.length === 0) {
- throw new Error(`llm-deepseek: catalog model "${model.id}" has an empty name`)
- }
- if (model.contextWindow !== undefined
- && (!Number.isInteger(model.contextWindow) || model.contextWindow <= 0)) {
- throw new Error(
- `llm-deepseek: catalog model "${model.id}" contextWindow must be a positive integer`,
- )
- }
- if (model.maxTokens !== undefined
- && (!Number.isInteger(model.maxTokens) || model.maxTokens <= 0)) {
- throw new Error(
- `llm-deepseek: catalog model "${model.id}" maxTokens must be a positive integer`,
- )
- }
- if (seen.has(model.id)) throw new Error(`llm-deepseek: duplicate catalog model "${model.id}"`)
- seen.add(model.id)
- return {
- id: model.id,
- ...model.name === undefined ? {} : { name: model.name },
- ...model.description === undefined ? {} : { description: model.description },
- ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
- ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
- }
- })
- }
- /**
- * The one explicit resolve step from raw config to validated connection
- * facts. Programmatic construction may bypass Schemastery normalization, so
- * every default and bound is re-judged here — for the composition entry at
- * load (fail loud) and for each settings snapshot at its first use.
- * @param config - raw plugin config or resolved settings snapshot.
- * @returns validated connection facts plus the credential reference.
- */
- export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
- if (config.thinking === 'disabled'
- && config.reasoningEffort !== undefined
- && config.reasoningEffort !== 'off') {
- throw new Error('llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled')
- }
- if (config.defaultContextWindow !== undefined
- && (!Number.isInteger(config.defaultContextWindow) || config.defaultContextWindow <= 0)) {
- throw new Error('llm-deepseek: defaultContextWindow must be a positive integer')
- }
- if (config.maxTokens !== undefined
- && (!Number.isSafeInteger(config.maxTokens) || config.maxTokens <= 0)) {
- throw new Error('llm-deepseek: maxTokens must be a positive safe integer')
- }
- const streamIdleTimeoutMs = config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
- if (!Number.isFinite(streamIdleTimeoutMs)
- || streamIdleTimeoutMs <= 0
- || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
- throw new Error(
- `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
- )
- }
- return {
- ...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {},
- apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
- baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL,
- defaults: {
- thinking: config.thinking,
- reasoningEffort: config.reasoningEffort,
- },
- maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
- defaultContextWindow: config.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,
- models: resolveModels(config.models),
- streamIdleTimeoutMs,
- retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-deepseek: retryPolicy'),
- }
- }
- export function apply(ctx: Context, config: Config): void {
- let current: () => Config = () => config
- let lastRaw: Config | undefined
- let lastGood: ResolvedDeepSeekOptions | undefined
- const options = (): ResolvedDeepSeekOptions => {
- const raw = current()
- if (raw === lastRaw && lastGood !== undefined) return lastGood
- try {
- const next = resolveAdapterOptions(raw)
- lastRaw = raw
- lastGood = next
- return next
- } catch (error) {
- // Static composition resolves before anything registers, so this branch
- // only sees a live settings snapshot failing a beyond-schema bound:
- // keep serving the last good facts and say so once per bad snapshot.
- if (lastGood === undefined) throw error
- lastRaw = raw
- ctx.logger.error('llm-deepseek: keeping the last good configuration after an invalid settings section')
- ctx.logger.error(error)
- return lastGood
- }
- }
- options()
- const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise<string> => {
- // Every credential fact comes from the caller's snapshot, so a rejected
- // settings generation cannot leak its key onto the previous endpoint.
- if (connection.apiKey !== undefined) return connection.apiKey
- const ref = connection.apiKeyEnv
- const credentials = ctx.get('credentials')
- if (credentials !== undefined) {
- const hit = await credentials.resolve(ref)
- if (hit !== undefined) return hit.value
- } else {
- // Without the seam, keep the historical ambient fallback so a plain
- // cordis.yml composition works from the environment alone.
- const ambient = process.env[ref]
- if (ambient !== undefined && ambient.length > 0) return ambient
- }
- throw new LlmError(
- `llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials`
- + ` service (the web Models page writes it), export ${ref} in the launching environment, or — as a`
- + ' last resort — set a literal "apiKey" in the llm-deepseek settings section',
- 'MISSING_CREDENTIAL',
- )
- }
- const adapter = new DeepSeekAdapter({ options, resolveApiKey })
- ctx.llm.registerConfigurableProviders([
- { provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] },
- ])
- // Route effects bind to this apply fiber via the stable `ctx` reference,
- // even when a swap runs inside the scoped settings callback below.
- const registration = ctx.llm.registerAdapter([PROVIDER], adapter)
- let registeredPolicy = options().retryPolicy
- const ensureRegistrationFacts = (): void => {
- const policy = options().retryPolicy
- if (deepEqualJson(policy, registeredPolicy)) return
- // The registry captures the retry policy at registration, so it is the one
- // fact per-request resolution cannot refresh. `replace` re-reads it in one
- // synchronous registry section: disposing and re-registering instead would
- // publish an empty route set between the two, and an observer that reacted
- // to it would see this provider disappear and come back.
- registration.replace([PROVIDER])
- registeredPolicy = policy
- }
- installSettingsSection(ctx, NS, Config, config, {
- setSource: (source) => {
- current = source
- },
- onChange: ensureRegistrationFacts,
- })
- }
|