# Lo que aprendimos construyendo una capa unificada de proveedores LLM en Rust

> Lo que aprendimos al lanzar octolib, la librería de Rust detrás de nuestro stack de IA: cómo abstraer múltiples proveedores LLM, normalizar tool calls y uso de tokens, y sobrevivir al día en que el formato de razonamiento de un modelo devolvió un 400.

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](https://github.com/Muvon/octolib), la librería de Rust de código abierto que hace esa absorción para [Octocode](/blog/octolib-the-engine-behind-our-ai-stack), 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](/blog/octolib-the-engine-behind-our-ai-stack) o el artículo hermano sobre [ejecutar un agente de IA en muchos modelos](/blog/running-one-ai-agent-across-many-models), 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.

```rust
#[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:

```rust
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`:

```rust
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 _sí_ 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:

```rust
#[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:

```rust
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í:

```rust
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:

```rust
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:

```rust
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_creation` — _pero 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:

```rust
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`:

```rust
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:

```rust
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`:

```rust
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á:

```rust
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](https://github.com/Muvon/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:

```rust
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](https://github.com/Muvon/octolib) y [OctoHub](https://github.com/Muvon/octohub) son de código abierto bajo Apache-2.0. Si falta un proveedor que necesitas, la plantilla está en `src/llm/providers/openai.rs` — [abre un issue o un PR](https://github.com/Muvon/octolib/issues)._
