Skip to content

LLM Providers: adaptation vendeur

源码版本v2026.6.11

Responsabilités

Un LLM provider est la couche fine qui adapte les API de différents vendeurs en une interface de streaming unifiée. Anthropic, OpenAI Responses, OpenAI Completions, Azure OpenAI, Google Gemini, Google Vertex, Mistral, OpenAI Codex (ChatGPT Responses) — chaque API a un format de requête/réponse/événement de streaming différent, mais la boucle principale d'OpenClaw ne veut qu'un seul flux d'événements: AssistantMessageEventStream (thinking / text / toolCall / toolResult / error / stop). Le provider fait la traduction au milieu: parser le flux SSE/JSON du modèle en l'événement unifié OpenClaw.

Le provider ne « pense » pas; il fait quatre choses: construire le client (API key, OAuth, headers, stratégie de cache), construire les params de requête (messages/tool definitions/options repliés en format vendeur), parser la réponse streamée (traduire la séquence d'événements vendeur en événements unifiés), traiter les erreurs (HTTP error, stream tronqué, message_stop manquant sont tous encodés en stopReason: "error"). Tous les providers implémentent le même contrat StreamFunction (types.ts StreamFunction:L201-L208), donc la boucle principale n'a pas à savoir qui elle appelle.

Motivation de conception

Pourquoi abstraire le provider en StreamFunction? Parce que les vendeurs diffèrent mais la sémantique d'appel est isomorphe. Chaque provider accepte (model, context, options) et retourne AssistantMessageEventStream — « input/output isomorphe, implémentation interne variable » est le scénario idéal pour une abstraction d'interface. La boucle principale appelle stream(model, context, options), et le choix du vendeur est décidé par le champ model.api.

Le type Api (types.ts KnownApi:L6-L18) liste tous les ids d'API builtin:

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

Notez le | (string & {}) final — une astuce TypeScript pour « autoriser n'importe quelle chaîne tout en préservant l'auto-complétion ». Cela signifie que Api n'est pas enfermé dans l'ensemble KnownApi: un plugin tiers peut enregistrer son propre provider avec un id api personnalisé (par exemple "my-custom-llm"). registerApiProvider (api-registry.ts registerApiProvider:L77-L90) accepte n'importe quel TApi extends Api et l'inscrit dans une Map<string, RegisteredApiProvider>.

Le contrat StreamFunction (packages/llm-core/src/types.ts:L193-L208) est clair:

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

Contrat clé: erreurs non jetées — une fois le provider invoqué, toute failure ultérieure (erreur réseau, refus du modèle, stream tronqué) doit être encodée dans le stream retourné, se terminant par un AssistantMessage avec stopReason: "error". Ce contrat permet au try/catch de la boucle principale de se concentrer sur les erreurs « avant l'appel provider » (params invalides, api non enregistré); les erreurs internes du provider passent toutes par le stream — unification de la logique de gestion d'erreur.

Le chargement différé est un autre design important (register-builtins.ts lazy: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;
  };
}

Le module provider n'est importé qu'au premier appel réel — ce qui évite le coût de démarrage de « charger tous les providers au lancement ». Si l'utilisateur n'appelle qu'Anthropic, le code d'OpenAI/Google/Mistral n'est jamais chargé. Une failure de chargement (module manquant, erreur de syntaxe) est aussi encodée en événement error dans le stream, sans throw — respect du même contrat.

registerBuiltInApiProviders (register-builtins.ts enregistre tous les builtin:L342-L414) enregistre tous les providers builtin dans apiProviderRegistry, chacun avec sourceId: BUILT_IN_API_PROVIDER_SOURCE_ID; cela permet à unregisterApiProviders(sourceId) de tout nettoyer en un appel lors du teardown de tests.

Les providers custom peuvent aussi s'enregistrer: custom-api-registry.ts ensureCustomApiRegistered:L15-L36 offre à l'appelant SDK une porte, mais avec une garde « si un provider existe déjà, on n'enregistre pas » — pour éviter d'écraser l'implémentation builtin.

Fichiers clés

Flux de données

La boucle principale appelle stream (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);
}

Notez que « api non enregistré » lève ici — cela se produit avant l'appel provider, la boucle principale peut catch. Une fois entré dans provider.stream, toutes les erreurs suivantes passent par le stream.

Le registry interne est une simple 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 vérifie que model.api match; sinon lève Mismatched api — c'est la défense contre la dégradation de type: du code externe passe un Model<"openai-responses"> à la fonction stream, mais à l'exécution model.api doit réellement valoir "openai-responses", sinon type et runtime sont incohérents.

