Saltar al contenido principal
intermediatePart 1

Un endpoint, muchos modelos: despliega un gateway LLM en una instancia WEC

· 16 min de lectura
Rafael Fernandes
Ingeniero de PLN y Redactor Técnico en WiLine
Share:
+LiteLLM+
0/4
🎯 Skill path0/4 earned
Self-hosting an LLM gateway
  • 1One endpoint, scoped keys
  • 2Route work to the right model
  • 3Mask PII at the gateway
  • 🏆Budgets and real cost

Así es como suele ir. Una aplicación necesita un modelo, así que pegas la clave API en su .env. Luego, una segunda aplicación necesita una. Luego un script. Seis meses después, la misma clave está en cinco lugares, nadie recuerda cuál de ellas sigue funcionando, y no puedes rotarla sin romper algo que solo descubrirás cuando se rompa.

Un gateway es la solución aburrida. Un endpoint frente a cada modelo, un lugar que mantiene la verdadera credencial, y una clave específica por aplicación que puedes revocar por sí sola. Esta publicación despliega uno en una instancia WEC y lo apunta a la API de Inferencia WEC.

Lo que estamos poniendo en la caja

El host ya ejecuta cinco otras pilas, lo cual es importante: este es el caso normal, no una VM limpia. Antes de agregar nada, verifica qué está escuchando y qué está libre:

docker ps --format '{{.Names}}\t{{.Ports}}'
sudo ss -tlnp | grep -E ':(4000|5432|5433)\b'
Salida
langfuse-postgres-1 127.0.0.1:5433->5432/tcp
langfuse-clickhouse-1 127.0.0.1:8123->8123/tcp, 127.0.0.1:9000->9000/tcp
openclaw-caddy-1 100.87.239.229:80->80/tcp, 100.87.239.229:443->443/tcp
...
LISTEN 0 244 127.0.0.1:5432 users:(("postgres",pid=892))
LISTEN 0 4096 127.0.0.1:5433 users:(("docker-proxy"))

El puerto 4000 está libre. Postgres 5432 pertenece al host y 5433 a Langfuse, así que la base de datos del gateway no obtiene ninguno: no publicará un puerto en absoluto.

Luego confirma que el backend responde antes de poner algo frente a él:

curl -s https://inference.wiline.com/v1/models \
-H "Authorization: Bearer $WEC_API_KEY" | jq -r '.data[].id'
Salida
whisper-large-v3
gemma4
wiline-coding
zai-org/GLM-5.2
Qwen2.5-3B-Instruct
Qwen3.5:9B
Qwen3.5-122B
kokoro
Llama3.1-8B-Instruct
bge-m3
Qwen3.5-9B
wiline-auto
wiline-cost

Trece modelos en una clave. Eso es lo que estamos a punto de dejar de repartir.

La pila

Dos contenedores: el gateway y un Postgres para su propio estado. Crea un directorio y escribe el archivo de composición:

mkdir -p ~/llm-gateway && cd ~/llm-gateway
docker-compose.yml
services:
litellm:
image: ghcr.io/berriai/litellm:main-stable
container_name: llm-gateway
restart: unless-stopped
ports:
- "127.0.0.1:4000:4000"
volumes:
- ./config.yaml:/app/config.yaml:ro
command: ["--config", "/app/config.yaml", "--port", "4000"]
env_file: .env
depends_on:
db:
condition: service_healthy

db:
image: postgres:17
container_name: llm-gateway-db
restart: unless-stopped
user: "999:999"
environment:
POSTGRES_USER: llmproxy
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: litellm
volumes:
- gateway-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U llmproxy -d litellm"]
interval: 5s
timeout: 5s
retries: 10

volumes:
gateway-db:

Cuatro de esas líneas están haciendo trabajo real:

127.0.0.1:4000:4000 mantiene el gateway fuera de Internet público. Mantiene cada credencial de modelo que posees, así que publicarlo en 0.0.0.0 pondría todas detrás de una sola contraseña compartida. Parte 1 de la serie de endurecimiento es la versión larga de por qué eso es un mal intercambio.

La base de datos no tiene ports: en absoluto. Nada fuera de la red de composición necesita alcanzarla, y 5432 ya estaba ocupado.

