Go SDK: Bifrost embebido en tu aplicacion
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:
- Latencia mínima. Sin gateway no hay salto de red local, ni serialización HTTP, ni un proceso intermedio. La petición va de tu código al motor de Bifrost mediante una llamada a función en memoria. Para cargas sensibles a la latencia, eliminar ese hop importa.
- Embeber en un único servicio Go. Si toda tu inferencia vive dentro de un solo binario Go (un worker, un agente, una herramienta CLI), levantar un gateway aparte es infraestructura extra que no necesitas. El SDK te da el mismo motor sin un proceso adicional que vigilar.
- Control total del ciclo de vida. Controlas la inicialización, el apagado (
Shutdown), el logger, y puedes implementar la interfaz de cuenta para leer claves de tu propio secret manager, base de datos o sistema de configuración, en lugar de unconfig.json.
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
corese referencia en el código con el identificadorbifrost(es el nombre del paquete Go), por eso las llamadas se escribenbifrost.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:
GetConfiguredProvidersdevuelve la lista de proveedores que tu cuenta soporta (schemas.OpenAI,schemas.Anthropic,schemas.Mistral,schemas.Groq,schemas.Cohere,schemas.Cerebras, etc.).GetKeysForProviderentrega las claves API de cada proveedor. Devuelve un slice deschemas.Key, lo que te permite tener varias claves por proveedor con pesos distintos (la base del load balancing del capítulo 07).GetConfigForProvideraporta la configuración de red y concurrencia: timeouts, reintentos, tamaño de buffer y workers.
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:
Valuees la clave API (un string). En el ejemplo la leemos conos.Getenv. Usa siempre variables de entorno o un secret manager; nunca incrustes la clave en el código fuente.Modelsrestringe la clave a una lista de modelos. Un slice vacío ([]string{}) significa que la clave sirve para cualquier modelo del proveedor.Weightcontrola el reparto de tráfico cuando hay varias claves del mismo proveedor (load balancing).
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:
ProvideryModelson campos del request, no parte de la URL como en el gateway. Cambiar de proveedor es cambiar una constante.Inputes un slice deschemas.ChatMessage. Cada mensaje tiene unRole(schemas.ChatMessageRoleUser,...RoleSystem,...RoleAssistant) y unContent.Contentes un puntero aschemas.ChatMessageContent. Para texto plano usas el campoContentStr, que es un*string. El helperschemas.Ptr(...)convierte un valor en un puntero de forma concisa (Go no permite tomar la dirección de un literal directamente).- La respuesta se lee navegando
response.Choices[0].Message.Content.ContentStr, igual estructura que devolvería el gateway en JSON, pero como structs Go tipados.
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:
- El error viaja dentro del chunk. A diferencia del request normal, donde el error es el segundo valor de retorno, en streaming cada
chunklleva su propioBifrostError. Compruebachunk.BifrostError != nilen cada iteración y rompe el bucle si aparece. - El contenido llega en el
Delta. Cada chunk parcial trae su texto enchoice.ChatStreamResponseChoice.Delta.Content(un*string). Acumulas esos fragmentos para reconstruir la respuesta completa. - El último chunk es especial. Bifrost estandariza todos los streams para enviar el
usage(tokens) y elfinish reasonsolo en el último chunk; el contenido viaja en los chunks anteriores. Si tu código necesita el conteo de tokens, lo encontrarás al final, no repartido.
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.
- El Go SDK (
go get github.com/maximhq/bifrost/core) es el motor sobre el que se construye el gateway. Conviene cuando buscas latencia mínima, un único servicio Go o control total del ciclo de vida y la configuración. - La configuración se hace en código implementando la interfaz
AccountconGetConfiguredProviders,GetKeysForProvideryGetConfigForProvider, lo que te permite leer claves de tu propio secret manager. - Inicializas con
bifrost.Initpasando unBifrostConfig, y cierras conclient.Shutdown(). ChatCompletionRequestdevuelve la respuesta y un error tipadoBifrostErrorcon información estructurada del fallo.ChatCompletionStreamRequestdevuelve un canal que recorres confor chunk := range; el contenido llega enDelta.Contenty elusagesolo en el último chunk.- El tool calling declara herramientas como
schemas.ChatTooly se controla conParams.ToolChoice; las llamadas se leen enMessage.ChatAssistantMessage.ToolCalls. - Las context keys (
bfCtx.SetValue) pasan metadata por petición (request id, virtual key, headers, herramientas MCP) y conectan tu código con plugins y governance. - El logger se configura con
NewDefaultLoggero sustituyéndolo por una implementación propia de la interfazLogger, y se inyecta enInit.
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.