Descripción
Cuando se transmite una respuesta de Gemini, Discourse AI a veces solo procesa la primera parte. La respuesta completa se recibe de Google y se almacena en ai_api_audit_logs.raw_response_payload, el flujo termina con finishReason STOP y no se registra ningún error. El llamador recibe el texto hasta cierto evento y nada después de eso. Con la traducción por IA, el texto truncado se almacena como la traducción finalizada.
Observado en un sitio autoalojado que ejecuta release/2026.7 (2026.7.3) con el plugin discourse-ai integrado y gemini-3.8-flash en el nivel de servicio estándar. En 3 de aproximadamente 26,200 llamadas de traducción en tres días, response_tokens en el registro de auditoría fue menor que el candidatesTokenCount final que Google informó en la misma respuesta: 196 de 241, 84 de 328 y 131 de 179. En cada caso, la cantidad almacenada equivale a la cantidad acumulada de Google en un evento intermedio, por lo que la decodificación se detuvo después de ese evento.
Esto es independiente de los eventos de error dentro del flujo corregidos por #44262: no hay ningún evento de error aquí y el decodificador no se ve afectado por esa solicitud de extracción.
Causa raíz
DiscourseAi::Completions::Endpoints::Gemini::GeminiStreamingDecoder#decode divide su búfer en /\r?\n\r?\n/ y solo acepta un segmento si comienza con data: {.
Cuando un fragmento de red termina dentro de la línea en blanco que separa dos eventos, por ejemplo después del primer \r\n de \r\n\r\n, el evento anterior se analiza y el búfer se vacía. El siguiente fragmento entonces comienza con el \r\n restante, por lo que el siguiente segmento es "\r\ndata: {...}". No comienza con data: {, por lo que se mantiene en el búfer como una línea incompleta. Cada evento posterior se agrega a ese búfer y tampoco se reconoce. El flujo termina normalmente y los eventos restantes se descartan con el búfer.
Lo mismo ocurre con separadores solo de LF cuando un fragmento termina entre los dos caracteres de salto de línea.
El decodificador es idéntico en v2026.7.3 y en main en b5548f76 (2026-10-05).
Reproducir
En una consola de 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) }
Resultado: ["A", "B"]. Esperado: ["A", "B", "C", "D"]. Mover el límite del fragmento a cualquier punto fuera del separador, incluido el medio de un evento, da el resultado esperado.
Corrección sugerida
Ignorar los saltos de línea iniciales en un segmento antes de probarlo, por ejemplo:
line = line.sub(/\A[\r\n]+/, "")
if line.start_with?("data: {")
Con este cambio, la reproducción anterior devuelve los cuatro eventos, al igual que los límites después de \r\n, después de \r\n\r, dentro de un evento y con separadores solo de LF.
Búsqueda de llamadas afectadas
Cada fila devuelta por esta consulta es una llamada que Google completó normalmente y Discourse decodificó solo parcialmente:
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
Impacto
El fallo depende únicamente de dónde caen los límites de los fragmentos de red, por lo que no es específico de la traducción ni de un nivel de servicio; cualquier finalización de Gemini transmitida puede perder su final de esta manera. Para la traducción, el resultado es una publicación o título silenciosamente acortado que la relleno no reintenta, porque existe una fila de localización.