When you want to connect a self-developed model, a third-party vendor endpoint, or a local inference service to DeepSeek Harness (hereafter referred to as dsh), there is really only one thing you need to write: an adapter class that extends the LlmAdapter abstract class and implements the stream() method. It is responsible for translating your provider's API calls into the unified streaming protocol StreamChunk that Harness can consume, and then ctx.llm.registerAdapter() binds the provider route to the adapter instance. In this way, the agent-loop faces a provider-agnostic interface; whether it is DeepSeek, another cloud vendor, or that machine in your server room running quantized weights, it only ever sees a standardized sequence of asynchronous chunks. This article revolves around two key terms: the LLM adapter and StreamChunk. The former addresses "how to connect it in," while the latter addresses "what to emit once it is connected." After reading this, you will be able to independently write a complete adapter that is registrable, streamable, and capable of proper finalization.

LlmAdapter Abstract Class: Inheritance, the stream() Signature, and the AsyncIterable Contract

Let's put the single most important sentence up front: An LLM adapter is a class that extends LlmAdapter and implements the stream() method. It handles two translation jobs—converting the provider-agnostic requests emitted by the Harness into API calls in a specific provider's format, and converting the responses returned by the provider back into the Harness's own chunk structure, StreamChunk. Because it absorbs both ends, the agent-loop can confidently consume a unified interface without knowing anything about which API sits behind it.

To write this class, you first need to get the import paths straight. The abstract class itself, along with the two types used in its method signature—GenerateOptions and StreamChunk—all come from the same package: @deepseek-ai/dsh-llm. In other words, the top of your adapter file will basically look like this:

import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'

Here, LlmAdapter is a value import (you need to extend it), while GenerateOptions and StreamChunk are type imports. Marking them with the type modifier avoids leaving meaningless references behind at runtime. Context comes from @deepseek-ai/cordis and is the context object in the plugin system; later, ctx.llm is retrieved from it. Schema comes from @deepseek-ai/schemastery and is responsible for validating configuration when the plugin loads. The responsibility boundaries of these three packages are very clear—don't mix them up.

Next, let's talk about the stream() signature, which is the only method in the entire abstract class that you are required to implement:

async *stream(options: GenerateOptions): AsyncIterable<StreamChunk>

Breaking it down, there are three key points. First, the parameter is GenerateOptions, not the request object of any given SDK. GenerateOptions holds a single generation request from the Harness's perspective, and the most typical part is options.messages—an array of conversation messages organized by unified roles (system / user / assistant / tool, and so on). The adapter's first job is to translate this into the format required by your provider. Second, the return value is AsyncIterable<StreamChunk>. Note that this is not a Promise, nor an array, but an asynchronously iterable object. This means the consumer (agent-loop) pulls it chunk by chunk using for await (const chunk of adapter.stream(options)), processing as data arrives, with native support for streaming. Third, the method has a *, indicating that it is declared as an async generator with async *, and internally uses yield to send StreamChunk out piece by piece. Together, these three things form a contract: the caller doesn't care how many network round trips you make internally or how you assemble packets—it only receives a sequence of chunks in order.

There is a very easy pitfall here: don't cut corners by accumulating the entire response and then yielding it all at once. Technically, of course, you can wait until the full response arrives and then use yield to push block-start, text-delta, and block-end all in one go. The streaming protocol is still structurally valid, but you completely lose the time-to-first-token advantage that streaming provides, and the user experience degrades into an ordinary synchronous request. The value of an adapter lies precisely in yielding the increments arriving one by one at the network layer in real time. In your implementation, it is recommended to read the provider's SSE (Server-Sent Events) or chunked response block by block, and immediately yield a delta chunk each time an increment is read, without unnecessary buffering.

There is one more detail at the signature level worth emphasizing: AsyncIterable<StreamChunk> means "can be consumed by for await", not "returns an array of StreamChunk". If you write async stream(...): Promise<StreamChunk[]>, the type no longer matches the abstract class contract, and the consumer's for await will break as well. The TS compiler's type checking will stop you here, so don't try to work around it. In addition, once a generator function throws an exception, the exception bubbles up along the for await consumption point, which is exactly the natural entry point for transport error handling later—you can catch the provider SDK's errors between yields, convert them into appropriate error semantics, and rethrow, rather than letting raw HTTP error codes leak directly to the agent-loop.

示意图
The seam structure of the LLM adapter: the top-level agent-loop consumes a provider-agnostic streaming generation service, the middle-layer ctx.llm registry maintains the LlmAdapter abstract contract, and the underlying adapters each connect to different API formats.

It becomes clearer if you draw out the layering: at the top is the agent-loop, which only recognizes the capability of a "provider-agnostic streaming generation service"; in the middle is the ctx.llm registry, which maintains the LlmAdapter abstract contract, essentially a seam; at the bottom are the various adapters, each connecting to APIs in different formats. All your work happens in this bottom layer. As long as you uphold the interface, the two layers above need no changes at all. The benefit of this layering is that adding a new provider confines the scope of change to a single file and a single registration call, with zero intrusion into the agent-loop.

ctx.llm.registerAdapter(['my-provider'], adapter): Route Registration and Provider Binding

Now that the class is written, you still need to let Harness know it exists. Registration is done with a single call:

ctx.llm.registerAdapter(['my-provider'], adapter)

The two parameters of this method each serve a distinct purpose. The first parameter is the list of provider routes. Note that it is an array—in the example it is ['my-provider']—but this array can hold multiple strings, meaning a single adapter instance handles multiple provider identifiers at once. This is useful for aliasing or multi-tenant scenarios. For example, if you want both the names my-provider and my-provider-eu to route to the same adapter implementation, you can simply write ['my-provider', 'my-provider-eu'] without instantiating twice. The second parameter is the adapter instance. Note that it is an instance, not a class—meaning you must first new one up, pass in constructor arguments like apiKey, and then hand the instance to the registry.

Why is the registration a combination of "route list + instance"? Because the ctx.llm registry is essentially a mapping table from provider identifiers to adapter instances. When the agent-loop needs to initiate a generation and the request specifies a particular provider, the registry looks up the name in the table and uses whichever adapter's stream() is matched. This completely decouples the agent-loop from the specific API: it does not need to know what protocol DeepSeek uses, nor what protocol your custom model uses—it only needs a string identifier.

Registration is typically placed in the plugin's apply function, paired with an inject declaration for dependencies, ensuring ctx.llm is ready before registration happens. The full plugin export looks like this:

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)
}

There are several engineering details worth elaborating on here. inject = ['llm'] cannot be omitted. It tells the plugin system: this plugin depends on a service named llm, so please call apply after ctx.llm is available. If you omit this line and the load order happens to be unlucky, ctx.llm may be undefined when apply executes, and the registration call will throw an error directly during startup—which is also quite tricky to troubleshoot. The providers array comes from configuration rather than being hardcoded. The example feeds config.providers directly into the first parameter of registerAdapter, so users can decide in cordis.yml which provider names this adapter takes over. Loading the same plugin multiple times with different configurations allows mounting multiple sets of routes. apiKey is injected via the constructor. The adapter instance stores the key as a private field, retrieving it later when calling the provider API inside stream(), avoiding scattering sensitive information everywhere.

Configuration validation is handled by Schemastery. The official skeleton exports an interface Config and a const Config schema with the same name, written as:

export interface Config {
  apiKey: string
  providers: string[]
}

export const Config: Schema<Config> = Schema.object({
  apiKey: Schema.string().required(),
  providers: Schema.array(Schema.string()).required(),
})

The interface handles compile-time types, while the Schema handles runtime validation; having both under the same name is the standard practice in this system. Note that both fields are marked required(): without apiKey the adapter can't call the API, and an empty providers array is equivalent to registering a route that nobody can use. Both are configuration errors and should be caught at load time rather than only surfacing on the first real request. This "fail early" design saves a great deal of time on production troubleshooting.

