Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions astrbot/core/provider/sources/openai_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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", [])
Expand Down Expand Up @@ -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
Expand Down
141 changes: 84 additions & 57 deletions tests/test_openai_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading