El uso de herramientas — también llamado function calling — es lo que convierte un modelo de chat en algo que puede hacer cosas: consultar un registro, llamar a una API, ejecutar un cálculo, interrogar tu base de datos. El modelo no ejecuta el código; te dice qué función llamar y con qué argumentos, tú la ejecutas y le devuelves el resultado para que termine la respuesta. La buena noticia: la forma tools de OpenAI es el estándar de facto, y a través de Brievio el mismísimo código gobierna tanto Claude como Gemini tras un único base_url. Cambia el string de model; deja todo lo demás igual.
Este artículo es la versión práctica: define una herramienta, lee tool_calls, ejecuta el bucle multironda de principio a fin y gestiona las llamadas paralelas. Cada fragmento es ejecutable contra https://api.brievio.com/v1 con el SDK de Python de OpenAI. Señalaré los pocos puntos donde el comportamiento difiere de verdad entre familias de modelos para que no te lleves sorpresas en producción.
El modelo mental: el bucle, no una llamada mágica
El function calling es una conversación, no un único disparo. Sigue siempre los mismos cuatro compases:
- Tú envías el mensaje del usuario más una lista de herramientas que el modelo puede usar.
- El modelo decide. O responde en prosa, o devuelve uno o más
tool_calls— un nombre de función y un string JSON de argumentos — y se detiene. - Tú ejecutas la función en tu propio código y añades el resultado de vuelta a la lista de mensajes como un mensaje
tool. - Vuelves a llamar al modelo con el historial más largo. Lee el resultado y o bien responde, o bien pide otra herramienta. Repite hasta que ya no haya más llamadas.
El modelo nunca toca tus sistemas. Solo propone; tu código dispone. Esa frontera es toda la historia de seguridad del uso de herramientas: trata cada argumento que envía el modelo como entrada no confiable y valídalo como harías con el campo de un formulario.
Paso 1 — define una herramienta y lee la llamada
Una herramienta es un JSON Schema envuelto en {"type": "function", ...}. Los campos description no son decoración: son lo único que el modelo lee para decidir cuándo y cómo llamar. Escríbelos como si redactaras un docstring para alguien junior:
# Define una herramienta con el esquema "function" estándar de OpenAI y luego
# lee de vuelta los tool_calls del modelo. Misma forma para Claude y Gemini.
from openai import OpenAI
import json
client = OpenAI(
api_key="sk-brievio-...",
base_url="https://api.brievio.com/v1",
)
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city.",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "e.g. 'Tokyo'"},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature unit.",
},
},
"required": ["city"],
},
},
}
]
messages = [{"role": "user", "content": "What's the weather in Tokyo?"}]
resp = client.chat.completions.create(
model="claude-sonnet-4-6", # cambia a "gemini-2.5-pro" — mismo código abajo
messages=messages,
tools=tools,
tool_choice="auto", # deja que el modelo decida si llama
)
msg = resp.choices[0].message
# El modelo no respondió en prosa: te pidió ejecutar una función.
if msg.tool_calls:
for call in msg.tool_calls:
print(call.function.name) # "get_weather"
print(call.function.arguments) # '{"city": "Tokyo", "unit": "celsius"}'
args = json.loads(call.function.arguments) # siempre es un STRING JSON — parséalo
else:
print(msg.content) # respuesta directa, sin herramientaAquí hay dos cosas que pillan a la gente. Primera, function.arguments es un string JSON, no un diccionario — siempre le haces json.loads. Segunda, el modelo puede decidir no llamar a ninguna herramienta, en cuyo caso tool_calls queda vacío y content contiene una respuesta normal. Contempla ambos casos. Esto es idéntico tanto si pones model en claude-sonnet-4-6 como en gemini-2.5-pro; Brievio pasa la petición al modelo de primera mano genuino y devuelve las llamadas nativas — no las reconfigura ni las falsea.
Paso 2 — el bucle multironda
Ahora conecta el viaje de ida y vuelta. La forma que importa: añade el mensaje del asistente exactamente como se devolvió (lleva los ids de las llamadas), luego añade un mensaje tool por llamada, cada uno devolviendo su tool_call_id. Si fallas un id, la siguiente petición da un 400. Aquí está el bucle entero, funcionando para ambos proveedores desde una sola función:
# El bucle multironda: el modelo pide -> tú ejecutas la función ->
# le devuelves el resultado -> el modelo escribe la respuesta final.
def run_get_weather(city: str, unit: str = "celsius") -> dict:
# Tu implementación real: una llamada HTTP, una consulta a la BD, lo que sea.
return {"city": city, "temp": 18, "unit": unit, "sky": "clear"}
TOOL_IMPLS = {"get_weather": run_get_weather}
def answer(question: str, model: str) -> str:
messages = [{"role": "user", "content": question}]
while True:
resp = client.chat.completions.create(
model=model,
messages=messages,
tools=tools,
tool_choice="auto",
)
msg = resp.choices[0].message
# No se pidió ninguna herramienta -> esta es la respuesta final. Listo.
if not msg.tool_calls:
return msg.content
# 1. Añade el turno del asistente EXACTAMENTE como se devolvió (lleva los
# ids de tool_call que los mensajes siguientes deben referenciar).
messages.append(msg)
# 2. Ejecuta cada función pedida y añade un mensaje tool por llamada,
# devolviendo el tool_call_id correspondiente.
for call in msg.tool_calls:
fn = TOOL_IMPLS[call.function.name]
args = json.loads(call.function.arguments)
result = fn(**args)
messages.append({
"role": "tool",
"tool_call_id": call.id, # DEBE coincidir con el id de la llamada
"content": json.dumps(result), # serializa el resultado a string
})
# 3. Bucle: el modelo ahora ve la salida de la herramienta y continúa.
# La misma función sirve para ambos proveedores tras el único base_url:
print(answer("What's the weather in Tokyo?", "claude-sonnet-4-6"))
print(answer("What's the weather in Tokyo?", "gemini-2.5-pro"))Ese while True es el motor de todos los agentes que has usado. Un modelo puede encadenar herramientas — llamar a search, leer el resultado, luego llamar a get_details sobre el primer acierto y después responder — y el bucle gestiona cualquier profundidad sin casos especiales. Añade un contador de turnos como salvaguarda para que un modelo confundido no gire para siempre; de 8 a 10 rondas es un techo razonable para la mayoría de las aplicaciones.
Una advertencia honesta sobre la portabilidad: el protocolo es idéntico entre Claude y Gemini, pero el comportamiento no es un clon. Cada familia de modelos escoge herramientas distintas, redacta los argumentos de otra forma y varía en su disposición a llamar frente a responder desde su conocimiento previo. El código no cambia; el criterio sí. Prueba tus prompts contra cada modelo en el que pienses desplegar en lugar de asumir que uno se traslada perfectamente al otro.
Paso 3 — llamadas paralelas
Cuando una pregunta necesita varias consultas independientes — el clima de tres ciudades, el stock de cinco SKU — un modelo capaz puede devolver todas las llamadas en un único turno del asistente. Las ejecutas (de forma concurrente, si el trabajo es de E/S) y devuelves un mensaje tool por id antes de volver a preguntar:
# Llamadas paralelas: un solo turno del asistente puede pedir varias funciones
# a la vez. Las ejecutas (de forma concurrente si quieres) y devuelves un mensaje
# tool por cada id de llamada. Que un modelo agrupe las llamadas varía — así que
# itera siempre sobre la lista en lugar de asumir exactamente una.
messages = [{"role": "user",
"content": "Compare the weather in Tokyo, Paris and Cairo."}]
resp = client.chat.completions.create(
model="claude-sonnet-4-6",
messages=messages,
tools=tools,
tool_choice="auto",
)
msg = resp.choices[0].message
messages.append(msg)
# msg.tool_calls puede contener ahora TRES llamadas a get_weather con ids distintos.
from concurrent.futures import ThreadPoolExecutor
def handle(call):
args = json.loads(call.function.arguments)
result = TOOL_IMPLS[call.function.name](**args)
return {
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result),
}
with ThreadPoolExecutor() as pool:
tool_msgs = list(pool.map(handle, msg.tool_calls or []))
messages.extend(tool_msgs) # añade TODOS los resultados antes de la siguiente petición
final = client.chat.completions.create(
model="claude-sonnet-4-6", messages=messages, tools=tools,
)
print(final.choices[0].message.content)
# Nota: si un modelo devuelve las llamadas de una en una en vez de agrupadas, el
# bucle del fragmento anterior lo gestiona gratis — simplemente da más vueltas.Aquí es donde más difieren las familias de modelos, así que no codifiques una suposición a fuego. Que un modelo dado emita llamadas paralelas en un turno, o las recorra una a una a lo largo de varios turnos, varía según la familia y a veces según la petición. La solución es simple y ya está en el código de arriba: itera sobre tool_calls y deja que el bucle dé más vueltas si hace falta. El código que recorre la lista devuelta es correcto en ambos casos; el código que asume exactamente una llamada es el bug. Del mismo modo, la aplicación de esquemas estrictos (JSON garantizado como válido, claves extra rechazadas) no es uniforme — sigue validando los argumentos del lado del servidor sin importar qué modelo los produjo.
Por qué un único base_url es la verdadera ganancia
Sin un gateway, soportar Claude y Gemini significa dos SDK, dos esquemas de autenticación, dos formas de payload y dos sistemas de fontanería para los resultados de herramientas — los bloques de contenido tool_use/tool_result de Anthropic por un lado, las partes de function-call de Google por el otro. Tras el endpoint compatible con OpenAI de Brievio, ambos hablan el dialecto tools de Chat Completions que viste arriba, así que un test A/B entre modelos es un diff de una línea y tu capa de herramientas se escribe una sola vez. El contrato completo de petición/respuesta — incluidos los campos de herramientas — está en la documentación de Chat Completions, y la lista de modelos en vivo con los ids exactos está en la página de modelos.
Conviene decirlo con claridad: el valor solo se sostiene si el modelo del otro lado es el real. El uso de herramientas es, de hecho, una señal de autenticidad útil — un buque insignia genuino produce de forma fiable tool_calls bien formados con argumentos sensatos en esquemas no triviales, mientras que un sustituto más barato tiende a torpedear el JSON o a ignorar la herramienta. Brievio sirve los modelos de primera mano genuinos (Claude Sonnet 4.6, Opus 4.7, Gemini 2.5 Pro/Flash y otros), respeta el uso nativo de herramientas y reporta conteos de tokens honestos; si quieres comprobarlo por tu cuenta, mira cómo verificar que tu Claude es realmente Claude.
Una breve lista de campo
- Parsea los argumentos, siempre.
function.argumentses un string; hazlejson.loadsy valídalo antes de usarlo. - Devuelve los ids. Añade el mensaje del asistente tal cual, luego un mensaje
toolpor llamada con eltool_call_idcorrespondiente. Todos ellos antes de la siguiente petición. - Itera sobre la lista. Nunca asumas una llamada por turno — gestiona cero, una y muchas. Ese único hábito hace que tanto los modelos paralelos como los secuenciales simplemente funcionen.
- Limita las rondas. Un contador de turnos evita una espiral infinita de llamadas a herramientas y acota tu coste.
- No confíes en nada. Los argumentos son salida del modelo. Valida tipos, rangos y permisos exactamente como harías con la entrada del usuario.
Acierta esos cinco puntos y tendrás un agente con uso de herramientas que corre sin cambios entre Claude y Gemini, con la opción de enrutar por coste o por capacidad en cada petición. Ten en cuenta que las llamadas fallidas 4xx/5xx en Brievio no se facturan, así que las inevitables iteraciones de ajuste de esquema mientras afinas las definiciones de herramientas son gratis. Cuando estés listo para elegir qué modelos poner detrás de tus herramientas, la guía de selección de gateway recorre las disyuntivas que de verdad importan en producción.