# Conflicts: # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/subsystems/attachment.i18n.yaml # docs/subsystems/attachment.md # docs/subsystems/attachment.zh.md # docs/subsystems/core.i18n.yaml # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/README.md # packages/README.zh.md # packages/client/runtime/package.json # packages/client/ui-conversation/package.json # packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/compact/compact-basic/README.md # packages/compact/compact-basic/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/self-modification/tool-cordis/src/api-catalog.ts # pnpm-lock.yaml # scripts/type-equiv.manifest.json
340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
/**
|
|
* `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<string>
|
|
}
|
|
|
|
/** 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 },
|
|
inputModalities: ['text'],
|
|
}
|
|
}
|
|
|
|
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.
|
|
* @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<readonly LlmModelInfo[]> {
|
|
return Promise.resolve(this.config.options().models.map(model => modelInfo(provider, model)))
|
|
}
|
|
|
|
override resolveModel(
|
|
provider: string,
|
|
model: string,
|
|
_signal?: AbortSignal,
|
|
): Promise<LlmResolvedModelInfo> {
|
|
const connection = this.config.options()
|
|
const configured = connection.models.find(entry => entry.id === model)
|
|
const contextWindow = configured?.contextWindow
|
|
?? connection.defaultContextWindow
|
|
return Promise.resolve({
|
|
// The chat-completions wire route is text-only regardless of catalog
|
|
// membership, so the uncatalogued fallback declares the same negative
|
|
// capability — "unknown" here would let the host accept and persist
|
|
// images the serializer must then reject.
|
|
...configured === undefined
|
|
? { provider, id: model, name: model, inputModalities: ['text' as const] }
|
|
: 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<StreamChunk> {
|
|
// 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<StreamChunk> {
|
|
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))
|
|
}
|
|
}
|