Claves virtuales, presupuestos y límites en LiteLLM: la capa que decide quién consume la GPU, y las cuatro cosas que la documentación cuenta mal

Tercer artículo del track operativo de la capa de control. El par con Langfuse trató la observabilidad, y el día 2 del proxy la disponibilidad. Aquí toca el gobierno: quién puede llamar, a qué modelo, cuánto y con qué presupuesto. El modelo económico que hay detrás de esas cifras está en FinOps y multi-tenencia con LiteLLM.

TL;DR

En una plataforma sobre GPUs propias no hay factura de proveedor que ordene el consumo. La capacidad es fija, conocida y compartida, así que el reparto lo tiene que imponer alguien, y ese alguien es el gateway.

La jerarquía tiene seis niveles: organización, equipo, miembro de equipo, usuario interno, clave y cliente final, más los ámbitos de proyecto y etiqueta. Los presupuestos de cada nivel se evalúan de forma independiente y todos tienen que pasar. No se suman ni se anulan entre sí, con una excepción.

La excepción está en el código y es la trampa de gobierno más importante del sistema. Una clave que pertenece a un equipo suprime la comprobación del presupuesto personal de su dueño, salvo que se active apply_user_budget_to_team_keys. Un usuario con tope de cien euros que trabaje con la clave de su equipo gasta lo que el equipo le permita.

Rotar una clave conserva su gasto. /key/regenerate hace un UPDATE sobre la misma fila cambiando el hash, de modo que spend, max_budget y la ventana de presupuesto siguen intactos. Admite periodo de gracia, guardado en una tabla propia, y si el formato del periodo es incorrecto se ignora sin bloquear nada.

El limitador v3 ya es el que viene por defecto en la 1.100.0, y se vuelve al anterior con una variable de entorno. Su ventana es deslizante, de sesenta segundos, anclada a la primera petición, y los contadores se incrementan con scripts Lua dentro de Redis usando la marca de tiempo del propio Redis para cerrar la carrera entre réplicas.

Y cuatro puntos donde la documentación oficial no coincide con el código, verificados sobre la etiqueta v1.100.0: el presupuesto agotado devuelve 429, no 400; las acciones auditadas son seis, no tres; /key/info y /key/list son GET, no POST; y el limitador v3 arrastra un docstring que dice que no está listo para producción mientras es el que se carga por defecto.

Estás aquí: el gateway como frontera administrativa

Sin esta capa, una plataforma de inferencia propia tiene un único modo de operación: quien conoce la URL, consume. Funciona mientras hay un equipo. Deja de funcionar el día que hay tres, o que alguien conecta un agente que dispara veinte llamadas por interacción y se lleva por delante el tiempo hasta el primer token de todos los demás.

Las dos preguntas que esta capa responde son distintas y hay que separarlas. La de capacidad es instantánea: cuántas peticiones y cuántos tokens por minuto puede meter cada consumidor, para que la flota no se sature y la latencia se mantenga dentro del acuerdo de servicio. La de coste es acumulada: cuánto lleva gastado cada consumidor este mes contra su presupuesto. Se configuran en el mismo sitio, se aplican en momentos distintos y fallan de formas distintas.

La analogía: el llavero del edificio

Un edificio de oficinas reparte llaves en tres niveles. La maestra abre todo y la tiene el administrador. Las de planta abren una planta entera y las tiene cada empresa arrendataria. Y las de despacho abren un despacho.

Tres propiedades de ese sistema son las que importan aquí. Una llave se sustituye sin cambiar la cerradura de todo el edificio, y el inquilino conserva su plaza de garaje y su contador de luz: eso es la rotación que conserva el gasto. Una llave perdida se anula desde el cuadro central sin recoger nada físico: eso es el bloqueo desde la interfaz. Y el registro de quién retiró cada llave y cuándo vive en un libro aparte, que es lo que se le enseña a un inspector: eso es la tabla de auditoría.

