LLM Providers:ベンダー適合
責務
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 を列挙します:
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 を付けます。テスト teardown 時に unregisterApiProviders(sourceId) で一括空にできます。
カスタム 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;
// ... クライアント構築、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)——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 をカバーし,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。