Governance: virtual keys, presupuestos y límites

Por: Artiko
bifrostgovernancevirtual-keysbudgetsrate-limits

Governance: virtual keys, presupuestos y límites

En el capítulo 8 aprendiste a reducir costo y latencia con el caché semántico. Esa optimización es valiosa, pero plantea una pregunta incómoda: ¿quién está gastando ese presupuesto y cómo evitas que un equipo, un cliente o un script descontrolado se coma toda tu cuota de OpenAI en una tarde? Aquí es donde entra el módulo de governance (gobierno) de Bifrost.

Hasta ahora repartiste tus claves reales (OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.) entre proveedores como vimos en el capítulo 4. El problema es evidente: si entregas esas claves a tus equipos, pierdes todo control. No sabes quién consume qué, no puedes ponerle techo a nadie y revocar una clave significa rotarla para todo el mundo. Governance resuelve esto introduciendo una capa de identidad y política entre quien consume y tus claves reales: las virtual keys.

En este capítulo construirás, paso a paso, un sistema de gobierno completo: virtual keys que aíslan a tus consumidores, una jerarquía de presupuestos que va desde la clave individual hasta el cliente, límites de rate por tokens y por requests, restricciones de qué modelos puede usar cada quien, headers obligatorios para atribución y, finalmente, enrutamiento inteligente que manda las consultas simples a modelos baratos y las complejas a modelos potentes.

Qué es una virtual key

Una virtual key (VK) es la entidad central de governance en Bifrost. Es una clave lógica (un string con prefijo sk-bf-*) que entregas a un equipo, una aplicación o un cliente sin exponer jamás tus API keys reales. La VK no es solo una credencial: es el punto donde se enganchan las políticas de acceso, presupuesto y límites.

Cada virtual key te da control sobre cuatro dimensiones:

Una propiedad clave es que la VK pertenece de forma mutuamente exclusiva a un equipo (team_id) O a un cliente (customer_id) O a ninguno de los dos. Nunca a ambos. Además tiene un estado is_active que te permite habilitarla o deshabilitarla al instante sin tocar tus claves reales.

flowchart LR
    Dev["Equipo / App / Cliente"] -->|"x-bf-vk: sk-bf-..."| VK["Virtual Key<br/>(política + presupuesto)"]
    VK -->|valida acceso| Acceso{"Modelo permitido?<br/>Presupuesto OK?<br/>Rate limit OK?"}
    Acceso -->|si| Real["Claves reales<br/>OPENAI_API_KEY<br/>ANTHROPIC_API_KEY"]
    Acceso -->|no| Rechazo["402 / 403 / 429"]
    Real --> Proveedor["Proveedor de IA"]

El header de autenticación

Para usar una virtual key en una petición de inferencia, la pasas en una cabecera HTTP. Bifrost acepta varias formas según el estilo de SDK que uses:

Nota de compatibilidad: las virtual keys antiguas (sin el prefijo sk-bf-*) solo se soportan con la cabecera x-bf-vk.

Una petición de inferencia con virtual key se ve así:

curl -X POST http://localhost:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "x-bf-vk: sk-bf-*" \
  -d '{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "Hola!"}]}'

El consumidor nunca ve tu clave real de OpenAI: solo conoce su sk-bf-*. Si mañana ese equipo deja la empresa, deshabilitas la VK y listo, sin rotar nada más.

Autenticación vs. virtual keys

Conviene separar dos capas que son independientes:

Cuando la autenticación está habilitada, usa x-bf-vk para la virtual key, porque la cabecera Authorization la consume la autenticación. Si configuras disable_auth_on_inference: true, la VK sí puede viajar en Authorization. Y si quieres forzar que toda petición de inferencia lleve una VK, activa enforce_auth_on_inference: true en tu configuración.

Crear virtual keys

Tienes tres maneras de crear virtual keys: la Web UI, la API de governance y el config.json. Veremoslas en orden de menor a mayor automatización.

Desde la Web UI

En la interfaz que conociste en el capítulo 3, navega a Virtual Keys → Add Virtual Key y configura:

Desde la API de governance

Para crear una VK adjunta a un equipo, haces un POST al endpoint de governance:

