Skip to main content
La mayoría de las aplicaciones de IA comienzan llamando directamente a la API de un modelo. Eso funciona bien para prototipos, pero cuando varias aplicaciones, servicios o clientes necesitan acceso, las llamadas directas al proveedor se vuelven más difíciles de gestionar. Cada servicio necesita una clave del proveedor, cada cliente necesita aprender el comportamiento específico del proveedor, y cada equipo termina resolviendo la autenticación, los límites y la observabilidad de una forma ligeramente distinta. Un LLM gateway nos da un único lugar donde autenticar a quienes llaman, aplicar límites de tasa, ocultar las claves de proveedores upstream, registrar telemetría y mantener una API estable para nuestras propias aplicaciones. En este tutorial, construiremos uno en Rust con Axum, Postgres, SQLx y la API de Venice AI. Al terminar, tendrás un gateway que expone un endpoint /v1/chat/completions compatible con OpenAI, acepta tus propios tokens bearer, reenvía las peticiones a Venice, admite respuestas en streaming y emite spans y métricas útiles de OpenTelemetry. ¿Te interesa la implementación completa del código? Echa un vistazo a el repositorio de GitHub.

Requisitos previos

  • Rust 1.92+
  • Docker y Docker Compose
  • Una clave de API de Venice
  • curl
  • Familiaridad básica con servicios web en Rust
Antes de empezar, exporta tu clave de API de Venice:
Nunca expondremos esta clave a las aplicaciones cliente. El gateway la guardará en el lado del servidor y los clientes se autenticarán con claves de API específicas del gateway.

Qué vamos a construir

La implementación de referencia es un pequeño servicio en Rust con varias partes claras: Diagrama de arquitectura que muestra un cliente llamando al gateway en Rust, Postgres, Venice AI y OpenTelemetry Un cliente envía una petición compatible con OpenAI al gateway. El gateway autentica al cliente, comprueba los límites de tasa, reenvía la petición a Venice y registra telemetría por el camino. Como parte del gateway, nos aseguraremos de que este servicio sea horizontalmente escalable con la menor superficie posible en lo que respecta a la API en sí. Hay varias razones para ello: una de las principales es que si tienes un throughput muy alto, por ejemplo, casi con toda seguridad vas a querer usar réplicas (es decir, levantar más de una instancia del mismo servicio). Esto significa que, si aún no lo haces, arquitectónicamente vas a querer poner tu servicio original y las réplicas detrás de un balanceador de carga para que, si un contenedor o servicio cae, el servicio completo no sufra una interrupción. Además, también asumiremos que somos dueños de la creación de claves de API de alguna forma, aunque el servicio de gateway no debería emitirlas de forma aislada. Esto se representará como una tabla de Postgres que sembramos cuando se usa localmente. En producción, esto normalmente lo gestionaría el servicio de autenticación. Aunque es posible gestionar la creación de una clave de API upstream para cada usuario que use tu LLM gateway, en la práctica esto no suele ser recomendable. Al delegar esta responsabilidad al servicio upstream, también renuncias al control que normalmente tendrías, lo que significa que no puedes aplicar completamente cosas como los límites de tasa y los topes de gasto. El árbol de fuentes se mantiene intencionadamente pequeño:
Sin más preámbulos, vamos a construirlo.

Crear el servicio en Rust

Empieza con un nuevo proyecto binario en Rust:
Añade las dependencias que necesitamos en Cargo.toml, con explicaciones en el fragmento de código:

Cargar la configuración

El gateway lee todo desde variables de entorno. Para código de infraestructura como este, las variables de entorno son un buen valor por defecto porque el mismo binario puede ejecutarse localmente, en Docker Compose o en un entorno gestionado sin necesidad de un formato de archivo de configuración aparte. Además, muchos proveedores te permiten almacenar tus propias variables de entorno como secretos en su propio runtime de contenedores. A menudo esto es mucho más seguro que intentar usar algo como dotenv (o dotenvy en Rust, ya que el crate original dotenv está mayormente descontinuado). Crea src/config.rs:
Aunque aquí hay muchos valores posibles que se parsean desde variables de entorno, en general solo necesitas dos:
  • La URL de la base de datos
  • Tu clave de API de Venice
