Go SDK: Bifrost embebido en tu aplicacion

Por: Artiko
bifrostgo-sdkgolangembebidolibreria

Go SDK: Bifrost embebido en tu aplicación

Durante todo el tutorial tratamos a Bifrost como un servicio independiente: un gateway HTTP que levantas con npx, Docker o un binario, al que tus aplicaciones le hablan por la red apuntando su base URL. En el capítulo 12 vimos cómo extender ese gateway con plugins. Pero Bifrost no nació como un servidor: nació como una librería Go. El gateway HTTP que has usado hasta ahora es, en realidad, una capa delgada construida sobre el paquete core. Eso significa que puedes saltarte el servidor por completo y embeber el mismo motor directamente dentro de tu aplicación Go.

En este capítulo aprenderás cuándo conviene esa estrategia, cómo inicializar un cliente Bifrost con tu propia implementación de cuenta, y cómo ejecutar chat completions, streaming, tool calling, context keys y logging sin tocar una sola petición HTTP. Al terminar entenderás que el gateway y el SDK son dos caras de la misma moneda, y sabrás elegir la correcta para cada caso.

¿Cuándo usar el Go SDK en vez del gateway HTTP?

El gateway HTTP es la opción correcta el 90% de las veces: lo despliegas una vez, lo comparten todos tus servicios (sin importar el lenguaje), centraliza governance y observabilidad, y lo actualizas sin recompilar nada. Pero hay escenarios donde embeber el core directamente en un proceso Go gana:

La contrapartida: pierdes la Web UI, el lenguaje queda atado a Go, y cada servicio que embeba el SDK gestiona su propia configuración. Es la diferencia entre un router compartido y una librería privada.

flowchart LR
    subgraph gw["Modo Gateway HTTP"]
        A1["App Python"] -->|HTTP| G["Bifrost Gateway"]
        A2["App Node"] -->|HTTP| G
        A3["App Go"] -->|HTTP| G
        G --> P1["Proveedores LLM"]
    end
    subgraph sdk["Modo Go SDK embebido"]
        B["App Go"] -->|llamada en memoria| C["core de Bifrost"]
        C --> P2["Proveedores LLM"]
    end

Una regla práctica: si necesitas que varios lenguajes o equipos compartan la misma capa de governance, usa el gateway (capítulos 09 y 11). Si todo tu camino caliente es Go y persigues latencia, embebe el SDK.

Instalación

El SDK vive en el paquete core. Lo instalas como cualquier dependencia Go:

go get github.com/maximhq/bifrost/core

En tu código importas dos paquetes: el core (que expone Init, el tipo Bifrost y helpers) y schemas, que contiene todos los tipos de request, response, configuración y constantes de proveedor.

import (
    "context"

    "github.com/maximhq/bifrost/core"
    "github.com/maximhq/bifrost/core/schemas"
)

Nota: el paquete core se referencia en el código con el identificador bifrost (es el nombre del paquete Go), por eso las llamadas se escriben bifrost.Init(...) aunque el import sea .../core.

La interfaz Account: configuración en código

En el gateway, los proveedores y sus claves se definen en config.json o por la Web UI (ver capítulo 03). En el SDK los defines implementando una interfaz llamada Account. Esto es deliberado: tu código es la fuente de la configuración, así que puedes leer las claves de variables de entorno, de Vault, de una base de datos o de donde quieras.

La interfaz requiere tres métodos:

type Account interface {
    GetConfiguredProviders() ([]schemas.ModelProvider, error)
    GetKeysForProvider(ctx *context.Context, provider schemas.ModelProvider) ([]schemas.Key, error)
    GetConfigForProvider(provider schemas.ModelProvider) (*schemas.ProviderConfig, error)
}

Cada método tiene una responsabilidad clara:

Implementación realista

Aquí tienes una implementación completa y compilable conceptualmente. Lee las claves de variables de entorno y aplica las configuraciones por defecto de la librería:

package main

import (
    "context"
    "fmt"
    "os"

    "github.com/maximhq/bifrost/core/schemas"
)

type MyAccount struct{}

func (a *MyAccount) GetConfiguredProviders() ([]schemas.ModelProvider, error) {
    return []schemas.ModelProvider{
        schemas.OpenAI,
        schemas.Anthropic,
        schemas.Mistral,
    }, nil
}