curl -X POST http://localhost:8080/api/governance/virtual-keys \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Engineering Team API",
    "description": "Main API key for engineering team",
    "provider_configs": [
      {
        "provider": "openai",
        "weight": 0.5,
        "allowed_models": ["gpt-4o-mini"]
      }
    ],
    "team_id": "team-eng-001",
    "budget": {
      "max_limit": 100.00,
      "reset_duration": "1M"
    },
    "rate_limit": {
      "token_max_limit": 10000,
      "token_reset_duration": "1h",
      "request_max_limit": 100,
      "request_reset_duration": "1m"
    },
    "key_ids": ["8c52039e-38c6-48b2-8016-0bd884b7befb"],
    "is_active": true
  }'

Para una VK adjunta directamente a un cliente, cambias team_id por customer_id:

curl -X POST http://localhost:8080/api/governance/virtual-keys \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Executive API Key",
    "description": "Direct customer-level API access",
    "provider_configs": [
      {
        "provider": "openai",
        "weight": 0.5,
        "allowed_models": ["gpt-4o"]
      }
    ],
    "customer_id": "customer-acme-corp",
    "budget": {
      "max_limit": 500.00,
      "reset_duration": "1M"
    },
    "is_active": true
  }'

El resto de operaciones CRUD sobre virtual keys son:

OperaciónMétodo y endpoint
Listar todasGET /api/governance/virtual-keys
Obtener unaGET /api/governance/virtual-keys/{vk_id}
ActualizarPUT /api/governance/virtual-keys/{vk_id}
EliminarDELETE /api/governance/virtual-keys/{vk_id}

Desde config.json

Si prefieres declarar tu gobierno como código, el bloque governance en config.json te permite definir virtual keys, presupuestos y rate limits de forma estática:

{
  "governance": {
    "virtual_keys": [
      {
        "id": "vk-001",
        "name": "Engineering Team API",
        "value": "sk-bf-*",
        "description": "Main API key for engineering team",
        "is_active": true,
        "provider_configs": [
          {
            "provider": "openai",
            "weight": 0.5,
            "allowed_models": ["gpt-4o-mini"],
            "key_ids": ["openai-primary"]
          }
        ],
        "team_id": "team-eng-001",
        "rate_limit_id": "rate-limit-eng-vk"
      }
    ],
    "budgets": [
      {
        "id": "budget-eng-vk",
        "virtual_key_id": "vk-001",
        "max_limit": 100.00,
        "reset_duration": "1M",
        "current_usage": 0.0,
        "last_reset": "2025-01-01T00:00:00Z"
      }
    ],
    "rate_limits": [
      {
        "id": "rate-limit-eng-vk",
        "token_max_limit": 10000,
        "token_reset_duration": "1h",
        "token_current_usage": 0,
        "request_max_limit": 100,
        "request_reset_duration": "1m",
        "request_current_usage": 0
      }
    ]
  }
}

Los campos clave de la virtual key en config.json son:

La jerarquía de presupuestos

Aquí está el corazón del control de costo. Bifrost organiza los presupuestos en una jerarquía que va de lo más específico a lo más general. La idea es que cada petición debe pasar todos los presupuestos que apliquen, no solo uno.

flowchart TD
    Cust["Customer<br/>budget propio"] --> Team["Team<br/>budget de departamento"]
    Team --> VK["Virtual Key<br/>budget individual"]
    VK --> PC["Provider Config<br/>budget por proveedor"]

    classDef nivel fill:#1e293b,stroke:#38bdf8,color:#e2e8f0;
    class Cust,Team,VK,PC nivel;

La estructura conceptual es Customer → Team → Virtual Key → Provider Config. Una virtual key puede colgar directamente de un Customer, de un Team, o funcionar de forma autónoma sin ninguno de los dos.

Cómo cascadean los presupuestos

La regla de oro la marca la propia documentación: “cada presupuesto debe tener saldo restante suficiente para que la petición proceda”. El sistema verifica los presupuestos secuencialmente. Si falla el del Provider Config, el de la VK, el del Team o el del Customer, la petición se bloquea por completo, no hay deducciones parciales.