Aquí importan dos valores por defecto. VENICE_BASE_URL apunta a https://api.venice.ai/api/v1, y CAPTURE_GENAI_CONTENT por defecto es false para que el contenido de los prompts no se registre a menos que lo habilites intencionadamente. Ese segundo valor por defecto es el más importante. Un gateway puede ver todos los prompts y respuestas que pasan por él, pero la observabilidad no debería convertirse automáticamente en captura de contenido. En la mayoría de los sistemas en producción, los conteos de tokens, la latencia, los nombres de los modelos, los códigos de estado y los metadatos de facturación son suficientes para operaciones. En términos generales, registrar prompts y conversaciones en producción no solo puede ser un riesgo de privacidad, sino también un riesgo de almacenamiento. Añadirlos supone crear spans y trazas con un nivel extremadamente alto de cardinalidad (es decir, la unicidad de los datos dentro de un dataset). Esto puede encarecer mucho la búsqueda en tus datos de observabilidad, además de perjudicar potencialmente el rendimiento al buscar en los datos.

Crear el esquema de la base de datos

A continuación, crea migrations/0001_api_keys.sql. Solo almacenaremos los primeros 12 caracteres de cada clave de API del gateway como prefijo de búsqueda, más el hash SHA-256 de la clave completa. Eso permite que el gateway encuentre rápidamente una fila candidata sin almacenar las credenciales en texto plano. El prefijo no es secreto. Existe para indexar. El hash es lo que demuestra que quien llama presentó la clave completa. Esta es la misma forma básica que usan muchos sistemas de claves de API: mostrar la clave en texto plano una vez, almacenar una representación no reversible y mantener un prefijo corto para búsquedas y flujos de soporte.
Ahora añade una tabla para el rate limiting por ventanas fijas:
Este esquema es pequeño, pero nos da los invariantes importantes:
  • Las claves de API nunca se almacenan en texto plano.
  • Los ajustes de rate limit deben ser positivos.
  • Las claves revocadas no pueden permanecer activas.
  • Una ventana de rate limit se identifica de forma única por clave, hora de inicio y longitud de ventana.
Mantener esos invariantes en Postgres es útil porque cada quien que llama tiene que pasar por este estado de la base de datos. Incluso si después añadimos una API de administración, un trabajo en segundo plano de rotación de claves o una migración que importe claves desde otro sistema, la base de datos seguirá rechazando estados imposibles como una clave activa con una marca de tiempo de revocación.

Construir el cliente de Venice

A continuación, crearemos src/venice.rs. El cliente solo necesita conocer la URL upstream de chat completions, la clave de API de Venice y cuántas veces reintentar ante fallos transitorios. Mantener este wrapper pequeño es intencionado: el gateway no debería reimplementar toda la API de Venice. A un nivel básico, el trabajo del gateway es adjuntar la credencial del lado del servidor, aplicar un timeout, reintentar las peticiones que sea seguro reintentar y devolver la respuesta upstream en una forma que el router pueda reenviar.
Para peticiones sin streaming, podemos reintentar errores de conexión, timeouts y códigos de estado HTTP transitorios:
Los reintentos solo se aplican al camino sin streaming. Una vez que una respuesta en streaming ha comenzado, reintentar dentro del gateway podría duplicar la salida parcial o confundir a los clientes que ya recibieron chunks. Para streaming, el valor por defecto más apropiado es exponer el error y dejar que quien llama decida si reintenta la petición completa. Para streaming, creamos un EventSource a partir de la misma petición:
El endpoint de chat completions de Venice es compatible con OpenAI, así que el gateway puede aceptar un cuerpo familiar:
Puedes cambiar el modelo por cualquier modelo con capacidad de chat disponible en tu cuenta de Venice. Fíjate en que el cuerpo de la petición sigue siendo un serde_json::Value. Es una elección deliberada de compatibilidad. Si modelamos en Rust cada campo posible de chat completion, tenemos que mantener el gateway actualizado cada vez que la API upstream añada una opción útil. Al parsear solo lo que necesitamos en otras partes, permitimos que los parámetros más nuevos de Venice pasen sin necesidad de una nueva release del gateway.

