From 4d2c2f5873f7801c73c718fb3e5e7cafc724957f Mon Sep 17 00:00:00 2001 From: bugkeep <1921817430@qq.com> Date: Fri, 24 Apr 2026 15:39:20 +0800 Subject: [PATCH 1/2] fix: dedupe duplicated tool call fields --- .../core/provider/sources/openai_source.py | 25 +++++++++-- tests/test_openai_source.py | 43 +++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index f2d9474906..cc7649cb42 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -842,6 +842,15 @@ async def _parse_openai_completion( # the priority is higher than the tag extraction llm_response.reasoning_content = self._extract_reasoning_content(completion) + # Some OpenAI-compatible proxies may duplicate streaming chunks, causing tool call fields + # (e.g., id/name) to become self-concatenated (s + s). We defensively de-duplicate those. + # See: https://github.com/AstrBotDevs/AstrBot/issues/7694 + def _dedupe_self_concatenated(value: str, *, min_len: int) -> str: + if not value or len(value) < min_len or (len(value) % 2) != 0: + return value + half = len(value) // 2 + return value[:half] if value[:half] == value[half:] else value + # parse tool calls if any if choice.message.tool_calls and tools is not None: args_ls = [] @@ -867,14 +876,24 @@ async def _parse_openai_completion( args = {} else: args = tool_call.function.arguments + tool_call_id = ( + _dedupe_self_concatenated(tool_call.id, min_len=16) + if isinstance(tool_call.id, str) + else tool_call.id + ) + tool_call_name = ( + _dedupe_self_concatenated(tool_call.function.name, min_len=8) + if isinstance(tool_call.function.name, str) + else tool_call.function.name + ) args_ls.append(args) - func_name_ls.append(tool_call.function.name) - tool_call_ids.append(tool_call.id) + func_name_ls.append(tool_call_name) + tool_call_ids.append(tool_call_id) # gemini-2.5 / gemini-3 series extra_content handling extra_content = getattr(tool_call, "extra_content", None) if extra_content is not None: - tool_call_extra_content_dict[tool_call.id] = extra_content + tool_call_extra_content_dict[tool_call_id] = extra_content llm_response.role = "tool" llm_response.tools_call_args = args_ls diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index 83e18137c4..1817559e1d 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -1176,6 +1176,49 @@ async def test_parse_openai_completion_raises_empty_model_output_error(): await provider.terminate() +@pytest.mark.asyncio +async def test_parse_openai_completion_dedupes_self_concatenated_tool_call_fields(): + provider = _make_provider() + try: + tool_call_id = "call_95fae017db5b4a91b1259aba" + tool_name = "astr_kb_search" + completion = ChatCompletion.model_validate( + { + "id": "chatcmpl-toolcall-dup", + "object": "chat.completion", + "created": 0, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": None, + "refusal": None, + "tool_calls": [ + { + "id": tool_call_id + tool_call_id, + "type": "function", + "function": { + "name": tool_name + tool_name, + "arguments": "{}", + }, + } + ], + }, + "finish_reason": "tool_calls", + } + ], + } + ) + + llm_response = await provider._parse_openai_completion(completion, tools=object()) + assert llm_response.tools_call_ids == [tool_call_id] + assert llm_response.tools_call_name == [tool_name] + finally: + await provider.terminate() + + @pytest.mark.asyncio async def test_query_stream_extracts_usage_from_empty_choices_chunk(monkeypatch): provider = _make_provider() From 7aafe5ed6280c5feb963ea0265f6e01952bc6531 Mon Sep 17 00:00:00 2001 From: bugkeep <1921817430@qq.com> Date: Fri, 24 Apr 2026 20:35:09 +0800 Subject: [PATCH 2/2] refactor: extract tool call dedupe helper --- .../core/provider/sources/openai_source.py | 28 +++-- tests/test_openai_source.py | 110 ++++++++++++------ 2 files changed, 94 insertions(+), 44 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index cc7649cb42..e6ec9acc2d 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -55,6 +55,8 @@ ) class ProviderOpenAIOfficial(Provider): _ERROR_TEXT_CANDIDATE_MAX_CHARS = 4096 + _TOOL_CALL_ID_DEDUPE_MIN_LEN = 16 + _TOOL_CALL_NAME_DEDUPE_MIN_LEN = 8 @classmethod def _truncate_error_text_candidate(cls, text: str) -> str: @@ -69,6 +71,13 @@ def _safe_json_dump(value: Any) -> str | None: except Exception: return None + @staticmethod + def _dedupe_self_concatenated(value: str, *, min_len: int) -> str: + if not value or len(value) < min_len or (len(value) % 2) != 0: + return value + half = len(value) // 2 + return value[:half] if value[:half] == value[half:] else value + def _get_image_moderation_error_patterns(self) -> list[str]: """Return configured moderation patterns (case-insensitive substring match, not regex).""" configured = self.provider_config.get("image_moderation_error_patterns", []) @@ -842,15 +851,6 @@ async def _parse_openai_completion( # the priority is higher than the tag extraction llm_response.reasoning_content = self._extract_reasoning_content(completion) - # Some OpenAI-compatible proxies may duplicate streaming chunks, causing tool call fields - # (e.g., id/name) to become self-concatenated (s + s). We defensively de-duplicate those. - # See: https://github.com/AstrBotDevs/AstrBot/issues/7694 - def _dedupe_self_concatenated(value: str, *, min_len: int) -> str: - if not value or len(value) < min_len or (len(value) % 2) != 0: - return value - half = len(value) // 2 - return value[:half] if value[:half] == value[half:] else value - # parse tool calls if any if choice.message.tool_calls and tools is not None: args_ls = [] @@ -877,12 +877,18 @@ def _dedupe_self_concatenated(value: str, *, min_len: int) -> str: else: args = tool_call.function.arguments tool_call_id = ( - _dedupe_self_concatenated(tool_call.id, min_len=16) + self._dedupe_self_concatenated( + tool_call.id, + min_len=self._TOOL_CALL_ID_DEDUPE_MIN_LEN, + ) if isinstance(tool_call.id, str) else tool_call.id ) tool_call_name = ( - _dedupe_self_concatenated(tool_call.function.name, min_len=8) + self._dedupe_self_concatenated( + tool_call.function.name, + min_len=self._TOOL_CALL_NAME_DEDUPE_MIN_LEN, + ) if isinstance(tool_call.function.name, str) else tool_call.function.name ) diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index 1817559e1d..c1ec56f7db 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -52,6 +52,43 @@ def _make_groq_provider(overrides: dict | None = None) -> ProviderGroq: ) +def _make_tool_call_completion( + tool_call_id: str, + tool_name: str, + *, + completion_id: str, +) -> ChatCompletion: + return ChatCompletion.model_validate( + { + "id": completion_id, + "object": "chat.completion", + "created": 0, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": None, + "refusal": None, + "tool_calls": [ + { + "id": tool_call_id, + "type": "function", + "function": { + "name": tool_name, + "arguments": "{}", + }, + } + ], + }, + "finish_reason": "tool_calls", + } + ], + } + ) + + @pytest.mark.asyncio async def test_handle_api_error_content_moderated_removes_images(): provider = _make_provider( @@ -1177,44 +1214,51 @@ async def test_parse_openai_completion_raises_empty_model_output_error(): @pytest.mark.asyncio -async def test_parse_openai_completion_dedupes_self_concatenated_tool_call_fields(): +@pytest.mark.parametrize( + ("completion_id", "raw_tool_call_id", "raw_tool_name", "expected_id", "expected_name"), + [ + ( + "chatcmpl-toolcall-dup-id-only", + "call_95fae017db5b4a91b1259abacall_95fae017db5b4a91b1259aba", + "astr_kb_search", + "call_95fae017db5b4a91b1259aba", + "astr_kb_search", + ), + ( + "chatcmpl-toolcall-dup-name-only", + "call_95fae017db5b4a91b1259aba", + "astr_kb_searchastr_kb_search", + "call_95fae017db5b4a91b1259aba", + "astr_kb_search", + ), + ( + "chatcmpl-toolcall-dup-both", + "call_95fae017db5b4a91b1259abacall_95fae017db5b4a91b1259aba", + "astr_kb_searchastr_kb_search", + "call_95fae017db5b4a91b1259aba", + "astr_kb_search", + ), + ], + ids=["id-only", "name-only", "both-fields"], +) +async def test_parse_openai_completion_dedupes_self_concatenated_tool_call_fields( + completion_id: str, + raw_tool_call_id: str, + raw_tool_name: str, + expected_id: str, + expected_name: str, +): provider = _make_provider() try: - tool_call_id = "call_95fae017db5b4a91b1259aba" - tool_name = "astr_kb_search" - completion = ChatCompletion.model_validate( - { - "id": "chatcmpl-toolcall-dup", - "object": "chat.completion", - "created": 0, - "model": "gpt-4o-mini", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": None, - "refusal": None, - "tool_calls": [ - { - "id": tool_call_id + tool_call_id, - "type": "function", - "function": { - "name": tool_name + tool_name, - "arguments": "{}", - }, - } - ], - }, - "finish_reason": "tool_calls", - } - ], - } + completion = _make_tool_call_completion( + raw_tool_call_id, + raw_tool_name, + completion_id=completion_id, ) llm_response = await provider._parse_openai_completion(completion, tools=object()) - assert llm_response.tools_call_ids == [tool_call_id] - assert llm_response.tools_call_name == [tool_name] + assert llm_response.tools_call_ids == [expected_id] + assert llm_response.tools_call_name == [expected_name] finally: await provider.terminate()