El reporte de bug tenía tres palabras: "Opus está roto". No lo estaba. Lo que se había roto era una suposición. Teníamos una ruta de código que ponía thinking.type: "enabled" en todos los modelos Claude que soportaban razonamiento extendido. Anthropic lanzó Opus 4.7, que rechaza ese campo exacto con un 400. Solo acepta thinking.type: "adaptive". Un modelo, un valor de enum renombrado, y todas las peticiones enrutadas a él morían en el cable.

Ese es el trabajo entero de una capa unificada de proveedores LLM, comprimido en un solo incidente. No estás escribiendo un cliente HTTP. Estás manteniendo un modelo vivo de cómo diecinueve vendors distintos discrepan sobre los mismos cuatro conceptos —mensajes, herramientas, tokens y errores— y absorbiendo cada cambio que hacen para que el código por encima de ti nunca tenga que preocuparse.

Este es el análisis técnico profundo de octolib, la librería de Rust de código abierto que hace esa absorción para Octocode, Octomind y Octobrain. Voy a recorrer las lecciones que más nos costaron, cada una atada a una pieza real del código. Si leíste la introducción a octolib o el artículo hermano sobre ejecutar un agente de IA en muchos modelos, esta es la capa debajo de ambos.


La forma de la abstracción

El contrato es un trait. Todo lo que hay debajo es un proveedor que lo implementa.

#[async_trait::async_trait]
pub trait AiProvider: Send + Sync {
    fn name(&self) -> &str;
    fn supports_model(&self, model: &str) -> bool;
    async fn chat_completion(&self, params: ChatCompletionParams) -> Result<ProviderResponse>;
    fn get_api_key(&self) -> Result<String>;
    // sondeos de capacidad con valores por defecto: supports_caching, supports_vision,
    // supports_structured_output, supported_sampling_params, get_model_pricing...
}

Un llamante nunca construye un proveedor directamente. Le pasa una cadena provider:model a la factory:

let (provider, model) = ProviderFactory::get_provider_for_model("anthropic:claude-opus-4-8")?;
let response = provider.chat_completion(params).await?;

ProviderFactory::parse_model separa por el primer dos puntos: el prefijo del proveedor es obligatorio, por diseño. No hay adivinanza de "proveedor por defecto". anthropic:claude-opus-4-8, deepseek:deepseek-chat, openrouter:anthropic/claude-opus-4.8, cli:claude/... — el prefijo es la clave de enrutamiento y el nombre del modelo es todo lo que va después. Veintiún proveedores se registran en un único match:

match provider_name.to_lowercase().as_str() {
    "openai"    => Ok(Box::new(OpenAiProvider::new())),
    "anthropic" => Ok(Box::new(AnthropicProvider::new())),
    "deepseek"  => Ok(Box::new(DeepSeekProvider::new())),
    "moonshot" | "kimi" => Ok(Box::new(MoonshotProvider::new())),
    "octohub"   => Ok(Box::new(OctoHubProvider::new())),
    // ... cerebras, groq, together, nvidia, cloudflare, fireworks,
    //     featherless, minimax, zai, byteplus, amazon, google,
    //     ollama, local, openrouter
    _ => Err(anyhow::anyhow!("Unsupported provider: {}", provider_name)),
}

Los métodos por defecto del trait son la mitad anónima de esto. Un proveedor que no hace nada especial hereda comportamiento sensato: supports_caching devuelve false, supported_sampling_params devuelve SamplingSupport::ALL, el pricing cae a una tabla de referencia. Un proveedor que es especial sobrescribe exactamente los métodos donde diverge y nada más. Esa es toda la filosofía de diseño: hacer el caso común gratis y el caso raro local.

Lección cero, sobre la que descansa todo lo demás: la abstracción no es "una API de LLM". Es el conjunto de preguntas de capacidad que necesitarás hacer antes de construir una petición. No acertamos esa lista el primer día. La fuimos creciendo cada vez que un proveedor nos sorprendía, y el trait es el tejido cicatrizal.


Lección 1: Los tool calls son la misma idea en tres formas incompatibles

Si abstraes una sola cosa con cuidado, que sea el tool calling. Es donde los proveedores más divergen y donde un desajuste silencioso produce el peor modo de fallo: el modelo pidió llamar a una herramienta, tu código no se dio cuenta, el agente se bloquea.