Cuando la petición sí procede, “el costo se descuenta de todos los presupuestos aplicables: el mismo costo se aplica a cada nivel”. Es decir, una petición de $0.02 resta $0.02 al presupuesto de la VK, $0.02 al del Team y $0.02 al del Customer simultáneamente. Así cada nivel mantiene su propia visión del gasto acumulado.

Importante: el rate limiting aplica solo a nivel de virtual key. Los Teams y los Customers tienen presupuestos independientes pero no tienen rate limits propios.

Campos de un budget

Los campos del array budgets en config.json son:

Un ejemplo de presupuesto a nivel de provider config:

{
  "id": "budget-pc-openai",
  "provider_config_id": 1,
  "max_limit": 1000.00,
  "reset_duration": "1M",
  "calendar_aligned": true
}

Duraciones de reset

Los sufijos soportados para reset_duration son:

SufijoSignificado
mminuto
hhora
ddía
wsemana
Mmes
Yaño

La alineación de calendario (calendar_aligned: true) requiere periodos de nivel día o mayor. Las duraciones sub-horarias no pueden usar calendar_aligned.

Rate limits: tokens y requests

Mientras los presupuestos controlan el dinero, los rate limits controlan la frecuencia. Bifrost soporta dos comprobaciones paralelas que deben pasar ambas:

Un rate limit aislado en config.json se ve así:

{
  "id": "rl-gpt4o",
  "request_max_limit": 500,
  "request_reset_duration": "1h",
  "token_max_limit": 500000,
  "token_reset_duration": "1h"
}

Esto se traduce en: máximo 500 peticiones por hora y máximo 500 000 tokens por hora. En cuanto se cruza cualquiera de los dos límites, el proveedor queda excluido del enrutamiento y el cliente recibe un 429.

{
  "error": {
    "type": "rate_limited",
    "message": "Rate limits exceeded: [token limit exceeded (1500/1000, resets every 1h)]"
  }
}

Recuerda que las duraciones admiten formatos como "1h" (por hora), "1d" (por día) y "1M" (por mes), igual que los presupuestos.

Model limits: qué modelos puede usar cada VK

Hay dos mecanismos complementarios para restringir modelos.

allowed_models en provider_configs

La forma directa de limitar modelos es la lista allowed_models dentro de cada provider_config de la virtual key. Las reglas de validación son explícitas:

  1. allowed_models: ["*"] — permite todos los modelos soportados por el proveedor (valida contra el Model Catalog).
  2. allowed_models vacío ([] u omitido) — deny-all: niega todos los modelos.
  3. Lista explícita — solo se permiten los modelos listados.

El sistema funciona con deny-by-default: una virtual key sin provider_configs bloquea todos los proveedores hasta que los agregas explícitamente. Esto es seguridad por diseño: nadie tiene acceso a algo que no le concediste.

Un detalle crítico de enrutamiento: el cruce entre proveedores no ocurre automáticamente. Una petición de gpt-4o no se enruta a Anthropic a menos que agregues explícitamente "gpt-4o" al allowed_models de Anthropic.

Model limits con scope

El segundo mecanismo son los model limits, que aplican topes de gasto y de rate a modelos concretos, con un alcance configurable. Sus campos son:

Esto te permite, por ejemplo, ponerle un presupuesto global a gpt-4o independientemente de quién lo consuma, o ligar un rate limit específico al uso de un modelo caro dentro de una VK concreta. Todos los límites que coincidan se verifican de forma independiente durante el procesamiento de la petición, y “todos los presupuestos deben pasar para que una petición sea permitida”.

Códigos de error de governance

Cuando una política rechaza una petición, Bifrost devuelve un código HTTP que te dice exactamente qué falló:

CódigoSignificado
400Falta la virtual key (o falta un header requerido)
402Presupuesto excedido
403VK inactiva, o modelo/proveedor bloqueado
429Rate limit o límite de tokens excedido

Required headers: forzar atribución

A veces no basta con saber qué VK hizo la petición: necesitas metadata adicional para auditoría, aislamiento de tenants o ruteo. Para eso están los required headers (headers requeridos), que obligan a que ciertas cabeceras HTTP estén presentes en cada petición.

Sus casos de uso típicos:

