LLM Providers: vendor adapters
Responsibilities
An LLM provider is a thin layer that adapts different vendor APIs into a unified streaming interface. Anthropic, OpenAI Responses, OpenAI Completions, Azure OpenAI, Google Gemini, Google Vertex, Mistral, OpenAI Codex (ChatGPT Responses) — each vendor's API has different request / response / streaming event formats, but OpenClaw's agent main loop wants one event stream: AssistantMessageEventStream (thinking / text / toolCall / toolResult / error / stop). The provider's job is to translate in the middle: parse the vendor's SSE / JSON stream into OpenClaw's unified event stream.
The provider doesn't "think"; it does four things: build the client (API key, OAuth, headers, cache strategy), construct request parameters (fold messages / tool definitions / options into the vendor format), parse streaming responses (translate the vendor's event sequence into unified events), and handle errors (encode HTTP errors, stream truncation, missing message_stop, etc. as stopReason: "error"). All providers implement the same StreamFunction contract (types.ts StreamFunction:L201-L208), so the main loop doesn't care which vendor it's calling.
Design motivation
Why abstract providers as StreamFunction? Because vendor differences are large but call semantics are isomorphic. Every provider takes (model, context, options) and returns AssistantMessageEventStream — "input/output isomorphic, internal implementations vary" is the ideal scenario for an interface abstraction. The main loop just calls stream(model, context, options); which vendor actually runs is decided by model.api.
The Api type (types.ts KnownApi:L6-L18) lists all built-in API ids:
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 & {});Note the trailing | (string & {}) — a TypeScript trick for "allow any string but keep autocompletion". It means Api isn't locked to the KnownApi set; third-party plugins can register their own provider with a custom api id (like "my-custom-llm"). registerApiProvider (api-registry.ts registerApiProvider:L77-L90) accepts any TApi extends Api and stores it in a Map<string, RegisteredApiProvider>.
The StreamFunction contract (packages/llm-core/src/types.ts:L193-L208) is explicit:
// 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;Key contract: errors don't throw — once a provider is invoked, subsequent failures (network errors, model refusals, stream truncation) must be encoded into the returned stream, ending with an AssistantMessage of stopReason: "error". This lets the main loop's try/catch focus on errors that happen "before the provider is called" (parameter errors, unregistered api); provider-internal errors all go through the stream path — unifying error-handling logic.
Lazy loading is another key design (register-builtins.ts lazy loading: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;
};
}The provider module is only imported on the first actual call — this avoids the cold-start cost of "force-load every provider at startup". If the user only calls Anthropic, OpenAI / Google / Mistral code never loads. Load failures (missing module, syntax errors) are also encoded as error events in the stream, not thrown — honoring the same contract.
registerBuiltInApiProviders (register-builtins.ts register all builtins:L342-L414) registers every built-in provider into apiProviderRegistry, each tagged with sourceId: BUILT_IN_API_PROVIDER_SOURCE_ID so unregisterApiProviders(sourceId) can clear them in one shot during test teardown.
Custom providers can also register: custom-api-registry.ts ensureCustomApiRegistered:L15-L36 gives SDK callers an entry point, but with a guard "if a provider exists, don't register" — to avoid clobbering built-in implementations.
Key files
types.ts KnownApi:L6-L18— 9 built-in API ids.types.ts StreamFunction:L193-L208— provider contract: errors go into the stream, not thrown.api-registry.ts types and registry:L13-L50—ApiProviderinterface and internal Map.api-registry.ts registerApiProvider:L77-L90— register / replace an API provider.api-registry.ts getApiProvider:L93-L100— look up by api id.stream.ts stream/complete:L22-L40— main entrystreamandcomplete, dispatching bymodel.api.register-builtins.ts lazy loading:L156-L181—createLazyStreamwrapper.register-builtins.ts export lazy streams:L316-L339— lazy stream exports for the 9 built-in providers.register-builtins.ts registerBuiltInApiProviders:L342-L414— registers into the registry.anthropic.ts streamAnthropic:L447-L535— Anthropic provider implementation.openai-responses.ts streamOpenAIResponses:L76-L106— OpenAI Responses provider.custom-api-registry.ts:L15-L36— SDK custom provider registration entry.openclaw-provider-index.ts:L13-L69— pre-declared provider index; the model picker can see it even without plugins installed.
Data flow
The main loop calls 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);
}Note "api not registered" throws here — it happens before the provider is called, so the main loop can catch it. Once inside provider.stream, all subsequent errors go through the stream.
The registry internally is a simple 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 validates that model.api matches; on mismatch it throws Mismatched api — this is a defense against type downgrading: external code passes Model<"openai-responses"> to the stream function; the runtime model.api must actually equal "openai-responses", otherwise type and runtime disagree.
The Anthropic provider implementation (anthropic.ts streamAnthropic:L447-L535) is a canonical sample:
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;
// ... build client, buildParams, client.messages.create stream:true, parse eventsNote that Fable-series (certain classifier models in the Claude 5 series) have a special behavior: they may refuse mid-generation. So the provider can't "emit while receiving" — it must buffer until the stopReason is known before exposing events. createDeferredEventBuffer is that buffer; other models go straight to stream. This is an example of a provider adapting vendor-specific behavior: Anthropic has one special corner, embedded inside the provider, not polluting the main loop.
The OpenAI Responses provider is symmetric (src/llm/providers/openai-responses.ts:L76-L106), just without the Fable buffering logic — that's Anthropic-specific.
Boundaries and failures
- errors go into the stream, not thrown: the
StreamFunctioncontract (packages/llm-core/src/types.ts:L196-L200) explicitly says "request/model/runtime failures should be encoded in the returned stream, not thrown". HTTP errors, stream truncation, missing message_stop inside a provider all get encoded as anAssistantMessagewithstopReason: "error".anthropic.tsexplicitly checksif (sawMessageStart && !sawMessageEnd) throwatsrc/llm/providers/anthropic.ts:L442-L444, but this throw is caught inside an async IIFE and turned into a stream error event. - lazy-load failure encoded into the stream:
createLazyStream's.catchturns module-load failure intoouter.push({ type: "error" })(src/llm/providers/register-builtins.ts:L172-L177). This lets "provider SDK not installed" and "API call failed" go through the same path, so the main loop doesn't have to distinguish them. - type/runtime mismatch throws:
wrapStreamchecksmodel.api !== apiand throwsMismatched api(packages/llm-runtime/src/api-registry.ts:L52-L62). This is a defense against type downgrading, happening before the stream is actually called. - api not registered throws:
resolveApiProviderthrowsNo API provider registered for api: ...when no provider is found (packages/llm-runtime/src/stream.ts:L14-L20). This is a config error, outside the stream contract; the main loop catches it and should report it to the user. - Fable buffering:
usesClaudeFable5MessagesContract(model)decides whether to enablecreateDeferredEventBuffer(src/llm/providers/anthropic.ts:L474-L479). This is Anthropic-specific — other providers don't have this "mid-stream refusal" behavior and go straight to the stream. - Cache retention policy:
resolveCacheRetention()decides whether to include a cache session id (src/llm/providers/anthropic.ts:L500-L511). When"none", it explicitly omitscacheSessionIdto avoid accidentally enabling prompt cache. - GitHub Copilot dynamic headers: the provider also handles special headers for the
github-copilotprovider (buildCopilotDynamicHeaders), an adaptation for using GitHub Copilot's Claude as an Anthropic provider (src/llm/providers/anthropic.ts:L492-L498) — one provider internally adapting multiple provider ids. - pre-declared index:
OPENCLAW_PROVIDER_INDEX(src/model-catalog/provider-index/openclaw-provider-index.ts:L13-L69) lets the model picker see pre-declared info for providers like Moonshot / DeepSeek even when plugins aren't installed. Once the plugin is actually installed, the plugin manifest is authoritative. - custom provider doesn't override built-ins:
ensureCustomApiRegisteredfirst checksgetApiProvider(api)and returns false if one already exists (src/agents/custom-api-registry.ts:L17-L19). This prevents SDK-registered custom providers from accidentally clobbering built-in implementations.
Summary
A provider is a thin layer adapting different vendor APIs: 9 built-in providers cover Anthropic / OpenAI / Google / Mistral / Azure / Vertex / Codex, registered into the apiProviderRegistry Map via registerApiProvider. The StreamFunction contract mandates that errors go into the stream rather than throwing; lazy loading means cold start only loads providers actually used. Vendor-specific corners like Anthropic Fable buffering and Copilot dynamic headers are encapsulated inside their respective providers, not polluting the main loop. The main loop calls stream(model, context, options) via Agent main loop; which vendor actually runs is decided by model.api — the config layer only needs to declare the provider's apiKey / baseUrl / models[] in openclaw.json.
Official references: Providers docs · README.