Merge origin/master into feat/plan-mode
This commit is contained in:
@@ -2,7 +2,9 @@
|
||||
|
||||
DeepSeek chat-completions adapter for the harness LLM seam: hand-rolled `fetch` + SSE translation from the official wire format (source of truth: the API docs — guides/thinking_mode, guides/tool_calls, api/create-chat-completion) into the `StreamChunk` protocol.
|
||||
|
||||
A second, independent implementation of the same seam exists in `@deepseek-ai/dsh-llm-pi-ai` (library-backed). Same Config shape — pick one per context (registering both for the same model names throws by design).
|
||||
A second, library-backed implementation of the same seam exists in `@deepseek-ai/dsh-llm-pi-ai`. This package always owns the `deepseek` provider route; mounting a pi-ai profile with `provider: deepseek` in the same context throws `LlmError('DUPLICATE_ADAPTER')` by design.
|
||||
|
||||
The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire serialization, SSE parsing, and chunk translation helpers are not part of that root contract.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -12,17 +14,24 @@ A second, independent implementation of the same seam exists in `@deepseek-ai/ds
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com
|
||||
models: [deepseek-v4-flash, deepseek-v4-pro] # one adapter, registered for each name
|
||||
thinking: enabled # optional; provider default is enabled
|
||||
reasoningEffort: high # optional; high | max — omitted ⇒ not sent
|
||||
streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default
|
||||
models: # optional; defaults to V4 Flash and V4 Pro
|
||||
- id: deepseek-v4-flash
|
||||
name: DeepSeek V4 Flash
|
||||
- id: private-reasoner
|
||||
description: Company-hosted reasoning model
|
||||
```
|
||||
|
||||
`models` lists every model name this one adapter instance serves: the adapter registers itself for each (the harness model name IS the wire `model` string), so a `generate`/`stream` call routes to it whenever `options.model` is any of them. Registering a second adapter for a name already taken throws `LlmError('DUPLICATE_ADAPTER')` (the LLM service enforces one adapter per model, all-or-nothing).
|
||||
The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`.
|
||||
|
||||
`reasoningEffort` is **omitted by default** — when unset, the `reasoning_effort` wire field is not sent and the server applies its own default for the model. The only accepted values are `high` and `max` (DeepSeek's official effort levels). It is meaningful only with thinking enabled (the provider default).
|
||||
|
||||
`thinking`/`reasoningEffort` are adapter-level request defaults serialized as the official top-level `thinking: {type}` / `reasoning_effort` wire fields. They live in adapter config (not `GenerateOptions`) to keep the core vocabulary provider-neutral.
|
||||
|
||||
`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; agent-level retry is a separate plugin policy.
|
||||
|
||||
## App attribution
|
||||
|
||||
Every request carries the shared attribution header from dsh-llm's `attributionHeaders()` - the mandatory `User-Agent` baseline identifying the harness (see [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts)). Direct DeepSeek requests and OpenAI-compatible gateway requests get no provider-specific app-attribution headers under this adapter contract; OpenRouter app attribution is deferred to a future explicit OpenRouter adapter or mode.
|
||||
@@ -36,25 +45,41 @@ Every request carries the shared attribution header from dsh-llm's `attributionH
|
||||
|
||||
## Errors
|
||||
|
||||
Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `RATE_LIMIT` (429), `INVALID_REQUEST` (400), `SERVER` (5xx), `HTTP_<status>` otherwise. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', code: <REASON>}` chunks.
|
||||
Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_<status>` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit suites run against a local `node:http` mock SSE server (no network). Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback.
|
||||
Unit suites run against a local `node:http` mock SSE server (no network), including structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### DeepSeek request
|
||||
|
||||
**What the model sees**: The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. On a prior assistant turn with tool calls, its reasoning content is passed back as required; reasoning from tool-call-free turns is omitted.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Provider tokenization governs exact input. Conditional reasoning passback increases tool-round-trip context, while dropping other reasoning avoids paying those tokens again; cache-read usage is reported when available.
|
||||
The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. On a prior assistant turn with tool calls, its reasoning content is passed back as required; reasoning from tool-call-free turns is omitted.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Provider tokenization governs exact input. Conditional reasoning passback increases tool-round-trip context, while dropping other reasoning avoids paying those tokens again; cache-read usage is reported when available.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
An unchanged assembled prefix is eligible for DeepSeek cache reuse, which this adapter reports in usage. A model-route change or any upstream prompt, schema, prefix, or history change may prevent reuse from the first changed token; reasoning passback appends during tool round trips.
|
||||
|
||||
### DeepSeek response
|
||||
|
||||
**What the model sees**: Reasoning, text, and raw-string tool arguments are translated into harness chunks for the loop to log and assemble.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Generated tokens follow provider thinking and effort settings plus the request's `maxTokens`; only loop-retained blocks affect later input.
|
||||
Reasoning, text, and raw-string tool arguments are translated into harness chunks for the loop to log and assemble.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Generated tokens follow provider thinking and effort settings plus the request's `maxTokens`; only loop-retained blocks affect later input.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Loop-retained response blocks append to the next request and preserve its earlier reusable prefix; dropped blocks have no later cache effect. Changing the provider or model selects a different cache domain.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -30,6 +31,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,25 @@
|
||||
* @module dsh-llm-deepseek/adapter
|
||||
*/
|
||||
|
||||
import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { serializeRequest } from './serialize.ts'
|
||||
import type { RequestDefaults } from './serialize.ts'
|
||||
import { parseSse } from './sse.ts'
|
||||
import { translate } from './translate.ts'
|
||||
import type { WireError } from './types.ts'
|
||||
|
||||
/** One optional model entry advertised by the hand-written adapter. */
|
||||
export interface DeepSeekCatalogModel {
|
||||
/** Wire model id accepted by the configured endpoint. */
|
||||
id: string
|
||||
/** Selector label; defaults to {@link id}. */
|
||||
name?: string
|
||||
/** Optional selector detail for deployments with similar model variants. */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** Constructor options for {@link DeepSeekAdapter}; the plugin's `apply` resolves them from Config + environment. */
|
||||
export interface DeepSeekAdapterOptions {
|
||||
/** Bearer token sent in the `authorization` header on every request. */
|
||||
@@ -21,17 +32,46 @@ export interface DeepSeekAdapterOptions {
|
||||
baseURL: string
|
||||
/** Request defaults applied to every call (thinking mode, effort). */
|
||||
defaults?: RequestDefaults
|
||||
/** Advisory models exposed to discovery consumers; requests remain unrestricted. */
|
||||
models?: readonly DeepSeekCatalogModel[]
|
||||
/** Maximum provider idle time while one stream read is outstanding. */
|
||||
streamIdleTimeoutMs?: number
|
||||
}
|
||||
|
||||
/** Default maximum idle interval while an adapter stream read is outstanding. */
|
||||
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000
|
||||
const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT'
|
||||
|
||||
function providerRetryAfterMs(value: string | null): number | undefined {
|
||||
if (value === null) return undefined
|
||||
if (/^\d+$/.test(value)) {
|
||||
const delay = Number(value) * 1_000
|
||||
return Number.isFinite(delay) && delay > 0 ? delay : undefined
|
||||
}
|
||||
const delay = Date.parse(value) - Date.now()
|
||||
return Number.isFinite(delay) && delay > 0 ? delay : undefined
|
||||
}
|
||||
|
||||
function requestId(headers: Headers): ReturnType<typeof ProviderRequestId> | undefined {
|
||||
const value = headers.get('x-request-id') ?? headers.get('x-deepseek-request-id')
|
||||
return value === null || value.length === 0 ? undefined : ProviderRequestId(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an HTTP status to a stable LlmError code.
|
||||
* @param status - status of a non-2xx provider response.
|
||||
* @returns `AUTH` (401/403), `RATE_LIMIT` (429), `INVALID_REQUEST` (400), `SERVER` (5xx), or `HTTP_<status>` for anything else.
|
||||
* @param error - parsed provider error body, when available.
|
||||
* @returns the normalized harness error code.
|
||||
*/
|
||||
export function httpErrorCode(status: number): string {
|
||||
export function httpErrorCode(status: number, error?: WireError['error']): string {
|
||||
if (status === 401 || status === 403) return 'AUTH'
|
||||
const detail = [error?.code, error?.type, error?.message].filter(Boolean).join(' ')
|
||||
if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE
|
||||
if (status === 429) return 'RATE_LIMIT'
|
||||
if (status === 400) return 'INVALID_REQUEST'
|
||||
if (status === 400) {
|
||||
if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE
|
||||
return 'INVALID_REQUEST'
|
||||
}
|
||||
if (status >= 500) return 'SERVER'
|
||||
return `HTTP_${status}`
|
||||
}
|
||||
@@ -40,43 +80,136 @@ export function httpErrorCode(status: number): string {
|
||||
* The first real `LlmAdapter`. One instance serves every model name it was
|
||||
* registered under (the harness model name IS the wire model name).
|
||||
*
|
||||
* Abort: `options.signal` is handed to fetch — both the initial request and
|
||||
* the body stream reject on abort, which surfaces to the loop as a rejected
|
||||
* step (the loop already contains step errors).
|
||||
* One stable signal reaches both initial fetch and body reads. Caller aborts
|
||||
* map to `ABORTED`; the configured per-read idle watchdog maps to `TIMEOUT`.
|
||||
*/
|
||||
export class DeepSeekAdapter extends LlmAdapter {
|
||||
private readonly streamIdleTimeoutMs: number
|
||||
|
||||
constructor(private readonly options: DeepSeekAdapterOptions) {
|
||||
super()
|
||||
this.streamIdleTimeoutMs = options.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
|
||||
if (!Number.isFinite(this.streamIdleTimeoutMs)
|
||||
|| this.streamIdleTimeoutMs <= 0
|
||||
|| this.streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(
|
||||
`llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override providerInfo(provider: string): LlmProviderInfo {
|
||||
return { id: provider, name: 'DeepSeek' }
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve((this.options.models ?? []).map(model => ({
|
||||
provider,
|
||||
id: model.id,
|
||||
name: model.name ?? model.id,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
})))
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const consumer = new AbortController()
|
||||
const upstream = options.signal === undefined
|
||||
? consumer.signal
|
||||
: AbortSignal.any([options.signal, consumer.signal])
|
||||
using watchdog = idleWatchdog(upstream, this.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE)
|
||||
const iterator = this.request(options, watchdog.signal)[Symbol.asyncIterator]()
|
||||
let exhausted = false
|
||||
try {
|
||||
while (true) {
|
||||
const result = await watchdog.next(iterator)
|
||||
if (result.done) {
|
||||
exhausted = true
|
||||
return
|
||||
}
|
||||
yield result.value
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (timeoutOf(watchdog.signal, STREAM_IDLE_TIMEOUT_CODE) !== undefined) {
|
||||
throw new LlmError(
|
||||
`DeepSeek stream idle timeout after ${this.streamIdleTimeoutMs}ms`,
|
||||
'TIMEOUT',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
if (options.signal?.aborted) {
|
||||
throw new LlmError('DeepSeek request aborted by caller', 'ABORTED', { cause: error })
|
||||
}
|
||||
if (error instanceof LlmError) throw error
|
||||
throw new LlmError(`DeepSeek API stream from ${this.options.baseURL} failed`, 'TRANSPORT', { cause: error })
|
||||
} finally {
|
||||
consumer.abort('DeepSeek stream consumer stopped')
|
||||
if (!exhausted && iterator.return !== undefined) {
|
||||
try {
|
||||
await iterator.return()
|
||||
} catch (_abortedTransportTeardown) {
|
||||
// The consumer controller already owns termination; a return-time abort cannot add a second outcome.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async * request(options: GenerateOptions, signal: AbortSignal): AsyncIterable<StreamChunk> {
|
||||
const body = serializeRequest(options, this.options.defaults ?? {})
|
||||
// Prepared outside the try so the TRANSPORT label below covers exactly the
|
||||
// transport boundary, never a serialization failure.
|
||||
const payload = JSON.stringify(body)
|
||||
const headers = {
|
||||
'authorization': `Bearer ${this.options.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
'accept': 'text/event-stream',
|
||||
...attributionHeaders(),
|
||||
...options.sessionId !== undefined
|
||||
? { 'x-deepseek-harness-session-id': String(options.sessionId) }
|
||||
: {},
|
||||
}
|
||||
|
||||
// TODO(http): adopt the Cordis HTTP service when shared transport configuration
|
||||
// outweighs its additional runtime dependencies.
|
||||
const response = await fetch(`${this.options.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'authorization': `Bearer ${this.options.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
'accept': 'text/event-stream',
|
||||
...attributionHeaders(),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
...options.signal ? { signal: options.signal } : {},
|
||||
})
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${this.options.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: payload,
|
||||
signal,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
// The outer stream distinguishes caller cancellation and watchdog expiry.
|
||||
if (signal.aborted) throw error
|
||||
// fetch wraps every transport failure (DNS, refused connection, TLS,
|
||||
// proxy) in a bare `TypeError: fetch failed` whose actionable detail
|
||||
// lives on `cause`. Wrapping with the endpoint and chaining the cause
|
||||
// lets `errorChain` render the full diagnosis at every reporting seam.
|
||||
throw new LlmError(
|
||||
`DeepSeek API request to ${this.options.baseURL} failed`,
|
||||
'TRANSPORT',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const code = httpErrorCode(response.status)
|
||||
let message = `DeepSeek API error (HTTP ${response.status})`
|
||||
let providerError: WireError['error']
|
||||
try {
|
||||
const parsed = await response.json() as WireError
|
||||
if (parsed.error?.message) message = parsed.error.message
|
||||
providerError = parsed.error
|
||||
if (providerError?.message) message = providerError.message
|
||||
} catch {
|
||||
// Only swallow error-body parsing: status and code are already captured,
|
||||
// so malformed gateway JSON must not mask the actionable HTTP failure.
|
||||
// Only swallow error-body parsing: the HTTP status still identifies the
|
||||
// failure, so malformed gateway JSON must not mask it.
|
||||
}
|
||||
throw new LlmError(message, code, response.status)
|
||||
const delay = providerRetryAfterMs(response.headers.get('retry-after'))
|
||||
const id = requestId(response.headers)
|
||||
throw new LlmError(message, httpErrorCode(response.status, providerError), {
|
||||
status: response.status,
|
||||
...delay === undefined ? {} : { providerRetryAfterMs: delay },
|
||||
...id === undefined ? {} : { requestId: id },
|
||||
})
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Register a {@link DeepSeekAdapter} for configured model names on `ctx.llm`. Configuration uses
|
||||
* Register a {@link DeepSeekAdapter} for the `deepseek` provider route on `ctx.llm`. Configuration uses
|
||||
* Cordis schemastery; pass secrets from environment variables through `cordis.yml` with `!!js`,
|
||||
* as shown in the package README, rather than reading ad hoc files.
|
||||
* @module @deepseek-ai/dsh-llm-deepseek
|
||||
@@ -8,19 +8,23 @@
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
import { DeepSeekAdapter } from './adapter.ts'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts'
|
||||
import type { DeepSeekCatalogModel } from './adapter.ts'
|
||||
|
||||
export { DeepSeekAdapter, httpErrorCode } from './adapter.ts'
|
||||
export type { DeepSeekAdapterOptions } from './adapter.ts'
|
||||
export { serializeMessages, serializeRequest } from './serialize.ts'
|
||||
export { DeepSeekAdapter } from './adapter.ts'
|
||||
export type { DeepSeekAdapterOptions, DeepSeekCatalogModel } from './adapter.ts'
|
||||
export type { RequestDefaults } from './serialize.ts'
|
||||
export { DONE, parseSse } from './sse.ts'
|
||||
export { mapFinishReason, mapUsage, translate } from './translate.ts'
|
||||
export type * from './types.ts'
|
||||
|
||||
export const name = 'llm-deepseek'
|
||||
export const inject = ['llm']
|
||||
|
||||
const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
|
||||
{ id: 'deepseek-v4-flash' },
|
||||
{ id: 'deepseek-v4-pro' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Plugin config, validated by the same-named schemastery schema. Every field
|
||||
* is optional in yml: credentials/endpoint fall back to the environment (a
|
||||
@@ -32,40 +36,66 @@ export interface Config {
|
||||
apiKey?: string
|
||||
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
|
||||
baseURL?: string
|
||||
/** Model names to register (sent verbatim on the wire). */
|
||||
models?: string[]
|
||||
/** Thinking-mode default for every request (provider default: enabled). */
|
||||
thinking?: 'enabled' | 'disabled'
|
||||
/** Thinking effort (only meaningful with thinking enabled). */
|
||||
reasoningEffort?: 'high' | 'max'
|
||||
/** 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
|
||||
}
|
||||
|
||||
const catalogModel: z<DeepSeekCatalogModel> = z.object({
|
||||
id: z.string().required(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
})
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
apiKey: z.string(),
|
||||
baseURL: z.string(),
|
||||
models: z.array(z.string()).default(['deepseek-v4-flash', 'deepseek-v4-pro']),
|
||||
thinking: z.union(['enabled', 'disabled']),
|
||||
reasoningEffort: z.union(['high', 'max']),
|
||||
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),
|
||||
})
|
||||
|
||||
/** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
|
||||
export const PUBLIC_BASE_URL = 'https://api.deepseek.com'
|
||||
|
||||
/** 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 (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 },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const apiKey = config.apiKey ?? process.env.DEEPSEEK_API_KEY
|
||||
if (apiKey === undefined || apiKey.length === 0) {
|
||||
throw new Error('llm-deepseek: an API key is required (Config.apiKey or $DEEPSEEK_API_KEY)')
|
||||
}
|
||||
const baseURL = config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL
|
||||
// schemastery's .default() guarantees models is set after validation.
|
||||
const models = config.models as string[]
|
||||
|
||||
ctx.llm.registerAdapter(models, new DeepSeekAdapter({
|
||||
ctx.llm.registerAdapter(['deepseek'], new DeepSeekAdapter({
|
||||
apiKey,
|
||||
baseURL,
|
||||
defaults: {
|
||||
thinking: config.thinking,
|
||||
reasoningEffort: config.reasoningEffort,
|
||||
},
|
||||
models: resolveModels(config.models),
|
||||
streamIdleTimeoutMs: config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -35,7 +35,10 @@ export function mapFinishReason(reason: string): FinishReason {
|
||||
case 'length': return { kind: 'max-tokens' }
|
||||
default:
|
||||
// content_filter, insufficient_system_resource, future additions.
|
||||
return { kind: 'error', message: `model stopped: ${reason}`, code: reason.toUpperCase() }
|
||||
return {
|
||||
kind: 'error',
|
||||
failure: { message: `model stopped: ${reason}`, code: reason.toUpperCase() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ const FLASH = 'deepseek-v4-flash'
|
||||
const PRO = 'deepseek-v4-pro'
|
||||
const contexts: Context[] = []
|
||||
|
||||
async function harness(model: string, config: Partial<Config> = {}) {
|
||||
async function harness(_model: string, config: Partial<Config> = {}) {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, { models: [model], ...config })
|
||||
await ctx.plugin(LlmDeepSeek, config)
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', ()
|
||||
const ctx = await harness(FLASH, { thinking: 'disabled' })
|
||||
const kinds: string[] = []
|
||||
for await (const chunk of ctx.llm.stream({
|
||||
provider: 'deepseek',
|
||||
model: FLASH,
|
||||
messages: ask('Count from 1 to 5, digits only.'),
|
||||
maxTokens: 50,
|
||||
|
||||
@@ -2,15 +2,25 @@ import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { LlmError, userAgent } from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, {
|
||||
CONTEXT_WINDOW_EXCEEDED_CODE,
|
||||
errorChain,
|
||||
LlmError,
|
||||
ProviderRequestId,
|
||||
QUOTA_EXCEEDED_CODE,
|
||||
userAgent,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { DeepSeekAdapter, httpErrorCode } from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { httpErrorCode } from '../src/adapter.ts'
|
||||
import { assemble } from './assemble.ts'
|
||||
|
||||
/** One scripted behavior for the next request the mock server receives. */
|
||||
type Behavior =
|
||||
| { kind: 'sse'; events: string[]; delayMs?: number }
|
||||
| { kind: 'http-error'; status: number; body: string; contentType?: string }
|
||||
| { kind: 'http-error'; status: number; body: string; contentType?: string; headers?: Record<string, string> }
|
||||
| { kind: 'close-early'; events: string[] }
|
||||
|
||||
interface MockServer {
|
||||
@@ -28,6 +38,7 @@ const servers: Server[] = []
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
|
||||
vi.unstubAllEnvs()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** Local chat-completions stand-in: replays scripted behaviors per request. */
|
||||
@@ -46,7 +57,10 @@ async function mockServer(script: Behavior[]): Promise<MockServer> {
|
||||
return
|
||||
}
|
||||
if (behavior.kind === 'http-error') {
|
||||
response.writeHead(behavior.status, { 'content-type': behavior.contentType ?? 'application/json' })
|
||||
response.writeHead(behavior.status, {
|
||||
'content-type': behavior.contentType ?? 'application/json',
|
||||
...behavior.headers,
|
||||
})
|
||||
response.end(behavior.body)
|
||||
return
|
||||
}
|
||||
@@ -86,7 +100,7 @@ const textEvents = [
|
||||
async function harness(baseURL: string, config: object = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, { apiKey: 'test-key', baseURL, models: ['deepseek-v4-flash'], ...config })
|
||||
await ctx.plugin(LlmDeepSeek, { apiKey: 'test-key', baseURL, ...config })
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -123,6 +137,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
|
||||
const kinds: string[] = []
|
||||
for await (const chunk of ctx.llm.stream({
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
||||
})) {
|
||||
@@ -131,6 +146,19 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
expect(kinds).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish'])
|
||||
})
|
||||
|
||||
it('forwards the harness session id for host-side trajectory routing', async () => {
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const ctx = await harness(server.url)
|
||||
|
||||
await assemble(ctx, {
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
||||
sessionId: SessionId('child-session'),
|
||||
})
|
||||
|
||||
expect(server.headers[0]?.['x-deepseek-harness-session-id']).toBe('child-session')
|
||||
})
|
||||
|
||||
it('forwards thinking config onto the wire', async () => {
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const ctx = await harness(server.url, { thinking: 'disabled', reasoningEffort: 'high' })
|
||||
@@ -158,7 +186,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
status,
|
||||
body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }),
|
||||
}
|
||||
const server = await mockServer([behavior, behavior, behavior])
|
||||
const server = await mockServer([behavior, behavior])
|
||||
const ctx = await harness(server.url)
|
||||
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
|
||||
.rejects.toThrow(`failed with ${status}`)
|
||||
@@ -166,11 +194,116 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
|
||||
.catch((error: unknown) => (error as LlmError).code),
|
||||
).resolves.toBe(code)
|
||||
// The numeric HTTP status is carried on the error for explicit handling.
|
||||
await expect(
|
||||
assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
|
||||
.catch((error: unknown) => (error as LlmError).status),
|
||||
).resolves.toBe(status)
|
||||
})
|
||||
|
||||
it('classifies a thrown HTTP context-window rejection with the canonical code', async () => {
|
||||
const server = await mockServer([{
|
||||
kind: 'http-error',
|
||||
status: 400,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: 'This model maximum context length is 128000 tokens; your input exceeds that limit.',
|
||||
type: 'invalid_request_error',
|
||||
code: 'context_length_exceeded',
|
||||
},
|
||||
}),
|
||||
}])
|
||||
const ctx = await harness(server.url)
|
||||
const code = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
||||
.catch((error: unknown) => (error as LlmError).code)
|
||||
expect(code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE)
|
||||
})
|
||||
|
||||
it('retains status, Retry-After seconds, and provider request id as structured facts', async () => {
|
||||
const server = await mockServer([{
|
||||
kind: 'http-error',
|
||||
status: 429,
|
||||
body: JSON.stringify({ error: { message: 'slow down' } }),
|
||||
headers: { 'retry-after': '2', 'x-request-id': 'req-429' },
|
||||
}])
|
||||
const ctx = await harness(server.url)
|
||||
let thrown: unknown
|
||||
try {
|
||||
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
||||
} catch (error: unknown) {
|
||||
thrown = error
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(LlmError)
|
||||
expect((thrown as LlmError).failure).toEqual({
|
||||
message: 'slow down',
|
||||
code: 'RATE_LIMIT',
|
||||
status: 429,
|
||||
providerRetryAfterMs: 2_000,
|
||||
requestId: ProviderRequestId('req-429'),
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a future Retry-After HTTP date and the DeepSeek request-id fallback', async () => {
|
||||
const now = 1_800_000_000_000
|
||||
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now)
|
||||
try {
|
||||
const server = await mockServer([{
|
||||
kind: 'http-error',
|
||||
status: 503,
|
||||
body: JSON.stringify({ error: { message: 'come back later' } }),
|
||||
headers: {
|
||||
'retry-after': new Date(now + 3_000).toUTCString(),
|
||||
'x-deepseek-request-id': 'deepseek-503',
|
||||
},
|
||||
}])
|
||||
const ctx = await harness(server.url)
|
||||
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
|
||||
.rejects.toMatchObject({
|
||||
failure: {
|
||||
message: 'come back later',
|
||||
code: 'SERVER',
|
||||
status: 503,
|
||||
providerRetryAfterMs: 3_000,
|
||||
requestId: ProviderRequestId('deepseek-503'),
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
dateNow.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('omits zero, non-finite, invalid, and past Retry-After values', async () => {
|
||||
const values = [
|
||||
'0',
|
||||
'9'.repeat(400),
|
||||
'not-a-date',
|
||||
new Date(0).toUTCString(),
|
||||
]
|
||||
for (const value of values) {
|
||||
const server = await mockServer([{
|
||||
kind: 'http-error',
|
||||
status: 429,
|
||||
body: JSON.stringify({ error: { message: 'retry later' } }),
|
||||
headers: { 'retry-after': value },
|
||||
}])
|
||||
const ctx = await harness(server.url)
|
||||
let thrown: LlmError | undefined
|
||||
try {
|
||||
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof LlmError) thrown = error
|
||||
}
|
||||
expect(thrown?.failure).toEqual({ message: 'retry later', code: 'RATE_LIMIT', status: 429 })
|
||||
}
|
||||
})
|
||||
|
||||
it('classifies only context-capacity HTTP 400 details as context overflow', () => {
|
||||
expect(httpErrorCode(400, { message: 'request too large for model context' }))
|
||||
.toBe(CONTEXT_WINDOW_EXCEEDED_CODE)
|
||||
expect(httpErrorCode(400, { message: 'invalid input: temperature exceeds maximum allowed value' }))
|
||||
.toBe('INVALID_REQUEST')
|
||||
expect(httpErrorCode(413, { code: 'context_length_exceeded' })).toBe('HTTP_413')
|
||||
})
|
||||
|
||||
it('distinguishes terminal quota exhaustion from transient HTTP 429 throttling', () => {
|
||||
expect(httpErrorCode(429, { code: 'insufficient_quota', message: 'account credits exhausted' }))
|
||||
.toBe(QUOTA_EXCEEDED_CODE)
|
||||
expect(httpErrorCode(429, { message: 'request rate limit exceeded' })).toBe('RATE_LIMIT')
|
||||
})
|
||||
|
||||
it('keeps the status-line message for JSON error bodies without a message', async () => {
|
||||
@@ -191,6 +324,40 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
expect(httpErrorCode(418)).toBe('HTTP_418')
|
||||
})
|
||||
|
||||
it('wraps a transport failure in TRANSPORT with the fetch cause chain in the message', async () => {
|
||||
// Port 1 is reserved/unbound: fetch rejects with `TypeError: fetch failed`
|
||||
// whose actionable detail (ECONNREFUSED) lives on `cause`.
|
||||
const ctx = await harness('http://127.0.0.1:1')
|
||||
let caught: unknown
|
||||
try {
|
||||
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
||||
} catch (error: unknown) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(LlmError)
|
||||
const llmError = caught as LlmError
|
||||
expect(llmError.code).toBe('TRANSPORT')
|
||||
expect(llmError.message).toContain('http://127.0.0.1:1')
|
||||
expect(llmError.cause).toBeInstanceOf(TypeError)
|
||||
// The chain renderer reaches the transport diagnosis through the cause.
|
||||
expect(errorChain(llmError)).toMatch(/ECONNREFUSED|EADDRNOTAVAIL|bad port/)
|
||||
})
|
||||
|
||||
it('classifies an aborted request without losing the transport rejection', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const ctx = await harness('http://127.0.0.1:1')
|
||||
let caught: unknown
|
||||
try {
|
||||
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], signal: controller.signal })
|
||||
} catch (error: unknown) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(LlmError)
|
||||
expect(caught).toMatchObject({ code: 'ABORTED' })
|
||||
expect((caught as LlmError).cause).toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('throws EMPTY_RESPONSE when the response has no body', async () => {
|
||||
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
@@ -198,7 +365,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
)
|
||||
try {
|
||||
const iterate = async (): Promise<void> => {
|
||||
for await (const _chunk of adapter.stream({ model: 'm', messages: [] })) { /* drain */ }
|
||||
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
|
||||
}
|
||||
await expect(iterate()).rejects.toThrow(/no response body/)
|
||||
} finally {
|
||||
@@ -206,14 +373,20 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects with STREAM_CLOSED when the server drops mid-stream', async () => {
|
||||
it('classifies an abrupt body close as TRANSPORT and retains its cause', async () => {
|
||||
const server = await mockServer([{
|
||||
kind: 'close-early',
|
||||
events: ['{"choices":[{"delta":{"content":"par"}}]}'],
|
||||
}])
|
||||
const ctx = await harness(server.url)
|
||||
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
|
||||
.rejects.toThrow(/terminated|socket|without \[DONE\]/)
|
||||
let caught: unknown
|
||||
try {
|
||||
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
|
||||
} catch (error: unknown) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toMatchObject({ code: 'TRANSPORT' })
|
||||
expect(errorChain(caught)).toMatch(/terminated|socket|without \[DONE\]/)
|
||||
})
|
||||
|
||||
it('aborts mid-stream via the request signal', async () => {
|
||||
@@ -224,6 +397,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
const pending = (async () => {
|
||||
const chunks = []
|
||||
for await (const chunk of ctx.llm.stream({
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [],
|
||||
signal: controller.signal,
|
||||
@@ -234,30 +408,168 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
})()
|
||||
|
||||
setTimeout(() => { controller.abort() }, 30)
|
||||
await expect(pending).rejects.toThrow()
|
||||
await expect(pending).rejects.toMatchObject({ code: 'ABORTED' })
|
||||
})
|
||||
|
||||
it('maps connection failures to TRANSPORT without losing the cause', async () => {
|
||||
const cause = new TypeError('connection refused')
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(cause)
|
||||
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' })
|
||||
try {
|
||||
const drain = async (): Promise<void> => {
|
||||
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
|
||||
}
|
||||
await expect(drain()).rejects.toMatchObject({ code: 'TRANSPORT', cause })
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders a non-Error transport rejection without losing its cause', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => {
|
||||
const failed = Promise.withResolvers<Response>()
|
||||
failed.reject('offline')
|
||||
return failed.promise
|
||||
})
|
||||
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' })
|
||||
try {
|
||||
const drain = async (): Promise<void> => {
|
||||
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
|
||||
}
|
||||
await expect(drain()).rejects.toMatchObject({
|
||||
message: 'DeepSeek API request to https://example.invalid failed',
|
||||
code: 'TRANSPORT',
|
||||
cause: 'offline',
|
||||
})
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('aborts the underlying body when the stream stays idle past its watchdog', async () => {
|
||||
vi.useFakeTimers()
|
||||
let stopped = false
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => {
|
||||
const signal = init?.signal
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
signal?.addEventListener('abort', () => {
|
||||
stopped = true
|
||||
controller.error(signal.reason)
|
||||
}, { once: true })
|
||||
},
|
||||
})
|
||||
return Promise.resolve(new Response(body, { status: 200 }))
|
||||
})
|
||||
const adapter = new DeepSeekAdapter({
|
||||
apiKey: 'k',
|
||||
baseURL: 'https://example.invalid',
|
||||
streamIdleTimeoutMs: 100,
|
||||
})
|
||||
try {
|
||||
const drain = (async () => {
|
||||
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
|
||||
})()
|
||||
const rejected = expect(drain).rejects.toMatchObject({ code: 'TIMEOUT' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await rejected
|
||||
expect(stopped).toBe(true)
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin registration and config', () => {
|
||||
it('registers the configured models and unregisters on dispose (HMR safety)', async () => {
|
||||
it('keeps wire helpers off the package root', () => {
|
||||
for (const helper of [
|
||||
'httpErrorCode',
|
||||
'serializeMessages',
|
||||
'serializeRequest',
|
||||
'DONE',
|
||||
'parseSse',
|
||||
'mapFinishReason',
|
||||
'mapUsage',
|
||||
'translate',
|
||||
]) expect(LlmDeepSeek).not.toHaveProperty(helper)
|
||||
})
|
||||
|
||||
it('registers the deepseek provider and unregisters on dispose (HMR safety)', async () => {
|
||||
const server = await mockServer([])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const fiber = await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: server.url,
|
||||
models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
|
||||
})
|
||||
expect(ctx.llm.models().sort()).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
await fiber.dispose()
|
||||
expect(ctx.llm.models()).toEqual([])
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('defaults the model list', async () => {
|
||||
it('owns the deepseek provider and advertises the default models', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
expect(ctx.llm.models().sort()).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' },
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the default model catalog when apply is called directly', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' },
|
||||
])
|
||||
})
|
||||
|
||||
it('advertises configured models without restricting arbitrary request ids', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
models: [
|
||||
{ id: 'private-fast' },
|
||||
{ id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' },
|
||||
],
|
||||
})
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
||||
{ provider: 'deepseek', id: 'private-fast', name: 'private-fast' },
|
||||
{ provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' },
|
||||
])
|
||||
})
|
||||
|
||||
it('allows an explicit empty model catalog', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
models: [],
|
||||
})
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[[{ id: '' }], /ids must be non-empty/],
|
||||
[[{ id: 'm', name: '' }], /empty name/],
|
||||
[[{ id: 'm' }, { id: 'm' }], /duplicate catalog model/],
|
||||
] as const)('rejects invalid advisory model config', async (models, message) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
models: [...models],
|
||||
})).rejects.toThrow(message)
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => {
|
||||
@@ -266,7 +578,7 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {})
|
||||
expect(ctx.llm.models().length).toBeGreaterThan(0)
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
})
|
||||
|
||||
it('throws a clear error when no API key is available', async () => {
|
||||
@@ -275,7 +587,7 @@ describe('plugin registration and config', () => {
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {}))
|
||||
.rejects.toThrow(/an API key is required/)
|
||||
expect(ctx.llm.models()).toEqual([])
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('prefers explicit config over env for key and base URL', async () => {
|
||||
@@ -292,7 +604,7 @@ describe('plugin registration and config', () => {
|
||||
vi.stubEnv('DEEPSEEK_BASE_URL', server.url)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', models: ['deepseek-v4-flash'] })
|
||||
await ctx.plugin(LlmDeepSeek, { apiKey: 'k' })
|
||||
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(server.requests).toHaveLength(1)
|
||||
})
|
||||
@@ -304,11 +616,38 @@ describe('plugin registration and config', () => {
|
||||
await ctx.plugin(LlmService)
|
||||
// Registration succeeds; no call is made (would hit api.deepseek.com).
|
||||
await ctx.plugin(LlmDeepSeek, {})
|
||||
expect(ctx.llm.models().length).toBeGreaterThan(0)
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
})
|
||||
|
||||
it('adapter is constructible directly for embedding', () => {
|
||||
it('adapter is constructible directly for embedding', async () => {
|
||||
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
expect(adapter).toBeInstanceOf(DeepSeekAdapter)
|
||||
await expect(adapter.listModels('deepseek')).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('rejects invalid idle watchdog bounds for direct and plugin composition', async () => {
|
||||
expect(() => new DeepSeekAdapter({
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
streamIdleTimeoutMs: Number.POSITIVE_INFINITY,
|
||||
})).toThrow(/streamIdleTimeoutMs.*positive finite/)
|
||||
expect(() => new DeepSeekAdapter({
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1,
|
||||
})).toThrow(/streamIdleTimeoutMs.*no greater/)
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
streamIdleTimeoutMs: 0,
|
||||
})).rejects.toThrow(/streamIdleTimeoutMs/)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1,
|
||||
})).rejects.toThrow(/streamIdleTimeoutMs/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,11 +15,19 @@ export interface AssembledResult {
|
||||
finish: FinishReason
|
||||
}
|
||||
|
||||
export async function assemble(ctx: Context, options: GenerateOptions): Promise<AssembledResult> {
|
||||
export async function assemble(ctx: Context, options: Omit<GenerateOptions, 'provider'> & { provider?: string }): Promise<AssembledResult> {
|
||||
const assembler = new BlockAssembler()
|
||||
for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk)
|
||||
const request = { provider: 'deepseek', ...options }
|
||||
for await (const chunk of ctx.llm.stream(request)) assembler.push(chunk)
|
||||
return {
|
||||
message: assembler.message(),
|
||||
message: {
|
||||
...assembler.message(),
|
||||
provenance: {
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
...assembler.replayState === undefined ? {} : { replayState: assembler.replayState },
|
||||
},
|
||||
},
|
||||
...assembler.usage !== undefined ? { usage: assembler.usage } : {},
|
||||
finish: assembler.finish,
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { serializeMessages, serializeRequest } from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { serializeMessages, serializeRequest } from '../src/serialize.ts'
|
||||
|
||||
function request(overrides: Partial<GenerateOptions> = {}): GenerateOptions {
|
||||
return { model: 'deepseek-v4-flash', messages: [], ...overrides }
|
||||
return { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [], ...overrides }
|
||||
}
|
||||
|
||||
describe('serializeMessages', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import { DONE, parseSse } from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { DONE, parseSse } from '../src/sse.ts'
|
||||
|
||||
/** Build a byte stream from string fragments (fragments = network reads). */
|
||||
async function* bytes(...fragments: (string | Uint8Array)[]): AsyncGenerator<Uint8Array> {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { DONE, mapFinishReason, mapUsage, translate } from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { DONE } from '../src/sse.ts'
|
||||
import { mapFinishReason, mapUsage, translate } from '../src/translate.ts'
|
||||
|
||||
async function* feed(...payloads: (string | object)[]): AsyncGenerator<string> {
|
||||
for (const payload of payloads) {
|
||||
@@ -231,8 +232,7 @@ describe('mapFinishReason', () => {
|
||||
(wire) => {
|
||||
expect(mapFinishReason(wire)).toEqual({
|
||||
kind: 'error',
|
||||
message: `model stopped: ${wire}`,
|
||||
code: wire.toUpperCase(),
|
||||
failure: { message: `model stopped: ${wire}`, code: wire.toUpperCase() },
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user