index.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. /**
  2. * Register a {@link DeepSeekAdapter} for the `deepseek-official` provider route on
  3. * `ctx.llm`, with connection facts resolved per request instead of frozen at
  4. * load: the plugin layers its `cordis.yml` entry config under the optional
  5. * `llm-deepseek` user-settings section (`ctx.settings`) and resolves the API
  6. * key through the optional credential seam (`ctx.credentials`), so a changed
  7. * base URL, catalog, or key reaches the very next request without restarting
  8. * anything, while an in-flight stream keeps the facts it started with. The
  9. * one registration-captured fact — the retry policy — re-registers the route
  10. * in place when it changes.
  11. * @module @deepseek-ai/dsh-llm-deepseek
  12. */
  13. import type { Context } from 'cordis'
  14. import z from 'schemastery'
  15. import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
  16. import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
  17. import { credentialRef } from '@deepseek-ai/dsh-credentials'
  18. import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
  19. import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
  20. import {
  21. DEFAULT_CONTEXT_WINDOW,
  22. DEFAULT_MAX_TOKENS,
  23. DEFAULT_STREAM_IDLE_TIMEOUT_MS,
  24. DeepSeekAdapter,
  25. } from './adapter.ts'
  26. import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts'
  27. export {
  28. DEFAULT_CONTEXT_WINDOW,
  29. DEFAULT_MAX_TOKENS,
  30. DEFAULT_STREAM_IDLE_TIMEOUT_MS,
  31. DeepSeekAdapter,
  32. } from './adapter.ts'
  33. export type { DeepSeekAdapterOptions, DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts'
  34. export type { RequestDefaults } from './serialize.ts'
  35. export type * from './types.ts'
  36. export const name = 'llm-deepseek'
  37. export const inject = ['llm']
  38. const NS = settingsNamespace('llm-deepseek')
  39. const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY'
  40. /** The single provider route this plugin owns. */
  41. const PROVIDER = 'deepseek-official'
  42. const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
  43. { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: DEFAULT_CONTEXT_WINDOW },
  44. { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: DEFAULT_CONTEXT_WINDOW },
  45. ]
  46. /**
  47. * Plugin config, validated by the same-named schemastery schema and doubling
  48. * as the `llm-deepseek` settings-section shape. Every field is optional in
  49. * yml: a missing API key resolves through {@link Config.apiKeyEnv} at each
  50. * request (a request without any key fails with `MISSING_CREDENTIAL`, not at
  51. * plugin load), omitted thinking mode uses the provider default, and omitted
  52. * reasoning effort resolves to `high`.
  53. */
  54. export interface Config {
  55. /** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
  56. apiKey?: string
  57. /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
  58. apiKeyEnv?: string
  59. /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
  60. baseURL?: string
  61. /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */
  62. thinking?: 'enabled' | 'disabled'
  63. /** Default thinking effort (default `high`); `off` disables thinking per request. */
  64. reasoningEffort?: 'off' | 'high' | 'max'
  65. /** Default per-request output cap (default 256,000); a model's own cap and explicit request values win. */
  66. maxTokens?: number
  67. /** Positive context capacity used when the selected model has no exact value (default 1,000,000). */
  68. defaultContextWindow?: number
  69. /** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */
  70. models?: DeepSeekCatalogModel[]
  71. /** Maximum provider idle time while one stream read is outstanding (default five minutes). */
  72. streamIdleTimeoutMs?: number
  73. /** Provider-owned model-request retry policy; omission uses normal defaults. */
  74. retryPolicy?: RetryPolicyConfig
  75. }
  76. const catalogModel: z<DeepSeekCatalogModel> = z.object({
  77. id: z.string().required(),
  78. name: z.string(),
  79. description: z.string(),
  80. contextWindow: z.number().step(1).min(1),
  81. maxTokens: z.number().step(1).min(1),
  82. })
  83. export const Config: z<Config> = z.object({
  84. apiKey: z.string().role('secret'),
  85. apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
  86. baseURL: z.string(),
  87. thinking: z.union(['enabled', 'disabled']),
  88. reasoningEffort: z.union(['off', 'high', 'max']),
  89. maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_TOKENS),
  90. defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW),
  91. models: z.array(catalogModel).default(DEFAULT_MODELS),
  92. streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
  93. retryPolicy: RetryPolicySchema,
  94. })
  95. /** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
  96. export const PUBLIC_BASE_URL = 'https://api.deepseek.com'
  97. /**
  98. * One resolution's complete request facts. Connection and credential facts
  99. * are one value on purpose: a snapshot the resolver rejects keeps the whole
  100. * previous generation, so a request can never pair a stale endpoint with a
  101. * newer key.
  102. */
  103. export type ResolvedDeepSeekOptions = DeepSeekConnectionOptions
  104. /** Resolve, validate, and detach the advisory model catalog. */
  105. function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): DeepSeekCatalogModel[] {
  106. const seen = new Set<string>()
  107. return (models ?? DEFAULT_MODELS).map((model) => {
  108. if (model.id.length === 0) throw new Error('llm-deepseek: catalog model ids must be non-empty')
  109. if (model.name !== undefined && model.name.length === 0) {
  110. throw new Error(`llm-deepseek: catalog model "${model.id}" has an empty name`)
  111. }
  112. if (model.contextWindow !== undefined
  113. && (!Number.isInteger(model.contextWindow) || model.contextWindow <= 0)) {
  114. throw new Error(
  115. `llm-deepseek: catalog model "${model.id}" contextWindow must be a positive integer`,
  116. )
  117. }
  118. if (model.maxTokens !== undefined
  119. && (!Number.isInteger(model.maxTokens) || model.maxTokens <= 0)) {
  120. throw new Error(
  121. `llm-deepseek: catalog model "${model.id}" maxTokens must be a positive integer`,
  122. )
  123. }
  124. if (seen.has(model.id)) throw new Error(`llm-deepseek: duplicate catalog model "${model.id}"`)
  125. seen.add(model.id)
  126. return {
  127. id: model.id,
  128. ...model.name === undefined ? {} : { name: model.name },
  129. ...model.description === undefined ? {} : { description: model.description },
  130. ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
  131. ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
  132. }
  133. })
  134. }
  135. /**
  136. * The one explicit resolve step from raw config to validated connection
  137. * facts. Programmatic construction may bypass Schemastery normalization, so
  138. * every default and bound is re-judged here — for the composition entry at
  139. * load (fail loud) and for each settings snapshot at its first use.
  140. * @param config - raw plugin config or resolved settings snapshot.
  141. * @returns validated connection facts plus the credential reference.
  142. */
  143. export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
  144. if (config.thinking === 'disabled'
  145. && config.reasoningEffort !== undefined
  146. && config.reasoningEffort !== 'off') {
  147. throw new Error('llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled')
  148. }
  149. if (config.defaultContextWindow !== undefined
  150. && (!Number.isInteger(config.defaultContextWindow) || config.defaultContextWindow <= 0)) {
  151. throw new Error('llm-deepseek: defaultContextWindow must be a positive integer')
  152. }
  153. if (config.maxTokens !== undefined
  154. && (!Number.isSafeInteger(config.maxTokens) || config.maxTokens <= 0)) {
  155. throw new Error('llm-deepseek: maxTokens must be a positive safe integer')
  156. }
  157. const streamIdleTimeoutMs = config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
  158. if (!Number.isFinite(streamIdleTimeoutMs)
  159. || streamIdleTimeoutMs <= 0
  160. || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
  161. throw new Error(
  162. `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
  163. )
  164. }
  165. return {
  166. ...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {},
  167. apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
  168. baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL,
  169. defaults: {
  170. thinking: config.thinking,
  171. reasoningEffort: config.reasoningEffort,
  172. },
  173. maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
  174. defaultContextWindow: config.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,
  175. models: resolveModels(config.models),
  176. streamIdleTimeoutMs,
  177. retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-deepseek: retryPolicy'),
  178. }
  179. }
  180. export function apply(ctx: Context, config: Config): void {
  181. let current: () => Config = () => config
  182. let lastRaw: Config | undefined
  183. let lastGood: ResolvedDeepSeekOptions | undefined
  184. const options = (): ResolvedDeepSeekOptions => {
  185. const raw = current()
  186. if (raw === lastRaw && lastGood !== undefined) return lastGood
  187. try {
  188. const next = resolveAdapterOptions(raw)
  189. lastRaw = raw
  190. lastGood = next
  191. return next
  192. } catch (error) {
  193. // Static composition resolves before anything registers, so this branch
  194. // only sees a live settings snapshot failing a beyond-schema bound:
  195. // keep serving the last good facts and say so once per bad snapshot.
  196. if (lastGood === undefined) throw error
  197. lastRaw = raw
  198. ctx.logger.error('llm-deepseek: keeping the last good configuration after an invalid settings section')
  199. ctx.logger.error(error)
  200. return lastGood
  201. }
  202. }
  203. options()
  204. const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise<string> => {
  205. // Every credential fact comes from the caller's snapshot, so a rejected
  206. // settings generation cannot leak its key onto the previous endpoint.
  207. if (connection.apiKey !== undefined) return connection.apiKey
  208. const ref = connection.apiKeyEnv
  209. const credentials = ctx.get('credentials')
  210. if (credentials !== undefined) {
  211. const hit = await credentials.resolve(ref)
  212. if (hit !== undefined) return hit.value
  213. } else {
  214. // Without the seam, keep the historical ambient fallback so a plain
  215. // cordis.yml composition works from the environment alone.
  216. const ambient = process.env[ref]
  217. if (ambient !== undefined && ambient.length > 0) return ambient
  218. }
  219. throw new LlmError(
  220. `llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials`
  221. + ` service (the web Models page writes it), export ${ref} in the launching environment, or — as a`
  222. + ' last resort — set a literal "apiKey" in the llm-deepseek settings section',
  223. 'MISSING_CREDENTIAL',
  224. )
  225. }
  226. const adapter = new DeepSeekAdapter({ options, resolveApiKey })
  227. ctx.llm.registerConfigurableProviders([
  228. { provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] },
  229. ])
  230. // Route effects bind to this apply fiber via the stable `ctx` reference,
  231. // even when a swap runs inside the scoped settings callback below.
  232. const registration = ctx.llm.registerAdapter([PROVIDER], adapter)
  233. let registeredPolicy = options().retryPolicy
  234. const ensureRegistrationFacts = (): void => {
  235. const policy = options().retryPolicy
  236. if (deepEqualJson(policy, registeredPolicy)) return
  237. // The registry captures the retry policy at registration, so it is the one
  238. // fact per-request resolution cannot refresh. `replace` re-reads it in one
  239. // synchronous registry section: disposing and re-registering instead would
  240. // publish an empty route set between the two, and an observer that reacted
  241. // to it would see this provider disappear and come back.
  242. registration.replace([PROVIDER])
  243. registeredPolicy = policy
  244. }
  245. installSettingsSection(ctx, NS, Config, config, {
  246. setSource: (source) => {
  247. current = source
  248. },
  249. onChange: ensureRegistrationFacts,
  250. })
  251. }