Se configuran con el campo required_headers dentro de la sección client del config.json:

{
  "client": {
    "required_headers": ["X-Tenant-ID", "X-Correlation-ID"]
  }
}

El campo es de tipo string[], opcional, y el matcheo es case-insensitive. Si llega una petición sin alguno de esos headers, Bifrost responde con un 400 Bad Request antes de que la petición toque al proveedor:

{
  "error": {
    "message": "missing required headers: x-tenant-id, x-correlation-id",
    "type": "missing_required_headers"
  }
}

Un prerequisito importante: la validación de required headers requiere que governance esté habilitado. La comprobación aplica tanto a peticiones de inferencia LLM como a la ejecución de herramientas MCP que verás en el capítulo 10.

Governance routing: enrutamiento por petición

El enrutamiento por gobierno opera a través de la configuración de la virtual key y te da control granular sobre cómo se dirigen las peticiones. Ya viste los campos centrales (provider_configs, allowed_models, weight, key_ids), pero hay dos comportamientos que conviene entender bien: el balanceo por peso y los fallbacks automáticos.

Balanceo de carga por peso

Cuando varios proveedores tienen pesos numéricos, las peticiones se distribuyen proporcionalmente. Los pesos se normalizan automáticamente a una suma de 1.0 en base a los pesos de todos los proveedores disponibles.

{
  "governance": {
    "virtual_keys": [
      {
        "id": "vk-prod-main",
        "provider_configs": [
          {
            "provider": "openai",
            "allowed_models": ["gpt-4o", "gpt-4o-mini"],
            "weight": 0.2
          }
        ],
        "key_ids": ["key-prod-001"]
      }
    ]
  }
}

Por ejemplo, si una petición de gpt-4o puede ir a Azure (peso 80%) o a OpenAI (peso 20%), y ambos soportan el modelo, el 80% va a Azure y el 20% a OpenAI. Si el modelo solo lo soporta un proveedor, el 100% va a ese. Un weight: null excluye al proveedor de la selección ponderada.

Fallbacks automáticos

Si tu petición no trae un array fallbacks propio en el cuerpo, governance los genera automáticamente: ordena los proveedores por peso (de mayor a menor) y los agrega como fallbacks. Esto te da resiliencia sin intervención manual, complementando lo que viste en el capítulo 7. Si especificas fallbacks manualmente, esos tienen prioridad sobre la generación automática.

El complexity router: enrutar por complejidad

Llegamos a la pieza más inteligente del gobierno. El complexity router clasifica automáticamente cada petición entrante en cuatro niveles de complejidad y expone el resultado como la variable complexity_tier, que puedes usar en reglas de enrutamiento. La meta es directa: mandar las consultas simples a modelos baratos y las complejas a modelos potentes, optimizando costo sin tocar el código de tu aplicación.

flowchart TD
    Req["Peticion entrante"] --> Score["Scoring 0.0 - 1.0<br/>(5 dimensiones ponderadas)"]
    Score --> Tier{"complexity_tier"}
    Tier -->|< 0.15| S["SIMPLE → modelo barato<br/>(groq llama-3.1-8b)"]
    Tier -->|0.15 - 0.35| M["MEDIUM → modelo intermedio<br/>(gpt-4o-mini)"]
    Tier -->|0.35 - 0.60| C["COMPLEX → modelo capaz<br/>(claude-sonnet)"]
    Tier -->|>= 0.60 o override| R["REASONING → modelo frontera<br/>(claude-opus)"]

El algoritmo de scoring

El sistema genera un puntaje de 0.0 a 1.0 a partir de cinco dimensiones ponderadas:

DimensiónPesoPropósito
Presencia de código30%Artefactos de programación, debugging
Marcadores de razonamiento25%Lenguaje analítico de múltiples pasos
Términos técnicos25%Jerga de infraestructura y operaciones
Conteo de tokens10%Longitud del prompt (más largo = mayor)
Indicadores simples−5%Atenuador para saludos y consultas triviales

