Fleeexdocs

Appel de fonctions

L'aller-retour complet des outils (requête, tool_calls, tour de résultat, réponse finale) et la façon dont les outils sont facturés.

tools et tool_choice sont honorés, et la boucle entière l'est aussi : un modèle qui peut demander un appel d'outil, et un appelant qui peut lui renvoyer le résultat. Une demi-boucle serait un endpoint utilisable une seule fois.

L'aller-retour

tool-loop.ts
import type OpenAI from "openai";
 
const tools: OpenAI.ChatCompletionTool[] = [
  {
    type: "function",
    function: {
      name: "get_weather",
      description: "Météo actuelle pour une ville.",
      parameters: {
        type: "object",
        properties: { city: { type: "string" } },
        required: ["city"],
      },
    },
  },
];
 
const messages: OpenAI.ChatCompletionMessageParam[] = [
  { role: "user", content: "Quel temps fait-il à Lyon ?" },
];
 
// 1. Demander, en proposant l'outil.
const first = await client.chat.completions.create({
  model: "nova-lite",
  messages,
  tools,
});
 
const message = first.choices[0]!.message;
 
if (first.choices[0]!.finish_reason === "tool_calls") {
  // 2. Rejouer le tour assistant tel quel : il porte les tool_calls.
  messages.push(message);
 
  for (const call of message.tool_calls ?? []) {
    // `arguments` est une *chaîne* JSON, comme sur le protocole OpenAI.
    const args = JSON.parse(call.function.arguments) as { city: string };
    const result = await getWeather(args.city);
 
    // 3. Un message tool par appel, reprenant son id en écho.
    messages.push({
      role: "tool",
      tool_call_id: call.id,
      content: JSON.stringify(result),
    });
  }
 
  // 4. Redemander, cette fois avec les résultats dans la conversation.
  const second = await client.chat.completions.create({
    model: "nova-lite",
    messages,
    tools,
  });
  console.log(second.choices[0]?.message.content);
}

Ce qui revient

  • finish_reason: "tool_calls", et choices[0].message.tool_calls[] = { id, type: "function", function: { name, arguments } }.
  • arguments est une chaîne JSON, pas un objet. C'est la forme du protocole OpenAI : passez-la à JSON.parse.
  • content vaut null quand le modèle n'a produit aucun texte à côté de l'appel. C'est ce que renvoie OpenAI, et c'est ce que vous rejouez tel quel.

Ce que vous renvoyez

Le tour assistant qui a demandé l'appel est rejoué avec ses tool_calls, et chaque résultat est un message tool. Les deux sont validés, et une conversation malformée donne un 400 plutôt qu'un 502 venu du fournisseur :

Règle
tool_callsUniquement sur un message assistant.
tool_call_idObligatoire sur un message tool, interdit partout ailleurs.
contentObligatoire, sauf sur un message assistant porteur de tool_calls.
Chaque tool_call_idDoit correspondre à un id déclaré par un message assistant antérieur.

Un outil déclaré sans parameters est transmis avec le schéma signifiant « ne prend aucun argument », ce qui est bien ce qui a été demandé.

tool_choice

Valeur
'auto'Le modèle décide.
'required'Le modèle doit appeler un outil.
{ type: 'function', function: { name } }Cet outil-là.
'none'Un 400. Il n'existe aucun moyen d'exposer les outils tout en en interdisant l'usage, et les deux approximations sont indétectables : retirer les outils change la réponse et les tokens d'entrée facturés, ignorer le champ laisse le modèle appeler un outil que vous aviez interdit. Envoyez plutôt la requête sans tools.

tool_choice sans tools est aussi un 400.

Streaming : les arguments arrivent en fragments

Avec stream: true, un appel d'outil est livré sous forme de fragments delta.tool_calls, dans la forme propre à OpenAI :

  • le premier fragment d'un appel porte index, id, type et le nom de la fonction, avec arguments: "" ;
  • chaque fragment suivant ne porte qu'un morceau du JSON des arguments, sous le même index.

Concaténer les morceaux d'un même index redonne exactement la chaîne qu'aurait portée un corps non diffusé. Accumulez donc par index et parsez une seule fois à la fin : un fragment isolé n'est pas du JSON valide.

const pending = new Map<number, { name: string; args: string }>();
 
for await (const chunk of stream) {
  for (const fragment of chunk.choices[0]?.delta?.tool_calls ?? []) {
    const entry = pending.get(fragment.index) ?? { name: "", args: "" };
    if (fragment.function?.name) entry.name = fragment.function.name;
    entry.args += fragment.function?.arguments ?? "";
    pending.set(fragment.index, entry);
  }
}
 
for (const [, { name, args }] of pending) {
  console.log(name, JSON.parse(args)); // parser seulement une fois complet
}

Les outils coûtent des tokens d'entrée

C'est le point qui surprend les intégrateurs : les schémas d'outils et les résultats d'outils sont du contenu de prompt. Ils comptent dans l'estimation pessimiste qui dimensionne la réservation sur le portefeuille, et ils font partie des tokens qui vous sont facturés.

  • Le name, la description et les octets sérialisés du schéma parameters de chaque outil déclaré.
  • Le nom d'outil dans un tool_choice de forme { type: 'function' }.
  • Le contenu et le tool_call_id de chaque message tool, ainsi que l'id, le name et les arguments sérialisés de chaque entrée tool_calls rejouée.

Un schéma de 16 Ko représente des milliers de tokens d'entrée à chaque appel de la boucle, et un aller-retour en deux temps paie le schéma deux fois. Un schéma trop gros se manifeste donc par un 402 sur un portefeuille qui aurait couvert la conversation, avant même d'apparaître sur une facture. Raccourcissez les descriptions, et n'envoyez pas des outils que le modèle ne peut pas utiliser à ce tour.

Les arguments d'un appel d'outil sont de la sortie générée et sont facturés comme telle : une réponse composée uniquement d'un appel d'outil n'est pas gratuite.

Limites

Toutes donnent un 400. Voir le tableau complet des limites.

Limite
tools128 au maximum
function.name64 caractères au maximum, sur la grammaire [A-Za-z0-9_-] d'OpenAI
function.description4 096 caractères au maximum
function.parametersun objet JSON, 16 384 caractères sérialisés au maximum, 10 niveaux de profondeur au maximum
tool_calls32 par message au maximum ; chaque arguments 32 768 caractères au maximum, et doit se parser en objet
tool_call_id128 caractères au maximum

function.strict, comme tout autre sous-champ non déclaré d'un outil, donne un 400 qui le nomme.

Ce qui est stocké

Rien du contenu. Les appels d'outils sont de la sortie et les résultats d'outils de l'entrée ; l'événement d'usage enregistre le décompte des tokens, le modèle et le coût, jamais un message, un appel d'outil ou un résultat d'outil.