Skip to content

LLM Providers: adaptación por proveedor

源码版本v2026.6.11

Responsabilidad

Un LLM provider es la capa fina que adapta el API de distintos proveedores a una interfaz de streaming unificada. Anthropic, OpenAI Responses, OpenAI Completions, Azure OpenAI, Google Gemini, Google Vertex, Mistral, OpenAI Codex (ChatGPT Responses) — cada API de proveedor tiene su propio formato de petición/respuesta/evento de streaming, pero el bucle principal del agent de OpenClaw solo quiere un flujo de eventos: AssistantMessageEventStream (thinking / text / toolCall / toolResult / error / stop). El trabajo del provider es traducir en medio: parsear el flujo SSE/JSON del API del modelo en el flujo de eventos unificado de OpenClaw.

El provider no «piensa»; solo hace cuatro cosas: construir el cliente (API key, OAuth, headers, política de cache), montar los parámetros de la petición (messages/tool definitions/options plegados al formato del proveedor), parsear la respuesta en streaming (traduce la secuencia de eventos del proveedor al evento unificado) y gestionar errores (errores HTTP, stream truncado, message_stop faltante, todo codificado como stopReason: "error"). Todos los providers implementan el mismo contrato StreamFunction (types.ts StreamFunction:L201-L208), así que el bucle principal no se preocupa por qué proveedor se está llamando.

Motivación de diseño

¿Por qué abstraer el provider como StreamFunction? Porque las diferencias entre proveedores son grandes pero la semántica de invocación es isomorfa. Cada provider acepta (model, context, options) y devuelve AssistantMessageEventStream — este escenario de «entrada/salida isomorfa, implementación interna diferente» es ideal para abstraer con una interfaz. El bucle principal solo llama a stream(model, context, options); qué proveedor se usa lo decide el campo model.api.

El tipo Api (types.ts KnownApi:L6-L18) lista todos los ids de 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 & {});

Nótese el | (string & {}) final — es un truco de TypeScript para «permitir cualquier string pero conservar el autocompletado». Significa que Api no está cerrado en el conjunto KnownApi: un plugin de terceros puede registrar su propio provider con un id api custom (por ejemplo "my-custom-llm"). registerApiProvider (api-registry.ts registerApiProvider:L77-L90) acepta cualquier TApi extends Api y lo guarda en un Map<string, RegisteredApiProvider>.

El contrato StreamFunction (packages/llm-core/src/types.ts:L193-L208) está claramente escrito:

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;

Contrato clave: los errores no se lanzan — una vez invocado el provider, los fallos posteriores (error de red, modelo que se niega, stream truncado) deben codificarse en el stream devuelto, terminando con un AssistantMessage con stopReason: "error". Este contrato permite al try/catch del bucle principal centrarse en los errores «antes de llamar al provider» (parámetros incorrectos, api no registrada); los errores internos del provider van todos por el stream, unificando la lógica de manejo de errores.

La carga perezosa es otro diseño importante (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;
  };
}

El módulo del provider solo se importa la primera vez que se le invoca de verdad — esto evita el coste de arranque en frío de «cargar todos los providers al arrancar». Si el usuario solo llama a Anthropic, el código de OpenAI/Google/Mistral no se carga. Un fallo de carga (módulo faltante, error de sintaxis) también se codifica como un evento error en el stream, sin lanzar excepción — cumpliendo el mismo contrato.

registerBuiltInApiProviders (register-builtins.ts registra todos los builtin:L342-L414) registra todos los providers builtin en apiProviderRegistry, cada uno con sourceId: BUILT_IN_API_PROVIDER_SOURCE_ID, para que unregisterApiProviders(sourceId) limpie todo de una sola vez en el teardown de tests.

Los providers custom también se pueden registrar: custom-api-registry.ts ensureCustomApiRegistered:L15-L36 ofrece al llamador del SDK una entrada, pero añade el guardián «si ya existe provider, no registrar» — evitando sobrescribir implementaciones builtin.

Archivos clave

Flujo de datos

El bucle principal llama a 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);
}

Nótese que «api no registrada» aquí lanza error — ocurre antes de llamar al provider, el bucle principal puede hacer catch. Una vez dentro de provider.stream, todos los errores van por el stream.

