Inferencia: streaming, tool calling, multimodal y reranking

Por: Artiko
bifroststreamingtool-callingmultimodalembeddings

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 model usa el formato provider/model (por ejemplo openai/gpt-4o-mini o cohere/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:

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:

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:

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:

CapacidadEndpoint
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:

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:

Parámetros opcionales:

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:

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

ProveedorEjemplo de modelo
Coherecohere/rerank-v3.5
vLLMvllm/BAAI/bge-reranker-v2-m3
Bedrockbedrock/<rerank-model-or-arn>
Vertex AIvertex/<ranking-model>

Compatibilidad con vLLM. Bifrost reintenta automáticamente el endpoint /rerank cuando recibe respuestas 404, 405 o 501, 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:

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:

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 reasoning a reasoning.effort (OpenAI), thinking.budget_tokens (Anthropic), reasoning_config.budget_tokens (Bedrock Anthropic), reasoningConfig.maxReasoningEffort (Bedrock Nova) y thinking_budget / thinking_level (Gemini). Tú no escribes ninguno de esos: escribes reasoning y 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


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

Siguiente: Resiliencia: retries, fallbacks y load balancing