
Une application qui utilise un modèle d’IA a souvent besoin de données précises : une catégorie, un montant, une référence ou une liste de champs à enregistrer.
Demander « réponds uniquement en JSON » ne suffit pas toujours. Le modèle peut ajouter une introduction, oublier une propriété ou utiliser une catégorie que votre application ne reconnaît pas.
Les sorties structurées, ou Structured Outputs, permettent de mieux contrôler ces réponses. Lorsqu’un modèle et sa route prennent en charge cette fonction, vous transmettez un schéma qui décrit la structure attendue.
Pour obtenir un résultat fiable, trois éléments doivent fonctionner ensemble : un contrat de données précis, une génération contrainte et une validation dans votre application.
Ce guide utilise des modèles du catalogue RouterLab et présente un exemple complet de classification de tickets de support. Les informations et les interfaces sont vérifiées au 13 octobre 2026 ; les capacités des modèles peuvent évoluer.
1. JSON valide, structure conforme et données exactes
Ces trois propriétés sont différentes.
| Propriété | Ce qu’elle vérifie |
|---|---|
| JSON valide | Le texte peut être lu par un parseur JSON. |
| Conformité au schéma | Les champs, les types et les valeurs autorisées correspondent au contrat. |
| Exactitude | Les données représentent correctement le contenu analysé. |
Par exemple :
{
"categorie": "urgent",
"resume": "Un problème de paiement."
}
Cet objet est du JSON valide. Il ne respecte toutefois pas un schéma qui exige une catégorie parmi facturation, technique, compte et autre, ainsi qu’un champ priorite.
Inversement, un objet peut respecter parfaitement le schéma tout en classant un problème de paiement dans la catégorie technique.
Les sorties structurées contrôlent la forme de la réponse. La qualité de l’analyse doit être évaluée séparément. Chez OpenAI, cette distinction explique aussi la différence entre le mode JSON et les Structured Outputs : le premier assure la syntaxe, les seconds ajoutent la conformité au schéma pris en charge. Documentation OpenAI.
2. Utiliser les modèles et les routes RouterLab
Les exemples de ce guide s’appuient sur les identifiants suivants :
| Modèle | Identifiant RouterLab | Format de requête publié |
|---|---|---|
| GPT-6 Sol | gpt-6-sol | OpenAI-compatible |
| Claude Sonnet 5.5 | claude-sonnet-5-5 | Claude Messages |
| Gemini 3.8 Flash | gemini-3.8-flash | OpenAI-compatible |
| Qwen 3.8 Flash | qwen3.8-flash | OpenAI-compatible |
| DeepSeek V4.1 Flash | deepseek-v4.1-flash | OpenAI-compatible |
Les formats disponibles sont indiqués dans le catalogue RouterLab. Vérifiez les modèles accessibles avec votre clé et le format proposé par leur fiche.
Le fournisseur du modèle et le protocole de votre client sont deux choix distincts. Sur RouterLab, un modèle Gemini peut être utilisé avec une route compatible OpenAI. Une route Claude Messages attend, quant à elle, un autre corps de requête.
La compatibilité avec un protocole ne signifie pas que tous les modèles acceptent chaque paramètre ou chaque contrainte de schéma.
3. Définir le contrat avec Zod ou Pydantic
Zod est une bibliothèque de validation pour TypeScript. Pydantic remplit ce rôle en Python.
Ces bibliothèques permettent de définir les données attendues et de vérifier les résultats reçus. Elles évitent de disperser les règles de validation dans votre application.
Pour notre ticket de support, nous attendons quatre champs :
categorie:facturation,technique,compteouautre;priorite:basse,normaleouhaute;resume: une description courte du problème ;reference: une référence présente dans le message, ounull.
La dernière règle est importante. Si le client ne fournit aucune référence, le modèle doit pouvoir exprimer cette absence. Exiger systématiquement une chaîne de caractères peut encourager une valeur inventée.
Les instructions doivent aussi préciser les règles de classification. Un paiement en attente ne signifie pas automatiquement une priorité haute. Celle-ci peut être réservée à un incident bloquant ou à une échéance explicitement mentionnée.
4. Exemple complet en TypeScript
Nous allons utiliser la route RouterLab compatible OpenAI :
POST https://api.routerlab.ch/v1/chat/completions
Le SDK OpenAI permet de configurer une URL différente de celle du fournisseur. L’authentification utilise votre clé RouterLab. Guide d’intégration RouterLab.
Installer les dépendances
Dans un projet Node.js :
npm install openai zod
npm install --save-dev tsx @types/node
Définissez ensuite votre clé dans l’environnement du serveur.
Sous Linux ou macOS :
export ROUTERLAB_API_KEY="votre-cle-routerlab"
Sous PowerShell :
$env:ROUTERLAB_API_KEY = "votre-cle-routerlab"
Pour vérifier les identifiants accessibles avec cette clé :
curl https://api.routerlab.ch/v1/models \
-H "Authorization: Bearer $ROUTERLAB_API_KEY"
Envoyer la demande et valider la réponse
Créez un fichier classer-ticket.ts :
import OpenAI from "openai";
import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";
const TicketSchema = z.strictObject({
categorie: z.enum(["facturation", "technique", "compte", "autre"]),
priorite: z.enum(["basse", "normale", "haute"]),
resume: z.string(),
reference: z.string().nullable(),
});
const responseFormat = zodResponseFormat(TicketSchema, "ticket_support");
async function main() {
const apiKey = process.env.ROUTERLAB_API_KEY;
if (!apiKey) throw new Error("ROUTERLAB_API_KEY is missing.");
const client = new OpenAI({
apiKey,
baseURL: "https://api.routerlab.ch/v1",
timeout: 60_000,
maxRetries: 0,
});
const model = process.env.ROUTERLAB_MODEL ?? "gpt-6-sol";
const start = performance.now();
const response = await client.chat.completions.create({
model,
stream: false,
response_format: responseFormat,
messages: [
{
role: "system",
content: [
"Classe le ticket de support fourni.",
"Utilise autre si la catégorie est ambiguë.",
"La priorité normale est la valeur habituelle.",
"Réserve haute à un incident bloquant ou urgent",
"explicitement décrit dans le message.",
"Résume le problème en français.",
"Copie la référence seulement si elle est présente.",
"Sinon, utilise null.",
].join(" "),
},
{
role: "user",
content:
"Mon paiement a été accepté, mais mes crédits " +
"ne sont toujours pas disponibles.",
},
],
});
const choice = response.choices[0];
if (!choice) throw new Error("No completion was returned.");
if (choice.message.refusal) throw new Error("The model refused the request.");
if (choice.finish_reason === "length") {
throw new Error("The response was truncated.");
}
if (choice.finish_reason !== "stop") {
throw new Error(`Unexpected finish reason: ${choice.finish_reason}`);
}
const content = choice.message.content;
if (!content) throw new Error("The response is empty.");
let data: unknown;
try {
data = JSON.parse(content);
} catch {
throw new Error("The response is not valid JSON.");
}
const validation = TicketSchema.safeParse(data);
if (!validation.success) {
console.error(validation.error.issues);
throw new Error("The response does not match the schema.");
}
console.log(JSON.stringify(validation.data, null, 2));
console.error({
model: response.model,
request: response.id,
duration_ms: Math.round(performance.now() - start),
usage: response.usage,
});
}
main().catch((error) => {
if (error instanceof OpenAI.APIError) {
console.error("API error:", error.status, error.message);
} else {
console.error(error instanceof Error ? error.message : "Unexpected error.");
}
process.exitCode = 1;
});
Exécutez le script :
npx tsx classer-ticket.ts
Voici une réponse illustrative conforme au contrat :
{
"categorie": "facturation",
"priorite": "normale",
"resume": "Le paiement est accepté, mais les crédits ne sont pas disponibles.",
"reference": null
}
Le texte du résumé peut varier. Les catégories et la structure attendue restent définies par le schéma.
Le helper zodResponseFormat construit le format de schéma envoyé au serveur. La validation locale utilise safeParse, qui renvoie explicitement un succès ou une erreur. z.strictObject rejette également les propriétés inattendues. Validation avec Zod, objets stricts.
Les contraintes du mode strict
Dans le format OpenAI, utilisez json_schema avec strict: true pour demander une conformité au schéma. Les objets doivent notamment déclarer additionalProperties: false, et les propriétés doivent être requises. Une valeur inconnue peut être représentée par un type acceptant null.
Tous les mots-clés de JSON Schema ne sont pas pris en charge. Un schéma métier complexe peut nécessiter une version simplifiée pour le fournisseur. Le helper du SDK ne crée pas de capacité supplémentaire dans le modèle ou la route. Schémas acceptés par OpenAI.
5. Reprendre le même exemple avec Gemini ou Qwen
Pour une autre route compatible OpenAI, le script conserve la même organisation. Modifiez l’identifiant du modèle :
export ROUTERLAB_MODEL="gemini-3.8-flash"
npx tsx classer-ticket.ts
Ou :
export ROUTERLAB_MODEL="qwen3.8-flash"
npx tsx classer-ticket.ts
Sous PowerShell, utilisez par exemple $env:ROUTERLAB_MODEL = "gemini-3.8-flash", puis relancez le script.
Ces essais permettent de vérifier la prise en charge de la demande structurée sur la route choisie et la qualité de la classification.
Une réponse HTTP 200 ne suffit pas à démontrer une garantie de schéma. Conservez la validation locale et examinez les résultats sur des entrées variées.
Gemini 3.8 Flash prend en charge les sorties structurées dans son API native. Lorsqu’on l’utilise à travers RouterLab, le contrat présenté ici est celui de la route compatible OpenAI. Fiche officielle Gemini 3.8 Flash.
6. Le cas de Claude Sonnet 5.5
Claude Sonnet 5.5 utilise ici la route publiée au format Claude Messages :
POST https://api.routerlab.ch/v1/messages
Elle emploie les en-têtes x-api-key et anthropic-version. Le corps de requête et les blocs de réponse diffèrent de Chat Completions. Guide Claude Messages RouterLab.
Pour les sorties JSON natives de Claude, le schéma se transmet avec output_config.format. En réutilisant le responseFormat défini plus haut, le corps d’une demande structurée reprend cette organisation :
const corpsClaude = {
model: "claude-sonnet-5-5",
max_tokens: 4096,
system:
"Classe le ticket. Utilise une priorité normale " +
"sauf urgence explicite. Une référence absente vaut null.",
messages: [
{
role: "user",
content:
"Mon paiement a été accepté, mais mes crédits " +
"ne sont toujours pas disponibles.",
},
],
output_config: {
format: {
type: "json_schema",
schema: responseFormat.json_schema.schema,
},
},
};
Cet extrait décrit le format natif à vérifier sur la route utilisée. La prise en charge de output_config.format doit être confirmée avant de l’intégrer à votre application.
Pour traiter la réponse, vérifiez son stop_reason, récupérez le bloc de texte, puis appliquez le même schéma de validation local.
Chez Claude, les sorties JSON natives et les arguments d’outils stricts sont deux fonctions distinctes. Forcer un outil ne remplace pas le mode strict ; Claude Sonnet 5.5 et Opus 5.5 refusent les configurations tool_choice de type tool ou any. Sorties structurées Claude, restrictions des appels d’outils.
7. Valider le résultat en Python avec Pydantic
Une application Python peut appliquer le même contrat avec Pydantic.
Installez la bibliothèque :
python -m pip install pydantic
Dans un terminal Linux ou macOS, enregistrez le JSON produit par le script TypeScript :
npx tsx classer-ticket.ts > ticket.json
Les diagnostics restent séparés du fichier grâce à leur écriture sur stderr. Sur Windows, veillez à enregistrer ticket.json en UTF-8.
Créez valider-ticket.py :
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, ConfigDict
class Ticket(BaseModel):
model_config = ConfigDict(extra="forbid")
categorie: Literal["facturation", "technique", "compte", "autre"]
priorite: Literal["basse", "normale", "haute"]
resume: str
reference: str | None
content = Path("ticket.json").read_text(encoding="utf-8")
ticket = Ticket.model_validate_json(content, strict=True)
print(ticket.model_dump())
Puis exécutez-le :
python valider-ticket.py
Le champ reference doit être présent, même lorsque sa valeur est null. Les propriétés supplémentaires sont rejetées. Une erreur de structure déclenche une exception de validation. Lecture et validation JSON avec Pydantic.
8. Traiter les échecs sans enregistrer de données partielles
Les erreurs doivent conduire à des actions différentes.
| Situation | Action adaptée |
|---|---|
| Schéma ou paramètre rejeté | Vérifier la compatibilité du modèle, de la route et du schéma. |
| Refus du modèle | Traiter le refus comme un résultat distinct. |
| Réponse interrompue | Examiner les limites de génération et la taille de la tâche. |
| JSON invalide | Écarter le résultat et rechercher pourquoi le contrat attendu n’a pas été obtenu. |
| Schéma non respecté | Conserver l’erreur de validation et écarter le résultat. |
| Données conformes mais incorrectes | Améliorer les consignes, les exemples et les critères d’évaluation. |
Une erreur 400 liée au schéma ne sera pas corrigée par des reprises identiques. Une indisponibilité temporaire peut justifier quelques reprises espacées.
Définissez un nombre maximal d’essais et mesurez leur coût. Le script présenté désactive les reprises automatiques afin que chaque exécution corresponde à une tentative visible.
En streaming, les fragments reçus peuvent être incomplets. Attendez la fin de la réponse et sa validation avant d’enregistrer l’objet définitif.
9. Vérifier aussi l’exactitude des données
La validation du schéma ne suffit pas pour décider qu’un ticket est correctement classé.
Préparez un jeu d’exemples avec des réponses attendues :
- un paiement accepté sans crédits disponibles ;
- une clé API refusée ;
- une demande de changement d’adresse e-mail ;
- un message trop vague pour déterminer une catégorie ;
- un incident urgent explicitement décrit ;
- un message contenant une référence à recopier.
Pour chaque modèle, mesurez séparément la conformité et l’exactitude. Une catégorie autorisée mais incorrecte est une erreur métier, même lorsque le JSON est impeccable.
Les champs extraits doivent être recoupés avec la source. Une référence au bon format peut avoir été inventée.
10. Comparer le coût d’un résultat accepté
Une comparaison utile conserve le même jeu de tickets, les mêmes règles et les mêmes critères d’acceptation.
Relevez :
- le taux de résultats conformes ;
- le taux de classifications correctes ;
- les refus et interruptions ;
- la latence ;
- le nombre de reprises ;
- le coût facturé pour obtenir un résultat accepté.
Comparez notamment GPT-6 Sol et Gemini 3.8 Flash avec le même script, puis Claude Sonnet 5.5 avec son protocole adapté. Le guide propose une méthode de comparaison ; les réponses montrées sont illustratives et ne constituent pas un benchmark des modèles.
Les sorties structurées ne garantissent pas une facture identique à celle d’une réponse libre. Claude ajoute notamment des instructions système facturées comme des tokens d’entrée. La première utilisation d’un schéma peut aussi ajouter une latence de préparation chez OpenAI. Documentation Claude, documentation OpenAI.
Le bon indicateur est le coût d’un résultat correct et accepté, avec les reprises nécessaires pour l’obtenir.
Avant la mise en production
- Définissez le contrat et les valeurs inconnues dans votre code.
- Vérifiez l’identifiant, le protocole et les capacités du modèle RouterLab.
- Confirmez la prise en charge du schéma sur la route choisie.
- Traitez les refus, interruptions et erreurs de validation.
- Contrôlez les règles métier avant tout enregistrement.
- Évaluez plusieurs modèles sur des données représentatives.
- Mesurez la qualité, la latence et le coût total du parcours.
Tester l’API RouterLab
Passez d’un article à une requête réelle : créez un essai, récupérez une clé et appelez les modèles depuis une API compatible OpenAI.