# RubyLLM: una sola interfaz para todos los modelos

Hace unas semanas abrí un fichero que escribí yo mismo hace año y medio y sentí la necesidad de pedirme perdón. Se llamaba `lib/llm/stream_normalizer.rb`, tenía ciento y pico líneas, un `case` de tres ramas y un comentario en mayúsculas que decía «OJO: aquí el delta viene en otro sitio». Debajo, cuatro ficheros hermanos: un cliente por proveedor, cada uno con su forma de montar los mensajes, su manera de emitir chunks y su dialecto propio para declarar herramientas. Todo funcionaba. Todo era mío. Y todo estaba envejeciendo a una velocidad que daba vértigo.

Esa carpeta es el motivo por el que este post existe.

## La fatiga del adaptador

Si has metido un LLM en una aplicación de verdad, ya sabes de qué hablo. Cada proveedor publica su SDK, y cada SDK es un mundo pequeño con sus costumbres. Los roles no se llaman igual. El contenido multimodal se codifica distinto. El streaming llega por *server-sent events* en todos los casos, sí, pero el trozo útil está en un sitio diferente en cada uno, y hay quien te manda el uso de tokens al final y quien te lo va goteando. Las herramientas —el *function calling*, el pegamento que convierte un chatbot en algo que sirve— se declaran con un esquema por aquí y con otro por allá, y la diferencia no es cosmética: cambia dónde vive la descripción, cómo se marcan los parámetros obligatorios y qué hace el modelo cuando le pasas algo que no esperaba.

Nada de eso es difícil por separado. Lo difícil es **mantenerlo**. Escribes el adaptador de OpenAI un martes, el de Anthropic el jueves, y durante seis meses vives feliz. Luego uno de los dos publica una versión de su API con un campo nuevo que te interesa, el otro deprecia un endpoint, aparece un modelo que quieres probar y resulta que tu capa de abstracción casera asumía cosas que ese modelo no cumple. Y ahí estás, un viernes por la tarde, depurando un normalizador de chunks en lugar de trabajar en tu producto.

Yo he pasado por eso construyendo una plataforma de bots conversacionales con RAG que hoy está en producción. Funciona bien, estoy orgulloso de ella, y aun así: si sumo las horas dedicadas a mantener la capa de adaptadores, sale un número que preferiría no ver escrito.

## Qué es RubyLLM

