第 16 章 模型与 Provider 抽象层

第 16 章 模型与 Provider 抽象层

本章是"第 4 部分 · 模型与插件"的第一章。在前面章节里,我们已经把 Gateway 控制平面(第 02--05 章)、SQLite 持久化(第 06--09 章)、Agent loop 与 Harness 选择(第 10--12 章)拆过一遍。现在要回答一个此前一直"悬空"的问题:当 agent loop 决定"该调用模型了"之后,一次补全请求究竟是怎么从 src/ 走到某个具体 provider(OpenAI / Anthropic / Google / ...)的网络 SDK,又是怎么把各家千奇百怪的流式响应重新收敛成统一的 assistant/tool delta 的?

这一层由 src/llm/、packages/ai/、packages/llm-core/ 三个位置共同承载,并由 extensions/* 下的 provider 插件向外扩展。本章以源码为准,逐文件拆解:stream.ts(stream facade)、model-runtime-binding.ts、ai-transport-host.ts、model-registry.ts、api-registry.ts、register-builtins.ts、host.ts、provider-runtime.ts,以及一个真实的 provider 插件 extensions/openai。

交叉引用:与第 10 章(Agent loop 事件流)在第 16.7 节的"流归一化"处相遇;与第 11 章(Harness 注册与运行时选择)在 16.8 节的 route 判定处相遇;与第 12 章(Prepared Model Runtime Generation)在 16.5 与 16.9 节交叉;与第 17 章(插件体系与 Plugin SDK)在 16.10 与 16.11 节交叉。


16.1 为什么需要"模型与 Provider 抽象层"

在进入源码之前,先把这一层要解决的"熵"说清楚。OpenClaw 支持几十个模型 provider(extensions/ 里 provider 类插件数量最多,见第 01 章 1.5.3),它们之间的差异至少有五个维度:

  1. wire 协议不同:OpenAI 有 Responses 与 Chat Completions 两条 API,Anthropic 是 Messages API,Google 是 Generative AI / Vertex,Bedrock 是 Converse...
  2. 流式事件形状不同:SSE 分片、字段名、增量语义、结束事件各不相同;有的 provider 甚至先缓冲全部 delta,直到终止分类才吐事件。
  3. 能力开关不同 :是否支持 reasoning_effort、stop、response_format、prompt cache、server-side compaction...
  4. 认证方式不同:API key、OAuth、device code、SIWC、AWS/GCP 标记...
  5. 传输加固不同:代理、TLS、本地服务、出口哨兵、请求超时、审计归属。

如果让 agent loop 直接认识这五个维度,那么每加一个 provider 都要改 core ------ 这正是根 AGENTS.md 反复警告的"逐调用代价(per-call tax)"和"竞争的所有者(competing owners)"。因此 OpenClaw 的选择是把这一层分成三个所有者,各管一段:

所有者 位置 它拥有什么 它不拥有什么
LLM type/stream 契约 packages/llm-core/ 归一化类型(Model/Context/AssistantMessage/AssistantMessageEvent)、EventStream、校验 具体 provider 实现、网络
传输运行时(transport runtime) packages/ai/ API provider registry、stream runtime、host policy ports、各家 transport 实现 OpenClaw 配置、插件 loader、密钥库
OpenClaw 集成 facade src/llm/ 把上面两者接进 OpenClaw:安装 host 端口、绑定 prepared runtime、暴露进程默认 stream API 具体 wire 协议细节
provider 插件 extensions/* 某厂商的 catalog、auth、stream hook、route 归一化 通用调度、其他 provider 的行为

一句话:packages/* 是"可复用的库",src/llm/ 是"接入 OpenClaw 的 facade",extensions/* 是"厂商私有实现"。这与第 01 章 1.4.1 的"一处责任一个所有者"和 1.5.2 的"facade 负责接线"完全一致。


16.2 分层地图:三层四个目录

先把磁盘结构摆出来(本章实测):

bash 复制代码
packages/llm-core/src
├── types.ts                # 全部归一化类型 + StreamFunction 契约 + AssistantMessageEvent 协议
├── index.ts                # 公开 barrel:model-contracts / types / usage-cost / diagnostics / event-stream / validation
├── utils/event-stream.ts   # EventStream / AssistantMessageEventStream / bindAssistantMessageEventStream
├── model-data.ts           # 模型数据的纯类型(cost / thinking level / routing)
└── model-contracts/anthropic.ts

packages/ai/src
├── stream.ts               # createRuntime / createLlmRuntime / createNodeLlmRuntime / LlmRuntime
├── api-registry.ts         # ApiProvider / ApiRegistry / createApiRegistry
├── host.ts                 # AiTransportHost 端口契约 + inert 默认 + AsyncLocalStorage 作用域
├── providers/              # 内置 API provider(register-builtins.ts + 各 wire 适配器)
│   └── register-builtins.ts
└── transports/             # 各家 transport 实现(openai-responses-* / openai-completions-* / anthropic-* ...)

src/llm
├── stream.ts               # stream facade(本章主角)
├── model-runtime-binding.ts# prepared runtime 的挂载点
├── ai-transport-host.ts    # 把 OpenClaw 的 host 实现装进 packages/ai
├── model-registry.ts       # ModelRegistry 抽象(供 model picker / 可用性检查)
├── types.ts                # 仅 re-export llm-core 类型
├── utils/oauth/            # OAuth(openai-chatgpt / anthropic)
└── providers/stream-wrappers/  # OpenAI 等 provider 的 StreamFn 装饰器

注意 src/llm/types.ts 的全文只有两行:

ts 复制代码
// Core facade for shared LLM type contracts.
export * from "../../packages/llm-core/src/types.js";

这是一个刻意的类型再导出 seam :src/llm 的调用方(agent loop、simple-completion、媒体理解等)import ./llm/types.js,而真正的定义住在 packages/llm-core。这样定义只有一处(packages/llm-core/src/types.ts),src/llm 不制造第二套类型。


16.3 三条"注册---解析"链路

理解这一层的关键,是分清三条彼此不同的注册/解析链路。它们分别解决"wire 协议怎么分派"、"厂商插件怎么被找到"、"模型目录怎么被组装",且各有各的 owner。

16.3.1 链路 A:API provider registry(按 wire 协议分派)

packages/ai/src/api-registry.ts 定义了一个极小的注册表。核心类型:

ts 复制代码
export interface ApiProvider<TApi extends Api = Api, TOptions extends StreamOptions = StreamOptions> {
  api: TApi;                                   // 该 provider 处理的 model API id
  stream: StreamFunction<TApi, TOptions>;      // 完整流式适配器
  streamSimple: StreamFunction<TApi, SimpleStreamOptions>; // agent/plugin 默认走这个
}

createApiRegistry() 返回一个隔离的注册表(Map<string, RegisteredApiProviderEntry>),并带 sourceId 以便按来源整批注销:

ts 复制代码
function registerApiProvider<TApi extends Api, TOptions extends StreamOptions>(
  provider: ApiProvider<TApi, TOptions>,
  sourceId?: string,
): void {
  providers.set(provider.api, {
    provider: {
      api: provider.api,
      stream: wrapStream(provider.api, provider.stream),
      streamSimple: wrapStream(provider.api, provider.streamSimple),
    },
    sourceId,
  });
}

wrapStream 是一个不变量守卫 ------它在运行期校验 model.api 与注册的 api 一致,不一致就抛错:

ts 复制代码
function wrapStream<TApi extends Api, TOptions extends StreamOptions>(
  api: TApi,
  stream: StreamFunction<TApi, TOptions>,
): ApiStreamFunction {
  return (model, context, options) => {
    if (model.api !== api) {
      throw new Error(`Mismatched api: ${model.api} expected ${api}`);
    }
    return stream(model as Model<TApi>, context, options as TOptions);
  };
}

这是链路 A 的核心纪律 :分派的键是 model.api(wire 协议家族,KnownApi 见 16.8),而不是 model.provider。多个 provider(例如所有 OpenAI-compatible 端点)可以共享同一个 api provider。

16.3.2 内置 API provider 的懒注册

packages/ai/src/providers/register-builtins.ts 把 9 个内置 wire 适配器懒注册进 registry:

ts 复制代码
export const BUILT_IN_API_PROVIDER_SOURCE_ID = "core:built-in";

const registerBuiltIns: RegisterBuiltIn[] = [
  createLazyRegistration("anthropic-messages", () => import("./anthropic.js"), (m) => ({ stream: m.streamAnthropic, streamSimple: m.streamSimpleAnthropic })),
  createLazyRegistration("openai-completions", () => import("./openai-completions.js"), ...),
  createLazyRegistration("mistral-conversations", () => import("./mistral.js"), ...),
  createLazyRegistration("openai-responses", () => import("./openai-responses.js"), ...),
  createLazyRegistration("azure-openai-responses", () => import("./azure-openai-responses.js"), ...),
  createLazyRegistration("openai-chatgpt-responses", () => import("./openai-chatgpt-responses.js"), ...),
  createLazyRegistration("google-generative-ai", () => import("./google.js"), ...),
  createLazyRegistration("google-interactions", () => import("./google-interactions.js"), ...),
  createLazyRegistration("google-vertex", () => import("./google-vertex.js"), ...),
];

这里有一个非常重要的设计注释:

sql 复制代码
// Registration is transport-free; each lazy adapter owns its fetch or construction unwrap.

意思是:注册动作本身不构造任何网络客户端 。createLazyRegistration 只在第一次真正发起请求时 import() 对应模块,createLazyStream 用一个 AssistantMessageEventStream 作为"即时外壳",把异步加载结果 forward 进去:

ts 复制代码
function createLazyStream<TApi extends Api, TOptions extends StreamOptions, TStreams>(
  load: () => Promise<TStreams>,
  select: (streams: TStreams) => StreamFunction<TApi, TOptions>,
): StreamFunction<TApi, TOptions> {
  return (model, context, options) => {
    const outer = new AssistantMessageEventStream();
    load()
      .then((streams) => forwardStream(outer, select(streams)(model, context, options)))
      .catch((error: unknown) => {
        const message = createLazyLoadErrorMessage(model, error, options?.signal);
        outer.push({ type: "error", reason: message.stopReason, error: message });
        outer.end(message);
      });
    return outer;
  };
}

why(为什么懒 + 即时外壳) :这既满足了"调用方同步拿到一个 stream"的契约(见 StreamFunction 的注释),又不把 9 个 provider 的网络 SDK 拉进冷启动路径。加载失败也被编码进流(error 事件),而不是抛出------这对应 packages/llm-core/src/types.ts 里 StreamFunction 的契约注释:

ts 复制代码
// Contract:
// - Must return an AssistantMessageEventStream.
// - Once invoked, request/model/runtime failures should be encoded in the
//   returned stream, not thrown.
// - Error termination must produce an AssistantMessage with stopReason
//   "error" or "aborted" and errorMessage, emitted via the stream protocol.

16.3.3 链路 B:ProviderPlugin registry(厂商插件)

链路 A 管的是"wire 协议",链路 B 管的是"厂商 "。src/plugins/provider-plugin.types.ts 定义了 ProviderPlugin,这是一个以 hook 为主体的契约对象(此处摘录与本章相关的字段):

ts 复制代码
export type ProviderPlugin = {
  id: string;
  label: string;
  docsPath?: string;
  hookAliases?: string[];          // 内部 hook 路由别名(不改用户可见 provider id)
  envVars?: string[];
  auth: ProviderAuthMethod[];      // 认证方法(API key / OAuth / device code...)
  catalog?: ProviderPluginCatalog; // @deprecated:旧文本 catalog hook(会做网络 I/O)
  staticCatalog?: ProviderPluginCatalog; // @deprecated:离线/静态行(不做网络 I/O)
  resolveDynamicModel?: (ctx) => ProviderRuntimeModel | null | undefined;
  prepareDynamicModel?: (ctx) => Promise<ProviderRuntimeModel | void>;
  preferRuntimeResolvedModel?: (ctx) => boolean;
  normalizeResolvedModel?: (ctx) => ProviderRuntimeModel | null | undefined;
  normalizeModelId?: (ctx) => string | null | undefined;
  normalizeTransport?: (ctx) => { api?: string | null; baseUrl?: string } | null | undefined;
  normalizeConfig?: (ctx) => ModelProviderConfig | null | undefined;
  buildReplayPolicy?: (ctx) => ProviderReplayPolicy | null | undefined;
  // ...以及 createStreamFn / resolveSyntheticAuth / augmentModelCatalog 等
};

这些 hook 不直接暴露给 agent loop 。真正对外的解析入口集中在 src/plugins/provider-runtime.ts,它用一套泛化的 runtimeHook 工厂把"找到 provider 插件 → 调用对应 hook"收敛成可复用的函数:

ts 复制代码
type RuntimeHookName = {
  [K in keyof ProviderPlugin]-?: NonNullable<ProviderPlugin[K]> extends (context: never) => unknown
    ? K
    : never;
}[keyof ProviderPlugin];

function runtimeHook<K extends RuntimeHookName>(name: K) {
  return (params: ProviderRuntimeLookup & { context: Parameters<Hook>[0] }): ReturnType<Hook> | undefined => {
    const plugin = resolveProviderRuntimePlugin(params);
    const hook = plugin?.[name] as ((context) => ReturnType<Hook>) | undefined;
    return hook?.call(plugin, params.context);
  };
}

其中与本章直接相关的是 resolveProviderStreamFn ------ 它是"厂商插件提供的 StreamFn"如何进入流式运行时的入口:

ts 复制代码
export function resolveProviderStreamFn(params: ...): StreamFn | undefined {
  const plugin = ...; // 依 runtimeHandle / allowRuntimePluginLoad 解析
  const streamFn = plugin?.createStreamFn?.(params.context);
  if (!streamFn || plugin?.supportsSystemPromptCacheBoundary) {
    return streamFn ?? undefined;
  }
  // 默认剥离 system prompt 缓存边界标记,除非插件声明自己处理边界
  return (model, context, options) =>
    streamFn(model, context.systemPrompt
      ? { ...context, systemPrompt: stripSystemPromptCacheBoundary(context.systemPrompt) }
      : context, options);
}

why(为什么厂商插件用 hook 而不是直接注册 StreamFn 到链路 A) :因为同一厂商往往对应多个 wire 家族(OpenAI 同时有 openai-responses/openai-completions/openai-chatgpt-responses/azure-openai-responses),而 core 需要在这些家族之外 做 route 归一化、auth 合成、系统提示贡献、用量抓取等横切工作。把这些横切工作交给 provider-runtime.ts 统一编排,插件只提供"当需要时给出一个 StreamFn"。

16.3.4 链路 C:ModelRegistry 与 model catalog(目录组装)

src/llm/model-registry.ts 全文很短,但它是第 01 章 1.4.1 提到的"投影(projection)"的接口定义:

ts 复制代码
import type { Model } from "./types.js";

/** Registry abstraction used by model pickers and provider availability checks. */
export type ModelRegistry = {
  getAll(): Model[];
  getAvailable(): Model[];
  find(provider: string, modelId: string): Model | undefined;
  hasConfiguredAuth(model: Model): boolean;
};