La analogía se rompe en un punto, y es el punto donde está el fallo de gobierno de este sistema. En el edificio, tener llave de planta no anula el límite de consumo del despacho. En LiteLLM, sí.

La jerarquía, y la línea que la rompe

Los niveles y sus endpoints de gestión:

NivelEndpointsPresupuesto
Organización/organization/new, /organization/member_add, …max_budget
Equipo/team/new, /team/member_add, /team/block, …max_budget
Miembro de equipo/team/member_add con max_budget_in_teamFila propia en la tabla de presupuestos
Usuario interno/user/new, /user/update, …max_budget
Clave/key/generate, /key/update, …max_budget, soft_budget
Cliente final/customer/new, y el juego paralelo /end_user/*max_budget

El comportamiento por defecto es el correcto para una plataforma multi-inquilino: cada ámbito lleva su propio contador y la comprobación de cada uno es independiente de las demás. Una petición pasa si pasan todas.

Y luego está esta condición, que vive en las comprobaciones de autorización:

is_team_key = team_object is not None and team_object.team_id is not None
if is_team_key and general_settings.get("apply_user_budget_to_team_keys") is not True:
    return

Traducido: cuando la clave pertenece a un equipo, la comprobación del presupuesto del usuario dueño de esa clave retorna sin evaluar nada. El tope personal existe en la base de datos, se ve en la interfaz, y no se aplica.

Para un despliegue donde los presupuestos personales son decorativos, da igual. Para uno donde el tope por persona es el control que se le enseñó a alguien como garantía de que un usuario no puede desbordar la plataforma, es un agujero. La línea que lo cierra es una:

general_settings:
  apply_user_budget_to_team_keys: true

Claves virtuales: el ciclo de vida completo

Los endpoints existen todos y algunos no están donde se espera:

RutaMétodoPara qué
/key/generatePOSTCrear
/key/updatePOSTModificar
/key/infoGETConsultar una
/key/listGETListar
/key/deletePOSTBorrar
/key/regeneratePOSTRotar
/key/block, /key/unblockPOSTSuspender y reactivar
/key/healthPOSTComprobar los callbacks de esa clave

Los dos marcados en negrita son GET, y varios ejemplos de la documentación dan a entender lo contrario. /key/health tampoco es lo que su nombre sugiere: no valida la clave ni su presupuesto, comprueba los callbacks de registro asociados a ella.

Los parámetros de creación son muchos, y los que gobiernan son estos:

curl -X POST 'https://gateway.interno/key/generate' \
  -H 'Authorization: Bearer sk-...' \
  -d '{
    "key_alias": "equipo-datos-notebooks",
    "team_id": "t-datos",
    "models": ["llama-70b", "qwen-30b"],
    "max_budget": 250,
    "budget_duration": "30d",
    "tpm_limit": 400000,
    "rpm_limit": 600,
    "max_parallel_requests": 8,
    "duration": "90d",
    "tags": ["produccion", "notebooks"]
  }'

Junto a esos hay controles que rara vez se usan y resuelven problemas concretos: model_max_budget para poner tope por modelo dentro de la misma clave, model_tpm_limit y model_rpm_limit para lo mismo con capacidad, enforced_params para exigir que ciertas claves lleguen siempre con determinados campos, allowed_routes para que una clave de aplicación no pueda tocar los endpoints de gestión, y blocked para nacer suspendida.

Cómo se guardan

El hash de una clave es un SHA-256 hexadecimal de una sola pasada, sin sal y sin función de derivación:

hashed_token = hashlib.sha256(token.encode()).hexdigest()

La fila vive en LiteLLM_VerificationToken, con el hash como clave primaria, y contiene el gasto acumulado, los límites, la caducidad, los modelos permitidos y el resto de la configuración.

Dos consecuencias que hay que tener claras antes de una auditoría. La primera es que este hash no está pensado para resistir un ataque de diccionario sobre la base de datos, y el propio código lo reconoce en un comentario sobre la derivación de claves de cifrado, donde admite que un SHA-256 sin sal de una pasada no es una función de derivación y que pasar a HKDF sería más defendible en una auditoría. La mitigación real es que las claves virtuales se generan con suficiente entropía, no la fortaleza del hash. La segunda es que LITELLM_SALT_KEY no interviene aquí: esa variable cifra las credenciales de proveedor almacenadas, no las claves virtuales.

Sobre esa variable hay una trampa que tumba un despliegue entero. Si no está definida, cae por defecto en la clave maestra. Rotar LITELLM_MASTER_KEY sin haber fijado antes una LITELLM_SALT_KEY propia deja ilegibles todas las credenciales de proveedor guardadas, y el fallo no bloquea: registra un error y devuelve nulo, con lo que el síntoma aparece como modelos que dejan de autenticarse sin explicación. Fijar LITELLM_SALT_KEY el día uno, y no tocarla nunca, es de las cosas que más disgustos ahorra.

Rotación

/key/regenerate no crea una clave nueva y borra la vieja. Actualiza la misma fila cambiando el hash, de modo que el gasto acumulado, el presupuesto máximo y la ventana de reinicio se conservan. Para un chargeback mensual esto es exactamente lo que se quiere: rotar a mitad de mes no pone el contador a cero.

Además admite periodo de gracia, para que la clave antigua siga siendo válida mientras los consumidores se actualizan:

curl -X POST 'https://gateway.interno/key/sk-vieja/regenerate' \
  -H 'Authorization: Bearer sk-...' \
  -d '{"grace_period": "24h"}'

El hash antiguo se guarda en una tabla aparte con su fecha de revocación. Sin ese parámetro, la revocación es inmediata, y el valor por defecto se puede fijar con LITELLM_KEY_ROTATION_GRACE_PERIOD.

Un detalle con dientes: si el formato del periodo es incorrecto, el sistema registra un aviso y continúa sin periodo de gracia. La rotación se completa, la clave vieja muere en el acto, y lo único que queda es una línea de log. En un procedimiento de rotación automatizado, esa combinación de “sigue adelante” y “avisa en el log” es la que produce el corte de servicio a las tres de la mañana.

Qué pasa exactamente cuando se agota un presupuesto

El código lanza BudgetExceededError con status 429. La documentación en algún punto dice 400, y en eso manda el código. Que sea 429 tiene una implicación práctica: los clientes de OpenAI reintentan automáticamente ante un 429, así que un presupuesto agotado se convierte en una tormenta de reintentos contra un gateway que va a seguir rechazando. Los clientes internos deberían distinguir el motivo, que viaja en el mensaje.

Los mensajes son plantillas y dicen el ámbito, el gasto y el tope:

Budget has been exceeded! Key=<clave> Current cost: <gasto>, Max budget: <tope>
Budget has been exceeded! Team=<equipo> Current cost: <gasto>, Max budget: <tope>
ExceededBudget: User=<usuario> over budget. Spend=<gasto>, Budget=<tope>
ExceededBudget: End User=<cliente> over budget. Spend=<gasto>, Budget=<tope>
Budget has been exceeded! Organization=<org> ...
Budget has been exceeded! Tag=<etiqueta> ...

Dos precisiones sobre la comparación. Es gasto >= tope, con el signo igual incluido, así que llegar justo al tope ya bloquea. Y el presupuesto blando, soft_budget, no bloquea nada: solo escribe una línea de log al cruzarlo. Sirve para avisar, no para contener, y montar sobre él una alerta es la forma correcta de usarlo.

El resto de rechazos, con sus códigos:

SituaciónCódigoComportamiento
Presupuesto agotado429Mensaje con ámbito, gasto y tope
Clave caducada401Además borra la entrada de caché, así que la caducidad surte efecto en toda la flota sin esperar al TTL
Modelo no permitido403Tipo de error distinto según el ámbito: clave, equipo, usuario, organización o proyecto
Clave bloqueadaExcepción genéricaKey is blocked. Update via /key/unblock if you're an admin.

La última fila es una inconsistencia del proyecto que merece conocerse: el bloqueo lanza una excepción sin tipo ni código explícito, mientras la caducidad y el acceso a modelos sí los llevan. Cualquier alerta que discrimine por tipo de error no verá los bloqueos igual que ve lo demás.

Un apunte útil sobre modelos permitidos: la comprobación acepta tanto el alias como el modelo subyacente, de forma que una clave autorizada a llama-70b puede llamar por su alias o por su nombre real sin configuración adicional.

Límites de capacidad: el limitador v3

Este es el cambio silencioso más relevante del año en esta capa. En la 1.100.0, el limitador que se carga por defecto es la tercera versión. Para volver a la anterior hay que pedirlo de forma explícita:

LEGACY_MULTI_INSTANCE_RATE_LIMITING=true

No hay bandera para activarlo, solo para desactivarlo. Y arrastra una contradicción que hay que conocer antes de fiarse: el propio fichero sigue abriendo con un comentario que dice que está en desarrollo y no listo para producción, mientras es el que corre. El diseño está declaradamente inspirado en el de Envoy.

La ventana es deslizante y dura sesenta segundos, configurable con LITELLM_RATE_LIMIT_WINDOW_SIZE. No es un registro rodante exacto ni una ventana alineada con el reloj: se ancla a la primera petición y se reinicia cuando ha pasado el tamaño de ventana desde ese momento. Para límites de un minuto, la diferencia con una ventana de calendario aparece en los bordes y rara vez importa.

Lo que sí importa es Redis. Los contadores se incrementan con scripts Lua ejecutados dentro de Redis, usando la marca de tiempo del propio Redis y no la del pod, precisamente para que los reinicios de ventana sean deterministas entre réplicas y para cerrar la carrera entre comprobar y contar. Sin Redis, cada pod cuenta por su cuenta y el límite efectivo se multiplica por el número de pods. Un límite de 600 peticiones por minuto con cinco réplicas es un límite de 3.000.

Hay una imprecisión inherente en los límites de tokens que no se resuelve con Redis. El número de tokens de salida no se conoce hasta que la respuesta termina, así que el limitador cuenta con una estimación antes de la llamada y reconcilia después. El exceso posible es, por cada petición en vuelo, la diferencia entre lo estimado y lo real. Con concurrencia alta y respuestas largas, eso permite sobrepasar el tope dentro de una ventana. Para proteger la flota, el límite de peticiones concurrentes (max_parallel_requests) es un instrumento más directo que el de tokens por minuto.

Lo que ve el cliente

Al ser limitado, un 429 con el mensaje del ámbito, el límite actual, lo que queda y cuándo se reinicia. Y tres cabeceras: retry-after con el tamaño de ventana, es decir 60 por defecto, más rate_limit_type y reset_at.

En el camino de éxito, las cabeceras informativas usan un formato anidado propio:

x-ratelimit-api_key-remaining-requests
x-ratelimit-api_key-limit-tokens
x-ratelimit-team-remaining-requests

No son los nombres estándar x-ratelimit-limit-requests que emiten OpenAI y compatibles, así que un cliente que espere el formato habitual no encontrará nada. Hay además una incidencia abierta sobre estas cabeceras perdiéndose en respuestas en streaming, que es la mayoría del tráfico de un asistente.

Identidad: más allá de la clave estática

Repartir claves a mano funciona con tres equipos y deja de funcionar con treinta. Las dos vías para conectar el gateway a la identidad corporativa:

JWT. Se activa con enable_jwt_auth y se configura con el bloque litellm_jwtauth, que mapea campos del token a entidades de LiteLLM: el equipo sale de team_id_jwt_field, el usuario de user_id_jwt_field, la organización de org_id_jwt_field. Admite notación de punto para claims anidados, creación automática de usuarios y equipos con user_id_upsert y team_id_upsert, control de acceso basado en roles con enforce_rbac, restricción de modelos por equipo con enforce_team_based_model_access, y varios emisores a la vez. La clave pública del emisor se cachea con un TTL de 600 segundos por defecto.

general_settings:
  enable_jwt_auth: true
  litellm_jwtauth:
    team_id_jwt_field: "groups"
    user_id_jwt_field: "sub"
    user_email_jwt_field: "email"
    enforce_rbac: true
    enforce_team_based_model_access: true
    team_id_upsert: true

OIDC para la interfaz de administración. Con Keycloak se usa el proveedor genérico, y las variables son PROXY_BASE_URL, GENERIC_CLIENT_ID, GENERIC_CLIENT_SECRET, GENERIC_AUTHORIZATION_ENDPOINT, GENERIC_TOKEN_ENDPOINT, GENERIC_USERINFO_ENDPOINT, más un juego de atributos para mapear el identificador, el correo, el nombre y el rol, y GENERIC_ROLE_MAPPINGS_* para traducir grupos de Keycloak a roles de LiteLLM. El inicio de sesión está limitado en frecuencia y las sesiones tienen TTL propio.

Para el aprovisionamiento automático hay SCIM en la versión enterprise, con base /scim/v2, y la baja de un usuario bloquea sus claves y las saca de la caché de autenticación. Esa parte no está en el árbol abierto, así que no se puede verificar contra el código.

Esto encaja con el trabajo de identidad ya tratado en hardening y secretos del stack soberano y en el artículo sobre autenticación de MCP con Keycloak, que usa el mismo emisor.

Auditoría: qué queda registrado

La tabla es LiteLLM_AuditLog y sus columnas son las que se esperan de un registro de cambios:

id, updated_at, changed_by, changed_by_api_key,
action, table_name, object_id, before_value, updated_values

Guarda el valor anterior y el nuevo, y atribuye el cambio a un actor y a la clave con la que se hizo.

Tres precisiones que la documentación no recoge bien.

Las acciones son seis, en pasado: created, updated, deleted, blocked, unblocked y rotated. La documentación menciona tres. Que el bloqueo, el desbloqueo y la rotación sean acciones propias es justo lo que hace falta para reconstruir el ciclo de vida de una credencial ante un auditor.

El alcance es mayor de lo anunciado. Además de equipos, usuarios, claves y modelos, se auditan los cambios de configuración del proxy y de la configuración de SSO. Un cambio de emisor de identidad queda registrado.

El valor por defecto depende de la licencia. El orden de resolución es litellm_settings.store_audit_logs, luego la variable LITELLM_STORE_AUDIT_LOGS, y si ninguna está puesta, queda activado en enterprise y desactivado en la versión abierta. Un despliegue OSS que dé por hecho que hay auditoría no la tiene:

litellm_settings:
  store_audit_logs: true

Dos piezas más para una cadena de evidencia seria. La atribución del actor se puede pasar con la cabecera LiteLLM-Changed-By, y su uso está restringido por la configuración de la clave o del equipo, de modo que un llamante cualquiera no puede falsificarla. Y los eventos se pueden enviar fuera con audit_log_callbacks y sus parámetros de S3, que es lo que permite depositarlos en un almacén inmutable con bloqueo de objeto.

Esa última parte es la que convierte el registro en evidencia. La tabla en Postgres es mutable por quien tenga acceso a la base de datos, y a septiembre de 2026 no tiene índices, así que consultarla por rango de fechas sobre un histórico largo tampoco será cómodo.

Qué demuestra esto ante un auditor, y qué no

Esta es la parte que decide si el montaje sirve para un cliente regulado. Con todo lo anterior configurado, la plataforma sostiene sin dificultad:

Control de acceso por identidad corporativa, con altas y bajas en el proveedor de identidad y no en el gateway, y con el rol determinando a qué modelos se llega. Segregación entre inquilinos, con contadores independientes por organización, equipo y persona. Trazabilidad de los cambios administrativos, con actor, momento, valor anterior y valor nuevo, exportable a un almacén inmutable. Y límites de consumo demostrables, con la evidencia del rechazo en el log de gasto.

Lo que no cubre, y hay que resolver fuera:

El registro de las interacciones no es esto. La tabla de auditoría registra cambios de configuración, no llamadas al modelo. Las llamadas están en el log de gasto y en las trazas, y las trazas son best-effort, que es la discusión del primer artículo de la serie.

La integridad del registro no la aporta Postgres. Sin exportación a almacenamiento inmutable, la evidencia es tan fuerte como los permisos de la base de datos.

El contenido de los prompts es un problema aparte, con su propio tratamiento de datos personales y su decisión sobre si se guarda, se enmascara o no se registra.

Y una advertencia sobre el control que suele enseñarse primero: mientras apply_user_budget_to_team_keys no esté activado, el tope por persona no se está aplicando a las claves de equipo. Enseñar ese campo en la interfaz como prueba de un control que no se ejecuta es el tipo de hallazgo que un auditor competente encuentra. El mapeo completo de controles está en ENS, ISO 42001 y EU AI Act.

Checklist

  1. apply_user_budget_to_team_keys: true si los presupuestos personales tienen que aplicarse.
  2. LITELLM_SALT_KEY fijada el día uno, distinta de la clave maestra y nunca rotada a la ligera.
  3. store_audit_logs: true de forma explícita, sin confiar en el valor por defecto.
  4. audit_log_callbacks hacia un almacén inmutable si hay que sostener una auditoría.
  5. Redis obligatorio si hay más de una réplica, o los límites se multiplican por el número de pods.
  6. max_parallel_requests por clave, además de los límites por minuto, para proteger la flota de un agente desbocado.
  7. allowed_routes en las claves de aplicación, para que no alcancen los endpoints de gestión.
  8. soft_budget con una alerta encima, entendiendo que no bloquea.
  9. Rotación con grace_period verificado, porque un formato inválido revoca en el acto.
  10. JWT contra Keycloak en cuanto haya más de un puñado de equipos, con enforce_rbac y enforce_team_based_model_access.

Trampas y cosas que no son lo que parecen

El tope de un usuario no se aplica. Está usando una clave de equipo y falta apply_user_budget_to_team_keys.

Los clientes reintentan en bucle contra un presupuesto agotado. El código es 429 y las bibliotecas de OpenAI reintentan los 429 automáticamente.

El presupuesto blando no ha detenido nada. No bloquea, solo registra.

Los límites por minuto se cumplen al doble o al triple. No hay Redis, y cada réplica lleva su cuenta.

El cliente no ve las cabeceras de límite. Los nombres son anidados y propios, y en streaming hay una incidencia abierta por la que se pierden.

No hay registro de auditoría. Es un despliegue OSS y el valor por defecto ahí es desactivado.

Los modelos dejan de autenticarse tras rotar la clave maestra. No había LITELLM_SALT_KEY fija, y las credenciales de proveedor cifradas ya no se pueden descifrar.

La rotación cortó el servicio en el acto. El grace_period llevaba un formato que el sistema no entendió y siguió adelante sin él.

El bloqueo de una clave no dispara la alerta. Se lanza como excepción genérica, sin el tipo que sí llevan la caducidad y el acceso a modelos.

Cierre

La capa de claves y presupuestos es la que convierte una URL compartida en una plataforma con inquilinos. Está bien resuelta en LiteLLM y tiene más profundidad de la que la documentación sugiere, con presupuestos por modelo dentro de una clave, ventanas de presupuesto múltiples, límites por etiqueta y rotación que preserva el histórico.

De todo el artículo, dos cosas se llevan la atención. La línea de apply_user_budget_to_team_keys, porque es la diferencia entre tener un control y creer que se tiene. Y la fijación de LITELLM_SALT_KEY, porque su valor por defecto convierte una rotación rutinaria de la clave maestra en una caída difícil de diagnosticar. Las dos son una línea de configuración y las dos se descubren tarde.

Ver también

Fuentes