Normalize 12+ provider SSE formats into a single chunk protocol for the agent runtime.
Agents/Providers — Adapter Pattern
Unified streaming interface across 12+ LLM providers
The provider adapter architecture wraps every supported LLM provider behind a common streaming interface. Each provider ships a dedicated stream-wrapper file that normalizes vendor-specific SSE formats, manages auth quirks, and emits a uniform chunk protocol consumed by the rest of OpenClaw.
Multi-Provider Adapter Pattern Schema Sanitization IAM Auth
📚 What is this & why does it matter?
Every LLM provider speaks a slightly different dialect of server-sent events, uses different auth mechanisms, and returns data in different shapes. The adapter pattern ensures the rest of the codebase never has to care about these differences — it always receives the same normalized stream chunks regardless of which provider is active.
💡 Real-world analogy
Think of an international power adapter kit. Every country has a different plug shape and voltage (providers), but your laptop charger (the core agent) only has one cable. The adapter (stream wrapper) converts the local electrical format into the standard your device expects. Swap countries, swap adapters — the charger never changes. Similarly, adding a new LLM provider means writing one new wrapper file without touching any core logic.
Contents
Overview
Raw SSE events from provider HTTP streams, model configuration from model.ts, extra params from extra-params.ts.
Which wrapper to instantiate (based on provider ID), whether to sanitize schemas (Google), and whether to inject cache-retention headers (Anthropic).
Normalized stream chunks fed into attempt.ts, usage metadata accumulated in UsageAccumulator.
flowchart TD
subgraph Registry["Model Registry"]
MODEL["model.ts (24 KB)\nModel registry & resolution"]
NORM["model.provider-normalization.ts\nProvider ID normalization"]
EXTRA["extra-params.ts\nProvider-specific params"]
end
subgraph Selection["Provider Selection"]
RESOLVE["resolveProvider(modelId)"]
NORM --> RESOLVE
MODEL --> RESOLVE
EXTRA --> RESOLVE
end
subgraph Wrappers["Stream Wrappers"]
OAI["openai-stream-wrappers.ts"]
GOO["google-stream-wrappers.ts"]
ANT["anthropic-cache-retention.ts"]
BED["bedrock-stream-wrappers.ts"]
MOON["moonshot-stream-wrappers.ts"]
MINI["minimax-stream-wrappers.ts"]
PROXY["proxy-stream-wrappers.ts"]
ZAI["zai-stream-wrappers.ts"]
end
subgraph Output["Normalized Output"]
CHUNKS["Uniform chunk protocol"]
USAGE["UsageAccumulator"]
end
RESOLVE --> OAI
RESOLVE --> GOO
RESOLVE --> ANT
RESOLVE --> BED
RESOLVE --> MOON
RESOLVE --> MINI
RESOLVE --> PROXY
RESOLVE --> ZAI
OAI --> CHUNKS
GOO --> CHUNKS
ANT --> CHUNKS
BED --> CHUNKS
MOON --> CHUNKS
MINI --> CHUNKS
PROXY --> CHUNKS
ZAI --> CHUNKS
CHUNKS --> USAGE
The flow begins in model.ts where the model registry resolves a model identifier to a concrete provider. Provider ID normalization in model.provider-normalization.ts handles special cases such as Google and XAI model prefixes. Once the provider is identified, the corresponding stream wrapper is instantiated with any extra parameters from extra-params.ts.
Decision Points
| Decision | What controls it | Why it matters |
|---|---|---|
| Which wrapper to use | Resolved provider ID from model.provider-normalization.ts | Each provider has unique SSE format, auth, and capability requirements |
| Schema sanitization | GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS constant | Google Gemini rejects certain JSON schema keywords that other providers accept |
| Cache-retention headers | anthropic-cache-retention.ts config | Anthropic requires explicit headers to keep prompt caches alive across requests |
| IAM auth injection | Bedrock provider detection | AWS Bedrock uses SigV4 signing instead of bearer tokens |
| Tool payload compatibility | anthropic-family-tool-payload-compat.ts | Anthropic tool call format differs from OpenAI-compatible providers |
| Capability flag resolution | OpenRouter provider detection | OpenRouter proxies multiple providers; capability flags determine available features |
| Extra parameters injection | extra-params.ts per-provider map | Some providers require additional request body fields not in the common interface |
How It Works
The adapter pattern in OpenClaw follows a three-phase approach: resolve the target provider, wrap the provider-specific HTTP stream, and normalize the output into uniform chunks. Each wrapper file implements the same contract while encapsulating all provider-specific logic.
Phase 1: Model Resolution
The model registry in model.ts (24 KB) maps model identifiers to provider metadata. When a model string arrives, model.provider-normalization.ts normalizes provider prefixes — for example, Google models may arrive as google/gemini-pro or gemini-pro, and XAI models may use vendor-specific prefixes that need canonicalization.
// model.provider-normalization.ts — simplified provider resolution
export function normalizeProviderId(rawModel: string): ProviderInfo {
const lower = rawModel.toLowerCase();
// Google special cases: "google/gemini-*" or bare "gemini-*"
if (lower.startsWith("google/") || lower.startsWith("gemini-")) {
return { provider: "google", modelId: lower.replace("google/", "") };
}
// XAI special cases: "xai/grok-*" or "grok-*"
if (lower.startsWith("xai/") || lower.startsWith("grok-")) {
return { provider: "xai", modelId: lower.replace("xai/", "") };
}
// Default: split on first "/" — "anthropic/claude-3" → provider="anthropic"
const slashIdx = lower.indexOf("/");
if (slashIdx > 0) {
return { provider: lower.slice(0, slashIdx), modelId: lower.slice(slashIdx + 1) };
}
// Bare model name: look up in registry
return resolveFromRegistry(rawModel);
}
Phase 2: Stream Wrapping
Once the provider is resolved, the runtime instantiates the corresponding stream wrapper. Each wrapper implements the same streaming contract but encapsulates provider-specific concerns:
| Wrapper File | Provider | Special Handling |
|---|---|---|
openai-stream-wrappers.ts | OpenAI / compatible | Standard SSE format, tool call delta assembly |
google-stream-wrappers.ts | Google Gemini | Schema sanitization via cleanForGemini(), custom chunk format |
anthropic-cache-retention.ts | Anthropic Claude | Cache-retention headers, prompt caching lifecycle |
bedrock-stream-wrappers.ts | AWS Bedrock | IAM SigV4 auth, event stream binary framing |
moonshot-stream-wrappers.ts | Moonshot | Custom thinking/reasoning stream splits |
minimax-stream-wrappers.ts | Minimax | Provider-specific token counting |
proxy-stream-wrappers.ts | Custom proxy | Pass-through for user-hosted endpoints |
zai-stream-wrappers.ts | Z AI | Custom SSE event names and payload shapes |
cleanForGemini() function strips JSON schema keywords listed in GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS. If a tool definition contains additionalProperties, $ref, or oneOf, these are removed before sending the request to Gemini. Failing to sanitize results in 400 errors from the Gemini API.
Phase 3: Output Normalization
Every wrapper transforms raw provider events into the same chunk protocol. The normalization ensures that downstream code in attempt.ts and payloads.ts never needs provider-conditional logic.
// Simplified normalized chunk interface
interface NormalizedChunk {
type: "text" | "tool_call" | "thinking" | "usage" | "done";
content?: string;
toolCall?: {
id: string;
name: string;
arguments: string; // always JSON string
};
thinking?: string;
usage?: { inputTokens: number; outputTokens: number };
}
// Each wrapper implements this contract:
type StreamWrapper = (
response: Response,
onChunk: (chunk: NormalizedChunk) => void,
signal: AbortSignal,
) => Promise<void>;
Provider Wrapper Class Diagram
classDiagram
class StreamWrapper {
<<interface>>
+wrap(response, onChunk, signal) Promise~void~
}
class OpenAIWrapper {
-parseSSELine(line) NormalizedChunk
-assembleToolCallDelta(delta) void
+wrap(response, onChunk, signal) Promise~void~
}
class GoogleWrapper {
-cleanForGemini(schema) object
-GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS string[]
-parseGeminiChunk(data) NormalizedChunk
+wrap(response, onChunk, signal) Promise~void~
}
class AnthropicWrapper {
-injectCacheRetention(headers) Headers
-parseCacheStatus(event) CacheInfo
+wrap(response, onChunk, signal) Promise~void~
}
class BedrockWrapper {
-signWithSigV4(request) SignedRequest
-decodeEventStream(buffer) Event[]
+wrap(response, onChunk, signal) Promise~void~
}
class MoonshotWrapper {
-splitThinkingStream(chunk) NormalizedChunk[]
+wrap(response, onChunk, signal) Promise~void~
}
class MinimaxWrapper {
+wrap(response, onChunk, signal) Promise~void~
}
class ProxyWrapper {
+wrap(response, onChunk, signal) Promise~void~
}
class ZAIWrapper {
-mapEventName(raw) string
+wrap(response, onChunk, signal) Promise~void~
}
StreamWrapper <|.. OpenAIWrapper
StreamWrapper <|.. GoogleWrapper
StreamWrapper <|.. AnthropicWrapper
StreamWrapper <|.. BedrockWrapper
StreamWrapper <|.. MoonshotWrapper
StreamWrapper <|.. MinimaxWrapper
StreamWrapper <|.. ProxyWrapper
StreamWrapper <|.. ZAIWrapper
All eight wrapper classes implement the same StreamWrapper interface. The class diagram above shows the private helper methods unique to each provider and the shared public wrap() contract that the runtime depends on.
StreamWrapper interface in a new file and registering it in the provider resolution map. No changes to attempt.ts, payloads.ts, or any consumer code.
Annotated Code Walkthrough
Walk through how an OpenAI-compatible stream wrapper normalizes SSE events into the common chunk protocol.
The wrapper receives the raw HTTP response and begins reading the SSE event stream line by line:
// openai-stream-wrappers.ts — entry point
export async function wrapOpenAIStream(
response: Response,
onChunk: (chunk: NormalizedChunk) => void,
signal: AbortSignal,
): Promise<void> {
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
processSSELine(line, onChunk);
}
}
onChunk({ type: "done" });
}
Each SSE line is parsed. The data: prefix is stripped and the JSON payload is transformed into a NormalizedChunk:
function processSSELine(
line: string,
onChunk: (chunk: NormalizedChunk) => void,
): void {
if (!line.startsWith("data: ")) return;
const payload = line.slice(6);
if (payload === "[DONE]") return;
const event = JSON.parse(payload);
const delta = event.choices?.[0]?.delta;
if (!delta) return;
// Text content
if (delta.content) {
onChunk({ type: "text", content: delta.content });
}
// Tool call accumulation
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
onChunk({
type: "tool_call",
toolCall: {
id: tc.id ?? currentToolCallId,
name: tc.function?.name ?? currentToolCallName,
arguments: tc.function?.arguments ?? "",
},
});
}
}
// Usage metadata (final chunk)
if (event.usage) {
onChunk({
type: "usage",
usage: {
inputTokens: event.usage.prompt_tokens,
outputTokens: event.usage.completion_tokens,
},
});
}
}
currentToolCallArgs and only emits the full tool call once the argument string is complete. This is why attempt.tool-call-argument-repair.ts exists — to fix malformed JSON from incomplete deltas.
For Anthropic, the wrapper injects cache-retention headers before initiating the stream:
// anthropic-cache-retention.ts — header injection
function buildAnthropicHeaders(baseHeaders: Headers, cacheConfig: CacheConfig): Headers {
const headers = new Headers(baseHeaders);
if (cacheConfig.enabled) {
headers.set("anthropic-beta", "prompt-caching-2024-07-31");
headers.set("anthropic-cache-control", JSON.stringify({
type: "ephemeral",
ttl: cacheConfig.ttlSeconds ?? 300,
}));
}
return headers;
}
Anthropic Tool Call Compatibility
The anthropic-family-tool-payload-compat.ts module bridges the gap between OpenAI-style and Anthropic-style tool call formats. Anthropic expects tool inputs as a top-level input object rather than a function.arguments JSON string:
// anthropic-family-tool-payload-compat.ts — format bridge
export function toAnthropicToolUse(openaiToolCall: OpenAIToolCall): AnthropicToolUse {
return {
type: "tool_use",
id: openaiToolCall.id,
name: openaiToolCall.function.name,
input: JSON.parse(openaiToolCall.function.arguments),
};
}
export function fromAnthropicToolResult(result: AnthropicToolResult): OpenAIToolMessage {
return {
role: "tool",
tool_call_id: result.tool_use_id,
content: typeof result.content === "string"
? result.content
: JSON.stringify(result.content),
};
}
function.arguments contains malformed JSON (from incomplete streaming deltas), toAnthropicToolUse will throw. This is why the attempt.tool-call-argument-repair.ts module runs before compatibility conversion.
Edge Cases & Error Handling
| Scenario | Behavior | Code Location |
|---|---|---|
Google schema contains $ref | cleanForGemini() strips unsupported keywords recursively | google-stream-wrappers.ts |
| Anthropic cache miss | Falls back to full prompt send; no error, just higher token cost | anthropic-cache-retention.ts |
| Bedrock IAM token expired | Wrapper returns auth error; triggers cooldown in auth-profiles.ts | bedrock-stream-wrappers.ts |
| OpenRouter model lacks tool support | Capability flags disable tool calls; agent falls back to text-only | openrouter-model-capabilities.ts |
Malformed SSE line (no data: prefix) | Line is silently skipped by all wrappers | All *-stream-wrappers.ts |
| Connection reset mid-stream | Reader throws; wrapper emits error chunk for fallback handling | All wrappers |
| Proxy endpoint returns non-SSE | proxy-stream-wrappers.ts detects content-type mismatch and throws | proxy-stream-wrappers.ts |
Configuration
| Knob / Parameter | Default | Description |
|---|---|---|
GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS | ["$ref", "oneOf", "anyOf", "additionalProperties"] | JSON schema keywords stripped before sending to Google Gemini |
extra-params.ts per-provider map | Varies | Provider-specific request body extensions (e.g., top_k for Anthropic) |
| Anthropic cache TTL | 300 (seconds) | Time-to-live for prompt cache entries via cache-retention headers |
| Bedrock region | us-east-1 | AWS region for SigV4 signing in Bedrock requests |
| OpenRouter capability flags | Auto-detected | Per-model capability bitmask resolved from OpenRouter’s model metadata API |
| Proxy endpoint URL | User-configured | Base URL for custom proxy pass-through endpoint |
Related Topics
See also:
- Providers overview — the parent sub-module page
- Token Management — API key rotation and auth profile health
- Model Selection — model resolution, fallback chains, and context window sizing
- Streaming pipeline — how normalized chunks flow through the agent runtime
Directory Map
Source files for the adapter pattern
pi-embedded-runner/
├── model.ts ← Model registry & resolution (24 KB)
├── model.provider-normalization.ts ← Provider ID normalization
├── extra-params.ts ← Per-provider extra request params
├── openai-stream-wrappers.ts ← OpenAI SSE adapter
├── google-stream-wrappers.ts ← Google/Gemini adapter + cleanForGemini
├── anthropic-cache-retention.ts ← Anthropic cache-retention headers
├── anthropic-family-tool-payload-compat.ts ← Anthropic tool call format bridge
├── bedrock-stream-wrappers.ts ← AWS Bedrock + IAM SigV4 adapter
├── moonshot-stream-wrappers.ts ← Moonshot adapter
├── minimax-stream-wrappers.ts ← Minimax adapter
├── proxy-stream-wrappers.ts ← Generic proxy pass-through
└── zai-stream-wrappers.ts ← Z AI adapter