1
/** Plugin configuration and complete request-local resolution for DeepSeek. */2
import type { Volatile } from '@deepseek-ai/cordis'4
import z from '@deepseek-ai/schemastery'5
import { isVolatile } from '@deepseek-ai/cosmokit'6
import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'7
import type { ModelModality, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'8
import type { LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'9
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'10
import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './types.ts'11
import { DEFAULT_MODELS } from './models.ts'12
import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS, DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES, DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM, DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM, DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM, DEFAULT_FILE_EXPIRY_SECONDS, DEFAULT_FILE_REFRESH_MARGIN_SECONDS, DEFAULT_FILE_QUOTA_CLEANUP_BATCH, DEFAULT_FILES_API_TIMEOUT_MS } from './defaults.ts'13
import { DEFAULT_MAX_IMAGES_PER_REQUEST, DEFAULT_MAX_REQUEST_FILES_BYTES, DEFAULT_REQUEST_IMAGE_MAX_BYTES } from './request-pricing.ts'15
const MODEL_MODALITIES = ['text', 'image'] as const satisfies readonly ModelModality[]17
/** Shared Messages request configuration, without provider credential selection. */18
export interface Config {19
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */20
baseURL: Volatile<string | undefined>21
/** Deployment thinking policy; `disabled` limits every conversation request to `off`. */22
thinking: Volatile<'enabled' | 'disabled' | undefined>23
/** Default thinking effort (default `high`); `off` disables thinking per request. */24
reasoningEffort: Volatile<'off' | 'low' | 'high' | 'max' | undefined>25
/** Default per-request output cap (default 256,000); a model's own cap and explicit request values win. */26
maxTokens: Volatile<number>27
/** Positive context capacity used when the selected model has no exact value (default 1,000,000). */28
defaultContextWindow: Volatile<number>29
/** Advisory models shown by discovery consumers; defaults to V41 Flash and V4 Pro. */30
models: Volatile<DeepSeekCatalogModel[]>31
/** Maximum provider idle time while one stream read is outstanding (default five minutes). */32
streamIdleTimeoutMs: Volatile<number>33
/** Maximum accumulated file-referenced image bytes per chat request (default 128 MiB). */34
maxRequestFilesBytes: Volatile<number>35
/** Maximum accumulated base64 image payload after Files API fallback (default 20 MiB). */36
maxInlineRequestImageBytes: Volatile<number>37
/** Maximum number of represented images per chat request (default 600). */38
maxImagesPerRequest: Volatile<number>39
/** Raw-byte removal step after the request exceeds its file bound (default 64 MiB). */40
imageOffloadByteQuantum: Volatile<number>41
/** Base64-byte removal step after inline fallback exceeds its bound (default 10 MiB). */42
inlineImageOffloadByteQuantum: Volatile<number>43
/** Image-count removal step after the request exceeds its count bound (default 20). */44
imageOffloadCountQuantum: Volatile<number>45
/** Maximum duration of one request-image Files API resolution (default one minute). */46
filesApiTimeoutMs: Volatile<number>47
/** Explicit lifetime assigned to each uploaded image (default seven days). */48
fileExpiresAfterSeconds: Volatile<number>49
/** Remaining lifetime below which an indexed file is replaced (default one hour). */50
fileRefreshMarginSeconds: Volatile<number>51
/** Oldest harness-owned files deleted before one quota-recovery upload retry (default 100). */52
fileQuotaCleanupBatch: Volatile<number>53
/** Provider-owned model-request retry policy; omission uses normal mode with five retries. */54
retryPolicy: Volatile<RetryPolicyConfig | undefined>55
}57
/** Plain options accepted by the provider resolver. */58
export type Options = { [K in keyof Config]?: Config[K] extends Volatile<infer T> ? Exclude<T, undefined> : never }60
/** Read the current value behind every reference of a validated Config.61
* @param config Parsed plugin Config.62
* @returns Plain options for the resolver.63
*/64
export function plainOptions(config: Config): Options {65
return Object.fromEntries(Object.entries(config).map(([key, value]) => [key, isVolatile(value) ? value.get() : value]))66
}68
const catalogModel: z<DeepSeekCatalogModel> = z.object({69
id: z.string().required(),70
name: z.string(),71
description: z.string(),72
contextWindow: z.number().step(1).min(1),73
maxTokens: z.number().step(1).min(1),74
inputModalities: z.array(z.union(MODEL_MODALITIES)).min(1).default(['text']),75
imagePixelBudget: z.union([z.number().step(1).min(1), 'low']),76
imageMaxBytes: z.number().step(1).min(1),77
systemPromptUpdate: z.const('in-history'),78
toolUpdate: z.union(['in-history', 'addition-only'] as const),79
})81
/** Shared schema fields for Messages protocol options. */82
export const deepSeekConfigFields = {83
baseURL: z.string().volatile(),84
thinking: z.union(['enabled', 'disabled']).volatile(),85
reasoningEffort: z.union(['off', 'low', 'high', 'max']).volatile(),86
maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_TOKENS).volatile(),87
defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW).volatile(),88
models: z.array(catalogModel).default(DEFAULT_MODELS).volatile(),89
streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS).volatile(),90
maxRequestFilesBytes: z.number().step(1).min(1).default(DEFAULT_MAX_REQUEST_FILES_BYTES).volatile(),91
maxInlineRequestImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES).volatile(),92
maxImagesPerRequest: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGES_PER_REQUEST).volatile(),93
imageOffloadByteQuantum: z.number().step(1).min(1).default(DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM).volatile(),94
inlineImageOffloadByteQuantum: z.number().step(1).min(1).default(DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM).volatile(),95
imageOffloadCountQuantum: z.number().step(1).min(1).default(DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM).volatile(),96
filesApiTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_FILES_API_TIMEOUT_MS).volatile(),97
fileExpiresAfterSeconds: z.number().step(1).min(3_600).max(2_592_000).default(DEFAULT_FILE_EXPIRY_SECONDS).volatile(),98
fileRefreshMarginSeconds: z.number().step(1).min(0).default(DEFAULT_FILE_REFRESH_MARGIN_SECONDS).volatile(),99
fileQuotaCleanupBatch: z.number().step(1).min(1).max(1_000).default(DEFAULT_FILE_QUOTA_CLEANUP_BATCH).volatile(),100
retryPolicy: RetryPolicySchema.volatile(),101
}103
export const Config = z.object(deepSeekConfigFields)105
/** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */106
export const PUBLIC_BASE_URL = 'https://api.deepseek.com/anthropic'108
/** Environment variable naming this provider's endpoint, honored only from trusted layers. */109
const BASE_URL_ENV = 'DEEPSEEK_BASE_URL'111
/** Complete protocol settings captured for one request operation. */112
export type ResolvedDeepSeekOptions = DeepSeekConnectionOptions114
/** Resolve, validate, and detach the advisory model catalog. */115
function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): DeepSeekCatalogModel[] {116
const seen = new Set<string>()117
return (models ?? DEFAULT_MODELS).map((model) => {118
if (Object.hasOwn(model, 'imageDetail')) {119
throw new Error('llm-deepseek: catalog model imageDetail is no longer supported; use imagePixelBudget')120
}121
if (model.id.length === 0) throw new Error('llm-deepseek: catalog model ids must be non-empty')122
if (model.name !== undefined && model.name.length === 0) {123
throw new Error(`llm-deepseek: catalog model "${model.id}" has an empty name`)124
}125
if (model.contextWindow !== undefined126
&& (!Number.isInteger(model.contextWindow) || model.contextWindow <= 0)) {127
throw new Error(128
`llm-deepseek: catalog model "${model.id}" contextWindow must be a positive integer`,129
)130
}131
if (model.maxTokens !== undefined132
&& (!Number.isInteger(model.maxTokens) || model.maxTokens <= 0)) {133
throw new Error(134
`llm-deepseek: catalog model "${model.id}" maxTokens must be a positive integer`,135
)136
}137
const inputModalities = model.inputModalities ?? ['text']138
if (inputModalities.length === 0) {139
throw new Error(`llm-deepseek: catalog model "${model.id}" inputModalities must not be empty`)140
}141
if (inputModalities.some(modality => !MODEL_MODALITIES.includes(modality))) {142
throw new Error(143
`llm-deepseek: catalog model "${model.id}" inputModalities must contain only "text" and "image"`,144
)145
}146
if (new Set(inputModalities).size !== inputModalities.length) {147
throw new Error(`llm-deepseek: catalog model "${model.id}" inputModalities must not contain duplicates`)148
}149
const hasImage = inputModalities.includes('image')150
if (!hasImage && (model.imagePixelBudget !== undefined || model.imageMaxBytes !== undefined)) {151
throw new Error(`llm-deepseek: text-only catalog model "${model.id}" cannot declare image request limits`)152
}153
if (model.imagePixelBudget !== undefined154
&& model.imagePixelBudget !== 'low'155
&& (!Number.isSafeInteger(model.imagePixelBudget) || model.imagePixelBudget <= 0)) {156
throw new Error(`llm-deepseek: catalog model "${model.id}" imagePixelBudget must be "low" or a positive safe integer`)157
}158
if (model.imageMaxBytes !== undefined159
&& (!Number.isSafeInteger(model.imageMaxBytes) || model.imageMaxBytes <= 0)) {160
throw new Error(`llm-deepseek: catalog model "${model.id}" imageMaxBytes must be a positive safe integer`)161
}162
// Widened: a dynamic config update reaches this check without schema validation.163
const systemPromptUpdate: string | undefined = model.systemPromptUpdate164
if (systemPromptUpdate !== undefined && systemPromptUpdate !== 'in-history') {165
throw new Error(`llm-deepseek: catalog model "${model.id}" systemPromptUpdate must be "in-history" when present`)166
}167
const toolUpdate: string | undefined = model.toolUpdate168
if (toolUpdate !== undefined && toolUpdate !== 'in-history' && toolUpdate !== 'addition-only') {169
throw new Error(`llm-deepseek: catalog model "${model.id}" toolUpdate must be "in-history" or "addition-only" when present`)170
}171
if (seen.has(model.id)) throw new Error(`llm-deepseek: duplicate catalog model "${model.id}"`)172
seen.add(model.id)173
return {174
id: model.id,175
...model.name === undefined ? {} : { name: model.name },176
...model.description === undefined ? {} : { description: model.description },177
...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },178
...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },179
...model.systemPromptUpdate === undefined ? {} : { systemPromptUpdate: model.systemPromptUpdate },180
...model.toolUpdate === undefined ? {} : { toolUpdate: model.toolUpdate },181
inputModalities: [...inputModalities],182
...hasImage183
? {184
...model.imagePixelBudget === undefined ? {} : { imagePixelBudget: model.imagePixelBudget },185
imageMaxBytes: model.imageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES,186
}187
: {},188
}189
})190
}192
/**193
* The one explicit resolve step from raw config to validated protocol194
* settings. Programmatic construction may bypass Schemastery normalization, so195
* every default and bound is re-judged here — for the composition entry at196
* load (fail loud) and for each settings snapshot at its first use.197
* @param config - raw plugin config or resolved settings snapshot.198
* @param environment - this run's environment layers, or `undefined` outside199
* the product CLI. Every layer may supply an endpoint: the product trusts the200
* project it is launched in, so a checkout can point its own agent at the201
* gateway that checkout is meant to use.202
* @returns validated protocol settings.203
*/204
export function resolveAdapterOptions(config: Options, environment?: LaunchEnvironmentSnapshot): ResolvedDeepSeekOptions {205
// Settings updates can reach this resolver without schema validation.206
if (Object.hasOwn(config, 'protocol')) {207
throw new Error('llm-deepseek: protocol is not configurable; remove it and use a Messages-compatible baseURL')208
}209
if (config.thinking === 'disabled'210
&& config.reasoningEffort !== undefined211
&& config.reasoningEffort !== 'off') {212
throw new Error('llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled')213
}214
if (config.defaultContextWindow !== undefined215
&& (!Number.isInteger(config.defaultContextWindow) || config.defaultContextWindow <= 0)) {216
throw new Error('llm-deepseek: defaultContextWindow must be a positive integer')217
}218
if (config.maxTokens !== undefined219
&& (!Number.isSafeInteger(config.maxTokens) || config.maxTokens <= 0)) {220
throw new Error('llm-deepseek: maxTokens must be a positive safe integer')221
}222
const streamIdleTimeoutMs = config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS223
if (!Number.isFinite(streamIdleTimeoutMs)224
|| streamIdleTimeoutMs <= 0225
|| streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {226
throw new Error(227
`llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,228
)229
}230
const maxRequestFilesBytes = config.maxRequestFilesBytes ?? DEFAULT_MAX_REQUEST_FILES_BYTES231
if (!Number.isSafeInteger(maxRequestFilesBytes) || maxRequestFilesBytes <= 0) {232
throw new Error('llm-deepseek: maxRequestFilesBytes must be a positive safe integer')233
}234
const maxInlineRequestImageBytes = config.maxInlineRequestImageBytes ?? DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES235
if (!Number.isSafeInteger(maxInlineRequestImageBytes) || maxInlineRequestImageBytes <= 0) {236
throw new Error('llm-deepseek: maxInlineRequestImageBytes must be a positive safe integer')237
}238
const maxImagesPerRequest = config.maxImagesPerRequest ?? DEFAULT_MAX_IMAGES_PER_REQUEST239
if (!Number.isSafeInteger(maxImagesPerRequest) || maxImagesPerRequest <= 0) {240
throw new Error('llm-deepseek: maxImagesPerRequest must be a positive safe integer')241
}242
const imageOffloadByteQuantum = config.imageOffloadByteQuantum ?? DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM243
if (!Number.isSafeInteger(imageOffloadByteQuantum) || imageOffloadByteQuantum <= 0) {244
throw new Error('llm-deepseek: imageOffloadByteQuantum must be a positive safe integer')245
}246
if (imageOffloadByteQuantum > maxRequestFilesBytes) {247
throw new Error('llm-deepseek: imageOffloadByteQuantum must not exceed maxRequestFilesBytes')248
}249
const inlineImageOffloadByteQuantum = config.inlineImageOffloadByteQuantum250
?? DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM251
if (!Number.isSafeInteger(inlineImageOffloadByteQuantum) || inlineImageOffloadByteQuantum <= 0) {252
throw new Error('llm-deepseek: inlineImageOffloadByteQuantum must be a positive safe integer')253
}254
if (inlineImageOffloadByteQuantum > maxInlineRequestImageBytes) {255
throw new Error('llm-deepseek: inlineImageOffloadByteQuantum must not exceed maxInlineRequestImageBytes')256
}257
const imageOffloadCountQuantum = config.imageOffloadCountQuantum ?? DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM258
if (!Number.isSafeInteger(imageOffloadCountQuantum) || imageOffloadCountQuantum <= 0) {259
throw new Error('llm-deepseek: imageOffloadCountQuantum must be a positive safe integer')260
}261
if (imageOffloadCountQuantum > maxImagesPerRequest) {262
throw new Error('llm-deepseek: imageOffloadCountQuantum must not exceed maxImagesPerRequest')263
}264
const filesApiTimeoutMs = config.filesApiTimeoutMs ?? DEFAULT_FILES_API_TIMEOUT_MS265
if (!Number.isFinite(filesApiTimeoutMs)266
|| filesApiTimeoutMs <= 0267
|| filesApiTimeoutMs > MAX_TIMER_DELAY_MS) {268
throw new Error(269
`llm-deepseek: filesApiTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,270
)271
}272
const fileExpiresAfterSeconds = config.fileExpiresAfterSeconds ?? DEFAULT_FILE_EXPIRY_SECONDS273
if (!Number.isSafeInteger(fileExpiresAfterSeconds)274
|| fileExpiresAfterSeconds < 3_600275
|| fileExpiresAfterSeconds > 2_592_000) {276
throw new Error('llm-deepseek: fileExpiresAfterSeconds must be an integer from 3600 through 2592000')277
}278
const fileRefreshMarginSeconds = config.fileRefreshMarginSeconds ?? DEFAULT_FILE_REFRESH_MARGIN_SECONDS279
if (!Number.isSafeInteger(fileRefreshMarginSeconds)280
|| fileRefreshMarginSeconds < 0281
|| fileRefreshMarginSeconds >= fileExpiresAfterSeconds) {282
throw new Error('llm-deepseek: fileRefreshMarginSeconds must be a non-negative integer below fileExpiresAfterSeconds')283
}284
const fileQuotaCleanupBatch = config.fileQuotaCleanupBatch ?? DEFAULT_FILE_QUOTA_CLEANUP_BATCH285
if (!Number.isSafeInteger(fileQuotaCleanupBatch)286
|| fileQuotaCleanupBatch < 1287
|| fileQuotaCleanupBatch > 1_000) {288
throw new Error('llm-deepseek: fileQuotaCleanupBatch must be an integer from 1 through 1000')289
}290
const baseURL = config.baseURL ?? environment?.get(BASE_URL_ENV)?.value ?? PUBLIC_BASE_URL291
const parsed = new URL(baseURL)292
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) {293
throw new Error('llm-deepseek: Messages baseURL must be an HTTP(S) root without credentials, query, or fragment')294
}295
return {296
baseURL,297
defaults: {298
thinking: config.thinking,299
reasoningEffort: config.reasoningEffort,300
},301
maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,302
defaultContextWindow: config.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,303
models: resolveModels(config.models),304
streamIdleTimeoutMs,305
maxRequestFilesBytes,306
maxInlineRequestImageBytes,307
maxImagesPerRequest,308
imageOffloadByteQuantum,309
inlineImageOffloadByteQuantum,310
imageOffloadCountQuantum,311
filesApiTimeoutMs,312
filePolicy: {313
expiresAfterSeconds: fileExpiresAfterSeconds,314
refreshMarginSeconds: fileRefreshMarginSeconds,315
quotaCleanupBatch: fileQuotaCleanupBatch,316
},317
retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-deepseek: retryPolicy'),318
}319
}