Plugins y extensibilidad

Por: Artiko
bifrostpluginsmiddlewaregoextensibilidad

Plugins y extensibilidad

En el capítulo 11 vimos cómo Bifrost te entrega observabilidad nativa: logs en tiempo real, métricas Prometheus y trazas OpenTelemetry. Quizá no lo notaste, pero ese sistema de logging es en realidad un plugin más. Y aquí está la idea clave de este capítulo: casi todo lo “mágico” que hace Bifrost —governance, semantic caching, telemetría— no está cableado en el núcleo, sino implementado como plugins que se enganchan alrededor de cada llamada al proveedor.

Eso es enorme. Significa que el mismo mecanismo que usa Maxim AI para construir las features oficiales está disponible para ti: puedes interceptar cada request antes de que salga al proveedor, inspeccionar o transformar cada respuesta antes de que vuelva al cliente, e incluso cortocircuitar la llamada por completo (devolver una respuesta sintética sin tocar al proveedor). En este capítulo desmenuzamos esa arquitectura de middleware, recorremos los plugins integrados, simulamos respuestas con el plugin mocker para tests, escribimos nuestro primer plugin en Go y, finalmente, compilamos un binario dinámico de Bifrost capaz de cargarlo.

Qué es un plugin en Bifrost

Un plugin es middleware: código que se ejecuta antes y después de la llamada al proveedor, con la posibilidad de modificar, registrar, validar o enriquecer los datos que fluyen por el sistema. Es el mismo patrón conceptual que un middleware de Express o un interceptor de Axios, pero aplicado al pipeline de inferencia de un AI gateway.

Bifrost implementa los plugins con el sistema de plugins nativo de Go: se compilan como archivos de objeto compartido (.so) y se cargan en tiempo de ejecución. Esto trae cuatro ventajas frente a un modelo basado en procesos externos:

WASM está deprecado. Versiones tempranas de Bifrost exploraron plugins en WebAssembly (WASM) para portabilidad. Ese enfoque quedó deprecado en favor de los plugins en Go, que ofrecen acceso de tipos nativo y mejor rendimiento. Si encuentras tutoriales viejos sobre plugins WASM, ignóralos: el camino soportado hoy es Go.

El modelo simétrico pre/post

El corazón de la arquitectura es un modelo de hooks pre/post simétrico alrededor de la llamada al proveedor. Los hooks “pre” se ejecutan hacia delante en el orden de registro; los hooks “post” se ejecutan en orden inverso (patrón LIFO, last-in first-out), como el desenrollado de una pila. Esto garantiza que cada plugin “cierre” lo que abrió en el orden correcto, igual que los defer anidados de Go.

flowchart LR
    Cliente([Cliente]) --> PA[Plugin A · PreLLMHook]
    PA --> PB[Plugin B · PreLLMHook]
    PB --> PC[Plugin C · PreLLMHook]
    PC --> Prov{{Llamada al proveedor LLM}}
    Prov --> QC[Plugin C · PostLLMHook]
    QC --> QB[Plugin B · PostLLMHook]
    QB --> QA[Plugin A · PostLLMHook]
    QA --> Respuesta([Respuesta al cliente])

    PB -.->|short-circuit| QB

    classDef pre fill:#1e3a5f,stroke:#4a90d9,color:#fff
    classDef post fill:#2d4a22,stroke:#6abf4b,color:#fff
    classDef prov fill:#5a3a1e,stroke:#d9904a,color:#fff
    class PA,PB,PC pre
    class QA,QB,QC post
    class Prov prov

Fíjate en la flecha punteada del diagrama: el Plugin B puede cortocircuitar la cadena. Si devuelve un short-circuit, la llamada al proveedor y los plugins posteriores en la fase “pre” se saltan, pero —y esto es importante— todos los PreHooks que ya se ejecutaron reciben igualmente su PostHook correspondiente. La simetría se respeta siempre.

Las fases del ciclo de vida

La documentación describe cinco estados del ciclo de vida de un plugin:

  1. Discovery — Bifrost encuentra y cataloga los plugins (vía CLI, entorno, config o escaneo de directorio).
  2. Loading — verificación de firma del binario y validación de checksum.
  3. Initialization — se llama a Init(config) con un timeout acotado.
  4. Runtime — procesamiento activo de requests con monitoreo de salud.
  5. Cleanup — liberación de recursos al apagar el gateway, vía Cleanup().

Los hooks: dónde se engancha tu plugin

