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 メインループはただ 1 種のイベントストリームを欲します:AssistantMessageEventStream(thinking / text / toolCall / toolResult / error / stop)。provider の仕事は中間で翻訳すること:モデル API の SSE/JSON ストリームを OpenClaw の統一イベントストリームに解析します。

provider は「思考」せず,4 件事だけを行います:クライアント構築(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 を付けます。テスト teardown 時に unregisterApiProviders(sourceId) で一括空にできます。

カスタム 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,
  });
}

wrapStreammodel.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;
      // ... クライアント構築、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.tssrc/llm/providers/anthropic.ts:L442-L444if (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 呼び出し失敗」が同じ処理パスを走り,メインループは区別不要です。
  • 型/ランタイム不一致はスロー:wrapStreammodel.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)——1 つの provider が内部で複数 provider id に適合。
  • 事前宣言索引:OPENCLAW_PROVIDER_INDEX(src/model-catalog/provider-index/openclaw-provider-index.ts:L13-L69) はモデルセレクタがプラグイン未インストール時にも Moonshot/DeepSeek などの provider の事前宣言情報を見えるようにします。プラグインが本当にインストールされた後,plugin manifest が権威になります。
  • カスタム provider はビルトインを上書きしない:ensureCustomApiRegistered はまず getApiProvider(api) でチェックし,既にあれば false を返します(src/agents/custom-api-registry.ts:L17-L19)。SDK 登録のカスタム provider がビルトイン実装を誤って上書きするのを防ぎます。

まとめ

provider は異なるベンダー API を適合させる薄い層です:9 個のビルトイン provider が Anthropic/OpenAI/Google/Mistral/Azure/Vertex/Codex をカバーし,registerApiProviderapiProviderRegistry Map に登録します。StreamFunction 契約はエラーを stream に入れスローしないと規定し,遅延読み込みでコールドスタート時に使う provider だけを読み込みます。Anthropic Fable バッファ、Copilot 動的 headers といったベンダー固有の角落は対応 provider 内部にカプセル化され,メインループを汚染しません。メインループは Agent メインループstream(model, context, options) を呼び,具体的にどのベンダーを走らせるかは model.api が決定します——設定層は openclaw.json で provider の apiKey/baseUrl/models を宣言するだけです。

公式資料:Providers ドキュメント · README