How to write a DSH plugin LLM adapter for DeepSeek Harness
The way to connect a new model provider to DeepSeek Harness is to write an LLM adapter: extend LlmAdapter, implement stream(), translate provider-agnostic requests into the vendor API, and convert the response back into Harness chunks. Register it on the right route with ctx.llm.registerAdapter(), and override resolveModel() and listModels() as needed. This is the key development surface for making a DSH plugin support any model backend.
What a DSH plugin LLM adapter is: extend LlmAdapter, implement stream()
An LLM adapter is a class that extends LlmAdapter and implements stream(), doing translation in both directions: inbound it turns a Harness request into the vendor API, outbound it converts the response back into Harness chunks (source). The official minimal implementation looks like this:
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
class MyAdapter extends LlmAdapter {
private apiKey: string
constructor(apiKey: string) {
super()
this.apiKey = apiKey
}
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
// 1. Convert options.messages to the provider format.
// 2. Call the streaming API.
// 3. Convert the response into StreamChunk values.
}
}
export interface Config {
apiKey: string
providers: string[]
}
export const Config: Schema<Config> = Schema.object({
apiKey: Schema.string().required(),
providers: Schema.array(Schema.string()).required(),
})
export const name = 'my-llm-adapter'
export const inject = ['llm']
export function apply(ctx: Context, config: Config) {
const adapter = new MyAdapter(config.apiKey)
ctx.llm.registerAdapter(config.providers, adapter)
}
Ship an adapter plugin in four steps:
- Write the adapter class — extend
LlmAdapterand implementasync *stream(options). Expect: the type hints require anAsyncIterable<StreamChunk>return. - Declare Config and
inject— define fields such asapiKeywith schemastery and declareinject = ['llm']. Expect:ctx.llmis available, and the key can be injected the official way via!!js process.env.MY_KEY. - Register inside
apply—ctx.llm.registerAdapter(config.providers, adapter). Expect: requests go through your adapter whenoptions.providermatches that route. - Override optional methods — implement
resolveModel()/listModels()when needed. Expect: the model picker can list the models you provide.
Registration is side-effect based and HMR-safe: each provider route maps to exactly one adapter, and registering the same route twice throws, while a multi-route registration either all succeeds or all fails (source).
How to write a DSH plugin stream(): the StreamChunk protocol and key rules
stream() must emit chunks according to the official StreamChunk protocol, where ordering and pairing are hard constraints (source). The key rules are:
- Every
block-startmust have a matchingblock-end;indexincrements from 0 and identifies the content block. usagemust be emitted beforefinish, andfinishmust be the last chunk — nothing is emitted after it.- A
tool-call-delta'sargumentsDeltais the increment of raw JSON text; if the provider returns an already-parsed object, re-stringify it atblock-end. - A robust practice: buffer
finish/usageuntil the provider's end-of-stream marker and flush them together, to handle the case where only a usage chunk trails at the end.
Emit the protocol chunks like this:
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text: 'Hello' }
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Hello world' } }
yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }
yield { type: 'finish', reason: { kind: 'stop' } }
If your provider does not support some GenerateOptions field, you must throw LlmError(..., 'UNSUPPORTED_OPTION') rather than silently dropping it. The official reference implementations are packages/llm/llm-deepseek/ (direct HTTP, with SSE framed by eventsource-parser) and packages/llm/llm-pi-ai/.
How a DSH plugin registers and exposes model metadata
options.provider selects the adapter and options.model is the provider's model id, so a dynamic model catalog adapter can offer new models without restarting the lifecycle (source). Wire the adapter to the agent loop in cordis.yml:
- id: my-llm
name: './src/my-llm-adapter.ts'
config:
apiKey: !!js process.env.MY_API_KEY
providers:
- my-provider
- id: agent-loop
name: '@deepseek-ai/dsh-agent-loop'
config:
agents:
- id: main
provider: my-provider
model: my-model-v1
Overriding resolveModel(provider, model, signal?) is where you declare model capabilities: return the exact provider / model identity plus optional context and reasoning metadata. Reasoning effort is an ordered list of opaque IDs that the adapter maps to provider requests; keep the adapter's authoritative list (including off when supported) rather than promoting it to a core enum, and never auto-adjust unsupported values. An async query must honor the optional signal so cancellation and resource release settle cleanly.
Errors have only two legal paths: throw an LlmError with a stable code from stream() (transport and protocol failures), or end the stream with finish { kind: 'error' | 'aborted' } (in-band provider failures). Merge attributionHeaders() into every HTTP request and pass options.signal:
if (!response.ok) {
throw new LlmError(`Provider API error: ${response.status}`, 'PROVIDER_HTTP_ERROR')
}
Self-check after writing: is usage before finish and finish last; do unsupported fields throw UNSUPPORTED_OPTION; is signal passed through; does your route collide with someone else's adapter. To first understand which kind of capability the ctx.llm extension point an adapter mounts on belongs to, see capability seams and core services; to compare community implementations, find similar adapter plugins in DSH Plugin Hub.
FAQ
A DSH plugin LLM adapter is a class that extends LlmAdapter and implements the stream() method, translating provider-agnostic Harness requests into a specific vendor API and converting responses back into Harness chunks. The minimal build has four parts: the adapter class, a Config schema, inject = ['llm'], and the registration inside apply.
A DSH plugin stream() must emit chunks according to the StreamChunk protocol: every block-start needs a matching block-end, index increments from 0, usage must come before finish, and finish must be the last chunk. Tool-call arguments stay a raw JSON string throughout, with streaming increments carried by argumentsDelta.
A DSH plugin registers an adapter with ctx.llm.registerAdapter(providers, adapter), where the first argument is the list of provider routes that adapter owns. GenerateOptions.provider selects the adapter, GenerateOptions.model is a model id the adapter itself owns, and you override listModels() when you can advertise model options.
A DSH plugin adapter must throw an LlmError with a stable code for any field the provider does not support, and must not silently drop it. The official recommendation is to throw UNSUPPORTED_OPTION for unsupported items so the agent loop keeps the error and its code for diagnostics and policy handling.
A DSH plugin adapter has exactly two error paths: transport and protocol failures throw an LlmError from stream(), while in-band provider failures end the stream with finish {kind: 'error' | 'aborted'}. Cancellation passes options.signal to fetch or the SDK, and reasoning effort is declared by overriding resolveModel() to return an ordered list of opaque IDs.
Related Terms
- LlmAdapter
- LlmAdapter is the base class for an LLM adapter in a DSH plugin; extend it and implement stream() to connect a new model provider to DeepSeek Harness.— https://deepseek-harness.github.io/deepseek-harness/en/develop/practice/llm-adapter
- StreamChunk
- StreamChunk is the chunk type a DSH plugin LLM adapter must emit, using block-start, text-delta, block-end, tool-call-delta, usage, and finish chunks to describe one streamed reply.— https://deepseek-harness.github.io/deepseek-harness/en/develop/practice/llm-adapter
- registerAdapter
- registerAdapter is the DSH plugin method that attaches an adapter to model provider routes; it is called as ctx.llm.registerAdapter(providers, adapter), and each provider route maps to only one adapter.— https://deepseek-harness.github.io/deepseek-harness/en/reference/cookbook/adding-an-llm-adapter
- resolveModel
- resolveModel is the overridable DSH plugin adapter method that publishes exact model metadata, returning the provider and model identity plus optional context and reasoning fields.— https://deepseek-harness.github.io/deepseek-harness/en/develop/practice/llm-adapter
Sources
- DeepSeek Harness docs - LLM adapter· deepseek-ai
- DeepSeek Harness docs - Cookbook: adding an LLM adapter· deepseek-ai