How to write a DSH plugin LLM adapter for DeepSeek Harness

Plugin DevelopmentPublished 2026-10-02Author: DeepSeek Plugin Market
DSH pluginDeepSeek HarnessLLM adapterLlmAdapterCordis
A DSH plugin LLM adapter extends LlmAdapter and implements stream() to translate provider-agnostic requests into a vendor API and back into StreamChunk.

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:

ts
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:

  1. Write the adapter class — extend LlmAdapter and implement async *stream(options). Expect: the type hints require an AsyncIterable<StreamChunk> return.
  2. Declare Config and inject — define fields such as apiKey with schemastery and declare inject = ['llm']. Expect: ctx.llm is available, and the key can be injected the official way via !!js process.env.MY_KEY.
  3. Register inside apply — ctx.llm.registerAdapter(config.providers, adapter). Expect: requests go through your adapter when options.provider matches that route.
  4. 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-start must have a matching block-end; index increments from 0 and identifies the content block.
  • usage must be emitted before finish, and finish must be the last chunk — nothing is emitted after it.
  • A tool-call-delta's argumentsDelta is the increment of raw JSON text; if the provider returns an already-parsed object, re-stringify it at block-end.
  • A robust practice: buffer finish / usage until 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:

ts
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:

yaml
- 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:

ts
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

What is a DSH plugin LLM adapter and its minimal build?

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.

Which StreamChunk rules must a DSH plugin stream() follow?

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.

How does a DSH plugin register an adapter for a provider?

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.

What should a DSH plugin adapter do on unsupported 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.

How do DSH plugin adapters handle errors and cancellation?

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