DeepSeek Harness LLM Adapter: Connect Any Model
Model providers are not limited to DeepSeek. If you want to connect dsh to your own model endpoint, you need to write an LLM adapter.
In this section, we will cover the LlmAdapter abstract class, the stream() method, route registration, and cordis.yml configuration.
What is an LLM adapter?
An LLM adapter is a class that inherits from LlmAdapter and implements the stream() method.
It converts the Harness's provider-agnostic requests into API calls for a specific provider.
It then converts the response back into Harness chunks, namely StreamChunk.
Therefore, agent-loop consumes a unified interface and doesn't care which provider's API is behind it.
At the top is agent-loop, which consumes a provider-agnostic streaming generation service.
In the middle is the ctx.llm registry, which maintains the abstract contract of LlmAdapter.
At the bottom are the individual adapters, each connecting to a different API format.
Used when registeringctx.llm.registerAdapter(['my-provider'], adapter)Bind route.
Minimal implementation
The official documentation gives a minimal skeleton; we follow it and add comments.
stream() is the core, returning an async generator AsyncIterable.
The three-step comments mark the standard flow: converting the message format, calling the streaming API, and converting the response into StreamChunk.
Example
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
// Adapter: inherits from the abstract class and implements stream().
class MyAdapter extends LlmAdapter {
private apiKey: string
constructor(apiKey: string) {
super()
this.apiKey = apiKey
}
// stream() returns an async generator, yielding StreamChunk piece by piece
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.
}
}
// Plugin config: apiKey and providers are both required.
export interface Config {
apiKey: string
providers: string[]
}
// Schemastery schema with the same name, validate configuration on load
export const Config: Schema<Config> = Schema.object({
apiKey: Schema.string().required(),
providers: Schema.array(Schema.string()).required(),
})
export const name = 'my-llm-adapter'
// Declare dependency on the llm service to ensure ctx.llm is ready.
export const inject = ['llm']
export function apply(ctx: Context, config: Config) {
const adapter = new MyAdapter(config.apiKey)
// Bind the provider route list to this adapter
ctx.llm.registerAdapter(config.providers, adapter)
}
apiKey comes from the plugin configuration, avoiding hardcoding the secret into the code.
The providers array declares which provider routes this adapter is responsible for.
apply 里先 new MyAdapter(config.apiKey),再 registerAdapter 完ChengRegister.
inject: ['llm'] ensures apply is executed only after the ctx.llm service is ready.
stream() returns an asynchronous generator that uses yield to emit chunks one by one.
The next article will explain the StreamChunk chunking protocol in detail.
GenerateOptions: What the adapter receives
stream() receives the GenerateOptions exported by the repository.
It includes model, reasoning effort, conversation history, system prompt, tool schema, generation parameters, stop sequences, and abort signal.
The complete fields are subject to the TypeScript types exported by @deepseek-ai/dsh-llm.
| Fields | Description |
|---|---|
| provider | Select a registered adapter |
| model | Model IDs owned by the adapter do not need to be registered at startup. |
| messages | Conversation history |
| system prompt | System prompt |
| tools | tool schema |
| reasoning | The reasoning effort ID supported by the adapter (optional). |
| signal | Abort signal, used by cancellation and resource release to fully stop. |
The adapter must map the supported fields to the specific API.
If a field cannot be supported, it should throw an LlmError with a stable code, and must not silently drop it.
Unsupported fields should raise explicit errors rather than being silently ignored; otherwise, the model will receive incomplete results.
Register adapter
In apply, call ctx.llm.registerAdapter to complete route registration.
Example
ctx.llm.registerAdapter(['my-provider'], adapter)
The first parameter is the list of provider routes handled by the adapter.
GenerateOptions.provider selects a registered adapter.
GenerateOptions.model passes in the model ID owned by the adapter, which does not need to be registered at lifecycle startup.
If the adapter can expose model options to the selector, it can override listModels().
When you need to return the exact provider and model identity, you can override resolveModel(provider, model, signal?).
resolveModel returns, in one query, the exact provider, model identity, and optional context and reasoning metadata.
The reasoning metadata describes the model's optional reasoning efforts: ordered opaque IDs, display names, and optional configuration defaults.
Preserve the authoritative optional list given by the adapter, including the off value returned by the upstream capability API; do not promote these values into core enums.
Asynchronous queries must respond to the optional signal so that cancellation and resource release come to a complete stop.
The service validates the aggregated result: explicitly specified but unsupported reasoning effort will be rejected before calling stream().
Omitting reasoning indicates that the model has no optional reasoning effort capability.
Using in cordis.yml
Load the adapter plugin together with agent-loop, and make agent-loop use the new provider and model.
Example
# Load adapter plugin; apiKey is read from environment variables.
- id: my-llm
name: './src/my-llm-adapter.ts'
config:
apiKey: !!js process.env.MY_API_KEY
providers:
- my-provider
# Configure agent-loop to use the new adapter's provider and model
- id: agent-loop
name: '@deepseek-ai/dsh-agent-loop'
config:
agents:
- id: main
provider: my-provider
model: my-model-v1
The config.apiKey of the my-llm plugin is read from the environment variable MY_API_KEY and is not written to disk.
The providers array registers the route my-provider to MyAdapter.
Configure provider and model in agent-loop's agents.main, and generated requests will hit the new adapter.
Practical reference
The repository has two complete existing implementations for comparison.
llm-deepseek adapts to DeepSeek API, using OpenAI-compatible format.
llm-pi-ai adapts Pi AI, which uses a different API format.
Comparing these two adapters shows how the same harness contract is implemented on top of different provider SDKs.
Reading llm-deepseek first and then llm-pi-ai makes it easiest to see the seam idea of "contract unchanged, implementations vary."
Summary and self-test
One-sentence summary: LLM adapter = inherit LlmAdapter + implement stream() + registerAdapter route registration + cordis.yml configuration.
Self-test question 1: What is the return type of stream()?
Self-test question 2: What does the first parameter of registerAdapter represent?
Self-test question 3: Which configuration options does agent-loop use to select a new adapter?
other extensions