Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 85 additions & 3 deletions astrbot/core/provider/sources/openai_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,8 @@ async def _query_stream(
llm_response.reasoning_content = reasoning
_y = True
if delta.content:
completion_text = delta.content
# Don't strip streaming chunks to preserve spaces between words
completion_text = self._normalize_content(delta.content, strip=False)
llm_response.result_chain = MessageChain(
chain=[Comp.Plain(completion_text)],
)
Expand Down Expand Up @@ -371,6 +372,86 @@ def _extract_usage(self, usage: CompletionUsage) -> TokenUsage:
output=completion_tokens,
)

@staticmethod
def _normalize_content(raw_content: Any, strip: bool = True) -> str:
"""Normalize content from various formats to plain string.
Some LLM providers return content as list[dict] format
like [{'type': 'text', 'text': '...'}] instead of
plain string. This method handles both formats.
Args:
raw_content: The raw content from LLM response, can be str, list, or other.
strip: Whether to strip whitespace from the result. Set to False for
streaming chunks to preserve spaces between words.
Returns:
Normalized plain text string.
"""
if isinstance(raw_content, list):
# Check if this looks like OpenAI content-part format
# Only process if at least one item has {'type': 'text', 'text': ...} structure
has_content_part = any(
isinstance(part, dict) and part.get("type") == "text"
for part in raw_content
)
if has_content_part:
text_parts = []
for part in raw_content:
if isinstance(part, dict) and part.get("type") == "text":
text_val = part.get("text", "")
# Coerce to str in case text is null or non-string
text_parts.append(str(text_val) if text_val is not None else "")
return "".join(text_parts)
# Not content-part format, return string representation
return str(raw_content)

if isinstance(raw_content, str):
content = raw_content.strip() if strip else raw_content
# Check if the string is a JSON-encoded list (e.g., "[{'type': 'text', ...}]")
# This can happen when streaming concatenates content that was originally list format
# Only check if it looks like a complete JSON array (requires strip for check)
check_content = raw_content.strip()
if (
check_content.startswith("[")
and check_content.endswith("]")
and len(check_content) < 8192

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse long list-encoded content beyond 8KB

The normalization path stops attempting list decoding when a bracketed string reaches 8192 characters, so long responses from OpenAI-compatible providers that serialize content parts (the exact format this patch targets) will fall back to raw [{"type":"text",...}] text instead of plain assistant output. This creates a size-dependent regression where short answers are fixed but long-form answers or accumulated streamed completions are still corrupted.

Useful? React with 👍 / 👎.

):
try:
# First try standard JSON parsing
parsed = json.loads(check_content)
except json.JSONDecodeError:
# If that fails, try parsing as Python literal (handles single quotes)
# This is safer than blind replace("'", '"') which corrupts apostrophes
try:
import ast

parsed = ast.literal_eval(check_content)
except (ValueError, SyntaxError):
parsed = None

if isinstance(parsed, list):
# Only convert if it matches OpenAI content-part schema
# i.e., at least one item has {'type': 'text', 'text': ...}
has_content_part = any(
isinstance(part, dict) and part.get("type") == "text"
for part in parsed
)
if has_content_part:
text_parts = []
for part in parsed:
if isinstance(part, dict) and part.get("type") == "text":
text_val = part.get("text", "")
# Coerce to str in case text is null or non-string
text_parts.append(
str(text_val) if text_val is not None else ""
)
if text_parts:
return "".join(text_parts)
return content

return str(raw_content)

async def _parse_openai_completion(
self, completion: ChatCompletion, tools: ToolSet | None
) -> LLMResponse:
Expand All @@ -383,8 +464,7 @@ async def _parse_openai_completion(

# parse the text completion
if choice.message.content is not None:
# text completion
completion_text = str(choice.message.content).strip()
completion_text = self._normalize_content(choice.message.content)
# specially, some providers may set <think> tags around reasoning content in the completion text,
# we use regex to remove them, and store then in reasoning_content field
reasoning_pattern = re.compile(r"<think>(.*?)</think>", re.DOTALL)
Expand All @@ -394,6 +474,8 @@ async def _parse_openai_completion(
[match.strip() for match in matches],
)
completion_text = reasoning_pattern.sub("", completion_text).strip()
# Also clean up orphan </think> tags that may leak from some models
completion_text = re.sub(r"</think>\s*$", "", completion_text).strip()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict orphan stripping to leakage cases

The new unconditional </think> tail removal truncates valid model output whenever a response intentionally ends with that literal tag (for example XML/HTML or prompt-echoed text), because _parse_openai_completion now deletes it even when no leaked <think>...</think> reasoning block exists. This silently alters user-visible content and should be gated to confirmed leakage scenarios.

Useful? React with 👍 / 👎.

llm_response.result_chain = MessageChain().message(completion_text)

# parse the reasoning content if any
Expand Down