Here's a table to align the responsibilities of the registration-related elements:

ElementSourceRoleTypical value / form
LlmAdapter@deepseek-ai/dsh-llmAbstract base class, requires implementing stream()class MyAdapter extends LlmAdapter
stream()Your adapter classTranslates requests into provider calls and responses into chunksasync *stream(options): AsyncIterable<StreamChunk>
registerAdapter first argumentconfig.providersProvider route list, determines which names hit this adapter['my-provider']
registerAdapter second argumentnew MyAdapter(apiKey)The adapter instance that actually handles requestsadapter instance object
injectPlugin exportDeclares a dependency on the llm service, ensuring ctx.llm is ready['llm']
Config + Schema@deepseek-ai/schemasteryValidates apiKey and providers at load timeBoth fields required()

Here's a complete adapter skeleton you can paste and run directly, tying together the class, config, and registration (the body of stream() is stubbed out with three comments for now, expanded in the next section):

// 文件路径:src/my-llm-adapter.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'

// 适配器:继承抽象类,实现 stream()
class MyAdapter extends LlmAdapter {
  private apiKey: string

  constructor(apiKey: string) {
    super()
    this.apiKey = apiKey
  }

  // stream() 返回异步生成器,逐片产出 StreamChunk
  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.
  }
}

// 插件配置:apiKey 与 providers 都必填
export interface Config {
  apiKey: string
  providers: string[]
}

// 同名的 Schemastery schema,加载时校验配置
export const Config: Schema<Config> = Schema.object({
  apiKey: Schema.string().required(),
  providers: Schema.array(Schema.string()).required(),
})

export const name = 'my-llm-adapter'

// 声明依赖 llm 服务,保证 ctx.llm 已就绪
export const inject = ['llm']

export function apply(ctx: Context, config: Config) {
  const adapter = new MyAdapter(config.apiKey)
  // 把提供方路由列表绑定到这个适配器
  ctx.llm.registerAdapter(config.providers, adapter)
}

One more point that's easy to overlook: registration happens only once. apply is called once when the plugin loads, and the adapter instance is registered just that once. Don't assume that every incoming request triggers a fresh registration, and don't try to modify the registry from within stream() either—the adapter's job is only to translate and produce, while routing is handled once and for all by the registry at startup. With clear separation of responsibilities, a lot of things won't get messy.

From GenerateOptions to Provider Requests: Three-Step Annotations for Message Format Conversion

Back inside stream(). The official skeleton leaves three lines of comments that neatly outline the adapter's complete workflow and also define its sole responsibility boundary:

  1. Convert options.messages to the provider format. — Transform messages in the Harness unified format into the request body required by the provider.
  2. Call the streaming API. — Use your apiKey to call the provider's streaming endpoint.
  3. Convert the response into StreamChunk values. — Translate the response streamed back by the provider, chunk by chunk, into StreamChunk.

These three steps are the entirety of the adapter's work—not one step less, and not one step more. Why call it the "sole responsibility boundary"? Because everything outside these three steps—session state management, tool orchestration, upper-level retry policy decisions, context compression—is Harness's job and should not be the adapter's concern. The thinner the adapter, the easier it is to maintain and reuse. Conversely, once you stuff business logic into the adapter, you lose the cleanliness of "switching providers by changing only one file," and the adapter gradually turns into a giant class that no one dares to touch.

The first step's conversion is often more tedious than imagined. The role naming and content structure in options.messages follow the Harness unified convention, while different providers express this convention in different ways: some extract the system prompt into a separate field, while others allow it as the first message in messages; some require multimodal content to be wrapped in an array with type tags, while others accept only plain strings; some impose additional schema requirements on tool-call history. Your conversion function needs to smooth over these differences one by one—note, smooth over, not discard. Whatever can be expressed in the provider format should be faithfully mapped over; whatever truly cannot be expressed should at least ensure that the converted request remains semantically coherent.

When calling the streaming API in the second step, there are several engineering points worth noting. First, be sure to use the provider's streaming mode, rather than waiting to receive the complete response all at once. Most provider SDKs offer a stream switch or return an asynchronously iterable stream object, which naturally fits our AsyncIterable. Second, authentication should go in the request headers rather than the URL, to avoid keys appearing in logs or proxy records. Third, respect the provider's timeout and retry semantics, but do not perform infinite retries inside the adapter—infinite retries turn a single failure into a request that hangs forever, dragging down the agent-loop as well. If you retry, it is recommended to limit the number of attempts and retry only during the connection-establishment phase, not after the stream has already begun emitting valid increments, otherwise you will produce duplicate chunks.

The third step is translating back into StreamChunk, which is also the part covered in the most detail in the next article. Here, first give one invariant: no matter how many variations the provider's streaming format has, what you ultimately output must be a strictly ordered sequence of StreamChunk. Internally, you can use any temporary structure to buffer and assemble, but as soon as it is yielded, the order and structure must conform to the protocol. This constraint of "free internally, strict at the exit" locks complexity inside the adapter.

Comparing the three steps with typical failures makes it easier to build intuition:

StepCore ActionInputOutputCommon Failures and Countermeasures
1. Message format conversionMap unified messages into the provider request bodyoptions.messagesProvider request objectRole/multimodal structure mismatch; map fields explicitly one by one, avoid passing through wholesale
2. Call the streaming APIInitiate a streaming request with authentication and read the response streamRequest object + apiKeyProvider response stream / event streamMisusing a non-streaming interface, infinite retries, large buffering; enable streaming, limit retries, push while reading
3. Convert to StreamChunkTranslate response events into chunks and yield themProvider response streamAsyncIterable<StreamChunk>Chunk order scrambled, buffering too long; strictly emit in block-start/delta/block-end order

To wrap up this section in one sentence: an adapter only translates; it does not make decisions. Do these three steps well, and it qualifies as competent; do these three steps so well that only three steps remain, and it becomes excellent. In the next three sections, we will focus on the third step—what exactly a StreamChunk looks like.

Getting Started with the StreamChunk Protocol: The block-start / delta / block-end Three-State Wrapper

What kind of data does an adapter's stream() actually emit? The answer is StreamChunk, a strictly ordered chunk protocol. It is the streaming contract between Harness and adapters, and each chunk is a small object carrying a type field that identifies what it is. To understand this protocol, the most fundamental rule is: a content block begins with block-start, is incrementally transmitted via delta in the middle, and ends with block-end.

This three-state wrapper is the skeleton of StreamChunk. block-start announces "I'm opening a block," and it carries two key pieces of information: index (the number of this block) and blockType (what type of block this is). delta is incremental filling, carrying only a small piece of new content at a time, and it can appear many times. block-end wraps things up, and in addition to index it also carries the complete block content—that is, the final form after all previous increments have been concatenated. Why provide a complete block again at the end? Because consumers may skip concatenating it themselves for performance reasons and directly use the complete object in block-end; the protocol supports both the incremental path and the complete path, and both work, which is a very thoughtful design.

Running through these three states is index. It stays consistent throughout the entire sequence: if a block's block-start uses index 0, then all of its subsequent deltas must have index 0, and the final block-end's index must also be 0. The role of index is to distinguish the increments of different blocks—in real scenarios blocks may appear interleaved, and only by relying on index can consumers know "which block this delta belongs to."

The end of a complete sequence also has two special chunks: usage and finish. After all content blocks have ended, usage is sent first to report token consumption, then finish is sent to declare the reason for ending. finish is the last chunk, and in its reason field, kind being stop indicates a normal end, while tool-calls indicates the model is requesting tool execution. The order of these two chunks cannot be reversed, a point that will be reinforced again in the next section and the final section.

示意图
The StreamChunk sequence of a complete generation: a text block goes block-start → text-delta × 2 → block-end, a tool-call block goes block-start → tool-call-delta → block-end, and finally usage and finish wrap things up.

