Skip to content
Open
Show file tree
Hide file tree
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
12 changes: 7 additions & 5 deletions astrbot/core/provider/sources/openai_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -848,17 +848,19 @@ async def _parse_openai_completion(
# parse the text completion
if choice.message.content is not None:
completion_text = self._normalize_content(choice.message.content)
# specially, some providers may set <think> tags around reasoning content in the completion text,
# Some compatible providers wrap reasoning in <think> or <thinking> tags.
# we use regex to remove them, and store then in reasoning_content field
reasoning_pattern = re.compile(r"<think>(.*?)</think>", re.DOTALL)
reasoning_pattern = re.compile(r"<(think|thinking)>(.*?)</\1>", re.DOTALL)
matches = reasoning_pattern.findall(completion_text)
if matches:
llm_response.reasoning_content = "\n".join(
[match.strip() for match in matches],
[match[1].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()
# Also clean up trailing reasoning closing tags from some models.
completion_text = re.sub(
r"</(?:think|thinking)>\s*$", "", completion_text
).strip()
llm_response.result_chain = MessageChain().message(completion_text)
elif refusal := getattr(choice.message, "refusal", None):
refusal_text = self._normalize_content(refusal)
Expand Down
69 changes: 69 additions & 0 deletions tests/test_openai_thinking_tags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import pytest
from openai.types.chat.chat_completion import ChatCompletion

from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial


@pytest.mark.asyncio
@pytest.mark.parametrize(
("content", "reasoning_field", "answer", "reasoning"),
[
("<think>First\nsecond</think>Answer", None, "Answer", "First\nsecond"),
("<thinking>First\nsecond</thinking>Answer", None, "Answer", "First\nsecond"),
("<thinking></thinking>Answer", None, "Answer", ""),
("<thinking>Only reasoning</thinking>", None, "", "Only reasoning"),
(
"<think>One</think><thinking>Two</thinking>Answer",
None,
"Answer",
"One\nTwo",
),
("Answer</think>", None, "Answer", None),
("Answer</thinking> ", None, "Answer", None),
(
"Ordinary thinking and reasoning",
None,
"Ordinary thinking and reasoning",
None,
),
("<thinker>Example</thinker>", None, "<thinker>Example</thinker>", None),
("<thinking>Inline</thinking>Answer", "Structured", "Answer", "Structured"),
("<thinking>Inline</thinking>Answer", "", "Answer", ""),
("<thinking>Unfinished", None, "<thinking>Unfinished", None),
(
"<think>Example</thinking>Answer",
None,
"<think>Example</thinking>Answer",
None,
),
],
)
async def test_openai_reasoning_tag_compatibility(
content, reasoning_field, answer, reasoning
):
provider = ProviderOpenAIOfficial.__new__(ProviderOpenAIOfficial)
provider.reasoning_key = "reasoning_content"
message = {"role": "assistant", "content": content}
if reasoning_field is not None:
message["reasoning_content"] = reasoning_field
completion = ChatCompletion.model_validate(
{
"id": "reasoning-tags",
"object": "chat.completion",
"created": 0,
"model": "test-model",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": message,
}
],
}
)
response = await provider._parse_openai_completion(completion, None)
assert response.completion_text == answer
if reasoning is not None:
assert response.reasoning_content == reasoning
else:
assert not response.reasoning_content