# 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

**URL:** <https://meta.discourse.org/t/discourse-ai-gemini-streaming-decoder-drops-the-rest-of-a-reply-when-a-network-chunk-ends-inside-the-blank-line-between-two-events/413982>\
**Category:** Bug\
**Tags:** ai\
**Created:** [Octobre 5, 2026, 7:17 UTC](https://meta.discourse.org/t/discourse-ai-gemini-streaming-decoder-drops-the-rest-of-a-reply-when-a-network-chunk-ends-inside-the-blank-line-between-two-events/413982 "2026-10-05T19:17:03Z")\
**Posts on this page:** 1\
**Showing post:** 1

<div class="post-metadata">

**Author:** ![Sailor](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/sailor/32/579029_2.png) [@Sailor](https://meta.discourse.org/u/Sailor)\
**Post date:** [Octobre 5, 2026, 7:17 UTC](https://meta.discourse.org/t/discourse-ai-gemini-streaming-decoder-drops-the-rest-of-a-reply-when-a-network-chunk-ends-inside-the-blank-line-between-two-events/413982/1 "2026-10-05T19:17:03Z")

</div>

## 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 :

```ruby
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 :

```ruby
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 :

```sql
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.

---

_[View the full topic](https://meta.discourse.org/t/discourse-ai-gemini-streaming-decoder-drops-the-rest-of-a-reply-when-a-network-chunk-ends-inside-the-blank-line-between-two-events/413982)._
