Descrizione
Quando una risposta di Gemini viene trasmessa in streaming, Discourse AI elabora occasionalmente solo la prima parte di essa. La risposta completa viene ricevuta da Google e archiviata in ai_api_audit_logs.raw_response_payload, lo streaming termina con finishReason STOP e non viene registrato alcun errore. Il chiamante riceve il testo fino a un certo evento e nulla dopo. Con la traduzione AI, il testo troncato viene archiviato come traduzione completata.
Osservato su un sito self-hosted che esegue release/2026.7 (2026.7.3) con il plugin discourse-ai incluso e gemini-3.8-flash sul tier di servizio standard. In 3 su circa 26.200 chiamate di traduzione in tre giorni, response_tokens nel log di audit era inferiore al candidatesTokenCount finale riportato da Google nella stessa risposta: 196 su 241, 84 su 328 e 131 su 179. In ogni caso, il conteggio archiviato corrisponde al conteggio cumulativo di Google a un evento intermedio, il che significa che la decodifica si è interrotta dopo quell’evento.
Questo è separato dagli eventi di errore in-stream corretti da #44262: non c’è alcun evento di errore qui e il decoder non è stato modificato da quella pull request.
Causa principale
DiscourseAi::Completions::Endpoints::Gemini::GeminiStreamingDecoder#decode divide il buffer con /\r?\n\r?\n/ e accetta un segmento solo se inizia con data: {.
Quando un chunk di rete termina all’interno della riga vuota che separa due eventi, ad esempio dopo il primo \r\n di \r\n\r\n, l’evento precedente viene analizzato e il buffer viene svuotato. Il chunk successivo inizia quindi con il rimanente \r\n, quindi il segmento successivo è "\r\ndata: {...}". Non inizia con data: {, quindi viene mantenuto nel buffer come riga incompleta. Ogni evento successivo viene aggiunto a quel buffer e non viene mai riconosciuto. Lo streaming termina normalmente e gli eventi rimanenti vengono scartati insieme al buffer.
Lo stesso accade con separatori solo LF quando un chunk termina tra i due caratteri di fine riga.
Il decoder è identico in v2026.7.3 e su main in b5548f76 (2026-10-05).
Riproduzione
In una 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) }
Risultato: ["A", "B"]. Atteso: ["A", "B", "C", "D"]. Spostare il confine del chunk in qualsiasi punto al di fuori del separatore, incluso il centro di un evento, produce il risultato atteso.
Correzione suggerita
Ignorare i caratteri di fine riga iniziali di un segmento prima di testarlo, ad esempio:
line = line.sub(/\A[\r\n]+/, "")
if line.start_with?("data: {")
Con questa modifica, la riproduzione sopra restituisce tutti e quattro gli eventi, così come i confini dopo \r\n, dopo \r\n\r, all’interno di un evento e con separatori solo LF.
Individuazione delle chiamate interessate
Ogni riga restituita da questa query è una chiamata che Google ha completato normalmente e Discourse ha decodificato solo parzialmente:
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
Impatto
Il difetto dipende solo da dove cadono i confini dei chunk di rete, quindi non è specifico per la traduzione o per un tier di servizio; qualsiasi completamento Gemini in streaming può perdere la sua coda in questo modo. Per la traduzione, il risultato è un post o un titolo silenziosamente accorciato che il backfill non ritenta, poiché esiste una riga di localizzazione.