Looking at the accompanying diagram from top to bottom, the rhythm of the sequence becomes clear at a glance: first a text block (block-start → text-delta × 2 → block-end), then a tool-call block (block-start → tool-call-delta → block-end), and finally usage and finish. Any generation, regardless of how much content it has, can be fit into this template. Below is a minimal but fully runnable example of a chunk sequence that directly yields the above structure, and it can serve as a baseline for a reference implementation:

// 文件路径:示例代码,演示一次完整的 chunk 序列
import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'

async function* exampleChunks(): AsyncIterable<StreamChunk> {
  // 1. Start each content block with block-start.
  // 开启一个文本块,index 为 0
  yield { type: 'block-start', index: 0, blockType: 'text' }

  // 2. Stream text through text-delta.
  // 文本增量,可拆成多个分片
  yield { type: 'text-delta', index: 0, text: 'runoob' }
  yield { type: 'text-delta', index: 0, text: ' 教程' }

  // 3. End each content block with block-end and the complete block.
  // 用完整块结束,index 与 block-start 一致
  yield {
    type: 'block-end',
    index: 0,
    block: { type: 'text', text: 'runoob 教程' },
  }

  // 4. Tool-call block.
  // 开启一个工具调用块,index 为 1
  yield { type: 'block-start', index: 1, blockType: 'tool-call' }

  // 工具名与参数增量,id 用 CallId 工厂生成
  yield {
    type: 'tool-call-delta',
    index: 1,
    id: CallId('call-123'),
    name: 'bash',
    argumentsDelta: '{"command":"echo runoob"}',
  }

  // 用完整块结束,arguments 是拼好的 JSON 文本
  yield {
    type: 'block-end',
    index: 1,
    block: {
      type: 'tool-call',
      id: CallId('call-123'),
      name: 'bash',
      arguments: '{"command":"echo runoob"}',
    },
  }

  // 5. Token usage.
  // 报告 token 用量,必须在 finish 之前
  yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }

  // 6. Finish reason.
  // 最后一个分片,声明结束原因
  yield { type: 'finish', reason: { kind: 'stop' } }

  // Alternatively, { kind: 'tool-calls' } requests tool execution.
}

Summarize this sequence into a comparison table by chunk type, so you can use it directly as a checklist when writing an adapter:

Chunk TypeRequired FieldsPurposeOccurrence Count
block-starttype, index, blockTypeOpens a content blockExactly once per block
text-deltatype, index, textIncremental content of a text blockOne or more times per text block
tool-call-deltatype, index, id, name, argumentsDeltaIncremental arguments of a tool call block (raw JSON text)One or more times per tool call block
block-endtype, index, blockCloses a content block and provides the complete blockExactly once per block
usagetype, usage (inputTokens / outputTokens)Reports token usageOnce at the end
finishtype, reason (kind)Declares the finish reason and terminates the sequenceOnce, as the final chunk

Three ironclad rules are worth repeating to yourself over and over: First, every block must start with block-start and end with block-end, with at least one delta in between. Never send only a start without an end—that leaves the consumer waiting forever for a closing chunk that never arrives, which in many implementations manifests directly as a hanging request. Second, index must be strictly consistent within the same block and must not repeat across different blocks. In practice, simply incrementing is enough: 0, 1, 2… Third, usage and finish must appear after the block-end of all blocks. They are global finalizers, not the finalizer of any particular block, and mixing them in the middle of blocks will completely muddle the semantics.

Text Blocks and Tool-Call Blocks: How the Two blockTypes Each Walk Through start / delta / end

There are two kinds of content blocks in a StreamChunk, distinguished by cubeType (that is, the blockType field): text and tool-call. Structurally they share the same three-state wrapper, and each runs through a complete lifecycle independently, without interleaving. This point is extremely important—it does not mean that a text block can run halfway, have a tool call inserted, and then resume running text; rather, it means "you run your start / delta / end, then I run my start / delta / end." The two segments in the accompanying figure are exactly this standard sequential relationship.

Let's look at the text block first. Its lifecycle is: block-start (blockType is text, index recorded) → several text-delta entries (each carrying a small piece of text) → block-end (carrying the complete text block). The complete block for a text block looks like { type: 'text', text: 'runoob 教程' }, where the text field is the final concatenated text. In the example it is split into two pieces: first 'runoob', then ' 教程', which concatenate to exactly 'runoob 教程'.

Now let's look at the tool-call block. Its lifecycle is isomorphic, but the content fields differ: block-start (blockType is tool-call, index recorded) → several tool-call-delta entries (each carrying id, name, and argumentsDelta) → block-end (carrying the complete tool-call block). The complete block looks like { type: 'tool-call', id: CallId('call-123'), name: 'bash', arguments: '{...}' }. Note that here arguments is the fully concatenated JSON text, while argumentsDelta in the delta phase is only an incremental piece of it.

Laying the two kinds of blocks side by side for comparison makes both their differences and commonalities clear:

DimensionText block (blockType: text)Tool-call block (blockType: tool-call)
Opening chunkblock-start, blockType: 'text'block-start, blockType: 'tool-call'
Incremental chunk typetext-deltatool-call-delta
Fields carried by incrementstext (text fragment)id, name, argumentsDelta (raw JSON text increment)
Fields of the complete blocktype, texttype, id, name, arguments
IdentityNo independent id, distinguished by indexHas an id, generated by the CallId factory
Whether it triggers host behaviorNo, it is just contentYes, together with a finish reason of tool-calls it requests execution

Why must text blocks and tool-call blocks each run through a complete lifecycle on their own, rather than being mixed together? Because their consumption semantics are completely different. What a text block produces is content for humans to read, and the consumer can render it as it is received; what a tool-call block produces is a structured instruction for the system to execute, and the consumer usually has to wait until block-end and obtain complete and valid arguments before it dares to parse and execute. By separating the two into independent blocks, the consumer can use the simplest rule to decide "when a tool can be executed"—as soon as it sees the block-end of the tool-call block, without worrying that text is still streaming afterward.

The most common engineering mistake is interleaved output: the model may, within a single generation, first say a couple of sentences of explanation and then initiate a tool call, and some providers' returned event streams also push text and tool arguments alternately. What your adapter needs to do is normalize them internally, ensuring that what is yielded is a clean order of "text block fully ends → tool-call block fully begins." If you take the easy way out and yield the interleaved increments directly in arrival order, the consumer will see the deltas of the text block and the deltas of the tool-call block interspersed with each other—at best causing garbled rendering, at worst making the tool arguments impossible to concatenate completely.

So how do you handle reordering inside the adapter? A reliable approach is to maintain two buffers inside stream(): a text buffer and a tool-call buffer. When you read a text event, append it to the text buffer and immediately yield a text-delta; when you read a tool-argument event, append it to the tool buffer and immediately yield a tool-call-delta. But note: if the two kinds of events really can arrive interleaved, you must decide the block boundaries before yielding. A more robust strategy is to delay opening a new block: only allow yielding the next block-start once a block has definitely begun and the previous block has definitely ended. This requires you to buffer a bit more state, but in exchange you get a strictly ordered output sequence. For most providers that return in a "text first, tool calls after" order, straightforward sequential processing is enough; only enable the finer-grained buffering strategy when you observe interleaving in practice.

Another detail is index assignment. In the example, the text block uses 0 and the tool-call block uses 1, which is the most intuitive incremental strategy. It doesn't require starting from 0, only that the index be unique within the same sequence and consistent with the block's start / delta / end. It's recommended to maintain a counter let nextIndex = 0 at the beginning of stream(), taking the current value and then incrementing it each time you open a new block, so you don't have to manage it manually and risk mistakes.

text-delta Incremental Concatenation and the argumentsDelta Semantics of tool-call-delta

