LLM-Provider: Hersteller-Anpassung
Verantwortung
Ein LLM-Provider ist eine dünne Schicht, die verschiedene Hersteller-APIs an eine einheitliche Streaming-Schnittstelle anpasst. Anthropic, OpenAI Responses, OpenAI Completions, Azure OpenAI, Google Gemini, Google Vertex, Mistral, OpenAI Codex (ChatGPT Responses) — jede API hat eigene Request-/Response-/Streaming-Ereignisformate, aber die Agent-Hauptschleife von OpenClaw will nur einen Ereignisstrom: AssistantMessageEventStream (thinking / text / toolCall / toolResult / error / stop). Der Provider übersetzt dazwischen: Er parst den SSE/JSON-Strom der Modell-API in OpenClaws einheitlichen Ereignisstrom.
Der Provider „denkt" nicht, er macht nur vier Dinge: Client erstellen (API-Schlüssel, OAuth, Header, Cache-Strategie), Request-Parameter konstruieren (messages/tool definitions/options ins Hersteller-Format falten), Streaming-Response parsen (Ereignissequenz des Herstellers in einheitliche Ereignisse übersetzen), Fehler behandeln (HTTP-Fehler, Stream-Abbruch, fehlendes message_stop werden als stopReason: "error" kodiert). Alle Provider implementieren denselben StreamFunction-Vertrag (types.ts StreamFunction:L201-L208); die Hauptschleife muss nicht wissen, welcher Hersteller gerade aufgerufen wird.
Designmotivation
Warum Provider als StreamFunction abstrahieren? Weil die Herstellerunterschiede groß, die Aufrufsemantik jedoch isomorph ist. Jeder Provider nimmt (model, context, options) und liefert AssistantMessageEventStream zurück — „Ein-/Ausgabe isomorph, interne Implementierung verschieden" ist das ideale Szenario für Schnittstellenabstraktion. Die Hauptschleife ruft nur stream(model, context, options) auf; welcher Hersteller genutzt wird, entscheidet das Feld model.api.
Der Typ Api (types.ts KnownApi:L6-L18) listet alle eingebauten 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 & {});Beachten Sie am Ende | (string & {}) — ein TypeScript-Trick „beliebige Zeichenkette zulassen, aber Autovervollständigung bewahren". Api ist nicht auf die KnownApi-Menge beschränkt; Drittanbieter-Plugins können eigene Provider mit eigenen API-IDs registrieren (z. B. "my-custom-llm"). registerApiProvider (api-registry.ts registerApiProvider:L77-L90) akzeptiert beliebiges TApi extends Api und speichert es in einer Map<string, RegisteredApiProvider>.
Der StreamFunction-Vertrag (packages/llm-core/src/types.ts:L193-L208) ist explizit:
// 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" oder "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;Schlüsselvertrag: Fehler werden nicht geworfen — sobald ein Provider aufgerufen wurde, müssen nachfolgende Fehler (Netzwerk, Modellverweigerung, Stream-Abbruch) im zurückgegebenen Stream kodiert werden und mit einer AssistantMessage mit stopReason: "error" enden. Dieser Vertrag erlaubt der Hauptschleife, sich im try/catch auf Fehler „vor dem Provider-Aufruf" (Parameterfehler, API nicht registriert) zu konzentrieren; interne Provider-Fehler laufen einheitlich über den Stream-Pfad.
Lazy-Laden ist ein weiteres wichtiges Design (register-builtins.ts Lazy-Laden: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;
};
}Das Provider-Modul wird erst beim ersten tatsächlichen Aufruf importiert — das vermeidet die Kaltstartkosten „beim Start alle Provider laden". Ruft der Nutzer nur Anthropic auf, wird der Code von OpenAI/Google/Mistral gar nicht geladen. Lade-Fehler (fehlendes Modul, Syntaxfehler) werden als error-Ereignis im Stream kodiert, nicht geworfen — derselbe Vertrag.
registerBuiltInApiProviders (register-builtins.ts registriert alle eingebauten:L342-L414) registriert alle eingebauten Provider in der apiProviderRegistry, jeweils mit sourceId: BUILT_IN_API_PROVIDER_SOURCE_ID, damit unregisterApiProviders(sourceId) beim Test-Teardown alles mit einem Aufruf bereinigt.
Custom Provider können ebenfalls registriert werden: custom-api-registry.ts ensureCustomApiRegistered:L15-L36 bietet SDK-Aufrufern einen Weg, hat aber eine „bereits vorhandener Provider wird nicht überschrieben"-Wächter — verhindert das versehentliche Überschreiben der eingebauten Implementierung.
Schlüsseldateien
types.ts KnownApi:L6-L18— 9 eingebaute API-IDs.types.ts StreamFunction:L193-L208— Provider-Vertrag: Fehler in Stream, nicht werfen.api-registry.ts Typen und Registry:L13-L50—ApiProvider-Schnittstelle und interne Map.api-registry.ts registerApiProvider:L77-L90— API-Provider registrieren/ersetzen.api-registry.ts getApiProvider:L93-L100— Lookup nach API-ID.stream.ts stream/complete:L22-L40— Haupt-Eintrittstreamundcomplete, Dispatch nachmodel.api.register-builtins.ts Lazy-Laden:L156-L181—createLazyStream-Wrapper.register-builtins.ts exportiert lazy stream:L316-L339— Lazy-Stream-Exporte der 9 eingebauten Provider.register-builtins.ts registerBuiltInApiProviders:L342-L414— In Registry registrieren.anthropic.ts streamAnthropic:L447-L535— Anthropic-Provider-Implementierung.openai-responses.ts streamOpenAIResponses:L76-L106— OpenAI-Responses-Provider.custom-api-registry.ts:L15-L36— SDK-Custom-Provider-Registrierung.openclaw-provider-index.ts:L13-L69— Vordeklarierter Provider-Index, sichtbar auch ohne installierte Plugins.
Datenfluss
Die Hauptschleife ruft stream auf (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);
}Beachten Sie: „API nicht registriert" wirft hier — das passiert vor dem Provider-Aufruf, die Hauptschleife kann es abfangen. Sobald provider.stream betreten wird, laufen alle Fehler über den Stream.
Die Registry-intern ist eine einfache 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 prüft, ob model.api passt; wenn nicht, wirft es Mismatched api — das ist eine Verteidigungslinie für Typ-Degradierung: Übergibt externer Code Model<"openai-responses"> an die Stream-Funktion, muss model.api zur Laufzeit tatsächlich "openai-responses" sein, sonst liegen Typ und Runtime nicht konsistent vor.
Die Anthropic-Provider-Implementierung (anthropic.ts streamAnthropic:L447-L535) ist ein typisches Beispiel:
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 erstellen, buildParams, client.messages.create stream:true, Ereignisse parsenBeachten Sie: Die Fable-Serie (bestimmte Classifier-Modelle der Claude-5-Serie) hat ein spezielles Verhalten — kann die Generierung mitten in der Ausführung verweigern. Deshalb kann der Provider nicht „während des Empfangs streamen" — er muss puffern, bis die stopReason bekannt ist, bevor Ereignisse exponiert werden. createDeferredEventBuffer ist dieser Puffer; bei anderen Modellen geht der Stream direkt durch. Das ist ein Beispiel, wie der Provider herstellerspezifisches Verhalten kapselt: Anthropic hat hier eine Ecke, die im Provider steckt und die Hauptschleife nicht verschmutzt.
Der OpenAI-Responses-Provider ist strukturell analog (src/llm/providers/openai-responses.ts:L76-L106), nur ohne Fable-Pufferlogik — da diese Anthropic-spezifisch ist.
Grenzen und Fehler
- Fehler in Stream, nicht werfen: Der
StreamFunction-Vertrag (packages/llm-core/src/types.ts:L196-L200) schreibt vor „request/model/runtime failures should be encoded in the returned stream, not thrown". HTTP-Fehler, Stream-Abbrüche, fehlendes message_stop müssen alsAssistantMessagemitstopReason: "error"kodiert werden.anthropic.tsprüft insrc/llm/providers/anthropic.ts:L442-L444explizitif (sawMessageStart && !sawMessageEnd) throw, dieser Throw wird jedoch in der async IIFE gefangen und in ein Stream-Error-Ereignis gewandelt. - Lazy-Laden-Fehler in Stream kodiert: Der
.catchincreateLazyStreamwandelt Modullade-Fehler inouter.push({ type: "error" })(src/llm/providers/register-builtins.ts:L172-L177) um. „Provider-SDK nicht installiert" und „API-Aufruf fehlgeschlagen" laufen über denselben Pfad; die Hauptschleife muss nicht unterscheiden. - Typ-/Runtime-Inkonsistenz wirft:
wrapStreamprüft beimodel.api !== apiund wirftMismatched api(packages/llm-runtime/src/api-registry.ts:L52-L62). Verteidigungslinie für Typ-Degradierung, passiert vor dem tatsächlichen Stream-Aufruf. - API nicht registriert wirft:
resolveApiProviderwirft bei fehlendem ProviderNo API provider registered for api: ...(packages/llm-runtime/src/stream.ts:L14-L20). Konfigurationsfehler, nicht vom Stream-Vertrag abgedeckt; die Hauptschleife fängt es und meldet es dem Nutzer. - Fable-Puffer:
usesClaudeFable5MessagesContract(model)entscheidet, obcreateDeferredEventBuffer(src/llm/providers/anthropic.ts:L474-L479) aktiviert wird. Anthropic-spezifisch — andere Provider haben dieses „mitten in der Generierung verweigern"-Verhalten nicht und streamen direkt. - Cache-Retention-Strategie:
resolveCacheRetention()entscheidet, ob eine cache session id mitgegeben wird (src/llm/providers/anthropic.ts:L500-L511). Bei"none"wirdcacheSessionIdexplizit nicht gesendet, um versehentlichen Prompt-Cache zu vermeiden. - GitHub Copilot dynamische Header: Der Provider behandelt intern auch die speziellen Header des
github-copilot-Providers (buildCopilotDynamicHeaders) — das ist die Anpassung, wenn Claude von GitHub Copilot als Anthropic-Provider genutzt wird (src/llm/providers/anthropic.ts:L492-L498) — ein Provider, der intern mehrere Provider-IDs anpasst. - Vordeklarierter Index:
OPENCLAW_PROVIDER_INDEX(src/model-catalog/provider-index/openclaw-provider-index.ts:L13-L69) lässt die Modellauswahl auch ohne installierte Plugins die Vordeklaration von Moonshot/DeepSeek sehen. Ist das Plugin tatsächlich installiert, ist das Plugin-Manifest die Autorität. - Custom-Provider überschreibt nicht eingebaut:
ensureCustomApiRegisteredprüft zuerstgetApiProvider(api); existiert bereits, liefert es false (src/agents/custom-api-registry.ts:L17-L19) zurück. Verhindert, dass ein SDK-registrierter Custom-Provider versehentlich die eingebaute Implementierung überschreibt.
Zusammenfassung
Provider ist eine dünne Schicht, die verschiedene Hersteller-APIs anpasst: 9 eingebaute Provider decken Anthropic/OpenAI/Google/Mistral/Azure/Vertex/Codex ab und werden über registerApiProvider in die apiProviderRegistry-Map eingetragen. Der StreamFunction-Vertrag schreibt „Fehler in Stream, nicht werfen" vor; Lazy-Laden lässt den Kaltstart nur die tatsächlich genutzten Provider laden. Anthropic-Fable-Puffer, Copilot-dynamische Header — diese herstellerspezifischen Ecken sind im jeweiligen Provider gekapselt und verschmutzen die Hauptschleife nicht. Die Hauptschleife ruft über Agent-Hauptschleife stream(model, context, options) auf; welcher Hersteller genutzt wird, entscheidet model.api — die Konfigurationsschicht muss nur in openclaw.json apiKey/baseUrl/models des Providers deklarieren.
Vergleich mit offiziellen Ressourcen: Providers-Doku · README.