A partir de la versión 1.5.x, Bifrost expone varios puntos de enganche. No necesitas implementarlos todos: cada hook es opcional, y omitir uno simplemente desactiva ese punto de intercepción. Estos son los principales:

HookCuándo se ejecutaPuede cortocircuitar
PreRequestHookUna sola vez por request, en la fase de enrutamiento (elige proveedor/modelo y fallbacks)No (decisiones de routing)
PreLLMHookPor cada intento de proveedor, justo antes de la llamadaSí (LLMPluginShortCircuit)
PostLLMHookPor cada intento de proveedor, justo después (orden inverso)Puede recuperar errores
HTTPTransportPreHookA nivel de transporte HTTP, antes de procesar
HTTPTransportPostHookA nivel de transporte HTTP, después (orden inverso)
HTTPTransportStreamChunkHookPor cada chunk de una respuesta en streaming
PreMCPHook / PostMCPHookAlrededor de las llamadas al MCP Gateway

Una distinción crucial: PreRequestHook corre exactamente una vez por request, mientras que PreLLMHook y PostLLMHook corren una vez por cada intento de proveedor. Esto importa cuando hay fallbacks: si el proveedor primario falla y Bifrost reintenta con el secundario, los hooks PreLLMHook/PostLLMHook se vuelven a disparar, pero PreRequestHook no.

El orden de ejecución completo

Poniendo todo junto, el flujo de un request a través de la maquinaria de hooks queda así:

sequenceDiagram
    participant C as Cliente
    participant HTTP as HTTPTransportPreHook
    participant Route as PreRequestHook
    participant LLM as PreLLMHook
    participant P as Proveedor
    participant LLMp as PostLLMHook
    participant HTTPp as HTTPTransportPostHook

    C->>HTTP: request entrante
    HTTP->>Route: (1 vez) decisiones de routing
    Route->>LLM: por cada intento de proveedor
    LLM->>P: llamada al LLM (o short-circuit)
    P-->>LLMp: respuesta o error
    LLMp-->>HTTPp: orden inverso (LIFO)
    HTTPp-->>C: respuesta final

Recuperación de errores y short-circuit

Dos mecanismos hacen que los plugins sean potentes de verdad, no solo “observadores pasivos”:

El short-circuit. Un PreLLMHook puede devolver un *schemas.LLMPluginShortCircuit que contiene una respuesta sintética. Cuando lo hace, Bifrost se salta la llamada al proveedor y usa esa respuesta. Esto es exactamente lo que hace el plugin de semantic caching: si encuentra un hit en caché, cortocircuita y devuelve la respuesta cacheada sin gastar un solo token. El short-circuit tiene dos variantes según el campo AllowFallbacks:

La recuperación de errores. Un PostLLMHook recibe tanto la respuesta como el *schemas.BifrostError. Puede convertir un error en una respuesta exitosa: por ejemplo, transformar un fallo del proveedor en una respuesta de fallback construida por tu plugin. Esto te da un punto centralizado para degradar con elegancia.

Por seguridad, los fallos de un plugin en la fase PreRequestHook son no bloqueantes: se registran como warnings y el pipeline continúa. Además, Bifrost aplica recuperación de panics y aislamiento de ejecución para que un plugin defectuoso no tumbe el gateway.

Los plugins integrados

Bifrost se construye sobre su propia maquinaria de plugins. Conocerlos te ayuda a entender qué hace el gateway por debajo y a decidir cuándo necesitas escribir uno propio:

PluginPara qué sirve
governanceAplica las reglas de governance: virtual keys, presupuestos, rate limits, reglas de routing CEL y load balancing ponderado. Corre en la fase de routing.
semanticcacheImplementa el semantic caching: busca respuestas similares por significado y, si hay hit, cortocircuita la llamada al proveedor.
telemetryExpone métricas de Prometheus y alimenta la observabilidad (latencias, tokens, conteos por proveedor/modelo).
loggingRegistra cada request y respuesta para el monitoreo en tiempo real de la Web UI.
mockerSimula respuestas de proveedor para testing y desarrollo, sin tocar APIs reales (lo vemos en detalle abajo).
jsonparserProcesa y valida payloads JSON dentro del pipeline.
maximIntegra con la plataforma de Maxim AI para tracing y evaluación de calidad de las respuestas.

Estos plugins viven en el grupo builtin del sistema de secuenciamiento, que veremos más adelante. Tus plugins se colocan antes (pre_builtin) o después (post_builtin) de ese grupo.

