index.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  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 '@deepseek-ai/cordis'
  14. import z from '@deepseek-ai/schemastery'
  15. import { assertUsableApiKey, LlmError, resolveImageAttachmentAccess, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
  16. import type { ModelModality, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
  17. import type {} from '@deepseek-ai/dsh-fs'
  18. import { credentialRef } from '@deepseek-ai/dsh-credentials'
  19. import { launchEnvironmentOf, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
  20. import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
  21. import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
  22. import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
  23. import {
  24. DEFAULT_CONTEXT_WINDOW,
  25. DEFAULT_FILE_EXPIRY_SECONDS,
  26. DEFAULT_FILE_QUOTA_CLEANUP_BATCH,
  27. DEFAULT_FILE_REFRESH_MARGIN_SECONDS,
  28. DEFAULT_FILES_API_TIMEOUT_MS,
  29. DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM,
  30. DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM,
  31. DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM,
  32. DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET,
  33. DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES,
  34. DEFAULT_MAX_IMAGES_PER_REQUEST,
  35. DEFAULT_MAX_REQUEST_FILES_BYTES,
  36. DEFAULT_MAX_TOKENS,
  37. DEFAULT_REQUEST_IMAGE_MAX_BYTES,
  38. DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
  39. DEFAULT_STREAM_IDLE_TIMEOUT_MS,
  40. DeepSeekAdapter,
  41. } from './adapter.ts'
  42. import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts'
  43. export {
  44. DEFAULT_CONTEXT_WINDOW,
  45. DEFAULT_FILE_EXPIRY_SECONDS,
  46. DEFAULT_FILE_QUOTA_CLEANUP_BATCH,
  47. DEFAULT_FILE_REFRESH_MARGIN_SECONDS,
  48. DEFAULT_FILES_API_TIMEOUT_MS,
  49. DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM,
  50. DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM,
  51. DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM,
  52. DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET,
  53. DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES,
  54. DEFAULT_MAX_IMAGES_PER_REQUEST,
  55. DEFAULT_MAX_REQUEST_FILES_BYTES,
  56. DEFAULT_MAX_TOKENS,
  57. DEFAULT_REQUEST_IMAGE_MAX_BYTES,
  58. DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
  59. DEFAULT_STREAM_IDLE_TIMEOUT_MS,
  60. DeepSeekAdapter,
  61. } from './adapter.ts'
  62. export type { DeepSeekAdapterOptions, DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts'
  63. export { DeepSeekFileStore, MAX_CHAT_IMAGE_BYTES } from './file-store.ts'
  64. export type { DeepSeekFileConnection, DeepSeekFilePolicy, DeepSeekFileReference } from './file-store.ts'
  65. export { DeepSeekFilesClient, MAX_FILE_EXPIRY_SECONDS, MAX_FILE_UPLOAD_BYTES, MAX_STORED_FILE_BYTES, MAX_STORED_FILE_COUNT, MIN_FILE_EXPIRY_SECONDS } from './files-api.ts'
  66. export type { DeepSeekFileObject, DeepSeekFilePage } from './files-api.ts'
  67. export { DeepSeekFileId } from './file-id.ts'
  68. export type { DeepSeekFileId as DeepSeekFileIdType } from './file-id.ts'
  69. export { DeepSeekUploadIndex, deepSeekFileScope } from './upload-index.ts'
  70. export type { DeepSeekUploadRecord } from './upload-index.ts'
  71. export type { RequestDefaults } from './serialize.ts'
  72. export type * from './types.ts'
  73. export const name = 'llm-deepseek'
  74. export const inject = ['llm']
  75. const NS = settingsNamespace('llm-deepseek')
  76. const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY'
  77. /** The single provider route this plugin owns. */
  78. const PROVIDER = 'deepseek-official'
  79. const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
  80. {
  81. id: 'deepseek-v4-flash',
  82. name: 'DeepSeek-V4-Flash',
  83. description: 'Fast, efficient, and economical; suited to focused, routine, or parallel tasks.',
  84. contextWindow: DEFAULT_CONTEXT_WINDOW,
  85. },
  86. {
  87. id: 'deepseek-v4-pro',
  88. name: 'DeepSeek-V4-Pro',
  89. description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.',
  90. contextWindow: DEFAULT_CONTEXT_WINDOW,
  91. },
  92. {
  93. id: 'deepseek-v4-flash-vision-exp',
  94. name: 'DeepSeek-V4-Flash-Vision-Exp',
  95. contextWindow: DEFAULT_CONTEXT_WINDOW,
  96. inputModalities: ['text', 'image'],
  97. imagePixelBudget: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
  98. imageMaxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES,
  99. },
  100. ]
  101. const MODEL_MODALITIES = ['text', 'image'] as const satisfies readonly ModelModality[]
  102. /**
  103. * Plugin config, validated by the same-named schemastery schema and doubling
  104. * as the `llm-deepseek` settings-section shape. Every field is optional in
  105. * yml: a missing API key resolves through {@link Config.apiKeyEnv} at each
  106. * request (a request without any key fails with `MISSING_CREDENTIAL`, not at
  107. * plugin load), omitted thinking mode uses the provider default, and omitted
  108. * reasoning effort resolves to `high`.
  109. */
  110. export interface Config {
  111. /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
  112. apiKeyEnv?: string
  113. /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */
  114. baseURL?: string
  115. /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */
  116. thinking?: 'enabled' | 'disabled'
  117. /** Default thinking effort (default `high`); `off` disables thinking per request. */
  118. reasoningEffort?: 'off' | 'low' | 'high' | 'max'
  119. /** Default per-request output cap (default 256,000); a model's own cap and explicit request values win. */
  120. maxTokens?: number
  121. /** Positive context capacity used when the selected model has no exact value (default 1,000,000). */
  122. defaultContextWindow?: number
  123. /** Advisory models shown by discovery consumers; defaults to V4 Flash, V4 Pro, and V4 Flash Vision Exp. */
  124. models?: DeepSeekCatalogModel[]
  125. /** Maximum provider idle time while one stream read is outstanding (default five minutes). */
  126. streamIdleTimeoutMs?: number
  127. /** Maximum accumulated file-referenced image bytes per chat request (default 128 MiB). */
  128. maxRequestFilesBytes?: number
  129. /** Maximum accumulated base64 image payload after Files API fallback (default 20 MiB). */
  130. maxInlineRequestImageBytes?: number
  131. /** Maximum number of represented images per chat request (default 600). */
  132. maxImagesPerRequest?: number
  133. /** Raw-byte removal step after the request exceeds its file bound (default 64 MiB). */
  134. imageOffloadByteQuantum?: number
  135. /** Base64-byte removal step after inline fallback exceeds its bound (default 10 MiB). */
  136. inlineImageOffloadByteQuantum?: number
  137. /** Image-count removal step after the request exceeds its count bound (default 20). */
  138. imageOffloadCountQuantum?: number
  139. /** Maximum duration of one request-image Files API resolution (default one minute). */
  140. filesApiTimeoutMs?: number
  141. /** Explicit lifetime assigned to each uploaded image (default seven days). */
  142. fileExpiresAfterSeconds?: number
  143. /** Remaining lifetime below which an indexed file is replaced (default one hour). */
  144. fileRefreshMarginSeconds?: number
  145. /** Oldest harness-owned files deleted before one quota-recovery upload retry (default 100). */
  146. fileQuotaCleanupBatch?: number
  147. /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */
  148. retryPolicy?: RetryPolicyConfig
  149. }
  150. const catalogModel: z<DeepSeekCatalogModel> = z.object({
  151. id: z.string().required(),
  152. name: z.string(),
  153. description: z.string(),
  154. contextWindow: z.number().step(1).min(1),
  155. maxTokens: z.number().step(1).min(1),
  156. inputModalities: z.array(z.union(MODEL_MODALITIES)).min(1).default(['text']),
  157. imagePixelBudget: z.union([z.number().step(1).min(1), 'low']),
  158. imageMaxBytes: z.number().step(1).min(1),
  159. })
  160. export const Config: z<Config> = z.object({
  161. apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
  162. baseURL: z.string(),
  163. thinking: z.union(['enabled', 'disabled']),
  164. reasoningEffort: z.union(['off', 'low', 'high', 'max']),
  165. maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_TOKENS),
  166. defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW),
  167. models: z.array(catalogModel).default(DEFAULT_MODELS),
  168. streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
  169. maxRequestFilesBytes: z.number().step(1).min(1).default(DEFAULT_MAX_REQUEST_FILES_BYTES),
  170. maxInlineRequestImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES),
  171. maxImagesPerRequest: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGES_PER_REQUEST),
  172. imageOffloadByteQuantum: z.number().step(1).min(1).default(DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM),
  173. inlineImageOffloadByteQuantum: z.number().step(1).min(1).default(DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM),
  174. imageOffloadCountQuantum: z.number().step(1).min(1).default(DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM),
  175. filesApiTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_FILES_API_TIMEOUT_MS),
  176. fileExpiresAfterSeconds: z.number().step(1).min(3_600).max(2_592_000).default(DEFAULT_FILE_EXPIRY_SECONDS),
  177. fileRefreshMarginSeconds: z.number().step(1).min(0).default(DEFAULT_FILE_REFRESH_MARGIN_SECONDS),
  178. fileQuotaCleanupBatch: z.number().step(1).min(1).max(1_000).default(DEFAULT_FILE_QUOTA_CLEANUP_BATCH),
  179. retryPolicy: RetryPolicySchema,
  180. })
  181. /** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
  182. export const PUBLIC_BASE_URL = 'https://api.deepseek.com'
  183. /** Environment variable naming this provider's endpoint, honored only from trusted layers. */
  184. const BASE_URL_ENV = 'DEEPSEEK_BASE_URL'
  185. /**
  186. * One resolution's complete request facts. Connection and credential facts
  187. * are one value on purpose: a snapshot the resolver rejects keeps the whole
  188. * previous generation, so a request can never pair a stale endpoint with a
  189. * newer key.
  190. */
  191. export type ResolvedDeepSeekOptions = DeepSeekConnectionOptions
  192. /** Resolve, validate, and detach the advisory model catalog. */
  193. function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): DeepSeekCatalogModel[] {
  194. const seen = new Set<string>()
  195. return (models ?? DEFAULT_MODELS).map((model) => {
  196. if (Object.hasOwn(model, 'imageDetail')) {
  197. throw new Error('llm-deepseek: catalog model imageDetail is no longer supported; use imagePixelBudget')
  198. }
  199. if (model.id.length === 0) throw new Error('llm-deepseek: catalog model ids must be non-empty')
  200. if (model.name !== undefined && model.name.length === 0) {
  201. throw new Error(`llm-deepseek: catalog model "${model.id}" has an empty name`)
  202. }
  203. if (model.contextWindow !== undefined
  204. && (!Number.isInteger(model.contextWindow) || model.contextWindow <= 0)) {
  205. throw new Error(
  206. `llm-deepseek: catalog model "${model.id}" contextWindow must be a positive integer`,
  207. )
  208. }
  209. if (model.maxTokens !== undefined
  210. && (!Number.isInteger(model.maxTokens) || model.maxTokens <= 0)) {
  211. throw new Error(
  212. `llm-deepseek: catalog model "${model.id}" maxTokens must be a positive integer`,
  213. )
  214. }
  215. const inputModalities = model.inputModalities ?? ['text']
  216. if (inputModalities.length === 0) {
  217. throw new Error(`llm-deepseek: catalog model "${model.id}" inputModalities must not be empty`)
  218. }
  219. if (inputModalities.some(modality => !MODEL_MODALITIES.includes(modality))) {
  220. throw new Error(
  221. `llm-deepseek: catalog model "${model.id}" inputModalities must contain only "text" and "image"`,
  222. )
  223. }
  224. if (new Set(inputModalities).size !== inputModalities.length) {
  225. throw new Error(`llm-deepseek: catalog model "${model.id}" inputModalities must not contain duplicates`)
  226. }
  227. const hasImage = inputModalities.includes('image')
  228. if (!hasImage && (model.imagePixelBudget !== undefined || model.imageMaxBytes !== undefined)) {
  229. throw new Error(`llm-deepseek: text-only catalog model "${model.id}" cannot declare image request limits`)
  230. }
  231. if (model.imagePixelBudget !== undefined
  232. && model.imagePixelBudget !== 'low'
  233. && (!Number.isSafeInteger(model.imagePixelBudget) || model.imagePixelBudget <= 0)) {
  234. throw new Error(`llm-deepseek: catalog model "${model.id}" imagePixelBudget must be "low" or a positive safe integer`)
  235. }
  236. if (model.imageMaxBytes !== undefined
  237. && (!Number.isSafeInteger(model.imageMaxBytes) || model.imageMaxBytes <= 0)) {
  238. throw new Error(`llm-deepseek: catalog model "${model.id}" imageMaxBytes must be a positive safe integer`)
  239. }
  240. if (seen.has(model.id)) throw new Error(`llm-deepseek: duplicate catalog model "${model.id}"`)
  241. seen.add(model.id)
  242. return {
  243. id: model.id,
  244. ...model.name === undefined ? {} : { name: model.name },
  245. ...model.description === undefined ? {} : { description: model.description },
  246. ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
  247. ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
  248. inputModalities: [...inputModalities],
  249. ...hasImage
  250. ? {
  251. imagePixelBudget: model.imagePixelBudget === 'low'
  252. ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET
  253. : model.imagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
  254. imageMaxBytes: model.imageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES,
  255. }
  256. : {},
  257. }
  258. })
  259. }
  260. /**
  261. * The one explicit resolve step from raw config to validated connection
  262. * facts. Programmatic construction may bypass Schemastery normalization, so
  263. * every default and bound is re-judged here — for the composition entry at
  264. * load (fail loud) and for each settings snapshot at its first use.
  265. * @param config - raw plugin config or resolved settings snapshot.
  266. * @param environment - this run's environment layers, or `undefined` outside
  267. * the product CLI. Every layer may supply an endpoint: the product trusts the
  268. * project it is launched in, so a checkout can point its own agent at the
  269. * gateway that checkout is meant to use.
  270. * @returns validated connection facts plus the credential reference.
  271. */
  272. export function resolveAdapterOptions(config: Config, environment?: LaunchEnvironmentSnapshot): ResolvedDeepSeekOptions {
  273. if (config.thinking === 'disabled'
  274. && config.reasoningEffort !== undefined
  275. && config.reasoningEffort !== 'off') {
  276. throw new Error('llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled')
  277. }
  278. if (config.defaultContextWindow !== undefined
  279. && (!Number.isInteger(config.defaultContextWindow) || config.defaultContextWindow <= 0)) {
  280. throw new Error('llm-deepseek: defaultContextWindow must be a positive integer')
  281. }
  282. if (config.maxTokens !== undefined
  283. && (!Number.isSafeInteger(config.maxTokens) || config.maxTokens <= 0)) {
  284. throw new Error('llm-deepseek: maxTokens must be a positive safe integer')
  285. }
  286. const streamIdleTimeoutMs = config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
  287. if (!Number.isFinite(streamIdleTimeoutMs)
  288. || streamIdleTimeoutMs <= 0
  289. || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
  290. throw new Error(
  291. `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
  292. )
  293. }
  294. const maxRequestFilesBytes = config.maxRequestFilesBytes ?? DEFAULT_MAX_REQUEST_FILES_BYTES
  295. if (!Number.isSafeInteger(maxRequestFilesBytes) || maxRequestFilesBytes <= 0) {
  296. throw new Error('llm-deepseek: maxRequestFilesBytes must be a positive safe integer')
  297. }
  298. const maxInlineRequestImageBytes = config.maxInlineRequestImageBytes ?? DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES
  299. if (!Number.isSafeInteger(maxInlineRequestImageBytes) || maxInlineRequestImageBytes <= 0) {
  300. throw new Error('llm-deepseek: maxInlineRequestImageBytes must be a positive safe integer')
  301. }
  302. const maxImagesPerRequest = config.maxImagesPerRequest ?? DEFAULT_MAX_IMAGES_PER_REQUEST
  303. if (!Number.isSafeInteger(maxImagesPerRequest) || maxImagesPerRequest <= 0) {
  304. throw new Error('llm-deepseek: maxImagesPerRequest must be a positive safe integer')
  305. }
  306. const imageOffloadByteQuantum = config.imageOffloadByteQuantum ?? DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM
  307. if (!Number.isSafeInteger(imageOffloadByteQuantum) || imageOffloadByteQuantum <= 0) {
  308. throw new Error('llm-deepseek: imageOffloadByteQuantum must be a positive safe integer')
  309. }
  310. if (imageOffloadByteQuantum > maxRequestFilesBytes) {
  311. throw new Error('llm-deepseek: imageOffloadByteQuantum must not exceed maxRequestFilesBytes')
  312. }
  313. const inlineImageOffloadByteQuantum = config.inlineImageOffloadByteQuantum
  314. ?? DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM
  315. if (!Number.isSafeInteger(inlineImageOffloadByteQuantum) || inlineImageOffloadByteQuantum <= 0) {
  316. throw new Error('llm-deepseek: inlineImageOffloadByteQuantum must be a positive safe integer')
  317. }
  318. if (inlineImageOffloadByteQuantum > maxInlineRequestImageBytes) {
  319. throw new Error('llm-deepseek: inlineImageOffloadByteQuantum must not exceed maxInlineRequestImageBytes')
  320. }
  321. const imageOffloadCountQuantum = config.imageOffloadCountQuantum ?? DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM
  322. if (!Number.isSafeInteger(imageOffloadCountQuantum) || imageOffloadCountQuantum <= 0) {
  323. throw new Error('llm-deepseek: imageOffloadCountQuantum must be a positive safe integer')
  324. }
  325. if (imageOffloadCountQuantum > maxImagesPerRequest) {
  326. throw new Error('llm-deepseek: imageOffloadCountQuantum must not exceed maxImagesPerRequest')
  327. }
  328. const filesApiTimeoutMs = config.filesApiTimeoutMs ?? DEFAULT_FILES_API_TIMEOUT_MS
  329. if (!Number.isFinite(filesApiTimeoutMs)
  330. || filesApiTimeoutMs <= 0
  331. || filesApiTimeoutMs > MAX_TIMER_DELAY_MS) {
  332. throw new Error(
  333. `llm-deepseek: filesApiTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
  334. )
  335. }
  336. const fileExpiresAfterSeconds = config.fileExpiresAfterSeconds ?? DEFAULT_FILE_EXPIRY_SECONDS
  337. if (!Number.isSafeInteger(fileExpiresAfterSeconds)
  338. || fileExpiresAfterSeconds < 3_600
  339. || fileExpiresAfterSeconds > 2_592_000) {
  340. throw new Error('llm-deepseek: fileExpiresAfterSeconds must be an integer from 3600 through 2592000')
  341. }
  342. const fileRefreshMarginSeconds = config.fileRefreshMarginSeconds ?? DEFAULT_FILE_REFRESH_MARGIN_SECONDS
  343. if (!Number.isSafeInteger(fileRefreshMarginSeconds)
  344. || fileRefreshMarginSeconds < 0
  345. || fileRefreshMarginSeconds >= fileExpiresAfterSeconds) {
  346. throw new Error('llm-deepseek: fileRefreshMarginSeconds must be a non-negative integer below fileExpiresAfterSeconds')
  347. }
  348. const fileQuotaCleanupBatch = config.fileQuotaCleanupBatch ?? DEFAULT_FILE_QUOTA_CLEANUP_BATCH
  349. if (!Number.isSafeInteger(fileQuotaCleanupBatch)
  350. || fileQuotaCleanupBatch < 1
  351. || fileQuotaCleanupBatch > 1_000) {
  352. throw new Error('llm-deepseek: fileQuotaCleanupBatch must be an integer from 1 through 1000')
  353. }
  354. return {
  355. apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
  356. baseURL: config.baseURL
  357. ?? environment?.get(BASE_URL_ENV)?.value
  358. ?? PUBLIC_BASE_URL,
  359. defaults: {
  360. thinking: config.thinking,
  361. reasoningEffort: config.reasoningEffort,
  362. },
  363. maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
  364. defaultContextWindow: config.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,
  365. models: resolveModels(config.models),
  366. streamIdleTimeoutMs,
  367. maxRequestFilesBytes,
  368. maxInlineRequestImageBytes,
  369. maxImagesPerRequest,
  370. imageOffloadByteQuantum,
  371. inlineImageOffloadByteQuantum,
  372. imageOffloadCountQuantum,
  373. filesApiTimeoutMs,
  374. filePolicy: {
  375. expiresAfterSeconds: fileExpiresAfterSeconds,
  376. refreshMarginSeconds: fileRefreshMarginSeconds,
  377. quotaCleanupBatch: fileQuotaCleanupBatch,
  378. },
  379. retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-deepseek: retryPolicy'),
  380. }
  381. }
  382. export function apply(ctx: Context, config: Config): void {
  383. let current: () => Config = () => config
  384. let lastRaw: Config | undefined
  385. let lastGood: ResolvedDeepSeekOptions | undefined
  386. const options = (): ResolvedDeepSeekOptions => {
  387. const raw = current()
  388. if (raw === lastRaw && lastGood !== undefined) return lastGood
  389. try {
  390. const next = resolveAdapterOptions(raw, launchEnvironmentOf(ctx))
  391. lastRaw = raw
  392. lastGood = next
  393. return next
  394. } catch (error) {
  395. // Static composition resolves before anything registers, so this branch
  396. // only sees a live settings snapshot failing a beyond-schema bound:
  397. // keep serving the last good facts and say so once per bad snapshot.
  398. if (lastGood === undefined) throw error
  399. lastRaw = raw
  400. ctx.logger.error('llm-deepseek: keeping the last good configuration after an invalid settings section')
  401. ctx.logger.error(error)
  402. return lastGood
  403. }
  404. }
  405. options()
  406. const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise<string> => {
  407. // Every credential fact comes from the caller's snapshot, so a rejected
  408. // settings generation cannot leak its key onto the previous endpoint.
  409. const ref = connection.apiKeyEnv
  410. const credentials = ctx.get('credentials')
  411. if (credentials !== undefined) {
  412. const hit = await credentials.resolve(ref)
  413. if (hit !== undefined) return assertUsableApiKey(hit.value, 'llm-deepseek', ref)
  414. } else {
  415. // Without the seam there is no managed store to rank against, so the
  416. // environment is the whole credential plane.
  417. const ambient = launchEnvironmentOf(ctx).get(ref)
  418. if (ambient !== undefined && ambient.value.length > 0) {
  419. return assertUsableApiKey(ambient.value, 'llm-deepseek', ref)
  420. }
  421. }
  422. throw new LlmError(
  423. `llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials`
  424. + ` service (the web Models page writes it), or export ${ref} in the launching environment`,
  425. 'MISSING_CREDENTIAL',
  426. )
  427. }
  428. let userId: AnonymousUserId | undefined
  429. const resolveUserId = (): AnonymousUserId => userId ??= getOrCreateAnonymousUserId()
  430. const adapter = new DeepSeekAdapter({
  431. options,
  432. resolveApiKey,
  433. resolveUserId,
  434. resolveAttachments: () => ctx.get('attachments'),
  435. resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(
  436. attachments,
  437. hostPath => ctx.get('fs')?.processPathFromHostPath(hostPath),
  438. ref,
  439. ),
  440. prepareExtensions: (request) => {
  441. const extensions = ctx.get('deepseekLlmApiExtensions')
  442. return extensions?.prepare(request)
  443. ?? Promise.resolve({ fields: {}, accept: () => Promise.resolve() })
  444. },
  445. })
  446. ctx.llm.registerConfigurableProviders([
  447. { provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] },
  448. ])
  449. // Route effects bind to this apply fiber via the stable `ctx` reference,
  450. // even when a swap runs inside the scoped settings callback below.
  451. const registration = ctx.llm.registerAdapter([PROVIDER], adapter)
  452. let registeredPolicy = options().retryPolicy
  453. const ensureRegistrationFacts = (): void => {
  454. const policy = options().retryPolicy
  455. if (deepEqualJson(policy, registeredPolicy)) return
  456. // The registry captures the retry policy at registration, so it is the one
  457. // fact per-request resolution cannot refresh. `replace` re-reads it in one
  458. // synchronous registry section: disposing and re-registering instead would
  459. // publish an empty route set between the two, and an observer that reacted
  460. // to it would see this provider disappear and come back.
  461. registration.replace([PROVIDER])
  462. registeredPolicy = policy
  463. }
  464. installSettingsSection(ctx, NS, Config, config, {
  465. setSource: (source) => {
  466. current = source
  467. },
  468. onChange: ensureRegistrationFacts,
  469. })
  470. }