The core idea behind delta chunking is "divide and conquer." On the text side, text-delta can be split into multiple chunks, which the consumer concatenates into the complete text. In the example, 'runoob 教程' is split into two chunks, 'runoob' and ' 教程', purely for demonstration; in real scenarios, how many chunks you get depends on how the provider pushes and how you read. A single Chinese response might be sliced into dozens or even hundreds of increments in the provider's stream. You yield each one as you read it, the consumer collects them in index order, and a simple string concatenation restores the full text. This division of labor—"the producer splits, the consumer joins"—is the foundation of the streaming experience: the first character appears as early as possible, and subsequent content arrives progressively.

A common misconception needs to be cleared up here: the text in a text-delta is not "the Nth character of the whole text" but "the newly added segment." What the consumer must do is append, not replace. If you accidentally stuff the accumulated full text into every text-delta in your adapter, the consumer will append it again, producing duplicated concatenations like 'runoobrunoob 教程'. So the adapter must yield only the pure increment each time. The check is simple: concatenate all text-delta texts in order, and the result should exactly equal the text of the complete block in block-end—not one character more, not one character less.

On the tool-call side, the semantics need to be tightened a bit further. The argumentsDelta of tool-call-delta is an increment of the raw JSON text. Note three keywords: "raw," "JSON text," and "increment." It is not a parsed object, not a partial value of some field, but a contiguous slice of the final arguments JSON string. In the example, argumentsDelta is '{"command":"echo runoob"}', delivered in a single piece; but in real scenarios it is very likely to be split into segments like '{"comm', 'and":"echo', ' runoob"}', or even cut in the middle of an escape sequence. The adapter does not need to—and should not—parse it; it only needs to yield it as-is; the moment parsing is actually needed is after block-end, when the arguments field already holds the fully concatenated JSON text.

Why design tool arguments as "streaming JSON text increments" rather than "a directly parsed object"? Because in a streaming scenario, the provider itself emits JSON characters token by token, and you simply cannot parse a valid object before it finishes. The protocol conforms to this physical reality, letting the incremental phase only carry strings, deferring both concatenation and parsing to the end. This way the adapter can achieve near-zero parsing logic, doing nothing but transport.

Comparing the semantics of the two deltas side by side:

Comparison Itemtext-delta.texttool-call-delta.argumentsDelta
Nature of contentPlain text fragmentRaw JSON text fragment
Directly parseableNo parsing needed; display or concatenate directlyNot parseable in isolation; must wait for full concatenation before parsing
Consumer actionString accumulationString accumulation, then JSON.parse after obtaining the complete JSON text
Relationship to block-endAccumulated result equals block.textAccumulated result equals block.arguments
Adapter responsibilityTransport increments; never resend accumulated valuesTransport increments; never perform partial JSON parsing

How do you ensure consistency when concatenating? Here are two practical recommendations. First, during the delta phase, yield only the newly added content; block-level integrity is guaranteed by block-end as a fallback. Even if for some reason you split a delta into too many or too few pieces, as long as the complete block in block-end is correct, the consumer can correct it by treating the complete block as authoritative. This is one of the reasons the protocol provides both deltas and complete blocks.Second, add an assertion during development: concatenate the text of all deltas under the same index and compare it with block.text (or block.arguments) from block-end; they must be equal. Put this assertion into your unit tests, and it will catch the vast majority of concatenation-related bugs.

Also, a reminder about an edge case: what if argumentsDelta is an empty string, or there is no tool-call-delta at all? If a tool call has no arguments whatsoever, you should still send block-start, then (you may omit the delta, or send a delta with an empty argumentsDelta, depending on the agreement between provider and consumer), and finally use block-end to provide the complete block with arguments as the text of an empty JSON object (for example, '{}'). The key is that block-start and block-end must appear in pairs; whether there is a delta in between is secondary. The integrity of a tool call is guaranteed by this start / end pair, not by the number of deltas.

CallId('call-123') Factory and the id / name / arguments Fields of a Tool Call Block

A tool call block has three key fields worth explaining on their own: id, name, and arguments. They answer three questions respectively—who this call is, which tool to invoke, and what the parameters are.

Let's start with id. The example uses CallId('call-123'). Note that this is not an ordinary string, but an identifier generated through the CallId factory. The id must be generated using the CallId factory; you cannot just throw in a bare string. The factory's job is to wrap the raw call identifier returned by the provider into a CallId type recognized by the protocol, so that when you later reference this tool call in the conversation history and feed the tool execution result back to the model, the identifier's type stays consistent and you won't hit a "string vs CallId" mismatch at the type level. In stream(), you should feed the provider's call id as-is into CallId (in the example, 'call-123'), rather than inventing your own random value unrelated to the provider—otherwise the tool result may fail to match when fed back.

Next, name. It is the tool name, which in the example is 'bash'. This field appears in both the delta stage and the block-end stage, and the adapter must ensure consistency between the two. The tool name usually comes from the model output, and you must pass it through as-is—do not rename or map it in the adapter. The tool name must match the tool table registered on the host side; renaming will cause the tool to fail to resolve. The name may sometimes arrive in fragments (for example, the model emits only the first few characters), in which case the name in the complete block at block-end takes precedence. If the delta stage can include it, include it; if it can't be fully included, that's fine—what matters is that the complete block is accurate.

Finally, arguments. It appears only in the complete block at block-end, as "assembled JSON text," which in the example is '{"command":"echo runoob"}'. Note that it is a string, not an object—the protocol preserves the raw JSON text and leaves parsing to the consumer. The benefit is that the adapter does not participate in JSON parsing, nor does it need to normalize subtle formatting differences in parameters across providers (such as whether quotes are completed or trailing commas are allowed). The consumer parses the complete text on its own, and if parsing fails, the cause can be clearly attributed to the model output rather than the adapter.

Here are the constraints on the three fields organized into a checklist:

  • id: Must be generated using the CallId(...) factory; keep it consistent between delta and block-end; its content should come from the provider's raw call identifier—do not invent it yourself.
  • name: The tool name string; keep it consistent between delta and block-end; pass it through as-is without renaming.
  • arguments: A complete JSON text string, appearing only in the complete block at block-end; consistent with the accumulated result of all argumentsDelta.

Below is a side-by-side reference for how to write these three fields in a tool call block—ready to copy directly:

// delta stage: carry id, name, and argument delta
const callId = CallId('call-123')
yield {
  type: 'tool-call-delta',
  index: 1,
  id: callId,
  name: 'bash',
  argumentsDelta: '{"command":"echo runoob"}',
}

// block-end stage: provide the complete block, where arguments is the assembled JSON text
yield {
  type: 'block-end',
  index: 1,
  block: {
    type: 'tool-call',
    id: callId,
    name: 'bash',
    arguments: '{"command":"echo runoob"}',
  },
}

Two details are worth emphasizing in passing. First, the same call should use the same CallId value in both delta and block-end. In the example, CallId('call-123') is written both times, since semantically they represent the same call. If you obtain the id from the provider in your implementation, store it in a local variable (such as callId in the example above) and reuse it in both places, to avoid accidentally writing two different values. Second, multiple tool calls may occur within a single generation, in which case each call should have its own index and its own id, each running through start / delta / end. The index distinguishes blocks, while the id distinguishes calls; do not conflate the two: the index is a positional identifier within the stream, and the id is a business-level call identifier.

If the model initiates multiple tool calls within the same generation (common in parallel tool-calling scenarios), your adapter should produce a set of three-state chunks for each call in order: the first call goes block-start → tool-call-delta (possibly multiple) → block-end, then the second call repeats the same sequence. Note that they each have different indexes, and the reason for `finish` in the multi-call scenario is still { kind: 'tool-calls' }, because the finish reason is "tool execution requested," which is independent of the number of calls.

Order of the usage and finish closing chunks: report token usage first, then declare the stop reason

A complete sequence always ends with two chunks: usage and finish. The order is mandatory—usage reports token usage first, then finish declares the stop reason; the order cannot be reversed. In the example, usage is written like this:

yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }
yield { type: 'finish', reason: { kind: 'stop' } }

usage carries two numeric fields: inputTokens and outputTokens, representing the number of input and output tokens consumed by this generation. The example uses 100 and 50. These two numbers should come from the usage information returned by the provider; if you can get them, fill them in truthfully, and if you cannot, handle them according to the convention (do not make them up). Why emit it before finish? Because consumers typically perform closing actions when they see finish—settling usage, updating billing, writing logs, closing the stream—and they need a moment when "all data has arrived." By placing usage before finish, the consumer already has complete usage information when processing finish, so it can complete the wrap-up in one pass. If you reverse it and send finish before usage, the consumer will likely end iteration at the moment it sees finish, usage will never be read, and the statistics will be lost.

finish is the last chunk, and its reason.kind declares why this generation ended. In the example it is { kind: 'stop' }, indicating a normal end where the model finished speaking on its own. Another case is { kind: 'tool-calls' }, indicating that the model is requesting tool execution—note that this usually means at least one tool call block has already been produced earlier, and the consumer enters the tool execution flow based on this, then initiates the next round of generation after feeding the execution results back. It can be understood this way: finish's reason determines the agent-loop's next action, stop ends the current round, and tool-calls continues running tools. What the adapter needs to do is correctly map the stop reason given by the provider onto these two kinds.

The fields and semantics of the two closing chunks are summarized below:

ChunkFieldSemanticsPosition constraint
usageusage.inputTokens, usage.outputTokensToken usage for this generationAfter all block-end and before finish
finishreason.kindstop means normal end; tool-calls means requesting tool executionThe last chunk of the entire sequence, exactly once

There are also several closing disciplines to uphold. First, finish must exist and must appear exactly once. Yield it once when the normal path ends; do not yield an extra finish on the exception path in an attempt to "patch things up," because that will make the consumer see two end signals. Exceptions should be conveyed by throwing, not by forging a finish. Second, usage is also a closing field; do not place it in the middle of blocks. Some implementations emit usage right after the first block ends just for convenience, which is wrong; usage describes the total usage of the entire generation and is only meaningful after all blocks have ended. Third, even if this generation has no content blocks at all (for example, the model ends directly or only requests tool execution), usage and finish must still be emitted. An empty response is still a valid generation, and the two closing chunks cannot be omitted.

Remember the order of the entire sequence with one mnemonic: blocks open first, deltas fill in, blocks close after; then close out, report usage, and declare. The corresponding order is block-start → delta → block-end (repeatable for multiple blocks) → usage → finish. As long as the adapter ensures that the objects it yields strictly follow this rhythm, the agent-loop can consume them stably, no matter whose model is behind it. At this point, both main threads have been laid out: how to write the interface (LlmAdapter and registerAdapter) and how to produce chunks (the StreamChunk protocol). In the next part, we will apply this protocol to the implementation details of a real adapter, discussing at which layer transmission errors should be caught and converted, how interleaved events are put back in place, and, after getting the above chunk sequence working, how to verify with a minimal test case that your adapter is indeed compliant.

In the previous section, we already clarified the responsibility boundaries of the LlmAdapter abstract class and the stream() method: the adapter is responsible for translating Harness's provider-agnostic requests into concrete vendor API calls, and then translating vendor responses back into Harness chunks. In this section, we push the lens into protocol details and implementation engineering: what StreamChunk actually looks like, why the chunk order cannot be scrambled, how finish.reason forks between "natural end" and "request to execute tools," and the complete lifecycle of a real plugin from cordis.yml to registerAdapter.

finish.reason: the fork between stop for normal completion and tool-calls for requesting tool execution

finish is the final chunk of a streaming generation, and the reason field it carries determines what the agent-loop does next. The semantics of its values as given in the source material are very clear: when reason.kind is stop, it means the model has produced a natural conclusion, and this round of conversation can proceed to the next round of user input; when reason.kind is tool-calls, it means the model is asking the Harness to execute tools, and the tool execution results need to flow back into the context before initiating a new round of generation. The difference between these two paths is not a "format difference" but a "control-flow difference."

To put this fork in more engineering terms: from the perspective of the agent-loop, what it consumes is a unified async stream. It aggregates content blocks by index while watching for the final finish chunk. If reason.kind === 'tool-calls', the loop cannot simply exit; instead, it must extract the already-assembled tool-call blocks, hand them to the tool execution layer, and after obtaining results, construct new options.messages and call the adapter's stream() again. Therefore, adapter authors must guarantee one thing: whenever tool-call blocks are produced, finish must report tool-calls; only when there are no tool-call blocks is it permissible to report stop. If this consistency is broken, the Harness will either execute with an empty tool list or discard the tool requests the model explicitly made—both are runtime failures that are extremely difficult to diagnose.

finish.reason.kindSemanticsHarness's next actionTypical trigger scenario
stopGeneration ends naturally, conversation turn completeReturn the aggregated text blocks to the upper layer, wait for the next round of inputOrdinary Q&A, plain-text summaries, final answer already given
tool-callsRequest the Harness to execute tools and then flow backExtract tool-call blocks, execute tools, write results back to messages, then regenerateThe model decides to invoke external capabilities such as bash, retrieval, file writing, etc.

Note a detail that is easy to overlook: finish must be the very last chunk of the entire stream, and usage must be sent before it. The source material explicitly notes in its example: "report token usage, must be before finish." The significance of this ordering constraint is that the agent-loop can only complete cost and usage accounting at the end of this round of generation after first receiving usage; if the adapter sends finish first and usage afterward, the consumer may close the aggregator and return upward the moment it receives finish, causing the usage chunk to be discarded. So the ordering is not a matter of style—it is a protocol contract.

One more layer of judgment experience: when the model initiates a tool call after generating a stretch of text, the correct approach is to first close the text block normally (block-end carrying the complete text), then open the tool-call block. Do not attempt to mix text deltas and tool-call deltas under the same index, because blockType is already declared at block-start, and subsequent deltas cannot change the block's type. The example sequence in the source material confirms this as well: index 0 is text, index 1 is tool-call, each block goes through its own start / delta / end, and only then does the stream wrap up. This model of "self-consistent within a block, ordered across blocks" is the core of the entire StreamChunk protocol.

Full breakdown of the exampleChunks fragment sequence, piece by piece: text block 0 + tool-call block 1

The material provides an official exampleChunks example that emits all chunks of a single generation in order. We walk through them one by one by index to see clearly how the two blocks connect end to end, and where usage and finish finally land.

First piece: open the text block. { type: 'block-start', index: 0, blockType: 'text' }. Here index is the block number, starting from 0; blockType declares that this block's type is text. When the consumer receives this piece, it knows to initialize a text aggregator next and register it at index 0. Note that there is no text content yet at this point—block-start is only responsible for "reserving a slot + declaring the type."

Second and third pieces: text deltas. First { type: 'text-delta', index: 0, text: 'runoob' }, then { type: 'text-delta', index: 0, text: ' 教程' }. The material specifically notes that "text-delta can be split into multiple fragments, incrementally concatenated into the complete text." This means the adapter can absolutely slice a reply into any number of deltas and emit them, as long as they all point to the same index, and the consumer can reconstruct it by concatenating them in order. The engineering trade-off is: fragments too coarse, and the streaming feel degrades; fragments too fine, and the fragment count and scheduling overhead rise. The common approach is to follow the granularity of upstream SSE events—convert one segment for each segment the upstream provides, and don't take it upon yourself to buffer them into large chunks.

Fourth piece: close the text block. { type: 'block-end', index: 0, block: { type: 'text', text: 'runoob 教程' } }. There are two key points: first, index must match block-start, both being 0; second, the block carries the complete block, that is, the final text after concatenating the two deltas. This gives the consumer a verification anchor—if the consumer's own concatenated result does not match the complete block carried by block-end, then something went wrong during transmission or conversion of the fragments. The material's comment puts it directly: "end with the complete block, index consistent with block-start."

