Skip to content

Commit dfea718

Browse files
committed
fix: handle list format content from OpenAI-compatible APIs
Some LLM providers return content as list[dict] format like [{'type': 'text', 'text': '...'}] instead of plain string. This causes the raw list representation to be displayed to users. Changes: - Add _normalize_content() helper to extract text from various content formats - Use json.loads instead of ast.literal_eval for safer parsing - Add size limit check (8KB) before attempting JSON parsing - Clean up orphan </think> tags that may leak from some models Fixes #5124
1 parent 549cbb8 commit dfea718

1 file changed

Lines changed: 55 additions & 3 deletions

File tree

astrbot/core/provider/sources/openai_source.py

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)