user: "999:999" ejecuta Postgres como su usuario no root incorporado desde el primer arranque. Parte 2 cubre lo que eso te ofrece y cómo encontrar el UID correcto para una imagen en lugar de adivinar.

condition: service_healthy importa más de lo que parece. El gateway ejecuta migraciones de base de datos al inicio: sin esto, compite con Postgres y se reinicia en bucle.

Informándole sobre los modelos

config.yaml
model_list:
- model_name: qwen-small
litellm_params:
model: openai/Qwen2.5-3B-Instruct
api_base: https://inference.wiline.com/v1
api_key: os.environ/WEC_API_KEY

- model_name: qwen-mid
litellm_params:
model: openai/Qwen3.5-9B
api_base: https://inference.wiline.com/v1
api_key: os.environ/WEC_API_KEY

- model_name: qwen-large
litellm_params:
model: openai/Qwen3.5-122B
api_base: https://inference.wiline.com/v1
api_key: os.environ/WEC_API_KEY

- model_name: embeddings
litellm_params:
model: openai/bge-m3
api_base: https://inference.wiline.com/v1
api_key: os.environ/WEC_API_KEY

general_settings:
master_key: os.environ/LITELLM_MASTER_KEY
database_url: os.environ/DATABASE_URL
store_model_in_db: true

litellm_settings:
drop_params: true

model_name es el alias que tus aplicaciones piden. model: es lo que el gateway realmente llama. Esa indirecta es la mayor parte del valor: cambia qwen-mid para apuntar a otro lugar el próximo mes y ningún cliente cambia.

El prefijo openai/ dice "habla el protocolo OpenAI a un api_base personalizado." La API de Inferencia WEC es compatible con OpenAI, así que eso es todo lo que se necesita.

Secretos

Genera las contraseñas en lugar de inventarlas:

PGPW=$(openssl rand -hex 16)
MK="sk-$(openssl rand -hex 20)"
cat > .env <<EOF
POSTGRES_PASSWORD=${PGPW}
DATABASE_URL=postgresql://llmproxy:${PGPW}@db:5432/litellm
LITELLM_MASTER_KEY=${MK}
WEC_API_KEY=your-wec-key-here
EOF
chmod 600 .env

Luego pon tu verdadera clave WEC en lugar de your-wec-key-here. La clave maestra es la credencial raíz del gateway: puede crear claves, eliminar claves y acceder a cada modelo, así que va en tu gestor de contraseñas y en ningún otro lugar.

Levantándolo

docker compose up -d
Salida
✔ Imagen ghcr.io/berriai/litellm:main-stable Descargada 58.2s
✔ Red llm-gateway_default Creada 0.1s
✔ Volumen llm-gateway_gateway-db Creado 0.1s
✔ Contenedor llm-gateway-db Saludable 8.8s
✔ Contenedor llm-gateway Creado 0.2s

docker compose up descargando la imagen y creando ambos contenedores Figura 1. Una descarga, dos contenedores, una red y un volumen.

Creado en lugar de Iniciado en esa última línea es normal para la salida de composición, pero verifica de todos modos — y verifica cuánto te costó la imagen en disco mientras estás ahí:

docker compose ps -a && echo && df -h /
Salida
NAME IMAGE SERVICE STATUS PORTS
llm-gateway ghcr.io/berriai/litellm:main-stable litellm Up 3 minutes 127.0.0.1:4000->4000/tcp
llm-gateway-db postgres:17 db Up 3 minutes (healthy) 5432/tcp

Filesystem Size Used Avail Use% Mounted on
/dev/vda1 58G 54G 4.2G 93% /

Nota la columna PORTS de la base de datos: 5432/tcp sin enlace de host delante de ella. La imagen del gateway es de 1.16GB, lo cual en una caja que ya ejecuta cinco pilas no es nada despreciable. Verifica que tienes espacio antes de comenzar, no después.

Dos líneas en el registro de inicio valen la pena leer, porque ambas se ven peores de lo que son:

