diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py
index 15fd6b72f4..c90060224b 100644
--- a/astrbot/core/provider/sources/openai_source.py
+++ b/astrbot/core/provider/sources/openai_source.py
@@ -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 tags around reasoning content in the completion text,
+ # Some compatible providers wrap reasoning in or tags.
# we use regex to remove them, and store then in reasoning_content field
- reasoning_pattern = re.compile(r"(.*?)", 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 tags that may leak from some models
- completion_text = re.sub(r"\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)
diff --git a/tests/test_openai_thinking_tags.py b/tests/test_openai_thinking_tags.py
new file mode 100644
index 0000000000..ae88f770e5
--- /dev/null
+++ b/tests/test_openai_thinking_tags.py
@@ -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"),
+ [
+ ("First\nsecondAnswer", None, "Answer", "First\nsecond"),
+ ("First\nsecondAnswer", None, "Answer", "First\nsecond"),
+ ("Answer", None, "Answer", ""),
+ ("Only reasoning", None, "", "Only reasoning"),
+ (
+ "OneTwoAnswer",
+ None,
+ "Answer",
+ "One\nTwo",
+ ),
+ ("Answer", None, "Answer", None),
+ ("Answer ", None, "Answer", None),
+ (
+ "Ordinary thinking and reasoning",
+ None,
+ "Ordinary thinking and reasoning",
+ None,
+ ),
+ ("Example", None, "Example", None),
+ ("InlineAnswer", "Structured", "Answer", "Structured"),
+ ("InlineAnswer", "", "Answer", ""),
+ ("Unfinished", None, "Unfinished", None),
+ (
+ "ExampleAnswer",
+ None,
+ "ExampleAnswer",
+ 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