@@ -42,65 +42,97 @@ impl Reasoning {
4242 }
4343
4444 pub fn from_parts ( parts : Vec < Vec < ReasoningPart > > ) -> Vec < ReasoningFull > {
45- // Flatten all parts and group by type
46- let mut grouped: std:: collections:: HashMap < Option < String > , Vec < ReasoningPart > > =
47- std:: collections:: HashMap :: new ( ) ;
45+ let mut result: Vec < ReasoningFull > = Vec :: new ( ) ;
46+ let mut current_text_parts: Vec < ReasoningPart > = Vec :: new ( ) ;
4847
4948 for part_vec in parts {
5049 for part in part_vec {
51- grouped. entry ( part. type_of . clone ( ) ) . or_default ( ) . push ( part) ;
50+ // According to OpenRouter SDK:
51+ // 1. Only 'reasoning.text' blocks are merged when consecutive.
52+ // 2. All other types (summary, encrypted, etc.) are appended as-is.
53+ // 3. IMPORTANT: If 'type_of' is None, but 'text' is present, it's treated as
54+ // 'reasoning.text'.
55+ let is_text = part. type_of . as_deref ( ) == Some ( "reasoning.text" )
56+ || ( part. type_of . is_none ( ) && part. text . is_some ( ) ) ;
57+
58+ if is_text {
59+ current_text_parts. push ( part) ;
60+ } else {
61+ // Non-text type encountered. Flush any pending text parts first.
62+ if !current_text_parts. is_empty ( ) {
63+ if let Some ( merged) = Self :: merge_parts (
64+ Some ( "reasoning.text" . to_string ( ) ) ,
65+ & current_text_parts,
66+ ) {
67+ result. push ( merged) ;
68+ }
69+ current_text_parts. clear ( ) ;
70+ }
71+
72+ // Add this non-text part as a separate block
73+ result. push ( ReasoningFull {
74+ text : part. text ,
75+ signature : part. signature ,
76+ data : part. data ,
77+ id : part. id ,
78+ format : part. format ,
79+ index : part. index ,
80+ type_of : part. type_of ,
81+ } ) ;
82+ }
5283 }
5384 }
5485
55- grouped
56- . into_iter ( )
57- . filter_map ( |( type_key, parts) | {
58- // Merge text from all parts
59- let text = parts
60- . iter ( )
61- . filter_map ( |p| p. text . as_deref ( ) )
62- . collect :: < String > ( ) ;
63-
64- // Get first non-empty value for each field
65- let signature = parts. iter ( ) . find_map ( |p| {
66- p. signature
67- . as_deref ( )
68- . filter ( |s| !s. is_empty ( ) )
69- . map ( String :: from)
70- } ) ;
71- let data = parts. iter ( ) . find_map ( |p| {
72- p. data
73- . as_deref ( )
74- . filter ( |s| !s. is_empty ( ) )
75- . map ( String :: from)
76- } ) ;
77- let id = parts
78- . iter ( )
79- . find_map ( |p| p. id . as_deref ( ) . filter ( |s| !s. is_empty ( ) ) . map ( String :: from) ) ;
80- let format = parts. iter ( ) . find_map ( |p| {
81- p. format
82- . as_deref ( )
83- . filter ( |s| !s. is_empty ( ) )
84- . map ( String :: from)
85- } ) ;
86- let index = parts. iter ( ) . find_map ( |p| p. index ) ;
87-
88- // Only include if at least one field has data
89- if text. is_empty ( ) && signature. is_none ( ) && data. is_none ( ) {
90- return None ;
91- }
86+ // Flush any remaining text parts
87+ if !current_text_parts. is_empty ( )
88+ && let Some ( merged) =
89+ Self :: merge_parts ( Some ( "reasoning.text" . to_string ( ) ) , & current_text_parts)
90+ {
91+ result. push ( merged) ;
92+ }
93+
94+ result
95+ }
9296
93- Some ( ReasoningFull {
94- text : ( !text. is_empty ( ) ) . then_some ( text) ,
95- signature,
96- data,
97- id,
98- format,
99- index,
100- type_of : type_key,
101- } )
102- } )
103- . collect ( )
97+ fn merge_parts ( type_key : Option < String > , parts : & [ ReasoningPart ] ) -> Option < ReasoningFull > {
98+ // Merge text from all parts
99+ let text = parts
100+ . iter ( )
101+ . filter_map ( |p| p. text . as_deref ( ) )
102+ . collect :: < String > ( ) ;
103+
104+ // Get first non-empty value for each field
105+ let signature = parts. iter ( ) . find_map ( |p| {
106+ p. signature
107+ . as_deref ( )
108+ . filter ( |s| !s. is_empty ( ) )
109+ . map ( String :: from)
110+ } ) ;
111+ let id = parts
112+ . iter ( )
113+ . find_map ( |p| p. id . as_deref ( ) . filter ( |s| !s. is_empty ( ) ) . map ( String :: from) ) ;
114+ let format = parts. iter ( ) . find_map ( |p| {
115+ p. format
116+ . as_deref ( )
117+ . filter ( |s| !s. is_empty ( ) )
118+ . map ( String :: from)
119+ } ) ;
120+ let index = parts. iter ( ) . find_map ( |p| p. index ) ;
121+
122+ // Only include if at least one field has data
123+ if text. is_empty ( ) && signature. is_none ( ) {
124+ return None ;
125+ }
126+
127+ Some ( ReasoningFull {
128+ text : ( !text. is_empty ( ) ) . then_some ( text) ,
129+ signature,
130+ data : None ,
131+ id,
132+ format,
133+ index,
134+ type_of : type_key,
135+ } )
104136 }
105137}
106138
@@ -109,7 +141,7 @@ mod tests {
109141 use super :: * ;
110142
111143 #[ test]
112- fn test_reasoning_detail_from_parts_groups_by_type ( ) {
144+ fn test_reasoning_detail_from_parts_merges_consecutive_types ( ) {
113145 // Create a fixture with parts of different types across streaming deltas
114146 let fixture = vec ! [
115147 // First delta: reasoning.text
@@ -131,107 +163,79 @@ mod tests {
131163 id: Some ( "tool_call_id" . to_string( ) ) ,
132164 ..Default :: default ( )
133165 } ] ,
166+ // Fourth delta: another reasoning.text appears (non-consecutive with first)
167+ vec![ ReasoningPart {
168+ type_of: Some ( "reasoning.text" . to_string( ) ) ,
169+ text: Some ( "Part 3" . to_string( ) ) ,
170+ ..Default :: default ( )
171+ } ] ,
134172 ] ;
135173
136174 // Execute the function to get the actual result
137175 let actual = Reasoning :: from_parts ( fixture) ;
138176
139- // Both types should be separate entries
140- assert_eq ! ( actual. len( ) , 2 ) ;
177+ // Should have 3 entries: merged text 1+2, encrypted, and separate text 3
178+ assert_eq ! ( actual. len( ) , 3 ) ;
141179
142- // Find each type
143- let text_entry = actual
144- . iter ( )
145- . find ( |r| r. type_of == Some ( "reasoning.text" . to_string ( ) ) )
146- . expect ( "Should have reasoning.text entry" ) ;
147- let encrypted_entry = actual
148- . iter ( )
149- . find ( |r| r. type_of == Some ( "reasoning.encrypted" . to_string ( ) ) )
150- . expect ( "Should have reasoning.encrypted entry" ) ;
180+ // Verify order and content
181+ assert_eq ! ( actual[ 0 ] . type_of, Some ( "reasoning.text" . to_string( ) ) ) ;
182+ assert_eq ! ( actual[ 0 ] . text, Some ( "Part 1 Part 2" . to_string( ) ) ) ;
151183
152- // Verify text entry has merged text
153- assert_eq ! ( text_entry . text , Some ( "Part 1 Part 2 " . to_string( ) ) ) ;
184+ assert_eq ! ( actual [ 1 ] . type_of , Some ( "reasoning.encrypted" . to_string ( ) ) ) ;
185+ assert_eq ! ( actual [ 1 ] . data , Some ( "encrypted_data " . to_string( ) ) ) ;
154186
155- // Verify encrypted entry has data and id
156- assert_eq ! ( encrypted_entry. data, Some ( "encrypted_data" . to_string( ) ) ) ;
157- assert_eq ! ( encrypted_entry. id, Some ( "tool_call_id" . to_string( ) ) ) ;
187+ assert_eq ! ( actual[ 2 ] . type_of, Some ( "reasoning.text" . to_string( ) ) ) ;
188+ assert_eq ! ( actual[ 2 ] . text, Some ( "Part 3" . to_string( ) ) ) ;
158189 }
159190
160191 #[ test]
161192 fn test_reasoning_detail_from_parts_with_different_lengths ( ) {
162- // Create a fixture with different types to test grouping
193+ // Create a fixture with different types of reasoning
163194 let fixture = vec ! [
164- vec![
165- ReasoningPart {
166- type_of: Some ( "type1" . to_string( ) ) ,
167- text: Some ( "a-text" . to_string( ) ) ,
168- signature: Some ( "a-sig" . to_string( ) ) ,
169- ..Default :: default ( )
170- } ,
171- ReasoningPart {
172- type_of: Some ( "type2" . to_string( ) ) ,
173- text: Some ( "b-text" . to_string( ) ) ,
174- signature: Some ( "b-sig" . to_string( ) ) ,
175- ..Default :: default ( )
176- } ,
177- ] ,
178195 vec![ ReasoningPart {
179196 type_of: Some ( "type1" . to_string( ) ) ,
197+ text: Some ( "a-text" . to_string( ) ) ,
198+ signature: Some ( "a-sig" . to_string( ) ) ,
199+ ..Default :: default ( )
200+ } ] ,
201+ vec![ ReasoningPart {
202+ type_of: Some ( "type2" . to_string( ) ) ,
203+ text: Some ( "b-text" . to_string( ) ) ,
204+ signature: Some ( "b-sig" . to_string( ) ) ,
205+ ..Default :: default ( )
206+ } ] ,
207+ vec![ ReasoningPart {
208+ type_of: Some ( "type2" . to_string( ) ) ,
180209 text: Some ( "c-text" . to_string( ) ) ,
181210 signature: Some ( "c-sig" . to_string( ) ) ,
182211 ..Default :: default ( )
183212 } ] ,
184- vec![
185- ReasoningPart {
186- type_of: Some ( "type1" . to_string( ) ) ,
187- text: Some ( "d-text" . to_string( ) ) ,
188- signature: Some ( "d-sig" . to_string( ) ) ,
189- ..Default :: default ( )
190- } ,
191- ReasoningPart {
192- type_of: Some ( "type2" . to_string( ) ) ,
193- text: Some ( "e-text" . to_string( ) ) ,
194- signature: Some ( "e-sig" . to_string( ) ) ,
195- ..Default :: default ( )
196- } ,
197- ReasoningPart {
198- type_of: Some ( "type3" . to_string( ) ) ,
199- text: Some ( "f-text" . to_string( ) ) ,
200- signature: Some ( "f-sig" . to_string( ) ) ,
201- ..Default :: default ( )
202- } ,
203- ] ,
204213 ] ;
205214
206215 // Execute the function to get the actual result
207- let mut actual = Reasoning :: from_parts ( fixture) ;
208- actual. sort_by ( |a, b| a. type_of . cmp ( & b. type_of ) ) ; // Sort by type for consistent ordering
216+ let actual = Reasoning :: from_parts ( fixture) ;
209217
210- // Define the expected result - now grouped by type
211- let mut expected = vec ! [
212- // type1: a + c + d (text merged, signature is first non-empty)
218+ // Non-text types are NEVER merged, even if consecutive
219+ let expected = vec ! [
213220 ReasoningFull {
214221 type_of: Some ( "type1" . to_string( ) ) ,
215- text: Some ( "a-textc-textd- text" . to_string( ) ) ,
216- signature: Some ( "a-sig" . to_string( ) ) , // First non-empty signature
222+ text: Some ( "a-text" . to_string( ) ) ,
223+ signature: Some ( "a-sig" . to_string( ) ) ,
217224 ..Default :: default ( )
218225 } ,
219- // type2: b + e (text merged, signature is first non-empty)
220226 ReasoningFull {
221227 type_of: Some ( "type2" . to_string( ) ) ,
222- text: Some ( "b-texte- text" . to_string( ) ) ,
223- signature: Some ( "b-sig" . to_string( ) ) , // First non-empty signature
228+ text: Some ( "b-text" . to_string( ) ) ,
229+ signature: Some ( "b-sig" . to_string( ) ) ,
224230 ..Default :: default ( )
225231 } ,
226- // type3: f
227232 ReasoningFull {
228- type_of: Some ( "type3 " . to_string( ) ) ,
229- text: Some ( "f -text" . to_string( ) ) ,
230- signature: Some ( "f -sig" . to_string( ) ) ,
233+ type_of: Some ( "type2 " . to_string( ) ) ,
234+ text: Some ( "c -text" . to_string( ) ) ,
235+ signature: Some ( "c -sig" . to_string( ) ) ,
231236 ..Default :: default ( )
232237 } ,
233238 ] ;
234- expected. sort_by ( |a, b| a. type_of . cmp ( & b. type_of ) ) ; // Sort expected for consistent comparison
235239
236240 // Assert that the actual result matches the expected result
237241 assert_eq ! ( actual, expected) ;
@@ -261,12 +265,29 @@ mod tests {
261265 // Execute the function to get the actual result
262266 let actual = Reasoning :: from_parts ( fixture) ;
263267
264- // Define the expected result
265- let expected = vec ! [ ReasoningFull {
266- text: Some ( "a-textb-test" . to_string( ) ) ,
267- signature: Some ( "b-sig" . to_string( ) ) ,
268- ..Default :: default ( )
269- } ] ;
268+ // parts with text but no type_of are treated as reasoning.text and merged if
269+ // consecutive. The middle part has NO text and NO type_of, so it breaks
270+ // the consecutive text merge.
271+ let expected = vec ! [
272+ ReasoningFull {
273+ text: Some ( "a-text" . to_string( ) ) ,
274+ signature: None ,
275+ type_of: Some ( "reasoning.text" . to_string( ) ) ,
276+ ..Default :: default ( )
277+ } ,
278+ ReasoningFull {
279+ text: None ,
280+ signature: Some ( "b-sig" . to_string( ) ) ,
281+ type_of: None ,
282+ ..Default :: default ( )
283+ } ,
284+ ReasoningFull {
285+ text: Some ( "b-test" . to_string( ) ) ,
286+ signature: None ,
287+ type_of: Some ( "reasoning.text" . to_string( ) ) ,
288+ ..Default :: default ( )
289+ } ,
290+ ] ;
270291
271292 // Assert that the actual result matches the expected result
272293 assert_eq ! ( actual, expected) ;
@@ -299,7 +320,7 @@ mod tests {
299320 } ,
300321 ReasoningPart {
301322 type_of: Some ( "reasoning.encrypted" . to_string( ) ) ,
302- text : Some ( "complete-text " . to_string( ) ) ,
323+ data : Some ( "complete-data " . to_string( ) ) ,
303324 signature: Some ( "complete-sig" . to_string( ) ) ,
304325 ..Default :: default ( )
305326 } ,
@@ -313,33 +334,23 @@ mod tests {
313334 } ,
314335 ReasoningPart {
315336 type_of: Some ( "reasoning.encrypted" . to_string( ) ) ,
316- text : Some ( "more-text2 " . to_string( ) ) ,
337+ data : Some ( "more-data2 " . to_string( ) ) ,
317338 signature: Some ( "more-sig" . to_string( ) ) ,
318339 ..Default :: default ( )
319340 } ,
320341 ] ,
321342 ] ;
322343
323- let mut actual = Reasoning :: from_parts ( fixture) ;
324- actual. sort_by ( |a, b| a. type_of . cmp ( & b. type_of ) ) ; // Sort by type for consistent ordering
344+ let actual = Reasoning :: from_parts ( fixture) ;
325345
326- // Now grouped by type: reasoning.text and reasoning.encrypted are separate
327- // entries
328- let mut expected = vec ! [
329- ReasoningFull {
330- type_of: Some ( "reasoning.text" . to_string( ) ) ,
331- text: Some ( "text-onlymore-text" . to_string( ) ) ,
332- signature: None , // No signature in reasoning.text type
333- ..Default :: default ( )
334- } ,
335- ReasoningFull {
336- type_of: Some ( "reasoning.encrypted" . to_string( ) ) ,
337- text: Some ( "complete-textmore-text2" . to_string( ) ) ,
338- signature: Some ( "complete-sig" . to_string( ) ) , // First non-empty signature
339- ..Default :: default ( )
340- } ,
341- ] ;
342- expected. sort_by ( |a, b| a. type_of . cmp ( & b. type_of ) ) ; // Sort expected as well for consistent comparison
343- assert_eq ! ( actual, expected) ;
346+ // Encrypted reasoning blocks are NEVER merged.
347+ // Non-consecutive text blocks are NOT merged.
348+ // In this case: [text1, encrypted1, text2, encrypted2]
349+ assert_eq ! ( actual. len( ) , 4 ) ;
350+
351+ assert_eq ! ( actual[ 0 ] . text, Some ( "text-only" . to_string( ) ) ) ;
352+ assert_eq ! ( actual[ 1 ] . data, Some ( "complete-data" . to_string( ) ) ) ;
353+ assert_eq ! ( actual[ 2 ] . text, Some ( "more-text" . to_string( ) ) ) ;
354+ assert_eq ! ( actual[ 3 ] . data, Some ( "more-data2" . to_string( ) ) ) ;
344355 }
345356}
0 commit comments