Un endpoint, muchos modelos: despliega un gateway LLM en una instancia WEC
+- 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'
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'
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
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
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
✔ 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
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 /
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
✅ 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.
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
"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}
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'
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 sí 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'
"gateway works"
{
"completion_tokens": 3,
"prompt_tokens": 35,
"total_tokens": 38
}
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.
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}'
sin clave: 401
clave incorrecta: 401
clave maestra: 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.
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:
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:
| Campo | Valor |
|---|---|
| Propietario | Tú |
| Equipo | dejar vacío |
| Nombre de Clave | demo-app |
| Modelos | qwen-small |
| Presupuesto Máximo (USD) | 0.10 |
| Restablecer Presupuesto | diario |
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í.
Figura 8. Específico para un modelo, limitado a diez centavos al día.
Figura 9. El diálogo lo dice claramente. Créelo.
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'
¡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
{
"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"
}
}
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.
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}'
{
"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
}
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
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.