func (a *MyAccount) GetKeysForProvider(ctx *context.Context, provider schemas.ModelProvider) ([]schemas.Key, error) {
    switch provider {
    case schemas.OpenAI:
        return []schemas.Key{{
            Value:  os.Getenv("OPENAI_API_KEY"),
            Models: []string{},
            Weight: 1.0,
        }}, nil
    case schemas.Anthropic:
        return []schemas.Key{{
            Value:  os.Getenv("ANTHROPIC_API_KEY"),
            Models: []string{},
            Weight: 1.0,
        }}, nil
    case schemas.Mistral:
        return []schemas.Key{{
            Value:  os.Getenv("MISTRAL_API_KEY"),
            Models: []string{},
            Weight: 1.0,
        }}, nil
    }
    return nil, fmt.Errorf("provider %s not supported", provider)
}

func (a *MyAccount) GetConfigForProvider(provider schemas.ModelProvider) (*schemas.ProviderConfig, error) {
    return &schemas.ProviderConfig{
        NetworkConfig:            schemas.DefaultNetworkConfig,
        ConcurrencyAndBufferSize: schemas.DefaultConcurrencyAndBufferSize,
    }, nil
}

Sobre los campos de schemas.Key:

Ajustar red y concurrencia

schemas.DefaultNetworkConfig y schemas.DefaultConcurrencyAndBufferSize cubren la mayoría de los casos, pero puedes construir la configuración a mano cuando necesites afinar timeouts, reintentos o el tamaño del pool de workers. El NetworkConfig expone, entre otros campos: BaseURL (útil para apuntar a un endpoint compatible o un modelo local), MaxRetries, RetryBackoffInitial, RetryBackoffMax y ExtraHeaders. El ConcurrencyAndBufferSize define MaxConcurrency (workers concurrentes por proveedor) y BufferSize (tamaño de la cola interna):

func (a *MyAccount) GetConfigForProvider(provider schemas.ModelProvider) (*schemas.ProviderConfig, error) {
    return &schemas.ProviderConfig{
        NetworkConfig: schemas.NetworkConfig{
            MaxRetries:          3,
            RetryBackoffInitial: 100 * time.Millisecond,
            RetryBackoffMax:     5 * time.Second,
        },
        ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{
            MaxConcurrency: 10,
            BufferSize:     100,
        },
    }, nil
}

Estos MaxRetries y backoff son la versión embebida de la resiliencia que configurabas en el gateway en el capítulo 07.

Inicializar el cliente Bifrost

Con la cuenta lista, inicializas el motor con bifrost.Init. Recibe un schemas.BifrostConfig con tu cuenta (y opcionalmente un logger, que veremos más adelante) y devuelve un cliente *bifrost.Bifrost. Acuérdate de llamar a Shutdown con defer para liberar los pools de workers de forma ordenada:

func main() {
    client, initErr := bifrost.Init(context.Background(), schemas.BifrostConfig{
        Account: &MyAccount{},
    })
    if initErr != nil {
        log.Fatalf("error inicializando Bifrost: %v", initErr)
    }
    defer client.Shutdown()

    // ... usar client para hacer peticiones
}

A partir de aquí, client es el único objeto que necesitas: todas las operaciones de inferencia son métodos sobre él.

Tu primer ChatCompletion en Go

El método central es ChatCompletionRequest. Recibe un contexto de Bifrost y un *schemas.BifrostChatRequest, y devuelve la respuesta junto a un error tipado de Bifrost.

El contexto se crea con schemas.NewBifrostContext, envolviendo un context.Context estándar. schemas.NoDeadline indica que no quieres imponer un deadline propio (Bifrost gestiona sus timeouts internos):

response, err := client.ChatCompletionRequest(
    schemas.NewBifrostContext(context.Background(), schemas.NoDeadline),
    &schemas.BifrostChatRequest{
        Provider: schemas.OpenAI,
        Model:    "gpt-4o-mini",
        Input: []schemas.ChatMessage{
            {
                Role: schemas.ChatMessageRoleUser,
                Content: &schemas.ChatMessageContent{
                    ContentStr: schemas.Ptr("Explica que es un AI gateway en una frase."),
                },
            },
        },
    },
)
if err != nil {
    log.Printf("la peticion fallo: %v", err)
    return
}

fmt.Println(*response.Choices[0].Message.Content.ContentStr)

