Skip to content

LLM Providers:廠商介接

源码版本v2026.6.11

職責

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:

typescript
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) 寫得很清楚:

typescript
// 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):

typescript
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 就不登錄」的守護——避免覆蓋內建實作。

關鍵檔案

資料流

主循環叫 stream(stream.ts stream:L22-L30):

typescript
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):

typescript
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) 是典型樣本:

typescript
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"AssistantMessageanthropic.tssrc/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-copilot provider 的特殊 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 不覆蓋內建:ensureCustomApiRegisteredgetApiProvider(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