Hay matices que vale conocer: el system prompt aporta un 25% de peso a las dimensiones puntuadas (contexto léxico suave, sin forzar el nivel); la mezcla por defecto de conversación es 60% del último mensaje + 40% del historial, que se ajusta a 35%/65% para follow-ups referenciales cortos como “hazlo” o “reintenta”; y existe un override de razonamiento: dos o más keywords de razonamiento (o una fuerte combinada con señales de código/técnicas) fuerzan el nivel Reasoning sin importar el puntaje numérico.

Configuración en config.json

La estructura completa va bajo governance.complexity_analyzer_config:

{
  "governance": {
    "complexity_analyzer_config": {
      "tier_boundaries": {
        "simple_medium": 0.15,
        "medium_complex": 0.35,
        "complex_reasoning": 0.60
      },
      "keywords": {
        "code_keywords": ["function", "class", "api", "debug", "deploy"],
        "reasoning_keywords": ["step by step", "explain why", "tradeoffs", "root cause analysis"],
        "technical_keywords": ["architecture", "kubernetes", "latency", "authentication"],
        "simple_keywords": ["hello", "hi", "thanks", "what is", "define"]
      }
    }
  }
}

Especificaciones de los campos:

Cuando complexity_analyzer_config está presente en config.json, el modo split por defecto preserva las ediciones de UI y API entre reinicios mientras la sección del archivo no cambie. Si quieres que el archivo sea la fuente de verdad absoluta, agrega source_of_truth: "config.json".

Reglas de enrutamiento con CEL

La variable complexity_tier se usa en expresiones CEL con operadores estándar:

complexity_tier == "REASONING"
complexity_tier in ["COMPLEX", "REASONING"]
complexity_tier != "SIMPLE"
!(complexity_tier in ["SIMPLE", "MEDIUM"])

Y se combina con otras condiciones, como un header de tier premium o el nombre de un equipo:

headers["x-tier"] == "premium" && complexity_tier == "REASONING"
team_name == "ml-research" && complexity_tier == "REASONING"

Una escalera completa de cuatro niveles, que es el patrón clásico de optimización de costo, se define así:

[
  {"cel_expression": "complexity_tier == \"SIMPLE\"", "targets": [{"provider": "groq", "model": "llama-3.1-8b-instant", "weight": 1}], "priority": 0},
  {"cel_expression": "complexity_tier == \"MEDIUM\"", "targets": [{"provider": "openai", "model": "gpt-4o-mini", "weight": 1}], "priority": 1},
  {"cel_expression": "complexity_tier == \"COMPLEX\"", "targets": [{"provider": "anthropic", "model": "claude-sonnet-4-5", "weight": 1}], "priority": 2},
  {"cel_expression": "complexity_tier == \"REASONING\"", "targets": [{"provider": "anthropic", "model": "claude-opus-4-5", "weight": 1}], "priority": 3}
]

Para un despliegue conservador, puedes empezar con una sola regla que solo desvíe lo más complejo al modelo frontera y deje todo lo demás en su ruta normal:

{
  "id": "complexity-reasoning",
  "name": "Reasoning → Frontier model",
  "cel_expression": "complexity_tier == \"REASONING\"",
  "targets": [{"provider": "anthropic", "model": "claude-opus-4-5", "weight": 1}],
  "priority": 0
}

Rendimiento y limitaciones

El impacto del router es mínimo: menos de 1 ms, porque el matcheo de keywords es pre-compilado, en proceso y sin llamadas externas. En los logs de enrutamiento verás líneas como Complexity: tier=REASONING score=0.38 words=25, y puedes depurar las decisiones en la Web UI bajo Routing Decision Logs.

Eso sí, el análisis de complejidad requiere texto de usuario analizable. No corre sobre generación de imágenes, embeddings, audio/video, peticiones de count-tokens, chat con multimedia mixto (texto + imágenes) ni peticiones que solo traen system/developer prompts. Cuando el análisis no está disponible, complexity_tier queda en "unknown" y las reglas CEL simplemente degradan en silencio (no matchean), sin romper el flujo.

Resumen

En este capítulo construiste el sistema de gobierno completo de Bifrost:

Con governance dominado, tienes control total sobre quién consume qué, cuánto gasta y a qué modelo llega. El siguiente paso es extender Bifrost más allá de la inferencia: darle herramientas a tus agentes.

Siguiente: MCP Gateway: herramientas para tus agentes