Dos vendors, la misma función, distinto formato de cable:

  • Anthropic devuelve un bloque de contenido tool_use. Los argumentos viven en un campo input que ya es un objeto JSON.
  • OpenAI y todos los con forma de OpenAI devuelven un array tool_calls donde function.arguments es una cadena JSON que tienes que parsear tú mismo — y que, cuando un modelo trunca o alucina, no siempre es JSON válido.

octolib modela esta divergencia explícitamente en vez de fingir que no existe. El tipo interno es un enum etiquetado:

#[serde(tag = "provider")]
pub enum ProviderToolCalls {
    Anthropic  { content: Vec<AnthropicToolUse> },   // input: serde_json::Value
    OpenAI     { tool_calls: Vec<OpenAIToolCall> },   // function.arguments: String
    OpenRouter { tool_calls: Vec<OpenAIToolCall> },
    DeepSeek   { tool_calls: Vec<OpenAIToolCall> },
    Generic    { calls: Vec<GenericToolCall> },
}

extract_from_exchange despacha según el nombre del proveedor, y to_tool_calls() colapsa las cinco variantes en un único Vec<ToolCall> plano para el llamante. La rama de OpenAI es donde vive la lección:

let arguments: serde_json::Value = if call.function.arguments.is_empty() {
    serde_json::Value::Object(serde_json::Map::new())
} else {
    serde_json::from_str(&call.function.arguments)
        .map_err(ToolCallError::InvalidArguments)?
};

Una cadena de argumentos vacía es legal y significa "sin argumentos" — no un error. Una malformada sí es un error real. Tuvimos que distinguir esos dos casos por las malas, después de que un modelo emitiera "" para una herramienta sin argumentos y una versión temprana lo tratara como fallo de parseo y descartara la llamada.

Hay una segunda trampa, más sutil: el round-tripping. Cuando devuelves la conversación para el siguiente turno, los tool calls previos del asistente tienen que recodificarse en la forma de ese proveedor. Así que octolib guarda los tool calls en una forma canónica GenericToolCall en el historial, y el convert_messages de cada proveedor reconstruye sus bloques nativos al salir — Anthropic reconstruye bloques tool_use con el objeto intacto; la ruta compatible con OpenAI vuelve a serializar los argumentos de vuelta a una cadena. El fallback tolerante también importa aquí:

serde_json::from_str(&call.function.arguments).unwrap_or_else(|_| {
    serde_json::json!({"raw_arguments": call.function.arguments})
})

En lugar de perder un tool call malformado, preservamos la cadena cruda bajo una clave raw_arguments. El modelo a veces puede recuperarse de ver su propia salida rota. Nunca puede recuperarse de una llamada que desapareció en silencio.


Lección 2: El uso de tokens es donde el dinero se fuga sin ruido

Cada proveedor reporta uso. No hay dos que lo reporten igual. Si quieres coste por petición —y con miles de peticiones a lo largo de una docena de proveedores, lo quieres— tienes que normalizar una entrada genuinamente caótica en un solo struct:

pub struct TokenUsage {
    pub input_tokens: u64,        // LIMPIO — nunca incluye tokens de caché
    pub cache_read_tokens: u64,
    pub cache_write_tokens: u64,
    pub output_tokens: u64,
    pub reasoning_tokens: u64,
    pub total_tokens: u64,
    pub cost: Option<f64>,
    pub request_time_ms: Option<u64>,
}

El struct de deserialización para proveedores compatibles con OpenAI cuenta la historia real. Un único objeto usage tiene que tolerar todos los dialectos a la vez:

struct OpenAiCompatUsage {
    input_tokens: Option<u64>,       // algunos proveedores
    prompt_tokens: Option<u64>,      // OpenAI clásico
    completion_tokens: Option<u64>,
    output_tokens: Option<u64>,      // otros
    total_tokens: Option<u64>,
    reasoning_tokens: Option<u64>,
    completion_tokens_details: Option<CompletionTokensDetails>,  // reasoning aquí...
    prompt_tokens_details: Option<PromptTokensDetails>,          // cached_tokens aquí
    total_cost: Option<f64>, cost: Option<f64>,                  // o aquí
    prompt_cost: Option<f64>, completion_cost: Option<f64>,      // ...o repartido en estos
}