Fifth piece: open the tool-call block. { type: 'block-start', index: 1, blockType: 'tool-call' }. The index increments to 1, and blockType becomes tool-call. Text blocks and tool-call blocks are two different kinds of content blocks, each going through its own start / delta / end, so this is a new starting point rather than something stacked on index 0.

Sixth piece: tool-call delta. { type: 'tool-call-delta', index: 1, id: CallId('call-123'), name: 'bash', argumentsDelta: '{\"command\":\"echo runoob\"}' }. This has the highest field density, so let's go through them one by one: id is generated by the CallId factory, ensuring type safety and a unified identifier; name is the tool name; argumentsDelta is an increment of the raw JSON text—note the material's exact wording: it is not a parsed object, but a string fragment. For many upstream vendors, streaming tool calls simply push the JSON arguments sliced character by character, and the adapter's job is to carry these character fragments as-is. Do not perform JSON parsing or completion here, otherwise it is extremely easy to throw an error on a half-finished JSON string.

Chunk 7: Closing the tool-call block. { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('call-123'), name: 'bash', arguments: '{\"command\":\"echo runoob\"}' } }. The index is still 1, and arguments inside the block is the "assembled JSON text." At this point, both content blocks are fully closed.

Chunk 8: Usage report. { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }. The source material explicitly states it "must come before finish." The fields are inputTokens and outputTokens, with example values of 100 and 50 respectively.

Chunk 9: Finish declaration. { type: 'finish', reason: { kind: 'stop' } }. The source material notes that this is the "last chunk, declaring the reason for finishing," and hints that it can be replaced with { kind: 'tool-calls' } to request tool execution. Since this example does produce a tool-call block at index 1, a more self-consistent variant would write this chunk as { kind: 'tool-calls' }; the example keeps stop only to demonstrate the field's position.

Below is a minimal generator you can run and compare against directly, producing the nine chunks above verbatim, so you can get the protocol working locally before integrating with a real vendor:

// File path: examples/example-chunks.ts
import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'

export async function* exampleChunks(): AsyncIterable<StreamChunk> {
  // 1. Open the text block, index 0
  yield { type: 'block-start', index: 0, blockType: 'text' }

  // 2. Text delta, can be split into multiple chunks
  yield { type: 'text-delta', index: 0, text: 'runoob' }
  yield { type: 'text-delta', index: 0, text: ' 教程' }

  // 3. End with the complete block, index matches block-start
  yield {
    type: 'block-end',
    index: 0,
    block: { type: 'text', text: 'runoob 教程' },
  }

  // 4. Tool-call block, index 1
  yield { type: 'block-start', index: 1, blockType: 'tool-call' }
  yield {
    type: 'tool-call-delta',
    index: 1,
    id: CallId('call-123'),
    name: 'bash',
    argumentsDelta: '{\"command\":\"echo runoob\"}',
  }
  yield {
    type: 'block-end',
    index: 1,
    block: {
      type: 'tool-call',
      id: CallId('call-123'),
      name: 'bash',
      arguments: '{\"command\":\"echo runoob\"}',
    },
  }

  // 5. token usage, must come before finish
  yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }

  // 6. Finish reason; replace with { kind: 'tool-calls' } to request tool execution
  yield { type: 'finish', reason: { kind: 'stop' } }
}

Once you get this generator working, the concept of a "chunk" will become muscle memory: each chunk is a closed unit of start → delta* → end, chunks are distinguished from one another by index, and the entire stream is wrapped up by usage + finish. All the difficulties in adapting to different vendors later essentially boil down to mapping each vendor's proprietary event stream into these closed units.

cordis.yml Configuration and Schemastery Validation: apiKey and providers Are Both Required

Now that the protocol is clear, let's return to the plugin side. For an adapter plugin to be loaded by Harness, it needs to declare its configuration structure and validate it at load time. cordis.yml is the configuration carrier, and Schemastery is the validator. The configuration interface given in the material is very restrained—only two fields, but both are required.

// File path: src/my-llm-adapter.ts (configuration section)
import Schema from '@deepseek-ai/schemastery'

export interface Config {
  apiKey: string
  providers: string[]
}

export const Config: Schema<Config> = Schema.object({
  apiKey: Schema.string().required(),
  providers: Schema.array(Schema.string()).required(),
})

There is a hard-and-fast convention here, stated in the original material as "a Schemastery schema with the same name, validating the configuration at load time": the exported Config interface and the exported Config schema must share the same name and the same structure. In TypeScript, an interface and a constant can coexist under the same name—the former provides compile-time types, the latter provides runtime validation, and both are indispensable. The interface is what gives you type hints when you write config.apiKey inside apply(ctx, config); the schema is what reports an error at the plugin loading stage when the user omits a field or fills in the wrong type, rather than throwing an obscure runtime exception on the first request.

The division of labor between the two fields is also worth spelling out. apiKey is Schema.string().required()—a credential, usually one per provider; providers is Schema.array(Schema.string()).required()—a list of provider route names that determines which routes this adapter responds to. In the example, the material passes in an array like ['my-provider']—note that it is an array, not a single string, which leaves room in the interface for binding multiple routes at once.

Configuration ItemSchemastery SyntaxRequiredTypePurpose
apiKeySchema.string().required()YesstringCredential required to access a specific provider
providersSchema.array(Schema.string()).required()Yesstring[]List of route names bound to this adapter

The corresponding cordis.yml snippet looks like this:

# File path: cordis.yml
plugins:
  my-llm-adapter:
    apiKey: sk-your-provider-key
    providers:
      - my-provider
      - my-provider-backup

There are three common pitfalls. The first: inconsistent field name spelling. If the interface declares apiKey but the schema declares api_key, TypeScript will not report an error (because they are two independent declarations), but when the user fills in api_key according to the schema, reading apiKey in the code yields undefined. The way to avoid this is to write the interface and the schema adjacent to each other and bind the two with an explicit assignment, such as export const Config: Schema<Config> = ..., letting the generic parameter check structural differences for you.

Second category: forgetting required. A field that omits .required() will not raise an error when missing; instead, it silently becomes undefined. A missing apiKey will get you a 401 on the first request; a missing providers is even more insidious—registerAdapter(undefined, adapter) may not throw, but the route will never match, manifesting as "the plugin loads successfully but the model can't be reached." Therefore, the material's emphasis on marking both fields as required is a necessary defense.

Third category: providers filled in as a single string. When a user writes providers: my-provider in YAML instead of a list, Schemastery's Schema.array() will reject it outright at the loading stage—which is actually a good thing: the error is shifted forward to the configuration stage. When publishing a plugin, including a minimal cordis.yml example in the README can eliminate a large number of these issues.

inject = ['llm'] and apply(ctx, config): dependency injection ensures ctx.llm is ready

Once configuration validation passes, the plugin enters its lifecycle. The two exports provided in the material—inject and apply—form the plugin's execution entry points. Their relationship is: inject declares dependencies, apply executes once dependencies are ready.

