cd ../back to blog
$Guide//June 4, 2026//7 min read

Usage d’outils avec Claude et Gemini : un seul base_url, le même code

Définis un outil, lis les tool_calls, déroule la boucle multi-tours et gère les appels en parallèle — le même code OpenAI pilote Claude et Gemini via Brievio.

L’usage d’outils — aussi appelé function calling — c’est ce qui transforme un modèle de chat en quelque chose capable de faire des choses : consulter un enregistrement, appeler une API, lancer un calcul, interroger ta base de données. Le modèle n’exécute pas le code ; il t’indique quelle fonction appeler et avec quels arguments, tu la lances, et tu lui renvoies le résultat pour qu’il termine sa réponse. La bonne nouvelle : la forme tools d’OpenAI est le standard de fait, et via Brievio le même code exact pilote à la fois Claude et Gemini derrière un seul base_url. Tu changes la chaîne model ; tu laisses tout le reste tel quel.

Cet article, c’est la version pratique : définir un outil, lire les tool_calls, dérouler la boucle multi-tours de bout en bout, et gérer les appels en parallèle. Chaque snippet est exécutable contre https://api.brievio.com/v1 avec le SDK Python d’OpenAI. Je vais signaler les quelques endroits où le comportement diffère réellement entre familles de modèles, pour que tu n’aies pas de mauvaise surprise en production.

Le bon modèle mental : la boucle, pas un appel magique

Le function calling est une conversation, pas un coup unique. Il suit toujours les quatre mêmes temps :

  • Tu envoies le message utilisateur plus une liste des outils que le modèle a le droit d’utiliser.
  • Le modèle décide. Soit il répond en prose, soit il renvoie un ou plusieurs tool_calls — un nom de fonction et une chaîne JSON d’arguments — et s’arrête.
  • Tu lances la fonction dans ton propre code et tu ajoutes le résultat dans la liste de messages sous forme de message tool.
  • Tu rappelles le modèle avec l’historique allongé. Il lit le résultat et soit répond, soit demande un autre outil. Répète jusqu’à ce qu’il n’y ait plus d’appels d’outils.

Le modèle ne touche jamais à tes systèmes. Il ne fait que proposer ; c’est ton code qui dispose. Cette frontière, c’est toute l’histoire de la sécurité de l’usage d’outils — traite chaque argument que le modèle envoie comme une entrée non fiable et valide-le comme tu le ferais pour un champ de formulaire.

Étape 1 — définir un outil et lire l’appel

Un outil, c’est un JSON Schema enveloppé dans {"type": "function", ...}. Les champs description ne sont pas de la décoration — c’est la seule chose que le modèle lit pour décider quand et comment appeler. Rédige-les comme si tu écrivais une docstring pour un ingénieur junior :

define_tool.py
# Définis un outil avec le schéma "function" standard d'OpenAI, puis relis
# les tool_calls du modèle. Forme identique pour Claude et 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",   # remplace par "gemini-2.5-pro" — même code ci-dessous
    messages=messages,
    tools=tools,
    tool_choice="auto",          # laisse le modèle décider s'il appelle
)

msg = resp.choices[0].message

# Le modèle n'a pas répondu en prose — il te demande de lancer une fonction.
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)  # toujours une CHAÎNE JSON — parse-la
else:
    print(msg.content)           # réponse simple, aucun outil nécessaire

Deux pièges classiques ici. Premièrement, function.arguments est une chaîne JSON, pas un dict — tu fais toujours un json.loads dessus. Deuxièmement, le modèle peut choisir de ne pas appeler d’outil, auquel cas tool_calls est vide et content contient une réponse normale. Gère les deux cas. C’est identique que tu mettes model à claude-sonnet-4-6 ou à gemini-2.5-pro ; Brievio transmet la requête au véritable modèle d’origine et renvoie les appels d’outils natifs — il ne les remodèle ni ne les fabrique.

Étape 2 — la boucle multi-tours

Maintenant, câble l’aller-retour. Ce qui compte dans la forme : ajoute le message de l’assistant exactement tel que renvoyé (il porte les ids d’appel), puis ajoute un message tool par appel, chacun renvoyant son tool_call_id. Une erreur d’id et la requête suivante renvoie un 400. Voici toute la boucle, qui marche pour les deux fournisseurs depuis une seule fonction :

tool_loop.py
# La boucle multi-tours : le modèle demande -> tu lances la fonction ->
# tu lui renvoies le résultat -> le modèle rédige la réponse finale.
def run_get_weather(city: str, unit: str = "celsius") -> dict:
    # Ton implémentation réelle : un appel HTTP, une requête en base, peu importe.
    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

        # Aucun outil demandé -> c'est la réponse finale. Terminé.
        if not msg.tool_calls:
            return msg.content

        # 1. Ajoute le tour de l'assistant EXACTEMENT tel que renvoyé (il porte les
        #    identifiants tool_call que les messages suivants doivent référencer).
        messages.append(msg)

        # 2. Lance chaque fonction demandée et ajoute un message tool par appel,
        #    en renvoyant le tool_call_id correspondant.
        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,          # DOIT correspondre à l'id de l'appel
                "content": json.dumps(result),    # transforme le résultat en chaîne
            })
        # 3. Boucle : le modèle voit maintenant la sortie de l'outil et continue.

# La même fonction marche pour les deux fournisseurs derrière l'unique 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"))

