Les modèles de chat veulent discuter. Ton pipeline, lui, veut un enregistrement. L’écart entre "voici un gentil paragraphe sur le ticket" et {"category": "billing", "priority": "high"}, c’est là que la plupart des intégrations LLM cassent en silence — une clôture markdown égarée, une virgule en trop, une clé hallucinée, et le json.loads en aval explose à 3 h du matin. Ce billet parle de la façon d’extraire de force un JSON valide et utile de Claude et Gemini, en utilisant la même forme de requête OpenAI derrière une seule base_url, et de la couche de validation qui rend tout cela digne de la production plutôt que d’une démo.
Il y a trois outils pour ce travail : response_format avec json_object, response_format avec json_schema, et l’appel natif d’outils/de fonctions. Ils ne sont pas interchangeables, et choisir le mauvais est la raison la plus fréquente pour laquelle une fonctionnalité de sortie structurée paraît instable. On va parcourir chacun d’eux : quand le dégainer, comment concevoir le schéma, et comment valider et réparer ce qui revient.
Mode JSON : parsable garanti, correct non garanti
Le levier le plus simple est response_format={"type": "json_object"}. Il contraint le modèle à émettre du JSON syntaxiquement valide — pas de préambule en prose, pas de clôture ```json, pas d’excuses. Ce qu’il ne fait pas, c’est imposer ta structure. Tu dois toujours décrire les champs dans le prompt, et le modèle peut encore omettre une clé, en inventer une, ou mettre une chaîne là où tu voulais un booléen.
# response_format=json_object : le modèle est contraint d'émettre du JSON
# syntaxiquement valide. Il n'impose PAS TA structure — tu dois quand même décrire
# les champs dans le prompt. Appel identique pour Claude et Gemini derrière une seule base_url.
from openai import OpenAI
import json
client = OpenAI(
api_key="sk-brievio-...",
base_url="https://api.brievio.com/v1",
)
resp = client.chat.completions.create(
model="claude-sonnet-4-6", # ou "gemini-2.5-flash" — même code
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Extract the support ticket fields. Reply ONLY with a JSON object "
"with keys: category (one of billing|bug|feature|other), "
"priority (low|medium|high), summary (string), "
"needs_human (boolean)."
),
},
{"role": "user", "content": "I was charged twice this month, please refund."},
],
)
data = json.loads(resp.choices[0].message.content) # garanti parsable
print(data["category"], data["priority"]) # "billing" "high"
# json_object garantit : ça parse. Il ne garantit PAS que tes clés existent,
# que les enums sont valides, ni que les types sont bons. C'est à ça que sert la validation.C’est le bon outil quand la structure est simple, quand tu maîtrises étroitement le prompt, ou quand tu vas valider de toute façon (et c’est le cas). Le modèle mental : json_object t’achète la garantie que json.loads ne lèvera pas d’exception. Il ne t’achète pas la garantie que l’objet veut dire ce que tu crois. Considère cette nuance comme tout l’enjeu.
Mode JSON Schema : contraindre la structure, pas que la syntaxe
Quand tu veux que les noms de champs, les types et les enums soient imposés — pas seulement demandés — dégaine json_schema. Le schéma voyage avec la requête, et avec strict: true (là où la famille de modèles le prend en charge) la sortie est contrainte à le respecter. Les deux champs qui donnent vraiment un sens à "strict" sont additionalProperties: false (pas de clés-surprises) et un tableau required complet (pas de clés manquantes).
# response_format=json_schema : le schéma est envoyé au modèle et la sortie y
# est contrainte. Mets strict=True pour la garantie ferme là où c'est pris en charge.
# additionalProperties=False + les clés required, c'est ce qui donne du sens à "strict".
schema = {
"name": "support_ticket",
"strict": True,
"schema": {
"type": "object",
"additionalProperties": False,
"properties": {
"category": {"type": "string", "enum": ["billing", "bug", "feature", "other"]},
"priority": {"type": "string", "enum": ["low", "medium", "high"]},
"summary": {"type": "string"},
"needs_human": {"type": "boolean"},
},
"required": ["category", "priority", "summary", "needs_human"],
},
}
resp = client.chat.completions.create(
model="claude-sonnet-4-6",
response_format={"type": "json_schema", "json_schema": schema},
messages=[
{"role": "system", "content": "Extract the support ticket fields."},
{"role": "user", "content": "I was charged twice this month, please refund."},
],
)
ticket = json.loads(resp.choices[0].message.content)
# Avec json_schema strict, category est prouvablement l'un des quatre enums —
# pas besoin d'un "if category not in (...)" défensif sur le chemin nominal.Voici la réserve honnête, et elle compte : la prise en charge de json_schema strict varie selon la famille de modèles. Certains modèles honorent chaque contrainte, y compris les additionalProperties: false imbriqués ; d’autres traitent le schéma comme une indication forte plutôt qu’une grammaire ferme, surtout sur les objets profondément imbriqués, les unions (anyOf) ou les structures récursives. Brievio transmet ton response_format tel quel au modèle authentique de première partie, donc ce que tu obtiens, c’est le vrai comportement du vrai modèle — pas une émulation édulcorée. Mais cela signifie aussi que les limites natives du modèle sont tes limites. La règle pratique : demande le schéma, puis valide quand même. Ne laisse jamais le "strict" te dissuader de l’étape de validation.
Mode JSON vs appel d’outils : lequel utiliser quand
L’appel d’outils/de fonctions renvoie lui aussi du JSON structuré — les arguments reviennent sous forme de chaîne JSON associée à un nom de fonction. Alors lequel utiliser ? La distinction porte sur l’intention, pas sur le formatage :
- Utilise le mode JSON quand le JSON est la réponse. Tu extrais des champs, tu classes, tu résumes dans un enregistrement, ou tu génères un objet de configuration. Il y a exactement une structure que tu veux récupérer, à chaque fois.
response_formatcolle mieux — une seule sortie, pas de cérémonie d’appel de fonction, pas de plomberietool_choice. - Utilise l’appel d’outils quand le modèle choisit une action. Il pourrait appeler
get_weather, ousearch_db, ou répondre en prose — et tu veux que le modèle décide lequel, en appelant éventuellement plusieurs. L’appel de fonction est conçu pour le dispatch : plusieurs structures candidates, le modèle choisit. Forcer ça à travers un unique objet JSON est maladroit. - La zone grise : un appel d’outil unique forcé comme sortie structurée. Régler
tool_choicepour exiger une fonction précise est une façon éprouvée d’obtenir une sortie structurée sur les modèles antérieurs àjson_schema. Ça marche encore et c’est un repli tout à fait correct. Mais si un modèle prend en chargejson_schema, ce chemin est plus direct et demande moins de réflexion.
Si ta charge de travail porte réellement sur des actions et du dispatch plutôt que sur un enregistrement figé, la mécanique et les pièges inter-modèles sont détaillés dans l’usage des outils avec Claude et Gemini. Pour tout ce qui est "donne-moi cet objet", reste avec response_format.
Concevoir un schéma que le modèle peut réellement atteindre
Un schéma est un prompt. La façon dont tu le façonnes change le taux de réussite autant que le choix du modèle. Quelques règles qui paient dans les deux familles :
- Préfère le plat au profondément imbriqué. Trois niveaux d’imbrication avec des objets optionnels, c’est là que le mode strict vacille. Si tu peux aplatir
address.cityencity, fais-le, puis reconstruis la forme après la validation. - Utilise des enums pour tout ensemble fermé.
"priority": {"enum": ["low","medium","high"]}est bien plus fiable qu’unestringlibre que tu post-traites. Les enums sont la fonctionnalité de schéma au meilleur effet de levier. - Nomme les champs comme le ferait un humain.
needs_human_reviewbatnh_flag. Le modèle remplit plus exactement les champs bien nommés, parce que le nom porte l’instruction. - Mets une
descriptionsur les champs ambigus. Une ligne par champ à l’intérieur du schéma résout la plupart des cas où "le modèle a mal deviné" sans réécriture du prompt. - Rends l’optionnalité explicite. Si un champ peut être absent, soit tu le laisses hors de
required, soit tu le modélises comme une union nullable — n’attends pas que le modèle invente une valeur sentinelle. Décide qui détient le cas "manquant", toi ou le modèle. - Évite les nombres en forme libre quand un type borné suffit. Une note entière de 1 à 5 sous forme d’enum
[1,2,3,4,5]surpasse "un nombre de 1 à 5" dans le prompt.
Valider et réparer : la couche qui part en prod
La plus grande amélioration de fiabilité, c’est de traiter la sortie du modèle comme une requête de client non fiable : parse-la, valide-la contre ton vrai schéma, et en cas d’échec réessaie une fois avec l’erreur renvoyée. Un modèle Pydantic (ou zod, ou la validation JSON Schema dans ton langage) attrape les cas qui passent même à travers le mode strict — et le tour de réparation en corrige la plupart, parce que le modèle est doué pour corriger une erreur que tu lui pointes directement.
# Ne fais jamais confiance à une sortie que tu n'as pas validée. Traite le modèle
# comme un client non fiable : parse -> valide contre ton schéma -> réessaie une fois
# avec l'erreur renvoyée. C'est la couche qui transforme "marche en général" en "part en prod".
from pydantic import BaseModel, ValidationError
from typing import Literal
class Ticket(BaseModel):
category: Literal["billing", "bug", "feature", "other"]
priority: Literal["low", "medium", "high"]
summary: str
needs_human: bool
def extract(text: str, model: str, retries: int = 1) -> Ticket:
messages = [
{"role": "system", "content": "Extract the support ticket fields as JSON."},
{"role": "user", "content": text},
]
for attempt in range(retries + 1):
resp = client.chat.completions.create(
model=model,
response_format={"type": "json_object"},
messages=messages,
)
raw = resp.choices[0].message.content
try:
return Ticket.model_validate_json(raw) # parse + validation en une étape
except ValidationError as e:
if attempt == retries:
raise
# Tour de réparation : montre au modèle exactement ce qui n'allait pas.
messages += [
{"role": "assistant", "content": raw},
{"role": "user", "content": f"That failed validation: {e}. Re-emit valid JSON only."},
]
raise RuntimeError("unreachable")Remarque ce que fait le tour de réparation : il montre au modèle sa propre mauvaise sortie et l’erreur de validation exacte, puis demande une ré-émission. Un seul essai supplémentaire résout l’écrasante majorité des échecs ; s’il échoue encore, tu veux le savoir, alors laisse-le lever l’exception. Ne boucle pas à l’infini en brûlant des tokens — borne les essais, journalise la charge brute, et alerte sur les échecs durs. Un échec de validation persistant signifie d’habitude que le schéma demande quelque chose que l’entrée ne peut pas fournir, pas que le modèle est cassé.
Deux notes de production. Un, fixe un max_tokens généreux : du JSON tronqué en plein objet est du JSON invalide, et un plafond de tokens trop serré est une cause majeure d’échecs de parsing sur les gros enregistrements. Deux, garde une temperature basse (0 à 0,3) pour l’extraction et la classification — tu veux que la même entrée donne le même enregistrement, et la créativité n’est pas une vertu quand tu remplis une struct.
Une seule forme, les deux familles de modèles
Chaque extrait ci-dessus tourne sur Claude Sonnet 4.6 et Gemini 2.5 Flash en changeant une chaîne — le champ model. C’est tout l’intérêt de router la sortie structurée à travers Brievio : le contrat response_format à la forme OpenAI est identique, donc tu peux comparer en A/B un modèle moins cher sur une tâche d’extraction, ou basculer entre familles pendant un incident, sans réécrire ton parsing ni ta validation. La requête que tu envoies est la requête que reçoit le modèle authentique de première partie — voici exactement ce qui correspond et ce à quoi faire attention quand tu dépends de la compatibilité OpenAI.
Un flux de travail concret : prototype avec json_schema strict sur Sonnet, confirme que ton validateur passe sur un jeu mis de côté, puis essaie le même schéma sur Flash. Si le modèle moins cher franchit ton taux de validation, tu as réduit le coût sans aucun changement de code — et parce que Brievio rapporte des décomptes de tokens honnêtes et facture les appels 4xx/5xx en échec à zéro, tes essais et tes tours de réparation ne cachent pas de mauvaise surprise au compteur. Compare les modèles sur la page des modèles, et le contrat complet requête/réponse pour le chat se trouve dans la documentation du chat.
Ce qu’il faut retenir
Dégaine json_object quand la structure est simple et que tu possèdes le prompt ; dégaine json_schema avec strict: true, additionalProperties: false et un tableau required complet quand tu veux la structure imposée ; dégaine l’appel d’outils quand le modèle choisit une action plutôt que de produire un enregistrement figé. Quel que soit ton choix, conçois le schéma plat et riche en enums, puis fais toujours parse-valide-répare — parce que la prise en charge de strict varie selon la famille de modèles, et que la couche de validation fait la différence entre une fonctionnalité de sortie structurée qui fait démo et une qui survit au vrai trafic. Le même code, le même contrat, le modèle authentique — entre Claude et Gemini, derrière une seule base URL.