LLM Providers:廠商介接
職責
LLM provider 是把不同廠商 API 介接成統一流式介面的薄層。Anthropic、OpenAI Responses、OpenAI Completions、Azure OpenAI、Google Gemini、Google Vertex、Mistral、OpenAI Codex(ChatGPT Responses)——每家 API 的請求/回應/流式事件格式都不一樣,但 OpenClaw 的 agent 主循環只想要一種事件流:AssistantMessageEventStream(thinking / text / toolCall / toolResult / error / stop)。provider 的工作就是在中間做翻譯:把模型 API 的 SSE/JSON 流解析成 OpenClaw 的統一事件流。
provider 不做「思考」,只做四件事:建用戶端(API key、OAuth、headers、cache 策略)、構造請求參數(messages/tool definitions/options 折成廠商格式)、解析流式回應(把廠商的事件序列翻譯成統一事件)、處理錯誤(把 HTTP 錯誤、流截斷、message_stop 缺失等都編碼成 stopReason: "error")。所有 provider 實作同一個 StreamFunction 契約(types.ts StreamFunction:L201-L208),因此主循環不用關心在叫哪家。
設計動機
為什麼把 provider 抽象成 StreamFunction?因為廠商差異大但呼叫語意同構。每個 provider 都接受 (model, context, options),都回傳 AssistantMessageEventStream——這種「輸入/輸出同構、內部實作各異」是介面抽象的理想場景。主循環只叫 stream(model, context, options),具體走哪家由 model.api 欄位決定。
Api 型別(types.ts KnownApi:L6-L18) 列了所有內建 API id:
export type KnownApi =
| "openai-completions"
| "mistral-conversations"
| "openai-responses"
| "azure-openai-responses"
| "openai-chatgpt-responses"
| "anthropic-messages"
| "bedrock-converse-stream"
| "google-generative-ai"
| "google-vertex";
export type Api = KnownApi | (string & {});注意末尾的 | (string & {})——這是 TypeScript 裡「允許任意字串但保留自動補全」的技巧。它意味著 Api 不被鎖死在 KnownApi 集合裡,第三方外掛可以登錄自己的 provider 走自訂 api id(比如 "my-custom-llm")。registerApiProvider(api-registry.ts registerApiProvider:L77-L90) 接受任意 TApi extends Api,然後存到一張 Map<string, RegisteredApiProvider> 裡。
StreamFunction 契約(packages/llm-core/src/types.ts:L193-L208) 寫得很清楚:
// 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.
export type StreamFunction<
TApi extends Api = Api,
TOptions extends StreamOptions = StreamOptions,
> = (
model: Model<TApi>,
context: Context,
options?: TOptions,
) => AssistantMessageEventStreamContract;關鍵契約:錯誤不拋——一旦 provider 被呼叫,後續的失敗(網路錯、模型拒絕、流截斷)必須編碼進回傳的 stream 裡,以一個 stopReason: "error" 的 AssistantMessage 結束。這個契約讓主循環的 try/catch 可以專注於「叫 provider 之前」的錯(參數錯、api 未登錄),provider 內部錯全部走 stream 處理路徑——統一了錯誤處理邏輯。
懶載入是另一個重要設計(register-builtins.ts 懶載入:L156-L181):
function createLazyStream<
TApi extends Api,
TOptions extends StreamOptions,
TSimpleOptions extends SimpleStreamOptions,
>(
loadModule: () => Promise<LazyProviderModule<TApi, TOptions, TSimpleOptions>>,
): StreamFunction<TApi, TOptions> {
return (model, context, options) => {
const outer = new AssistantMessageEventStream();
loadModule()
.then((module) => {
const inner = module.stream(model, context, options);
forwardStream(outer, inner);
})
.catch((error: unknown) => {
const message = createLazyLoadErrorMessage(model, error);
outer.push({ type: "error", reason: "error", error: message });
outer.end(message);
});
return outer;
};
}provider 模組只在第一次實際被呼叫時才 import——這避免了「啟動時強制載入所有 provider」的冷啟動成本。如果使用者只叫 Anthropic,OpenAI/Google/Mistral 的程式碼根本不會被載入。載入失敗(模組缺失、語法錯)也被編碼成 stream 裡的 error 事件,不拋錯——遵守同一個契約。
registerBuiltInApiProviders(register-builtins.ts 註冊所有內建:L342-L414) 把所有內建 provider 登錄進 apiProviderRegistry,每條都帶 sourceId: BUILT_IN_API_PROVIDER_SOURCE_ID,方便 unregisterApiProviders(sourceId) 在測試 teardown 時一鍵清空。
custom provider 也能登錄:custom-api-registry.ts ensureCustomApiRegistered:L15-L36 給 SDK 呼叫方一個口子,但加了「已有 provider 就不登錄」的守護——避免覆蓋內建實作。
關鍵檔案
types.ts KnownApi:L6-L18— 內建 9 種 API id。types.ts StreamFunction:L193-L208— provider 契約:錯誤進 stream 不拋。api-registry.ts 型別與登錄表:L13-L50—ApiProvider介面與內部 Map。api-registry.ts registerApiProvider:L77-L90— 登錄/替換 API provider。api-registry.ts getApiProvider:L93-L100— 按 api id 查詢。stream.ts stream/complete:L22-L40— 主入口stream與complete,按model.api分派。register-builtins.ts 懶載入:L156-L181—createLazyStream包裝。register-builtins.ts 匯出懶 stream:L316-L339— 9 個內建 provider 的懶 stream 匯出。register-builtins.ts registerBuiltInApiProviders:L342-L414— 登錄到 registry。anthropic.ts streamAnthropic:L447-L535— Anthropic provider 實作。openai-responses.ts streamOpenAIResponses:L76-L106— OpenAI Responses provider。custom-api-registry.ts:L15-L36— SDK 自訂 provider 登錄口。openclaw-provider-index.ts:L13-L69— 預聲明 provider 索引,未裝外掛時模型選擇器也能看到。
資料流
主循環叫 stream(stream.ts stream:L22-L30):
function resolveApiProvider(api: Api) {
const provider = getApiProvider(api);
if (!provider) {
throw new Error(`No API provider registered for api: ${api}`);
}
return provider;
}
export function stream<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ProviderStreamOptions,
): AssistantMessageEventStreamContract {
const provider = resolveApiProvider(model.api);
return provider.stream(model, context, options as StreamOptions);
}注意「api 未登錄」在這裡是拋錯的——發生在叫 provider 之前,主循環可以 catch。一旦進入 provider.stream,後續錯誤全部走 stream。
登錄表內部是一個簡單 Map(packages/llm-runtime/src/api-registry.ts:L45-L90):
const apiProviderRegistry = new Map<string, RegisteredApiProvider>();
export function registerApiProvider<TApi extends Api, TOptions extends StreamOptions>(
provider: ApiProvider<TApi, TOptions>,
sourceId?: string,
): void {
apiProviderRegistry.set(provider.api, {
provider: {
api: provider.api,
stream: wrapStream(provider.api, provider.stream),
streamSimple: wrapStreamSimple(provider.api, provider.streamSimple),
},
sourceId,
});
}wrapStream 會校驗 model.api 是否匹配,不匹配拋 Mismatched api——這是為型別降級設的防線:外部程式碼傳 Model<"openai-responses"> 給 stream 函式,執行時 model.api 必須真的等於 "openai-responses",否則就是型別+執行時不一致。
Anthropic provider 的實作(anthropic.ts streamAnthropic:L447-L535) 是典型樣本:
export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOptions> = (
model: Model<"anthropic-messages">,
context: Context,
options?: AnthropicOptions,
) => {
const stream = new AssistantMessageEventStream();
void (async () => {
const output: AssistantMessage = {
role: "assistant",
content: [],
api: model.api as Api,
provider: model.provider,
model: model.id,
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
stopReason: "stop",
timestamp: Date.now(),
};
// Fable classifiers can refuse after partial generation, so no event is
// safe to expose until the terminal stop reason is known.
const refusalBuffer = usesClaudeFable5MessagesContract(model)
? createDeferredEventBuffer<AssistantMessageEvent>(stream, () =>
notifyLlmRequestActivity(options?.signal),
)
: undefined;
const eventSink = refusalBuffer ?? stream;
try {
let client: Anthropic;
// ... 建 client、buildParams、client.messages.create stream:true、解析事件注意 Fable 系列(Claude 5 系列裡某些 classifier 模型)有個特殊行為:可能在生成中途拒絕。所以 provider 不能「邊收邊吐」——必須緩衝到 stopReason 已知才暴露事件。createDeferredEventBuffer 就是這種緩衝器,其他模型直接走 stream。這是 provider 介接廠商特定行為的一個例子:Anthropic 這家有一個特殊角落,就內嵌在 provider 裡,不污染主循環。
OpenAI Responses provider 同構(src/llm/providers/openai-responses.ts:L76-L106),只是少了 Fable 緩衝邏輯——因為這是 Anthropic 特有的。
邊界與失敗
- 錯誤進 stream 不拋:
StreamFunction契約(packages/llm-core/src/types.ts:L196-L200) 明確規定「request/model/runtime failures should be encoded in the returned stream, not thrown」。provider 內部的 HTTP 錯、流截斷、message_stop 缺失都得編成stopReason: "error"的AssistantMessage。anthropic.ts在src/llm/providers/anthropic.ts:L442-L444顯式偵測if (sawMessageStart && !sawMessageEnd) throw,但這個 throw 在 async IIFE 裡被 catch 後轉成 stream error 事件。 - 懶載入失敗編碼進 stream:
createLazyStream的.catch把模組載入失敗轉成outer.push({ type: "error" })(src/llm/providers/register-builtins.ts:L172-L177)。這讓「provider SDK 沒裝」和「API 呼叫失敗」走同一條處理路徑,主循環無需區分。 - 型別/執行時不一致拋錯:
wrapStream檢查model.api !== api時拋Mismatched api(packages/llm-runtime/src/api-registry.ts:L52-L62)。這是為型別降級設的防線,發生在 stream 真正被呼叫之前。 - api 未登錄拋錯:
resolveApiProvider找不到 provider 拋No API provider registered for api: ...(packages/llm-runtime/src/stream.ts:L14-L20)。這是設定錯誤,不在 stream 契約覆蓋範圍內,主循環 catch 後該報錯給使用者。 - Fable 緩衝:
usesClaudeFable5MessagesContract(model)決定是否啟用createDeferredEventBuffer(src/llm/providers/anthropic.ts:L474-L479)。這是 Anthropic 特定的——其他 provider 沒有這種「中途拒絕」行為,直接走 stream。 - Cache retention 策略:
resolveCacheRetention()決定是否帶 cache session id(src/llm/providers/anthropic.ts:L500-L511)。"none"時顯式不傳cacheSessionId,避免誤啟用 prompt cache。 - GitHub Copilot 動態 headers:provider 內部還處理了
github-copilotprovider 的特殊 headers(buildCopilotDynamicHeaders),這是把 GitHub Copilot 的 Claude 當 Anthropic provider 用時的介接(src/llm/providers/anthropic.ts:L492-L498)——一個 provider 內部介接多個 provider id。 - 預聲明索引:
OPENCLAW_PROVIDER_INDEX(src/model-catalog/provider-index/openclaw-provider-index.ts:L13-L69) 讓模型選擇器在外掛未裝時也能看到 Moonshot/DeepSeek 等 provider 的預聲明資訊。外掛真裝上後,plugin manifest 才是權威。 - custom provider 不覆蓋內建:
ensureCustomApiRegistered先getApiProvider(api)檢查,已有就回傳 false(src/agents/custom-api-registry.ts:L17-L19)。這防止 SDK 登錄的 custom provider 誤覆蓋內建實作。
小結
provider 是介接不同廠商 API 的薄層:9 個內建 provider 覆蓋 Anthropic/OpenAI/Google/Mistral/Azure/Vertex/Codex,透過 registerApiProvider 登錄進 apiProviderRegistry Map。StreamFunction 契約規定錯誤進 stream 不拋,懶載入讓冷啟動只載入用到的 provider。Anthropic Fable 緩衝、Copilot 動態 headers 這些廠商特定角落被封裝在對應 provider 內部,不污染主循環。主循環透過 Agent 主循環 叫 stream(model, context, options),具體走哪家由 model.api 決定——設定層只需要在 openclaw.json 裡聲明 provider 的 apiKey/baseUrl/models。
對照官方資料:Providers 文件 · README。