Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
277 lines
13 KiB
TypeScript
277 lines
13 KiB
TypeScript
/**
|
|
* 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 { 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'
|
|
|
|
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<DeepSeekCatalogModel> = 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<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`)
|
|
}
|
|
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?: 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 adapter = new DeepSeekAdapter({ options, resolveApiKey, resolveUserId })
|
|
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,
|
|
})
|
|
}
|