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 es una gema de Carmine Paolino 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, licencia MIT, primer commit el 30 de enero de 2025 y más de once millones y medio de descargas acumuladas en RubyGems. 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, 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, 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:
# 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:
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:
chat.with_model('gpt-5-nano')
El streaming es un bloque, sin ceremonias:
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:
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:
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.
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:
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.
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í:
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:
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:
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í:
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:
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.
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 y sobre todo las de Ruby 4.0, y con una comunidad que ha vuelto a discutir en serio cómo se escribe Ruby idiomático. Si andas por esos temas, tengo más cosas etiquetadas ahí.
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.
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. 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.