@@ -323,7 +323,7 @@ async def _query_stream(
323323 llm_response .reasoning_content = reasoning
324324 _y = True
325325 if delta .content :
326- completion_text = delta .content
326+ completion_text = self . _normalize_content ( delta .content )
327327 llm_response .result_chain = MessageChain (
328328 chain = [Comp .Plain (completion_text )],
329329 )
@@ -371,6 +371,57 @@ def _extract_usage(self, usage: CompletionUsage) -> TokenUsage:
371371 output = completion_tokens ,
372372 )
373373
374+ @staticmethod
375+ def _normalize_content (raw_content : Any ) -> str :
376+ """Normalize content from various formats to plain string.
377+
378+ Some LLM providers return content as list[dict] format
379+ like [{'type': 'text', 'text': '...'}] instead of
380+ plain string. This method handles both formats.
381+
382+ Args:
383+ raw_content: The raw content from LLM response, can be str, list, or other.
384+
385+ Returns:
386+ Normalized plain text string.
387+ """
388+ if isinstance (raw_content , list ):
389+ # Extract text from list of content parts
390+ text_parts = []
391+ for part in raw_content :
392+ if isinstance (part , dict ) and part .get ("type" ) == "text" :
393+ text_parts .append (part .get ("text" , "" ))
394+ elif isinstance (part , str ):
395+ text_parts .append (part )
396+ return "" .join (text_parts )
397+
398+ if isinstance (raw_content , str ):
399+ content = raw_content .strip ()
400+ # Check if the string is a JSON-encoded list (e.g., "[{'type': 'text', ...}]")
401+ # This can happen when streaming concatenates content that was originally list format
402+ if (
403+ content .startswith ("[" )
404+ and content .endswith ("]" )
405+ and len (content ) < 8192
406+ ):
407+ try :
408+ parsed = json .loads (content .replace ("'" , '"' ))
409+ if isinstance (parsed , list ):
410+ text_parts = []
411+ for part in parsed :
412+ if isinstance (part , dict ) and part .get ("type" ) == "text" :
413+ text_parts .append (part .get ("text" , "" ))
414+ elif isinstance (part , str ):
415+ text_parts .append (part )
416+ if text_parts :
417+ return "" .join (text_parts )
418+ except (json .JSONDecodeError , TypeError ):
419+ # Not a valid JSON, keep original string
420+ pass
421+ return content
422+
423+ return str (raw_content )
424+
374425 async def _parse_openai_completion (
375426 self , completion : ChatCompletion , tools : ToolSet | None
376427 ) -> LLMResponse :
@@ -383,8 +434,7 @@ async def _parse_openai_completion(
383434
384435 # parse the text completion
385436 if choice .message .content is not None :
386- # text completion
387- completion_text = str (choice .message .content ).strip ()
437+ completion_text = self ._normalize_content (choice .message .content )
388438 # specially, some providers may set <think> tags around reasoning content in the completion text,
389439 # we use regex to remove them, and store then in reasoning_content field
390440 reasoning_pattern = re .compile (r"<think>(.*?)</think>" , re .DOTALL )
@@ -394,6 +444,8 @@ async def _parse_openai_completion(
394444 [match .strip () for match in matches ],
395445 )
396446 completion_text = reasoning_pattern .sub ("" , completion_text ).strip ()
447+ # Also clean up orphan </think> tags that may leak from some models
448+ completion_text = re .sub (r"</think>\s*$" , "" , completion_text ).strip ()
397449 llm_response .result_chain = MessageChain ().message (completion_text )
398450
399451 # parse the reasoning content if any
0 commit comments