El plugin mocker: simular proveedores para tests

El plugin mocker es el favorito de cualquiera que escriba tests. Su trabajo es mockear respuestas de proveedores de IA para testing, desarrollo y simulación: intercepta los requests y devuelve respuestas simuladas sin hacer llamadas reales (ni gastar tokens, ni depender de la red). Es ideal para tests de integración deterministas, demos offline y simulación de errores.

Configuración base

El plugin usa un objeto MockerConfig. Su esqueleto mínimo:

{
  "Enabled": true,
  "DefaultBehavior": "passthrough",
  "GlobalLatency": null,
  "Rules": []
}

El campo DefaultBehavior admite tres valores: "passthrough" (deja pasar el request al proveedor real si ninguna regla coincide), "success" o "error".

Definir reglas de mock

Cada entrada del array Rules describe una regla con condiciones de coincidencia y respuestas:

{
  "Name": "rule-identifier",
  "Enabled": true,
  "Priority": 0,
  "Probability": 1.0,
  "Conditions": {},
  "Responses": [],
  "Latency": null
}

Las condiciones filtran a qué requests aplica la regla. Puedes matchear por proveedor, modelo, una regex sobre el mensaje o el tamaño del request:

{
  "Conditions": {
    "Providers": ["openai", "anthropic"],
    "Models": ["gpt-4", "claude-3"],
    "MessageRegex": "(?i).*hello.*",
    "RequestSize": {
      "Min": 100,
      "Max": 1000
    }
  }
}

Las respuestas (Responses) son objetos ponderados por Weight. Una respuesta exitosa permite contenido estático o un template con variables:

{
  "Type": "success",
  "Weight": 0.8,
  "Content": {
    "Message": "Static response text",
    "MessageTemplate": "Hello from {{provider}} using {{model}}",
    "Model": "gpt-4",
    "Usage": {
      "PromptTokens": 15,
      "CompletionTokens": 25,
      "TotalTokens": 40
    },
    "FinishReason": "stop",
    "CustomFields": {}
  }
}

Y una respuesta de error simula un fallo del proveedor —perfecto para probar tus fallbacks y retries:

{
  "Type": "error",
  "Weight": 0.2,
  "Error": {
    "Message": "Rate limit exceeded",
    "Type": "rate_limit",
    "Code": "429",
    "StatusCode": 429
  },
  "AllowFallbacks": false
}

Simular latencia

Para que tus tests reflejen condiciones realistas, puedes inyectar retardos. Latencia fija:

{
  "Latency": {
    "Type": "fixed",
    "Min": "250ms"
  }
}

O latencia variable (uniforme entre dos cotas):

{
  "Latency": {
    "Type": "uniform",
    "Min": "100ms",
    "Max": "500ms"
  }
}

Variables de template y bypass

El contenido dinámico admite las variables {{provider}}, {{model}} y {{faker.*}} para generar datos sintéticos (nombres, emails, UUIDs, etc.). Y si necesitas que un request concreto no sea mockeado, basta con poner en el contexto la clave schemas.BifrostContextKey("skip-mocker") en true: ese request pasará de largo al proveedor real.

Combina DefaultBehavior: "success" con una regla de Type: "error" y Probability baja para inyectar fallos aleatorios y validar la resiliencia de tu pipeline bajo condiciones adversas.

Escribir un plugin en Go

Llegó la parte divertida: tu propio plugin. Todo plugin debe exportar tres funciones obligatorias —Init, GetName y Cleanup— y cualquier subconjunto de los hooks opcionales. Las firmas exactas (a partir de v1.5.x) son:

func Init(config any) error
func GetName() string
func Cleanup() error

Los hooks de LLM tienen estas firmas:

func PreLLMHook(ctx *schemas.BifrostContext,
  req *schemas.BifrostRequest) (
  *schemas.BifrostRequest,
  *schemas.LLMPluginShortCircuit,
  error)

func PostLLMHook(ctx *schemas.BifrostContext,
  resp *schemas.BifrostResponse,
  bifrostErr *schemas.BifrostError) (
  *schemas.BifrostResponse,
  *schemas.BifrostError,
  error)

Ejemplo mínimo completo

Este es un plugin “Hello World” funcional, tomado de la documentación oficial. Registra un mensaje en cada hook y deja pasar request y respuesta sin modificarlos:

package main

import (
	"fmt"
	"github.com/maximhq/bifrost/core/schemas"
)

func Init(config any) error {
	fmt.Println("Init called")
	return nil
}

