这是《DeepSeek Harness 权威指南 》系列第 10 篇,也是二开线(B 线)的第 3 篇。源码基线为 deepseek-harness @
47f9438(v0.1.0-rc.5)。文中的源码片段来自该基线;作者 loopback demo 仅连接
127.0.0.1上临时启动的本机 HTTP / SSE server,不携带真实 API Key,也不联系真实模型提供方。自制图是对源码与本机观察的解释,不是任意 OpenAI 兼容端点的认证报告。
B1 中,ctx.tools.register() 把一个工具定义接到工具 registry。LLM adapter 的接缝相似,却更容易被过度简化:看见一个提供 OpenAI 风格 /v1/chat/completions 的 URL,就以为“把 base URL 和 model 改掉”即可。
实际至少有两层工作:
Harness vocabulary
(provider / model / messages / tools / signal / StreamChunk)
↓ adapter owns the translation
Provider wire vocabulary
(endpoint / credential / request JSON / SSE framing / error body / catalog)
这篇只完成一个可复核闭环:注册一个窄范围、text-only 的作者本机 adapter,让 LlmRuntime.stream() 按 provider 路由它;adapter 向本机 OpenAI 风格 SSE endpoint 发请求,再把 SSE 译回 StreamChunk。它不把这个 loopback 夸大为“所有 OpenAI 兼容服务都可用”。
本系列全部 demo 的完整代码见 rex-dhs-core/dsh-b2-adapter (公开仓库,含运行证据)。
一、先建立边界:provider 选 adapter,model 交给 adapter
LlmAdapter 的定义将责任范围写得很清楚(packages/llm/llm/src/index.ts:174-232):
/**
* Provider-wire adapter for the harness message and stream vocabulary. Register implementations
* with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include
* `attributionHeaders()`; prove the headers are added in the wire request or library header hook. The direct-fetch
* DeepSeek and library-backed pi-ai adapters meet this contract through different internals.
*/
export abstract class LlmAdapter {
/**
* Describe one provider route owned by this adapter.
* @param provider - a route passed to `registerAdapter()` for this instance.
* @returns detached display metadata whose id must equal `provider`.
*/
providerInfo(provider: string): LlmProviderInfo {
return { id: provider, name: provider }
}
/**
* Return the provider-owned retry policy captured with this route.
* @param _provider - a route passed to `registerAdapter()` for this instance.
* @returns a resolved policy, or `undefined` to use the normal defaults.
*/
providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined {
return undefined
}
/**
* List models this adapter can currently advertise for one owned provider.
* The result is advisory: an adapter may accept unlisted model ids, and
* consumers must not turn absence into request rejection.
* @param _provider - one provider route owned by this adapter.
* @returns discoverable models in adapter-preferred order.
*/
listModels(_provider: string): Promise<readonly LlmModelInfo[]> {
return Promise.resolve([])
}
/**
* Resolve all metadata available for one exact model. This query is
* independent of the advisory catalog and does not validate request routing.
* @param provider - one provider route owned by this adapter.
* @param model - exact model id passed to {@link GenerateOptions.model}.
* @param _signal - cancellation for this exact-model lookup; asynchronous
* implementations must settle promptly after it aborts.
* @returns provider/model identity plus any context, call-default, and reasoning metadata.
*/
resolveModel(
provider: string,
model: string,
_signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> {
return Promise.resolve({ provider, id: model, name: model })
}
/**
* Stream one model call as raw chunks. The only required method.
* @param options - the fully-assembled request; implementations must honor `options.signal`.
* @returns the chunk stream, obeying the adapter contract documented on `StreamChunk`.
*/
abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>
}
英文注释中最重要的四点:
- adapter 是 provider wire 层翻译器,不是“model 名称映射表”。
- 唯一必选方法是
stream();providerInfo、listModels、resolveModel、retry policy 都是可选扩展点。 listModels()的目录是 advisory(建议性目录)。目录未列出某个 model,不应被上层自动提升成“请求必定拒绝”。- 异步 adapter 工作必须响应
options.signal;这说明取消是需要实际转发的协作式契约,不是 runtime 能硬杀任何 SDK 或 fetch 的承诺。
因此在一次调用中:
| 字段 | 负责选择什么 | 不代表什么 |
|---|---|---|
provider | 已注册 adapter 的路由 | 不等于 endpoint URL、品牌名或万能兼容层 |
model | 交给已选 adapter 的精确 model id | 不等于自动得到该 model 的 capabilities |
messages / system / tools | Harness 的完整请求词汇 | 不等于每个 provider 都能原样接收 |
signal | 调用方取消信号 | 不等于 provider 或 SDK 已真正停止 |

图:依据 47f9438 的 LlmRuntime、LlmAdapter 和两套官方 adapter 实现绘制。provider 选择注册 adapter;adapter 决定 endpoint、凭据、request mapping、SSE / SDK translation 与能力边界。model 字符串本身不承担这些工作。
二、注册是有所有权的路由 effect
registerAdapter() 不只是把对象塞进一个全局 map。它会校验整组 provider routes、绑定到当前 fiber,并返回可 replace 的 disposer(packages/llm/llm/src/index.ts:330-367):
/**
* Register an adapter for the given provider routes. Throws `LlmError` with code
* `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing).
* Disposed with the fiber.
* @param providers - every provider route this adapter should serve.
* @param adapter - the adapter that streams calls for those providers.
* @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}.
*/
registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle {
// The routes this registration currently holds; `replace` rewrites it, and
// the disposer releases whatever it holds at disposal time.
const owned = new Set<string>()
// The disposer has run: `owned` being empty cannot say so on its own,
// because `replace([])` legally leaves a live registration holding none.
let released = false
const dispose = this.ctx.effect(function* (this: LlmRuntime) {
if (providers.length === 0) throw new LlmError('an adapter must register at least one provider', 'INVALID_ADAPTER')
this.commitRoutes(owned, this.prepareRoutes(providers, adapter, owned))
yield () => {
released = true
for (const provider of owned) this.adapters.delete(provider)
owned.clear()
this.emitAdaptersUpdated()
}
}.bind(this), 'llm.registerAdapter()')
// ctx.effect's disposer returns Promise<void>; our disposer API is
// synchronous fire-and-forget — discard the (always-resolved) promise.
const handle = (() => void dispose()) as AdapterRegistrationHandle
handle.replace = (next: string[]): void => {
// Registering here would leak: the effect's disposer already ran, so
// nothing remains to release whatever this call would put in the map.
if (released) {
throw new LlmError('a disposed adapter registration cannot replace its routes', 'REGISTRATION_DISPOSED')
}
this.commitRoutes(owned, this.prepareRoutes(next, adapter, owned))
}
return handle
}
从这段实现能确定的结论是:
- 初次注册必须至少有一条非空 provider route;
- 已被另一个 adapter 占用的 route 会使这次整组注册以
DUPLICATE_ADAPTER失败,不会只成功一半; - registration 随当前 Cordis fiber disposal 撤销;
replace()是同一 adapter instance 的原子 route swap,不是验证新 endpoint、凭据或 model 已经可用;- 释放后再
replace()会得到REGISTRATION_DISPOSED。
官方 runtime 测试也直接验证 provider 路由会把调用交给注册 adapter(packages/llm/llm/tests/service.spec.ts:176-184):
it('routes stream() to the registered adapter', async () => {
const ctx = new Context()
await ctx.plugin(LlmRuntime)
ctx.llm.registerAdapter(['test-provider'], new ScriptedAdapter(SCRIPT))
const chunks: StreamChunk[] = []
for await (const chunk of ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })) chunks.push(chunk)
expect(chunks).toEqual(SCRIPT)
})
这就是 B2 demo 使用 ctx.llm.stream() 而不是直接调用 adapter.stream() 的原因:前者验证 provider route、runtime selection、failure normalization 和 llm/stream waterfall;后者只验证 adapter 自己的生成器。
三、请求与流不是“JSON 进、文本出”
GenerateOptions 包含 provider、model、历史、system、tools、采样参数、stop、signal 和可选 purpose。它不是 OpenAI request body,adapter 必须判断每一项如何映射或拒绝(packages/llm/llm/src/types.ts:319-356)。
同时,adapter 返回的不是字符串,而是 StreamChunk 协议。A5 已解释七种 chunk variant;这里仅抓 adapter 实现最容易出错的四条:
- 每个
block-start需要对应的block-end,并且同一内容块复用 index。 usage必须在 terminalfinish之前发出;finish后不应再发 chunk。- tool-call arguments 是原始 JSON 字符串;provider 如果给对象,需要在 block-end 前明确 stringify。
- provider 不支持的字段应抛出稳定 code 的
LlmError,而不是静默删除。
官方 Pi adapter 对 stop 的处理就是一个非常小但关键的例子(packages/llm/llm-pi-ai/src/adapter.ts:276-279):
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
if (options.stop !== undefined) {
throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION')
}
“没有映射”不是“可以忽略”。如果调用方明确传入 stop,静默丢弃会让模型行为与上层请求表达不一致;稳定 UNSUPPORTED_OPTION 则给了调用方、日志和 retry / policy 层可诊断的事实。
四、作者本机最小 adapter:先验证路由,再验证 wire
为了将 adapter 的两个问题拆开,本文准备了两层无密钥 demo:
| 层次 | 作者本机文件 | 证明的范围 |
|---|---|---|
| 纯 registry adapter | local-mock-adapter.ts + adapter-route-demo.ts | fixed-route plugin shape、provider route、runtime stream、unsupported option 不静默丢弃 |
| loopback OpenAI-style SSE | local-openai-text-adapter.ts + openai-sse-loopback-demo.ts | 一个特定的本机 HTTP 请求形状、SSE framing、chunk translation 与 assembler 结果 |
两者都在:
E:/coding/rex-hugo/.tmp-research/dsh-b2-adapter/
它们不是 47f9438 中已提交的 adapter,也不是可发布 provider 插件。
1. 完整的 keyless mock plugin
以下是作者本机 mock adapter 的完整模块。它没有 endpoint、没有 credential、没有 provider HTTP 请求;目的仅是验证 plugin wiring 与 runtime routing:
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import {
LlmAdapter,
LlmError,
type GenerateOptions,
type StreamChunk,
} from '@deepseek-ai/dsh-llm'
export const name = 'b2-local-mock-adapter'
export const inject = ['llm']
export interface Config {
provider: string
label: string
}
export const Config: z<Config> = z.object({
provider: z.string().required(),
label: z.string().required(),
})
export class LocalMockAdapter extends LlmAdapter {
constructor(private readonly label: string) {
super()
}
override providerInfo(provider: string) {
return { id: provider, name: `${provider} (${this.label})` }
}
override resolveModel(provider: string, model: string) {
return Promise.resolve({ provider, id: model, name: `${this.label} / ${model}` })
}
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
if (options.stop?.length) {
throw new LlmError('b2 local mock does not implement GenerateOptions.stop', 'UNSUPPORTED_OPTION')
}
const text = `route=${options.provider}; model=${options.model}; adapter=${this.label}`
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text }
yield { type: 'block-end', index: 0, block: { type: 'text', text } }
yield { type: 'usage', usage: { inputTokens: 3, outputTokens: 7 } }
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
export function apply(ctx: Context, config: Config): void {
ctx.llm.registerAdapter([config.provider], new LocalMockAdapter(config.label))
}
这里的 resolveModel() 并不让 demo-model 成为全局内置 model;它只告诉 runtime:这个 adapter 接受当前精确的 provider / model route,并给出该 adapter 拥有的显示元数据。
作者实际从 E:/coding/deepseek-harness 执行:
node --import tsx E:/coding/rex-hugo/.tmp-research/dsh-b2-adapter/adapter-route-demo.ts
stdout:
=== registry-routed success ===
block-start index=0 type=text
text-delta index=0 text="route=local-mock; model=demo-model; adapter=LocalMock"
block-end index=0 type=text
usage {"inputTokens":3,"outputTokens":7}
finish {"kind":"stop"}
assembled-text=route=local-mock; model=demo-model; adapter=LocalMock
usage={"inputTokens":3,"outputTokens":7}
finish={"kind":"stop"}
=== adapter-declared unsupported option ===
finish {"kind":"error","failure":{"message":"b2 local mock does not implement GenerateOptions.stop","code":"UNSUPPORTED_OPTION"}}
assembled-text=
usage=undefined
finish={"kind":"error","failure":{"message":"b2 local mock does not implement GenerateOptions.stop","code":"UNSUPPORTED_OPTION"}}
该输出说明 runtime 按 provider: 'local-mock' 找到 adapter;当 adapter 为 stop 抛出 LlmError 后,runtime 对 adapter failure 输出 terminal finish { kind: 'error' }。它不证明 HTTP、SSE、真实 endpoint 或任意工具调用映射。
2. 本机 OpenAI-style SSE loopback:验证一条窄 wire slice
第二个 demo 为每个 case启动一个新的随机本机端口 HTTP server;固定路由 adapter 经 fetch() 访问:
http://127.0.0.1:<ephemeral-port>/v1/chat/completions
成功 case 只接受一种固定 text-only input:可选 system text、空 history、没有 tools、没有 stop、没有 reasoning effort。它要求 HTTP 200 与 content-type: text/event-stream,并发送以下受控 SSE fixture:
data: {"choices":[{"delta":{"content":"你好"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":",本地 SSE。"},"finish_reason":null}]}
data: {"choices":[{"delta":{},"finish_reason":"stop"}]}
data: {"choices":[],"usage":{"prompt_tokens":4,"completion_tokens":5}}
data: [DONE]
这里的 data: <JSON>\n\n、choices[0].delta.content、choices[0].finish_reason、独立 usage event 和 [DONE] 都是该固定 fixture的一部分。它们不等于对任意 gateway 的 SSE 完整兼容声明。

图:本机 loopback 只验证图中使用的 request / SSE slice。成功 case 与 stop 负例使用 fresh Context 和 fresh loopback server;没有真实 API Key、外网、模型或账单,也未覆盖 abort 时机。
运行命令:
node --import tsx E:/coding/rex-hugo/.tmp-research/dsh-b2-adapter/openai-sse-loopback-demo.ts
实际输出:
=== success run: registry-routed chunks ===
block-start #0 text
text-delta #0 "你好"
text-delta #0 ",本地 SSE。"
block-end #0 text
usage {"inputTokens":4,"outputTokens":5}
finish {"kind":"stop"}
=== success run: observed request (dummy credential redacted) ===
{"method":"POST","path":"/v1/chat/completions","authorization":"Bearer [REDACTED]","accept":"text/event-stream","body":{"model":"demo-model","messages":[{"role":"system","content":"Reply with a local demo greeting."}],"stream":true,"stream_options":{"include_usage":true}}}
=== success run: asserted transport / fixture counts ===
{"requestCount":1,"sseFixtureCount":1}
=== success run: assembled result ===
{"blocks":[{"type":"text","text":"你好,本地 SSE。"}],"usage":{"inputTokens":4,"outputTokens":5},"finish":{"kind":"stop"}}
=== separate negative run: unsupported stop ===
finish {"kind":"error","failure":{"message":"b2 loopback demo only implements empty history, optional system text, and text streaming","code":"UNSUPPORTED_OPTION"}}
transport requests=0; SSE fixtures=0; blocks=0
该脚本还会用 node:assert/strict 分别断言:
success case:fresh Context 的 provider directory 含
local-openai-loopback;本机 server 恰收到一次POST /v1/chat/completions;响应为200/text/event-stream;Authorization 仅在内存中与固定 dummy token 比较,输出时始终替换为[REDACTED];request body 与下列对象完全相等:{ model: 'demo-model', messages: [{ role: 'system', content: 'Reply with a local demo greeting.' }], stream: true, stream_options: { include_usage: true }, }因为使用的是
deepEqual,这个断言也排除了额外的tools、stop或reasoningwire field。脚本还精确断言 chunk type 顺序、最终 text、usage 和finish: { kind: 'stop' }。negative case:另起 fresh Context 和 fresh loopback server,输入唯一变化是
stop: ['END']。adapter 在fetch()前抛出UNSUPPORTED_OPTION;runtime 暴露唯一的 error finish。断言 server 的 request count、SSE fixture count 与 assembler block count 都为0。因此该负例不复用成功 case 的 transport、fixture 或 assembler 状态。
这比“请求返回 200”多验证了一层,也仍然只是一个固定 endpoint 与固定 payload / fixture 的 loopback 观察。local-demo-token 是没有外部价值的 dummy token;它证明该 adapter 的 header mapping 在这个 fixture 下被检查,不证明任何真实 credential 可用。
为什么 demo 不复刻官方生产 SSE parser
作者 loopback 的 parser 只足以处理其受控 event 格式。生产 adapter 不应把一个简单 split('\n\n') parser 当作对任意 SSE 的兼容保证。
官方 DeepSeek adapter 专门把 framing 委托给 eventsource-parser,并在 EOF 没有 [DONE] 时抛出稳定错误(packages/llm/llm-deepseek/src/sse.ts:1-40):
/**
* Decode an SSE byte stream into event `data` payloads. Framing — chunk
* reassembly, UTF-8/CRLF/BOM handling, comment and non-data field skipping,
* multi-`data:` joining — is `eventsource-parser`'s. Comments are reported
* only through an optional transport-activity callback. This module keeps the
* DeepSeek protocol: the literal `[DONE]` is yielded so the caller owns final
* flushing, and EOF before it raises {@link LlmError}. Framing is spec-strict:
* an event dispatches only on its blank-line terminator, so an unterminated
* tail at EOF is truncation, not a flushable payload.
*
* @module dsh-llm-deepseek/sse
*/
import { EventSourceParserStream } from 'eventsource-parser/stream'
import { LlmError } from '@deepseek-ai/dsh-llm'
/** The terminal payload DeepSeek (and OpenAI) send after the last chunk. */
export const DONE = '[DONE]'
/**
* Parse an SSE byte stream into data payloads. Yields `[DONE]` as the final
* value and returns; throws `LlmError('STREAM_CLOSED')` when the stream ends
* without it (truncated response — the model call cannot be trusted).
* @param stream - raw SSE bytes; reads may split anywhere, including mid-UTF-8 sequence.
* @param onComment - optional transport-activity callback; comments never enter the yielded payload stream.
* @returns each event's data payload in arrival order, the `[DONE]` sentinel last.
*/
export async function* parseSse(
stream: ReadableStream<BufferSource>,
onComment?: (comment: string) => void,
): AsyncGenerator<string> {
const events = stream
.pipeThrough(new TextDecoderStream())
.pipeThrough(new EventSourceParserStream({ onComment }))
for await (const { data } of events) {
yield data
if (data === DONE) return
}
throw new LlmError('SSE stream ended without [DONE]', 'STREAM_CLOSED')
}
英文注释明确覆盖了 chunk reassembly、UTF-8、CRLF、BOM、多 data: 行、comment 和 EOF 截断。真正对接某个网关时,应以该 provider 的 SSE 事实加测试,而不是复制 loopback 的简化 parser。
五、runtime 会规范化 adapter 失败,但并非吞掉所有错误
LlmRuntime 在 adapter selection、dispatch 和 iterator iteration 边界捕获 adapter failure,并把它转为 terminal finish;但 middleware、nested call、cleanup、consumer failure 仍是调用侧错误(packages/llm/llm/src/index.ts:838-939):
/**
* Final adapter boundary. Adapter selection, dispatch, iterator construction,
* and iteration failures become one terminal failure chunk. Middleware and
* downstream consumer failures remain thrown plugin or consumer errors.
*/
private async * adapterStream(
options: GenerateOptions,
prepared?: { registration: AdapterRegistration; config: LlmCallConfig },
): AsyncGenerator<StreamChunk> {
let iterator: AsyncIterator<StreamChunk>
try {
const registration = prepared?.registration ?? this.registration(options.provider)
const resolvedConfig = prepared === undefined
? (await this.resolveCallFor(registration, options, options.signal)).config
: prepared.config
if (prepared !== undefined && !callConfigEquals(options, resolvedConfig)) {
throw new LlmError(
'prepared LLM call config changed before adapter dispatch',
'INVALID_PREPARED_CALL',
)
}
const resolvedOptions = callConfigEquals(options, resolvedConfig)
? options
: Object.isFrozen(options)
? deepFreeze({ ...options, ...resolvedConfig })
: { ...options, ...resolvedConfig }
const adapter = registration.adapter
const stream = adapter.stream(this.forAdapter(resolvedOptions, adapter))
iterator = stream[Symbol.asyncIterator]()
} catch (error: unknown) {
yield adapterFailureChunk(error, options.signal)
return
}
let completed = false
try {
while (true) {
let item: { done: true } | { done: false; value: StreamChunk }
try {
const next = await iterator.next()
item = next.done
? { done: true }
: { done: false, value: next.value }
} catch (error: unknown) {
completed = true
yield adapterFailureChunk(error, options.signal)
return
}
if (item.done) {
completed = true
return
}
// End the adapter-owned try before yielding: consumer/middleware
// failures resumed into this generator must remain thrown.
yield item.value
}
} finally {
if (!completed) {
const close = iterator.return?.bind(iterator)
if (close) await close()
}
}
}
/**
* Stream one model call as raw chunks (token-level deltas). Replay state is
* retained only when the same adapter instance owns its historical provider
* and the target provider. Final adapter selection remains fixed through
* asynchronous exact-model resolution and dispatch. Adapter selection,
* dispatch, and iteration failures become terminal `error` or `aborted`
* finish chunks; middleware, nested-call, cleanup, and consumer failures
* remain thrown.
* @param options - the full request; `options.provider` selects the adapter.
* @returns the chunk stream, possibly wrapped by `llm/stream` listeners.
*/
stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
return this.streamWithRegistration(options)
}
private streamWithRegistration(
options: GenerateOptions,
prepared?: { registration: AdapterRegistration; config: LlmCallConfig },
): AsyncIterable<StreamChunk> {
return this.ctx.waterfall(
this,
'llm/stream',
options,
() => this.adapterStream(options, prepared),
)
}
}
/** Convert one adapter throw into the stream protocol's terminal outcome. */
function adapterFailureChunk(error: unknown, signal?: AbortSignal): StreamChunk {
const failure = normalizeLlmFailure(error)
return {
type: 'finish',
reason: signal?.aborted || failure.code === 'ABORTED'
? { kind: 'aborted', failure }
: { kind: 'error', failure },
}
}
这解释了 loopback 中 UNSUPPORTED_OPTION 的可见形式:adapter 内部抛出 LlmError,而 consumer 从 ctx.llm.stream() 读到 terminal error finish。不要因此写成“所有系统错误永不 throw”;源码注释明确排除了 middleware 和 consumer 等边界。
retry 是独立 policy,不在 adapter demo 内
本文的两段 ctx.llm.stream() demo 都只验证一次 runtime dispatch:上面展示的 streamWithRegistration() 直接把一次 adapter stream 交给 llm/stream waterfall,没有在 adapter 内部循环 retry。官方 llm-retry 则监听 agent-loop 的 agent/request-error,而不是给任意 direct stream consumer 自动重放请求(packages/llm/llm-retry/src/index.ts:210-219):
const disposeListener = ctx.on('agent/request-error', (
payload,
next: () => Promise<RequestErrorAction>,
) => {
// A waterfall may have captured this callback before its registration was
// removed. Lifetime cancellation must prevent that stale callback from
// entering a downstream policy after disposal.
if (lifetime.signal.aborted) return Promise.resolve<RequestErrorAction>(undefined)
return track(recover(payload, next))
})
英文注释说明:listener 即使已经被 waterfall 捕获,plugin disposal 也要用 lifetime cancellation 阻止它在卸载后继续进入 downstream recovery。对 adapter 作者而言,结论是:一次 stream() 就是一轮 provider attempt;不要在 adapter 内擅自实现无限 fetch retry。 真正的 retry policy、延迟、可取消等待和 agent session event 属于 llm-retry / agent-loop 边界。特别是 always policy 可以反复面对永久错误,不能把它当作安全的初学者默认值。
六、真实 endpoint 的 credential、headers、signal 与能力检查
官方 DeepSeek adapter 是直接 HTTP / SSE 实现;它不是建议复制品牌字段,而是可阅读的责任清单。
请求开始时,它把 caller signal 与自身 consumer signal 合并;在失败时区分 timeout、caller abort、已规范化 LlmError 和 transport failure(packages/llm/llm-deepseek/src/adapter.ts:214-268)。发送请求时,它生成 wire body、合并 attribution headers、设置 Authorization / accept、转发 signal,并对非 2xx 映射稳定错误(packages/llm/llm-deepseek/src/adapter.ts:279-345)。
这给自定义 adapter 的实现顺序:
- 在每次 stream 调用中取得当前 endpoint、credential 和 adapter config snapshot;不要把一部分旧 endpoint 与一部分新 key 拼在一起。
- 将
options.signal传给 fetch 或 provider SDK;如果使用自己的 watcher / iterator,也必须在 consumer 停止时清理它。 - 构造明确的 request mapping,避免通过
as any静默把 unknown option 送给 provider。 - 包含
attributionHeaders();它们是 harness contract 的一部分,实际应在 wire request 或 SDK header hook 上测试。 - 对 HTTP、解析、协议和不支持能力抛出带稳定 code 的
LlmError。 - 用 provider 的真实能力说明决定 tool / image / reasoning / stop 的支持;不支持时拒绝,而不是偷偷删除字段。
凭据不是文章示例里的字符串常量
作者 loopback 中的 local-demo-token 只存在于当前 Node 进程,并在输出前被替换为 [REDACTED]。它不是可用 secret。
真实 DeepSeek plugin 的 Config 采用的是 credential reference,而不是把实际 key 写死在 YAML(packages/llm/llm-deepseek/src/index.ts:54-101):
/**
* Plugin config, validated by the same-named schemastery schema and doubling
* as the `llm-deepseek` settings-section shape. Every field is optional in
* yml: a missing API key resolves through {@link Config.apiKeyEnv} at each
* request (a request without any key fails with `MISSING_CREDENTIAL`, not at
* plugin load), omitted thinking mode uses the provider default, and omitted
* reasoning effort resolves to `high`.
*/
export interface Config {
/** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
apiKeyEnv?: string
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */
baseURL?: string
/** Deployment thinking policy; `disabled` limits every conversation request to `off`. */
thinking?: 'enabled' | 'disabled'
/** Default thinking effort (default `high`); `off` disables thinking per request. */
reasoningEffort?: 'off' | 'high' | 'max'
/** Default per-request output cap (default 256,000); a model's own cap and explicit request values win. */
maxTokens?: number
/** Positive context capacity used when the selected model has no exact value (default 1,000,000). */
defaultContextWindow?: number
/** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */
models?: DeepSeekCatalogModel[]
/** Maximum provider idle time while one stream read is outstanding (default five minutes). */
streamIdleTimeoutMs?: number
/** Provider-owned model-request retry policy; omission uses normal defaults. */
retryPolicy?: RetryPolicyConfig
}
const catalogModel: z<DeepSeekCatalogModel> = z.object({
id: z.string().required(),
name: z.string(),
description: z.string(),
contextWindow: z.number().step(1).min(1),
maxTokens: z.number().step(1).min(1),
})
export const Config: z<Config> = z.object({
apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
baseURL: z.string(),
thinking: z.union(['enabled', 'disabled']),
reasoningEffort: z.union(['off', 'high', 'max']),
maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_TOKENS),
defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW),
models: z.array(catalogModel).default(DEFAULT_MODELS),
streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
retryPolicy: RetryPolicySchema,
})
这不要求每个 adapter 照抄 DeepSeek 的所有字段;它说明的是分层原则:deployment config 保存引用、endpoint 与能力配置,credential seam / trusted environment 在请求时解析实际 secret。不要自行扫描一个秘密文件,也不要把真实 key 写进 article、fixture、git history 或浏览器截图。
七、适配器测试清单:按可证明的粒度切开
| 测试层 | 最小观察 | 不该因此宣称 |
|---|---|---|
| registry routing | ctx.llm.stream() 进入指定 provider adapter | 真实 endpoint、认证或模型回答有效 |
| request serializer | 方法、path、headers、body 字段精确断言 | 所有兼容网关都接受同一请求 |
| SSE translator | block indexes、deltas、block-end、usage、finish 顺序 | 任意分块、BOM、CRLF、comment、EOF 情况都覆盖 |
| unsupported input | 稳定 LlmError / terminal finish error | 上层一定自动重试或修正参数 |
| cancellation | mock fetch / SDK 确实收到 abort signal | 任意远端服务已停止计算 |
| real provider smoke | 明确 provider / model / credential 的受控环境结果 | 对其他 model、地域、账户等级或未来 API 永远有效 |
建议先跑 loopback,再决定是否值得引入真实 endpoint smoke。这样可以在不暴露 credential、不消耗模型额度的情况下,先消除路由、schema、序列化、framing 与 finish 顺序的本地错误。
八、不要从“OpenAI compatible”推出这些结论
以下表述都过强:
| 不准确的说法 | 更准确的表述 |
|---|---|
| “改 base URL 就接好了新模型。” | endpoint 只是 adapter 的 connection fact;请求 / 响应映射、credential、SSE、错误和能力仍要实现。 |
| “provider 等于 model。” | provider 选择 adapter route;model 交由该 adapter 解析、列举或接受。 |
| “listModels 没列出就不能调用。” | catalog 是 advisory;是否接受未列 model 由 adapter 决定。 |
| “adapter 抛错后应用不会有异常。” | adapter boundary 会给 consumer terminal finish;middleware、cleanup、consumer 等错误仍可 throw。 |
| “传了 signal 就保证远端停止。” | adapter 必须转发 signal;远端、SDK 和协议如何响应仍需要分别验证。 |
| “token usage 就是账单。” | usage 是 adapter / provider 的流字段,不自动等同于账单或 tokenizer 真值。 |
| “本机 loopback 证明所有 OpenAI 兼容服务。” | 它只证明一个固定、text-only、受控 SSE slice。 |
九、下一步
B2 把模型接入问题收敛为一个可测试的 seam:
provider route
-> LlmRuntime selects registered adapter
-> adapter owns credential + endpoint + request mapping
-> provider / SDK stream
-> adapter emits StreamChunk
-> runtime exposes stream and normalizes adapter failure boundary
如果下一次要接真实第三方 endpoint,先写下:它支持哪些 GenerateOptions 字段、哪些 content block、哪些 finish reason、如何表达 usage、如何取消、哪些 HTTP / SSE 错误有稳定语义。答案不完整时,adapter 应缩小支持面或显式 UNSUPPORTED,而不是用“兼容 OpenAI”替代实现。
下一篇进入 B3:让 hook 与 permission 插件在工具和模型请求进入执行边界前做出可审计的 allow / deny / ask 决策。
如果你正在接一个“兼容 OpenAI”的内部网关:它的兼容承诺到底覆盖 chat request、SSE、tool calls、usage、reasoning、error body、model catalog 中的哪些部分?把这张能力清单写出来,通常比先改一个 base URL 更快发现真正的 adapter 工作量。