Compartir el estado de la aplicación

Crea src/state.rs:
Axum clona el estado dentro de los handlers, así que el propio estado debería ser barato de clonar. PgPool ya es un handle a un pool compartido, y Arc<Config> mantiene también barata la configuración. Esto da a cada handler acceso a las mismas tres cosas: configuración inmutable, conexiones a la base de datos en pool y el cliente de Venice. Mantenerlas en un único AppState también hace más sencillo el testing más adelante, porque los handlers reciben sus dependencias a través del estado de Axum en lugar de leer variables globales.

Autenticar claves de API del gateway

El cliente envía su clave del gateway así:
Crea src/auth.rs e implementa un extractor de Axum. El extractor permite que los handlers protegidos declaren que requieren una clave autenticada:
El flujo real de autenticación es:
  1. Parsear el token bearer.
  2. Tomar los primeros 12 bytes como prefijo de la clave.
  3. Hashear el token candidato completo con SHA-256.
  4. Cargar la fila de la clave activa por prefijo.
  5. Comparar en tiempo constante el hash almacenado y el hash candidato.
Esto mantiene separadas las credenciales upstream y las del gateway. Tus aplicaciones de producción pueden rotar las claves del gateway sin cambiar la clave de API de Venice, y la clave de Venice nunca necesita salir del servidor. El patrón del extractor es útil porque la autenticación se convierte en parte de la firma de tipos del handler. Una ruta que acepte AuthenticatedApiKey no puede saltarse accidentalmente la auth dentro del cuerpo de la función; Axum tiene que construir ese valor antes de que el handler se ejecute. Eso hace que el camino protegido sea fácil de auditar.

Añadir rate limits de ventana fija

Crea src/rate_limit.rs. El limitador de tasa usa una única sentencia SQL para insertar una nueva ventana o incrementar la existente:
La cláusula WHERE api_key_rate_limit_windows.request_count < $3 es la parte importante. Cuando la ventana ya está llena, Postgres no actualiza la fila y RETURNING no produce ninguna fila. El handler puede convertir eso en una respuesta 429 Too Many Requests con una cabecera Retry-After. Una ventana fija no es el limitador de tasa más sofisticado, pero es fácil de explicar, fácil de inspeccionar y suficientemente bueno para un tutorial de gateway. La contrapartida es que el tráfico puede acumularse alrededor de los límites de la ventana. Si necesitas un comportamiento más suave a escala, un token bucket o un limitador de ventana deslizante respaldado por Redis es un siguiente paso natural.

Devolver errores estilo OpenAI

Crea src/error.rs y haz que los errores de la aplicación implementen IntoResponse:
Para los errores generados por el gateway, devuelve un cuerpo JSON con la forma de los errores comunes de las APIs de modelos:
Para los errores upstream de Venice, conserva el código de estado y el cuerpo upstream. Eso hace la depuración mucho más fácil para los clientes, porque los errores de validación a nivel de proveedor siguen pareciendo errores de validación a nivel de proveedor. Esta división mantiene al gateway honesto sobre de dónde proviene un error. Si el gateway rechaza una petición porque falta el token bearer o quien llama excede el límite, devuelve un error con la forma del gateway. Si Venice rechaza la petición del modelo, conservamos el cuerpo upstream para que los desarrolladores cliente puedan ver el mensaje de validación del proveedor en lugar de un fallo genérico del proxy.

Construir el router

