2026-07-15 18:08:28 +08:00
|
|
|
|
# LLM 适配器
|
|
|
|
|
|
|
|
|
|
|
|
[English](llm-adapter.md) | 中文
|
|
|
|
|
|
|
2026-08-04 17:36:14 +08:00
|
|
|
|
本文介绍如何为 Harness 接入新的模型提供方。
|
2026-07-15 18:08:28 +08:00
|
|
|
|
|
|
|
|
|
|
## 概述
|
|
|
|
|
|
|
2026-08-04 17:36:14 +08:00
|
|
|
|
LLM 适配器是一个继承 `LlmAdapter` 并实现 `stream()` 方法的类,它会将 Harness 的提供方无关请求转换为具体提供方的 API 调用,并将响应转换回 Harness 分片。
|
2026-07-15 18:08:28 +08:00
|
|
|
|
|
|
|
|
|
|
## 最小实现
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
build(vendor): rescope the vendored Cordis packages into @deepseek-ai
Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it
prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`,
`verify-translation-pairing --write` for the touched bilingual pairs,
`gen-doc-graphs`, and one typert snapshot whose ids embed character offsets.
`pnpm run rescope-vendor --check` verifies the result.
Renames nine vendored packages (cordis, cosmokit, schemastery and the six
@cordisjs plugins) and every reference that resolves them: manifest names and
dependency keys, module specifiers including declare-module merges, cordis.yml
plugin names, tsconfig paths, every Markdown fence, and `docs/` prose.
Directory names, upstream versions, and dependency ranges are unchanged, so
vendor/README.md still reads as an upstream snapshot; its manifest table gains
an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed
at each fork's origin.
The tutorial tier follows the rename end to end: its yaml fences named plugins
the Loader can no longer resolve, its `ts ignore-check` fences disagreed with
the compiled fences beside them, and its prose quoted both. The contracts that
told readers to keep upstream names — the root convention and the vendoring
cookbook's tree comment and manifest invariant — now say to rescope instead.
Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle
purity gate now names the vendored libraries a browser bundle inlines, and the
files where a bare `cordis` is an agent-preset id keep that product data.
2026-08-10 22:04:06 +08:00
|
|
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
|
|
|
|
import Schema from '@deepseek-ai/schemastery'
|
2026-07-15 18:08:28 +08:00
|
|
|
|
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
|
2026-08-13 13:43:43 +08:00
|
|
|
|
providers: string[]
|
2026-07-15 18:08:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const Config: Schema<Config> = Schema.object({
|
|
|
|
|
|
apiKey: Schema.string().required(),
|
2026-08-13 13:43:43 +08:00
|
|
|
|
providers: Schema.array(Schema.string()).required(),
|
2026-07-15 18:08:28 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
export const name = 'my-llm-adapter'
|
|
|
|
|
|
export const inject = ['llm']
|
|
|
|
|
|
|
|
|
|
|
|
export function apply(ctx: Context, config: Config) {
|
|
|
|
|
|
const adapter = new MyAdapter(config.apiKey)
|
2026-08-13 13:43:43 +08:00
|
|
|
|
ctx.llm.registerAdapter(config.providers, adapter)
|
2026-07-15 18:08:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## StreamChunk 协议
|
|
|
|
|
|
|
2026-08-04 17:36:14 +08:00
|
|
|
|
`stream()` 必须按以下协议生成分片:
|
2026-07-15 18:08:28 +08:00
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
|
|
|
|
|
|
|
|
|
|
async function* exampleChunks(): AsyncIterable<StreamChunk> {
|
|
|
|
|
|
// 1. Start each content block with block-start.
|
|
|
|
|
|
yield { type: 'block-start', index: 0, blockType: 'text' }
|
|
|
|
|
|
|
|
|
|
|
|
// 2. Stream text through text-delta.
|
|
|
|
|
|
yield { type: 'text-delta', index: 0, text: 'Hello' }
|
|
|
|
|
|
yield { type: 'text-delta', index: 0, text: ' world' }
|
|
|
|
|
|
|
|
|
|
|
|
// 3. End each content block with block-end and the complete block.
|
|
|
|
|
|
yield {
|
|
|
|
|
|
type: 'block-end',
|
|
|
|
|
|
index: 0,
|
|
|
|
|
|
block: { type: 'text', text: 'Hello world' },
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 4. Tool-call block.
|
|
|
|
|
|
yield { type: 'block-start', index: 1, blockType: 'tool-call' }
|
|
|
|
|
|
yield {
|
|
|
|
|
|
type: 'tool-call-delta',
|
|
|
|
|
|
index: 1,
|
|
|
|
|
|
id: CallId('call-123'),
|
|
|
|
|
|
name: 'bash',
|
|
|
|
|
|
argumentsDelta: '{"command":"ls"}',
|
|
|
|
|
|
}
|
|
|
|
|
|
yield {
|
|
|
|
|
|
type: 'block-end',
|
|
|
|
|
|
index: 1,
|
|
|
|
|
|
block: {
|
|
|
|
|
|
type: 'tool-call',
|
|
|
|
|
|
id: CallId('call-123'),
|
|
|
|
|
|
name: 'bash',
|
|
|
|
|
|
arguments: '{"command":"ls"}',
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 5. Token usage.
|
|
|
|
|
|
yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }
|
|
|
|
|
|
|
|
|
|
|
|
// 6. Finish reason.
|
|
|
|
|
|
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
|
|
|
|
// Alternatively, { kind: 'tool-calls' } requests tool execution.
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 关键规则
|
|
|
|
|
|
|
2026-08-04 17:36:14 +08:00
|
|
|
|
- 每个 `block-start` 都必须有与之对应的 `block-end`。
|
|
|
|
|
|
- `index` 从 0 开始递增,用于标识内容块的顺序。
|
|
|
|
|
|
- `tool-call-delta` 的 `argumentsDelta` 是原始 JSON 文本的增量,可以在一个分片中完整生成,也可以分多个分片生成。
|
|
|
|
|
|
- `finish` 必须是最后一个分片。
|
|
|
|
|
|
- `usage` 必须在 `finish` 之前生成。
|
2026-07-15 18:08:28 +08:00
|
|
|
|
|
|
|
|
|
|
## GenerateOptions
|
|
|
|
|
|
|
2026-08-04 17:36:14 +08:00
|
|
|
|
`stream()` 接收仓库导出的 `GenerateOptions`。它包含模型、适配器拥有的推理强度 ID、对话历史、系统提示词、工具 schema、生成参数、停止序列和中止信号;完整字段以 `@deepseek-ai/dsh-llm` 导出的 TypeScript 类型为准。适配器必须将支持的字段映射到具体 API;如果无法支持某个字段,应抛出带稳定 code 的 `LlmError`,不得静默丢弃。
|
2026-07-25 07:47:51 +08:00
|
|
|
|
|
2026-08-04 17:36:14 +08:00
|
|
|
|
请覆写 `resolveModel(provider, model, signal?)`,在一次查询中返回确切的提供方/模型身份以及可选的 `context` 和 `reasoning` 元数据。推理元数据包含有序的不透明 ID、展示名称,以及可选的配置默认值;请保留适配器给出的权威可选列表,包括其上游能力 API 返回的 `off`,不要将这些值提升为核心枚举。异步查询必须响应该可选信号,使取消和资源释放过程完全停稳。服务会校验聚合结果,并在调用 `stream()` 前拒绝显式指定但不受支持的推理强度;省略 `reasoning` 表示该模型没有可选的推理强度能力。
|
2026-07-15 18:08:28 +08:00
|
|
|
|
|
|
|
|
|
|
## 注册适配器
|
|
|
|
|
|
|
|
|
|
|
|
```ts ignore-check
|
2026-08-13 13:43:43 +08:00
|
|
|
|
ctx.llm.registerAdapter(['my-provider'], adapter)
|
2026-07-15 18:08:28 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-08-13 13:43:43 +08:00
|
|
|
|
第一个参数是该适配器处理的提供方路由列表。`GenerateOptions.provider` 选择已注册的适配器,`GenerateOptions.model` 则传入由适配器拥有、无需在生命周期启动时注册的模型 id。适配器能够向选择器公布模型选项时,请覆写 `listModels()`。
|
2026-07-15 18:08:28 +08:00
|
|
|
|
|
|
|
|
|
|
## 在 cordis.yml 中使用
|
|
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
|
- id: my-llm
|
|
|
|
|
|
name: './src/my-llm-adapter.ts'
|
|
|
|
|
|
config:
|
|
|
|
|
|
apiKey: !!js process.env.MY_API_KEY
|
2026-08-13 13:43:43 +08:00
|
|
|
|
providers:
|
|
|
|
|
|
- my-provider
|
2026-07-15 18:08:28 +08:00
|
|
|
|
|
refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.
- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
patches never cross an include boundary. Precedence: base < surface <
(--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
test tree never inherits the user's route; new `--config-replace` boots a file
as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
configure or disable it. Upstream built the id index once before the patch
loop, leaving every surface-only row — the whole TUI front door — silently
unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay
Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.
Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 13:58:21 +08:00
|
|
|
|
- id: agent-loop
|
|
|
|
|
|
name: '@deepseek-ai/dsh-agent-loop'
|
2026-07-15 18:08:28 +08:00
|
|
|
|
config:
|
refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.
- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
patches never cross an include boundary. Precedence: base < surface <
(--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
test tree never inherits the user's route; new `--config-replace` boots a file
as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
configure or disable it. Upstream built the id index once before the patch
loop, leaving every surface-only row — the whole TUI front door — silently
unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay
Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.
Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 13:58:21 +08:00
|
|
|
|
agents:
|
|
|
|
|
|
- id: main
|
2026-08-13 13:43:43 +08:00
|
|
|
|
provider: my-provider
|
|
|
|
|
|
model: my-model-v1
|
2026-07-15 18:08:28 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 实战参考
|
|
|
|
|
|
|
2026-08-04 17:36:14 +08:00
|
|
|
|
仓库中包含以下两个完整实现:
|
2026-07-15 18:08:28 +08:00
|
|
|
|
|
|
|
|
|
|
- `packages/llm/llm-deepseek/` — DeepSeek API 适配器(OpenAI 兼容格式)
|
|
|
|
|
|
- `packages/llm/llm-pi-ai/` — Pi AI 适配器(不同的 API 格式)
|
|
|
|
|
|
|
2026-08-12 09:59:19 +08:00
|
|
|
|
对比这两个已交付的适配器,可以看到同一套 harness 契约如何在不同提供方 SDK 之上实现。
|
2026-07-15 18:08:28 +08:00
|
|
|
|
|
|
|
|
|
|
## 错误处理
|
|
|
|
|
|
|
2026-08-04 17:36:14 +08:00
|
|
|
|
适配器应通过带稳定 code 的 `LlmError` 抛出传输和协议故障;agent loop(智能体循环)会保留该错误及其 code,用于诊断和策略处理。不要依赖普通 `Error` 被自动转换。每个提供方 HTTP 请求还必须合并 `attributionHeaders()`,并传递 `options.signal`。
|
2026-07-15 18:08:28 +08:00
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
import {
|
|
|
|
|
|
attributionHeaders,
|
|
|
|
|
|
LlmAdapter,
|
|
|
|
|
|
LlmError,
|
|
|
|
|
|
type GenerateOptions,
|
|
|
|
|
|
type StreamChunk,
|
|
|
|
|
|
} from '@deepseek-ai/dsh-llm'
|
|
|
|
|
|
|
|
|
|
|
|
class HttpAdapter extends LlmAdapter {
|
|
|
|
|
|
constructor(private readonly endpoint: string) {
|
|
|
|
|
|
super()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
|
|
|
|
const response = await fetch(this.endpoint, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'content-type': 'application/json',
|
|
|
|
|
|
...attributionHeaders(),
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({ model: options.model, messages: options.messages }),
|
|
|
|
|
|
...options.signal ? { signal: options.signal } : {},
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!response.ok) {
|
2026-07-20 10:21:36 +08:00
|
|
|
|
throw new LlmError(`Provider API error: ${response.status}`, 'PROVIDER_HTTP_ERROR')
|
2026-07-15 18:08:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
// A real adapter parses the response and emits the complete chunk sequence.
|
|
|
|
|
|
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|