这是《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>
}

英文注释中最重要的四点:

  1. adapter 是 provider wire 层翻译器,不是“model 名称映射表”。
  2. 唯一必选方法是 stream()providerInfolistModelsresolveModel、retry policy 都是可选扩展点。
  3. listModels() 的目录是 advisory(建议性目录)。目录未列出某个 model,不应被上层自动提升成“请求必定拒绝”。
  4. 异步 adapter 工作必须响应 options.signal;这说明取消是需要实际转发的协作式契约,不是 runtime 能硬杀任何 SDK 或 fetch 的承诺。

因此在一次调用中:

字段负责选择什么不代表什么
provider已注册 adapter 的路由不等于 endpoint URL、品牌名或万能兼容层
model交给已选 adapter 的精确 model id不等于自动得到该 model 的 capabilities
messages / system / toolsHarness 的完整请求词汇不等于每个 provider 都能原样接收
signal调用方取消信号不等于 provider 或 SDK 已真正停止

LLM adapter 的路由、翻译和流式返回边界

图:依据 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 实现最容易出错的四条:

  1. 每个 block-start 需要对应的 block-end,并且同一内容块复用 index。
  2. usage 必须在 terminal finish 之前发出;finish 后不应再发 chunk。
  3. tool-call arguments 是原始 JSON 字符串;provider 如果给对象,需要在 block-end 前明确 stringify。
  4. 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 adapterlocal-mock-adapter.ts + adapter-route-demo.tsfixed-route plugin shape、provider route、runtime stream、unsupported option 不静默丢弃
loopback OpenAI-style SSElocal-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 200content-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\nchoices[0].delta.contentchoices[0].finish_reason、独立 usage event 和 [DONE] 都是该固定 fixture的一部分。它们不等于对任意 gateway 的 SSE 完整兼容声明。

作者本机 loopback 中 GenerateOptions、HTTP 请求、SSE、StreamChunk 与 BlockAssembler 的限定关系

图:本机 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,这个断言也排除了额外的 toolsstopreasoning wire 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 的实现顺序:

  1. 每次 stream 调用中取得当前 endpoint、credential 和 adapter config snapshot;不要把一部分旧 endpoint 与一部分新 key 拼在一起。
  2. options.signal 传给 fetch 或 provider SDK;如果使用自己的 watcher / iterator,也必须在 consumer 停止时清理它。
  3. 构造明确的 request mapping,避免通过 as any 静默把 unknown option 送给 provider。
  4. 包含 attributionHeaders();它们是 harness contract 的一部分,实际应在 wire request 或 SDK header hook 上测试。
  5. 对 HTTP、解析、协议和不支持能力抛出带稳定 code 的 LlmError
  6. 用 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 routingctx.llm.stream() 进入指定 provider adapter真实 endpoint、认证或模型回答有效
request serializer方法、path、headers、body 字段精确断言所有兼容网关都接受同一请求
SSE translatorblock indexes、deltas、block-end、usage、finish 顺序任意分块、BOM、CRLF、comment、EOF 情况都覆盖
unsupported input稳定 LlmError / terminal finish error上层一定自动重试或修正参数
cancellationmock 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 决策。

下一篇:Hook 与权限插件:把策略接进可观察的执行边界


如果你正在接一个“兼容 OpenAI”的内部网关:它的兼容承诺到底覆盖 chat request、SSE、tool calls、usage、reasoning、error body、model catalog 中的哪些部分?把这张能力清单写出来,通常比先改一个 base URL 更快发现真正的 adapter 工作量。