Ahora podemos cablear las rutas HTTP en src/router.rs:
El handler de chat comienza requiriendo un AuthenticatedApiKey. Si la autenticación falla, Axum nunca entra en el cuerpo del handler:
El gateway valida solo los campos que necesita para el comportamiento del gateway: model, messages y stream. Todo lo demás en el cuerpo JSON pasa a Venice. Eso mantiene al gateway compatible con las funcionalidades del proveedor que quizá quieras usar más adelante. El handler también hace explícitos los dos modos de respuesta. Las peticiones sin streaming esperan a que Venice devuelva una respuesta JSON completa y luego registran los metadatos de la respuesta antes de enviar los bytes al cliente. Las peticiones en streaming devuelven inmediatamente un cuerpo text/event-stream respaldado por un stream asíncrono. Esa división mantiene sencillo el camino sin streaming y da al camino en streaming el control suficiente para observar los chunks mientras pasan.

Soportar respuestas en streaming

Los chat completions en streaming usan server-sent events. Venice envía datos SSE, y el gateway retransmite esos datos al cliente. El gateway debería evitar hacer buffering de todo el stream, porque eso desvirtuaría el propósito del streaming. A los usuarios les importa el tiempo hasta el primer token, no solo el tiempo hasta el último token. Al reenviar cada evento upstream a medida que llega, los clientes pueden renderizar salida parcial mientras el modelo aún está generando. Crea src/sse.rs:
Cada mensaje se codifica de vuelta al formato SSE:
Esto preserva la experiencia de cliente que esperan los SDKs compatibles con OpenAI: los chunks llegan como eventos data: ..., y el stream termina con data: [DONE]. El observador del stream es también el lugar donde podemos recolectar metadatos sin cambiar lo que ve el cliente. Cada chunk se reenvía en formato SSE, pero el gateway todavía puede vigilar los IDs de respuesta, los finish reasons, el uso de tokens, los campos de coste y la información de tiempos a medida que esos chunks pasan.

Registrar telemetría GenAI

Los gateways son útiles porque cada petición pasa por un único lugar. Eso los convierte en un gran sitio para registrar modelo, latencia, uso de tokens, finish reasons, coste de facturación y tiempos de streaming. Crea src/telemetry.rs y empieza parseando la petición:
Después crea un span usando atributos semánticos GenAI:
Cuando llega una respuesta sin streaming, deserializa los campos conocidos de metadatos de respuesta en structs. El gateway sigue reenviando los bytes originales al cliente, pero la telemetría no necesita recorrer JSON arbitrario. El log de facturación usa el UUID de la clave del gateway en lugar del token bearer en texto plano, y el ID de petición viene del id de la respuesta de Venice:
Para respuestas en streaming, registra el tiempo hasta el primer chunk y el tiempo entre chunks de salida mientras se retransmite el stream SSE. Estas métricas son especialmente útiles cuando te importa la latencia percibida, no solo el tiempo total de la petición. La telemetría es donde un gateway se convierte en algo más que un proxy. Una vez que los spans incluyen el modelo solicitado, el modelo upstream, los conteos de tokens, los finish reasons, el estado y los logs de facturación por clave, puedes responder preguntas operativas prácticas: qué clientes gastan más, qué modelos son más lentos, si el streaming está mejorando la latencia percibida y si los errores vienen de auth, rate limits, transporte o del proveedor del modelo.

Arrancar el servidor

Ahora conecta todo en src/main.rs:
Al arrancar, el gateway:
  1. Lee la configuración.
  2. Inicializa la telemetría.
  3. Se conecta a Postgres.
  4. Ejecuta las migraciones de SQLx.
  5. Construye el estado compartido de la app.
  6. Arranca el servidor de Axum.
Ejecutar las migraciones al arrancar es cómodo para este tutorial, porque docker compose up puede llevar toda la pila a un estado funcional. En un despliegue de producción más grande, quizá prefieras ejecutar las migraciones como un paso de release separado para que los cambios de esquema se revisen y apliquen antes de que arranquen nuevas instancias del gateway.

