From 36f4e93bcd1fd114c09a791e26466d7ef40fd4aa Mon Sep 17 00:00:00 2001 From: Blueteemo Date: Thu, 23 Apr 2026 05:12:01 +0800 Subject: [PATCH 1/6] feat: add tool call name/id deduplication to fix streaming chunk duplication --- .../core/provider/sources/openai_source.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 67971a2a93..e91eedcdc9 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -62,6 +62,17 @@ def _truncate_error_text_candidate(cls, text: str) -> str: return text return text[: cls._ERROR_TEXT_CANDIDATE_MAX_CHARS] + @staticmethod + def _deduplicate_self_repeating(value: str) -> str: + """If string is a self-repeating pattern like 'abcabc', return 'abc'. + This handles streaming chunk duplication issues (e.g. 'call_xxxcall_xxx').""" + if not value or len(value) < 4: + return value + half = len(value) // 2 + if value[:half] == value[half:]: + return value[:half] + return value + @staticmethod def _safe_json_dump(value: Any) -> str | None: try: @@ -866,13 +877,18 @@ async def _parse_openai_completion( else: args = tool_call.function.arguments args_ls.append(args) - func_name_ls.append(tool_call.function.name) - tool_call_ids.append(tool_call.id) + func_name_ls.append( + cls._deduplicate_self_repeating(tool_call.function.name) + ) + tool_call_ids.append( + cls._deduplicate_self_repeating(tool_call.id or "") + ) # 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 + deduped_id = cls._deduplicate_self_repeating(tool_call.id or "") + tool_call_extra_content_dict[deduped_id] = extra_content llm_response.role = "tool" llm_response.tools_call_args = args_ls From 34caabefd8e310fce182670949c0ef6f1d42f41f Mon Sep 17 00:00:00 2001 From: Blueteemo Date: Thu, 23 Apr 2026 05:21:24 +0800 Subject: [PATCH 2/6] fix: address review feedback - cls to self, handle None, handle arbitrary repetitions, dedupe arguments --- .../core/provider/sources/openai_source.py | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index e91eedcdc9..4eb56391d1 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -63,14 +63,23 @@ def _truncate_error_text_candidate(cls, text: str) -> str: return text[: cls._ERROR_TEXT_CANDIDATE_MAX_CHARS] @staticmethod - def _deduplicate_self_repeating(value: str) -> str: - """If string is a self-repeating pattern like 'abcabc', return 'abc'. - This handles streaming chunk duplication issues (e.g. 'call_xxxcall_xxx').""" + def _deduplicate_self_repeating(value: str | None) -> str | None: + """If string is a self-repeating pattern like 'abcabc' or 'abcabcabc', return the base unit. + This handles streaming chunk duplication issues (e.g. 'call_xxxcall_xxx'). + Returns None unchanged.""" + if value is None: + return None if not value or len(value) < 4: return value + # Handle arbitrary repetitions by finding the smallest repeating unit half = len(value) // 2 - if value[:half] == value[half:]: - return value[:half] + for unit_size in range(1, half + 1): + if len(value) % unit_size != 0: + continue + unit = value[:unit_size] + repetitions = len(value) // unit_size + if unit * repetitions == value and repetitions >= 2: + return unit return value @staticmethod @@ -869,8 +878,11 @@ async def _parse_openai_completion( if tool_call.type == "function": # workaround for #1454 if isinstance(tool_call.function.arguments, str): + deduped_args = self._deduplicate_self_repeating( + tool_call.function.arguments + ) try: - args = json.loads(tool_call.function.arguments) + args = json.loads(deduped_args) except json.JSONDecodeError as e: logger.error(f"解析参数失败: {e}") args = {} @@ -878,17 +890,16 @@ async def _parse_openai_completion( args = tool_call.function.arguments args_ls.append(args) func_name_ls.append( - cls._deduplicate_self_repeating(tool_call.function.name) - ) - tool_call_ids.append( - cls._deduplicate_self_repeating(tool_call.id or "") + self._deduplicate_self_repeating(tool_call.function.name) ) + tool_call_ids.append(self._deduplicate_self_repeating(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: - deduped_id = cls._deduplicate_self_repeating(tool_call.id or "") - tool_call_extra_content_dict[deduped_id] = extra_content + deduped_id = self._deduplicate_self_repeating(tool_call.id) + if deduped_id is not None: + tool_call_extra_content_dict[deduped_id] = extra_content llm_response.role = "tool" llm_response.tools_call_args = args_ls From 75ac5af507fc2cb0503d2d2d716239690c3ef441 Mon Sep 17 00:00:00 2001 From: Blueteemo Date: Thu, 23 Apr 2026 05:52:31 +0800 Subject: [PATCH 3/6] fix: reuse deduplicated id once instead of recomputing --- astrbot/core/provider/sources/openai_source.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 4eb56391d1..d2f56a2e95 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -892,14 +892,13 @@ async def _parse_openai_completion( func_name_ls.append( self._deduplicate_self_repeating(tool_call.function.name) ) - tool_call_ids.append(self._deduplicate_self_repeating(tool_call.id)) + deduped_id = self._deduplicate_self_repeating(tool_call.id) + tool_call_ids.append(deduped_id) # gemini-2.5 / gemini-3 series extra_content handling extra_content = getattr(tool_call, "extra_content", None) - if extra_content is not None: - deduped_id = self._deduplicate_self_repeating(tool_call.id) - if deduped_id is not None: - tool_call_extra_content_dict[deduped_id] = extra_content + if extra_content is not None and deduped_id is not None: + tool_call_extra_content_dict[deduped_id] = extra_content llm_response.role = "tool" llm_response.tools_call_args = args_ls From d5204b6a43bd177683ccda9b6f1e89bb986f78c0 Mon Sep 17 00:00:00 2001 From: Blueteemo Date: Thu, 23 Apr 2026 09:55:56 +0800 Subject: [PATCH 4/6] fix: simplify deduplication to only handle exact double repetitions --- astrbot/core/provider/sources/openai_source.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index d2f56a2e95..ad55bd98cb 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -64,22 +64,16 @@ def _truncate_error_text_candidate(cls, text: str) -> str: @staticmethod def _deduplicate_self_repeating(value: str | None) -> str | None: - """If string is a self-repeating pattern like 'abcabc' or 'abcabcabc', return the base unit. - This handles streaming chunk duplication issues (e.g. 'call_xxxcall_xxx'). + """If string is a self-repeating pattern like 'abcabc' (exactly 2 repetitions), + return the base unit. This handles streaming chunk duplication issues. Returns None unchanged.""" if value is None: return None if not value or len(value) < 4: return value - # Handle arbitrary repetitions by finding the smallest repeating unit half = len(value) // 2 - for unit_size in range(1, half + 1): - if len(value) % unit_size != 0: - continue - unit = value[:unit_size] - repetitions = len(value) // unit_size - if unit * repetitions == value and repetitions >= 2: - return unit + if value[:half] == value[half:]: + return value[:half] return value @staticmethod From 94b1449777b0290a31bc53a8457494e6cc28787a Mon Sep 17 00:00:00 2001 From: Blueteemo Date: Thu, 23 Apr 2026 13:56:48 +0800 Subject: [PATCH 5/6] fix: add min_length=20 to avoid affecting short tool names like pingping --- astrbot/core/provider/sources/openai_source.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index ad55bd98cb..869442f751 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -63,13 +63,14 @@ def _truncate_error_text_candidate(cls, text: str) -> str: return text[: cls._ERROR_TEXT_CANDIDATE_MAX_CHARS] @staticmethod - def _deduplicate_self_repeating(value: str | None) -> str | None: - """If string is a self-repeating pattern like 'abcabc' (exactly 2 repetitions), - return the base unit. This handles streaming chunk duplication issues. + def _deduplicate_self_repeating(value: str | None, min_length: int = 20) -> str | None: + """If string is a self-repeating pattern like 'astr_kb_searchastr_kb_search' + (exactly 2 repetitions, min 20 chars), return the base unit. + This handles streaming chunk duplication issues for tool names/IDs. Returns None unchanged.""" if value is None: return None - if not value or len(value) < 4: + if not value or len(value) < min_length: return value half = len(value) // 2 if value[:half] == value[half:]: From e32997193c69785e92831945f888311b7d08e1f0 Mon Sep 17 00:00:00 2001 From: Blueteemo Date: Thu, 23 Apr 2026 13:59:31 +0800 Subject: [PATCH 6/6] style: apply ruff formatting --- astrbot/core/provider/sources/openai_source.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 869442f751..e2933a487b 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -63,7 +63,9 @@ def _truncate_error_text_candidate(cls, text: str) -> str: return text[: cls._ERROR_TEXT_CANDIDATE_MAX_CHARS] @staticmethod - def _deduplicate_self_repeating(value: str | None, min_length: int = 20) -> str | None: + def _deduplicate_self_repeating( + value: str | None, min_length: int = 20 + ) -> str | None: """If string is a self-repeating pattern like 'astr_kb_searchastr_kb_search' (exactly 2 repetitions, min 20 chars), return the base unit. This handles streaming chunk duplication issues for tool names/IDs.