func GetName() string {
	return "Hello World Plugin"
}

func PreLLMHook(ctx *schemas.BifrostContext,
	req *schemas.BifrostRequest) (
	*schemas.BifrostRequest,
	*schemas.LLMPluginShortCircuit,
	error) {
	ctx.Log(schemas.LogLevelInfo, "PreLLMHook called")
	return req, nil, nil
}

func PostLLMHook(ctx *schemas.BifrostContext,
	resp *schemas.BifrostResponse,
	bifrostErr *schemas.BifrostError) (
	*schemas.BifrostResponse,
	*schemas.BifrostError,
	error) {
	ctx.Log(schemas.LogLevelInfo, "PostLLMHook called")
	return resp, bifrostErr, nil
}

func Cleanup() error {
	fmt.Println("Cleanup called")
	return nil
}

Anatomía de lo que devuelve cada hook:

El parámetro ctx *schemas.BifrostContext es tu navaja suiza: contexto con alcance de request para loggear (ctx.Log) y para almacenar/leer valores que viajan entre hooks.

Los tipos clave de schemas

Conviene tener claro qué representa cada tipo que aparece en las firmas:

TipoQué representa
BifrostContextContexto con alcance de request: logging y almacenamiento de valores.
BifrostRequestEl request LLM entrante (modelo, proveedor, mensajes, parámetros).
BifrostResponseLa respuesta del proveedor (completions, conteos de tokens).
BifrostErrorError estructurado con status code, tipo y mensaje.
LLMPluginShortCircuitContiene la respuesta sintética para saltarse la llamada al proveedor.
BifrostStreamChunkWrapper tipado de un chunk de respuesta en streaming.

Compilar el plugin

Una vez escrito, lo compilas como objeto compartido con el build mode de plugin de Go:

go build -buildmode=plugin -o my-plugin.so main.go

Y lo registras en config.json dentro del array plugins:

{
  "plugins": [
    {
      "enabled": true,
      "name": "my-plugin",
      "path": "/path/to/my-plugin.so",
      "config": { "api_key": "value" }
    }
  ]
}

El campo config se pasa tal cual al Init(config any) de tu plugin, así que ahí defines tus parámetros (claves, umbrales, lo que necesites).

Reglas de compatibilidad (importantes)

El sistema de plugins de Go es potente pero estricto. Si no respetas estas reglas, el plugin no cargará:

Tu go.mod debe declarar la versión de Go y la dependencia del core de Bifrost:

module github.com/example/my-plugin
go 1.26.1
require github.com/maximhq/bifrost/core v1.2.38

Plugin sequencing: el orden importa

Cuando tienes varios plugins, el orden de ejecución deja de ser un detalle y pasa a ser parte del diseño. Bifrost organiza los plugins en tres grupos de colocación (placement groups) que se ejecutan en secuencia:

flowchart LR
    subgraph Pre["pre_builtin (1º request / último response)"]
        direction TB
        A[auth-validator · order 0]
        B[request-enricher · order 1]
    end
    subgraph Builtin["builtin (governance, cache, telemetry...)"]
        BB[plugins integrados]
    end
    subgraph Post["post_builtin (último request / 1º response)"]
        direction TB
        C[response-logger · order 0]
        D[analytics · order 1]
    end

    Pre --> Builtin --> Post

    classDef pre fill:#1e3a5f,stroke:#4a90d9,color:#fff
    classDef bi fill:#5a3a1e,stroke:#d9904a,color:#fff
    classDef post fill:#2d4a22,stroke:#6abf4b,color:#fff
    class A,B pre
    class BB bi
    class C,D post

Las reglas son:

Esta tabla muestra una secuencia concreta de ejecución, donde se ve la simetría LIFO entre pre-hooks y post-hooks:

PluginPlacementOrderPre-hookPost-hook
auth-validatorpre_builtin0
request-enricherpre_builtin1
(integrados)builtin
response-loggerpost_builtin0
analyticspost_builtin1

Configurar el orden

En config.json defines placement y order por plugin:

{
  "plugins": [
    {
      "name": "auth-validator",
      "enabled": true,
      "path": "/plugins/auth-validator.so",
      "placement": "pre_builtin",
      "order": 0
    },
    {
      "name": "request-enricher",
      "enabled": true,
      "path": "/plugins/request-enricher.so",
      "placement": "pre_builtin",
      "order": 1
    },
    {
      "name": "response-logger",
      "enabled": true,
      "path": "/plugins/response-logger.so",
      "placement": "post_builtin",
      "order": 0
    }
  ]
}