export const inject = ['llm'] means: this plugin depends on a service named llm. When loading the plugin, Harness (built on Cordis's dependency injection) first confirms that ctx.llm is already available; if it isn't ready, apply will not be called. The material's original wording is "declare a dependency on the llm service, ensuring ctx.llm is ready." This solves a very real timing problem: if a plugin calls ctx.llm.registerAdapter(...) before ctx.llm has finished registering, it will either throw an exception outright or fail silently—and such failures often appear in the startup log as nothing more than an inconspicuous error line, making them very costly to troubleshoot.

Once inside apply(ctx, config), the logic consists of only two steps: construct the adapter instance, and bind the routes. The material's code is:

export function apply(ctx: Context, config: Config) {
  const adapter = new MyAdapter(config.apiKey)
  // 把提供方路由列表绑定到这个适配器
  ctx.llm.registerAdapter(config.providers, adapter)
}

Let's break this down point by point. First, new MyAdapter(config.apiKey) injects the validated apiKey into the adapter instance; the adapter holds it internally and uses it for authentication in stream(). Second, ctx.llm.registerAdapter(config.providers, adapter) writes the mapping of "route name array → adapter instance" into the ctx.llm registry. The material also explains elsewhere the registry's role: it is the middle layer, maintaining the abstract contract of LlmAdapter; the top layer is the agent-loop, which consumes a provider-agnostic streaming generation service; the bottom layer consists of the individual adapters, each interfacing with a different API format. This three-layer sandwich structure is precisely the seam depicted in the seam diagram.

Several engineering pitfalls converge here. Pitfall one: registering outside of apply. Some people write registerAdapter at the module's top-level scope, so it executes as soon as the module is imported. At that point ctx may not yet exist, or ctx.llm may not yet be ready. The correct approach is to register only inside apply.

Pitfall two: writing inject as something other than a dependency string. inject must be an array of service names; writing the wrong name is equivalent to not declaring the dependency at all, and the timing problem remains.

Pitfall three: doing heavy work inside apply. apply should be lightweight assembly logic—constructing instances and registering routes. Do not initiate network requests inside apply to probe endpoint availability, and do not perform large-scale initialization; otherwise you will slow down startup, and a single failed probe may cause the entire plugin to fail loading.

Pitfall four: multi-instance conflicts. If the same providers name is registered by two plugins simultaneously, the behavior of the later registration depends on the registry implementation. In large-scale deployments, it is advisable to give each route a unique name and to check for duplicates with a script during the configuration review stage.

Landing the adapter skeleton: file structure and export conventions of src/my-llm-adapter.ts

Putting all the previous snippets together gives you a minimal runnable skeleton. The file path provided in the material is src/my-llm-adapter.ts, and the export conventions consist of the four-piece set: name, Config, inject, and apply. The complete structure is as follows:

// File path: src/my-llm-adapter.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'

// Adapter: extend the abstract class and implement stream()
class MyAdapter extends LlmAdapter {
  private apiKey: string

  constructor(apiKey: string) {
    super()
    this.apiKey = apiKey
  }

  // stream() returns an async generator, yielding StreamChunk one piece at a time
  async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
    // 1. Convert options.messages into the provider format
    // 2. Call the streaming API
    // 3. Convert the response into StreamChunk
  }
}

// Plugin config: both apiKey and providers are required
export interface Config {
  apiKey: string
  providers: string[]
}

// A Schemastery schema of the same name, validating config at load time
export const Config: Schema<Config> = Schema.object({
  apiKey: Schema.string().required(),
  providers: Schema.array(Schema.string()).required(),
})

export const name = 'my-llm-adapter'

// Declare a 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 routing list to this adapter
  ctx.llm.registerAdapter(config.providers, adapter)
}

You can remember the division of labor among the four exports like this. name is the plugin identifier, used for logs, config section keys, and error attribution, and it must match the key in cordis.yml. Config is a dual-form export: the interface provides the type, the schema provides validation, and the two share the same name and structure. inject is the dependency declaration, and currently only ['llm'] is needed. apply is the assembly function, receiving the ready ctx and the validated config.

The signature of stream() is async *stream(options: GenerateOptions): AsyncIterable<StreamChunk>. There are three layers of information here: async generator indicates that it is an async generator, naturally supporting yield to produce pieces one at a time and for await...of to consume them one at a time; the GenerateOptions parameter carries the Harness's provider-agnostic request, the most important part of which is options.messages; the return type AsyncIterable<StreamChunk> locks the output to a sequence of StreamChunk. The three-step comments in the material—convert the message format, call the streaming API, convert the response into StreamChunk—are exactly the universal three-stage pattern of all adapters.

Export NameFormRequiredResponsibilityConsequences of Getting It Wrong
nameString constantYesPlugin identifier, corresponding to the cordis.yml section keyThe plugin fails to load correctly or log attribution becomes ambiguous
Configinterface + Schema exported under the same nameYesCompile-time types + runtime validationConfiguration errors cannot be surfaced at load time
injectString arrayYes (when depending on llm)Declares dependencies, ensuring ctx.llm is readyRegistration happens too early, causing registerAdapter to fail
applyFunctionYesConstructs the adapter and binds routesThe adapter does not take effect, and routes always return 404

There is also a practical recommendation: keep the adapter class MyAdapter module-private (not exported), and export only the four-piece set. This way, external code can only invoke capabilities through route names and cannot directly new MyAdapter() to bypass configuration validation. Likewise, the constructor's apiKey parameter should not have a default value, forcing the caller to pass it explicitly and avoiding the situation where "configuration is missing but it still runs with a default empty string."

September 2026 in Practice: Engineering Multi-Provider Routing and StreamChunk Order Validation

When adapters go from "one" to "a batch," the engineering focus shifts from "getting it to run" to "being regression-testable, observable, and extensible." Below are the mainstream implementation practices for multi-provider scenarios around September 2026, all built around the mechanisms presented in the source material.

First, bind multiple routes at once with a providers array. The registration signature from the source material, ctx.llm.registerAdapter(config.providers, adapter), already accepts an array, so scenarios with "the same API format, multiple route names" don't require registering multiple times. A typical use case is having a primary and a backup route name point to the same adapter instance, paired with an upper-layer routing strategy for failover; alternatively, splitting a vendor's multiple model families into multiple route names while selecting different request parameters by route name inside the adapter. The benefit is clear: there's only one adapter instance, so the connection pool, credentials, and retry policies are all reused.

Second, assert the order of the chunk sequence. The source material uses exampleChunks to demonstrate strict ordering; once scaled up, this ordering should become an executable assertion rather than relying on manual review. Two categories of assertions are recommended: structural assertions—every block-start must have a paired block-end with the same index; state machine assertions—a delta may only appear within a block that has been opened and not yet closed, and neither "a delta without a block-start" nor "a delta after block-end" is allowed. A third category is closing assertions—the last two chunks of the stream must be usage and finish, with usage preceding finish.

The validator below can be plugged directly into CI to check the chunk stream produced by the adapter:

// 文件路径:test/assert-chunk-sequence.ts
import type { StreamChunk } from '@deepseek-ai/dsh-llm'

export function assertChunkSequence(chunks: StreamChunk[]): void {
  const open = new Map<number, string>()

  chunks.forEach((c, i) => {
    if (c.type === 'block-start') {
      if (open.has(c.index)) {
        throw new Error(`第 ${i} 片:index ${c.index} 重复 block-start`)
      }
      open.set(c.index, c.blockType)
      return
    }
    if (c.type === 'text-delta' || c.type === 'tool-call-delta') {
      if (!open.has(c.index)) {
        throw new Error(`第 ${i} 片:index ${c.index} 的 delta 落在未开启的块内`)
      }
      return
    }
    if (c.type === 'block-end') {
      if (!open.has(c.index)) {
        throw new Error(`第 ${i} 片:index ${c.index} 缺少配对的 block-start`)
      }
      open.delete(c.index)
      return
    }
    if (c.type === 'usage') {
      if (open.size > 0) {
        throw new Error(`第 ${i} 片:usage 之前仍有未闭合的块`)
      }
      return
    }
    if (c.type === 'finish') {
      const last = chunks[i - 1]
      if (!last || last.type !== 'usage') {
        throw new Error('finish 之前必须是 usage')
      }
      if (i !== chunks.length - 1) {
        throw new Error('finish 必须是最后一个分片')
      }
    }
  })

  if (open.size > 0) {
    throw new Error(`仍有未闭合的块:${[...open.keys()].join(', ')}`)
  }
}