[RubyLLM](https://rubyllm.com/) es una gema de [Carmine Paolino](https://paolino.me/) que ataca exactamente ese problema. Su descripción oficial es «*one delightful Ruby framework for every major AI provider*», y por una vez el eslogan no miente demasiado: una interfaz única para OpenAI, Anthropic, Gemini, Bedrock, Azure, VertexAI, xAI, Mistral, DeepSeek, Perplexity, OpenRouter, GPUStack y Ollama —trece proveedores con implementación propia en la última versión estable— más cualquier API compatible con el formato de OpenAI, que a estas alturas son casi todas.

Los números, a fecha de hoy: **4.327 estrellas** y 490 forks en [el repo](https://github.com/crmne/ruby_llm), licencia MIT, primer commit el 30 de enero de 2025 y más de once millones y medio de descargas acumuladas en [RubyGems](https://rubygems.org/gems/ruby_llm). La versión estable es la **1.16.0**, publicada el 9 de junio de 2026, y hay una rama de desarrollo de la 2.0 bastante viva.

Paolino no es un desconocido en esta casa: [ayer mismo escribí sobre Fastpotify](/post/fastpotify-cliente-nativo-spotify-rust), su cliente nativo de Spotify escrito en Rust. Es la misma persona. Un tipo que se pasa el día haciendo Ruby y Rails para IA y los ratos libres escribiendo un reproductor de música en Rust me cae bien por defecto. Además mantiene [Chat with Work](https://chatwithwork.com), un producto comercial construido sobre esta misma gema, lo cual importa más de lo que parece: la gema no es un proyecto de fin de semana, es la infraestructura de la que come su autor.

Un apunte de honestidad antes de seguir. El README presume de «solo tres dependencias: Faraday, Zeitwerk y Marcel». El gemspec de la 1.16.0 declara **nueve**. No es un drama —cuatro son satélites del propio Faraday y otra es una gema suya para esquemas— pero digamos las cosas como son. Y hay un detalle más importante: el README de la rama `main` documenta la 2.0 en desarrollo, con métodos que **no existen** en la 1.16.0. Si copias y pegas de ahí a un proyecto real, te vas a comer un `NoMethodError` con cara de tonto. Lo digo por experiencia reciente.

## La API en cinco minutos

La configuración es un inicializador y poco más:

```ruby
# config/initializers/ruby_llm.rb
RubyLLM.configure do |config|
  config.openai_api_key    = ENV.fetch('OPENAI_API_KEY')
  config.anthropic_api_key = ENV.fetch('ANTHROPIC_API_KEY')

  config.default_model   = 'claude-sonnet-4-6'
  config.request_timeout = Rails.env.production? ? 120 : 30
  config.max_retries     = 3
  config.logger          = Rails.logger
end
```

Y a partir de ahí, todo cuelga de `RubyLLM.chat`:

```ruby
chat = RubyLLM.chat
chat.ask "¿Cuál es la mejor forma de aprender Ruby?"
```

Cambiar de modelo —incluso a mitad de conversación, conservando el historial— es una línea:

```ruby
chat.with_model('gpt-5-nano')
```

El streaming es un bloque, sin ceremonias:

```ruby
chat.ask "Cuéntame una historia sobre Ruby" do |chunk|
  print chunk.content
end
```

Los adjuntos se pasan con `with:` y la gema detecta el tipo sola, sin que tengas que decirle si es imagen, PDF o audio:

```ruby
chat.ask "Resume este contrato", with: "contrato.pdf"
chat.ask "Compara estos dos", with: ["diagrama.png", "informe.pdf"]
```

Y hay funciones sueltas a nivel de módulo para lo que no es conversación: `RubyLLM.embed` para vectores, `RubyLLM.paint` para generar imágenes, `RubyLLM.transcribe` para pasar audio a texto y `RubyLLM.moderate` para el filtro de contenido. En la 1.16.0 son esas cuatro; la 2.0 añade unas cuantas más.

Cuando lo probé por primera vez, lo que me llamó la atención no fue ninguna función concreta. Fue que **no tuve que leer nada** para escribir las primeras veinte líneas. Eso, en una gema que habla con trece APIs distintas, tiene mérito de diseño.

## Herramientas: donde la cosa se pone seria

Un chat que solo habla es una demo. Un chat que consulta tu base de datos es un producto. RubyLLM resuelve esto con una clase por herramienta y un DSL corto:

```ruby
class SaldoCliente < RubyLLM::Tool
  description "Devuelve el saldo pendiente de un cliente del ERP"
  param :cliente_id, desc: "Identificador numérico del cliente"

  def execute(cliente_id:)
    cliente = Cliente.find_by(id: cliente_id)
    return { error: "Cliente #{cliente_id} no encontrado" } unless cliente

    { cliente: cliente.razon_social, saldo: cliente.saldo_pendiente.to_f }
  end
end

chat.with_tool(SaldoCliente).ask "¿Cuánto debe el cliente 4471?"
```

Fíjate en el `return { error: ... }` en lugar de un `raise`. Es el patrón que recomienda la documentación y me parece correcto: un error dentro de una herramienta normalmente no es un fallo de tu aplicación, es información que el modelo necesita para corregirse y volver a intentarlo. Reventar la petición entera porque un ID no existe es tirar por la borda la única cosa que estos modelos hacen razonablemente bien, que es reaccionar a un mensaje de error.

Sobre esa base, la 1.16.0 añade agentes reutilizables: una clase que empaqueta modelo, instrucciones y herramientas para no repetirte en cada punto de entrada.

```ruby
class AsistenteFacturacion < RubyLLM::Agent
  model 'claude-sonnet-4-6'
  instructions 'Responde en español. Consulta siempre el ERP antes de dar cifras.'
  tools SaldoCliente
end

AsistenteFacturacion.new.ask "¿Cuánto debe el cliente 4471?"
```

Para salida estructurada hay esquemas, que es la forma civilizada de pedirle JSON a un modelo sin rezar:

```ruby
class DatosFactura < RubyLLM::Schema
  string :numero
  number :total
  string :cif, required: false
end

respuesta = chat.with_schema(DatosFactura).ask("Extrae los datos", with: "factura.pdf")
respuesta.content # => {"numero" => "F-2026-0412", "total" => 1210.0, "cif" => "B12345678"}
```

## La parte que de verdad la diferencia: Rails

Todo lo anterior lo hace, con más o menos gracia, cualquier cliente HTTP unificado. Lo que separa a RubyLLM del montón es lo que viene ahora, y es también la razón por la que le he dedicado un post entero.

```bash
bin/rails generate ruby_llm:install
bin/rails db:migrate
bin/rails ruby_llm:load_models
```

El generador crea las migraciones y los modelos para cuatro tablas —chats, mensajes, llamadas a herramientas y un registro de modelos— y configura ActiveStorage para los adjuntos. Los modelos quedan así:

```ruby
class Chat < ApplicationRecord
  acts_as_chat
  belongs_to :user
end

class Message < ApplicationRecord
  acts_as_message
end

class ToolCall < ApplicationRecord
  acts_as_tool_call
end

class Model < ApplicationRecord
  acts_as_model
end
```

Y entonces sucede la cosa buena:

```ruby
chat = Chat.create!(model: 'claude-sonnet-4-6', user: current_user)
chat.with_instructions("Eres el asistente de facturación de Acme.")
chat.with_tool(SaldoCliente)

chat.ask "¿Cuánto debe el cliente 4471?"
chat.ask "¿Y desde cuándo?"

chat.messages.count # => 5
```

Cinco y no cuatro: las instrucciones de sistema también son una fila. Detalle nada menor, porque significa que puedes leerlas, auditarlas y cambiarlas por conversación sin tocar código.

Ese `chat` es un ActiveRecord de verdad. Pertenece a un usuario, tiene *scopes*, se puede paginar, aparece en tus *fixtures* y lo borras con `dependent: :destroy` como cualquier otra cosa. El historial de la conversación no vive en una variable de instancia que se evapora al acabar la petición: **está en PostgreSQL**, y la segunda pregunta funciona porque la gema recarga los mensajes anteriores desde la tabla.

Quien haya montado esto a mano sabe que ahí es donde se va el tiempo de verdad. No en llamar a la API: en decidir qué se persiste, cuándo, y qué pasa cuando el proveedor devuelve un 429 en mitad de un mensaje a medio escribir. RubyLLM ya tiene respuesta: guarda el mensaje del usuario de inmediato, crea el del asistente vacío, lo rellena al terminar y **lo destruye si la llamada falla**, para no dejarte huérfanos en la tabla.

La contabilidad de tokens y costes viene resuelta en la misma capa, que es exactamente donde la quieres:

```ruby
mensaje = chat.messages.last
mensaje.tokens.input
mensaje.tokens.output
mensaje.tokens.cache_read
mensaje.cost.total

chat.cost.total
```

Poder responder «¿cuánto nos costó la conversación 8842?» con una consulta de ActiveRecord en lugar de con un CSV descargado del panel del proveedor tres días después vale más de lo que parece. Sobre todo cuando el que pregunta es el que paga.

Y para la interfaz existe `bin/rails generate ruby_llm:chat_ui`, que te deja un chat funcionando en `/chats` con streaming por Turbo. Como andamio para enseñar algo a un cliente el martes, cumple. Como interfaz definitiva, evidentemente no, pero eso ya lo sabías.

## El precio de abstraer

Ahora la letra pequeña, que en este blog nunca falta.

Toda abstracción cobra un peaje, y el de RubyLLM se paga **el día que necesitas lo que un proveedor concreto hace mejor**. El ejemplo canónico es el *prompt caching* de Anthropic: si tienes un prompt de sistema gordo que se repite en cada llamada, cachearlo cambia la factura de forma seria. RubyLLM lo soporta, pero así:

```ruby
bloque = RubyLLM::Content::Raw.new([
  { type: 'text', text: manual_completo, cache_control: { type: 'ephemeral' } },
  { type: 'text', text: pregunta_del_usuario }
])

chat.ask(bloque)
```

Eso es un payload crudo de Anthropic viajando dentro de la capa que existe justamente para que no escribas payloads crudos de Anthropic. Funciona, está documentado y es la decisión pragmática correcta. Pero llamemos a las cosas por su nombre: **en esa línea has perdido la portabilidad**. Lo mismo pasa con `with_params` y `with_headers`, los dos escotillones para colar parámetros que la interfaz común no contempla. Son necesarios y celebro que existan. También son el sitio exacto donde la promesa de «cambia de modelo sin reescribir código» deja de cumplirse.

Hay más diferencias que la interfaz uniforme no puede borrar porque no son de forma, son de fondo. Las ventanas de contexto no son iguales, y un prompt que cabe holgado en un modelo revienta en otro. Los modelos de razonamiento tienen sus propios controles —la gema expone `with_thinking(effort:, budget:)`, que ya es más de lo que hacen otros— pero lo que significa «esfuerzo alto» varía por proveedor. Los precios se mueven cada pocos meses. Y la calidad de la ejecución de herramientas entre modelos es abismalmente distinta: el mismo `SaldoCliente` que un modelo grande usa a la primera, uno pequeño lo llama con el parámetro cambiado de nombre. Ninguna gema arregla eso. **La API se unifica; los modelos no.**

Dicho lo cual, hay una cosa que sí resuelve mejor que mi código casero, y es el manejo de errores. La jerarquía está bien pensada:

```ruby
begin
  chat.ask(pregunta)
rescue RubyLLM::RateLimitError
  ReintentarChatJob.set(wait: 30.seconds).perform_later(chat.id)
rescue RubyLLM::ContextLengthExceededError
  compactar_conversacion(chat)
rescue RubyLLM::ServiceUnavailableError, RubyLLM::OverloadedError
  responder_con_fallback(chat)
rescue RubyLLM::Error => e
  Rails.logger.error("LLM #{e.response&.status}: #{e.message}")
  raise
end
```

Tener `RateLimitError`, `ContextLengthExceededError`, `OverloadedError`, `PaymentRequiredError` y compañía como clases distintas y **con el mismo significado en trece proveedores** es, para mí, uno de los argumentos más fuertes de la gema. Los reintentos con backoff exponencial vienen configurados de serie, y lo que no debe reintentarse —adivina: superar el contexto— no se reintenta. Eso es alguien que ha estado ahí.

El registro de modelos va en la misma línea: unos ochocientos modelos catalogados con sus capacidades, su ventana de contexto y su precio, consultables desde Ruby.

```ruby
modelo = RubyLLM.models.find('claude-sonnet-4-6')
modelo.context_window
modelo.supports?('function_calling')

RubyLLM.models.chat_models.select { |m| m.supports?('structured_output') }
```

Poder decidir en tiempo de ejecución si un modelo aguanta tu prompt antes de mandárselo es el tipo de detalle aburrido que nadie pone en la portada y que te ahorra incidencias de madrugada.

## Ruby, sin envidia

Hay un contexto que conviene no perder de vista. Durante tres años, meter IA en una aplicación seria significaba, en la práctica, tener Python en alguna parte. No porque Ruby no pudiera —son llamadas HTTP, por favor— sino porque el ecosistema estaba allí y aquí no. Muchos acabamos con un microservicio Python al lado de nuestro Rails haciendo de traductor, con todo lo que eso arrastra en despliegue, observabilidad y gente que sepa mantener las dos cosas.

RubyLLM llega justo cuando esa excusa deja de sostenerse. Y no llega sola: el lenguaje viene de una racha excelente, con [las novedades de Ruby 3.4](/post/novedades-de-ruby-3-4) y sobre todo [las de Ruby 4.0](/post/novedades-de-ruby-4-0), y con una comunidad que ha vuelto a discutir en serio [cómo se escribe Ruby idiomático](/post/comparando-el-estilo-clasico-orientado-a-clases-con-el-estilo-ruby-idiomatico-funcional-y-modular). Si andas por esos temas, tengo [más cosas etiquetadas ahí](/search?tag=ruby).

Que la gema apueste por concurrencia basada en *fibers* y se lleve bien con el `isolation_level = :fiber` de Rails también dice algo: no está pensada para el script de demo, está pensada para aguantar cien conversaciones simultáneas sin bloquear el pool de conexiones.

## ¿Lo meterías en producción?

Sí. Con condiciones, que es como se responden estas preguntas.

**Lo metería sin pensarlo** si estoy arrancando una funcionalidad de IA dentro de una aplicación Rails ya existente y quiero conversaciones persistidas, con usuario, con herramientas que tocan mis modelos y con la factura contabilizada por conversación. Montar eso a mano son dos semanas largas y luego es tuyo para siempre. Aquí es un generador y una tarde. Ese cálculo no admite discusión.

**Lo metería con cuidado** si tengo un compromiso fuerte con un solo proveedor y estoy exprimiendo sus funciones exclusivas. Si tres cuartas partes de tu código ya son bloques `Content::Raw` y `with_params`, estás pagando una capa de indirección para no usarla. En ese caso el SDK oficial es más honesto.

**No lo metería** si mi caso es una única llamada a una única API en un servicio que no es Rails. Para eso, Faraday y treinta líneas. Añadir un framework para hacer un `POST` es la clase de decisión que uno defiende en la reunión y lamenta en el *code review*, y va bastante en contra de [lo que pienso sobre sostenibilidad y arquitectura](/post/rails-sostenible-i-sostenibilidad-y-arquitectura).

Y una advertencia operativa que no es culpa de la gema pero te va a tocar igual: darle herramientas a un modelo es darle capacidad de actuar sobre tus datos. `SaldoCliente` es inofensivo porque solo lee. El día que alguien escriba `ActualizarFactura` sin pensarlo dos veces, la conversación pasa de ser un chat a ser un actor con permisos, y ahí la pregunta ya no es qué gema usas sino [quién audita lo que hace ese actor](/post/openbot-ai-coworkers-open-source). RubyLLM no te va a ayudar con eso, ni tiene por qué. Pero conviene tenerlo escrito antes de la demo, no después.

Mi plan, por si sirve de algo: la carpeta `lib/llm/` con mis adaptadores se muere. No mañana, pero se muere. Voy a migrar primero el bot menos crítico, comparar facturas y latencias durante un mes, y si la cosa se comporta, seguir. La parte que me duele no es tirar el código —eso se hace con `git rm` y una cerveza— sino admitir que la solución que llevo año y medio manteniendo cabe ahora en una gema de nueve dependencias que mantiene otro.

Duele. Pero duele de la manera buena.
