use derive_setters::Setters; use serde::{Deserialize, Serialize}; /// Represents a reasoning detail that may be included in the response #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default, Setters)] #[setters(into)] pub struct ReasoningDetail { pub text: Option, pub signature: Option, pub data: Option, pub id: Option, pub format: Option, pub index: Option, pub type_of: Option, } /// Type alias for partial reasoning (used in streaming) pub type ReasoningPart = ReasoningDetail; /// Type alias for complete reasoning pub type ReasoningFull = ReasoningDetail; #[derive(Clone, Debug, PartialEq)] pub enum Reasoning { Part(Vec), Full(Vec), } impl Reasoning { pub fn as_partial(&self) -> Option<&Vec> { match self { Reasoning::Part(parts) => Some(parts), Reasoning::Full(_) => None, } } pub fn as_full(&self) -> Option<&Vec> { match self { Reasoning::Part(_) => None, Reasoning::Full(full) => Some(full), } } pub fn from_parts(parts: Vec>) -> Vec { // Flatten all parts and group by type let mut grouped: std::collections::HashMap, Vec> = std::collections::HashMap::new(); for part_vec in parts { for part in part_vec { grouped.entry(part.type_of.clone()).or_default().push(part); } } grouped .into_iter() .filter_map(|(type_key, parts)| { // Merge text from all parts let text = parts .iter() .filter_map(|p| p.text.as_deref()) .collect::(); // Get first non-empty value for each field let signature = parts.iter().find_map(|p| { p.signature .as_deref() .filter(|s| !s.is_empty()) .map(String::from) }); let data = parts.iter().find_map(|p| { p.data .as_deref() .filter(|s| !s.is_empty()) .map(String::from) }); let id = parts .iter() .find_map(|p| p.id.as_deref().filter(|s| !s.is_empty()).map(String::from)); let format = parts.iter().find_map(|p| { p.format .as_deref() .filter(|s| !s.is_empty()) .map(String::from) }); let index = parts.iter().find_map(|p| p.index); // Only include if at least one field has data if text.is_empty() && signature.is_none() && data.is_none() { return None; } Some(ReasoningFull { text: (!text.is_empty()).then_some(text), signature, data, id, format, index, type_of: type_key, }) }) .collect() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_reasoning_detail_from_parts_groups_by_type() { // Create a fixture with parts of different types across streaming deltas let fixture = vec![ // First delta: reasoning.text vec![ReasoningPart { type_of: Some("reasoning.text".to_string()), text: Some("Part 1 ".to_string()), ..Default::default() }], // Second delta: reasoning.text continues vec![ReasoningPart { type_of: Some("reasoning.text".to_string()), text: Some("Part 2".to_string()), ..Default::default() }], // Third delta: reasoning.encrypted appears vec![ReasoningPart { type_of: Some("reasoning.encrypted".to_string()), data: Some("encrypted_data".to_string()), id: Some("tool_call_id".to_string()), ..Default::default() }], ]; // Execute the function to get the actual result let actual = Reasoning::from_parts(fixture); // Both types should be separate entries assert_eq!(actual.len(), 2); // Find each type let text_entry = actual .iter() .find(|r| r.type_of == Some("reasoning.text".to_string())) .expect("Should have reasoning.text entry"); let encrypted_entry = actual .iter() .find(|r| r.type_of == Some("reasoning.encrypted".to_string())) .expect("Should have reasoning.encrypted entry"); // Verify text entry has merged text assert_eq!(text_entry.text, Some("Part 1 Part 2".to_string())); // Verify encrypted entry has data and id assert_eq!(encrypted_entry.data, Some("encrypted_data".to_string())); assert_eq!(encrypted_entry.id, Some("tool_call_id".to_string())); } #[test] fn test_reasoning_detail_from_parts_with_different_lengths() { // Create a fixture with different types to test grouping let fixture = vec![ vec![ ReasoningPart { type_of: Some("type1".to_string()), text: Some("a-text".to_string()), signature: Some("a-sig".to_string()), ..Default::default() }, ReasoningPart { type_of: Some("type2".to_string()), text: Some("b-text".to_string()), signature: Some("b-sig".to_string()), ..Default::default() }, ], vec![ReasoningPart { type_of: Some("type1".to_string()), text: Some("c-text".to_string()), signature: Some("c-sig".to_string()), ..Default::default() }], vec![ ReasoningPart { type_of: Some("type1".to_string()), text: Some("d-text".to_string()), signature: Some("d-sig".to_string()), ..Default::default() }, ReasoningPart { type_of: Some("type2".to_string()), text: Some("e-text".to_string()), signature: Some("e-sig".to_string()), ..Default::default() }, ReasoningPart { type_of: Some("type3".to_string()), text: Some("f-text".to_string()), signature: Some("f-sig".to_string()), ..Default::default() }, ], ]; // Execute the function to get the actual result let mut actual = Reasoning::from_parts(fixture); actual.sort_by(|a, b| a.type_of.cmp(&b.type_of)); // Sort by type for consistent ordering // Define the expected result - now grouped by type let mut expected = vec![ // type1: a + c + d (text merged, signature is first non-empty) ReasoningFull { type_of: Some("type1".to_string()), text: Some("a-textc-textd-text".to_string()), signature: Some("a-sig".to_string()), // First non-empty signature ..Default::default() }, // type2: b + e (text merged, signature is first non-empty) ReasoningFull { type_of: Some("type2".to_string()), text: Some("b-texte-text".to_string()), signature: Some("b-sig".to_string()), // First non-empty signature ..Default::default() }, // type3: f ReasoningFull { type_of: Some("type3".to_string()), text: Some("f-text".to_string()), signature: Some("f-sig".to_string()), ..Default::default() }, ]; expected.sort_by(|a, b| a.type_of.cmp(&b.type_of)); // Sort expected for consistent comparison // Assert that the actual result matches the expected result assert_eq!(actual, expected); } #[test] fn test_reasoning_detail_from_parts_with_none_values() { // Create a fixture with some None values let fixture = vec![ vec![ReasoningPart { text: Some("a-text".to_string()), signature: None, ..Default::default() }], vec![ReasoningPart { text: None, signature: Some("b-sig".to_string()), ..Default::default() }], vec![ReasoningPart { text: Some("b-test".to_string()), signature: None, ..Default::default() }], ]; // Execute the function to get the actual result let actual = Reasoning::from_parts(fixture); // Define the expected result let expected = vec![ReasoningFull { text: Some("a-textb-test".to_string()), signature: Some("b-sig".to_string()), ..Default::default() }]; // Assert that the actual result matches the expected result assert_eq!(actual, expected); } #[test] fn test_reasoning_detail_from_empty_parts() { // Empty fixture let fixture: Vec> = vec![]; // Execute the function to get the actual result let actual = Reasoning::from_parts(fixture); // Define the expected result - should be an empty vector let expected: Vec = vec![]; // Assert that the actual result matches the expected result assert_eq!(actual, expected); } #[test] fn test_reasoning_detail_from_parts_keeps_partial_reasoning() { let fixture = vec![ vec![ ReasoningPart { type_of: Some("reasoning.text".to_string()), text: Some("text-only".to_string()), signature: None, ..Default::default() }, ReasoningPart { type_of: Some("reasoning.encrypted".to_string()), text: Some("complete-text".to_string()), signature: Some("complete-sig".to_string()), ..Default::default() }, ], vec![ ReasoningPart { type_of: Some("reasoning.text".to_string()), text: Some("more-text".to_string()), signature: None, ..Default::default() }, ReasoningPart { type_of: Some("reasoning.encrypted".to_string()), text: Some("more-text2".to_string()), signature: Some("more-sig".to_string()), ..Default::default() }, ], ]; let mut actual = Reasoning::from_parts(fixture); actual.sort_by(|a, b| a.type_of.cmp(&b.type_of)); // Sort by type for consistent ordering // Now grouped by type: reasoning.text and reasoning.encrypted are separate // entries let mut expected = vec![ ReasoningFull { type_of: Some("reasoning.text".to_string()), text: Some("text-onlymore-text".to_string()), signature: None, // No signature in reasoning.text type ..Default::default() }, ReasoningFull { type_of: Some("reasoning.encrypted".to_string()), text: Some("complete-textmore-text2".to_string()), signature: Some("complete-sig".to_string()), // First non-empty signature ..Default::default() }, ]; expected.sort_by(|a, b| a.type_of.cmp(&b.type_of)); // Sort expected as well for consistent comparison assert_eq!(actual, expected); } }