Fíjate en varios detalles importantes:

Manejo de errores: BifrostError

Cuando algo falla (clave inválida, proveedor caído, rate limit, modelo inexistente), Bifrost no devuelve un error genérico opaco: devuelve un error estructurado, BifrostError, que transporta el mensaje, el tipo de error y, cuando aplica, el código de estado del proveedor subyacente. Esto te permite ramificar tu lógica según la causa real del fallo:

response, err := client.ChatCompletionRequest(bfCtx, req)
if err != nil {
    // err es un *schemas.BifrostError con informacion estructurada
    log.Printf("Bifrost error: %v", err)
    // Aqui puedes inspeccionar el tipo de error para decidir si
    // reintentar, hacer fallback manual o devolver al usuario.
    return
}

Como viste en el capítulo 07, Bifrost ya aplica reintentos y fallbacks de forma interna según tu configuración; el BifrostError solo aflora cuando todas esas estrategias se agotaron.

Streaming en Go

Para respuestas token a token usas ChatCompletionStreamRequest. La firma es casi idéntica a la versión no-streaming, pero en lugar de una respuesta única devuelve un canal Go del que recibes chunks a medida que el modelo genera. Consumir un stream es, idiomáticamente, un for chunk := range stream:

stream, err := client.ChatCompletionStreamRequest(
    schemas.NewBifrostContext(context.Background(), schemas.NoDeadline),
    &schemas.BifrostChatRequest{
        Provider: schemas.OpenAI,
        Model:    "gpt-4o-mini",
        Input:    messages,
    },
)
if err != nil {
    log.Printf("la peticion de streaming fallo: %v", err)
    return
}

for chunk := range stream {
    if chunk.BifrostError != nil {
        log.Printf("error en el stream: %v", chunk.BifrostError)
        break
    }

    if chunk.BifrostChatResponse != nil && len(chunk.BifrostChatResponse.Choices) > 0 {
        choice := chunk.BifrostChatResponse.Choices[0]
        if choice.ChatStreamResponseChoice != nil &&
            choice.ChatStreamResponseChoice.Delta != nil &&
            choice.ChatStreamResponseChoice.Delta.Content != nil {
            content := *choice.ChatStreamResponseChoice.Delta.Content
            fmt.Print(content)
        }
    }
}

Tres cosas que conviene entender del modelo de streaming:

sequenceDiagram
    participant App as App Go
    participant B as core Bifrost
    participant P as Proveedor LLM
    App->>B: ChatCompletionStreamRequest
    B->>P: stream upstream
    P-->>B: delta "Un"
    B-->>App: chunk (Delta.Content="Un")
    P-->>B: delta " gateway"
    B-->>App: chunk (Delta.Content=" gateway")
    P-->>B: fin + usage
    B-->>App: ultimo chunk (usage + finish_reason)
    Note over App,B: el canal se cierra y el for...range termina

Cuando el modelo termina, Bifrost cierra el canal y el for ... range finaliza solo. No necesitas señal de cierre adicional.

Tool calling en Go

El tool calling embebido funciona igual que en el gateway (capítulo 06), pero declarando las herramientas como structs Go. Una herramienta es un schemas.ChatTool de tipo función, con su nombre, descripción y un esquema de parámetros estilo JSON Schema:

calculatorTool := schemas.ChatTool{
    Type: schemas.ChatToolTypeFunction,
    Function: &schemas.ChatToolFunction{
        Name:        "calculator",
        Description: schemas.Ptr("Una herramienta de calculadora"),
        Parameters: &schemas.ToolFunctionParameters{
            Type: "object",
            Properties: map[string]interface{}{
                "operation": map[string]interface{}{
                    "type":        "string",
                    "description": "La operacion a realizar",
                    "enum":        []string{"add", "subtract", "multiply", "divide"},
                },
                "a": map[string]interface{}{
                    "type":        "number",
                    "description": "El primer numero",
                },
                "b": map[string]interface{}{
                    "type":        "number",
                    "description": "El segundo numero",
                },
            },
            Required: []string{"operation", "a", "b"},
        },
    },
}

Las herramientas se pasan en Params.Tools, y con Params.ToolChoice controlas si el modelo decide por su cuenta ("auto"), si lo fuerzas a una función concreta o si las desactivas ("none"):