Sembrar una clave local del gateway

Para desarrollo local, crea scripts/seed_api_key.sh. El script inserta una clave de API del gateway en Postgres almacenando su prefijo y su hash SHA-256:
La clave local por defecto es:
Para un despliegue real, genera claves aleatorias más largas, muéstralas una sola vez a quien llama y almacena solo el hash. El script de siembra es intencionadamente aburrido porque las credenciales locales deberían ser fáciles de recrear. La versión de producción es donde añadirías una generación de claves más robusta, un registro de auditoría, expiración y un flujo de mostrado único.

Ejecutar localmente

Para ejecutar localmente, usaremos Docker Compose para arrancar tanto el gateway como Postgres. Esto mantiene el tutorial reproducible: los lectores no necesitan una base de datos configurada manualmente, y el gateway puede usar la misma forma de DATABASE_URL que usaría en un despliegue en contenedores.
También necesitaremos un pequeño Dockerfile que compile el binario de Rust y lo copie a una imagen de runtime más pequeña:
Para ejecutar la pila, usa el siguiente comando:
No olvides que también puedes ejecutarlo en segundo plano con el flag -d si quieres usar tu terminal para otras cosas después (y luego usar docker compose down para eliminarlo). En otra terminal, siembra la clave de desarrollo del gateway:
Si tu máquina ya tiene Postgres corriendo en el puerto 5432, quita el mapeo de puerto del host para el servicio Postgres de Compose. El gateway solo necesita alcanzar Postgres en la red interna de Docker. Lo importante a tener en cuenta es que la clave de API de Venice solo debe estar en el entorno del gateway. Las peticiones de los clientes deberían usar la clave del gateway sembrada. Esa separación es todo el propósito de poner un gateway delante del proveedor del modelo.

Probar el gateway

Primero, comprueba la salud:
Deberías ver:
Ahora envía una petición de chat completion sin streaming:
La respuesta debería parecerse a un chat completion compatible con OpenAI:
Para streaming:
Deberías ver chunks SSE:
El repositorio también incluye un script de smoke test:
Para comprobaciones locales de calidad del código, ejecuta:
Probar ambos modos de respuesta importa porque ejercitan caminos distintos del proxy. La prueba sin streaming demuestra que la auth, el rate limiting, el reenvío upstream y la telemetría de respuesta JSON funcionan. La prueba de streaming demuestra que el gateway puede mantener abierta una conexión SSE y reenviar los chunks sin hacer buffering de la respuesta final primero.

Extender este gateway

Este gateway es intencionadamente pequeño, pero te da una base sólida. Buenos siguientes pasos incluyen:
  • Añadir presupuestos por sujeto y límites de gasto mensuales.
  • Soportar múltiples proveedores upstream detrás de la misma API compatible con OpenAI.
  • Almacenar metadatos de las peticiones para logs de auditoría manteniendo el registro de prompts desactivado por defecto.
  • Añadir una API de administración para crear, revocar y rotar claves del gateway.
  • Añadir listas de modelos permitidos por clave de API.
  • Añadir Redis u otro almacén compartido si necesitas un rate limiting con menor latencia entre muchas instancias del gateway.
La idea principal de diseño es mantener la política en el gateway y la inferencia en Venice. Eso permite que las aplicaciones cliente usen una API familiar mientras tu plataforma conserva el control sobre claves, uso, límites y observabilidad.

Cerrando

¡Gracias por leer! Espero que esto te haya ayudado a ver cómo construir un LLM gateway práctico en Rust sin convertirlo en un proyecto de plataforma enorme. Combinando Axum, Postgres, SQLx, OpenTelemetry y la API de chat completions compatible con OpenAI de Venice, podemos construir un gateway lo bastante pequeño como para entenderlo y lo bastante útil como para colocarlo delante de aplicaciones reales.