第 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),它们之间的差异至少有五个维度:
- wire 协议不同:OpenAI 有 Responses 与 Chat Completions 两条 API,Anthropic 是 Messages API,Google 是 Generative AI / Vertex,Bedrock 是 Converse...
- 流式事件形状不同:SSE 分片、字段名、增量语义、结束事件各不相同;有的 provider 甚至先缓冲全部 delta,直到终止分类才吐事件。
- 能力开关不同 :是否支持
reasoning_effort、stop、response_format、prompt cache、server-side compaction... - 认证方式不同:API key、OAuth、device code、SIWC、AWS/GCP 标记...
- 传输加固不同:代理、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 三条链路的注册/解析总图
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,可以先 awaitensureTransportRuntimeHost(),并在此之后调用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:
- 基础策略端口 ------
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 章"凭据有所有者 + 出口哨兵"在这层的落点。
- 插件端口 ------
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 在其上做了三件语义增强:
- merge 相邻的 partialless
text_delta:未读的相邻同 index 无快照 delta 直接合并(this.queue[last] = { ...event, delta: previous.delta + event.delta }),减少下游处理量。 - 思考块 append 追踪 :用
WeakMap<ThinkingContent, {before, after, delta}>记录 append 事实,readAssistantThinkingAppend只返回"当前快照与观测快照仍匹配"的 append。 - 终止时清理 :
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 归一化数据流图
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-responsesapi)走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 的投影关系
把三者串起来看投影方向:
- 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,
...
};
三个要点:
getModelRegistryRuntime(resolved.modelRegistry):从模型注册表这个 owner 拿到它自己的apiRegistry+llmRuntime(见 16.5)。prepareModelForSimpleCompletion:把apiRegistry注入模型,使"简单补全"能用这个生命周期的 registry,而不是进程默认(隔离)。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 的流收敛成统一协议的那一层。要点回顾:
- 三层所有者 :
packages/llm-core(类型/协议)、packages/ai(transport runtime + host 端口)、src/llm(OpenClaw facade)、extensions/*(厂商实现)。 - 三条链路 :API provider registry(按
model.api分派 wire)、ProviderPlugin registry(厂商 hook)、ModelRegistry + model catalog(目录读与投影)。 - stream facade :
src/llm/stream.ts以模块顶层副作用装好 host 与内置 provider,用resolveRuntime(model)在"prepared runtime"与"进程默认"间选择;同步stream用deferUntilTransportRuntimeHost保证端口先就绪。 - model-runtime-binding :用不可枚举 Symbol 在
Model上私自携带 prepared runtime / completion transport / owner,保持 transcript 字节不变。 - transport host :库只定义端口(inert 默认),OpenClaw 分两步安装,并用
AsyncLocalStorage让 host 随异步上下文隔离。 - 归一化 :
AssistantMessageEvent协议 +EventStream/AssistantMessageEventStream+ 各 transport 实现,把 text/thinking/toolcall delta 与终止事件统一。 - 两条 OpenAI route :
openai-completionsvsopenai-responses/openai-chatgpt-responses,由 provider 插件的normalizeResolvedModel/normalizeTransport归一化,且作者显式配置优先于 catalog 默认。 - catalog 与 registry 分离 :catalog 是投影(
planEffectiveModelCatalogRows),registry 是读侧;account catalog 是授权权威,静态行仅展示。 - 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。代码摘录为说明性节选,可能删减;接口名、注册点、默认值以仓库当前源码为准。