use serde_json::{Value, json};
#[derive(Debug, Default)]
pub(super) struct Accumulator {
parts: Vec<Value>,
role: Option<String>,
finish_reason: Option<String>,
usage: Option<Value>,
generated: bool,
prompt_feedback: Option<Value>,
}
impl Accumulator {
pub(super) fn new() -> Self {
Self::default()
}
pub(super) fn push(&mut self, data: &str) -> Option<String> {
let chunk: Value = serde_json::from_str(data).ok()?;
let candidate = chunk.get("candidates")?.get(0)?;
if let Some(usage) = chunk.get("usageMetadata") {
self.usage = Some(usage.clone());
}
if let Some(feedback) = chunk.get("promptFeedback") {
self.prompt_feedback = Some(feedback.clone());
}
if let Some(reason) = candidate.get("finishReason").and_then(Value::as_str) {
self.finish_reason = Some(reason.to_owned());
}
let content = candidate.get("content");
if let Some(role) = content.and_then(|c| c.get("role")).and_then(Value::as_str) {
self.role = Some(role.to_owned());
}
let mut visible = String::new();
for part in content
.and_then(|c| c.get("parts"))
.and_then(Value::as_array)
.into_iter()
.flatten()
{
self.generated = true;
if is_plain_text(part) {
let text = part.get("text").and_then(Value::as_str).unwrap_or_default();
visible.push_str(text);
match self.parts.last_mut().filter(|last| is_plain_text(last)) {
Some(last) => {
let joined = format!(
"{}{text}",
last.get("text").and_then(Value::as_str).unwrap_or_default()
);
*last = json!({ "text": joined });
}
None => self.parts.push(part.clone()),
}
} else {
self.parts.push(part.clone());
}
}
(!visible.is_empty()).then_some(visible)
}
pub(super) fn done(&self) -> bool {
self.finish_reason.is_some()
}
pub(super) const fn generated(&self) -> bool {
self.generated
}
pub(super) fn into_response(self) -> Value {
let mut response = json!({
"candidates": [{
"content": {
"role": self.role.unwrap_or_else(|| "model".to_owned()),
"parts": self.parts,
},
"finishReason": self.finish_reason,
}],
});
if let Some(usage) = self.usage {
response["usageMetadata"] = usage;
}
if let Some(feedback) = self.prompt_feedback {
response["promptFeedback"] = feedback;
}
response
}
}
fn is_plain_text(part: &Value) -> bool {
part.as_object()
.is_some_and(|object| object.len() == 1 && object.contains_key("text"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adjacent_text_is_joined_and_signed_parts_survive() {
let mut acc = Accumulator::new();
assert_eq!(
acc.push(r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"Hel"}]}}]}"#),
Some("Hel".to_owned())
);
assert_eq!(
acc.push(r#"{"candidates":[{"content":{"parts":[{"text":"lo"}]}}]}"#),
Some("lo".to_owned())
);
assert_eq!(
acc.push(
r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"f","args":{}},
"thoughtSignature":"SIG"}]},"finishReason":"STOP"}],
"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":4}}"#
),
None,
"a function call is not visible text"
);
assert!(acc.done());
assert!(acc.generated());
let response = acc.into_response();
let parts = response["candidates"][0]["content"]["parts"]
.as_array()
.expect("parts");
assert_eq!(parts.len(), 2, "text was not merged: {parts:?}");
assert_eq!(parts[0]["text"], "Hello");
assert_eq!(
parts[1]["thoughtSignature"], "SIG",
"the signature Gemini 3 requires back was lost reassembling the stream"
);
assert_eq!(response["usageMetadata"]["candidatesTokenCount"], 4);
}
#[test]
fn usage_is_taken_from_the_last_chunk_not_summed() {
let mut acc = Accumulator::new();
acc.push(r#"{"candidates":[{"content":{"parts":[{"text":"a"}]}}],"usageMetadata":{"candidatesTokenCount":1}}"#);
acc.push(r#"{"candidates":[{"content":{"parts":[{"text":"b"}]},"finishReason":"STOP"}],"usageMetadata":{"candidatesTokenCount":9}}"#);
assert_eq!(
acc.into_response()["usageMetadata"]["candidatesTokenCount"],
9
);
}
#[test]
fn an_empty_stream_reports_nothing_generated() {
let acc = Accumulator::new();
assert!(!acc.generated());
assert!(!acc.done());
}
}