También puedes reordenar en caliente vía la API, con un PUT:

curl -X PUT http://localhost:8080/api/plugins/my-plugin \
  -H "Content-Type: application/json" \
  -d '{
    "placement": "pre_builtin",
    "order": 0
  }'

¿Qué placement elegir?

“Cuando tengas dudas, usa el placement por defecto post_builtin.” — documentación oficial.

Building dynamic binary: compilar Bifrost con tus plugins

Hay un detalle que sorprende a muchos: el binario estándar de Bifrost no puede cargar plugins. El sistema de plugins de Go exige enlazado dinámico para cargar archivos .so en tiempo de ejecución, pero los builds que distribuye Bifrost suelen ser estáticos. Por eso, para usar plugins custom necesitas compilar un binario dinámico de Bifrost.

Compilar el binario dinámico

Con un go build directo, la clave es omitir los flags de enlazado estático (-extldflags '-static' y -tags "sqlite_static"):

go build \
  -ldflags="-w -s -X main.Version=v1.3.30" \
  -o bifrost-http

Si el repo trae Makefile, es más cómodo el flag DYNAMIC=1, que quita automáticamente los flags estáticos manteniendo CGO_ENABLED=1:

make build DYNAMIC=1
make build DYNAMIC=1 VERSION=1.3.30
make build DYNAMIC=1 GOOS=linux GOARCH=amd64
make build DYNAMIC=1 GOOS=linux GOARCH=arm64

Verificar que el binario es dinámico

Antes de cargar nada, confirma que el binario realmente está enlazado dinámicamente con ldd:

ldd tmp/bifrost-http

La salida debe mostrar dependencias sobre libc.musl-x86_64.so.1 (Alpine) o libc.so.6 (Debian). Si ldd dice “not a dynamic executable”, construiste un estático y el plugin no cargará.

Compilar el plugin con la libc correcta

Recuerda la regla de la libc: el plugin y Bifrost deben compartir versión de libc. Lo más seguro es compilar el plugin dentro del mismo entorno que usarás para correr Bifrost. Para Alpine/musl:

docker run --rm \
  -v "$PWD:/work" \
  -w /work \
  golang:1.26.1-alpine3.23 \
  sh -c "apk add --no-cache gcc musl-dev && \
         go build -buildmode=plugin -o myplugin.so main.go"

Para Debian/glibc:

docker run --rm \
  -v "$PWD:/work" \
  -w /work \
  golang:1.26.1-bookworm \
  sh -c "apt-get update && apt-get install -y gcc && \
         go build -buildmode=plugin -o myplugin.so main.go"

Las variables de entorno relevantes en estos builds son CGO_ENABLED=1 (requerido para SQLite y para el soporte de plugins), GOOS=linux y GOARCH=amd64 o arm64.

Apuntar al plugin compilado

Finalmente, en config.json indicas la ruta del .so ya compilado:

{
  "plugins": [
    {
      "path": "/app/data/plugins/myplugin.so",
      "config": {}
    }
  ]
}

Arranca el binario dinámico de Bifrost apuntando a esa configuración y, en el log de inicio, deberías ver el mensaje de tu Init (“Init called” en nuestro ejemplo) confirmando que el plugin se cargó.

flowchart TD
    G[Compilar Bifrost dinamico<br/>make build DYNAMIC=1] --> V{ldd confirma<br/>dynamic executable?}
    V -- No --> G
    V -- Si --> P[Compilar plugin<br/>go build -buildmode=plugin]
    P --> M[Misma libc, arch y<br/>version de Go?]
    M -- No --> P
    M -- Si --> R[Registrar .so en config.json<br/>plugins path]
    R --> S[Arrancar Bifrost<br/> ver Init called en el log]

    classDef step fill:#1e3a5f,stroke:#4a90d9,color:#fff
    classDef check fill:#5a3a1e,stroke:#d9904a,color:#fff
    class G,P,R,S step
    class V,M check

Resumen

Los plugins son el mecanismo de extensibilidad de Bifrost y la base sobre la que están construidas sus propias features. Lo esencial que te llevas:

Hasta aquí hemos usado Bifrost como un servicio HTTP independiente. En el próximo capítulo damos un giro: lo embebemos dentro de tu propia aplicación Go como una librería, usando el mismo sistema de tipos (schemas) y middleware que acabas de conocer.

Siguiente: Go SDK: Bifrost embebido en tu aplicación