Discourse AI : une erreur Gemini dans une réponse en flux est ignorée et le texte partiel est enregistré comme traduction complète

Description

Lorsque l’API Gemini de Google échoue en cours de réponse en streaming, Discourse AI considère la réponse comme complète. Avec la traduction par IA activée, le fragment reçu avant l’échec est stocké en tant que traduction finale du message ou du titre du sujet. Aucune erreur n’est consignée dans les journaux, et la réinitialisation (backfill) ne réessaie jamais l’élément, car une ligne de traduction existe désormais.

Observé sur un site auto-hébergé exécutant release/2026.7 (2026.7.3, commit f1caa6321287f918644fba9ff8943579fdcf027a) avec le plugin discourse-ai inclus. Le LLM est gemini-3.8-flash via le fournisseur Google, avec le niveau de service défini sur flex. Flex est le niveau où Google allège la charge, ce qui provoque régulièrement ces échecs en cours de flux ; le traitement décrit ci-dessous ne dépend pas du niveau.

Dans ces cas, Google répond avec un HTTP 200, transmet un ou plusieurs événements normaux, puis envoie un événement d’erreur dans le même flux :

data: {"candidates": [ ... ],"usageMetadata": { ... },"serviceTier": "flex","modelVersion": "gemini-3.8-flash","responseId": "..."}

data: {"error": {"code": 503,"message": "This model is currently experiencing high demand. Spikes in demand are usually temporary. Please try again later.","status": "UNAVAILABLE"}}

Le journal d’audit enregistre un response_status 200 et un petit nombre de response_tokens. Exemples de ce qui a été stocké : un message de 917 caractères enregistré sous 26 caractères (seulement sa ligne de salutation), des messages contenant uniquement des liens enregistrés sous les 8 à 24 premiers caractères de l’URL, et des titres de sujets coupés en plein mot.

Mesuré sur ce site : 23 des 394 appels de traduction du niveau Flex (5,8 %) ont abouti de cette manière. Aucun des 952 appels du niveau standard vers le même modèle n’a connu ce problème.

Cause racine

  1. DiscourseAi::Completions::Endpoints::Gemini#decode_chunk ne lit que candidates dans chaque événement de flux analysé. Un événement dont la clé de premier niveau est error ne produit aucune partie et est ignoré sans aucun contrôle.
  2. DiscourseAi::Completions::Endpoints::Base décide entre succès, nouvelle tentative et échec uniquement à partir du statut HTTP (response.code.to_i != 200). Le statut est ici 200, donc la logique de nouvelle tentative existante pour les 503 n’est jamais atteinte.
  3. Le flux se termine ensuite normalement, et l’appelant reçoit le texte accumulé jusqu’à présent. DiscourseAi::Translation::PostLocalizer et TopicLocalizer l’enregistrent.

Le même code est présent sur main à la date du 2026-10-03.

Correction suggérée

Considérer un objet error de premier niveau dans un événement Gemini en streaming comme un échec de complétion : supprimer la sortie partielle et lever CompletionFailed, afin que la gestion existante des nouvelles tentatives s’applique aux codes réessayables tels que 503 et que les appelants ne reçoivent jamais un fragment en tant que réponse complète.

Reproduction

L’échec dépend de la charge de Google, il ne peut donc pas être forcé. Sur un site utilisant un modèle Gemini au niveau Flex avec la traduction par IA, les appels affectés peuvent être trouvés a posteriori dans le journal d’audit :

SELECT id, created_at, post_id, topic_id, response_status, response_tokens
FROM ai_api_audit_logs
WHERE feature_name = 'translation'
  AND raw_response_payload LIKE '%data: {"error"%'
  AND response_tokens > 0
ORDER BY created_at

Chaque ligne correspond à un appel qui a renvoyé un HTTP 200, produit une sortie, puis a transporté un événement d’erreur. Les lignes correspondantes dans post_localizations ou topic_localizations contiennent le texte tronqué.

Contournement

Le déplacement des agents de traduction vers le niveau de service standard a stoppé les nouvelles occurrences. Les fragments stockés ont dû être trouvés avec la requête ci-dessus, supprimés et traduits à nouveau.

1 « J'aime »