La extracción es una cadena de fallbacks con .or(): input es input_tokens o prompt_tokens o el prompt_eval_count de Ollama o cero. Los tokens de razonamiento se esconden en un campo de nivel superior o anidados bajo completion_tokens_details. El coste es total_cost o cost o prompt_cost + completion_cost, y solo si la config del proveedor decide confiar en el coste upstream. Cuando la API no devuelve campo de coste, octolib lo calcula desde una tabla de pricing por modelo.

Dos cosas nos mordieron específicamente aquí, ambas codificadas en comentarios en el código ahora porque nunca queremos reaprenderlas:

  • input_tokens debe estar "limpio". Algunas APIs incluyen los tokens cacheados en el conteo de input; otras no. Restamos cache_read_tokens nosotros mismos para que el campo siempre signifique lo mismo, o la matemática de costes cuenta dos veces los tokens cacheados baratos a la tarifa cara.
  • El split de TTL de caché de Anthropic. Anthropic factura las escrituras de caché de 5 minutos a 1.25× el input y las de 1 hora a 2×. Devuelve un desglose por TTL en cache_creationpero solo a veces. Cuando ese objeto anidado está ausente, la suposición segura es 5m, no 1h. Adivina 1h y sobrecargas tu propia contabilidad de costes en ~60% en el uso más común. Es un valor por defecto de una línea con un comentario de cuatro líneas explicando por qué.

La lección aburrida: el rastreo de costes no es una función que atornillas encima. Es un problema de normalización disfrazado de aritmética, y la aritmética es la parte fácil.


Lección 3: No hacemos streaming, y fue la decisión correcta

Esta es contraria a la intuición, así que déjame ser preciso. En los proveedores que aceptan una bandera stream, octolib envía stream: false. El comentario en el adaptador de DeepSeek es directo: // We don't support streaming in octolib yet. Bufferizamos la respuesta entera antes de devolverla.

Para una librería cuyo trabajo son los bucles de agente y la salida estructurada, esto es una característica, no una carencia. La razón está en el helper HTTP compartido:

pub(super) async fn send_and_read(
    request: reqwest::RequestBuilder,
    timeout: Option<Duration>,
) -> anyhow::Result<CapturedResponse> {
    let response = apply_request_timeout(request, timeout).send().await?;
    let status = response.status();
    let headers = response.headers().clone();
    let body = response.text().await?;   // cuerpo leído DENTRO de la unidad reintentada
    Ok(CapturedResponse { status, headers, body })
}

Tanto el send() como la lectura completa del cuerpo ocurren dentro de la misma unidad asíncrona que envuelve el bucle de reintentos. Esto importa porque el fallo de transporte más feo es un RST a mitad de stream — la conexión muere después de que lleguen las cabeceras pero mientras el cuerpo todavía viene. Un firewall inspeccionando el payload, un balanceador de carga sobrecargado, un timeout de NAT en una generación larga. Si haces streaming, ese fallo aflora en lo profundo de tu manejador de tokens, con media respuesta ya emitida, sin forma limpia de reintentar. Como leemos el cuerpo dentro de la unidad reintentada, is_connection_error clasifica el fallo (is_connect() || is_request() || is_body()), el bucle refresca el cliente HTTP, y la petición entera se reintenta limpiamente.

El tradeoff es honesto: no obtienes latencia de primer token, y no puedes renderizar la salida progresivamente. Para una UI de chat, haz streaming. Para un agente que va a parsear la respuesta entera en tool calls o un esquema JSON antes de hacer nada de todas formas, bufferizar es más simple, más correcto bajo redes inestables, y elimina una clase entera de bugs de estado parcial. Elegimos corrección. El streaming está en la lista; nunca ha sido urgente.


Lección 4: Los reintentos son fáciles. Saber qué es reintentable, no.

El backoff en sí no tiene nada de notable — base_timeout * 2^attempt, capado a cinco minutos, cancelable vía un token watch:

pub fn is_retryable_status(status: u16) -> bool {
    status == 429 || status >= 500
}

Reintenta 429 y 5xx. Nunca reintentes un 4xx — una petición mala sigue mala. Esa parte son doce líneas.

