Le streaming, c’est toute la différence entre une zone de chat qui reste morte pendant huit secondes et une autre qui se met à écrire en moins d’une seconde. La mécanique est la même que le modèle derrière ton base_url soit Claude, Gemini ou GPT : tu mets stream=True, tu itères les chunks, tu lis le delta sur chacun, et tu t’arrêtes au repère [DONE]. Comme Brievio parle le protocole Chat Completions d’OpenAI, exactement la même boucle fonctionne sur chaque vrai modèle first-party — tu changes la chaîne model et rien d’autre.
Cet article explique comment fonctionne réellement le streaming Server-Sent Events à travers un endpoint compatible OpenAI, comment obtenir un usage de tokens exact sur le dernier chunk avec stream_options, le pattern identique en Python et en Node, et les deux modes de défaillance silencieux qui font qu’un flux a l’air correct tout en te trahissant en douce : le faux streaming (mis en mémoire tampon) et l’usage manquant.
Ce que « streaming » veut dire sur HTTP
Un appel sans streaming, c’est une requête, une réponse : le serveur réfléchit quelques secondes, puis te remet la complétion entière d’un coup. Le streaming garde la connexion HTTP ouverte et pousse la réponse par morceaux à mesure que le modèle la génère, en utilisant les Server-Sent Events (SSE). Sur le fil, chaque morceau arrive sous forme d’une ligne qui commence par data: suivie d’un objet JSON, et le flux se termine par une ligne littérale data: [DONE].
Tu n’analyses presque jamais ce texte toi-même — le SDK s’en charge. Ce que tu obtiens en code, c’est un itérable de chunks. Chaque chunk ressemble à un objet de complétion normal, sauf que le contenu vit dans choices[0].delta au lieu de choices[0].message, et il ne contient que le fragment généré depuis le chunk précédent. Concatène chaque delta.content dans l’ordre et tu as reconstruit le message complet. La seule métrique qui compte ici, c’est le temps jusqu’au premier token (TTFB) : combien de temps avant l’apparition du premier delta non vide. Ce chiffre est toute la raison d’être du streaming.
Le pattern Python
Voici le tout — une vraie boucle de streaming qui capture aussi l’usage. La seule ligne spécifique à Brievio, c’est le base_url :
from openai import OpenAI
client = OpenAI(
api_key="sk-brievio-...",
base_url="https://api.brievio.com/v1", # un seul base_url, de vrais modèles first-party
)
# stream=True bascule la réponse en flux Server-Sent Events.
# Tu itères l'objet ; chaque élément est un chunk avec un "delta" partiel.
stream = client.chat.completions.create(
model="claude-sonnet-4-6", # ou gemini-2.5-flash, gpt-..., etc.
messages=[{"role": "user", "content": "Explain Raft consensus in 200 words."}],
stream=True,
stream_options={"include_usage": True}, # demande l'usage sur le DERNIER chunk
)
usage = None
for chunk in stream:
# Le dernier événement de données avant [DONE] porte l'usage et une liste choices vide.
if chunk.usage is not None:
usage = chunk.usage
continue
delta = chunk.choices[0].delta.content
if delta:
print(delta, end="", flush=True) # affiche les tokens à mesure qu'ils arrivent
print()
# usage n'est rempli que parce que include_usage a été activé. Ce sont de vrais décomptes.
print(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens)Trois détails qui font trébucher. Premièrement, le delta de contenu peut être None ou vide sur certains chunks (le chunk d’ouverture ne fait souvent que définir le rôle), donc protège-toi avant d’afficher. Deuxièmement, le chunk qui porte l’usage arrive après que le contenu est terminé et a une liste choices vide — c’est pour ça que l’exemple vérifie chunk.usage en premier et fait continue. Troisièmement, tu ne cherches pas [DONE] toi-même ; le SDK consomme ce repère et termine l’itérateur pour toi. Si tu appelais l’endpoint avec des requests ou un fetch bruts, alors tu découperais sur les sauts de ligne et tu romprais sur [DONE] à la main.
La même boucle en Node
Le SDK Node expose le flux comme un itérable asynchrone, donc la structure est identique — for await ... of au lieu de for, et process.stdout.write au lieu d’un print qui vide le tampon :
import OpenAI from "openai";
const client = new OpenAI({
apiKey: "sk-brievio-...",
baseURL: "https://api.brievio.com/v1",
});
// Même contrat en Node : stream=true renvoie un itérable asynchrone de chunks.
const stream = await client.chat.completions.create({
model: "claude-sonnet-4-6",
messages: [{ role: "user", content: "Explain Raft consensus in 200 words." }],
stream: true,
stream_options: { include_usage: true },
});
let usage = null;
for await (const chunk of stream) {
// Événement final : choices est vide, usage est présent.
if (chunk.usage) {
usage = chunk.usage;
continue;
}
const delta = chunk.choices[0]?.delta?.content;
if (delta) process.stdout.write(delta); // écris chaque token dans le terminal
}
console.log();
console.log(usage?.prompt_tokens, usage?.completion_tokens, usage?.total_tokens);Note le chaînage optionnel (chunk.choices[0]?.delta?.content). Sur le dernier chunk porteur d’usage, choices est vide, donc indexer [0] sans la protection lèverait une erreur juste sur la ligne d’arrivée. C’est la raison la plus courante pour qu’un gestionnaire de streaming Node plante sur le dernier événement après avoir eu l’air de marcher parfaitement pendant toute la réponse.
Obtenir un vrai usage sur le dernier chunk
Par défaut, une réponse en streaming n’inclut pas les décomptes de tokens — il n’y a d’objet usage sur aucun chunk. C’est une partie délibérée du protocole OpenAI, et ça mord les équipes qui streament en production et n’arrivent plus ensuite à rapprocher leur facture. Le correctif tient en un paramètre :
- Mets
stream_options={"include_usage": True}(Python) oustream_options: { include_usage: true }(Node). - Le serveur émet alors un chunk supplémentaire juste avant
[DONE]dontchoicesest vide et dontusagecontientprompt_tokens,completion_tokensettotal_tokens. - Sur Brievio, ce sont les décomptes authentiques rapportés par le modèle — les mêmes chiffres que tu obtiendrais d’un appel sans streaming, facturés à environ 15 % en dessous du tarif officiel. Il n’y a pas d’objet usage rembourré ni de prompt système injecté gonflant le côté entrée.
Si tu sautes include_usage et qu’il te faut quand même une estimation de tokens, ta seule option est de compter en local avec le tokenizer du modèle — ce qui est approximatif et représente une charge de maintenance. Active simplement le drapeau.
Les ruptures silencieuses : faux streaming et usage manquant
Deux modes de défaillance passent un examen rapide à l’œil nu et ne se révèlent que sous scrutin. Tous deux méritent une vérification de 20 secondes avant de confier du vrai trafic à une passerelle.
- Faux streaming mis en mémoire tampon. Certaines passerelles acceptent
stream=True, attendent la complétion amont entière, puis te la rejouent en rafale de chunks à la fin. Ta boucle tourne, les deltas arrivent, tout a l’air streamé — mais le TTFB est identique à un appel sans streaming, parce que rien n’a été envoyé avant que le modèle finisse. L’indice est simple : chronomètre l’écart entre l’envoi de la requête et le premier delta non vide. En vrai streaming, il tombe bien en dessous d’une seconde ; sur un replay tamponné, il égale le temps de génération complet. Si la latence du premier token suit la latence totale, tu ne streames pas, tu regardes un enregistrement. - Usage manquant ou fabriqué. Une passerelle qui n’honore pas
include_usagete laisse sans décomptes de tokens sur les appels streamés — donc tu rapproches ta facture du vide. Pire, une passerelle malhonnête peut attacher un objetusageaux chiffres gonflés, parce que sur un flux le client recompte rarement. Vérifie-le de la façon ennuyeuse : lance le même prompt une fois en streaming et une fois sans, et confirme que l’usage du dernier chunk streamé correspond à l’usagesans streaming. Ils devraient être identiques. - Erreurs en milieu de flux qui ressemblent à une fin propre. Si le modèle amont échoue à mi-parcours, une passerelle correcte la fait remonter comme une exception dans ta boucle, pas comme une troncature silencieuse. Vérifie toujours que tu as reçu une raison de fin (ou le chunk d’usage) avant de traiter le texte comme complet — un flux qui s’arrête juste comme ça n’est pas la même chose qu’un flux qui a terminé.
À retenir
Le streaming sur un endpoint compatible OpenAI, ce sont quatre pièces mobiles : stream=True, itérer les chunks, lire chaque delta et laisser le SDK gérer [DONE]. Ajoute stream_options={"include_usage": True} et tu obtiens en plus des décomptes de tokens honnêtes sur le dernier chunk. Les mêmes quinze lignes fonctionnent sans changement sur Claude Sonnet 4.6, Gemini 2.5 Flash et la famille GPT derrière un seul base_url — change la chaîne du modèle, garde la boucle.
Avant de déployer, mesure le temps jusqu’au premier token et compare l’usage streamé contre non streamé. Le vrai streaming te donne un TTFB sous la seconde et des décomptes concordants ; un replay tamponné trahit sa latence. Sur Brievio, les appels 4xx/5xx échoués ne sont pas facturés, donc tu peux lancer ces vérifications gratuitement. Consulte la référence Chat Completions pour la liste complète des paramètres, le reste de la documentation de l’API pour les outils et la vision sur le même flux, le guide pour appeler Claude avec le SDK OpenAI pour les bases sans streaming, et le catalogue de modèles pour chaque slug sur lequel tu peux pointer cette boucle.