« Compatible OpenAI » est l’expression la plus surchargée du marché de l’infra IA. Elle peut vouloir dire « tu peux pointer le SDK OpenAI sur notre URL et un appel de chat basique répond » — ce qui est la partie facile, les 80 % — ou elle peut vouloir dire « chaque champ, chaque événement de streaming, chaque aller-retour d’appel d’outil et chaque chiffre d’usage se comporte comme ton code l’attend déjà ». L’écart entre les deux, c’est là que vivent les incidents en production. Cet article est le guide de terrain : ce qui doit véritablement correspondre pour que ton code existant tourne sans modification, ce qui se comporte à l’identique d’un amont à l’autre, et ce qui diffère en silence quand le modèle derrière la forme OpenAI est en réalité Claude (Anthropic) ou Gemini (Google) plutôt que GPT.
Brievio est une passerelle compatible OpenAI placée devant les vrais modèles de première main, alors voici le point de vue du traducteur — la couche qui doit faire ressortir aussi bien l’API Messages d’Anthropic que l’API Vertex de Google par le tuyau à la forme OpenAI. Je vais être précis sur les endroits où l’abstraction est propre et ceux où elle fuit, parce que prétendre qu’elle ne fuit jamais, c’est le meilleur moyen de se faire réveiller à 2 h du matin.
Ce que « compatible » doit réellement vouloir dire
La compatibilité n’est pas une case marketing à cocher ; c’est un contrat avec le SDK que tu as déjà importé. Les bibliothèques OpenAI pour Python et Node font des hypothèses fortes sur le format de transport. Une passerelle n’est compatible que si elle les respecte toutes :
- Le schéma de requête.
POST /v1/chat/completionsavecmodel,messages(une liste d’objets rôle/contenu), et les réglages optionnels —temperature,max_tokens,top_p,stop,tools,response_format. Les paramètres inconnus devraient être acceptés et ignorés, pas renvoyer une erreur 400. - L’enveloppe de réponse. Un objet avec
id,object: "chat.completion",model,choices[](chacun avecmessage,index,finish_reason), et un blocusage. Les SDK désérialisent en objets typés ; oublie un champ etresp.choices[0].message.contentplante sur la machine de quelqu’un d’autre. - Le protocole de streaming. Des Server-Sent Events avec la sentinelle
data: [DONE]et des objetsdeltapar token. C’est ce que les passerelles « compatibles » se trompent le plus subtilement le plus souvent. - Les formes d’erreur et les codes HTTP. Un 429 doit ressembler à une limite de débit, un 400 doit transporter un objet
erroravec untypeet unmessage. La logique de réessai et de backoff du SDK se cale là-dessus.
Voici la base — la partie que tout le monde réussit. Deux lignes changent et l’appel renvoie un objet de complétion normal :
# Tout l'intérêt : change deux lignes, garde ton code.
# Même SDK, même forme de requête, même objet de réponse — un autre modèle derrière.
from openai import OpenAI
client = OpenAI(
api_key="sk-brievio-...",
base_url="https://api.brievio.com/v1", # était https://api.openai.com/v1
)
resp = client.chat.completions.create(
model="claude-sonnet-4-6", # était gpt-4o
messages=[
{"role": "system", "content": "You are concise."},
{"role": "user", "content": "Summarize the CAP theorem in two sentences."},
],
temperature=0.2,
max_tokens=300,
)
print(resp.choices[0].message.content)
print(resp.usage) # prompt_tokens / completion_tokens / total_tokens — mêmes champs
# resp.id, resp.model, resp.choices[0].finish_reason tous présents et façonnés comme OpenAI.Si une passerelle ne sait pas faire au moins ça, passe ton chemin. Mais c’est le minimum vital, pas la ligne d’arrivée. La vraie question, c’est ce qui se passe quand tu actives les fonctionnalités que ton appli réelle utilise.
Streaming : là où la compatibilité fuit en silence
Le streaming est la fonctionnalité la plus susceptible d’être techniquement présente et pratiquement cassée. L’itérateur de streaming du SDK attend trois choses : un content type text/event-stream, des deltas qui arrivent de façon incrémentale sur choices[0].delta.content, et une ligne littérale data: [DONE] pour clore le flux. Trompe-toi sur l’un d’eux et le symptôme est exaspérant — ça marche dans ton test curl, ça reste bloqué en production.
# Le streaming, c'est là que la « compatibilité » naïve fuit. Le contrat dont tu dépends :
# - Content-Type: text/event-stream
# - chaque événement est "data: {json}\n\n", les deltas arrivent sur choices[0].delta.content
# - le flux se termine par une sentinelle littérale "data: [DONE]\n\n"
stream = client.chat.completions.create(
model="gemini-2-5-pro",
messages=[{"role": "user", "content": "Explain B-trees in one paragraph."}],
stream=True,
)
for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
print(delta.content, end="", flush=True)
# Ce qui casse les clients si une passerelle s'y prend mal :
# - bufferiser toute la réponse puis la vider d'un seul bloc (pas du vrai streaming)
# - omettre la sentinelle [DONE] (certains SDK restent bloqués à l'attendre)
# - usage seulement à la fin — passe stream_options={"include_usage": True} pour l'obtenir.Le « faux streaming » le plus courant, c’est une passerelle qui appelle l’amont, attend la réponse entière, puis l’émet en un ou deux gros blocs. Le SDK ne plante pas — tu perds simplement tout l’intérêt du streaming (le temps jusqu’au premier token reste catastrophique). Une vraie passerelle maintient la connexion ouverte vers l’amont et transmet chaque token au fur et à mesure qu’il arrive. Pour Claude, ça veut dire traduire à la volée les événements content_block_delta d’Anthropic en événements chat.completion.chunk d’OpenAI ; pour Gemini, le même travail face au format de streaming de Vertex. La sortie est identique pour ton code, mais la mécanique en dessous fait une vraie traduction événement par événement.
Une vraie différence à connaître : l’usage dans les réponses en streaming. OpenAI n’inclut le bloc usage sur le dernier chunk que si tu passes stream_options={"include_usage": true}. Une bonne passerelle respecte ce drapeau face à chaque amont, pour que ton code de comptabilité de tokens n’ait pas à traiter chaque modèle en cas particulier. Vois le contrat de streaming complet dans la documentation des chat completions.
Outils et function calling : même forme, autre moteur
L’appel d’outils est la fonctionnalité où l’abstraction OpenAI fait vraiment ses preuves — parce que les trois fournisseurs ont des formats natifs complètement différents, et qu’une passerelle masque tout ça. Tu envoies le tableau tools d’OpenAI ; tu récupères des tool_calls sur le message. Ce qui se passe entre les deux est une vraie traduction :
# Tool / function calling : côté requête, ça correspond exactement à OpenAI.
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}]
resp = client.chat.completions.create(
model="claude-opus-4-7",
messages=[{"role": "user", "content": "What's the weather in Tokyo?"}],
tools=tools,
tool_choice="auto",
)
# Le modèle renvoie choices[0].message.tool_calls — une liste, chacun avec un id,
# .function.name et .function.arguments (une *chaîne* JSON que tu dois json.loads).
for call in resp.choices[0].message.tool_calls or []:
print(call.id, call.function.name, call.function.arguments)
# Tu ajoutes ensuite un message {"role": "tool", "tool_call_id": call.id, "content": result}
# et tu rappelles. Cette boucle est identique que l'amont soit
# Claude ou Gemini — la passerelle mappe le format d'outil natif de chaque fournisseur
# vers les tool_calls d'OpenAI à l'aller, et en sens inverse au retour.Sous le capot, Anthropic renvoie des blocs de contenu tool_use avec un objet input ; Gemini renvoie des parties functionCall avec args. La passerelle mappe les deux sur la forme tool_calls[] d’OpenAI — y compris le détail qu’OpenAI livre arguments sous forme de chaîne JSON que tu dois json.loads, et non d’objet déjà parsé. Ta boucle d’exécution d’outils — lire les appels, exécuter les fonctions, ajouter des messages role: "tool", rappeler — est rigoureusement identique quelle que soit la famille que tu vises. C’est tout l’argument : écris l’agent une fois, change de modèle avec une chaîne de caractères.
Les réserves honnêtes, parce qu’elles existent :
- Appels d’outils en parallèle. Les trois familles peuvent demander plusieurs outils en un tour, mais elles diffèrent sur l’agressivité avec laquelle elles le font pour un prompt donné. Ne suppose pas que le nombre exact ou l’ordre se transposent d’un modèle à l’autre — gère une liste, pas un nombre fixe.
- Schémas d’outils stricts / structurés. L’application du schéma JSON via
strict: truechez OpenAI est une fonctionnalité des modèles OpenAI. Sur Claude et Gemini, la passerelle transmet ton schéma comme définition d’outil et le modèle s’y conforme de près, mais la garantie est celle de l’amont, pas une magie que la passerelle peut fabriquer. - Nuances de
tool_choice.autoet le forçage d’une fonction précise sont bien pris en charge partout ; les combinaisons exotiques méritent un petit test sur chaque modèle que tu déploies réellement.
Vision et mode JSON : transmission directe, avec des bords
La vision utilise le format multimodal d’OpenAI en parties de contenu — une liste mêlant des entrées text et image_url. Face à un modèle qui voit nativement les images (Gemini 2.5 Pro/Flash, la famille Claude), la passerelle transmet l’image et l’appel multimodal fonctionne simplement. Le mode JSON — response_format: { type: "json_object" } — contraint la sortie à un objet parsable :
# Vision : le format multimodal d'OpenAI en parties de contenu, transmis tel quel à un
# modèle qui prend nativement en charge les images. URL ou data URI base64, les deux marchent.
resp = client.chat.completions.create(
model="gemini-2-5-pro",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": "What's in this chart? Give me the trend."},
{"type": "image_url", "image_url": {
"url": "https://example.com/q3-revenue.png",
}},
],
}],
)
print(resp.choices[0].message.content)
# Mode JSON — demande un objet garanti parsable :
resp = client.chat.completions.create(
model="claude-sonnet-4-6",
messages=[{"role": "user", "content": "Extract name and email as JSON."}],
response_format={"type": "json_object"},
)
import json
data = json.loads(resp.choices[0].message.content) # parse, à chaque fois.Où sont les bords : les limites d’entrée d’image (dimensions max, nombre max d’images par requête, types MIME acceptés) sont fixées par chaque amont, pas inventées par la passerelle — donc un TIFF de 50 Mo que Gemini rejette sera aussi rejeté derrière la forme OpenAI, avec une erreur traduite. Et le mode json_object garantit du JSON valide, pas du JSON qui correspond à ton schéma précis ; si tu as besoin d’une structure particulière, décris-la dans le prompt et valide après le parsing. Ce ne sont pas des bugs de la passerelle — c’est le contrat du modèle sous-jacent qui transparaît, ce qui est exactement ce que tu veux qu’un traducteur fidèle préserve.
Embeddings, et les choses qui, vraiment, ne se transposent pas
Deux autres surfaces qu’il faut nommer honnêtement. Les embeddings (/v1/embeddings) sont simples et stables — mais les vecteurs ne sont pas interchangeables d’un modèle à l’autre. Un embedding Gemini et un embedding OpenAI vivent dans des espaces différents avec des dimensionnalités différentes ; tu ne peux pas les mélanger dans un même index ni comparer leurs similarités cosinus. Choisis un modèle d’embedding et ré-embedde tout ton corpus si tu en changes. L’API est compatible ; les maths ne le sont pas.
Et les fuites qu’aucune dose de couche de compatibilité ne peut masquer — les fonctionnalités propres à un fournisseur qui n’ont tout simplement aucun champ OpenAI pour les transporter :
- Le prompt caching d’Anthropic. Les points de coupure
cache_controlnatifs vivent sur l’API Messages d’Anthropic. Par la forme OpenAI, tu obtiens à la place le cache de préfixe automatique façon OpenAI ; pour piloter le cache explicitement, tu utilises l’endpoint natif/v1/messages. (Les deux marchent sur Brievio — vois la documentation de l’API.) - Les tokenizers diffèrent selon la famille. « 1 000 tokens » ne représente pas la même longueur de chaîne entre GPT, Claude et Gemini — chacun a son propre tokenizer. Donc tes budgets
max_tokenset tes estimations de coût se décalent quand tu changes de modèle, même si le nom du champ n’a pas bougé. Une bonne passerelle rapporte les décomptes de tokens honnêtes de chaque amont dansusage; elle ne peut pas faire s’accorder trois tokenizers, et tu ne devrais pas faire confiance à celle qui prétend le contraire. - Réflexion étendue / reasoning. La réflexion étendue de Claude et les modes de réflexion de Gemini se présentent différemment du reasoning d’OpenAI. Le contenu arrive ; la plomberie exacte des champs est propre au modèle, donc ne code pas en dur la forme de reasoning d’un fournisseur pour tous.
- Sémantique du prompt système. Les trois acceptent un message système, mais ils le pondèrent et le tronquent légèrement différemment. Le comportement se transpose ; il n’est pas identique au bit près. Teste tes prompts modèle par modèle.
Comment une bonne passerelle normalise tout ça
Le travail de la couche de compatibilité, c’est d’être un traducteur fidèle et sans perte sur le chemin courant, et honnête sur les bords. Concrètement, ça veut dire : mapper le schéma de requête dans les deux sens ; traduire les événements de streaming token par token, sentinelle comprise ; convertir le format d’outil natif de chaque fournisseur vers et depuis tool_calls ; préserver la sémantique de finish_reason ; transmettre de vraies images aux modèles capables de vision ; et — la partie qu’il est facile de tricher — rapporter les décomptes de tokens réels de l’amont plutôt qu’un nombre rembourré. Sur Brievio, les modèles derrière la forme sont les vrais modèles de première main, traçables jusqu’à AWS Bedrock et Google Vertex, donc le comportement que tu normalises est celui du vrai modèle, pas d’un substitut moins cher. Si tu veux le vérifier toi-même, les quatre tests de ton Claude est-il vraiment Claude prennent environ une minute.
Deux principes découlent de tout ça pour quiconque construit sur un endpoint « compatible ». Premièrement, teste les fonctionnalités que tu utilises vraiment — un appel de chat qui passe ne te dit rien sur le fait que le streaming se vide de façon incrémentale ou que les ID d’appels d’outils font l’aller-retour. Deuxièmement, respecte les fuites : les tokenizers, les espaces d’embeddings, la syntaxe de cache et les formes de reasoning sont des propriétés de l’amont, et la passerelle honnête à leur sujet est celle à laquelle tu peux te fier en production. La compatibilité est un spectre, et la partie utile, c’est celle qui survit à ta charge de travail réelle — pas celle qui survit à une démo.
Ce qu’il faut concrètement retenir
Pointe le SDK OpenAI sur https://api.brievio.com/v1, change la chaîne de modèle, et lance ta suite de tests existante — pas un hello-world, ta suite. Exerce le streaming avec include_usage, fais un aller-retour d’appel d’outil, envoie une image, demande un json_object. Si les quatre passent sur le modèle que tu comptes déployer, la migration tient vraiment en deux lignes. Là où tu as besoin d’une fonctionnalité propre à un fournisseur — cache Anthropic explicite, contrôles de reasoning natifs — descends sur les endpoints natifs pour ce chemin-là et garde la forme OpenAI partout ailleurs. Tu veux le portage pas à pas depuis une base de code OpenAI existante ? Commence par appeler Claude avec le SDK OpenAI, puis parcours la liste des modèles pour choisir ce qui tourne derrière la forme.