L'implémentation du provider Anthropic (anthropic.ts streamAnthropic:L447-L535) est l'exemple canonique:

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;
      // ... construire client, buildParams, client.messages.create stream:true, parser les événements

Notez le comportement spécial des modèles Fable (certains modèles classifier de la série Claude 5): ils peuvent refuser en cours de génération. Le provider ne peut donc pas « stream et émettre en direct » — il doit buffer jusqu'à connaître le stopReason terminal. createDeferredEventBuffer est ce buffer; les autres modèles passent directement par stream. C'est un exemple d'adaptation provider à un comportement vendeur spécifique: un coin particulier d'Anthropic est encapsulé dans le provider, sans polluer la boucle principale.

Le provider OpenAI Responses est isomorphe (src/llm/providers/openai-responses.ts:L76-L106), sans la logique de buffer Fable — qui est spécifique à Anthropic.

Limites et modes d'échec

  • Erreur dans le stream sans throw: le contrat StreamFunction (packages/llm-core/src/types.ts:L196-L200) stipule explicitement « request/model/runtime failures should be encoded in the returned stream, not thrown ». Les erreurs HTTP internes au provider, stream tronqué, message_stop manquant doivent être encodés en AssistantMessage avec stopReason: "error". anthropic.ts en src/llm/providers/anthropic.ts:L442-L444 vérifie explicitement if (sawMessageStart && !sawMessageEnd) throw, mais ce throw est catch dans l'IIFE async puis converti en événement stream error.
  • Échec de lazy load encodé dans le stream: le .catch de createLazyStream convertit l'échec de chargement du module en outer.push({ type: "error" }) (src/llm/providers/register-builtins.ts:L172-L177). Ainsi « provider SDK non installé » et « appel API échoué » suivent le même chemin de traitement; la boucle principale n'a pas à les distinguer.
  • Incohérence type/runtime lève: wrapStream vérifie model.api !== api et lève Mismatched api (packages/llm-runtime/src/api-registry.ts:L52-L62). Défense contre la dégradation de type, avant le véritable appel stream.
  • api non enregistré lève: resolveApiProvider lève No API provider registered for api: ... (packages/llm-runtime/src/stream.ts:L14-L20). C'est une erreur de configuration, hors périmètre du contrat stream; la boucle principale catch et la remonte à l'utilisateur.
  • Buffer Fable: usesClaudeFable5MessagesContract(model) décide d'activer createDeferredEventBuffer (src/llm/providers/anthropic.ts:L474-L479). Spécifique à Anthropic — les autres providers n'ont pas ce comportement « refuser en cours », ils stream direct.
  • Stratégie de cache retention: resolveCacheRetention() décide d'inclure un cache session id (src/llm/providers/anthropic.ts:L500-L511). Si "none", on ne passe pas cacheSessionId, pour éviter une activation accidentelle de prompt cache.
  • Headers dynamiques GitHub Copilot: le provider gère aussi les headers spéciaux de github-copilot (buildCopilotDynamicHeaders), une adaptation quand on utilise le Claude de GitHub Copilot comme un provider Anthropic (src/llm/providers/anthropic.ts:L492-L498) — un provider peut en interne s'adapter à plusieurs provider ids.
  • Index prédéclaré: OPENCLAW_PROVIDER_INDEX (src/model-catalog/provider-index/openclaw-provider-index.ts:L13-L69) permet au sélecteur de modèle de voir Moonshot/DeepSeek etc. avant l'installation du plugin. Une fois le plugin installé, le manifeste du plugin fait autorité.
  • Custom provider n'écrase pas le builtin: ensureCustomApiRegistered appelle d'abord getApiProvider(api) pour vérifier; s'il existe déjà, retourne false (src/agents/custom-api-registry.ts:L17-L19). Empêche un custom provider SDK d'écraser accidentellement l'implémentation builtin.

Résumé

Le provider est la couche fine qui adapte les API vendeurs: 9 providers builtin couvrent Anthropic/OpenAI/Google/Mistral/Azure/Vertex/Codex, enregistrés via registerApiProvider dans la Map apiProviderRegistry. Le contrat StreamFunction impose « erreurs dans le stream, pas de throw »; le lazy loading fait que le démarrage à froid ne charge que les providers utilisés. Le buffer Fable d'Anthropic, les headers dynamiques Copilot sont encapsulés dans le provider correspondant, sans polluer la boucle principale. La boucle principale appelle stream(model, context, options) via Boucle principale de l'agent; le choix du vendeur est décidé par model.api — la couche de configuration n'a qu'à déclarer apiKey/baseUrl/models du provider dans openclaw.json.

Pour comparer avec la documentation officielle: Providers docs · README.