/** * 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 { assertUsableApiKey, LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { environmentOf, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' 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 { /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, 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 = 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 = z.object({ 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' /** Environment variable naming this provider's endpoint, honored only from trusted layers. */ const BASE_URL_ENV = 'DEEPSEEK_BASE_URL' /** * 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() 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. * @param environment - this run's environment layers, or `undefined` outside * the product CLI. Every layer may supply an endpoint: the product trusts the * project it is launched in, so a checkout can point its own agent at the * gateway that checkout is meant to use. * @returns validated connection facts plus the credential reference. */ export function resolveAdapterOptions(config: Config, environment?: EnvironmentSnapshot): 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 { apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), baseURL: config.baseURL ?? environment?.get(BASE_URL_ENV)?.value ?? 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, environmentOf(ctx)) 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 => { // Every credential fact comes from the caller's snapshot, so a rejected // settings generation cannot leak its key onto the previous endpoint. const ref = connection.apiKeyEnv const credentials = ctx.get('credentials') if (credentials !== undefined) { const hit = await credentials.resolve(ref) if (hit !== undefined) return assertUsableApiKey(hit.value, 'llm-deepseek', ref) } else { // Without the seam there is no managed store to rank against, so the // environment is the whole credential plane. const ambient = environmentOf(ctx).get(ref) if (ambient !== undefined && ambient.value.length > 0) { return assertUsableApiKey(ambient.value, 'llm-deepseek', ref) } } throw new LlmError( `llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials` + ` service (the web Models page writes it), or export ${ref} in the launching environment`, '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, }) }