use std::{collections::VecDeque, str::FromStr};
use serde::{Deserialize, Serialize};
use serde_json::json;
use thiserror::Error;
use super::{
request::{ContentPart, Role},
response::{OutputType, ResponseStatus, StatusDetails, Usage},
};
#[derive(Debug, Error)]
pub enum ParserError {
#[error("Failed to parse JSON: {0}")]
JsonError(#[from] serde_json::Error),
}
#[derive(Debug, Clone, PartialEq)]
pub enum Chunk {
Done,
Data(Box<ChunkResponse>),
}
impl FromStr for Chunk {
type Err = serde_json::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"[DONE]" => Ok(Chunk::Done),
_ => Ok(Chunk::Data(Box::new(
serde_json::from_str::<ChunkResponse>(s)?,
))),
}
}
}
impl Chunk {
pub fn try_to_string(&self) -> Result<String, serde_json::Error> {
match self {
Chunk::Done => Ok("[DONE]".to_string()),
Chunk::Data(response) => serde_json::to_string(response.as_ref()),
}
}
pub fn done() -> Self {
Chunk::Done
}
pub fn starter(id: impl Into<String>, model: impl Into<String>) -> Self {
let now = current_timestamp();
Chunk::Data(Box::new(ChunkResponse {
id: id.into(),
model: model.into(),
object: "response.chunk".to_string(),
created: now,
output: vec![ChunkOutput {
index: 0,
r#type: OutputType::Message,
role: Some(Role::Assistant),
delta: Some(Delta {
role: Some(Role::Assistant),
content: Some(VecDeque::from([json!({
"type": "output_text",
"text": "",
})])),
..Default::default()
}),
..Default::default()
}],
..Default::default()
}))
}
pub fn with_content(
id: impl Into<String>,
model: impl Into<String>,
content: impl Into<String>,
) -> Self {
let now = current_timestamp();
Chunk::Data(Box::new(ChunkResponse {
id: id.into(),
model: model.into(),
object: "response.chunk".to_string(),
created: now,
output: vec![ChunkOutput {
index: 0,
r#type: OutputType::Message,
role: Some(Role::Assistant),
delta: Some(Delta {
content: Some(VecDeque::from([json!({
"type": "output_text",
"text": content.into(),
})])),
..Default::default()
}),
..Default::default()
}],
..Default::default()
}))
}
pub fn builder(id: impl Into<String>, model: impl Into<String>) -> ChunkBuilder {
ChunkBuilder::new(id.into(), model.into())
}
}
fn current_timestamp() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
}
#[derive(Debug, Default, Clone)]
pub struct ChunkBuilder {
id: String,
model: String,
content: VecDeque<ContentPart>,
role: Option<Role>,
status: Option<ResponseStatus>,
status_details: Option<StatusDetails>,
usage: Option<Usage>,
created: Option<u64>,
}
impl ChunkBuilder {
pub fn new(id: String, model: String) -> Self {
Self {
id,
model,
..Default::default()
}
}
pub fn push_text(mut self, text: impl Into<String>) -> Self {
self.content.push_back(json!({
"type": "output_text",
"text": text.into(),
}));
self
}
pub fn role(mut self, role: Role) -> Self {
self.role = Some(role);
self
}
pub fn status(mut self, status: ResponseStatus) -> Self {
self.status = Some(status);
self
}
pub fn status_details(mut self, details: StatusDetails) -> Self {
self.status_details = Some(details);
self
}
pub fn usage(mut self, usage: Usage) -> Self {
self.usage = Some(usage);
self
}
pub fn created(mut self, created: u64) -> Self {
self.created = Some(created);
self
}
pub fn build(self) -> Chunk {
let created = self.created.unwrap_or_else(current_timestamp);
Chunk::Data(Box::new(ChunkResponse {
id: self.id,
model: self.model,
object: "response.chunk".to_string(),
created,
status: self.status,
status_details: self.status_details,
usage: self.usage,
output: if self.content.is_empty() {
Vec::new()
} else {
vec![ChunkOutput {
index: 0,
r#type: OutputType::Message,
role: self.role,
delta: Some(Delta {
role: self.role,
content: Some(self.content),
..Default::default()
}),
..Default::default()
}]
},
..Default::default()
}))
}
}
#[derive(Debug, Deserialize, Serialize, Default, Clone, PartialEq)]
pub struct ChunkResponse {
#[serde(default)]
pub id: String,
#[serde(default)]
pub model: String,
#[serde(default)]
pub object: String,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub event_type: Option<StreamEventType>,
#[serde(default, alias = "created_at")]
pub created: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<ResponseStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_details: Option<StatusDetails>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub output: Vec<ChunkOutput>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_fingerprint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response: Option<Box<super::response::Response>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_index: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_index: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub item_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delta: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub item: Option<super::response::Output>,
#[serde(skip_serializing_if = "Option::is_none")]
pub part: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub enum StreamEventType {
#[serde(rename = "response.created")]
ResponseCreated,
#[serde(rename = "response.in_progress")]
ResponseInProgress,
#[serde(rename = "response.completed")]
ResponseCompleted,
#[serde(rename = "response.failed")]
ResponseFailed,
#[serde(rename = "response.incomplete")]
ResponseIncomplete,
#[serde(rename = "response.output_item.added")]
OutputItemAdded,
#[serde(rename = "response.output_item.done")]
OutputItemDone,
#[serde(rename = "response.content_part.added")]
ContentPartAdded,
#[serde(rename = "response.content_part.done")]
ContentPartDone,
#[serde(rename = "response.output_text.delta")]
OutputTextDelta,
#[serde(rename = "response.output_text.done")]
OutputTextDone,
#[serde(rename = "response.function_call_arguments.delta")]
FunctionCallArgumentsDelta,
#[serde(rename = "response.function_call_arguments.done")]
FunctionCallArgumentsDone,
#[serde(rename = "response.file_search_call.in_progress")]
FileSearchCallInProgress,
#[serde(rename = "response.file_search_call.searching")]
FileSearchCallSearching,
#[serde(rename = "response.file_search_call.completed")]
FileSearchCallCompleted,
#[serde(rename = "response.web_search_call.in_progress")]
WebSearchCallInProgress,
#[serde(rename = "response.web_search_call.searching")]
WebSearchCallSearching,
#[serde(rename = "response.web_search_call.completed")]
WebSearchCallCompleted,
#[serde(rename = "response.code_interpreter_call.in_progress")]
CodeInterpreterCallInProgress,
#[serde(rename = "response.code_interpreter_call.interpreting")]
CodeInterpreterCallInterpreting,
#[serde(rename = "response.code_interpreter_call.completed")]
CodeInterpreterCallCompleted,
#[serde(rename = "response.code_interpreter_call.code.delta")]
CodeInterpreterCallCodeDelta,
#[serde(rename = "response.code_interpreter_call.code.done")]
CodeInterpreterCallCodeDone,
#[serde(rename = "response.audio.delta")]
AudioDelta,
#[serde(rename = "response.audio.done")]
AudioDone,
#[serde(rename = "response.audio.transcript.delta")]
AudioTranscriptDelta,
#[serde(rename = "response.audio.transcript.done")]
AudioTranscriptDone,
#[serde(rename = "response.refusal.delta")]
RefusalDelta,
#[serde(rename = "response.refusal.done")]
RefusalDone,
#[serde(rename = "response.reasoning_summary_part.added")]
ReasoningSummaryPartAdded,
#[serde(rename = "response.reasoning_summary_part.done")]
ReasoningSummaryPartDone,
#[serde(rename = "response.reasoning_summary_text.delta")]
ReasoningSummaryTextDelta,
#[serde(rename = "response.reasoning_summary_text.done")]
ReasoningSummaryTextDone,
#[serde(rename = "response.output_text.annotation.added")]
OutputTextAnnotationAdded,
#[serde(rename = "error")]
Error,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone, PartialEq)]
pub struct ChunkOutput {
pub index: usize,
#[serde(rename = "type")]
pub r#type: OutputType,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<Role>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delta: Option<Delta>,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone, PartialEq)]
pub struct Delta {
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<Role>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<VecDeque<ContentPart>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<String>,
}
#[derive(Debug, Default, Clone)]
pub struct ResponseChunkParser {
pub id: String,
pub object: String,
pub created: u64,
pub model: String,
system_fingerprint: Option<String>,
status: ResponseStatus,
status_details: Option<StatusDetails>,
usage: Option<Usage>,
pub content: String,
pub reasoning_content: String,
current_function_call: Option<FunctionCallAccumulator>,
outputs: Vec<super::response::Output>,
}
#[derive(Debug, Default, Clone)]
struct FunctionCallAccumulator {
id: String,
name: String,
call_id: String,
arguments: String,
}
impl ResponseChunkParser {
pub fn parse(
&mut self,
data: &str,
) -> Result<
(
Option<super::response::Response>,
Option<super::response::Output>,
),
ParserError,
> {
let chunk = Chunk::from_str(data)?;
match chunk {
Chunk::Done => {
let response = self.build_response();
Ok((Some(response), None))
}
Chunk::Data(response) => {
self.update_basic_info(&response);
let completed_output = match response.event_type {
Some(StreamEventType::ResponseCompleted) => {
if let Some(full_response) = response.response {
return Ok((Some(*full_response), None));
}
let resp = self.build_response();
return Ok((Some(resp), None));
}
Some(StreamEventType::OutputTextDelta) => {
if let Some(delta) = &response.delta {
self.content.push_str(delta);
}
None
}
Some(StreamEventType::ReasoningSummaryTextDelta) => {
if let Some(delta) = &response.delta {
self.reasoning_content.push_str(delta);
}
None
}
Some(StreamEventType::OutputItemAdded) => {
if let Some(item) = &response.item {
if item.r#type == super::response::OutputType::FunctionCall {
self.current_function_call = Some(FunctionCallAccumulator {
id: item.id.clone(),
name: item.name.clone().unwrap_or_default(),
call_id: item.call_id.clone().unwrap_or_default(),
arguments: item.arguments.clone().unwrap_or_default(),
});
}
}
None
}
Some(StreamEventType::FunctionCallArgumentsDelta) => {
if let Some(ref mut fc) = self.current_function_call {
if let Some(delta) = &response.delta {
fc.arguments.push_str(delta);
}
}
None
}
Some(StreamEventType::FunctionCallArgumentsDone)
| Some(StreamEventType::OutputItemDone) => {
self.finalize_function_call()
}
Some(StreamEventType::ResponseFailed)
| Some(StreamEventType::ResponseIncomplete) => {
self.status =
if response.event_type == Some(StreamEventType::ResponseFailed) {
ResponseStatus::Failed
} else {
ResponseStatus::Incomplete
};
None
}
_ => {
for output in &response.output {
if let Some(delta) = &output.delta {
if let Some(text) = &delta.text {
self.content.push_str(text);
}
if let Some(content_parts) = &delta.content {
for part in content_parts {
if let Some(text) =
part.get("text").and_then(|v| v.as_str())
{
self.content.push_str(text);
}
}
}
if let Some(args) = &delta.arguments {
if let Some(ref mut fc) = self.current_function_call {
fc.arguments.push_str(args);
}
}
}
}
None
}
};
Ok((None, completed_output))
}
}
}
pub fn build_response(&self) -> super::response::Response {
use super::response::{Output, OutputType, Response};
let mut outputs = self.outputs.clone();
if !self.content.is_empty() {
outputs.push(Output {
id: format!("output-{}", outputs.len()),
r#type: OutputType::Message,
role: Some(Role::Assistant),
content: vec![json!({
"type": "output_text",
"text": self.content.clone(),
})],
..Default::default()
});
}
if !self.reasoning_content.is_empty() {
outputs.push(Output {
id: format!("output-{}", outputs.len()),
r#type: OutputType::Reasoning,
summary: vec![json!({
"type": "summary_text",
"text": self.reasoning_content.clone(),
})],
..Default::default()
});
}
Response {
id: self.id.clone(),
object: if self.object.is_empty() {
"response".to_string()
} else {
self.object.clone()
},
created_at: self.created,
model: self.model.clone(),
status: self.status.clone(),
system_fingerprint: self.system_fingerprint.clone(),
usage: self.usage.clone(),
output: outputs,
output_text: if self.content.is_empty() {
None
} else {
Some(self.content.clone())
},
..Default::default()
}
}
pub fn update_id_if_empty(&mut self, id: &str) {
if self.id.is_empty() {
self.id = id.to_string();
}
}
pub fn update_model_if_empty(&mut self, model: &str) {
if self.model.is_empty() {
self.model = model.to_string();
}
}
pub fn set_system_fingerprint(&mut self, fingerprint: Option<String>) {
self.system_fingerprint = fingerprint;
}
pub fn set_status(&mut self, status: ResponseStatus) {
self.status = status;
}
pub fn push_content(&mut self, content: &str) {
self.content.push_str(content);
}
pub fn push_reasoning(&mut self, content: &str) {
self.reasoning_content.push_str(content);
}
pub fn push_output(&mut self, output: super::response::Output) {
self.outputs.push(output);
}
fn update_basic_info(&mut self, response: &ChunkResponse) {
if self.id.is_empty() && !response.id.is_empty() {
self.id = response.id.clone();
}
if !response.object.is_empty() {
self.object = response.object.clone();
}
if response.created > 0 {
self.created = response.created;
}
if !response.model.is_empty() {
self.model = response.model.clone();
}
if response.system_fingerprint.is_some() {
self.system_fingerprint = response.system_fingerprint.clone();
}
if let Some(status) = &response.status {
self.status = status.clone();
}
if response.status_details.is_some() {
self.status_details = response.status_details.clone();
}
if response.usage.is_some() {
self.usage = response.usage.clone();
}
}
fn finalize_function_call(&mut self) -> Option<super::response::Output> {
let fc = self.current_function_call.take()?;
let output = super::response::Output {
id: fc.id,
r#type: super::response::OutputType::FunctionCall,
status: Some(super::response::OutputStatus::Completed),
name: Some(fc.name),
call_id: Some(fc.call_id),
arguments: Some(fc.arguments),
..Default::default()
};
self.outputs.push(output.clone());
Some(output)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn starter_chunk_serialises() {
let chunk = Chunk::starter("resp_1", "gpt-4.1-mini");
let serialised = chunk.try_to_string().unwrap();
assert!(serialised.contains("response.chunk"));
}
#[test]
fn builder_produces_chunk_with_text() {
let chunk = Chunk::builder("resp_2", "gpt-4.1-mini")
.push_text("hello")
.build();
match chunk {
Chunk::Data(response) => {
assert_eq!(response.output.len(), 1);
let content = &response.output[0]
.delta
.as_ref()
.and_then(|delta| delta.content.clone())
.unwrap();
assert_eq!(content.len(), 1);
assert_eq!(
content[0].get("text").and_then(|value| value.as_str()),
Some("hello")
);
}
Chunk::Done => panic!("expected chunk data"),
}
}
#[test]
fn test_parser_for_text_content() {
let test_cases = vec![
r#"{"type":"response.created","response":{"id":"resp_123","object":"response","created_at":1700000000,"model":"gpt-4.1-mini","status":"in_progress","output":[]}}"#,
r#"{"type":"response.in_progress","response":{"id":"resp_123","object":"response","created_at":1700000000,"model":"gpt-4.1-mini","status":"in_progress","output":[]}}"#,
r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"item_0","type":"message","role":"assistant","content":[]}}"#,
r#"{"type":"response.content_part.added","output_index":0,"content_index":0,"part":{"type":"output_text","text":""}}"#,
r#"{"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"Hello"}"#,
r#"{"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":" "}"#,
r#"{"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"world"}"#,
r#"{"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"!"}"#,
r#"{"type":"response.output_text.done","output_index":0,"content_index":0,"text":"Hello world!"}"#,
r#"{"type":"response.content_part.done","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello world!"}}"#,
r#"{"type":"response.output_item.done","output_index":0,"item":{"id":"item_0","type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello world!"}]}}"#,
r#"{"type":"response.completed","response":{"id":"resp_123","object":"response","created_at":1700000000,"model":"gpt-4.1-mini","status":"completed","output":[{"id":"item_0","type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello world!"}]}],"usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}}"#,
];
let mut parser = ResponseChunkParser::default();
let mut final_response = None;
for data in test_cases {
let (response, _output) = parser.parse(data).expect("parse should succeed");
if let Some(resp) = response {
final_response = Some(resp);
}
}
let res = final_response.expect("Expected final response");
assert_eq!(res.id, "resp_123");
assert_eq!(res.model, "gpt-4.1-mini");
assert_eq!(res.status, ResponseStatus::Completed);
}
#[test]
fn test_parser_for_text_deltas_accumulated() {
let test_cases = vec![
r#"{"type":"response.output_text.delta","id":"resp_456","model":"gpt-4.1-mini","output_index":0,"content_index":0,"delta":"Hello"}"#,
r#"{"type":"response.output_text.delta","id":"resp_456","model":"gpt-4.1-mini","output_index":0,"content_index":0,"delta":" "}"#,
r#"{"type":"response.output_text.delta","id":"resp_456","model":"gpt-4.1-mini","output_index":0,"content_index":0,"delta":"world"}"#,
r#"{"type":"response.output_text.delta","id":"resp_456","model":"gpt-4.1-mini","output_index":0,"content_index":0,"delta":"!"}"#,
"[DONE]",
];
let mut parser = ResponseChunkParser::default();
let mut final_response = None;
for data in test_cases {
let (response, _) = parser.parse(data).expect("parse should succeed");
if let Some(resp) = response {
final_response = Some(resp);
}
}
let res = final_response.expect("Expected final response from [DONE]");
assert_eq!(res.id, "resp_456");
assert_eq!(res.output_text, Some("Hello world!".to_string()));
}
#[test]
fn test_parser_for_function_call() {
let test_cases = vec![
r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"fc_0","type":"function_call","name":"get_weather","call_id":"call_123","arguments":""}}"#,
r#"{"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"loc"}"#,
r#"{"type":"response.function_call_arguments.delta","output_index":0,"delta":"ation\""}"#,
r#"{"type":"response.function_call_arguments.delta","output_index":0,"delta":": \"NYC"}"#,
r#"{"type":"response.function_call_arguments.delta","output_index":0,"delta":"\"}"}"#,
r#"{"type":"response.function_call_arguments.done","output_index":0,"arguments":"{\"location\": \"NYC\"}"}"#,
];
let mut parser = ResponseChunkParser::default();
parser.update_id_if_empty("resp_fc");
parser.update_model_if_empty("gpt-4.1-mini");
let mut completed_output = None;
for data in test_cases {
let (_, output) = parser.parse(data).expect("parse should succeed");
if output.is_some() {
completed_output = output;
}
}
let output = completed_output.expect("Expected completed function call output");
assert_eq!(
output.r#type,
super::super::response::OutputType::FunctionCall
);
assert_eq!(output.name, Some("get_weather".to_string()));
assert_eq!(output.call_id, Some("call_123".to_string()));
assert_eq!(
output.arguments,
Some("{\"location\": \"NYC\"}".to_string())
);
}
#[test]
fn test_done_chunk() {
let input = "[DONE]";
let chunk: Chunk = input.parse().unwrap();
assert_eq!(chunk, Chunk::Done);
}
#[test]
fn test_parser_build_response() {
let mut parser = ResponseChunkParser::default();
parser.id = "resp_build".to_string();
parser.model = "gpt-4.1-mini".to_string();
parser.created = 1700000000;
parser.push_content("Test content");
let response = parser.build_response();
assert_eq!(response.id, "resp_build");
assert_eq!(response.model, "gpt-4.1-mini");
assert_eq!(response.created_at, 1700000000);
assert_eq!(response.output_text, Some("Test content".to_string()));
assert_eq!(response.output.len(), 1);
}
}