docker compose logs litellm --tail 40
Salida
✅ Diferencia de migración aplicada con éxito
INFO: Inicio de la aplicación completo.
INFO: Uvicorn ejecutándose en http://0.0.0.0:4000 (Presiona CTRL+C para salir)
register_model: model=openai/Qwen2.5-3B-Instruct no en el mapa de costos incorporado y no
se coincidió con ninguna variante de prefijo/región; los campos de costo de caché se establecerán en 0.

Estado del contenedor, uso de disco y el registro de inicio con migraciones y advertencias del mapa de costos Figura 2. Migraciones aplicadas, inicio de la aplicación completo, y una advertencia del mapa de costos por modelo.

0.0.0.0:4000 es el enlace dentro del contenedor. La publicación del host sigue siendo 127.0.0.1, que es lo que realmente controla el acceso. Y la advertencia del mapa de costos es más específica de lo que parece: se trata de la fijación de precios de caché específicamente. La fijación de precios de tokens regulares sigue ocurriendo, lo cual resulta ser importante más adelante.

La primera llamada

set -a && . ./.env && set +a
curl -s http://127.0.0.1:4000/v1/chat/completions \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"qwen-mid","messages":[{"role":"user","content":"Responde exactamente: gateway works"}],"max_tokens":20}' | jq
Salida
"finish_reason": "length",
"message": {
"reasoning_content": "Proceso de Pensamiento:\n\n1. **Analiza la Solicitud:**\n * Entrada: \"",
"content": null
}
"usage": {"completion_tokens": 20, "prompt_tokens": 16, "total_tokens": 36}

Respuesta cruda con finish_reason length, reasoning_content poblado y content null Figura 3. Una llamada exitosa sin nada en ella.

content: null. La llamada funcionó: se enruta, llegó al backend, contó tokens — pero no hay respuesta en ella.

Qwen3.5-9B es un modelo de razonamiento. Gastó todos los veinte tokens pensando y no le quedó nada para hablar. El movimiento obvio es darle más espacio:

curl -s http://127.0.0.1:4000/v1/chat/completions \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"qwen-mid","messages":[{"role":"user","content":"Responde exactamente: gateway works"}],"max_tokens":200}' | jq '.choices[0].message.content, .usage'
Salida
null
{
"completion_tokens": 200,
"prompt_tokens": 16,
"total_tokens": 216
}

Doscientos tokens, aún nada. Y esta es la parte que vale la pena pausar: una ejecución anterior de ese mismo comando respondió, con 192 tokens. Mismo modelo, mismo aviso, diferente cantidad de pensamiento. No hay un max_tokens que puedas establecer que garantice una respuesta, porque la longitud del razonamiento no es fija.

Ahora el mismo aviso a través del modelo pequeño, con los veinte tokens originales:

curl -s http://127.0.0.1:4000/v1/chat/completions \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"qwen-small","messages":[{"role":"user","content":"Responde exactamente: gateway works"}],"max_tokens":20}' | jq '.choices[0].message.content, .usage'
Salida
"gateway works"
{
"completion_tokens": 3,
"prompt_tokens": 35,
"total_tokens": 38
}

Modelo de razonamiento quemando 200 tokens de finalización y devolviendo null, modelo pequeño respondiendo en 3 Figura 4. Doscientos tokens y ninguna respuesta, contra tres tokens y la respuesta.

Nada está roto aquí. El modelo de razonamiento está haciendo exactamente lo que se supone que debe hacer, en una pregunta que no lo necesitaba. La solución no es un presupuesto de tokens más grande: no enviar trabajo trivial a ese modelo en primer lugar. Lo cual es todo el argumento para el enrutamiento, y el tema de la próxima publicación.

Habilidad desbloqueada 🏅

Puedes poner un gateway frente a varios modelos, dar a cada uno un alias que tus aplicaciones llamen en lugar de un ID de modelo de proveedor, y leer una respuesta lo suficientemente bien como para distinguir un modelo de razonamiento de uno simple.

Demostrando que la puerta está cerrada

Un gateway que mantiene cada credencial que posees debería rechazar a cualquiera sin una clave. Vale la pena verificar en lugar de asumir:

curl -s -o /dev/null -w "sin clave: %{http_code}\n" http://127.0.0.1:4000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"qwen-mid","messages":[{"role":"user","content":"hola"}]}'

