diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index fbcef1a80c..a08c3d2a4e 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", []) @@ -901,17 +910,30 @@ async def _parse_openai_completion( args = {} else: args = tool_call.function.arguments - # Some API may return None for tools with no parameters - if args is None: - args = {} + tool_call_id = ( + 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 = ( + 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 + ) 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 950e2ea162..123a7d9e35 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -54,64 +54,41 @@ def _make_groq_provider(overrides: dict | None = None) -> ProviderGroq: ) -def test_create_http_client_uses_openai_httpx_module(monkeypatch): - captured: dict[str, object] = {} - - def fake_create_proxy_client( - provider_label: str, - proxy: str | None = None, - headers: dict[str, str] | None = None, - verify=None, - httpx_module=None, - ): - captured["httpx_module"] = httpx_module - return object() - - monkeypatch.setattr( - openai_source_module, - "create_proxy_client", - fake_create_proxy_client, - ) - - provider = ProviderOpenAIOfficial.__new__(ProviderOpenAIOfficial) - provider._create_http_client({"proxy": ""}) - - from openai import _base_client as openai_base_client - - assert captured["httpx_module"] is openai_base_client.httpx - - -def test_create_http_client_falls_back_to_global_httpx_module(monkeypatch): - captured: dict[str, object] = {} - - def fake_create_proxy_client( - provider_label: str, - proxy: str | None = None, - headers: dict[str, str] | None = None, - verify=None, - httpx_module=None, - ): - captured["httpx_module"] = httpx_module - return object() - - real_import = builtins.__import__ - - def fake_import(name, globals=None, locals=None, fromlist=(), level=0): - if name == "openai" and fromlist: - raise ImportError("missing openai._base_client") - return real_import(name, globals, locals, fromlist, level) - - monkeypatch.setattr( - openai_source_module, - "create_proxy_client", - fake_create_proxy_client, +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", + } + ], + } ) - monkeypatch.setattr(builtins, "__import__", fake_import) - - provider = ProviderOpenAIOfficial.__new__(ProviderOpenAIOfficial) - provider._create_http_client({"proxy": ""}) - - assert captured["httpx_module"] is openai_source_module.httpx @pytest.mark.asyncio @@ -1238,6 +1215,56 @@ async def test_parse_openai_completion_raises_empty_model_output_error(): await provider.terminate() +@pytest.mark.asyncio +@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: + 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 == [expected_id] + assert llm_response.tools_call_name == [expected_name] + finally: + await provider.terminate() + + @pytest.mark.asyncio async def test_query_stream_extracts_usage_from_empty_choices_chunk(monkeypatch): provider = _make_provider()