Inferencia: streaming, tool calling, multimodal y reranking
Inferencia: streaming, tool calling, multimodal y reranking
En el capítulo 5 vimos cómo Bifrost se convierte en un drop-in replacement: cambias la base URL de tu SDK y todo sigue funcionando. Pero ese truco no se queda solo en mandar un mensaje y recibir una respuesta de texto. La gran promesa de un AI gateway es que todas las capacidades de inferencia (streaming, herramientas, visión, audio, reranking, reasoning) viajan por una sola API unificada, sin importar si por debajo hablas con OpenAI, Anthropic, Cohere, Gemini, Bedrock o un modelo local.
En este capítulo recorremos esas capacidades una por una. La idea de fondo, que repetiremos hasta el cansancio porque es la columna vertebral de Bifrost, es esta: escribes tu request una vez y Bifrost lo traduce al dialecto nativo de cada proveedor. Tu código no cambia cuando cambias de modelo.
flowchart LR
A["Tu app<br/>una sola API"] --> B["Bifrost Gateway<br/>:8080"]
B --> C["streaming SSE"]
B --> D["tool calling"]
B --> E["multimodal<br/>imagen / audio"]
B --> F["reranking"]
B --> G["reasoning"]
B --> H["async inference"]
C --> Z["OpenAI / Anthropic / Cohere<br/>Gemini / Bedrock / locales"]
D --> Z
E --> Z
F --> Z
G --> Z
H --> Z
Nota sobre el modelo en cada request. En todos los ejemplos el campo
modelusa el formatoprovider/model(por ejemploopenai/gpt-4o-miniocohere/rerank-v3.5). Así Bifrost sabe a qué proveedor enrutar. Si configuraste aliases o un proveedor por defecto (ver capítulo 4), puedes simplificar el nombre.
Streaming: respuestas token a token
Cuando un modelo genera una respuesta larga, esperar a que termine entera produce una experiencia pobre: el usuario mira una pantalla en blanco durante segundos. El streaming resuelve esto enviando la respuesta en fragmentos (chunks) a medida que se generan, usando Server-Sent Events (SSE).
Activarlo es tan simple como agregar "stream": true al body. Bifrost soporta streaming en tres endpoints distintos.
Chat Completions en streaming
El caso más común. Usa el endpoint /v1/chat/completions:
curl --location 'http://localhost:8080/v1/chat/completions' \
--header 'Content-Type: application/json' \
--data '{
"model": "openai/gpt-4o-mini",
"messages": [{"role": "user", "content": "Tell me a story..."}],
"stream": true
}'
Cada chunk llega como un evento SSE cuyo contenido incremental viaja en delta.content:
data: {"choices":[{"delta":{"content":"Once"}}]}
data: {"choices":[{"delta":{"content":" upon"}}]}
data: [DONE]
El flujo termina con el marcador especial data: [DONE].
Text Completions en streaming
Para el endpoint clásico /v1/completions (prompt plano, sin mensajes):
curl --location 'http://localhost:8080/v1/completions' \
--header 'Content-Type: application/json' \
--data '{
"model": "openai/gpt-4o-mini",
"prompt": "Write a short haiku about the ocean",
"stream": true
}'
Aquí el contenido incremental llega en choices[].text en lugar de delta.content.
Responses API en streaming
El endpoint /v1/responses (la API de respuestas estilo OpenAI) usa un formato SSE basado en eventos, no en deltas anónimos:
curl --location 'http://localhost:8080/v1/responses' \
--header 'Content-Type: application/json' \
--data '{
"model": "openai/gpt-4o-mini",
"input": "Tell me one interesting fact about Mars",
"stream": true
}'
En este caso los eventos tienen tipos explícitos: response.created, response.output_text.delta y response.completed. Importante: este formato no usa el marcador [DONE]; el final se señaliza con el evento response.completed.
Detalles clave del streaming en Bifrost
Bifrost estandariza todos los streams para que se comporten igual entre proveedores. Su política es enviar el usage (consumo de tokens) y el finish reason solo en el último chunk, mientras que el contenido viaja en los chunks anteriores. Así siempre sabes dónde mirar para obtener las métricas finales.
Dos puntos a tener presentes:
- El timeout por defecto es de 30 segundos.
- La disponibilidad de streaming depende del soporte del proveedor; no todos los modelos lo ofrecen.
sequenceDiagram
participant App as Tu app
participant BF as Bifrost
participant LLM as Proveedor
App->>BF: POST /v1/chat/completions (stream:true)
BF->>LLM: stream nativo del proveedor
LLM-->>BF: chunk 1 (delta.content)
BF-->>App: data: {delta.content}
LLM-->>BF: chunk N (delta.content + usage + finish_reason)
BF-->>App: data: {ultimo chunk con usage}
BF-->>App: data: [DONE]
Audio en streaming. El streaming también aplica a audio: para Text-to-Speech (TTS) se usa
"stream_format": "sse"y los chunks llegan como audio codificado en base64; para Speech-to-Text (transcripción) se envía--form 'stream="true"'y se reciben fragmentos de texto incrementales. Veremos audio en detalle más abajo.
Tool calling: dale herramientas al modelo
El tool calling (o function calling) permite que el modelo, en lugar de responder solo con texto, decida invocar una función que tú definiste: consultar el clima, buscar en una base de datos, ejecutar una calculadora. El modelo no ejecuta nada; devuelve la intención de llamar (nombre de la función + argumentos) y tu código la ejecuta.
La gran ventaja en Bifrost es que esto está unificado entre proveedores: defines tus herramientas una vez con el formato de OpenAI y Bifrost lo traduce al esquema nativo de Anthropic, Gemini, etc.
Definir las herramientas en el request
Las herramientas se declaran en el campo tools del body, en el endpoint /v1/chat/completions. Cada herramienta es un objeto con type: "function" y una definición de function con su name, description y parameters (un JSON Schema):
"tools": [
{
"type": "function",
"function": {
"name": "calculator",
"description": "A calculator tool for basic arithmetic operations",
"parameters": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"description": "The operation to perform",
"enum": ["add", "subtract", "multiply", "divide"]
},
"a": {
"type": "number",
"description": "The first number"
},
"b": {
"type": "number",
"description": "The second number"
}
},
"required": ["operation", "a", "b"]
}
}
}
]
Cómo llegan los tool_calls
Si el modelo decide usar la herramienta, la respuesta incluye un arreglo tool_calls. Fíjate que los arguments llegan como string JSON, no como objeto; tienes que parsearlos antes de usarlos:
"tool_calls": [{
"id": "call_abc123",
"type": "function",
"function": {
"name": "calculator",
"arguments": "{\"operation\":\"add\",\"a\":15,\"b\":27}"
}
}]
El flujo completo es: envías el prompt + las tools, el modelo responde con tool_calls, tú ejecutas la función en tu código, y devuelves el resultado al modelo para que produzca la respuesta final.
sequenceDiagram
participant App as Tu app
participant BF as Bifrost
participant LLM as Modelo
App->>BF: messages + tools
BF->>LLM: request (esquema traducido)
LLM-->>BF: tool_calls (calculator, args)
BF-->>App: tool_calls
App->>App: ejecuta calculator(15,27) = 42
App->>BF: messages + resultado de la tool
BF->>LLM: continua conversacion
LLM-->>BF: respuesta final en lenguaje natural
BF-->>App: "El resultado es 42"
Controlar cuándo se usan las tools
El campo tool_choice gobierna la decisión:
"tool_choice": "auto"— deja que el modelo decida automáticamente (es el valor por defecto)."tool_choice": "none"— desactiva el uso de herramientas."tool_choice": {"type": "function", "function": {"name": "calculator"}}— fuerza una herramienta específica.
Tools propias vs. MCP. Además de definir tus propias funciones, Bifrost puede conectarse a servidores MCP para exponer herramientas externas al modelo de forma automática. Eso lo cubrimos a fondo en el capítulo 10: MCP Gateway.
Multimodal: texto, imágenes y audio
Los modelos modernos no solo leen texto: ven imágenes y escuchan audio. Bifrost expone estas capacidades multimodales a través de content arrays: en lugar de que content sea un string, es un arreglo de objetos donde cada uno declara su type.
Estructura del content array
Un mismo mensaje puede mezclar texto, imagen y audio:
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "..."},
{"type": "image_url", "image_url": {"url": "https://..."}},
{"type": "input_audio", "input_audio": {"data": "...", "format": "wav"}}
]
}]
Los tipos disponibles son:
"type": "text"con su campo"text": "..."."type": "image_url"con"image_url": {"url": "...", "detail": "high"}(eldetailes opcional)."type": "input_audio"con"input_audio": {"data": "<base64>", "format": "wav"}.
Todo esto viaja al endpoint de visión/chat: /v1/chat/completions.
Enviar una imagen
Hay dos formas de pasar una imagen:
Por URL (la imagen vive en internet):
{"type": "image_url", "image_url": {"url": "https://ejemplo.com/foto.jpg", "detail": "high"}}
En base64 (imagen local). Primero la codificas y luego la incrustas como data URL:
base64 -i local_image.jpg
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,/9j/4AAQ..."}}
Endpoints de audio e imagen
Más allá del chat con visión, Bifrost expone endpoints dedicados:
| Capacidad | Endpoint |
|---|---|
| Generación de imágenes | /v1/images/generations |
| Text-to-Speech (TTS) | /v1/audio/speech |
| Speech-to-Text (transcripción) | /v1/audio/transcriptions |
Para TTS, las voces disponibles son alloy, echo, fable, onyx, nova y shimmer, y los formatos de salida (response_format) son mp3, opus, aac y flac.
Para transcripción, puedes ajustar:
"language": "es"para audio que no está en inglés."response_format": "text"o"verbose_json"."timestamp_granularities[]"con valores"word"y"segment"para marcas de tiempo.
Soporte por proveedor. No todos los proveedores soportan visión, audio o generación de imágenes. Bifrost expone la capacidad de forma uniforme, pero el modelo destino debe soportarla. Consulta la página de proveedores soportados para el detalle específico de cada uno.
Reranking: ordenar documentos por relevancia (RAG)
En un pipeline de RAG (Retrieval-Augmented Generation) primero recuperas un montón de documentos candidatos (por búsqueda vectorial, por ejemplo) y luego necesitas quedarte con los más relevantes para la pregunta. Ahí entra el reranking: un modelo especializado que ordena los documentos por relevancia frente a una query.
Bifrost expone reranking en el endpoint /v1/rerank, cuyo propósito es “ordenar documentos por relevancia para búsqueda, recuperación y selección de contexto”.
Estructura del request
Parámetros requeridos:
model: en formatoprovider/model, por ejemplocohere/rerank-v3.5.query: el texto que sirve como criterio de relevancia.documents: arreglo de objetos con un campotext(más opcionalmenteidymeta).
Parámetros opcionales:
top_n: máximo de resultados a devolver.return_documents: booleano para incluir el contenido del documento en cada resultado.max_tokens_per_doc: límite de tokens por documento (depende del proveedor).priority: hint dependiente del proveedor.fallbacks: modelos alternativos en formatoprovider/model(más sobre fallbacks en el capítulo 7).
curl 'http://localhost:8080/v1/rerank' \
-H 'Content-Type: application/json' \
-d '{"model":"cohere/rerank-v3.5","query":"gateway observability","top_n":2,"return_documents":true,"documents":[{"id":"a","text":"..."}]}'
Estructura de la respuesta
Cada resultado contiene:
index: la posición original del documento en el arreglo de entrada.relevance_score: puntuación numérica de relevancia (escala 0-1).document: el objeto coincidente con suidytext(solo si pedistereturn_documents).
Además, la respuesta incluye model, usage (con prompt_tokens, completion_tokens, total_tokens) y extra_fields con metadata como provider, latency y request_type.
Proveedores de reranking soportados
| Proveedor | Ejemplo de modelo |
|---|---|
| Cohere | cohere/rerank-v3.5 |
| vLLM | vllm/BAAI/bge-reranker-v2-m3 |
| Bedrock | bedrock/<rerank-model-or-arn> |
| Vertex AI | vertex/<ranking-model> |
Compatibilidad con vLLM. Bifrost reintenta automáticamente el endpoint
/rerankcuando recibe respuestas404,405o501, para acomodar las variantes de ruta que usan distintos servidores vLLM.
Reasoning: modelos de razonamiento
Los modelos de razonamiento (como la familia de OpenAI o el modo thinking de Anthropic) dedican cómputo extra a “pensar” antes de responder. El reto es que cada proveedor lo configura distinto: OpenAI usa niveles de esfuerzo, Anthropic usa un presupuesto de tokens, Gemini usa otra cosa. Bifrost los normaliza bajo un único campo.
El campo unificado reasoning
En el request usas siempre reasoning; en la respuesta el razonamiento llega en reasoning_details (un arreglo de bloques). Bifrost convierte estos campos al formato nativo de cada proveedor.
{
"reasoning": {
"effort": "high",
"max_tokens": 4096
}
}
Los niveles de effort aceptados dependen del proveedor:
- OpenAI y Gemini 3.0+:
"minimal","low","medium","high". - Bedrock Nova:
"low","medium","high". - Anthropic / Bedrock Anthropic: usan
"enabled"(binario, no por niveles).
Restricción de Anthropic. Anthropic requiere
reasoning.max_tokens >= 1024; un valor menor produce un error. Los modelos Anthropic en Bedrock comparten esta restricción. Bedrock Nova, Cohere y Gemini admiten un mínimo de 1 token.
Lógica de prioridad cuando das ambos
Si envías effort y max_tokens a la vez, Bifrost resuelve según el tipo de proveedor:
- Proveedores por tokens (Anthropic, Cohere, Gemini): usan
max_tokense ignoraneffort. - Proveedores por esfuerzo (OpenAI, Nova): usan
efforte ignoranmax_tokens.
Cómo llega el razonamiento en la respuesta
{
"choices": [{
"message": {
"content": "Final answer",
"reasoning": "Combined text",
"reasoning_details": [{
"index": 0,
"type": "text",
"text": "Step-by-step...",
"signature": "optional"
}]
}
}]
}
El campo signature aparece solo en respuestas de Anthropic y Bedrock, para verificación criptográfica de los bloques de razonamiento.
El reasoning también funciona en streaming: Anthropic emite eventos thinking_delta y signature_delta, mientras que Gemini transmite partes de tipo thought dentro del contenido.
Equivalencias nativas. Por debajo, Bifrost mapea
reasoningareasoning.effort(OpenAI),thinking.budget_tokens(Anthropic),reasoning_config.budget_tokens(Bedrock Anthropic),reasoningConfig.maxReasoningEffort(Bedrock Nova) ythinking_budget/thinking_level(Gemini). Tú no escribes ninguno de esos: escribesreasoningy listo.
Async inference: dispara y olvídate
Hay cargas de trabajo donde no necesitas la respuesta de inmediato: procesamiento por lotes, tareas no bloqueantes, jobs distribuidos. Para eso está la inferencia asíncrona, un patrón fire-and-forget: envías el request a un endpoint async, recibes al instante un job_id, y consultas el resultado más tarde haciendo polling.
Endpoints async
Siguen el patrón /v1/async/{operation_type} para enviar (POST) y /v1/async/{operation_type}/{job_id} para consultar (GET). Cubren casi todas las operaciones: chat/completions, completions, embeddings, images/generations, audio/speech, audio/transcriptions, ocr, rerank, responses, etc.
Enviar un job (POST)
curl -X POST http://localhost:8080/v1/async/chat/completions \
-H "Content-Type: application/json" \
-H "x-bf-vk: sk-bf-your-virtual-key" \
-H "x-bf-async-job-result-ttl: 3600" \
-d '{
"model": "openai/gpt-4o-mini",
"messages": [{"role": "user", "content": "Summarize the latest release notes in 3 bullets"}]
}'
Bifrost responde 202 Accepted con el job en estado pending:
{
"id": "1e89b165-d4fe-49e8-beb2-3e157f2df02f",
"status": "pending",
"created_at": "2026-02-19T08:10:17.831Z"
}
Consultar el resultado (GET)
curl -X GET http://localhost:8080/v1/async/chat/completions/1e89b165-d4fe-49e8-beb2-3e157f2df02f \
-H "x-bf-vk: sk-bf-your-virtual-key"
Mientras el job sigue pending o processing, devuelve 202 Accepted. Cuando termina (completed o failed), devuelve 200 OK con el result anidado:
{
"id": "1e89b165-d4fe-49e8-beb2-3e157f2df02f",
"status": "completed",
"created_at": "2026-02-19T08:10:17.831Z",
"completed_at": "2026-02-19T08:10:19.412Z",
"expires_at": "2026-02-19T09:10:19.412Z",
"status_code": 200,
"result": {"id": "chatcmpl-123", "object": "chat.completion"}
}
stateDiagram-v2
[*] --> pending: POST /v1/async/... (202)
pending --> processing: worker toma el job
processing --> completed: proveedor responde OK
processing --> failed: proveedor devuelve error
completed --> [*]: GET devuelve result (200)
failed --> [*]: GET devuelve error (200)
Configuración y limitaciones
- Requiere un Logs Store configurado: las rutas async no se registran sin él. Lo verás en el capítulo 11: Observabilidad.
- TTL del resultado: por defecto 3600 segundos (1 hora) desde que el job se completa. Lo ajustas por request con el header
x-bf-async-job-result-ttl(en segundos). Valores inválidos o no positivos caen al valor por defecto del servidor. - Virtual keys: si enviaste el job con
x-bf-vk, el polling debe usar la misma key o recibirás404 Job not found or expired. Más sobre virtual keys en el capítulo 9: Governance. - Sin streaming: los endpoints async no soportan respuestas en streaming.
- Solo gateway: la inferencia asíncrona no está disponible en el Go SDK (ver capítulo 13).
- Expiración: la limpieza corre cada minuto; los jobs expirados devuelven
404 Job not found or expired. Los jobs atascados enprocessingno expiran automáticamente. - Observabilidad intacta: los jobs async se registran con la marca
isAsyncRequest: trueen la UI de logs, y los hooks de gobernanza y cost tracking siguen ejecutándose.
Una API, todos los proveedores
Si algo debe quedar grabado de este capítulo es esto: escribiste streaming, tools, content multimodal, reranking y reasoning una sola vez, con un único formato, y Bifrost se encargó de traducirlo a OpenAI, Anthropic, Cohere, Gemini, Bedrock, Vertex o tu modelo local. Cambiar de proveedor es cambiar el prefijo de model, no reescribir tu integración.
Esa unificación es exactamente lo que hace valioso un AI gateway: tu lógica de aplicación se desacopla del proveedor de turno. El día que aparezca un modelo mejor, lo enchufas cambiando una cadena de texto.
flowchart TD
A["Tu request unificado"] --> B{Bifrost normaliza}
B --> C["reasoning -> reasoning.effort (OpenAI)"]
B --> D["reasoning -> thinking.budget_tokens (Anthropic)"]
B --> E["tools formato OpenAI -> esquema nativo Gemini"]
B --> F["content array -> vision/audio del proveedor"]
C --> G["Respuesta normalizada de vuelta"]
D --> G
E --> G
F --> G
Resumen
- El streaming se activa con
"stream": truey funciona en/v1/chat/completions(deltas endelta.content),/v1/completions(texto enchoices[].text) y/v1/responses(eventos comoresponse.output_text.delta). Bifrost poneusageyfinish reasonsolo en el último chunk; el timeout por defecto es 30s. - El tool calling se define en el campo
tools(contype: "function"y un JSON Schema enparameters); las invocaciones vuelven entool_callsconargumentscomo string JSON.tool_choicecontrolaauto,noneo forzar una función. Todo unificado entre proveedores. - El multimodal usa content arrays con tipos
text,image_url(URL o data URL en base64) einput_audio. Hay endpoints dedicados:/v1/images/generations,/v1/audio/speechy/v1/audio/transcriptions. - El reranking vive en
/v1/rerankconmodel,queryydocuments; devuelveindex,relevance_scoreydocument. Es la pieza clave para mejorar la recuperación en RAG. Soporta Cohere, vLLM, Bedrock y Vertex. - El reasoning se configura con el campo unificado
reasoning(efforty/omax_tokens) y llega enreasoning_details. Bifrost mapea a los campos nativos de cada proveedor y respeta sus restricciones. - La inferencia asíncrona sigue el patrón
/v1/async/{op}(POST ->job_id-> polling con GET). Requiere Logs Store, respeta virtual keys, no soporta streaming y es solo del gateway.