curl -s -o /dev/null -w "clave incorrecta: %{http_code}\n" http://127.0.0.1:4000/v1/chat/completions \
-H "Authorization: Bearer sk-not-a-real-key" -H "Content-Type: application/json" \
-d '{"model":"qwen-mid","messages":[{"role":"user","content":"hola"}]}'

curl -s -o /dev/null -w "clave maestra: %{http_code}\n" http://127.0.0.1:4000/v1/chat/completions \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" \
-d '{"model":"qwen-mid","messages":[{"role":"user","content":"hola"}],"max_tokens":5}'
Salida
sin clave: 401
clave incorrecta: 401
clave maestra: 200

Tres llamadas curl devolviendo 401, 401 y 200 Figura 5. Tanto anónimos como falsificados rechazados, la clave real a través.

La interfaz de administración

El gateway incluye una interfaz web, y está vinculada a localhost — que es el punto, así que accede a ella a través de SSH en lugar de abrir un puerto:

ssh -L 4000:127.0.0.1:4000 ubuntu@your-wec-instance

Deja eso funcionando y abre http://localhost:4000/ui. Nombre de usuario admin, la contraseña es la clave maestra de tu .env.

La pantalla de inicio de sesión del gateway, pidiendo admin más la clave maestra Figura 6. La página de inicio de sesión indica sus propias credenciales predeterminadas, lo cual es un buen recordatorio de que la clave maestra es lo único que está ahí.

Haz clic en Modelos + Endpoints:

Gestión de modelos listando qwen-small, qwen-mid, qwen-large y embeddings, todos mostrando costo cero Figura 7. Los cuatro alias registrados, cada uno mapeado a su objetivo openai/....

Claves que no pueden hacer todo

La clave maestra puede acceder a cada modelo y crear más claves. Ninguna aplicación debería tenerla nunca. En su lugar, emite una clave virtual específica para lo que esa aplicación realmente necesita.

Claves Virtuales+ Crear Nueva Clave:

CampoValor
Propietario
Equipodejar vacío
Nombre de Clavedemo-app
Modelosqwen-small
Presupuesto Máximo (USD)0.10
Restablecer Presupuestodiario

Dos cosas sobre este formulario. Presupuesto Máximo vive bajo Configuraciones Opcionales, que comienza colapsada — fácil de perder y luego preguntarse dónde fue el campo de presupuesto. Y dejar Modelos vacío significa todos los modelos, lo opuesto a lo que quieres aquí.

El formulario de creación de clave con qwen-small seleccionado y un presupuesto diario de diez centavos Figura 8. Específico para un modelo, limitado a diez centavos al día.

El diálogo Guardar tu Clave, advirtiendo que la clave no puede ser vista nuevamente Figura 9. El diálogo lo dice claramente. Créelo.

Copie la clave ahora

La clave generada se muestra una vez y nunca más. Cierra esto sin copiar y tu única opción es eliminar la clave y crear otra.

Ahora la parte que justifica todo el ejercicio. La misma clave, dos modelos:

curl -s http://127.0.0.1:4000/v1/chat/completions \
-H "Authorization: Bearer $VK" -H "Content-Type: application/json" \
-d '{"model":"qwen-small","messages":[{"role":"user","content":"di hola"}],"max_tokens":20}' | jq -r '.choices[0].message.content'
Salida
¡Hola! ¿Cómo puedo ayudarte hoy?
curl -s http://127.0.0.1:4000/v1/chat/completions \
-H "Authorization: Bearer $VK" -H "Content-Type: application/json" \
-d '{"model":"qwen-large","messages":[{"role":"user","content":"di hola"}],"max_tokens":20}' | jq
Salida
{
"error": {
"message": "clave no permitida para acceder al modelo. Esta clave solo puede acceder a modelos=['qwen-small']. Intentó acceder a qwen-large",
"type": "key_model_access_denied",
"param": "model",
"code": "403"
}
}

La misma clave virtual respondiendo en qwen-small y siendo rechazada con un 403 en qwen-large Figura 10. Una credencial, un modelo. Filtrarla y el radio de explosión es esa línea.

Compara eso con la situación en la que comenzamos: una clave en cinco archivos .env, con acceso a todos los trece modelos y sin forma de saber qué aplicación la está utilizando.

