diff --git a/src/llm/providers/astraflow.py b/src/llm/providers/astraflow.py index 1f7cceab9..2bcc6d6b2 100644 --- a/src/llm/providers/astraflow.py +++ b/src/llm/providers/astraflow.py @@ -16,6 +16,7 @@ from llm.core.interface import ( ) from llm.core.types import LLMInput, LLMOutput, ModelInfo, ProviderType, ToolCall from llm.providers.constants import EMPTY_FILTERED_RESPONSE_ERROR +from llm.providers.openai_compat import to_openai_message_param ASTRAFLOW_BASE_URL = "https://api.umodelverse.ai/v1" ASTRAFLOW_CN_BASE_URL = "https://api.modelverse.cn/v1" @@ -70,7 +71,7 @@ class _AstraflowBaseProvider(LLMProvider): try: params: dict[str, Any] = { "model": llm_input.model or self.default_model, - "messages": [msg.to_dict() for msg in llm_input.messages], + "messages": [to_openai_message_param(msg) for msg in llm_input.messages], } if llm_input.temperature != 1.0: params["temperature"] = llm_input.temperature diff --git a/src/llm/providers/atlas.py b/src/llm/providers/atlas.py index b266bb614..d626509cc 100644 --- a/src/llm/providers/atlas.py +++ b/src/llm/providers/atlas.py @@ -16,6 +16,7 @@ from llm.core.interface import ( ) from llm.core.types import LLMInput, LLMOutput, ModelInfo, ProviderType, ToolCall from llm.providers.constants import EMPTY_FILTERED_RESPONSE_ERROR +from llm.providers.openai_compat import to_openai_message_param ATLAS_BASE_URL = "https://api.atlascloud.ai/v1" DEFAULT_ATLAS_MODEL = "deepseek-ai/deepseek-v4-pro" @@ -83,7 +84,7 @@ class AtlasProvider(LLMProvider): try: params: dict[str, Any] = { "model": llm_input.model or self.default_model, - "messages": [msg.to_dict() for msg in llm_input.messages], + "messages": [to_openai_message_param(msg) for msg in llm_input.messages], } if llm_input.temperature != 1.0: params["temperature"] = llm_input.temperature diff --git a/src/llm/providers/openai.py b/src/llm/providers/openai.py index 0bf84a33d..7d00d333c 100644 --- a/src/llm/providers/openai.py +++ b/src/llm/providers/openai.py @@ -22,6 +22,7 @@ from llm.core.types import ( ToolCall, ) from llm.providers.constants import EMPTY_FILTERED_RESPONSE_ERROR +from llm.providers.openai_compat import to_openai_message_param class OpenAIProvider(LLMProvider): @@ -72,7 +73,7 @@ class OpenAIProvider(LLMProvider): try: params: dict[str, Any] = { "model": input.model or "gpt-4o-mini", - "messages": [msg.to_dict() for msg in input.messages], + "messages": [to_openai_message_param(msg) for msg in input.messages], "temperature": input.temperature, } if input.max_tokens: diff --git a/src/llm/providers/openai_compat.py b/src/llm/providers/openai_compat.py new file mode 100644 index 000000000..e1da6069e --- /dev/null +++ b/src/llm/providers/openai_compat.py @@ -0,0 +1,84 @@ +"""Message adapters shared by OpenAI-compatible providers.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from llm.core.types import Message, Role, ToolCall + +if TYPE_CHECKING: + from openai.types.chat import ( + ChatCompletionAssistantMessageParam, + ChatCompletionMessageParam, + ChatCompletionMessageToolCallParam, + ChatCompletionSystemMessageParam, + ChatCompletionToolMessageParam, + ChatCompletionUserMessageParam, + ) + + +def _to_openai_tool_call_param( + tool_call: ToolCall, +) -> ChatCompletionMessageToolCallParam: + """Convert a domain tool call to the OpenAI request schema.""" + try: + arguments = json.dumps(tool_call.arguments, allow_nan=False) + except (TypeError, ValueError) as exc: + raise ValueError("OpenAI tool call arguments must be valid JSON") from exc + + return { + "id": tool_call.id, + "type": "function", + "function": { + "name": tool_call.name, + "arguments": arguments, + }, + } + + +def to_openai_message_param(message: Message) -> ChatCompletionMessageParam: + """Convert a domain message to the OpenAI chat-completions schema.""" + if message.role == Role.SYSTEM: + system_message: ChatCompletionSystemMessageParam = { + "role": "system", + "content": message.content, + } + if message.name: + system_message["name"] = message.name + return system_message + + if message.role == Role.USER: + user_message: ChatCompletionUserMessageParam = { + "role": "user", + "content": message.content, + } + if message.name: + user_message["name"] = message.name + return user_message + + if message.role == Role.ASSISTANT: + assistant_message: ChatCompletionAssistantMessageParam = { + "role": "assistant", + "content": message.content, + } + if message.name: + assistant_message["name"] = message.name + if message.tool_calls: + assistant_message["tool_calls"] = [ + _to_openai_tool_call_param(tool_call) + for tool_call in message.tool_calls + ] + return assistant_message + + if message.role == Role.TOOL: + if not message.tool_call_id: + raise ValueError("OpenAI tool messages require a tool_call_id") + tool_message: ChatCompletionToolMessageParam = { + "role": "tool", + "content": message.content, + "tool_call_id": message.tool_call_id, + } + return tool_message + + raise ValueError(f"Unsupported OpenAI message role: {message.role}") diff --git a/tests/test_provider_tools.py b/tests/test_provider_tools.py index ba4c09415..7155953f7 100644 --- a/tests/test_provider_tools.py +++ b/tests/test_provider_tools.py @@ -1,11 +1,16 @@ import json import urllib.request +from collections.abc import Callable from io import BytesIO from types import SimpleNamespace +from typing import Any, cast import pytest -from llm.core.types import LLMInput, Message, Role, ToolDefinition +from llm.core.interface import LLMProvider +from llm.core.types import LLMInput, Message, Role, ToolCall, ToolDefinition +from llm.providers.astraflow import AstraflowProvider +from llm.providers.atlas import AtlasProvider from llm.providers.claude import ClaudeProvider from llm.providers.constants import EMPTY_FILTERED_RESPONSE_ERROR from llm.providers.ollama import OllamaProvider @@ -88,6 +93,94 @@ def test_openai_provider_serializes_tools_for_chat_completions(): ] +@pytest.mark.unit +@pytest.mark.parametrize( + "provider_type", + [OpenAIProvider, AstraflowProvider, AtlasProvider], +) +def test_openai_compatible_provider_serializes_assistant_tool_calls( + provider_type: Callable[..., LLMProvider], +) -> None: + provider = cast(Any, provider_type(api_key="test")) + client = _OpenAIClient() + provider.client = client + + provider.generate( + LLMInput( + messages=[ + Message(role=Role.USER, content="Find ECC"), + Message( + role=Role.ASSISTANT, + content="", + tool_calls=[ + ToolCall( + id="call_1", + name="search", + arguments={"query": "ecc"}, + ) + ], + ), + Message( + role=Role.TOOL, + content="result", + tool_call_id="call_1", + ), + ] + ) + ) + + assert client.completions.params is not None + assert client.completions.params["messages"] == [ + {"role": "user", "content": "Find ECC"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "search", + "arguments": '{"query": "ecc"}', + }, + }, + ], + }, + {"role": "tool", "content": "result", "tool_call_id": "call_1"}, + ] + + submitted_params = client.completions.params + with pytest.raises(ValueError, match="tool messages require a tool_call_id"): + provider.generate( + LLMInput(messages=[Message(role=Role.TOOL, content="unlinked result")]) + ) + assert client.completions.params is submitted_params + + for invalid_arguments in ({"score": float("nan")}, {"value": object()}): + with pytest.raises( + ValueError, + match="tool call arguments must be valid JSON", + ): + provider.generate( + LLMInput( + messages=[ + Message( + role=Role.ASSISTANT, + content="", + tool_calls=[ + ToolCall( + id="call_invalid", + name="score", + arguments=invalid_arguments, + ) + ], + ) + ] + ) + ) + assert client.completions.params is submitted_params + + def test_openai_provider_can_be_constructed_without_credentials(monkeypatch): monkeypatch.delenv("OPENAI_API_KEY", raising=False)