From 5acdd68b4ef487f671d015a50a53cc9b5dc31b8b Mon Sep 17 00:00:00 2001 From: konodiodaaaaa1 Date: Thu, 17 Sep 2026 16:38:32 +0800 Subject: [PATCH] fix(provider): recognize thinking tags in compatible completions --- .../core/provider/sources/openai_source.py | 12 ++-- tests/test_openai_thinking_tags.py | 69 +++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 tests/test_openai_thinking_tags.py 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)>(.*?)", 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"\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