注意它的注释:它是给 "model picker 与 provider 可用性检查" 用的抽象 ------即"读侧"。真正组装目录的拥有者是 src/model-catalog/,其公开 facade src/model-catalog/index.ts 只暴露一个规划函数:

ts 复制代码
export function planEffectiveModelCatalogRows(params: {
  registry: Parameters<typeof planManifestModelCatalogRows>[0]["registry"];
  config: OpenClawConfig;
  providerFilter?: string;
  ...
}) {
  return planManifestModelCatalogRows({
    registry: params.registry,
    includeProvider: (provider, plugin) =>
      isProviderCatalogSourceAllowed({ provider, plugin, config: params.config }),
    resolveRemoteProvider: (provider) => getRemoteModelCatalogProviderOverlay(params.config, provider),
    ...
  });
}

关键点:model catalog 是一份"规划(plan)",不是"事实存储" 。它把 manifest 静态行、远程 overlay、provider 插件 hook 的增补,按 config 与 allow 规则合并成"有效行"。这与第 01 章引用的公理"缓存/投影从 owner 派生并带显式失效生命周期"一致:目录是从 prepared model runtime generation(第 12 章)派生出来的投影。

16.3.5 三条链路的注册/解析总图

flowchart TD subgraph Plugins[&#34;extensions/* provider 插件&#34;] P1[&#34;definePluginEntry.register(api)&#34;] P2[&#34;api.registerProvider(ProviderPlugin)&#34;] P3[&#34;openclaw.plugin.json<br/>providers / modelCatalog / providerEndpoints&#34;] end subgraph Runtime[&#34;src/plugins/provider-runtime.ts&#34;] R1[&#34;resolveProviderRuntimePlugin&#34;] R2[&#34;resolveProviderStreamFn / normalizeTransport / augmentModelCatalog&#34;] end subgraph AI[&#34;packages/ai&#34;] A1[&#34;ApiRegistry(api -> ApiProvider)&#34;] A2[&#34;registerBuiltInApiProviders<br/>lazy wire adapters&#34;] A3[&#34;createLlmRuntime / startStream + host 绑定&#34;] end subgraph Core[&#34;src/llm&#34;] C1[&#34;stream facade(stream.ts)&#34;] C2[&#34;model-runtime-binding(prepared runtime 挂载)&#34;] C3[&#34;ModelRegistry 抽象(读侧)&#34;] end subgraph Catalog[&#34;src/model-catalog&#34;] K1[&#34;planEffectiveModelCatalogRows(规划/投影)&#34;] end P1 --> P2 P2 --> R1 P3 --> K1 R1 --> R2 R2 --> A1 A2 --> A1 A1 --> A3 A3 --> C1 C2 --> C1 K1 --> C3

16.4 stream facade:src/llm/stream.ts 逐段解析

src/llm/stream.ts 是本章的主角。它的头部注释就点明了它的 owner 身份:

ts 复制代码
// Streams LLM responses through registered providers and normalizes events.
// This facade owns the process-default AI runtime wiring: it installs the
// OpenClaw host policy ports and registers built-in providers exactly once,
// before any caller imports the stream API.

16.4.1 进程默认运行时的接线

文件顶部第一件事,就是把内置 API provider 装进进程默认 registry:

ts 复制代码
import { defaultApiRegistry, defaultLlmRuntime } from "@openclaw/ai/internal/runtime";
import { registerBuiltInApiProviders } from "@openclaw/ai/providers";
...
import "./ai-transport-host.js";   // 副作用:安装 OpenClaw 的 host 端口

registerBuiltInApiProviders(defaultApiRegistry);

packages/ai/src/internal/default-runtime.ts 解释了 defaultApiRegistry / defaultLlmRuntime 的来源与"为什么它不是公共 API":

ts 复制代码
// Process-default registry/runtime retained for the OpenClaw compatibility
// facade (src/llm). Deliberately not part of the public package API: external
// consumers create isolated runtimes via createLlmRuntime(); exporting these
// from the root barrel would reintroduce the mutable process-global registry.
const DEFAULT_RUNTIME_KEY = Symbol.for("openclaw.ai.defaultRuntime");

它把默认运行时挂在 globalThis 的一个 Symbol.for(...) 键上:

ts 复制代码
const defaultRuntime = resolveDefaultRuntime();
export const defaultApiRegistry = defaultRuntime.registry;
export const defaultLlmRuntime = defaultRuntime.runtime;

why(为什么用 Symbol.for 挂在 globalThis,而不是模块级变量) :OpenClaw 是 monorepo 且 packages/ai 可能被多个入口/多份打包产物加载。用 Symbol.for("openclaw.ai.defaultRuntime") 做键,可以让"同一个进程内"的默认运行时唯一 ,避免出现两份 registry 各自注册一半 provider。这也解释了眼下的顺序要求:registerBuiltInApiProviders(defaultApiRegistry) 必须在任何调用方使用 stream API 之前执行------stream.ts 用"模块顶层副作用"保证这一点。

16.4.2 两个补全入口:同步 stream 与 async complete 的差异

facade 暴露四个函数,但它们的 host 安装时机不同:

ts 复制代码
export function stream<TApi extends Api>(
  model: Model<TApi>, context: Context, options?: ProviderStreamOptions,
): AssistantMessageEventStreamContract {
  return deferUntilTransportRuntimeHost(model, () => resolveRuntime(model).stream(model, context, options));
}

export async function complete<TApi extends Api>(
  model: Model<TApi>, context: Context, options?: ProviderStreamOptions, assertCurrent?: () => void,
): Promise<AssistantMessage> {
  await ensureTransportRuntimeHost();
  assertCurrent?.();
  options?.signal?.throwIfAborted();
  return await resolveRuntime(model).complete(model, context, options);
}
  • stream / streamSimple 必须同步返回 一个 stream(这是 StreamFunction 契约),因此它们走 deferUntilTransportRuntimeHost,把重端口安装推迟到异步尾随任务里。
  • complete / completeSimple 是 async,可以先 await ensureTransportRuntimeHost(),并在此之后调用 assertCurrent?.() 与 options.signal.throwIfAborted()。

deferUntilTransportRuntimeHost 的实现:

ts 复制代码
function deferUntilTransportRuntimeHost(
  model: Model,
  start: () => AssistantMessageEventStreamContract,
): AssistantMessageEventStreamContract {
  const output = createAssistantMessageEventStream();
  void (async () => {
    try {
      await ensureTransportRuntimeHost();
      for await (const event of start()) {
        output.push(event);
      }
    } catch (error) {
      const message = createRuntimeHostErrorMessage(model, error);
      output.push({ type: "error", reason: "error", error: message });
    } finally {
      output.end();
    }
  })();
  return output;
}

why(为什么同步 stream 也要"先装 host 再跑") :ensureTransportRuntimeHost 会动态 import ../agents/ai-transport-runtime-host.js(见 16.6.2),后者把 OpenClaw 的插件端口(resolveProviderStream 等)装进 host。如果 provider 的 transport 需要这些端口(例如 Google 走 provider-owned stream),却因为 host 未就绪而拿到 inert 默认,就会静默失败。facade 用"先 await 安装、再迭代 provider 流"的写法,保证无论调用方是否 await,端口都已就位。

ensureTransportRuntimeHost 本身做了幂等缓存:

ts 复制代码
let transportRuntimeHostPromise: Promise<void> | undefined;

async function ensureTransportRuntimeHost(): Promise<void> {
  transportRuntimeHostPromise ??= import("../agents/ai-transport-runtime-host.js").then(
    ({ configureAiTransportRuntimeHost }) => configureAiTransportRuntimeHost(),
  );
  await transportRuntimeHostPromise;
}

注释进一步说明动机:

ts 复制代码
// Async completion entry points install heavy provider ports before the runtime
// can invoke them, without adding their plugin graph to this eager facade.

16.4.3 运行时解析:resolveRuntime

真正决定"这次请求归哪个 runtime 管"的是一行:

ts 复制代码
function resolveRuntime(model: Model) {
  return getModelLlmRuntime(model) ?? defaultLlmRuntime;
}

即:优先使用模型上绑定的 prepared runtime,没有则回退到进程默认 runtime 。getModelLlmRuntime 来自 model-runtime-binding.ts(下一节)。这就是第 12 章"prepared model runtime generation"与本层的交点------第 12 章构造的 runtime 通过 binding 挂到 Model 对象上,本 facade 在发起请求时读取它。


16.5 model-runtime-binding:prepared runtime 的挂载点

src/llm/model-runtime-binding.ts 全文都在回答"如何在 Model 对象上私下携带一个 prepared runtime,而不破坏 Model 的序列化形状"。

它用一个非导出的 Symbol 作为键,并用 Object.defineProperty(..., { enumerable: false }) 挂载:

ts 复制代码
const MODEL_LLM_RUNTIME = Symbol("openclaw.modelLlmRuntime");
const streamLlmRuntimes = new WeakMap<object, LlmRuntime>();

type ModelRuntimeBinding = {
  runtime?: LlmRuntime;
  completionTransport?: Model;
  completionOwner?: ModelCompletionOwner;
};
type RuntimeBoundModel = Model & { [MODEL_LLM_RUNTIME]?: ModelRuntimeBinding };

function bindModelRuntime(model: Model, binding: ModelRuntimeBinding): Model {
  const bound: RuntimeBoundModel = { ...model };
  Object.defineProperty(bound, MODEL_LLM_RUNTIME, { value: binding, enumerable: false });
  return bound;
}

bindModelLlmRuntime 同时把当前模型的 completion owner 一并记录(getModelCompletionOwner(model)),注释点明意图:

ts 复制代码
/** Carries the prepared lifecycle runtime without changing the serialized model shape. */
export function bindModelLlmRuntime(model, runtime, completionTransport?): Model {
  return bindModelRuntime(model, { runtime, completionTransport, completionOwner: getModelCompletionOwner(model) });
}

读取侧提供四个 getter:

ts 复制代码
export function getModelCompletionOwner(model): ModelCompletionOwner | undefined { ... }
export function getModelLlmRuntime(model): LlmRuntime | undefined { ... }
export function getModelCompletionTransport(model): Model | undefined { ... }

另有针对 streamFn 对象的绑定(用于 plugin 侧 stream consumer):

ts 复制代码
/** Associates a prepared stream entry point with the runtime that owns it. */
export function bindStreamLlmRuntime(streamFn: object, runtime: LlmRuntime): void {
  streamLlmRuntimes.set(streamFn, runtime);
}
export function getStreamLlmRuntime(streamFn: object | undefined): LlmRuntime | undefined {
  return streamFn ? streamLlmRuntimes.get(streamFn) : undefined;
}

不变量 :Model 是会被写进 transcript / 传给插件的"值对象"。如果 runtime 用可枚举属性挂上去,序列化、深拷贝、日志都会带上一个巨大的 runtime 图(含 registry、闭包)。用不可枚举的 Symbol 键,{ ...model } 展开时不会 复制它,JSON.stringify 也看不到它,但读侧仍能通过键取到------这是"稳定对话上下文"(第 01 章 1.4.3,transcript 字节不变)在这一层的具体落地。

bindStreamLlmRuntime 的消费者见 src/agents/sessions/model-registry-runtime.ts:

ts 复制代码
export function initializeModelRegistryRuntime(owner: object): void {
  const apiRegistry = createApiRegistry();
  const llmRuntime = { ...createLlmRuntime(apiRegistry), runStream: runPluginStreamConsumer };
  const runtime = { apiRegistry, llmRuntime };
  bindStreamLlmRuntime(llmRuntime.streamSimple, llmRuntime);
  resetApiRegistry(runtime);
  modelRegistryRuntimes.set(owner, runtime);
}

即:每个 model-registry 生命周期拥有自己的 apiRegistry + llmRuntime(隔离的),并把 streamSimple 关联到该 runtime。resetApiRegistry 会 clearApiProviders() 后重装内置 provider------保证一个生命周期内注册的 provider 不会泄漏到另一个。


16.6 transport host:为什么抽象出"host 端口"

16.6.1 问题:库要发网络请求,但策略属于宿主

packages/ai 是可复用库,它需要发网络请求、构造 provider payffload、出口密钥。但下面这些东西只应由宿主(OpenClaw)决定,不能写进库:

  • 出口 fetch 的加固(代理、TLS、超时、审计归属)
  • secret sentinel 的替换(出口时才把哨兵还原成真实密钥)
  • 模型可见内容的脱敏(tool result 里不能泄露密钥)
  • OpenAI strict-tool 的默认策略
  • 诊断日志的子系统与开关

于是 packages/ai/src/host.ts 定义了 AiTransportHost 一组端口(ports) ,并把库内默认实现设为惰性无害(inert):

ts 复制代码
// Host policy ports for the reusable transport package. Fetch guarding,
// secret redaction, strict-tool policy, and diagnostics logging are owned by
// the embedding application (OpenClaw core installs its implementations via
// configureAiTransportHost); the library defaults below are inert so external
// consumers get safe, dependency-free behavior without wiring anything.

端口接口(节选关键项):

ts 复制代码
export interface AiTransportHost {
  observePendingProviderWork?: (pending: Promise<unknown>) => void;
  buildModelFetch(model: Model, timeoutMs?: number, options?: { sanitizeSse?: boolean }): typeof fetch | undefined;
  resolveSecretSentinel(value: string): string;
  unwrapModelTransportSentinels?<T extends Model>(model: T, boundary: string): T;
  redactModelVisibleSecrets<T>(value: T): T;
  redactToolPayloadText(text: string): string;
  normalizeAnthropicInlineContentBlocks?: AnthropicInlineContentNormalizer;
  resolveOpenAIStrictToolSetting(model, options?): boolean | undefined;
  plugin: AiTransportPluginHost;                       // 插件操作端口(见 16.6.2)
  buildCopilotDynamicHeaders(messages): Record<string, string>;
  resolveProviderRequestCapabilities(input): AiProviderRequestCapabilities;
  resolveProviderRequestHeaders(input): Record<string, string> | undefined;
  resolveModelRequestTimeoutMs(model): number | undefined;
  requiresManagedTransport(model): boolean;
  inheritManagedTransport(source, target): Model;
  transformTransportMessages: AiTransformTransportMessages;
  registerCustomApi(registry, api, streamFn): boolean;
  logDebug(subsystem, build): void;
  logInfo(subsystem, message, data?): void;
  logWarn(subsystem, message, data?): void;
}

库内默认(inert)实现把一切安全地退化 :buildModelFetch 返回 undefined(保留 SDK 默认 fetch)、resolveSecretSentinel 原样返回、redactModelVisibleSecrets 原样返回......这样即便宿主什么都没装,库也能独立工作且不会"假装加固"。

16.6.2 OpenClaw 侧的两个安装器

OpenClaw 分两步安装 host:

  1. 基础策略端口 ------ src/llm/ai-transport-host.ts(模块顶层副作用执行):
ts 复制代码
configureProviderErrorRedactor(redactSecrets);

configureAiTransportHost({
  observePendingProviderWork: (pending) => { void trackAsyncWork(() => pending).catch(() => {}); },
  buildModelFetch: buildGuardedModelFetch,
  unwrapModelTransportSentinels: unwrapModelHeaderSentinelsForProviderEgress,
  resolveSecretSentinel: (value) => {
    const swapped = swapSecretSentinelsInText(value);
    const unknown = swapped.unknown[0];
    if (unknown) {
      throw new Error(`Secret sentinel ${unknown} is not registered in this process; refusing to construct provider client`);
    }
    return swapped.text;
  },
  redactModelVisibleSecrets,
  redactToolPayloadText,
  normalizeAnthropicInlineContentBlocks,
  resolveOpenAIStrictToolSetting,
  resolveModelRequestTimeoutMs: (model) => resolveModelRequestTimeoutMs(model, undefined),
  logDebug: (subsystem, build) => { /* 仅在 host 允许时构造诊断条目 */ },
  logInfo: ..., logWarn: ...,
});

注意 resolveSecretSentinel 的失败语义:遇到未注册的哨兵坚决抛错,拒绝构造 provider 客户端(fail closed)。这是第 20 章"凭据有所有者 + 出口哨兵"在这层的落点。

  1. 插件端口 ------ src/agents/ai-transport-runtime-host.ts,把 plugin.* 端口接到 src/plugins/provider-runtime.ts:
ts 复制代码
export function configureAiTransportRuntimeHost(): void {
  if (configured) return;
  const host = getDefaultAiTransportHost();
  configureAiTransportHost({
    ...host,
    plugin: {
      ...host.plugin,
      resolveProviderStream: (params) => resolveProviderStreamFn({ ... }),
      resolveTransportTurnState: (params) => resolveProviderTransportTurnStateWithPlugin({ ... }),
      wrapSimpleCompletionStream: (params) => { ... },
      createAnthropicVertexStream: createAnthropicVertexStreamFnForModel,
    },
    buildCopilotDynamicHeaders: ...,
    resolveProviderRequestCapabilities: ...,
    resolveProviderRequestHeaders: ...,
    requiresManagedTransport: (model) => { ... },
    inheritManagedTransport: ...,
    transformTransportMessages,
    registerCustomApi: ensureCustomApiRegistered,
  });
  configured = true;
}
configureAiTransportRuntimeHost();

why(为什么分两步、且第二步按需 import) :基础策略端口成本低、影响广,放在 src/llm/ai-transport-host.ts 里随 facade 一起 eager 安装;而 plugin.* 端口的实现依赖 src/plugins/ 与 src/agents/ 的一大片模块图,所以只在确实要跑 provider runtime 的路径 上按需安装(见 16.4.2 的 ensureTransportRuntimeHost)。这是"小内核、强插件"和"保持懒模块边界"(根 AGENTS.md)在一处具体体现。

16.6.3 AsyncLocalStorage 作用域:让"每次请求的 host"不打架

host.ts 还有一个容易被忽略但很关键的部分:作用域化 host 。它通过 node:async_hooks 的 AsyncLocalStorage 让"某次请求使用哪个 host"能跨越 lazy load 与异步 I/O:

ts 复制代码
function createAiTransportHostScope(): AiTransportHostScope | undefined {
  if (typeof process === "undefined") return undefined;
  const asyncHooks = (process as ProcessWithAsyncHooksBuiltin).getBuiltinModule?.("node:async_hooks");
  return asyncHooks ? new asyncHooks.AsyncLocalStorage<ActiveAiTransportHost>() : undefined;
}
// Avoid a static node:async_hooks import: provider modules also load in browser/Vite builds.
const scopedAiTransportHost = createAiTransportHostScope();

runWithAiTransportHost 在作用域可用时 scopedAiTransportHost.run(host, run),否则退化到单一进程默认并做一致性检查:

ts 复制代码
export function runWithAiTransportHost<T>(host: ActiveAiTransportHost, run: () => T): T {
  if (!scopedAiTransportHost) {
    if (host !== activeAiTransportHost) throw new Error("Scoped AI transport hosts require Node.js async context support");
    if (pendingUnscopedHost && pendingUnscopedHost !== host) throw new Error("AI transport host changed while browser provider work was still pending");
    const result = run();
    trackUnscopedOperation(host, result);
    return result;
  }
  return scopedAiTransportHost.run(host, run);
}

packages/ai/src/stream.ts 的 createRuntime 正是这个能力的使用者:它为每个 stream 绑定 host,并用 bindAssistantMessageEventStream 把 host 传播到 consumer 的每一次 push/result/next:

ts 复制代码
const runWithStreamHost = <T>(operation: () => T): T => runWithAiTransportHost(host, operation);
const started = runWithStreamHost(start);
const completion = getEventStreamCompletion(started);
const bound = bindAssistantMessageEventStream(started, runWithStreamHost);

只有当运行时不支持作用域(非 Node 环境)时,才退到 retainUnscopedStreamLifetime 手动缓冲事件、等生产者与消费者都结束再释放------这段代码较长,但意图是"在没有 AsyncLocalStorage 的环境里,用显式缓冲保证 host 生命周期正确"。

why(为什么要作用域而非全局单例) :一个 Gateway 进程里可能同时服务多个 tenant/agent,各自带不同的代理/TLS/密钥策略。如果用全局 host,交错的两条请求会互相污染。用 AsyncLocalStorage,host 随异步上下文传播,天然隔离。这对应第 01 章"一个 Gateway = 一个信任域 + 每租户一 cell"的思想在库层的实现。


16.7 stream facade 如何把不同 provider 的流归一化为 assistant/tool delta

这是本章技术含量最高的部分。不同 provider 的 SSE 事件五花八门,但 agent loop(第 10 章)只认一套协议。归一化靠三样东西:协议定义 、事件流容器 、各家 transport 实现。

16.7.1 协议:AssistantMessageEvent

packages/llm-core/src/types.ts 定义了唯一的流事件协议(节选):

ts 复制代码
export type AssistantMessageEvent =
  | { type: "start"; partial: AssistantMessage }
  | { type: "text_start"; contentIndex: number; partial: AssistantMessage }
  | { type: "text_delta"; contentIndex: number; delta: string; partial?: AssistantMessage }
  | { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage }
  | { type: "thinking_start"; contentIndex: number; partial: AssistantMessage }
  | { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
  | { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage }
  | { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }
  | { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
  | { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage }
  | { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse">; message: AssistantMessage }
  | { type: "error"; reason: Extract<StopReason, "aborted" | "error">; error: AssistantMessage };

协议注释明确了契约:

arduino 复制代码
// Streams should emit `start` before partial updates, then terminate with either:
// - `done` carrying the final successful AssistantMessage, or
// - `error` carrying the final AssistantMessage with stopReason "error" or "aborted" and errorMessage.

三个要点值得单独指出:

  • contentIndex 寻址 :所有 delta 都带 contentIndex,指向 AssistantMessage.content[] 里的块。这让"文本块 / 思考块 / tool call 块"可以在同一条流里交叉出现而互不混淆。
  • text_delta 的 partial 可选 :注释解释了原因------Plain text deltas may omit partial to avoid retaining one full assistant snapshot per token. 即每个 token 都带一份完整快照会爆内存。consume 侧需要当前文本时,从最近的 start/end 检查点回放 delta。
  • 终止二选一 :done 或 error,都由 AssistantMessage 承载最终状态。

16.7.2 容器:EventStream 与 AssistantMessageEventStream

packages/llm-core/src/utils/event-stream.ts 里,EventStream<T, R> 是通用异步可迭代事件队列:push(event) 入队(若已 done 则丢弃),[Symbol.asyncIterator] 出队(消费者在时直接交接,否则入内部数组),result() 返回一个单独的最终结果 Promise。

两个内存细节很讲究:

ts 复制代码
// The consumer owns this event now; compaction must not delay payload release.
this.queue[this.queueHead] = undefined;
this.queueHead += 1;
// Compact only after a substantial consumed prefix reaches half the backing array,
// keeping dequeue amortized O(1) when consumers lag.
if (this.queueHead >= 1024 && this.queueHead * 2 >= this.queue.length) {
  this.queue = this.queue.slice(this.queueHead);
  this.queueHead = 0;
}

出队即把槽位置 undefined(让大 payload 立即可被 GC),并且只在"已消费前缀 ≥1024 且占数组一半"时才压缩------保证消费者落后时摊还 O(1)。

AssistantMessageEventStream 在其上做了三件语义增强:

  1. merge 相邻的 partialless text_delta :未读的相邻同 index 无快照 delta 直接合并(this.queue[last] = { ...event, delta: previous.delta + event.delta }),减少下游处理量。
  2. 思考块 append 追踪 :用 WeakMap<ThinkingContent, {before, after, delta}> 记录 append 事实,readAssistantThinkingAppend 只返回"当前快照与观测快照仍匹配"的 append。
  3. 终止时清理 :done/error 时清除思考块追踪,避免历史块的 WeakMap 键在流释放后仍保留整段 reasoning 字符串。

end() 的语义也很重要------生产者若不吐终止事件就 end,必须响亮失败:

ts 复制代码
} else if (!this.resultSettled) {
  // A producer that ends without a terminal event or explicit result would
  // otherwise leave result() pending forever; awaiting consumers dead-end
  // silently for the whole run budget. Reject loudly instead. ...
  this.rejectFinalResult(new Error("event stream ended without a terminal event or final result"));
}

16.7.3 bindAssistantMessageEventStream:把 host 传播到 consumer

packages/ai/src/stream.ts 用 bindAssistantMessageEventStream(source, run, options?) 把 consumer 侧操作包进 host 作用域(见 16.6.3)。它的注释点明了"为什么完成事实住在生产者、而不是可变流表面上":

ts 复制代码
// Completion belongs to the producer, independently of queued-event consumption.
// Keep it outside the mutable stream surface: result() decorators may repair
// messages and release consumer-owned state when explicitly awaited.
const eventStreamCompletions = new WeakMap<object, Promise<unknown>>();

getEventStreamCompletion(stream) 让运行时不调用 consumer 的 result() 装饰器,就能观测到生产者原生结算------这是第 10 章"事件桥接"与第 12 章"生命周期 owner 必须等生产者真正结束"的接口。

16.7.4 各家 transport 实现:哪里做真正的解析

真正的 provider wire 解析在 packages/ai/src/transports/:

  • openai-responses-stream-*.ts(一整套 -internal.ts 文件):解析 Responses API 的 response 事件。
  • openai-completions-stream.ts:解析 Chat Completions 的 choices[].delta。
  • anthropic-transport-stream.ts + anthropic-stream-reducer.ts:解析 Anthropic Messages 的 SSE。
  • provider-transport-stream.ts:按 model.api 分派到上述实现。

provider-transport-stream.ts 的分派表:

ts 复制代码
const SUPPORTED_TRANSPORT_APIS = new Set<Api>([
  "openai-responses", "openai-chatgpt-responses", "openai-completions",
  "azure-openai-responses", "anthropic-messages", "google-generative-ai",
]);

function createSupportedTransportStreamFn(model: Model, ctx?) {
  switch (model.api) {
    case "openai-responses":
    case "openai-chatgpt-responses": return createOpenAIResponsesTransportStreamFn();
    case "openai-completions":       return createOpenAICompletionsTransportStreamFn();
    case "azure-openai-responses":   return createAzureOpenAIResponsesTransportStreamFn();
    case "anthropic-messages":       return createAnthropicMessagesTransportStreamFn();
    case "google-generative-ai":     return createProviderOwnedGoogleTransportStreamFn(model, ctx);
    default:                         return undefined;
  }
}

注意 Google 分支与其他不同:它通过 getAiTransportHost().plugin.resolveProviderStream(...) 走 provider-owned stream (因为 Google 的实现归 extensions/google 所有)。

16.7.5 归一化数据流图

sequenceDiagram participant Agent as Agent loop(第10章) participant Facade as src/llm/stream.ts participant Bind as model-runtime-binding participant RT as packages/ai runtime participant Reg as ApiRegistry participant Prov as Provider StreamFn<br/>(built-in 或插件) participant Host as AiTransportHost Agent->>Facade: stream(model, context, options) Facade->>Facade: deferUntilTransportRuntimeHost() Facade->>Facade: ensureTransportRuntimeHost()(装 plugin 端口) Facade->>Bind: getModelLlmRuntime(model) Bind-->>Facade: prepared runtime 或 undefined Facade->>RT: resolveRuntime(model).stream(...) RT->>Reg: resolveApiProvider(model.api) Reg-->>RT: RegisteredApiProvider(按 wire 协议) RT->>RT: startStream() 绑定 host 作用域 RT->>Prov: provider.stream(model, context, options) Prov->>Host: buildModelFetch / resolveSecretSentinel / log... Host-->>Prov: 受策略约束的 fetch Prov-->>RT: AssistantMessageEvent 流(start/text/toolcall/done|error) RT->>RT: bindAssistantMessageEventStream RT-->>Facade: 统一事件流 Facade-->>Agent: AssistantMessageEventStreamContract

16.8 OpenAI Responses 与 Completions 两条 route 的处理差异

OpenAI 是这一层最复杂的单一厂商,因为它同时拥有多条 route。理解它,就理解了"插件如何归一化 route"的范式(与第 11 章 harness 选择交叉)。

16.8.1 两条 route 的 wire 差异

  • openai-completions :经典 /v1/chat/completions,增量在 choices[].delta,finish_reason 收敛 stop reason,工具调用走 tool_calls[].function.arguments 增量拼接。
  • openai-responses :/v1/responses,事件更结构化(response.output_text.delta、response.function_call_arguments.delta、response.reasoning_summary_* 等),支持 previous_response_id 续写、store、server-side compaction。
  • openai-chatgpt-responses:ChatGPT/Codex 订阅路线,baseUrl 指向 Codex 端点,走 OAuth。

在类型层,它们的差异通过 Model.compat 的条件类型体现(packages/llm-core/src/types.ts):

ts 复制代码
compat?: TApi extends "openai-completions"
  ? OpenAICompletionsCompat
  : TApi extends "openai-responses" | "azure-openai-responses" | "openai-chatgpt-responses" | "openai-codex-responses"
    ? OpenAIResponsesCompat
    : TApi extends "anthropic-messages" ? AnthropicMessagesCompat : never;

即:compat 的形状随 api 而变 ,一个是 OpenAICompletionsCompat(supportsStore/supportsDeveloperRole/maxTokensField/thinkingFormat...),另一个是 OpenAIResponsesCompat(supportsInstructions/supportsResponsesContinuation/sendSessionIdHeader...)。这些 compat 字段正是契约测试与 provider 插件要填的"能力事实"。

16.8.2 插件如何把作者配置路由到正确的 api

extensions/openai/openai-provider.ts 里,normalizeResolvedModel 与 normalizeTransport 决定"这个模型最终走哪条 route":

ts 复制代码
normalizeResolvedModel: (ctx) => {
  if (normalizeProviderId(ctx.provider ?? "") !== PROVIDER_ID) return undefined;
  const authoredCompletionsRoute = resolveAuthoredOpenAICompletionsRoute(ctx);
  if (authoredCompletionsRoute) return { ...ctx.model, ...authoredCompletionsRoute };
  if (shouldUseCodexResponsesHooks({ ... })) return codexHooks.normalizeResolvedModel?.(ctx);
  return shouldUseOpenAIResponsesTransport({ ...ctx.model, modelId: ctx.modelId, config: ctx.config })
    ? { ...ctx.model, api: "openai-responses" }
    : ctx.model;
},
normalizeTransport: (ctx) => {
  const authoredCompletionsRoute = resolveAuthoredOpenAICompletionsRoute(ctx);
  if (authoredCompletionsRoute) {
    return ctx.api === authoredCompletionsRoute.api && ctx.baseUrl === authoredCompletionsRoute.baseUrl
      ? undefined : authoredCompletionsRoute;
  }
  if (shouldUseCodexResponsesHooks(ctx)) return codexHooks.normalizeTransport?.(ctx);
  return shouldUseOpenAIResponsesTransport(ctx) ? { api: "openai-responses", baseUrl: ctx.baseUrl } : undefined;
},

而"是否升级到 Responses"的核心判定:

ts 复制代码
function shouldUseOpenAIResponsesTransport(params: {
  provider: string; modelId?: string; api?: string | null; baseUrl?: string;
  config?: { models?: { providers?: Record<string, ModelProviderConfig | undefined> } };
}): boolean {
  if (params.api !== "openai-completions") return false;
  const isOwnerProvider = normalizeProviderId(params.provider) === PROVIDER_ID;
  const isPlatformEndpoint =
    typeof params.baseUrl === "string" && classifyOpenAIBaseUrl(params.baseUrl) === "platform";
  if (isOwnerProvider) {
    if (resolveAuthoredOpenAICompletionsRoute(params)) return false;
    return !params.baseUrl || isPlatformEndpoint;
  }
  return isPlatformEndpoint;
}

意图(这也是源码注释的原话) :Authored Completions is a current transport contract; only catalog defaults are upgraded. 即:

  • 用户显式 在配置里写了 api: "openai-completions" 的模型,尊重它 ,不升级(resolveAuthoredOpenAICompletionsRoute 返回真值即 return false)。
  • 只有 owner provider(openai)指向 platform 端点、且没有被作者显式配置为 Completions 时,才把 catalog 默认升级到 Responses。
  • Codex/订阅路线(Codex baseUrl 或 openai-chatgpt-responses api)走 codexHooks。

16.8.3 prepareExtraParams:route 相关的额外参数

同文件里 prepareExtraParams 依据 auth 与 route 决定额外参数,例如 SIWC(Sign in with ChatGPT)路线强制 transport: "sse"、store: false、responsesServerCompaction: false:

ts 复制代码
prepareExtraParams: (ctx) => {
  if (ctx.auth?.mode === "oauth" && ctx.auth.authFlow === TOKEN_SHARING_AUTH_FLOW) {
    return { ...ctx.extraParams, transport: "sse", store: false, responsesServerCompaction: false };
  }
  const useCodexTransport = ...;
  return (useCodexTransport ? nativeResponsesHooks : responsesHooks).prepareExtraParams?.(ctx);
},

注意 responsesHooks = buildOpenAIResponsesProviderHooks({ transport: "sse" }) 与 nativeResponsesHooks = buildOpenAIResponsesProviderHooks() 的差别:是否强制 SSE是 route 的一个开关。

16.8.4 route 判定小结(与第 11 章交叉)

第 11 章讲的是"runtime/harness 怎么选"(codex / copilot / anthropic 等)。本章的 route 判定发生在更下层 :model.api 决定"用哪套 wire + transport"。两者关系是:

  • harness 决定"用哪个 agent 运行时(可能换模型、换流式实现)";
  • provider 插件的 normalizeResolvedModel/normalizeTransport 决定"这个模型对象的 api/baseUrl 最终是什么";
  • 拿到确定的 model.api 后,本章的 ApiRegistry 决定"哪段 transport 代码来解析它"。

换言之,harness 是上层策略,route 是下层事实 。shouldResolveDynamicModelThroughCodex 里那句注释正好点出边界:

ts 复制代码
// The auth planner owns profile ordering and projects the selected physical
// route into providerConfig before materialization. Until then, only a
// one-route model contract may choose a transport.

16.9 model-registry 与 model catalog / projection

16.9.1 读侧:ModelRegistry

src/llm/model-registry.ts 的四个方法(15.3.4 已列)覆盖了 picker 与可用性检查所需:列全部、列可用、按 (provider, modelId) 查、判断是否已配置认证。它是读模型,不负责组装。

16.9.2 组装侧:catalog hook

provider 插件通过三类 hook 贡献目录行(ProviderPlugin 上):

  • catalog:旧文本 catalog hook(会做网络 I/O,如 OpenAI 的 account-scoped 模型发现)。
  • staticCatalog:离线/静态行(不做网络 I/O,用于未配置认证前即可展示)。
  • augmentModelCatalog:对已有条目做增补(如 OpenAI 用 family 模板补齐 contextWindow/contextTokens)。

OpenAI 的 catalog 有明确的授权语义(openai-provider.ts):

ts 复制代码
// A successful account catalog is authoritative even when it has no
// visible supported models; static rows cannot grant model access.
return { provider: { ...fallback, models: [ ... ] }, outcome: { provider: PROVIDER_ID, status: "ready" } };

以及失败时的降级语义:

ts 复制代码
} catch (error) {
  if (error instanceof LiveModelCatalogHttpError && (error.status === 401 || error.status === 403)) {
    return { provider: { ...fallback, models: [] }, outcome: { provider: PROVIDER_ID, status: "auth-rejected" } };
  }
  // Codex/ChatGPT discovery is advisory. Static OpenAI rows stay available
  // when OAuth refresh or the remote model list is unavailable.
  return { provider: fallback, outcome: { provider: PROVIDER_ID, status: "unavailable" } };
}

不变量 :401/403 属于认证事实 ,返回 auth-rejected 且清空模型行;其他网络失败属于可用性问题 ,保留静态行并标 unavailable。这就是为什么文档说"静态行不能授权访问,account catalog 才是权威"。

16.9.3 与第 12 章 prepared runtime generation 的投影关系

把三者串起来看投影方向:

flowchart LR G[&#34;prepared model runtime generation<br/>(第12章,owner:原子快照/lease)&#34;] C[&#34;model catalog 投影<br/>(planEffectiveModelCatalogRows)&#34;] R[&#34;ModelRegistry 读侧<br/>(getAll/find/hasConfiguredAuth)&#34;] B[&#34;model-runtime-binding<br/>(把 generation 的 runtime 挂到 Model)&#34;] S[&#34;stream facade<br/>(resolveRuntime(model))&#34;] G -->|派生| C C -->|供读| R G -->|bindModelLlmRuntime| B B -->|getModelLlmRuntime| S
  • generation 是 owner:第 12 章的 prepared runtime generation 持有原子快照与生命周期(lease)。
  • catalog 是投影 :它从 generation + manifest + 远程 overlay 规划出"有效行",带显式失效(planEffectiveModelCatalogRows 每次都依据当前 config 重算)。
  • binding 是挂载 :bindModelLlmRuntime 把 generation 的 LlmRuntime 私下挂到已解析的 Model 上。
  • facade 是消费者 :stream.ts 的 resolveRuntime(model) 优先读 binding,读不到才用进程默认。

why(为什么 registry 与 catalog 分离):

  • catalog 面向"组合":它需要 manifest、远程 overlay、allow 规则、provider hook,产物是"有哪些行"。
  • registry 面向"消费":picker 与可用性检查只需要"按 id 查一行、判断能否用",不需要知道行是怎么来的。
  • 两者混合会让读侧背上网络 I/O 与失效逻辑,违反"缓存/投影只在 owner 处派生"的公理。分离后,catalog 的 owner 是 src/model-catalog/,registry 的 owner 是 src/llm/model-registry.ts,边界干净。

16.10 auth profile 与 model runtime binding 的关系

这一节回答:"模型能不能被调用 "这件事,如何与"模型属于哪个 runtime 生命周期"绑定。

16.10.1 准备阶段:prepareSimpleCompletionModel

src/agents/simple-completion-runtime.ts 是认证与模型准备的汇合点。它做的关键步骤(摘录要点):

ts 复制代码
const modelRuntime = getModelRegistryRuntime(resolved.modelRegistry);
...
const preparedModel = attachModelProviderRuntimePluginHandle(model, providerRuntimeHandle);
const completionTransport =
  params.transport === "provider-stream"
    ? undefined
    : attachModelProviderRuntimePluginHandle(
        prepareModelForSimpleCompletion({
          apiRegistry: modelRuntime.apiRegistry,
          model: preparedModel,
          cfg: params.cfg,
          auth: { mode: resolvedAuth.mode, authFlow: resolvedAuth.authFlow },
          agentId: params.agentId,
        }),
        providerRuntimeHandle,
      );
...
return {
  model: bindModelLlmRuntime(preparedModel, modelRuntime.llmRuntime, completionTransport),
  auth: resolvedAuth,
  ...
};

三个要点:

  1. getModelRegistryRuntime(resolved.modelRegistry) :从模型注册表这个 owner 拿到它自己的 apiRegistry + llmRuntime(见 16.5)。
  2. prepareModelForSimpleCompletion :把 apiRegistry 注入模型,使"简单补全"能用这个生命周期的 registry,而不是进程默认(隔离)。
  3. bindModelLlmRuntime(preparedModel, modelRuntime.llmRuntime, completionTransport) :把 runtime 和 completion transport 一起挂到 Model 上。

16.10.2 auth 与 profile

同文件的 auth 解析路径很清楚:先 ensureAuthProfileStore(agentDir, ...) 读认证配置库,再区分"route 解析成功"与"直接 API key"两条路:

ts 复制代码
const preparedAuth = routeResolution?.kind === "routes" ? prepareAgentRuntimeAuth({ ...authParams, routeIntent }) : undefined;
...
if (preparedAuth && authStore) {
  const resolvedAuth = await resolvePreparedRuntimeAuthAttempts({ ... });
  auth = resolvedAuth.auth;
  resolvedModel = resolvedAuth.model;
} else {
  auth = await getApiKeyForModelCore({ ... });
}

随后若拿到原始 key,通过 prepareProviderRuntimeAuth → protectPreparedProviderRuntimeAuth 做出口前的认证保护(密钥以哨兵形式存放,出口才还原,见 16.6.2):

ts 复制代码
authValue = preparedAuth?.apiKey?.trim() || rawApiKey;
resolved.authStorage.setRuntimeApiKey(resolvedModel.provider, authValue);
resolvedModel = applyPreparedRuntimeAuthToModel(resolvedModel, preparedAuth);

auth profile 与 binding 的关系可以概括为:

  • auth profile 决定"用什么凭据"(mode: api_key / oauth / device_code;profileId;authFlow)。
  • model runtime binding 决定"用哪个 runtime + registry + transport"。
  • 两者在 prepare 阶段被"合并"进同一个 Model 值对象:凭据相关事实进入 model.headers/auth storage,runtime 事实进入不可枚举 Symbol 槽,transcript 里的 Model 字面量不变。

16.10.3 执行阶段:completeWithPreparedSimpleCompletionModel

src/agents/simple-completion-execution.ts 在真正发请求时读回 binding:

ts 复制代码
export async function completeWithPreparedSimpleCompletionModel(params): Promise<AssistantMessage> {
  const owner = getModelCompletionOwner(params.model);
  if (!owner) return await completePreparedModel(params);
  return await owner.run(() =>
    completePreparedModel({ ...params, assertCurrent: () => { owner.assertCurrent(); params.assertCurrent?.(); } }),
  );
}

async function completePreparedModel(params) {
  await import("./ai-transport-runtime-host.js");   // 装端口
  const runtime = getModelLlmRuntime(params.model);
  let completionModel = getModelCompletionTransport(params.model) ?? prepareModelForSimpleCompletion({ ... });
  if (runtime) completionModel = bindModelLlmRuntime(completionModel, runtime);
  ...
  return await completeSimple(completionModel, params.context, completionOptions, params.assertCurrent);
}

关键纪律 :owner.run(...) 把补全放进 completion owner 的作用域,并在 awaited 之后由 owner.assertCurrent() 重新校验"这次补全是否仍是当前 generation 的工作"------This 对应根 AGENTS.md 的"特权动作在 awaited 工作之后、副作用之前要重校验 live authority"。同时 getModelCompletionTransport 优先使用准备阶段就构造好的 completion transport,避免重复构造。


16.11 provider 插件如何注册:以 extensions/openai 为例

16.11.1 manifest-first

extensions/openai/openclaw.plugin.json 是权威元数据(不加载运行时代码即可读):

json 复制代码
{
  "id": "openai",
  "categories": ["models"],
  "capabilityCatalogEntry": "./capability-catalog.ts",
  "sessionRouteStateOwners": [ { "id": "openai", "label": "OpenAI", "providerIds": ["openai"], "authProfilePrefixes": ["openai:"] } ],
  "activation": { "onStartup": true },
  "enabledByDefault": true,
  "providers": ["openai"],
  "providerUsageAuthEnvVars": { "openai": ["OPENAI_ADMIN_KEY"] },
  "modelSupport": { "modelPrefixes": ["gpt-", "o1", "o3", "o4"] },
  "providerEndpoints": [
    { "endpointClass": "openai-public", "hosts": ["api.openai.com"], "hostSuffixes": [".api.openai.com"] },
    { "endpointClass": "openai", "hosts": ["chatgpt.com"] },
    { "endpointClass": "azure-openai", "hostSuffixes": [".openai.azure.com"] }
  ],
  "providerRequest": { "providers": { "openai": { "family": "openai-family" } } },
  "modelCatalog": { "modelsDev": { "openai": "openai" }, "providers": { "openai": { "baseUrl": "https://api.openai.com/v1", "api": "openai-responses", "models": [ ... ] } } }
}

providerEndpoints 是端点分类权威 (endpointClass),modelCatalog 是静态目录行 (对应 staticCatalog)。注意 defaultUtilityModel、modelPrefixes 等元数据在这里就声明了,控制平面无需加载运行时代码即可回答"openai 提供哪些前缀的模型"。

16.11.2 runtime 入口:definePluginEntry

extensions/openai/index.ts 是运行时入口:

ts 复制代码
export default definePluginEntry({
  id: "openai",
  name: "OpenAI Provider",
  description: "Bundled OpenAI provider plugins",
  register(api) {
    const { ensureAuthProfileStore, listProfilesForProvider, isProviderApiKeyConfigured } = api.runtime.modelAuth;
    const provider = buildOpenAIProvider();
    api.registerProvider({
      ...provider,
      ...openAIToolCompatHooks,
      resolveSystemPromptContribution: (ctx) => { /* GPT-5 系统提示贡献 */ },
    });
    api.registerEmbeddingProvider(openAiMemoryEmbeddingProviderAdapter);
    api.registerImageGenerationProvider(buildOpenAIImageGenerationProvider({ ... }));
    api.registerRealtimeTranscriptionProvider(buildOpenAIRealtimeTranscriptionProvider);
    api.registerRealtimeVoiceProvider((context) => { ... });
    api.registerSpeechProvider(buildOpenAISpeechProvider());
    api.registerMediaUnderstandingProvider(openaiMediaUnderstandingProvider);
  },
});

观察 :provider 插件不只是"文本模型",它还注册 embedding / image / speech / realtime / media-understanding 等多种 provider 面。api.registerProvider 是文本 provider 的注册点(对应链路 B),其余 registerXxxProvider 是各自能力面的注册点。

16.11.3 buildOpenAIProvider 的契约拼装

openai-provider.ts 的 buildOpenAIProvider() 把多块拼成一个完整 ProviderPlugin:

ts 复制代码
export function buildOpenAIProvider(): ProviderPlugin {
  const providerDefinition = createOpenAIProvider();     // 来自 provider-contract-api.ts(auth 方法列表)
  const apiKeyRuntime = createProviderApiKeyAuthMethod({ ... });
  for (const method of providerDefinition.auth) { ... }     // 为每个 auth 方法补 run/starterModel
  const codexHooks = buildOpenAICodexProviderHooks();
  const nativeResponsesHooks = buildOpenAIResponsesProviderHooks();
  const responsesHooks = buildOpenAIResponsesProviderHooks({ transport: "sse" });
  return {
    ...providerDefinition,
    catalog: { order: "simple", run: async (ctx) => { /* account-scoped 发现 */ } },
    staticCatalog: { order: "simple", run: async () => ({ providers: { [PROVIDER_ID]: OPENAI_MANIFEST_PROVIDER } }) },
    resolveDynamicModel: (ctx) => shouldResolveDynamicModelThroughCodex(ctx) ? codexHooks.resolveDynamicModel?.(ctx) : resolveOpenAIGptForwardCompatModel(ctx),
    normalizeResolvedModel: ...,
    normalizeTransport: ...,
    prepareExtraParams: ...,
    augmentModelCatalog: ...,
    // resolveUsageAuth / refreshOAuth / buildMissingAuthMessage / classifyFailoverReason / resolveThinkingProfile ...
  };
}

createOpenAIProvider()(provider-contract-api.ts)只声明 auth 契约 (4 种认证方法:oauth / device-code / siwc / api-key):

ts 复制代码
export function createOpenAIProvider(): ProviderPlugin {
  return {
    id: "openai",
    label: "OpenAI",
    hookAliases: ["azure-openai", "azure-openai-responses"],
    docsPath: "/providers/models",
    envVars: ["OPENAI_API_KEY"],
    auth: [
      { id: "oauth", kind: "oauth", label: "Codex login (browser)", run: noopAuth, matchesPersonalAccount, ... },
      { id: "device-code", kind: "device_code", label: "Codex login (device code)", run: noopAuth, ... },
      { id: "siwc", kind: "oauth", label: "Sign in with ChatGPT (Beta)", run: noopAuth, ... },
      { id: "api-key", kind: "api_key", label: "OpenAI API Key", run: noopAuth, ... },
    ],
  };
}

why(为什么契约先声明 auth,run 再在 buildOpenAIProvider 里补) :provider-contract-api.ts 是轻量契约表面 (可以被契约测试、wizard、控制平面在冷启动时读),而 run(真正弹浏览器/设备码/写配置)属于重运行时 ,通过 await import(...) 懒加载。这符合 SDK 指南"公开入口保持轻量、*.runtime 子路径承载重逻辑"。


16.12 不变量与取舍

把本章所有源码事实收敛成清单。

16.12.1 不变量

编号 不变量 源码依据 反例(被禁止)
I-16-1 分派键是 model.api(wire 协议),不是 model.provider api-registry.ts wrapStream 校验 model.api !== api 抛错 按 provider 名直接 select transport;同名 provider 混用不同 api
I-16-2 注册动作不构造网络客户端 register-builtins.ts:Registration is transport-free 在 registry 注册时 new SDK client
I-16-3 StreamFunction 同步返回 stream,失败编码进流 types.ts StreamFunction 契约注释;deferUntilTransportRuntimeHost provider 加载失败直接 throw 到调用方
I-16-4 流的终止必须是 done 或 error types.ts 协议注释;EventStream.end 无终止则 reject 生产者静默 end 让 result() 永久挂起
I-16-5 runtime/registry 挂在 Model 上时不可枚举、不改序列化形状 model-runtime-binding.ts Object.defineProperty({ enumerable: false }) 把 runtime 作为可枚举字段塞进 Model
I-16-6 出口未注册哨兵必须 fail closed ai-transport-host.ts resolveSecretSentinel 抛错 遇到未知哨兵静默当普通字符串发出
I-16-7 每次请求的 host 策略随异步上下文隔离 host.ts AsyncLocalStorage + runWithAiTransportHost 交错请求共用一个可变全局 host
I-16-8 作者显式配置的 route 优先于 catalog 默认 openai-provider.ts Authored Completions is a current transport contract 无条件把 Completions 升级为 Responses
I-16-9 account catalog 是授权权威;静态行不能授权 openai-provider.ts 注释;auth-rejected vs unavailable 用静态行"假装"有访问权限
I-16-10 特权补全在 awaited 之后重校验 generation simple-completion-execution.ts owner.assertCurrent() 用过期 generation 的 runtime 发请求

16.12.2 取舍

取舍 选择 代价 / 收益
进程默认 vs 隔离运行时 默认运行时挂 globalThis[Symbol.for(...)] 供 facade 用;库级调用方走 createLlmRuntime() 隔离 收益:OpenClaw 内单一 registry 不被拆散;代价:库必须显式避免把可变全局导出为公共 API
懒加载 vs 冷启动 9 个内置 wire 适配器全部 lazy import 收益:冷启动不加载全部 SDK;代价:首次请求多一次 import() 往返
host 端口化 vs 直接实现 库只定义端口,OpenClaw 装实现 收益:库可独立、无供应商耦合、可测试(inert 默认);代价:多一层间接
两步安装 host 基础端口 eager、插件端口 lazy 收益:不把 src/plugins 图拉进冷启动;代价:同步 stream 需 deferUntilTransportRuntimeHost
catalog 与 registry 分离 catalog 规划、registry 读 收益:读侧无网络/失效逻辑;代价:两处概念需要理解其投影方向
目录 value / 静态行价值 account catalog 权威,静态行仅展示 收益:不自造访问权限;代价:未认证时目录可能为空

16.13 与相邻子系统的边界

相邻子系统 边界契约 交点
第 10 章 Agent loop 事件流 agent loop 只消费 AssistantMessageEvent 协议;本层负责把它从各家 wire 归一化出来 AssistantMessageEvent;bindAssistantMessageEventStream;getEventStreamCompletion
第 11 章 Harness 注册与运行时选择 harness 决定"用哪个 agent 运行时",本层决定"用哪套 wire/transport";route 归一化由 provider 插件负责 normalizeResolvedModel / normalizeTransport;shouldUseOpenAIResponsesTransport
第 12 章 Prepared Model Runtime Generation generation 是 owner,本层通过 binding 消费它 bindModelLlmRuntime / getModelLlmRuntime;initializeModelRegistryRuntime
第 17 章 插件体系与 Plugin SDK api.registerProvider(ProviderPlugin) 是插件注册点;本层消费其 hook definePluginEntry;resolveProviderStreamFn;openclaw.plugin.json
第 20 章 安全模型与 SecretRef 出口哨兵由本节 host 端口在 egress 前还原;未注册 fail closed resolveSecretSentinel / redactModelVisibleSecrets / buildModelFetch

一处需要强调的边界:本层不认识"沙箱/exec approval" ,那是第 14/15 章的事情。本层只认识"把请求安全地送到 provider",硬化聚焦在网络出口与密钥出口(fetch 守卫、哨兵、脱敏),而不是工具执行权限。


16.14 小结与下一章

本章拆解了"模型与 Provider 抽象层"------OpenClaw 中把 agent loop 的意图送达任意 provider,并把任意 provider 的流收敛成统一协议的那一层。要点回顾:

  1. 三层所有者 :packages/llm-core(类型/协议)、packages/ai(transport runtime + host 端口)、src/llm(OpenClaw facade)、extensions/*(厂商实现)。
  2. 三条链路 :API provider registry(按 model.api 分派 wire)、ProviderPlugin registry(厂商 hook)、ModelRegistry + model catalog(目录读与投影)。
  3. stream facade :src/llm/stream.ts 以模块顶层副作用装好 host 与内置 provider,用 resolveRuntime(model) 在"prepared runtime"与"进程默认"间选择;同步 stream 用 deferUntilTransportRuntimeHost 保证端口先就绪。
  4. model-runtime-binding :用不可枚举 Symbol 在 Model 上私自携带 prepared runtime / completion transport / owner,保持 transcript 字节不变。
  5. transport host :库只定义端口(inert 默认),OpenClaw 分两步安装,并用 AsyncLocalStorage 让 host 随异步上下文隔离。
  6. 归一化 :AssistantMessageEvent 协议 + EventStream/AssistantMessageEventStream + 各 transport 实现,把 text/thinking/toolcall delta 与终止事件统一。
  7. 两条 OpenAI route :openai-completions vs openai-responses/openai-chatgpt-responses,由 provider 插件的 normalizeResolvedModel/normalizeTransport 归一化,且作者显式配置优先于 catalog 默认。
  8. catalog 与 registry 分离 :catalog 是投影(planEffectiveModelCatalogRows),registry 是读侧;account catalog 是授权权威,静态行仅展示。
  9. auth 与 binding 的关系 :auth profile 决定凭据,binding 决定 runtime/transport,两者在 prepare 阶段合并进同一 Model 值对象,execution 阶段以 owner.assertCurrent() 重校验。

下一章(第 17 章)进入"插件体系与 Plugin SDK":能力阶梯(capability ladder)、manifest-first loader、SDK 预算与 import guard、ClawHub / ACP / A2A。本章反复出现的 definePluginEntry、api.registerProvider、openclaw.plugin.json、src/plugin-sdk/* 正是下一章的主角------我们会看到这些插件契约为什么 被设计成 manifest-first、为什么 SDK 面要刻意保持小、以及 插件加载器如何在安全的边界内把 extensions/* 接进 core。


本章引用:src/llm/stream.ts、src/llm/model-runtime-binding.ts、src/llm/model-registry.ts、src/llm/ai-transport-host.ts、src/llm/types.ts、src/llm/providers/stream-wrappers/openai.ts、src/agents/ai-transport-runtime-host.ts、src/agents/simple-completion-runtime.ts、src/agents/simple-completion-execution.ts、src/agents/sessions/model-registry-runtime.ts、src/plugins/provider-runtime.ts、src/plugins/provider-plugin.types.ts、src/plugins/provider-runtime-model.types.ts、src/model-catalog/index.ts、packages/llm-core/src/types.ts、packages/llm-core/src/utils/event-stream.ts、packages/llm-core/src/index.ts、packages/ai/src/stream.ts、packages/ai/src/api-registry.ts、packages/ai/src/host.ts、packages/ai/src/providers/register-builtins.ts、packages/ai/src/transports/provider-transport-stream.ts、packages/ai/src/internal/default-runtime.ts、extensions/openai/index.ts、extensions/openai/openai-provider.ts、extensions/openai/provider-contract-api.ts、extensions/openai/openclaw.plugin.json。代码摘录为说明性节选,可能删减;接口名、注册点、默认值以仓库当前源码为准。

相关推荐
鬓戈1 小时前
LLM 训练原理、L20 单卡训练可行性与 nanoGPT 入门
人工智能
xsd202411181 小时前
微信开源 WeVisDoc:端到端文档解析模型
人工智能
skywalk81631 小时前
光明记忆(LIMS)v2.3 — 智能记忆管理平台(未开放版本)
人工智能·记忆·光明
对象存储与RustFS1 小时前
HelixDB 上手:Agent 记忆新方案,基于 RustFS 对象存储,附完整 docker-compose 部署示例
后端·rust·开源
弈语道破AI1 小时前
一稿出片!双十一品牌宣传AI视频生成工具推荐:即梦 Seedance 2.5 全球首发
人工智能·音视频
通信大模型1 小时前
中国科学 信息科学 | TokenCom: 面向多模态和多任务Token通信的视觉-语言模型
人工智能·计算机视觉·语言模型
LeeZhao@1 小时前
【具身智能】VLA-DM0.5:让机器人真正理解世界
人工智能·计算机视觉·机器人·agi
kaixin_啊啊1 小时前
【零基础学AI】第 8 章——让 AI 辅助编程
人工智能
miofly2 小时前
Karpathy:75%人口未接触LLM,约5000人可用的内部系统已让数千智能体协作数周
开源·github