Discourse AI : le décodeur de streaming Gemini abandonne la suite de la réponse si un fragment réseau se termine à l'intérieur de la ligne vide entre deux événements

Description

Lorsqu’une réponse Gemini est diffusée en continu (streaming), Discourse AI ne traite parfois que la première partie de celle-ci. La réponse complète est bien reçue de Google et stockée dans ai_api_audit_logs.raw_response_payload, le flux se termine avec finishReason STOP et aucune erreur n’est consignée dans les journaux. L’appelant reçoit le texte jusqu’à un certain événement et rien après. En cas de traduction par IA, le texte tronqué est stocké en tant que traduction terminée.

Observé sur un site auto-hébergé exécutant release/2026.7 (2026.7.3) avec le plugin discourse-ai inclus et gemini-3.8-flash sur le niveau de service standard. Dans 3 cas sur environ 26 200 appels de traduction sur trois jours, response_tokens dans le journal d’audit était inférieur au candidatesTokenCount final signalé par Google dans la même réponse : 196 sur 241, 84 sur 328 et 131 sur 179. Dans chaque cas, le nombre stocké correspond au nombre cumulé de Google à un événement intermédiaire, ce qui indique que le décodage s’est arrêté après cet événement.

Ceci est distinct des événements d’erreur en cours de flux corrigés par #44262 : il n’y a pas d’événement d’erreur ici, et le décodeur n’est pas modifié par cette demande de tirage (pull request).

Cause racine

DiscourseAi::Completions::Endpoints::Gemini::GeminiStreamingDecoder#decode divise son tampon sur /\r?\n\r?\n/ et n’accepte un segment que s’il commence par data: {.

Lorsqu’un bloc réseau se termine à l’intérieur de la ligne vide qui sépare deux événements, par exemple après le premier \r\n de \r\n\r\n, l’événement précédent est analysé et le tampon est vidé. Le bloc suivant commence alors par le \r\n restant, de sorte que le segment suivant est "\r\ndata: {...}". Il ne commence pas par data: {, il est donc conservé dans le tampon en tant que ligne incomplète. Chaque événement ultérieur est ajouté à ce tampon et n’est jamais reconnu non plus. Le flux se termine normalement et les événements restants sont supprimés avec le tampon.

Le même phénomène se produit avec des séparateurs uniquement en LF (saut de ligne) lorsqu’un bloc se termine entre les deux sauts de ligne.

Le décodeur est identique dans v2026.7.3 et sur main à b5548f76 (2026-10-05).

Reproduction

Dans une console Rails :

decoder = DiscourseAi::Completions::Endpoints::Gemini::GeminiStreamingDecoder.new
event = ->(text, tokens) { %(data: {"candidates": [{"content": {"parts": [{"text": "#{text}"}],"role": "model"}}],"usageMetadata": {"candidatesTokenCount": #{tokens}}}) }
chunks = [
  event.("A", 11) + "\r\n\r\n",
  event.("B", 38) + "\r\n",
  "\r\n" + event.("C", 65) + "\r\n\r\n",
  event.("D", 95) + "\r\n\r\n",
]
chunks.flat_map { |chunk| decoder.decode(chunk) }.map { |e| e.dig(:candidates, 0, :content, :parts, 0, :text) }

Résultat : ["A", "B"]. Attendu : ["A", "B", "C", "D"]. Déplacer la limite de bloc à n’importe quel point hors du séparateur, y compris au milieu d’un événement, donne le résultat attendu.

Correction suggérée

Ignorer les sauts de ligne en tête de segment avant de le tester, par exemple :

line = line.sub(/\A[\r\n]+/, "")
if line.start_with?("data: {")

Avec ce changement, la reproduction ci-dessus retourne les quatre événements, tout comme les limites après \r\n, après \r\n\r, au milieu d’un événement, et avec des séparateurs uniquement en LF.

Recherche des appels affectés

Chaque ligne renvoyée par cette requête correspond à un appel que Google a terminé normalement et que Discourse n’a décodé que partiellement :

SELECT l.id, l.created_at, l.post_id, l.topic_id, l.response_tokens AS stored_tokens, g.sent_tokens
FROM ai_api_audit_logs l
CROSS JOIN LATERAL (
  SELECT MAX((m)[1]::int) AS sent_tokens
  FROM regexp_matches(l.raw_response_payload, '"candidatesTokenCount":\s*(\d+)', 'g') AS m
) g
WHERE l.language_model LIKE 'gemini%'
  AND l.raw_response_payload ~ '"finishReason":\s*"STOP"'
  AND g.sent_tokens <> l.response_tokens
ORDER BY l.created_at

Impact

Le défaut ne dépend que de l’emplacement des limites de bloc réseau, il n’est donc pas spécifique à la traduction ou à un niveau de service ; toute complétion Gemini diffusée en continu peut perdre sa fin de cette manière. Pour la traduction, le résultat est un message ou un titre raccourci silencieusement que le remplissage rétroactif (backfill) ne retente pas, car une ligne de localisation existe.

1 « J'aime »