LLM Providers: adaptación por proveedor
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:
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:
// 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):
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
types.ts KnownApi:L6-L18— 9 ids de API builtin.types.ts StreamFunction:L193-L208— contrato del provider: errores al stream, no se lanzan.api-registry.ts tipos y registro:L13-L50— interfazApiProvidery Map interno.api-registry.ts registerApiProvider:L77-L90— registra/sustituye un API provider.api-registry.ts getApiProvider:L93-L100— consulta por api id.stream.ts stream/complete:L22-L40— entradas principalesstreamycomplete; despachan pormodel.api.register-builtins.ts lazy:L156-L181— envoltoriocreateLazyStream.register-builtins.ts exporta lazy stream:L316-L339— exports de lazy stream para 9 providers builtin.register-builtins.ts registerBuiltInApiProviders:L342-L414— registro en el registry.anthropic.ts streamAnthropic:L447-L535— implementación del provider Anthropic.openai-responses.ts streamOpenAIResponses:L76-L106— provider OpenAI Responses.custom-api-registry.ts:L15-L36— entrada de registro para providers custom del SDK.openclaw-provider-index.ts:L13-L69— índice de providers predeclarado; el selector de modelos lo ve aunque no haya plugin instalado.
Flujo de datos
El bucle principal llama a stream (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);
}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):
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:
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 eventosNó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 comoAssistantMessageconstopReason: "error".anthropic.tsdetecta explícitamente ensrc/llm/providers/anthropic.ts:L442-L444conif (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
.catchdecreateLazyStreamconvierte un fallo de carga del módulo enouter.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:
wrapStreamlanzaMismatched apicuandomodel.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:
resolveApiProviderlanzaNo 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 activarcreateDeferredEventBuffer(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íacacheSessionIdexplí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:
ensureCustomApiRegisteredprimero llamagetApiProvider(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.