El registry internamente es un Map simple (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 valida que model.api coincida; si no, lanza Mismatched api — una defensa para la degradación de tipos: si código externo pasa un Model<"openai-responses"> a la función stream, en runtime model.api debe realmente ser "openai-responses"; en caso contrario hay inconsistencia tipo + runtime.

La implementación del provider Anthropic (anthropic.ts streamAnthropic:L447-L535) es un ejemplo típico:

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;
      // ... construye client, buildParams, client.messages.create stream:true, parsea eventos

Nótese un comportamiento especial de la serie Fable (ciertos modelos classifier de Claude 5): pueden negarse a mitad de la generación. Por eso el provider no puede «ir recibiendo y emitiendo» — debe bufferear hasta conocer el stopReason. createDeferredEventBuffer es ese buffer; el resto de modelos van directos al stream. Es un ejemplo de adaptación del provider a un comportamiento específico del proveedor: Anthropic tiene un rincón especial, integrado en el propio provider, sin contaminar el bucle principal.

El provider OpenAI Responses es isomorfo (src/llm/providers/openai-responses.ts:L76-L106), solo sin la lógica del buffer Fable — porque es específica de Anthropic.

Límites y fallos

  • Errores al stream, no se lanzan: el contrato StreamFunction (packages/llm-core/src/types.ts:L196-L200) establece explícitamente «request/model/runtime failures should be encoded in the returned stream, not thrown». Los errores HTTP internos del provider, el stream truncado, el message_stop faltante se codifican como AssistantMessage con stopReason: "error". anthropic.ts detecta explícitamente en src/llm/providers/anthropic.ts:L442-L444 con if (sawMessageStart && !sawMessageEnd) throw, pero ese throw está dentro de una IIFE async que se captura y convierte en evento error del stream.
  • Fallo de carga perezosa se codifica en stream: el .catch de createLazyStream convierte un fallo de carga del módulo en outer.push({ type: "error" }) (src/llm/providers/register-builtins.ts:L172-L177). Así, «SDK del provider no instalado» y «llamada al API fallida» recorren la misma ruta de manejo; el bucle principal no necesita distinguir.
  • Inconsistencia tipo/runtime lanza error: wrapStream lanza Mismatched api cuando model.api !== api (packages/llm-runtime/src/api-registry.ts:L52-L62). Es una defensa para la degradación de tipos; ocurre antes de que el stream se invoque de verdad.
  • api no registrada lanza error: resolveApiProvider lanza No API provider registered for api: ... si no encuentra provider (packages/llm-runtime/src/stream.ts:L14-L20). Es un error de configuración, fuera del alcance del contrato del stream; el bucle principal hace catch y reporta al usuario.
  • Buffer Fable: usesClaudeFable5MessagesContract(model) decide si activar createDeferredEventBuffer (src/llm/providers/anthropic.ts:L474-L479). Es específico de Anthropic — otros providers no tienen este comportamiento de «negación a mitad» y van directos al stream.
  • Política de cache retention: resolveCacheRetention() decide si se envía un cache session id (src/llm/providers/anthropic.ts:L500-L511). Si es "none", no se envía cacheSessionId explícitamente, evitando activar prompt cache por error.
  • Headers dinámicas de GitHub Copilot: el provider también maneja las headers especiales del provider github-copilot (buildCopilotDynamicHeaders), una adaptación al usar el Claude de GitHub Copilot como provider Anthropic (src/llm/providers/anthropic.ts:L492-L498) — un provider adapta internamente varios provider ids.
  • Índice predeclarado: OPENCLAW_PROVIDER_INDEX (src/model-catalog/provider-index/openclaw-provider-index.ts:L13-L69) permite al selector de modelos ver info predeclarada de Moonshot/DeepSeek y otros aun sin plugin instalado. Cuando el plugin está realmente instalado, el manifest del plugin es la fuente de autoridad.
  • Provider custom no sobrescribe builtin: ensureCustomApiRegistered primero llama getApiProvider(api) para comprobar; si ya existe, devuelve false (src/agents/custom-api-registry.ts:L17-L19). Evita que un provider custom registrado por el SDK sobrescriba por error una implementación builtin.

Resumen

El provider es la capa fina que adapta distintos APIs de proveedor: 9 providers builtin cubren Anthropic/OpenAI/Google/Mistral/Azure/Vertex/Codex, registrados en el Map apiProviderRegistry vía registerApiProvider. El contrato StreamFunction establece que los errores van al stream y no se lanzan; la carga perezosa hace que el arranque en frío solo cargue los providers usados. El buffer Fable de Anthropic y las headers dinámicas de Copilot son rincones específicos de proveedor encapsulados en el provider correspondiente, sin contaminar el bucle principal. El bucle principal invoca stream(model, context, options) desde Bucle principal del agent; qué provider se usa lo decide model.api — la capa de configuración solo necesita declarar apiKey/baseUrl/models del provider en openclaw.json.

Referencias oficiales: Documentación de providers · README.