Habilidad desbloqueada 🏅

Puedes emitir una credencial específica por aplicación, restringirla a modelos específicos con un límite de gasto, y revocarla por sí sola sin tocar nada más.

Lo que el gateway registró

Cada llamada aterriza en un registro de gastos:

curl -s http://127.0.0.1:4000/spend/logs \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
| jq '[.[] | select(.spend > 0)] | .[-3:] | .[] | {model, spend, prompt_tokens, completion_tokens}'
Salida
{
"model": "openai/Qwen3.5-9B",
"spend": 8.520000000000001e-05,
"prompt_tokens": 16,
"completion_tokens": 20
}
{
"model": "openai/Qwen3.5-9B",
"spend": 2.62e-05,
"prompt_tokens": 11,
"completion_tokens": 5
}

Filas del registro de gastos mostrando conteos de tokens y una cifra en dólares por llamada Figura 11. Tokens contados por llamada, con un precio adjunto a cada uno.

Ese select(.spend > 0) no es cosmético. Las llamadas fallidas también se registran, con cero tokens y cero gasto: los 401 de antes están todos ahí con status: "failure". Útil por sí mismo: el registro de gastos también sirve como un registro de alguien intentando claves que no funcionan.

Los conteos de tokens son reales. Las cifras en dólares no lo son — no para ti, de todos modos. Estos son modelos autoalojados en tu propia infraestructura; el gateway los está fijando precios a partir de una tabla incorporada de tarifas de API públicas. El número es preciso y confiablemente incorrecto, lo cual es peor que en blanco. Arreglar eso significa declarar tu propio costo por token, y ese es el tema de la cuarta publicación de esta serie.

Dos cosas más en la entrada completa del registro, ambas útiles:

"litellm_overhead_time_ms": 36.189,
"messages": {},
"response": {}

El gateway mide su propia sobrecarga y la informa por solicitud: 36ms aquí, que es un número real al que puedes exigirle. Y los avisos y respuestas no se almacenan por defecto: el registro sabe que ocurrió una llamada y cuánto costó, no lo que se dijo.

Solución de problemas

El contenedor del gateway se inicia y sale inmediatamente

Casi siempre es la base de datos. Verifica docker compose logs litellm para errores de migración: si el gateway se levantó antes de que Postgres estuviera listo, falta la condición depends_on o el healthcheck no está pasando.

content regresa null con finish_reason: "length"

El modelo es un modelo de razonamiento y utilizó todo tu presupuesto de tokens pensando. Aumentar max_tokens ayuda pero no garantiza nada: el mismo aviso con 200 tokens respondió en una ejecución y devolvió null en la siguiente. Para trabajos que no necesitan razonamiento, envíalos a un modelo que no lo haga.

La interfaz de administración no carga

Está vinculada a 127.0.0.1, así que es inaccesible desde cualquier lugar que no sea la caja misma. Usa un túnel SSH. Si el túnel está activo y la página aún no carga, confirma que el puerto en docker compose ps coincide con el que has reenviado.

Una cadena de shell no hizo nada en silencio

Las cadenas unidas con && se detienen en el primer comando que falla, y un grep fallido cuenta como un fallo. Sourcing un archivo env cuyo nombre de variable adivinaste mal deja un marcador en su lugar y todo parece bien hasta un error de autenticación tres pasos después. Verifica los marcadores explícitamente:

grep -c 'your-wec-key-here' .env

Eliminando una clave virtual

Desde la terminal, por alias:

curl -s -X POST http://127.0.0.1:4000/key/delete \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{"key_aliases":["demo-app"]}' | jq
Finished this tutorial?
Mark it complete to earn One endpoint, scoped keys on your skill path.

Qué sigue

Ahora tienes un endpoint, una credencial por aplicación y un registro de cada llamada. La pregunta obvia es la que planteó la Figura 4: si el modelo pequeño responde en 3 tokens lo que el modelo de razonamiento gasta 200 sin responder en absoluto, ¿por qué se está enrutando cualquier cosa al costoso por defecto?

La próxima publicación pone al gateway a cargo de esa decisión y mide lo que cuesta pedirle.

Lectura adicional

Comments & questions

Hit an error, spotted a typo, or have a question? Leave a note below.