La parte difícil es la ruta de error de conexión. Una conexión HTTP del pool que el upstream cerró a medias en silencio produce un error de reqwest que es is_request() = true, is_connect() = false. Si solo compruebas is_connect(), nunca refrescas el pool, y cada reintento reutiliza el mismo socket muerto — así que todos tus reintentos fallan idénticamente y concluyes que el proveedor está caído cuando no lo está. Lo aprendimos de endpoints alojados en CN (DeepSeek, Moonshot) que cierran las conexiones keep-alive inactivas de forma agresiva. El arreglo es tratar is_connect(), is_request() e is_body() todos como errores de conexión, y cambiar atómicamente a un cliente fresco antes del siguiente intento:

if on_connection_error(&e) {
    crate::llm::providers::shared::refresh_http_client();  // nuevo pool, sin sockets viejos
}

El cliente en sí está afinado para exactamente esto: timeout de conexión de 20s (acotando el handshake, no la generación, que puede tardar minutos), TCP keepalive, frames PING de HTTP/2, y un timeout de pool inactivo de 30s para desalojar conexiones antes de que el edge upstream las mate. Nada de eso es glamoroso. Todo eso es la diferencia entre una librería que sobrevive a una tarde de red mala y una que te despierta con una alerta.


Lección 5: Las rarezas por modelo van en datos, no en if dispersos por todas partes

De vuelta al incidente de apertura. El arreglo no fue un hack — fue admitir que "qué knobs de sampling y razonamiento acepta este modelo" es dato sobre modelos, y ponerlo donde van los datos. En el adaptador de Anthropic, eso es un puñado de slices const:

const NO_SAMPLING_MODELS: &[&str]     = &["fable-5", "mythos-5", "opus-4-8", "opus-4-7"];
const ADAPTIVE_ONLY_MODELS: &[&str]   = &["fable-5", "mythos-5", "opus-4-7"];   // thinking manual → 400
const NO_TOP_P_MODELS: &[&str]        = &["opus-4-1", "opus-4-7", "sonnet-4-5", /* ... */];
const EFFORT_PARAM_MODELS: &[&str]    = &["fable-5", "mythos-5", "opus-4-7", "opus-4-6", /* ... */];

El mecanismo genérico que hace esto componible es SamplingSupport — una pequeña máscara booleana que un proveedor devuelve por modelo, declarando cuáles de temperature, top_p, top_k el modelo siquiera aceptará:

pub struct SamplingSupport { pub temperature: bool, pub top_p: bool, pub top_k: bool }
// SamplingSupport::ALL, ::NONE, ::TEMPERATURE_ONLY, ::TEMPERATURE_AND_TOP_P

El trait fusiona los valores pedidos por el usuario con esta máscara en effective_sampling_params. Un modelo que rechaza top_p simplemente nunca tiene top_p en su cuerpo de petición — ningún llamante en ninguna parte escribe if model == .... Hay una sutileza relacionada que solo encontramos al recibir un 400: algunos modelos de Anthropic rechazan cualquier valor de sampling no-default una vez que el razonamiento está activado, así que el adaptador decide thinking_enabled por adelantado y ajusta el sampling en consecuencia, antes de construir la petición. La lección se generaliza más allá de Anthropic: en el momento en que tienes dos comprobaciones if model.contains(...) para el mismo concepto, tienes una tabla de datos intentando nacer. Déjala.


Dónde encaja OctoHub: el proxy encima de la capa

La pregunta natural una vez que tienes un cliente unificado: ¿y si la unificación viviera en la red, para que los clientes no-Rust también la tuvieran? Eso es OctoHub — un servidor proxy LLM de alto rendimiento que es, literalmente, octolib con una cara HTTP y una base de datos.

El motor de proxy de OctoHub no reimplementa ningún proveedor. Depende de octolib y llama a la misma factory:

let (provider_name, model) = self.config.resolve_model(&req.model)?;
let provider = ProviderFactory::create_provider(&provider_name)?;
let response = provider.chat_completion(params).await?;

