forked from deepseek-ai/deepseek-harness
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.ts
More file actions
300 lines (283 loc) · 13.7 KB
/
Copy pathindex.ts
File metadata and controls
300 lines (283 loc) · 13.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
/**
* 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 '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { assertUsableApiKey, LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
import type { ModelModality, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { launchEnvironmentOf, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
import {
DEFAULT_CONTEXT_WINDOW,
DEFAULT_MAX_TOKENS,
DEFAULT_STREAM_IDLE_TIMEOUT_MS,
DeepSeekAdapter,
} from './adapter.ts'
import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts'
import type { WireImageResolver } from './serialize.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, WireImageResolver } 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'
/** Declarable catalog modalities, in stable order. */
const MODALITIES = ['text', 'image'] as const satisfies readonly ModelModality[]
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<DeepSeekCatalogModel> = z.object({
id: z.string().required(),
name: z.string(),
description: z.string(),
input: z.array(z.union(MODALITIES)),
contextWindow: z.number().step(1).min(1),
maxTokens: z.number().step(1).min(1),
})
export const Config: z<Config> = 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<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`)
}
// Schemastery materializes an absent array as [], which reads as "no
// declaration": the model stays text-only rather than failing the boot.
if (model.input !== undefined && model.input.length > 0) {
if (!model.input.includes('text')) {
throw new Error(`llm-deepseek: catalog model "${model.id}" input must include "text"`)
}
}
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.input === undefined || model.input.length === 0 ? {} : { input: [...model.input] },
...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?: LaunchEnvironmentSnapshot): 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, launchEnvironmentOf(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<string> => {
// 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 = launchEnvironmentOf(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',
)
}
let userId: AnonymousUserId | undefined
const resolveUserId = (): AnonymousUserId => userId ??= getOrCreateAnonymousUserId()
const attachments = ctx.get('attachments')
const resolveImageData: WireImageResolver | undefined = attachments === undefined
? undefined
: async (ref) => {
const stored = await attachments.readImage(ref)
return { data: stored.data, mediaType: stored.ref.mediaType }
}
const adapter = new DeepSeekAdapter({
options,
resolveApiKey,
resolveUserId,
...resolveImageData === undefined ? {} : { resolveImageData },
})
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,
})
}