Third, incorporate usage and finish into regression test cases. Many teams' regression cases only assert that "text is produced," missing usage and finish. Once a change causes the adapter to return early on an exception path, text still appears, but usage and finish are missing, and tool-calling scenarios silently fail. By writing "the last chunk is finish and the one before it is usage" as an assertion, such issues can be caught at the CI stage.

Fourth, validate the linkage between tool calls and finish semantics. A rule should be added to regression cases: if a tool-call block has appeared in the chunk sequence, then the finish's reason.kind must be tool-calls; if there is no tool-call block, it should be stop. This rule binds "model intent" to "protocol declaration," preventing the agent-loop from receiving a self-contradictory stream.

Fifth, establish a unified record-and-replay mechanism for multiple providers. Each adapter should record at least one real chunk sequence as a fixture, and replay it against the same set of sequential assertions. This way, when adding a new provider, as long as the fixture passes the assertions, protocol compatibility has its first layer of assurance; when issues arise in production, the same assertions can quickly determine whether the adapter transformed incorrectly or the upstream itself returned an anomalous sequence.

Troubleshooting Checklist for Streaming Transport Failures: Chunk Ordering, Index Alignment, and Missing Termination

When "the model isn't responding," "tools aren't executing," or "replies are being truncated" show up in production, the vast majority of root causes come down to the chunk protocol. The checklist below is ordered from "outside in, coarse to fine"—follow it and you'll converge quickly.

  1. First, confirm whether the stream has ended. Check whether a finish chunk was received. If there's no finish, it means the adapter may have thrown an exception or returned early when the upstream connection was interrupted, while the consumer is still waiting. In this case, check whether the exception path uniformly emits a "usage + finish" pair, or at least emits a recognizable end signal.
  2. Check whether usage comes before finish. This is the most common ordering mistake. If usage is missing, cost accounting is lost; if usage comes after finish, the consumer may have already closed the aggregator and discarded it. The fix is to enforce a fixed order in the adapter's teardown logic: yield usage first, then yield finish.
  3. Verify that block-start and block-end indices are paired. Every opened block must have a matching close with the same index. A common bug is hardcoding the index to 0 through copy-paste, which causes the block with index 1 to never close in multi-block scenarios, or two blocks to cross-contaminate each other.
  4. Check whether deltas land in the correct block. The index of a text-delta must point to an already-opened text block, and the index of a tool-call-delta must point to an already-opened tool-call block. A type mismatch (sending a tool-call-delta into a text block) may not raise an error in lenient implementations, but the aggregated result on the consumer side is guaranteed to be wrong.
  5. Verify consistency between blockType and subsequent delta types. If block-start declares a blockType, subsequent deltas must be of the same type. To produce both text and tool calls, the correct approach is to open two blocks, not to mix them within one block.
  6. Check whether the complete block carried by block-end matches the concatenated delta result. This is the last line of self-check. If the text the consumer assembles from deltas doesn't match the block in block-end, it means the adapter applied extra processing on one side (e.g., trimming the deltas, or completing the full block).
  7. Check whether the tool call's argumentsDelta is being parsed twice. Many adapter authors attempt JSON.parse upon receiving the first argumentsDelta, only to throw an error on a half-formed JSON and break the stream. The correct approach is to pass through the character fragments as-is, and only provide the assembled complete JSON text at block-end.
  8. Check whether finish.reason is self-consistent with the tool-call blocks. If there's a tool-call block but the reason is stop, Harness won't execute the tool, manifesting as "the model says it wants to call a tool but nothing happens"; if there's no tool-call block but the reason is tool-calls, Harness gets an empty tool list and may error out or spin idly.
  9. Check inject and registration timing. If the logs show "adapter not registered" or "route not matched," go back and confirm whether inject = ['llm'] is declared and whether registerAdapter is called inside apply. The symptom of this class of problems is "the entire stream never even started," which is easy to confuse with chunk ordering issues.
  10. Check whether the providers route name matches the caller side. If the config binds my-provider but the caller writes my-provider-2, you'll get a route-not-found error. For large-scale deployments, it's recommended to print the list of registered routes in the startup logs.

To turn this checklist into a reusable diagnostic tool, you can wrap a debug proxy around the adapter, print out the chunk sequence produced on each run, and run a set of assertions against it:

// File path: scripts/trace-adapter.ts
import { assertChunkSequence } from '../test/assert-chunk-sequence'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'

export async function traceStream(
  label: string,
  stream: AsyncIterable<StreamChunk>,
): Promise<StreamChunk[]> {
  const seen: StreamChunk[] = []
  for await (const chunk of stream) {
    seen.push(chunk)
    console.log(`[${label}] #${seen.length - 1}`, JSON.stringify(chunk))
  }
  assertChunkSequence(seen)
  console.log(`[${label}] total chunks ${seen.length}, order check passed`)
  return seen
}

// Usage: just wrap the stream produced by the adapter
// const chunks = await traceStream('my-provider', adapter.stream(options))

Running it is straightforward—just call it from your integration tests or a local debug script:

# Run the chunk order regression
npx tsx scripts/trace-adapter.ts

The value of this checklist lies in the fact that the order cannot be skipped: first check the ending, then the pairing, then the intra-chunk contents, and finally the registration. Many people jump straight to diffing the specific text content, which actually takes a detour—for protocol-layer problems, always start by pinpointing them with protocol-layer assertions.

Summary and Best Practices

Compress the two sections into an actionable checklist, organized into three phases: "before writing code, while writing code, and after shipping":

  • Keep adapter responsibilities singular: LlmAdapter only does bidirectional translation—converting options.messages into the provider's format, and converting provider responses into StreamChunk. Do not mix business logic, retry orchestration, or context trimming into the adapter; those belong to the upper layers.
  • Keep the block model firmly in mind: each content block is a closed unit of block-start → delta* → block-end; text blocks and tool-call blocks each go through this cycle once, distinguished by index; block-end must carry the complete block consistent with the concatenated deltas.
  • The closing order must not be reversed: first usage (including inputTokens / outputTokens), then finish; finish must be the last chunk.
  • finish.reason and tool calls must be strictly self-consistent: if any tool-call block was produced, use { kind: 'tool-calls' } to request that the Harness execute the tools and flow back; otherwise use { kind: 'stop' }.
  • Pass argumentsDelta through as-is: it is an increment of raw JSON text; do not perform JSON parsing or completion at the chunk stage—leave concatenation and parsing to the consumer after block-end.
  • Config dual export with the same name and shape: the Config interface provides types, the Config schema provides validation; mark both apiKey and providers with .required() so configuration errors surface at load time.
  • Dependency declarations are mandatory: export const inject = ['llm'] ensures ctx.llm is ready; call registerAdapter only inside apply(ctx, config) to prevent registering too early.
  • The four-piece export convention: name, Config, inject, and apply must all be present; keep the adapter class module-private, and force the constructor to take an explicit apiKey.
  • Bind multiple routes at once: directly leverage the array capability of registerAdapter(config.providers, adapter) so that primary/backup routes or multiple routes of the same format share a single adapter instance.
  • Turn protocol ordering into assertions: in CI, verify the index pairing of block-start / block-end, that deltas fall within the correct block, and the placement of usage and finish; additionally assert the linkage between tool-call blocks and finish.reason.
  • Use fixtures for multi-provider regression: record a real chunk sequence for each adapter as a replay sample; when adding a new provider, pass the assertions before shipping.
  • Troubleshoot in order: first check whether finish and usage are emitted and whether the order is correct, then check index pairing, then check delta attribution, and finally check inject and providers route registration.

Once you have done the twelve items above, you will have an LLM adapter that can connect to any vendor and be reliably consumed by the Harness: the agent-loop only faces the unified StreamChunk protocol, the ctx.llm registry maintains the abstract contract, and the underlying adapters each manage their own API formats—this is exactly the layered value depicted by the seam diagram.