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:

  • El timeout por defecto es de 30 segundos.
  • La disponibilidad de streaming depende del soporte del proveedor; no todos los modelos lo ofrecen.

Pausa/reanudación y memoria. Bifrost incorporó flujos de pausa y reanudación en las llamadas en streaming, de modo que un stream puede detenerse y retomarse sin perder el hilo de la generación. En el mismo esfuerzo se redujo el uso de memoria en los caminos de streaming, lo que hace más barato mantener muchos streams concurrentes abiertos.

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.

Herramientas nativas de Claude Sonnet 5

Más allá de las funciones que tú declaras, Bifrost soporta las herramientas integradas de la familia Claude Sonnet 5: generaciones con computer-use (el modelo opera una interfaz gráfica: mueve el cursor, hace clic, escribe) y con text-editor (edición de archivos de texto asistida por el modelo). Se usan a través del mismo mecanismo de tool calling unificado, sin que tengas que hablar el dialecto nativo de Anthropic.


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"} (el detail es 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:

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:

  • "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 formato provider/model, por ejemplo cohere/rerank-v3.5.
  • query: el texto que sirve como criterio de relevancia.
  • documents: arreglo de objetos con un campo text (más opcionalmente id y meta).

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 formato provider/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 su id y text (solo si pediste return_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

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:

  • 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_tokens e ignoran effort.
  • Proveedores por esfuerzo (OpenAI, Nova): usan effort e ignoran max_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.

Claude Sonnet 5 y el thinking adaptativo

La familia Claude Sonnet 5 añade un matiz al razonamiento: su thinking es adaptativo (adaptive-only thinking), es decir, el propio modelo decide cuánto “piensa” en función de la dificultad de la tarea en lugar de fijar un presupuesto rígido. Para orientar ese comportamiento Bifrost expone el parámetro effort, que ya conoces del campo reasoning. Además, con el thinking activo estos modelos aplican un gating de los parámetros de sampling: ciertos ajustes (como temperatura o top-p) quedan restringidos mientras el modelo razona, para no interferir con la calidad del razonamiento. No tienes que gestionar ese gating a mano; Bifrost lo respeta al traducir tu request.

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

  • 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ás 404 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 en processing no expiran automáticamente.
  • Observabilidad intacta: los jobs async se registran con la marca isAsyncRequest: true en 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": true y funciona en /v1/chat/completions (deltas en delta.content), /v1/completions (texto en choices[].text) y /v1/responses (eventos como response.output_text.delta). Bifrost pone usage y finish reason solo en el último chunk; el timeout por defecto es 30s.
  • El tool calling se define en el campo tools (con type: "function" y un JSON Schema en parameters); las invocaciones vuelven en tool_calls con arguments como string JSON. tool_choice controla auto, none o forzar una función. Todo unificado entre proveedores.
  • El multimodal usa content arrays con tipos text, image_url (URL o data URL en base64) e input_audio. Hay endpoints dedicados: /v1/images/generations, /v1/audio/speech y /v1/audio/transcriptions.
  • El reranking vive en /v1/rerank con model, query y documents; devuelve index, relevance_score y document. 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 (effort y/o max_tokens) y llega en reasoning_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.

Siguiente: Resiliencia: retries, fallbacks y load balancing