Skip to content

LLM-Provider: Hersteller-Anpassung ​

源码版本v2026.6.11

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:

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 & {});

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:

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" 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):

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;
  };
}

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 ​

Datenfluss ​

Die Hauptschleife ruft stream auf (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);
}

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):

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,
  });
}

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:

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;
      // ... Client erstellen, buildParams, client.messages.create stream:true, Ereignisse parsen

Beachten 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 als AssistantMessage mit stopReason: "error" kodiert werden. anthropic.ts prüft in src/llm/providers/anthropic.ts:L442-L444 explizit if (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 .catch in createLazyStream wandelt Modullade-Fehler in outer.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: wrapStream prüft bei model.api !== api und wirft Mismatched 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: resolveApiProvider wirft bei fehlendem Provider No 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, ob createDeferredEventBuffer (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" wird cacheSessionId explizit 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: ensureCustomApiRegistered prüft zuerst getApiProvider(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.