/** * `DeepSeekAdapter`: fetch + SSE against a DeepSeek (OpenAI-compatible) * chat-completions endpoint, emitting harness StreamChunks. The adapter is * transport-only: connection facts arrive through a thunk resolved once per * operation and the bearer token through a per-request resolver, so the * registering plugin owns validation, layering, and credential policy. * * @module dsh-llm-deepseek/adapter */ import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, ResolvedRetryPolicy, StreamChunk, } from '@deepseek-ai/dsh-llm' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { idleWatchdog, 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 direct-fetch 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 /** Known combined request/response context capacity; omitted when deployment metadata is unavailable. */ contextWindow?: number /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */ maxTokens?: number } /** * Validated connection facts for one operation. The plugin's * `resolveAdapterOptions` is the one explicit resolve step producing this * shape; the adapter trusts it and re-reads it per operation, which is what * makes a configuration change reach the next request without re-registration. */ export interface DeepSeekConnectionOptions { /** Endpoint base; `/chat/completions` is appended. */ baseURL: string /** * Credential reference of this same resolution, resolved per request. * Travelling with the endpoint is the point: a request can never pair one * generation's URL with another generation's secret. Configuration carries * only this name — a literal key is not a configuration value. */ apiKeyEnv: CredentialRef /** Request defaults applied to every call (thinking mode, effort). */ defaults: RequestDefaults /** Default per-request output cap; explicit request values win. */ maxTokens: number /** Positive context capacity used when the selected model has no exact value. */ defaultContextWindow: number /** Advisory models exposed to discovery consumers; requests remain unrestricted. */ models: readonly DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs: number /** Provider-owned model-request retry policy, already resolved. */ retryPolicy: ResolvedRetryPolicy } /** Constructor options for {@link DeepSeekAdapter}: the two resolution hooks the plugin owns. */ export interface DeepSeekAdapterOptions { /** Current validated connection facts; called once per operation. */ options: () => DeepSeekConnectionOptions /** * Resolve the bearer token for the connection facts of one request. The * snapshot is passed in — never re-read — so the key can only ever come * from the same resolution as the endpoint it is sent to. Throws `LlmError` * `MISSING_CREDENTIAL` when no key is available anywhere. */ resolveApiKey: (connection: DeepSeekConnectionOptions) => Promise } /** Default maximum idle interval while an adapter stream read is outstanding. */ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 /** Default combined request/response context capacity. */ export const DEFAULT_CONTEXT_WINDOW = 1_000_000 /** Default per-request output-token cap. */ export const DEFAULT_MAX_TOKENS = 256_000 const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT' const OFF_REASONING_EFFORT = ReasoningEffortId('off') const HIGH_REASONING_EFFORT = ReasoningEffortId('high') const MAX_REASONING_EFFORT = ReasoningEffortId('max') const REASONING_EFFORTS = [ { id: OFF_REASONING_EFFORT, name: 'Off' }, { id: HIGH_REASONING_EFFORT, name: 'High' }, { id: MAX_REASONING_EFFORT, name: 'Max' }, ] as const const OFF_ONLY_REASONING_EFFORTS = [ { id: OFF_REASONING_EFFORT, name: 'Off' }, ] as const function modelInfo(provider: string, model: DeepSeekCatalogModel): LlmModelInfo { return { provider, id: model.id, name: model.name ?? model.id, ...model.description === undefined ? {} : { description: model.description }, } } 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 | 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. * @param error - parsed provider error body, when available. * @returns the normalized harness error code. */ 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) { if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE return 'INVALID_REQUEST' } if (status >= 500) return 'SERVER' return `HTTP_${status}` } /** * The first real `LlmAdapter`. One instance serves every model name it was * registered under (the harness model name IS the wire model name). * * 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 { constructor(private readonly config: DeepSeekAdapterOptions) { super() } override providerInfo(provider: string): LlmProviderInfo { return { id: provider, name: 'DeepSeek' } } override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { return this.config.options().retryPolicy } override listModels(provider: string): Promise { return Promise.resolve(this.config.options().models.map(model => modelInfo(provider, model))) } override resolveModel( provider: string, model: string, _signal?: AbortSignal, ): Promise { const connection = this.config.options() const configured = connection.models.find(entry => entry.id === model) const contextWindow = configured?.contextWindow ?? connection.defaultContextWindow return Promise.resolve({ ...configured === undefined ? { provider, id: model, name: model } : modelInfo(provider, configured), context: { contextWindow }, defaultMaxTokens: configured?.maxTokens ?? connection.maxTokens, ...connection.defaults.thinking === 'disabled' ? { reasoning: { efforts: OFF_ONLY_REASONING_EFFORTS, defaultEffort: OFF_REASONING_EFFORT, }, } : { reasoning: { efforts: REASONING_EFFORTS, defaultEffort: connection.defaults.reasoningEffort === 'off' ? OFF_REASONING_EFFORT : connection.defaults.reasoningEffort === 'max' ? MAX_REASONING_EFFORT : HIGH_REASONING_EFFORT, }, }, }) } async * stream(options: GenerateOptions): AsyncIterable { // One resolution per stream call: connection facts and the credential // freeze here and hold for this whole request, so an in-flight stream // never observes a configuration change and the next call re-resolves. // The key resolves *from this snapshot*, so an endpoint and the secret // sent to it can never come from different configuration generations. const connection = this.config.options() const apiKey = await this.config.resolveApiKey(connection) const consumer = new AbortController() const upstream = options.signal === undefined ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]) using watchdog = idleWatchdog(upstream, connection.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE) const iterator = this.request( options, watchdog.signal, connection, apiKey, () => { watchdog.pulse() }, )[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 ${connection.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 ${connection.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, connection: DeepSeekConnectionOptions, apiKey: string, onComment: () => void, ): AsyncIterable { const body = serializeRequest(options, connection.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 ${apiKey}`, 'content-type': 'application/json', 'accept': 'text/event-stream', ...attributionHeaders(), ...options.sessionId !== undefined ? { 'x-deepseek-harness-session-id': String(options.sessionId) } : {}, ...options.purpose === 'compaction' ? { 'x-deepseek-harness-compact': '1' } : {}, } // TODO(http): adopt the Cordis HTTP service when shared transport configuration // outweighs its additional runtime dependencies. let response: Response try { response = await fetch(`${connection.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 boundary. throw new LlmError( `DeepSeek API request to ${connection.baseURL} failed`, 'TRANSPORT', { cause: error }, ) } if (!response.ok) { let message = `DeepSeek API error (HTTP ${response.status})` let providerError: WireError['error'] try { const parsed = await response.json() as WireError providerError = parsed.error if (providerError?.message) message = providerError.message } catch { // Only swallow error-body parsing: the HTTP status still identifies the // failure, so malformed gateway JSON must not mask it. } 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') } yield* translate(parseSse(response.body, onComment)) } }