Lo que OctoHub añade es la capa operativa alrededor de esa llamada: claves API por tenant, logging completo de petición/respuesta a SQLite/MySQL/Postgres, analíticas de uso, mapeo de nombres de modelo (un alias corto resuelve a una lista de cadenas provider:model, una elegida al azar para un balanceo de carga rudimentario), y límites de concurrencia por proveedor con timeouts de upstream. Habla una forma de Responses-API (/v1/completions, previous_completion_id para multi-turno) y un /v1/chat/completions compatible con OpenAI para clientes que ya hablan ese.

Y entonces cierra el bucle: octolib también incluye un proveedor octohub:. Así que una app Rust usando octolib puede enrutar a través de una instancia de OctoHub — que enruta de vuelta hacia afuera a través de octolib al upstream real — obteniendo claves centralizadas, logging y rastreo de costes gratis, con la misma cadena provider:model en todas partes. El proveedor octohub anuncia cada capacidad como soportada (supports_caching, supports_vision, supports_structured_output todas true) precisamente porque no puede saber qué hay detrás del proxy; el upstream real devuelve un error explícito si el modelo subyacente no puede honrar una petición. El proxy es deliberadamente honesto sobre su propia ignorancia.

Este es el pago de acertar la frontera del trait. La misma abstracción sirve a una llamada de librería en proceso y a un salto de red, y componen sin que ninguno de los dos lados sepa con cuál está hablando.


Lo que le diría a alguien que empieza esto hoy

Lección Lo concreto
Abstrae capacidades, no endpoints El trait es una lista de preguntas (supports_caching, supported_sampling_params), no solo chat_completion
Modela la divergencia de tool calls explícitamente Un enum etiquetado por forma de proveedor le gana a un struct "universal" con pérdidas
Normaliza el uso antes de confiar en el coste Cadenas .or() entre prompt_tokens/input_tokens/prompt_eval_count; mantén input_tokens limpio de caché
Bufferiza si vas a parsear de todas formas Leer el cuerpo dentro de la unidad reintentada hace que los RST a mitad de stream sean reintentables
Refresca el pool en errores de conexión is_request() e is_body() también son errores de conexión, no solo is_connect()
Las rarezas son datos Slices const de modelos y una máscara SamplingSupport, nunca if model.contains dispersos

Nada de esto es novedoso. Todo esto es del tipo de cosa que solo escribes después de que te ha mordido, por eso la mayoría de estas lecciones viven como comentarios de cuatro líneas encima de valores por defecto de una línea en el código real. La parte más difícil de una capa unificada de proveedores LLM no es la abstracción. Es mantenerse honesto sobre cuánto difieren realmente las cosas que hay debajo — y negarse a tapar una diferencia real con un valor por defecto esperanzado.

Preguntas frecuentes

¿Es octolib una librería de Rust para LLM que puedo usar de forma independiente?

Sí. Es de código abierto bajo Apache-2.0, compila con cargo check, y los ejemplos corren solo con una clave API. La cadena provider:model es la interfaz entera. Hace inferencia LLM, embeddings y reranking — nada más, ni agentes ni cadenas.

¿Qué proveedores soporta la capa unificada?

Veintiún proveedores LLM se registran en la factory, incluyendo OpenAI, Anthropic, Google, DeepSeek, Moonshot, MiniMax, Z.ai, Groq, Cerebras, Together, NVIDIA, Cloudflare, Fireworks, Featherless, BytePlus, Amazon Bedrock, OpenRouter, OctoHub, Ollama, endpoints locales, y un backend cli:. Los proveedores nativos tienen adaptadores a medida; el resto comparten una ruta compatible con OpenAI.

¿Hace streaming de tool calls?

Hoy no — octolib bufferiza la respuesta completa (stream: false) para que los resets de conexión a mitad de stream se vuelvan reintentables limpiamente, lo cual importa más para bucles de agente y salida estructurada que la latencia de primer token. El streaming está en el roadmap.

¿En qué se diferencia OctoHub de octolib?

octolib es la librería de Rust en proceso. OctoHub es un servidor proxy construido encima de ella — el mismo enrutamiento de proveedores, más claves multi-tenant, logging de peticiones, analíticas de uso y balanceo de carga. octolib incluso puede llamar a OctoHub vía su proveedor octohub:.

— Don


octolib y OctoHub son de código abierto bajo Apache-2.0. Si falta un proveedor que necesitas, la plantilla está en src/llm/providers/openai.rsabre un issue o un PR.