response, err := client.ChatCompletionRequest(
    schemas.NewBifrostContext(context.Background(), schemas.NoDeadline),
    &schemas.BifrostChatRequest{
        Provider: schemas.OpenAI,
        Model:    "gpt-4o-mini",
        Input: []schemas.ChatMessage{
            {
                Role: schemas.ChatMessageRoleUser,
                Content: &schemas.ChatMessageContent{
                    ContentStr: schemas.Ptr("¿Cuanto es 2+2? Usa la herramienta calculator."),
                },
            },
        },
        Params: &schemas.ChatParameters{
            Tools: []schemas.ChatTool{calculatorTool},
            ToolChoice: &schemas.ChatToolChoice{
                ChatToolChoiceStr: schemas.Ptr("auto"),
            },
        },
    },
)
if err != nil {
    log.Printf("la peticion fallo: %v", err)
    return
}

Cuando el modelo decide invocar una herramienta, la respuesta no trae texto sino una lista de llamadas en Message.ChatAssistantMessage.ToolCalls. Cada llamada incluye el ID, el nombre de la función y los argumentos como JSON:

assistant := response.Choices[0].Message.ChatAssistantMessage
if assistant != nil && assistant.ToolCalls != nil {
    for _, toolCall := range assistant.ToolCalls {
        fmt.Printf("Llamada a herramienta - %s: %s\n", *toolCall.ID, *toolCall.Function.Name)
        fmt.Printf("Argumentos - %s\n", toolCall.Function.Arguments)
    }
}

El flujo completo de un agente es el clásico de dos pasos: ejecutas la función en tu código con esos argumentos, añades el resultado al historial como un mensaje de rol tool, y vuelves a llamar a ChatCompletionRequest para que el modelo redacte la respuesta final.

Recuerda: si tus herramientas viven en servidores MCP, no necesitas declararlas a mano. El capítulo 10 cubre cómo Bifrost descubre e inyecta herramientas MCP automáticamente; en el SDK las activas con context keys, como verás a continuación.

Context keys: metadata por contexto

Las context keys son el mecanismo del SDK para pasar metadata y configuración por petición sin ensuciar el BifrostChatRequest. Se aprovechan plugins, governance y observabilidad, exactamente lo que en el gateway viajaba en headers HTTP. Las estableces sobre el BifrostContext con SetValue antes de hacer la petición:

bfCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
bfCtx.SetValue(schemas.BifrostContextKeyRequestID, "req-001")

response, err := client.ChatCompletionRequest(bfCtx, &schemas.BifrostChatRequest{
    Provider: schemas.OpenAI,
    Model:    "gpt-4o-mini",
    Input:    messages,
})

Las claves están tipadas como constantes del paquete schemas. Estas son algunas de las más útiles, agrupadas por propósito:

Trazabilidad y headers personalizados (alimentan logs y plugins, ver capítulo 11):

bfCtx.SetValue(schemas.BifrostContextKeyRequestID, "req-12345-abc")
bfCtx.SetValue(schemas.BifrostContextKeyExtraHeaders, map[string][]string{
    "x-correlation-id": {"corr-12345"},
    "x-tenant-id":      {"tenant-abc"},
})

Governance: seleccionar una virtual key (presupuestos y límites del capítulo 09):

bfCtx.SetValue(schemas.BifrostContextKeyVirtualKey, "vk-my-team")

Selección explícita de clave API por id o por nombre, útil cuando tienes varias claves del mismo proveedor:

bfCtx.SetValue(schemas.BifrostContextKeyAPIKeyName, "premium-key")

Herramientas MCP que deben incluirse en esta petición concreta:

bfCtx.SetValue(schemas.MCPContextKeyIncludeClients, []string{"github", "filesystem"})
bfCtx.SetValue(schemas.MCPContextKeyIncludeTools, []string{"filesystem-*"})

Algunas claves no se establecen, sino que se leen después de la petición para inspeccionar qué ocurrió internamente: qué clave se eligió, cuántos reintentos hubo o qué índice de fallback se usó. Son la versión programática de la telemetría del gateway:

retries := ctx.Value(schemas.BifrostContextKeyNumberOfRetries).(int)
fallbackIdx := ctx.Value(schemas.BifrostContextKeyFallbackIndex).(int)

También puedes pedir que la respuesta incluya el request y response crudos del proveedor (útil para depurar), activando las flags correspondientes y leyéndolas en ExtraFields:

bfCtx.SetValue(schemas.BifrostContextKeySendBackRawResponse, true)

response, _ := client.ChatCompletionRequest(bfCtx, req)
if response.ChatResponse != nil {
    extra := response.ChatResponse.ExtraFields
    fmt.Printf("Proveedor: %s\n", extra.Provider)
    fmt.Printf("Latencia: %dms\n", extra.Latency)
}

La gran ventaja: las context keys son el puente entre tu código y los plugins del capítulo 12. Un plugin de governance, por ejemplo, lee la virtual key del contexto para aplicar el presupuesto del equipo correcto.

Logging: configurar el logger

Por defecto Bifrost trae un logger integrado, pero puedes ajustar su nivel, su formato de salida y, sobre todo, sustituirlo por el tuyo. La interfaz Logger que Bifrost espera es esta:

type Logger interface {
    Debug(msg string, args ...any)
    Info(msg string, args ...any)
    Warn(msg string, args ...any)
    Error(msg string, args ...any)
    Fatal(msg string, args ...any)
    SetLevel(level schemas.LogLevel)
    SetOutputType(outputType schemas.LoggerOutputType)
}

Para la mayoría de los casos basta con el logger por defecto. Lo creas con NewDefaultLogger, le pasas un nivel inicial, y opcionalmente cambias el formato a JSON (ideal para que tus herramientas de observabilidad lo parseen):

logger := bifrost.NewDefaultLogger(schemas.LogLevelInfo)
logger.SetOutputType(schemas.LoggerOutputTypeJSON)
logger.SetLevel(schemas.LogLevelDebug)

Los niveles disponibles van de más a menos verboso: schemas.LogLevelDebug, schemas.LogLevelInfo, schemas.LogLevelWarn y schemas.LogLevelError.

El logger se inyecta en la inicialización, junto a la cuenta:

client, err := bifrost.Init(context.Background(), schemas.BifrostConfig{
    Account: &MyAccount{},
    Logger:  logger,
})

Como Logger es una interfaz, puedes envolver tu logger favorito (por ejemplo Zap o slog) implementando esos métodos, y así unificar los logs de Bifrost con los del resto de tu aplicación. Si quieres silenciar Bifrost por completo, implementa un NoOpLogger cuyos métodos no hagan nada:

type NoOpLogger struct{}

func (l *NoOpLogger) Debug(msg string, args ...any)                  {}
func (l *NoOpLogger) Info(msg string, args ...any)                   {}
func (l *NoOpLogger) Warn(msg string, args ...any)                   {}
func (l *NoOpLogger) Error(msg string, args ...any)                  {}
func (l *NoOpLogger) Fatal(msg string, args ...any)                  {}
func (l *NoOpLogger) SetLevel(level schemas.LogLevel)                {}
func (l *NoOpLogger) SetOutputType(t schemas.LoggerOutputType)       {}

Gateway y SDK: la misma máquina

Llegados aquí conviene cerrar el círculo. Todo lo que has hecho en este tutorial sobre el gateway HTTP (proveedores, fallbacks, caching semántico, governance, MCP, plugins, observabilidad) se ejecuta sobre este mismo paquete core. El gateway es, literalmente, un binario que crea una Account a partir de tu config.json, llama a bifrost.Init y expone los métodos por HTTP. Cuando embebes el SDK, tu main ocupa el lugar de ese binario.

Esto tiene una consecuencia práctica: las funcionalidades de resiliencia y enrutamiento son del motor, no del servidor. Reintentos, load balancing por Weight y selección de claves funcionan igual embebidos. Lo que es exclusivo del gateway es la capa de presentación: la Web UI, la API HTTP unificada y el drop-in replacement por base URL (capítulo 05). Y las capacidades marcadas como Enterprise a lo largo del tutorial (clustering, SSO, RBAC avanzado) siguen siendo Enterprise: el SDK te da el motor, no licencia funcionalidades de pago.

Resumen

En este capítulo cambiaste de perspectiva: dejaste de hablarle a Bifrost por HTTP y lo embebiste como librería Go dentro de tu proceso.

Con el SDK dominado, tienes las dos formas de operar Bifrost. Solo queda llevarlo a producción con garantías y saber qué abre la puerta Enterprise.

Siguiente: Despliegue en producción y camino a Enterprise