Ce while True est le moteur de tous les agents que tu as utilisés. Un modèle peut enchaîner les outils — appeler search, lire le résultat, puis appeler get_details sur le meilleur résultat, puis répondre — et la boucle gère une profondeur arbitraire sans cas particulier. Ajoute un compteur de tours comme garde-fou pour qu’un modèle perdu ne tourne pas à l’infini ; 8 à 10 tours est un plafond raisonnable pour la plupart des applications.

Une mise en garde honnête sur la portabilité : le protocole est identique entre Claude et Gemini, mais le comportement n’est pas un clone. Les différentes familles de modèles choisissent des outils différents, formulent les arguments différemment, et varient dans leur empressement à appeler plutôt qu’à répondre depuis leurs connaissances préalables. Le code ne change pas ; le jugement, lui, change. Teste tes prompts contre chaque modèle que tu comptes mettre en production plutôt que de supposer que l’un se transfère parfaitement à l’autre.

Étape 3 — les appels d’outils en parallèle

Quand une question nécessite plusieurs consultations indépendantes — la météo de trois villes, le stock de cinq SKU — un modèle capable peut renvoyer tous les appels dans un seul tour d’assistant. Tu les lances (en parallèle, si le travail est limité par les I/O) et tu renvoies un message tool par id avant de redemander :

parallel_calls.py
# Appels d'outils en parallèle : un seul tour d'assistant peut demander plusieurs
# fonctions d'un coup. Tu les lances (en parallèle si tu veux) et tu renvoies un
# message tool par id d'appel. Selon les modèles, les appels sont groupés ou non —
# alors itère toujours sur la liste plutôt que de supposer un seul appel.
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 peut désormais contenir TROIS appels get_weather avec des ids distincts.
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)   # ajoute TOUS les résultats avant la requête suivante

final = client.chat.completions.create(
    model="claude-sonnet-4-6", messages=messages, tools=tools,
)
print(final.choices[0].message.content)

# Note : si un modèle renvoie les appels un par un au lieu de les grouper, la boucle
# du snippet précédent gère ça gratuitement — elle enchaîne simplement plus de tours.

C’est ici que les familles de modèles diffèrent le plus, alors ne code pas une hypothèse en dur. Le fait qu’un modèle donné émette des appels parallèles en un tour, ou les déroule un par un sur plusieurs tours, varie selon la famille et parfois selon la requête. La parade est simple et déjà présente dans le code ci-dessus : itère sur tool_calls et laisse la boucle enchaîner plus de tours si besoin. Un code qui boucle sur la liste renvoyée est correct dans les deux cas ; un code qui suppose exactement un appel est le bug. De même, la vérification stricte du schéma (JSON garanti valide, clés en trop rejetées) n’est pas uniforme — continue de valider les arguments côté serveur, quel que soit le modèle qui les a produits.

Pourquoi un seul base_url est le vrai gain

Sans passerelle, supporter Claude et Gemini signifie deux SDK, deux schémas d’authentification, deux formes de payload, et deux mécaniques de renvoi des résultats d’outils — les blocs de contenu tool_use/tool_result d’Anthropic d’un côté, les parties function-call de Google de l’autre. Derrière l’endpoint compatible OpenAI de Brievio, les deux parlent le dialecte tools du Chat Completions que tu as vu plus haut, donc un test A/B entre modèles tient en une ligne de diff et ta couche d’outils s’écrit une seule fois. Le contrat complet requête/réponse — y compris les champs d’outils — est dans la documentation Chat Completions, et la liste à jour des modèles avec leurs ids exacts est sur la page des modèles.

Disons-le franchement : la valeur ne tient que si le modèle à l’autre bout est le vrai. L’appel d’outils est en fait un signal d’authenticité utile — un véritable modèle phare produit de façon fiable des tool_calls bien formés avec des arguments sensés sur des schémas non triviaux, là où un remplaçant au rabais a tendance à bâcler le JSON ou à ignorer l’outil. Brievio sert les véritables modèles d’origine (Claude Sonnet 4.6, Opus 4.7, Gemini 2.5 Pro/Flash et d’autres), honore l’appel d’outils natif, et rapporte des décomptes de tokens honnêtes ; si tu veux le confirmer toi-même, vois comment vérifier que ton Claude est bien Claude.

Une courte checklist de terrain

  • Parse les arguments, toujours. function.arguments est une chaîne ; fais un json.loads dessus et valide avant usage.
  • Renvoie les ids. Ajoute le message de l’assistant tel quel, puis un message tool par appel avec le tool_call_id correspondant. Tous avant la requête suivante.
  • Boucle sur la liste. Ne suppose jamais un seul appel par tour — gère zéro, un et plusieurs. Cette seule habitude fait que les modèles parallèles et séquentiels marchent tous les deux du premier coup.
  • Plafonne les tours. Un compteur de tours empêche une spirale d’appels d’outils infinie et borne ton coût.
  • Ne fais confiance à rien. Les arguments sont une sortie de modèle. Valide les types, les plages et les permissions exactement comme tu le ferais pour une entrée utilisateur.

Réussis ces cinq points et tu obtiens un agent à outils qui tourne sans modification entre Claude et Gemini, avec l’option de router selon le coût ou la capacité requête par requête. Note que les appels en échec 4xx/5xx sur Brievio ne sont pas facturés, donc les inévitables itérations de réglage de schéma pendant que tu peaufines tes définitions d’outils sont gratuites. Quand tu seras prêt à choisir quels modèles placer derrière tes outils, le guide de sélection de passerelle passe en revue les arbitrages qui comptent vraiment en production.