OpenAI API 2026
TL;DR En 2026 l'API OpenAI est double-pile : Chat Completions (legacy mais omniprésent) + Responses API (le nouveau standard, remplace les Assistants v2). Tu vises Responses pour les agents/conversations stateful, Chat Completions pour les calls one-shot. Structured outputs strict (
json_schema) est ton meilleur ami, ça te dispense de 80% des prompts "renvoie du JSON valide stp". Reasoning models (o4-mini, o4, o3) facturent les "reasoning tokens" séparément. Batch API -50%, Realtime API pour la voix. Fine-tuning sur GPT-4o-mini reste l'option pour les tâches répétitives à volume.
Mental model
OpenAI 2026 = une plateforme à 4 portes :
- Chat Completions : la porte historique. Stateless, simple.
- Responses API : la porte moderne. Stateful (server-side conversation state), tools built-in (web_search, file_search, code_interpreter), MCP, streaming.
- Realtime API : voix bidirectionnelle (speech-to-speech).
- Batch API : -50%, jusqu'à 24h.
┌─────────────────────────────────────────────────────────┐
│ OPENAI PLATFORM │
│ │
│ ┌─────────────────┐ ┌──────────────────────────┐ │
│ │ Chat Completions│ │ Responses API │ │
│ │ stateless │ │ - state server-side │ │
│ │ /v1/chat/... │ │ - tools natifs : │ │
│ │ tools/funcs │ │ • web_search │ │
│ │ structured │ │ • file_search (RAG) │ │
│ │ vision │ │ • code_interpreter │ │
│ └────────┬────────┘ │ • image_generation │ │
│ │ │ • MCP (remote) │ │
│ │ │ - reasoning + thinking │ │
│ │ └──────────┬───────────────┘ │
│ │ │ │
│ ┌────────▼────────────────────────▼──────────────────┐ │
│ │ MODELS 2026 │ │
│ │ GPT-5 / GPT-5 mini │ │
│ │ GPT-4.1 / 4.1-mini / 4.1-nano (le workhorse) │ │
│ │ o4 / o4-mini / o3 (reasoning, "thinking") │ │
│ │ GPT-4o (legacy, multimodal, vision/audio) │ │
│ │ text-embedding-3-large / -3-small │ │
│ │ Whisper-1, TTS-1 │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────┐ ┌─────────────────┐ │
│ │ Realtime API │ │ Batch API │ │
│ │ speech-to- │ │ 50% off │ │
│ │ speech │ │ <24h │ │
│ └──────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────┘Analogie : Chat Completions = téléphone fixe (un appel = une conversation). Responses API = WhatsApp (la plateforme garde l'historique, tu peux brancher des bots, des outils, des médias).
Chat Completions vs Responses : la décision qu'un staff prend en 30 secondes
| Critère | Chat Completions | Responses API |
|---|---|---|
| State | Stateless, tu gères l'historique | Server-side (previous_response_id) ou stateless (store=false) |
| Tools natifs | Function calling uniquement | web_search, file_search, code_interpreter, image_generation, MCP remote |
| Reasoning | Oui (o-series) | Oui + reasoning items réutilisables entre tours |
| Streaming | stream=True (deltas) | Événements typés (response.output_text.delta, .completed...) |
| Portabilité | Standard de facto (Azure, vLLM, Groq, Together l'implémentent) | Spécifique OpenAI |
| Coût caching | Prompt caching auto si tu réenvoies le préfixe | Idem + le state évite de réenvoyer l'historique |
| Quand | One-shot, multi-provider, code legacy, tu veux contrôler le contexte | Agents stateful, RAG managé, tu veux déléguer le contexte à OpenAI |
Le piège du
store=truepar défaut. Responses persiste tes payloads 30 jours côté OpenAI. En santé/finance/RGPD, metsstore=falseet gère ton historique toi-même (sinon tu exfiltres de la PII chez un sous-traitant non contractualisé pour ça). Le revers : sansstore, tu perds le caching cross-tours etprevious_response_id— tu réenvoies tout l'historique à chaque tour.
Pourquoi le "stateful server-side" n'est pas magique
previous_response_id ne supprime pas le coût : à chaque tour OpenAI re-feed l'historique complet au modèle, donc tu payes toujours les tokens d'input cumulés (atténués par le prompt caching). Ça t'épargne le transport et le bookkeeping, pas la facture de contexte. Mental model correct : c'est du sucre syntaxique + caching, pas une mémoire à coût nul. Pour un long fil, le coût croît en O(n²) sur les tokens — à un moment tu dois résumer/tronquer toi-même, exactement comme en Chat Completions.
Code minimal
Responses API (recommended) :
from openai import OpenAI
client = OpenAI() # OPENAI_API_KEY
# Single-turn
resp = client.responses.create(
model="gpt-5",
input="Résume cet article en 5 lignes : ...",
instructions="Tu es un journaliste éco. Reste neutre.",
)
print(resp.output_text)
# Multi-turn avec state server-side
conv = client.responses.create(
model="gpt-5",
input="Qu'est-ce que la directive DORA ?",
instructions="Tu es un consultant cybersec.",
)
followup = client.responses.create(
model="gpt-5",
previous_response_id=conv.id, # OpenAI garde l'état
input="Et son impact sur les PSAN ?",
)
print(followup.output_text)Chat Completions (legacy mais toujours utile) :
resp = client.chat.completions.create(
model="gpt-4.1",
messages=[
{"role": "system", "content": "Tu es un consultant."},
{"role": "user", "content": "Bonjour"},
],
temperature=0.0,
)
print(resp.choices[0].message.content)Structured outputs strict :
from pydantic import BaseModel
class Devis(BaseModel):
prix_eur: float
delai_jours: int
options: list[str]
resp = client.responses.parse(
model="gpt-5",
input="Devis pour assurance habitation 80m2 Lyon",
text_format=Devis, # Pydantic schema → strict JSON
)
devis: Devis = resp.output_parsed
print(devis.prix_eur, devis.options)Cas d'usage concrets
Compta — Extraction structurée depuis facture fournisseur (expert-comptable Bordeaux)
Problème : cabinet d'expertise comptable, 60 clients PME, ~3 000 factures fournisseurs/mois à saisir dans Sage. 2 collaborateurs à plein temps font du re-keying : numéro facture, date, montants HT/TVA/TTC, numéro de compte, mention TVA intra-com... Erreurs : ~6% (TVA mal qualifiée, doublons), redressements coûteux.
Solution : GPT-5 vision + structured outputs strict → JSON validé Pydantic → check anti-doublon → écriture Sage via API.
from pydantic import BaseModel, Field
from typing import Literal
class FactureFournisseur(BaseModel):
fournisseur_nom: str
fournisseur_siret: str | None
fournisseur_tva_intra: str | None
numero_facture: str
date_facture: str = Field(description="YYYY-MM-DD")
date_echeance: str | None = Field(default=None, description="YYYY-MM-DD")
montant_ht_eur: float
montant_tva_eur: float
montant_ttc_eur: float
taux_tva: float = Field(description="0.20, 0.10, 0.055, 0.021, 0")
devise: Literal["EUR", "USD", "GBP", "CHF"] = "EUR"
mention_autoliquidation: bool = False
categorie_compta: Literal[
"frais_generaux", "fournitures_bureau", "prestation_service",
"achat_marchandise", "telecom", "energie", "transport", "autre"
]
coherence_montants: bool = Field(description="HT + TVA == TTC ?")
confiance: float = Field(ge=0.0, le=1.0)
raisons_doute: list[str] = Field(default_factory=list)
def extract_facture(image_url: str) -> FactureFournisseur:
resp = client.responses.parse(
model="gpt-5",
instructions="Tu es un expert-comptable. Extrais les données de la facture avec rigueur.",
input=[{
"role": "user",
"content": [
{"type": "input_text", "text": "Extrais la facture suivante."},
{"type": "input_image", "image_url": image_url},
],
}],
text_format=FactureFournisseur,
)
return resp.output_parsedGains chiffrés :
- Saisie : 4 min/facture → 8 sec auto + 30 sec validation
- 2 ETP → 0.4 ETP sur cette tâche, 1.6 ETP réaffecté à du conseil facturable
- Erreurs : 6% → 0.8%
- Coût IA : ~$0.012/facture × 3000 = $36/mois
- Revenus additionnels conseil : 1.6 ETP × 750€/j × 18 j/mois = 21 600€/mois
- TJM mission : 1 300€/j × 30 j (Sage API + workflow validation) = 39 000€ + 1 200€/mois MCO
FinTech — KYC : OCR + structured JSON depuis pièce + selfie (néobanque B2C français)
Problème : néobanque, 200 dossiers KYC/jour. Compliance impose un re-keying humain + check liveness. 35% des dossiers en attente > 24h → friction onboarding, taux d'abandon 22%.
Solution : GPT-5 vision (excellent OCR + cross-check MRZ vs visuel) + structured outputs strict + intégration provider liveness tiers (Onfido, IDnow). Décision : auto-validation si confiance > 0.92, sinon humain.
class KYCResult(BaseModel):
type_piece: Literal["CNI", "PASSEPORT", "TITRE_SEJOUR"]
nom: str
prenom: str
date_naissance: str
nationalite: str
numero_piece: str
date_expiration: str
mrz_ligne1: str
mrz_ligne2: str
coherence_mrz_visuel: bool
suspicion_falsification: bool
raisons_suspicion: list[str]
confiance: float
def kyc_extract(piece_image_url: str) -> KYCResult:
resp = client.responses.parse(
model="gpt-5",
instructions=(
"Tu es un système KYC conforme RGPD. "
"Extrais les champs et détecte les indices de falsification "
"(typographie incohérente, MRZ non-checksum-valid, manipulation photo)."
),
input=[{
"role": "user",
"content": [
{"type": "input_text", "text": "Extrais et vérifie cette pièce d'identité."},
{"type": "input_image", "image_url": piece_image_url, "detail": "high"},
],
}],
text_format=KYCResult,
)
return resp.output_parsedGains chiffrés :
- Délai onboarding : 22h → 4 min
- Taux abandon : 22% → 9%
- Capacité KYC : 200/j → 800/j
- Coût IA : $0.04/dossier × 200 = $8/j (~240€/mois)
- ROI : +13% de comptes activés × 80€ LTV × 200 dossiers/j × 250 j/an = 520k€/an
- TJM mission : 1 400€/j × 40 j (compliance, audit ACPR, intégration core banking) = 56 000€ + 3 500€/mois MCO
E-commerce — Agent vocal support (marketplace bricolage)
Problème : marketplace de matériel bricolage, 600 appels/jour au support. SLA 90 sec, en pratique 6 min d'attente. Saisonnalité forte (mars-mai +120%), staffing impossible à scaler.
Solution : Realtime API + GPT-4o-realtime pour 1er niveau (tracking commande, retours, FAQ produits). Escalade humaine si sentiment négatif ou hors-périmètre.
import asyncio
import websockets
import json
import base64
async def voice_agent_session(audio_stream):
uri = "wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2026"
headers = {"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}", "OpenAI-Beta": "realtime=v1"}
async with websockets.connect(uri, additional_headers=headers) as ws:
await ws.send(json.dumps({
"type": "session.update",
"session": {
"instructions": SUPPORT_VOICE_PROMPT,
"voice": "shimmer",
"input_audio_format": "pcm16",
"output_audio_format": "pcm16",
"input_audio_transcription": {"model": "whisper-1"},
"turn_detection": {"type": "server_vad", "threshold": 0.5},
"tools": SUPPORT_TOOLS,
"tool_choice": "auto",
},
}))
# ... bidirectional audio handling ...Gains chiffrés :
- Temps attente : 6 min → 0 sec (Réponse immédiate)
- 70% des appels traités sans humain
- 8 agents → 3 agents
- ROI : 5 ETP × 32k€ chargé = 160k€/an économisés
- Coût Realtime : $0.06/min × 600 appels × 4 min = $144/j (~4 300€/mois)
- Marge nette : ~110k€/an
- TJM mission : 1 500€/j × 35 j = 52 500€ + 2 500€/mois MCO
Exemple end-to-end
Pipeline ETL fiscal complet : ingère des factures PDF (mail, S3, drive) → vision GPT-5 + structured outputs → DB Postgres → recommandations comptables.
"""
fiscal_etl.py — Pipeline ETL fiscal pour cabinet d'expertise comptable.
Workflow:
1. Watch inbox S3 (factures PDF déposées par clients)
2. Pour chaque PDF: convert pages → images
3. Extract via GPT-5 vision + structured outputs Pydantic
4. Validation: cohérence montants, doublons, format SIRET
5. Categorize: plan comptable PCG via GPT-5
6. Persist Postgres
7. Generate recommandations (provisions, TVA récup, charges déductibles)
"""
from __future__ import annotations
import os
import json
import base64
import asyncio
from pathlib import Path
from datetime import datetime, timedelta
from typing import Literal
from pydantic import BaseModel, Field, field_validator
from openai import AsyncOpenAI
from pypdf import PdfReader
from pdf2image import convert_from_path
import psycopg
import boto3
aclient = AsyncOpenAI()
s3 = boto3.client("s3")
# ---------- 1. Schemas ----------
class LigneFacture(BaseModel):
libelle: str
quantite: float
prix_unitaire_ht: float
montant_ht: float
taux_tva: float
class Facture(BaseModel):
fournisseur_nom: str
fournisseur_siret: str | None
fournisseur_tva_intra: str | None
numero_facture: str
date_facture: str
date_echeance: str | None
lignes: list[LigneFacture]
montant_ht_total: float
montant_tva_total: float
montant_ttc_total: float
devise: str = "EUR"
mention_autoliquidation: bool = False
coherence_montants: bool
confiance: float = Field(ge=0.0, le=1.0)
@field_validator("fournisseur_siret")
@classmethod
def validate_siret(cls, v: str | None) -> str | None:
if v is None: return None
v_clean = v.replace(" ", "")
if len(v_clean) != 14 or not v_clean.isdigit():
raise ValueError(f"SIRET invalide: {v}")
return v_clean
class Categorisation(BaseModel):
compte_pcg: str = Field(description="Numéro de compte PCG (ex: 606300, 615500)")
nature: Literal["charge_externe", "achat_marchandise", "immobilisation", "service", "autre"]
deductible_tva: bool
deductible_is: bool
commentaire_expert: str
class Reco(BaseModel):
type: Literal["provision", "tva_recup", "charge_deductible", "anomalie", "info"]
description: str
impact_eur: float
urgence: Literal["haute", "moyenne", "basse"]
# ---------- 2. Step 1: Extract ----------
EXTRACT_INSTRUCTIONS = """Tu es un expert-comptable français. Extrais les données de la facture avec rigueur.
Règles :
- Vérifie coherence_montants: somme HT lignes == HT total ; HT + TVA == TTC (tolérance 0.02€)
- Si autoliquidation TVA intra-com : taux_tva=0 sur ligne, mais identifie la mention
- SIRET = 14 chiffres ; TVA intra FR = FR + 11 chiffres
- Si champ illisible : null + lowercase confiance
- Toutes les dates au format ISO YYYY-MM-DD
"""
async def extract_facture(pdf_path: Path) -> Facture | None:
images = convert_from_path(pdf_path, dpi=200, first_page=1, last_page=2)
images_b64 = []
for img in images:
import io
buf = io.BytesIO()
img.save(buf, format="PNG")
images_b64.append(base64.b64encode(buf.getvalue()).decode())
content = [{"type": "input_text", "text": "Extrais la facture."}]
for b64 in images_b64:
content.append({
"type": "input_image",
"image_url": f"data:image/png;base64,{b64}",
"detail": "high",
})
try:
resp = await aclient.responses.parse(
model="gpt-5",
instructions=EXTRACT_INSTRUCTIONS,
input=[{"role": "user", "content": content}],
text_format=Facture,
temperature=0.0,
)
return resp.output_parsed
except Exception as e:
print(f"[!] Extract failed for {pdf_path}: {e}")
return None
# ---------- 3. Step 2: Categorize ----------
CATEGORIZE_INSTRUCTIONS = """Tu es un expert-comptable français. Catégorise la facture selon le PCG.
Comptes courants :
- 606100 Fournitures non stockables (eau, énergie)
- 606300 Fournitures bureau
- 611000 Sous-traitance générale
- 613200 Locations immobilières
- 615500 Entretien et réparations
- 622600 Honoraires
- 623000 Publicité, publications
- 626100 Frais postaux et télécom
- 626800 Frais bancaires
- 628100 Cotisations
- 218300 Matériel informatique (immo si > 500€ HT)
Indique si TVA récupérable (oui sauf gasoil VT/VP partiel, frais réception, cadeaux > 73€).
"""
async def categorize(facture: Facture) -> Categorisation:
resp = await aclient.responses.parse(
model="gpt-5",
instructions=CATEGORIZE_INSTRUCTIONS,
input=f"Facture: {facture.model_dump_json()}",
text_format=Categorisation,
temperature=0.0,
)
return resp.output_parsed
# ---------- 4. Step 3: Persist ----------
def save_to_db(client_id: str, facture: Facture, cat: Categorisation, pdf_key: str):
with psycopg.connect(os.environ["DATABASE_URL"]) as conn:
with conn.cursor() as cur:
cur.execute(
"""INSERT INTO factures
(client_id, fournisseur, siret, numero, date_facture, ht, tva, ttc,
compte_pcg, deductible_tva, payload, source_pdf)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT (client_id, fournisseur, numero) DO NOTHING""",
(
client_id, facture.fournisseur_nom, facture.fournisseur_siret,
facture.numero_facture, facture.date_facture,
facture.montant_ht_total, facture.montant_tva_total, facture.montant_ttc_total,
cat.compte_pcg, cat.deductible_tva,
facture.model_dump_json(), pdf_key,
),
)
def check_duplicate(client_id: str, facture: Facture) -> bool:
with psycopg.connect(os.environ["DATABASE_URL"]) as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT 1 FROM factures WHERE client_id=%s AND fournisseur=%s AND numero=%s",
(client_id, facture.fournisseur_nom, facture.numero_facture),
)
return cur.fetchone() is not None
# ---------- 5. Step 4: Recos ----------
RECO_INSTRUCTIONS = """Tu es un expert-comptable. À partir d'un lot de factures du mois, génère 3-7 recommandations actionables pour le client (provisions à passer, TVA non récupérée à corriger, anomalies)."""
async def generate_recos(factures_with_cat: list[dict]) -> list[Reco]:
payload = json.dumps(factures_with_cat[:50]) # cap pour rester sous limite
resp = await aclient.responses.parse(
model="gpt-5",
instructions=RECO_INSTRUCTIONS,
input=f"Factures du mois: {payload}",
text_format=list[Reco],
temperature=0.2,
)
return resp.output_parsed
# ---------- 6. Main pipeline ----------
async def process_pdf(client_id: str, pdf_key: str):
tmp = Path(f"/tmp/{pdf_key.replace('/', '_')}")
s3.download_file(os.environ["S3_BUCKET"], pdf_key, str(tmp))
facture = await extract_facture(tmp)
if facture is None or facture.confiance < 0.7:
await flag_for_human(client_id, pdf_key, facture)
return
if check_duplicate(client_id, facture):
print(f"[~] Duplicate skipped: {facture.numero_facture}")
return
cat = await categorize(facture)
save_to_db(client_id, facture, cat, pdf_key)
print(f"[+] {facture.fournisseur_nom} {facture.numero_facture} → {cat.compte_pcg}")
async def flag_for_human(client_id: str, pdf_key: str, facture: Facture | None):
with psycopg.connect(os.environ["DATABASE_URL"]) as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO factures_to_review (client_id, pdf_key, payload) VALUES (%s,%s,%s)",
(client_id, pdf_key, facture.model_dump_json() if facture else None),
)
async def end_of_month_recos(client_id: str):
with psycopg.connect(os.environ["DATABASE_URL"]) as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT payload, compte_pcg FROM factures "
"WHERE client_id=%s AND date_facture >= %s",
(client_id, datetime.now() - timedelta(days=30)),
)
rows = [{"facture": json.loads(p), "compte": c} for p, c in cur.fetchall()]
recos = await generate_recos(rows)
for r in recos:
print(f"[reco/{r.urgence}] {r.type}: {r.description} (impact {r.impact_eur}€)")
async def main():
response = s3.list_objects_v2(Bucket=os.environ["S3_BUCKET"], Prefix="factures/inbox/")
keys = [obj["Key"] for obj in response.get("Contents", [])]
sem = asyncio.Semaphore(10)
async def bounded(k):
async with sem:
await process_pdf(client_id=k.split("/")[2], pdf_key=k)
await asyncio.gather(*[bounded(k) for k in keys])
await end_of_month_recos(client_id="ACME-001")
if __name__ == "__main__":
asyncio.run(main())Coût observé en prod sur ce pipeline : ~0.012€/facture extraction + ~0.003€/facture catégorisation = 0.015€/facture. Pour 3000 factures/mois = 45€. Comparé à 2 ETP × 3500€/mois = 7000€ → ROI 150x.
Patterns courants
1. Structured outputs strict (vs json_object mode)
# Bon — strict schema, validation garantie
resp = client.responses.parse(
model="gpt-5",
input="...",
text_format=Devis, # Pydantic
)
# Moins bon — json_object: pas de schema, juste "valide JSON"
resp = client.chat.completions.create(
model="gpt-4.1",
response_format={"type": "json_object"},
messages=[{"role": "user", "content": "Renvoie un JSON avec prix_eur..."}],
)strict: True est désormais le défaut quand tu passes un Pydantic à text_format. Le modèle ne peut physiquement pas sortir du JSON invalide.
2. Function calling avec strict mode
tools = [{
"type": "function",
"function": {
"name": "search_orders",
"description": "Recherche commandes Shopify",
"parameters": {
"type": "object",
"properties": {
"email": {"type": "string"},
"status": {"type": "string", "enum": ["pending", "shipped", "delivered"]},
},
"required": ["email"],
"additionalProperties": False,
},
"strict": True,
},
}]additionalProperties: false + strict: True est indispensable pour que ça soit vraiment "strict".
3. Reasoning models (o4, o4-mini, o3)
resp = client.responses.create(
model="o4-mini",
input="Problème de logique : ...",
reasoning={"effort": "medium"}, # low | medium | high
)
print(resp.output_text)
print(resp.usage.output_tokens_details.reasoning_tokens) # facturé outputUse cases : code, math, planning multi-étapes. Coût plus haut, latence plus haute (10-60 sec), qualité supérieure sur les tâches difficiles.
Le piège que personne ne dit : le reasoning coûte sur les tâches faciles. Un o4-mini effort=high sur "classe ce ticket en bug/feature" va brûler 800 reasoning tokens facturés en output pour une décision qu'un gpt-4.1-nano tranche en 5 tokens. La règle staff : reasoning seulement quand la tâche a une vraie structure de recherche (plusieurs étapes inter-dépendantes, backtracking, preuve). Pour de l'extraction/classification/reformulation, un modèle non-reasoning bien promptté est plus rapide ET moins cher. Mesure avant de migrer : si gpt-4.1 passe ton eval, ne paie pas le reasoning.
Réutilisation des reasoning items entre tours (le détail Responses-only). Sur o-series via Responses, le modèle peut réutiliser sa chaîne de raisonnement d'un tour à l'autre via previous_response_id — il ne re-raisonne pas tout depuis zéro. C'est invisible en Chat Completions (où chaque appel repart à blanc). Conséquence : un agent multi-tours qui décompose un problème est sensiblement moins cher en Responses. Mais : si tu fais store=false, OpenAI te renvoie les reasoning items chiffrés (encrypted_content) que tu dois re-fournir au tour suivant — sinon tu perds le bénéfice. Oublier ça = chaque tour re-raisonne = facture qui double silencieusement.
Tableau de décision modèle (ce qu'un staff choisit en 10 secondes) :
| Tâche | Modèle 2026 | Pourquoi |
|---|---|---|
| Extraction / classification volume | gpt-4.1-mini ou -nano | Cheap, rapide, structured outputs OK, pas besoin de raisonner |
| OCR + vision structurée (factures, KYC) | gpt-5 ou gpt-4.1 | Qualité vision + JSON garanti |
| Code complexe / debug / archi | o4 ou o4-mini high | Vrai gain du reasoning |
| Math / logique / planning | o4-mini | Meilleur ratio reasoning/€ |
| Chat produit grand public | gpt-4.1 | Latence basse, qualité suffisante |
| Résumé / reformulation FR simple | gpt-4.1-mini | Inutile de surpayer |
| Back-office nocturne en masse | n'importe lequel en Batch -50% | Pas de SLA → moitié prix |
Heuristique de coût : commence au modèle le moins cher qui passe ton eval, et ne monte que si l'eval échoue. L'inverse (partir de GPT-5 et descendre) coûte 10-20x en prod sans que personne ne s'en aperçoive avant la facture.
4. Vision + structured outputs
resp = client.responses.parse(
model="gpt-5",
input=[{
"role": "user",
"content": [
{"type": "input_text", "text": "Extrais les champs"},
{"type": "input_image", "image_url": url, "detail": "high"},
],
}],
text_format=MySchema,
)detail: "high" = 2x plus cher en tokens d'image mais lecture précise (utile OCR). detail: "low" pour classification rapide.
5. Batch API (-50%)
# 1. Build .jsonl
import json
with open("batch_input.jsonl", "w") as f:
for i, doc in enumerate(documents):
f.write(json.dumps({
"custom_id": f"doc-{i}",
"method": "POST",
"url": "/v1/responses",
"body": {"model": "gpt-5", "input": doc, "text_format": ...},
}) + "\n")
# 2. Upload + create batch
file = client.files.create(file=open("batch_input.jsonl", "rb"), purpose="batch")
batch = client.batches.create(
input_file_id=file.id,
endpoint="/v1/responses",
completion_window="24h",
)
# 3. Poll
while batch.status not in ("completed", "failed", "expired"):
time.sleep(60)
batch = client.batches.retrieve(batch.id)
# 4. Read results
out = client.files.content(batch.output_file_id)
for line in out.text.splitlines():
record = json.loads(line)
# ...6. Embeddings
resp = client.embeddings.create(
model="text-embedding-3-large", # 3072 dim
input=["texte 1", "texte 2"],
dimensions=1024, # truncate Matryoshka style, -67% storage, -3% recall
)
vecs = [d.embedding for d in resp.data]Astuce 2026 : dimensions=1024 ou 512 sur text-embedding-3-large réduit storage et latence search avec perte de recall minime (Matryoshka representation).
7. Streaming (TTFB perçu, pas TTLB)
Pour toute UX où un humain lit (chat, génération longue), streamer divise par 5-10 le time-to-first-byte perçu même si le temps total est identique. En NestJS, tu relaies en SSE.
with client.responses.stream(
model="gpt-5",
input="Rédige un rapport de 800 mots sur DORA.",
) as stream:
for event in stream:
if event.type == "response.output_text.delta":
print(event.delta, end="", flush=True)
final = stream.get_final_response()
print(final.usage) # tokens dispo seulement à la finCôté serveur (FastAPI/NestJS), tu forward chaque delta en text/event-stream. Piège : si le client se déconnecte, annule le stream (stream.close()) sinon tu payes la génération complète pour un écran vide.
8. Prompt caching : comment ça marche vraiment
Le prompt caching OpenAI est automatique (pas de flag à poser comme cache_control chez Anthropic) mais il obéit à des règles mécaniques qu'un staff doit connaître pour le faire mordre :
- Préfixe exact, longueur minimale ~1024 tokens. Le cache matche le plus long préfixe commun avec un appel récent. En dessous de 1024 tokens d'input, pas de caching du tout.
- Granularité 128 tokens. Le hit s'incrémente par blocs de 128 — un préfixe de 1500 tokens cache 1408, pas 1500.
- TTL court : 5-10 min d'inactivité, étendu à ~1h en heures creuses. Ce n'est pas un cache persistant : c'est une optimisation de rafale. Pour 3000 factures traitées en 20 min, le système+tools reste chaud → caching massif. Pour 3000 factures étalées sur 24h, peu de hits.
- Réduction : ~50% sur les tokens cachés (les
cached_tokenssont facturés à moitié prix, pas gratuits).
Mental model : ordonne ton prompt du plus stable au plus variable. system → tools → few-shots → contexte fixe → input utilisateur. Tout ce qui change à chaque appel (timestamp, ID, contenu user) va à la fin. Une seule variation en tête (un datetime.now() dans les instructions, un dict Python sérialisé sans ordre de clés stable) casse 100% du cache. C'est exactement le piège de l'exercice 6.
# Vérifie que ça mord :
log.info("cached=%s / in=%s", resp.usage.input_tokens_details.cached_tokens,
resp.usage.input_tokens)
# Sur le 2e appel d'une rafale avec préfixe stable : cached/in doit être > 0.99. Tools natifs & MCP (Responses API)
Le gros différenciateur Responses vs Chat Completions, ce sont les outils hébergés : tu ne codes ni l'exécution ni le plumbing, OpenAI les exécute server-side et te rend le résultat dans la même réponse.
resp = client.responses.create(
model="gpt-5",
input="Quel est le dernier taux directeur BCE et son impact sur un prêt à taux variable ?",
tools=[
{"type": "web_search"}, # recherche web live, citations incluses
{"type": "file_search", "vector_store_ids": ["vs_abc"]}, # RAG managé
{"type": "code_interpreter", "container": {"type": "auto"}}, # sandbox Python
# MCP remote : branche un serveur d'outils externe (le tien ou un tiers)
{"type": "mcp", "server_label": "sage",
"server_url": "https://mcp.moncabinet.fr", "require_approval": "never"},
],
)Ce qu'un staff retient :
web_search/file_search/code_interpretert'évitent de réimplémenter RAG, scraping et sandbox — mais tu perds le contrôle (pas de re-ranking custom sur file_search, pas de choix de l'index, latence opaque). Pour un RAG sérieux, beaucoup gardent leur propre pipeline (embeddings + pgvector) et n'utilisent file_search que pour les prototypes.- MCP remote transforme Responses en orchestrateur : tu exposes tes outils métier (Sage, core banking) via un serveur MCP, OpenAI les appelle. Attention
require_approval:"never"laisse le modèle déclencher des écritures sans validation — en finance, mets"always"sur tout outil mutatif et garde un humain dans la boucle. - Coût caché : chaque built-in tool ajoute des tokens (résultats de recherche, contenu de fichiers) à ton input du tour suivant. Un
web_searchpeut injecter 5-10k tokens. Loggeusageaprès chaque tour d'agent.
10. Hardening prod : retries, timeouts, exceptions typées, observabilité
Un appel LLM en prod n'est pas un create() nu. Le SDK gère déjà beaucoup, mais tu dois le configurer et observer.
import logging
from openai import AsyncOpenAI, APITimeoutError, RateLimitError, APIStatusError
# Retries exponentiels (429/5xx/timeout) + timeout par call, configurés une fois
aclient = AsyncOpenAI(max_retries=4, timeout=30.0)
log = logging.getLogger("llm")
async def call(input_text: str, schema, model="gpt-5"):
try:
resp = await aclient.responses.parse(
model=model,
input=input_text,
text_format=schema,
timeout=20.0, # override par-call possible
)
except RateLimitError as e:
log.warning("rate_limited retry_after=%s", e.response.headers.get("retry-after"))
raise # laisse le retry SDK ou un fallback modèle moins cher
except APITimeoutError:
log.error("timeout model=%s", model)
raise
except APIStatusError as e: # 4xx/5xx non retryables (400 schema, 401, 403)
log.error("api_error status=%s body=%s", e.status_code, e.response.text[:300])
raise
u = resp.usage
# LOG SYSTÉMATIQUE DU COÛT — sinon tu pilotes à l'aveugle
log.info(
"llm_call model=%s in=%s out=%s reasoning=%s cached=%s",
model, u.input_tokens, u.output_tokens,
getattr(u.output_tokens_details, "reasoning_tokens", 0),
getattr(u.input_tokens_details, "cached_tokens", 0),
)
return resp.output_parsedCe qu'un staff exige dans ce bloc :
max_retries+timeoutposés au client, pas oubliés. Le défaut SDK (2 retries) est souvent trop bas pour les pics 429.- Exceptions typées distinguées : un
400(schéma invalide, prompt trop long) ne se retry jamais — c'est un bug, pas une surcharge. Le retry SDK ne touche que 408/409/429/5xx. resp.usageloggé à chaque call : input/output/reasoning/cached. Sans ça, ta facture est une boîte noire et tu ne détectes pas une régression de coût (ex. un prompt qui gonfle silencieusement).cached_tokens: vérifie que ton prompt caching mord (préfixe système stable). 0 cached sur un système constant = ton préfixe varie quelque part (timestamp, ordre de clés JSON).- Idempotency-Key sur les écritures (batch, fine-tune) pour ne pas relancer deux fois après un timeout réseau côté client.
11. Parallélisme : asyncio.gather borné, jamais for await
sem = asyncio.Semaphore(10) # borne la concurrence → évite le 429 auto-infligé
async def bounded(doc):
async with sem:
return await call(doc, MySchema)
results = await asyncio.gather(*[bounded(d) for d in docs], return_exceptions=True)
ok = [r for r in results if not isinstance(r, Exception)]return_exceptions=True est le détail senior : sans lui, une facture qui throw annule les 999 autres en vol. Avec, tu isoles l'échec et tu retraites juste celui-là.
Versions & écosystème 2026
| Élément | Version mai 2026 |
|---|---|
| openai Python SDK | 1.80+ |
| openai TS/JS SDK | 4.80+ |
| GPT-5 | Released Mar 2026, $5/$15 (input/output /M) |
| GPT-5 mini | $0.30/$1.20 |
| GPT-4.1 | $2/$8, contexte 128K |
| GPT-4.1 mini | $0.15/$0.60 |
| GPT-4.1 nano | $0.05/$0.20, ultra cheap |
| o4 | reasoning, $8/$32 |
| o4-mini | reasoning cheap, $1/$4 |
| o3 | legacy reasoning, $10/$40 |
| GPT-4o realtime | $5/min input, $20/min output (voix) |
| Whisper-1 | $0.006/min |
| TTS-1 / TTS-1-hd | $15/$30 par 1M chars |
| text-embedding-3-large | $0.13/M, 3072 dim (Matryoshka) |
| text-embedding-3-small | $0.02/M, 1536 dim |
Fine-tuning : disponible sur gpt-4.1-mini, gpt-4o-mini, gpt-4o. Coût : training ~$5-25/M tokens, inference 1.5-2x du modèle base.
Responses API est la voie recommandée pour les nouveaux projets. Assistants API v2 est en deprecation prévue mi-2026.
Pitfalls
json_objectmode sans schema → le modèle invente des champs. Toujoursjson_schemastrict.additionalProperties: true(par défaut) → strict mode pas vraiment strict. ForceadditionalProperties: false.Pydantic
Optional[X]sans default → strict mode rejette. UtiliseX | None = None.Mélanger Chat Completions et Responses dans un même projet sans raison → tu doubles le code. Choisis un standard.
Realtime API + WebSocket en sync → boucle audio mal gérée, glitches. Utilise asyncio.
Reasoning models avec
temperature→ ignoré silencieusement. Les reasoning models sont déterministes par défaut.Batch sans
custom_id→ impossible de matcher input/output après. Toujours uncustom_idunique.Vision : URLs non publiques → 401 silent. Utilise data URLs (base64) ou S3 presigned.
Embeddings : pas de normalisation → cosine similarity instable. OpenAI les retourne déjà normalisés en L2, mais si tu fais des opérations dessus, re-normalise.
Tokens reasoning facturés sur o-series → un appel "court" peut coûter cher si effort=high. Toujours logger
reasoning_tokens.
Pricing / ROI client
Calcul ROI sur le cas Compta (3000 factures/mois) :
| Poste | Avant | Après |
|---|---|---|
| ETP saisie | 2 × 3 500€ = 7 000€/mois | 0.4 × 3 500€ = 1 400€/mois |
| Coût IA | 0 | 45€/mois |
| Erreurs / redressements | 1 200€/mois | 150€/mois |
| Total coût | 8 200€/mois | 1 595€/mois |
| Économies / mois | 6 605€ |
Sur 12 mois : 79 260€ économisés.
Mission cabinet :
- Implémentation : 30 j × 1 300€ = 39 000€
- MCO : 1 200€/mois × 12 = 14 400€
- Total an 1 : 53 400€ → ROI = +25 860€ dès l'an 1, puis +65 000€/an
Testing / Eval
Eval structured output
# pip install promptfoo
# promptfoo init
# tests/extract_facture.yaml
"""
providers:
- openai:gpt-5
prompts:
- "Extrais cette facture: {{image_url}}"
tests:
- vars: {image_url: "https://.../f1.jpg"}
assert:
- type: is-json
- type: javascript
value: output.fournisseur_siret.length === 14
- type: javascript
value: output.coherence_montants === true
"""Eval LLM-as-judge avec Pydantic
class Judgement(BaseModel):
accuracy: float = Field(ge=0, le=1)
completeness: float = Field(ge=0, le=1)
explanation: str
def judge(output: dict, expected: dict) -> Judgement:
resp = client.responses.parse(
model="gpt-5",
instructions="Tu juges la qualité d'une extraction comptable.",
input=f"Output: {output}\nExpected: {expected}",
text_format=Judgement,
)
return resp.output_parsedQuand utiliser / éviter
Utilise OpenAI quand :
- Vision + structured outputs (excellent OCR + JSON garanti)
- Voix bidirectionnelle (Realtime API)
- Fine-tuning économique nécessaire (gpt-4o-mini)
- Tu as besoin de tools natifs (file_search, code_interpreter)
- Reasoning models pour tâches difficiles (o4-mini = excellent ratio)
Évite (ou complète) quand :
- FR ultra-qualitatif requis (Claude souvent meilleur sur nuances FR juridiques)
- Citations natives nécessaires (Claude)
- Souveraineté FR/EU stricte (Mistral)
- Budget hyper serré (Mistral Small 3 open weights)
- Très long context (Gemini 2.5 Pro 2M, Claude 200K)
🎤 En entretien
Q : Chat Completions ou Responses API pour un nouvel agent stateful ? Justifie. R : Responses — state server-side (previous_response_id), tools natifs (web_search/file_search/code_interpreter/MCP) et reasoning items réutilisables ; mais en santé/finance je force store=false pour la PII et je gère l'historique moi-même, quitte à perdre le caching cross-tours.
Q : previous_response_id réduit-il le coût d'un long fil ? R : Non, il évite le transport et le bookkeeping, pas la facture : OpenAI re-feed l'historique complet à chaque tour, donc coût en O(n²) sur les tokens, atténué par le prompt caching — à un moment tu dois résumer/tronquer toi-même.
Q : Comment garantir un JSON conforme à 100 %, pas juste "du JSON valide" ? R : Structured outputs strict (responses.parse + schéma Pydantic, ou json_schema strict) avec additionalProperties:false et X | None = None au lieu de Optional sans default ; le mode json_object ne contraint pas le schéma et invente des champs.
Q : Un appel reasoning (o4-mini, effort=high) "court" t'a coûté 10x le prix attendu. Pourquoi et comment tu le détectes en prod ? R : Les reasoning tokens sont facturés en output et invisibles dans la réponse texte ; je logge systématiquement usage.output_tokens_details.reasoning_tokens + cached_tokens à chaque call, et je plafonne via reasoning={"effort": "low"} ou un budget de sortie quand la latence/coût comptent.
Q : Comment choisis-tu le modèle pour une nouvelle feature ? Donne ta méthode, pas un nom. R : Je pars du modèle le moins cher plausible (souvent gpt-4.1-mini/-nano), je construis une eval sur 30-50 cas réels avec un seuil métier, et je ne monte en gamme (4.1 → GPT-5 → o-series) que si l'eval échoue ; le reasoning n'arrive que si la tâche a une vraie structure de recherche multi-étapes, sinon il coûte sans rien apporter. Partir du haut et descendre, c'est payer 10-20x sans le savoir.
Q : Le prompt caching OpenAI est "automatique" — qu'est-ce qui peut quand même te donner 0 hit ? R : Le cache matche un préfixe exact d'au moins ~1024 tokens avec un appel des ~5-10 dernières minutes ; il suffit d'une variation en tête (un datetime.now() dans le system, un dict sérialisé sans ordre de clés stable, l'input user placé avant le system) ou d'une rafale trop étalée dans le temps pour tout casser — d'où la règle "stable en tête, variable à la fin" et le monitoring de cached_tokens.
Q : Tu exposes tes outils métier (écriture compta) à un agent via MCP remote. Quel garde-fou non négociable ? R : require_approval: "always" sur tout outil mutatif avec un humain dans la boucle, jamais "never" sur une écriture — sinon le modèle peut déclencher des transactions irréversibles sur une hallucination ; en plus, idempotency-key côté outil et logs d'audit de chaque appel d'outil.
🏋️ Exercices
1. Le routeur Chat Completions ↔ Responses
Objectif : écrire un wrapper ask(input, *, stateful: bool, store: bool) qui route vers chat.completions (stateless, multi-provider) ou responses (stateful) selon les flags, en exposant la même signature de retour (.text, .usage). Indice/Solution : une dataclass LLMResult normalisée ; pour Responses, mappe output_text/usage.input_tokens ; pour Chat, choices[0].message.content/usage.prompt_tokens. Le test qui compte : basculer de l'un à l'autre sans toucher au code appelant.
2. Strict mode qui ne ment pas
Objectif : prendre un schéma FactureFournisseur et prouver par un test que additionalProperties:false + strict:True rejette tout champ hors-schéma, puis casser le schéma (un Optional[str] sans default) et observer l'erreur 400. Indice/Solution : responses.parse(text_format=...) ; injecte un prompt qui demande un champ bonus et vérifie qu'il n'apparaît pas. Pour le 400 : Optional[x] sans default n'est pas dans required → le strict schema OpenAI exige tous les champs required → BadRequestError. Fix : x: str | None = None.
3. Parallélisme borné qui survit aux pannes
Objectif : traiter 1 000 factures via responses.parse en parallèle sans déclencher de 429 auto-infligé, sans qu'une seule facture corrompue tue le batch, avec retry exponentiel sur 429/5xx seulement. Indice/Solution : AsyncOpenAI(max_retries=4, timeout=30) + asyncio.Semaphore(N) + asyncio.gather(..., return_exceptions=True). Le piège : un BadRequestError (400, schéma) ne doit jamais être retry — isole-le, ne le relance pas. Mesure : compte les cached_tokens pour vérifier que le préfixe système mord (sinon ton instructions varie quelque part).
4. Défends le ROI devant le DAF
Objectif : à partir du cas Compta (3000 factures/mois, ~0.015€/facture), reconstruire le tableau coût avant/après et défendre l'hypothèse la plus fragile quand le DAF la conteste. Indice/Solution : l'hypothèse fragile n'est pas le coût IA (45€/mois, négligeable) mais le "1.6 ETP réaffecté à du conseil facturable" — un ETP libéré ≠ un ETP vendu. Construis deux scénarios (réaffectation 100 % vs 50 %) et montre que le ROI reste positif même au pire cas grâce à la baisse des redressements (erreurs 6%→0.8%).
5. Batch vs temps réel : la bonne porte
Objectif : prendre le pipeline ETL fiscal et le réécrire en Batch API (-50%) ; mesurer où le passage casse l'UX et où il est gratuit. Indice/Solution : Batch = completion_window="24h", custom_id obligatoire pour rematcher input/output, endpoint /v1/responses. Gratuit pour l'extraction de masse en fin de journée ; impossible pour la validation KYC temps réel (SLA 4 min). Le découpage senior : real-time pour l'interactif, Batch pour le back-office nocturne — même schéma Pydantic, deux chemins.
6. Casse le caching, puis répare-le
Objectif : partir d'un service où cached_tokens est toujours à 0 malgré un système "constant", trouver pourquoi, et le ramener à >90% de hit. Indice/Solution : le préfixe varie — un datetime.now() dans les instructions, un model_dump() Python qui ne garantit pas l'ordre des clés, ou un champ user collé avant le système stable. Le caching OpenAI matche un préfixe exact ; mets tout le contenu stable (system + tools + few-shots) en tête, immuable, et les parties variables à la fin. Vérifie via usage.input_tokens_details.cached_tokens.
7. L'agent multi-tours instrumenté (le boss final)
Objectif : construire un agent Responses qui répond à "analyse l'impact d'une hausse BCE sur le portefeuille client X" en orchestrant file_search (docs internes) + un outil MCP get_portfolio (read) + un outil MCP book_advisory_note (write), sur plusieurs tours, avec : (a) require_approval:"always" sur l'écriture, (b) un compteur de coût cumulé loggé après chaque tour, (c) un cap dur qui interrompt l'agent si le coût dépasse 0.50€ ou 8 tours. Indice/Solution : boucle while sur previous_response_id ; à chaque tour, accumule usage (input + output + reasoning) × prix du modèle ; sur un response.requires_action pour l'écriture, exige une validation humaine avant de renvoyer le tool result. Le piège senior : les built-in tools (file_search, web_search) réinjectent leur contenu dans l'input du tour suivant — ton coût n'est pas linéaire en nombre de tours, il gonfle. Mesure-le et c'est là que le cap se justifie. Bonus : passe en store=false et prouve que tu dois re-fournir les encrypted_content des reasoning items pour ne pas re-raisonner.
8. Reasoning ou pas : défends le choix par la donnée
Objectif : prendre une tâche réelle (catégorisation PCG des factures, exercice du pipeline ETL) et démontrer par une eval chiffrée si le reasoning (o4-mini) bat un non-reasoning (gpt-4.1-mini) — accuracy et coût/latence — puis trancher. Indice/Solution : 50 factures gold-labellisées, mesure accuracy + p95 latence + €/1000 pour chaque modèle. Attendu : sur de la catégorisation à structure faible, gpt-4.1-mini égale o4-mini à 1/10 du coût et 1/5 de la latence → le reasoning ne se justifie pas. La leçon : on ne choisit pas un modèle "parce qu'il est plus intelligent", on le choisit sur une eval avec un seuil métier. Sache aussi nommer le cas inverse (un litige fiscal multi-règles) où le reasoning gagne.
Liens
- OpenAI Responses API : https://platform.openai.com/docs/api-reference/responses
- Structured outputs : https://platform.openai.com/docs/guides/structured-outputs
- Function calling : https://platform.openai.com/docs/guides/function-calling
- Vision : https://platform.openai.com/docs/guides/vision
- Realtime API : https://platform.openai.com/docs/guides/realtime
- Batch API : https://platform.openai.com/docs/guides/batch
- Fine-tuning : https://platform.openai.com/docs/guides/fine-tuning
- Embeddings : https://platform.openai.com/docs/guides/embeddings
- Pricing : https://openai.com/pricing
- Promptfoo : https://www.promptfoo.dev/