참고소스 수정본
This commit is contained in:
977
참고/neo4j-graphrag-python-main/tests/unit/llm/test_openai_llm.py
Normal file
977
참고/neo4j-graphrag-python-main/tests/unit/llm/test_openai_llm.py
Normal file
@@ -0,0 +1,977 @@
|
||||
# Copyright (c) "Neo4j"
|
||||
# Neo4j Sweden AB [https://neo4j.com]
|
||||
# #
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# #
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
# #
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
import warnings
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
from typing import Any, Callable, List
|
||||
import builtins
|
||||
|
||||
import httpx
|
||||
import openai
|
||||
import pytest
|
||||
from neo4j_graphrag.exceptions import LLMGenerationError
|
||||
from neo4j_graphrag.llm.types import LLMResponse
|
||||
from neo4j_graphrag.llm.openai_llm import AzureOpenAILLM, OpenAILLM
|
||||
from neo4j_graphrag.llm.types import ToolCallResponse
|
||||
from neo4j_graphrag.tool import Tool
|
||||
from neo4j_graphrag.types import LLMMessage
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
# Save the original __import__ before any patches are applied
|
||||
_original_import = builtins.__import__
|
||||
|
||||
|
||||
def get_mock_openai() -> MagicMock:
|
||||
mock = MagicMock()
|
||||
mock.OpenAIError = openai.OpenAIError
|
||||
mock.httpx = httpx
|
||||
return mock
|
||||
|
||||
|
||||
def create_selective_import_mock(mock_openai: MagicMock) -> Callable[..., Any]:
|
||||
"""Create a mock that only intercepts 'openai' imports, letting others pass through."""
|
||||
|
||||
def selective_import(name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
if name == "openai":
|
||||
return mock_openai
|
||||
return _original_import(name, *args, **kwargs)
|
||||
|
||||
return selective_import
|
||||
|
||||
|
||||
@patch("builtins.__import__", side_effect=ImportError)
|
||||
def test_openai_llm_missing_dependency(_mock_import: Mock) -> None:
|
||||
with pytest.raises(ImportError):
|
||||
OpenAILLM(model_name="gpt-5")
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_openai_llm_happy_path(mock_import: Mock) -> None:
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
mock_openai.OpenAI.return_value.chat.completions.create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content="openai chat response"))],
|
||||
)
|
||||
llm = OpenAILLM(api_key="my key", model_name="gpt")
|
||||
|
||||
res = llm.invoke("my text")
|
||||
assert isinstance(res, LLMResponse)
|
||||
assert res.content == "openai chat response"
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_openai_llm_with_message_history_happy_path(mock_import: Mock) -> None:
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
mock_openai.OpenAI.return_value.chat.completions.create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content="openai chat response"))],
|
||||
)
|
||||
llm = OpenAILLM(api_key="my key", model_name="gpt")
|
||||
message_history = [
|
||||
{"role": "user", "content": "When does the sun come up in the summer?"},
|
||||
{"role": "assistant", "content": "Usually around 6am."},
|
||||
]
|
||||
question = "What about next season?"
|
||||
|
||||
res = llm.invoke(question, message_history) # type: ignore
|
||||
assert isinstance(res, LLMResponse)
|
||||
assert res.content == "openai chat response"
|
||||
message_history.append({"role": "user", "content": question})
|
||||
# Use assert_called_once() instead of assert_called_once_with() to avoid issues with overloaded functions
|
||||
llm.client.chat.completions.create.assert_called_once() # type: ignore
|
||||
# Check call arguments individually
|
||||
call_args = llm.client.chat.completions.create.call_args[ # type: ignore
|
||||
1
|
||||
] # Get the keyword arguments
|
||||
assert call_args["messages"] == message_history
|
||||
assert call_args["model"] == "gpt"
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_openai_llm_with_message_history_and_system_instruction(
|
||||
mock_import: Mock,
|
||||
) -> None:
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
mock_openai.OpenAI.return_value.chat.completions.create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content="openai chat response"))],
|
||||
)
|
||||
system_instruction = "You are a helpful assistent."
|
||||
llm = OpenAILLM(
|
||||
api_key="my key",
|
||||
model_name="gpt",
|
||||
)
|
||||
message_history = [
|
||||
{"role": "user", "content": "When does the sun come up in the summer?"},
|
||||
{"role": "assistant", "content": "Usually around 6am."},
|
||||
]
|
||||
question = "What about next season?"
|
||||
|
||||
res = llm.invoke(question, message_history, system_instruction=system_instruction) # type: ignore
|
||||
assert isinstance(res, LLMResponse)
|
||||
assert res.content == "openai chat response"
|
||||
messages = [{"role": "system", "content": system_instruction}]
|
||||
messages.extend(message_history)
|
||||
messages.append({"role": "user", "content": question})
|
||||
# Use assert_called_once() instead of assert_called_once_with() to avoid issues with overloaded functions
|
||||
llm.client.chat.completions.create.assert_called_once() # type: ignore
|
||||
# Check call arguments individually
|
||||
call_args = llm.client.chat.completions.create.call_args[ # type: ignore
|
||||
1
|
||||
] # Get the keyword arguments
|
||||
assert call_args["messages"] == messages
|
||||
assert call_args["model"] == "gpt"
|
||||
|
||||
assert llm.client.chat.completions.create.call_count == 1 # type: ignore
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_openai_llm_with_message_history_validation_error(mock_import: Mock) -> None:
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
mock_openai.OpenAI.return_value.chat.completions.create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content="openai chat response"))],
|
||||
)
|
||||
llm = OpenAILLM(api_key="my key", model_name="gpt")
|
||||
message_history = [
|
||||
{"role": "human", "content": "When does the sun come up in the summer?"},
|
||||
{"role": "assistant", "content": "Usually around 6am."},
|
||||
]
|
||||
question = "What about next season?"
|
||||
|
||||
with pytest.raises(LLMGenerationError) as exc_info:
|
||||
llm.invoke(question, message_history) # type: ignore
|
||||
assert "Input should be 'user', 'assistant' or 'system'" in str(exc_info.value)
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
@patch("json.loads")
|
||||
def test_openai_llm_invoke_with_tools_happy_path(
|
||||
mock_json_loads: Mock,
|
||||
mock_import: Mock,
|
||||
test_tool: Tool,
|
||||
) -> None:
|
||||
# Set up json.loads to return a dictionary
|
||||
mock_json_loads.return_value = {"param1": "value1"}
|
||||
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
|
||||
# Mock the tool call response
|
||||
mock_function = MagicMock()
|
||||
mock_function.name = "test_tool"
|
||||
mock_function.arguments = '{"param1": "value1"}'
|
||||
|
||||
mock_tool_call = MagicMock()
|
||||
mock_tool_call.function = mock_function
|
||||
|
||||
mock_openai.OpenAI.return_value.chat.completions.create.return_value = MagicMock(
|
||||
choices=[
|
||||
MagicMock(
|
||||
message=MagicMock(
|
||||
content="openai tool response", tool_calls=[mock_tool_call]
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
llm = OpenAILLM(api_key="my key", model_name="gpt")
|
||||
tools = [test_tool]
|
||||
|
||||
res = llm.invoke_with_tools("my text", tools)
|
||||
assert isinstance(res, ToolCallResponse)
|
||||
assert len(res.tool_calls) == 1
|
||||
assert res.tool_calls[0].name == "test_tool"
|
||||
assert res.tool_calls[0].arguments == {"param1": "value1"}
|
||||
assert res.content == "openai tool response"
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
@patch("json.loads")
|
||||
def test_openai_llm_invoke_with_tools_with_message_history(
|
||||
mock_json_loads: Mock,
|
||||
mock_import: Mock,
|
||||
test_tool: Tool,
|
||||
) -> None:
|
||||
# Set up json.loads to return a dictionary
|
||||
mock_json_loads.return_value = {"param1": "value1"}
|
||||
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
|
||||
# Mock the tool call response
|
||||
mock_function = MagicMock()
|
||||
mock_function.name = "test_tool"
|
||||
mock_function.arguments = '{"param1": "value1"}'
|
||||
|
||||
mock_tool_call = MagicMock()
|
||||
mock_tool_call.function = mock_function
|
||||
|
||||
mock_openai.OpenAI.return_value.chat.completions.create.return_value = MagicMock(
|
||||
choices=[
|
||||
MagicMock(
|
||||
message=MagicMock(
|
||||
content="openai tool response", tool_calls=[mock_tool_call]
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
llm = OpenAILLM(api_key="my key", model_name="gpt")
|
||||
tools = [test_tool]
|
||||
|
||||
message_history = [
|
||||
{"role": "user", "content": "When does the sun come up in the summer?"},
|
||||
{"role": "assistant", "content": "Usually around 6am."},
|
||||
]
|
||||
question = "What about next season?"
|
||||
|
||||
res = llm.invoke_with_tools(question, tools, message_history) # type: ignore
|
||||
assert isinstance(res, ToolCallResponse)
|
||||
assert len(res.tool_calls) == 1
|
||||
assert res.tool_calls[0].name == "test_tool"
|
||||
assert res.tool_calls[0].arguments == {"param1": "value1"}
|
||||
|
||||
# Verify the correct messages were passed
|
||||
message_history.append({"role": "user", "content": question})
|
||||
# Use assert_called_once() instead of assert_called_once_with() to avoid issues with overloaded functions
|
||||
llm.client.chat.completions.create.assert_called_once() # type: ignore
|
||||
# Check call arguments individually
|
||||
call_args = llm.client.chat.completions.create.call_args[ # type: ignore
|
||||
1
|
||||
] # Get the keyword arguments
|
||||
assert call_args["messages"] == message_history
|
||||
assert call_args["model"] == "gpt"
|
||||
# Check tools content rather than direct equality
|
||||
assert len(call_args["tools"]) == 1
|
||||
assert call_args["tools"][0]["type"] == "function"
|
||||
assert call_args["tools"][0]["function"]["name"] == "test_tool"
|
||||
assert call_args["tools"][0]["function"]["description"] == "A test tool"
|
||||
assert call_args["tool_choice"] == "auto"
|
||||
assert call_args["temperature"] == 0.0
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
@patch("json.loads")
|
||||
def test_openai_llm_invoke_with_tools_with_system_instruction(
|
||||
mock_json_loads: Mock,
|
||||
mock_import: Mock,
|
||||
test_tool: Mock,
|
||||
) -> None:
|
||||
# Set up json.loads to return a dictionary
|
||||
mock_json_loads.return_value = {"param1": "value1"}
|
||||
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
|
||||
# Mock the tool call response
|
||||
mock_function = MagicMock()
|
||||
mock_function.name = "test_tool"
|
||||
mock_function.arguments = '{"param1": "value1"}'
|
||||
|
||||
mock_tool_call = MagicMock()
|
||||
mock_tool_call.function = mock_function
|
||||
|
||||
mock_openai.OpenAI.return_value.chat.completions.create.return_value = MagicMock(
|
||||
choices=[
|
||||
MagicMock(
|
||||
message=MagicMock(
|
||||
content="openai tool response", tool_calls=[mock_tool_call]
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
llm = OpenAILLM(api_key="my key", model_name="gpt")
|
||||
tools = [test_tool]
|
||||
|
||||
system_instruction = "You are a helpful assistant."
|
||||
|
||||
res = llm.invoke_with_tools("my text", tools, system_instruction=system_instruction)
|
||||
assert isinstance(res, ToolCallResponse)
|
||||
|
||||
# Verify system instruction was included
|
||||
messages = [{"role": "system", "content": system_instruction}]
|
||||
messages.append({"role": "user", "content": "my text"})
|
||||
# Use assert_called_once() instead of assert_called_once_with() to avoid issues with overloaded functions
|
||||
llm.client.chat.completions.create.assert_called_once() # type: ignore
|
||||
# Check call arguments individually
|
||||
call_args = llm.client.chat.completions.create.call_args[ # type: ignore
|
||||
1
|
||||
] # Get the keyword arguments
|
||||
assert call_args["messages"] == messages
|
||||
assert call_args["model"] == "gpt"
|
||||
# Check tools content rather than direct equality
|
||||
assert len(call_args["tools"]) == 1
|
||||
assert call_args["tools"][0]["type"] == "function"
|
||||
assert call_args["tools"][0]["function"]["name"] == "test_tool"
|
||||
assert call_args["tools"][0]["function"]["description"] == "A test tool"
|
||||
assert call_args["tool_choice"] == "auto"
|
||||
assert call_args["temperature"] == 0.0
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_openai_llm_invoke_with_tools_error(mock_import: Mock, test_tool: Tool) -> None:
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
|
||||
# Mock an OpenAI error
|
||||
mock_openai.OpenAI.return_value.chat.completions.create.side_effect = (
|
||||
openai.OpenAIError("Test error")
|
||||
)
|
||||
|
||||
llm = OpenAILLM(api_key="my key", model_name="gpt")
|
||||
tools = [test_tool]
|
||||
|
||||
with pytest.raises(LLMGenerationError):
|
||||
llm.invoke_with_tools("my text", tools)
|
||||
|
||||
|
||||
@patch("builtins.__import__", side_effect=ImportError)
|
||||
def test_azure_openai_llm_missing_dependency(_mock_import: Mock) -> None:
|
||||
with pytest.raises(ImportError):
|
||||
AzureOpenAILLM(model_name="gpt-5")
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_azure_openai_llm_happy_path(mock_import: Mock) -> None:
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
mock_openai.AzureOpenAI.return_value.chat.completions.create.return_value = (
|
||||
MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content="openai chat response"))],
|
||||
)
|
||||
)
|
||||
llm = AzureOpenAILLM(
|
||||
model_name="gpt",
|
||||
azure_endpoint="https://test.openai.azure.com/",
|
||||
api_key="my key",
|
||||
api_version="version",
|
||||
)
|
||||
|
||||
res = llm.invoke("my text")
|
||||
assert isinstance(res, LLMResponse)
|
||||
assert res.content == "openai chat response"
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_azure_openai_llm_with_message_history_happy_path(mock_import: Mock) -> None:
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
mock_openai.AzureOpenAI.return_value.chat.completions.create.return_value = (
|
||||
MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content="openai chat response"))],
|
||||
)
|
||||
)
|
||||
llm = AzureOpenAILLM(
|
||||
model_name="gpt",
|
||||
azure_endpoint="https://test.openai.azure.com/",
|
||||
api_key="my key",
|
||||
api_version="version",
|
||||
)
|
||||
|
||||
message_history = [
|
||||
{"role": "user", "content": "When does the sun come up in the summer?"},
|
||||
{"role": "assistant", "content": "Usually around 6am."},
|
||||
]
|
||||
question = "What about next season?"
|
||||
|
||||
res = llm.invoke(question, message_history) # type: ignore
|
||||
assert isinstance(res, LLMResponse)
|
||||
assert res.content == "openai chat response"
|
||||
message_history.append({"role": "user", "content": question})
|
||||
# Use assert_called_once() instead of assert_called_once_with() to avoid issues with overloaded functions
|
||||
llm.client.chat.completions.create.assert_called_once() # type: ignore
|
||||
# Check call arguments individually
|
||||
call_args = llm.client.chat.completions.create.call_args[ # type: ignore
|
||||
1
|
||||
] # Get the keyword arguments
|
||||
assert call_args["messages"] == message_history
|
||||
assert call_args["model"] == "gpt"
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_azure_openai_llm_with_message_history_validation_error(
|
||||
mock_import: Mock,
|
||||
) -> None:
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
mock_openai.AzureOpenAI.return_value.chat.completions.create.return_value = (
|
||||
MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content="openai chat response"))],
|
||||
)
|
||||
)
|
||||
llm = AzureOpenAILLM(
|
||||
model_name="gpt",
|
||||
azure_endpoint="https://test.openai.azure.com/",
|
||||
api_key="my key",
|
||||
api_version="version",
|
||||
)
|
||||
|
||||
message_history = [
|
||||
{"role": "user", "content": 33},
|
||||
]
|
||||
question = "What about next season?"
|
||||
|
||||
with pytest.raises(LLMGenerationError) as exc_info:
|
||||
llm.invoke(question, message_history) # type: ignore
|
||||
assert "Input should be a valid string" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("builtins.__import__")
|
||||
async def test_openai_llm_ainvoke_happy_path(mock_import: Mock) -> None:
|
||||
"""Test that ainvoke properly awaits the async call and returns LLMResponse."""
|
||||
# Mock OpenAI module
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
|
||||
# Build mock response matching OpenAI's structure
|
||||
mock_message = MagicMock()
|
||||
mock_message.content = "Return text"
|
||||
|
||||
mock_choice = MagicMock()
|
||||
mock_choice.message = mock_message
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [mock_choice]
|
||||
|
||||
# Async function instead of AsyncMock
|
||||
async def async_create(*args, **kwargs): # type: ignore[no-untyped-def]
|
||||
return mock_response
|
||||
|
||||
mock_openai.AsyncOpenAI.return_value.chat.completions.create = async_create
|
||||
|
||||
model_name = "gpt-3.5-turbo"
|
||||
input_text = "may thy knife chip and shatter"
|
||||
model_params = {"temperature": 0.5}
|
||||
llm = OpenAILLM(model_name, model_params, api_key="test-key")
|
||||
|
||||
response = await llm.ainvoke(input_text)
|
||||
|
||||
# Assert we got the expected content in LLMResponse
|
||||
assert isinstance(response, LLMResponse)
|
||||
assert response.content == "Return text"
|
||||
|
||||
|
||||
# LLM Interface V2 Tests
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_openai_llm_invoke_v2_happy_path(mock_import: Mock) -> None:
|
||||
"""Test V2 interface invoke method with List[LLMMessage] input."""
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
mock_openai.OpenAI.return_value.chat.completions.create.return_value = MagicMock(
|
||||
choices=[
|
||||
MagicMock(message=MagicMock(content="Paris is the capital of France."))
|
||||
],
|
||||
)
|
||||
|
||||
messages: List[LLMMessage] = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "What is the capital of France?"},
|
||||
]
|
||||
|
||||
llm = OpenAILLM(api_key="my key", model_name="gpt")
|
||||
response = llm.invoke(messages)
|
||||
|
||||
assert isinstance(response, LLMResponse)
|
||||
assert response.content == "Paris is the capital of France."
|
||||
|
||||
# Verify the client was called correctly
|
||||
llm.client.chat.completions.create.assert_called_once() # type: ignore
|
||||
call_args = llm.client.chat.completions.create.call_args[1] # type: ignore
|
||||
# Verify we have the right number of messages and model
|
||||
assert len(call_args["messages"]) == 2
|
||||
assert call_args["model"] == "gpt"
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_openai_llm_invoke_v2_with_conversation_history(mock_import: Mock) -> None:
|
||||
"""Test V2 interface invoke with conversation history."""
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
mock_openai.OpenAI.return_value.chat.completions.create.return_value = MagicMock(
|
||||
choices=[
|
||||
MagicMock(message=MagicMock(content="Berlin is the capital of Germany."))
|
||||
],
|
||||
)
|
||||
|
||||
messages: List[LLMMessage] = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "What is the capital of France?"},
|
||||
{"role": "assistant", "content": "Paris is the capital of France."},
|
||||
{"role": "user", "content": "What about Germany?"},
|
||||
]
|
||||
|
||||
llm = OpenAILLM(api_key="my key", model_name="gpt")
|
||||
response = llm.invoke(messages)
|
||||
|
||||
assert isinstance(response, LLMResponse)
|
||||
assert response.content == "Berlin is the capital of Germany."
|
||||
|
||||
# Verify all messages were passed correctly
|
||||
llm.client.chat.completions.create.assert_called_once() # type: ignore
|
||||
call_args = llm.client.chat.completions.create.call_args[1] # type: ignore
|
||||
assert len(call_args["messages"]) == 4
|
||||
assert call_args["model"] == "gpt"
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_openai_llm_invoke_v2_no_system_message(mock_import: Mock) -> None:
|
||||
"""Test V2 interface invoke without system message."""
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
mock_openai.OpenAI.return_value.chat.completions.create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content="I'm doing well, thank you!"))],
|
||||
)
|
||||
|
||||
messages: List[LLMMessage] = [
|
||||
{"role": "user", "content": "Hello, how are you?"},
|
||||
]
|
||||
|
||||
llm = OpenAILLM(api_key="my key", model_name="gpt")
|
||||
response = llm.invoke(messages)
|
||||
|
||||
assert isinstance(response, LLMResponse)
|
||||
assert response.content == "I'm doing well, thank you!"
|
||||
|
||||
# Verify only user message was passed
|
||||
llm.client.chat.completions.create.assert_called_once() # type: ignore
|
||||
call_args = llm.client.chat.completions.create.call_args[1] # type: ignore
|
||||
assert len(call_args["messages"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("builtins.__import__")
|
||||
async def test_openai_llm_ainvoke_v2_happy_path(mock_import: Mock) -> None:
|
||||
"""Test V2 interface async invoke method with List[LLMMessage] input."""
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
|
||||
# Build mock response matching OpenAI's structure
|
||||
mock_message = MagicMock()
|
||||
mock_message.content = "2+2 equals 4."
|
||||
|
||||
mock_choice = MagicMock()
|
||||
mock_choice.message = mock_message
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [mock_choice]
|
||||
|
||||
# Async function to simulate .create()
|
||||
async def async_create(*args, **kwargs): # type: ignore[no-untyped-def]
|
||||
"""Async mock for chat completions create."""
|
||||
return mock_response
|
||||
|
||||
mock_openai.AsyncOpenAI.return_value.chat.completions.create = async_create
|
||||
|
||||
messages: List[LLMMessage] = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "What is 2+2?"},
|
||||
]
|
||||
|
||||
llm = OpenAILLM(api_key="my key", model_name="gpt")
|
||||
response = await llm.ainvoke(messages)
|
||||
|
||||
# Assert the returned LLMResponse
|
||||
assert isinstance(response, LLMResponse)
|
||||
assert response.content == "2+2 equals 4."
|
||||
|
||||
# Verify async client was called
|
||||
# Patch async_create itself to track calls
|
||||
called_args = getattr(
|
||||
llm.async_client.chat.completions.create, "__wrapped_args__", None
|
||||
)
|
||||
assert called_args is None or True # optional, depends on how strict tracking is
|
||||
|
||||
|
||||
# Note: Async tool calling test is covered by the synchronous version above
|
||||
# The complex mocking of json.loads with local imports makes this test difficult to maintain
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_openai_llm_invoke_v2_validation_error(mock_import: Mock) -> None:
|
||||
"""Test V2 interface invoke with invalid message format raises error."""
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
|
||||
messages: List[LLMMessage] = [
|
||||
{"role": "invalid_role", "content": "This should fail."}, # type: ignore
|
||||
]
|
||||
|
||||
llm = OpenAILLM(api_key="my key", model_name="gpt")
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
llm.invoke(messages)
|
||||
assert "Unknown role: invalid_role" in str(exc_info.value)
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_openai_llm_get_messages_v2_all_roles(mock_import: Mock) -> None:
|
||||
"""Test get_messages_v2 method handles all message roles correctly."""
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
|
||||
messages: List[LLMMessage] = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
{"role": "user", "content": "How are you?"},
|
||||
]
|
||||
|
||||
llm = OpenAILLM(api_key="my key", model_name="gpt")
|
||||
result_messages = llm.get_messages_v2(messages)
|
||||
|
||||
# Convert to list for easier testing
|
||||
result_list = list(result_messages)
|
||||
|
||||
# Just verify the correct number of messages are returned
|
||||
# (Detailed content inspection is difficult due to OpenAI message object mocking)
|
||||
assert len(result_list) == 4
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_azure_openai_llm_invoke_v2_happy_path(mock_import: Mock) -> None:
|
||||
"""Test V2 interface invoke method for Azure OpenAI with List[LLMMessage] input."""
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
mock_openai.AzureOpenAI.return_value.chat.completions.create.return_value = (
|
||||
MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content="Azure OpenAI response"))],
|
||||
)
|
||||
)
|
||||
|
||||
messages: List[LLMMessage] = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "What is Azure?"},
|
||||
]
|
||||
|
||||
llm = AzureOpenAILLM(
|
||||
model_name="gpt",
|
||||
azure_endpoint="https://test.openai.azure.com/",
|
||||
api_key="my key",
|
||||
api_version="version",
|
||||
)
|
||||
response = llm.invoke(messages)
|
||||
|
||||
assert isinstance(response, LLMResponse)
|
||||
assert response.content == "Azure OpenAI response"
|
||||
|
||||
# Verify the correct messages were passed
|
||||
llm.client.chat.completions.create.assert_called_once() # type: ignore
|
||||
call_args = llm.client.chat.completions.create.call_args[1] # type: ignore
|
||||
assert len(call_args["messages"]) == 2
|
||||
assert call_args["model"] == "gpt"
|
||||
|
||||
|
||||
class _TestModelForOpenAI(BaseModel):
|
||||
"""Test model for structured output tests."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
# JSON schema for structured output tests
|
||||
_TEST_JSON_SCHEMA = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "test_schema",
|
||||
"strict": True,
|
||||
"schema": {"type": "object", "properties": {"result": {"type": "string"}}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_openai_llm_invoke_v2_with_pydantic_response_format(mock_import: Mock) -> None:
|
||||
"""Test V2 interface with Pydantic model as response_format."""
|
||||
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.side_effect = create_selective_import_mock(mock_openai)
|
||||
mock_openai.OpenAI.return_value.chat.completions.create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content='{"name": "John", "age": 30}'))],
|
||||
)
|
||||
|
||||
messages: List[LLMMessage] = [
|
||||
{"role": "user", "content": "Extract person info"},
|
||||
]
|
||||
|
||||
llm = OpenAILLM(api_key="my key", model_name="gpt")
|
||||
response = llm.invoke(messages, response_format=_TestModelForOpenAI)
|
||||
|
||||
assert response.content == '{"name": "John", "age": 30}'
|
||||
|
||||
# Verify the method was called (response_format handling is internal)
|
||||
llm.client.chat.completions.create.assert_called_once() # type: ignore
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_openai_llm_invoke_v2_with_json_schema_response_format(
|
||||
mock_import: Mock,
|
||||
) -> None:
|
||||
"""Test V2 interface with JSON schema dict as response_format."""
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
mock_openai.OpenAI.return_value.chat.completions.create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content='{"result": "success"}'))],
|
||||
)
|
||||
|
||||
messages: List[LLMMessage] = [
|
||||
{"role": "user", "content": "Test"},
|
||||
]
|
||||
|
||||
llm = OpenAILLM(api_key="my key", model_name="gpt")
|
||||
response = llm.invoke(messages, response_format=_TEST_JSON_SCHEMA)
|
||||
|
||||
assert response.content == '{"result": "success"}'
|
||||
|
||||
# Verify the method was called (response_format handling is internal)
|
||||
llm.client.chat.completions.create.assert_called_once() # type: ignore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("builtins.__import__")
|
||||
async def test_openai_llm_ainvoke_v2_with_pydantic_response_format(
|
||||
mock_import: Mock,
|
||||
) -> None:
|
||||
"""Test V2 interface async invoke with Pydantic response_format."""
|
||||
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.side_effect = create_selective_import_mock(mock_openai)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock(message=MagicMock(content='{"value": "test"}'))]
|
||||
|
||||
async def async_create(*args, **kwargs): # type: ignore[no-untyped-def]
|
||||
return mock_response
|
||||
|
||||
mock_openai.AsyncOpenAI.return_value.chat.completions.create = async_create
|
||||
|
||||
messages: List[LLMMessage] = [{"role": "user", "content": "Test"}]
|
||||
|
||||
llm = OpenAILLM(api_key="my key", model_name="gpt")
|
||||
response = await llm.ainvoke(messages, response_format=_TestModelForOpenAI)
|
||||
|
||||
assert response.content == '{"value": "test"}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("builtins.__import__")
|
||||
async def test_openai_llm_ainvoke_v2_with_json_schema_response_format(
|
||||
mock_import: Mock,
|
||||
) -> None:
|
||||
"""Test V2 interface async invoke with JSON schema response_format."""
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [
|
||||
MagicMock(message=MagicMock(content='{"result": "success"}'))
|
||||
]
|
||||
|
||||
async def async_create(*args, **kwargs): # type: ignore[no-untyped-def]
|
||||
return mock_response
|
||||
|
||||
mock_openai.AsyncOpenAI.return_value.chat.completions.create = async_create
|
||||
|
||||
messages: List[LLMMessage] = [{"role": "user", "content": "Test"}]
|
||||
|
||||
llm = OpenAILLM(api_key="my key", model_name="gpt")
|
||||
response = await llm.ainvoke(messages, response_format=_TEST_JSON_SCHEMA)
|
||||
|
||||
assert response.content == '{"result": "success"}'
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_openai_llm_close(mock_import: Mock) -> None:
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.side_effect = create_selective_import_mock(mock_openai)
|
||||
mock_openai.AsyncOpenAI.return_value.close = AsyncMock()
|
||||
|
||||
llm = OpenAILLM(api_key="my key", model_name="gpt")
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error")
|
||||
llm.close()
|
||||
|
||||
mock_openai.OpenAI.return_value.close.assert_called_once()
|
||||
mock_openai.AsyncOpenAI.return_value.close.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("builtins.__import__")
|
||||
async def test_openai_llm_aclose(mock_import: Mock) -> None:
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.side_effect = create_selective_import_mock(mock_openai)
|
||||
mock_openai.AsyncOpenAI.return_value.close = AsyncMock()
|
||||
|
||||
llm = OpenAILLM(api_key="my key", model_name="gpt")
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error")
|
||||
await llm.aclose()
|
||||
|
||||
mock_openai.OpenAI.return_value.close.assert_called_once()
|
||||
mock_openai.AsyncOpenAI.return_value.close.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("builtins.__import__")
|
||||
async def test_openai_llm_close_raises_in_async_context(mock_import: Mock) -> None:
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
|
||||
llm = OpenAILLM(api_key="my key", model_name="gpt")
|
||||
|
||||
with pytest.raises(RuntimeError, match="async with"):
|
||||
llm.close()
|
||||
|
||||
|
||||
# HTTP client tests
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_openai_llm_with_httpx_client(mock_import: Mock) -> None:
|
||||
"""Test that httpx.Client is forwarded only to the sync OpenAI client without warning."""
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
|
||||
http_client = httpx.Client()
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
OpenAILLM(model_name="gpt", api_key="my key", http_client=http_client)
|
||||
|
||||
assert not any("Invalid http_client" in str(w.message) for w in caught)
|
||||
_, sync_kwargs = mock_openai.OpenAI.call_args
|
||||
assert sync_kwargs.get("http_client") is http_client
|
||||
_, async_kwargs = mock_openai.AsyncOpenAI.call_args
|
||||
assert async_kwargs.get("http_client") is None
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_openai_llm_with_httpx_async_client(mock_import: Mock) -> None:
|
||||
"""Test that httpx.AsyncClient is forwarded only to the async OpenAI client without warning."""
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
|
||||
async_http_client = httpx.AsyncClient()
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
OpenAILLM(model_name="gpt", api_key="my key", http_client=async_http_client)
|
||||
|
||||
assert not any("Invalid http_client" in str(w.message) for w in caught)
|
||||
_, sync_kwargs = mock_openai.OpenAI.call_args
|
||||
assert sync_kwargs.get("http_client") is None
|
||||
_, async_kwargs = mock_openai.AsyncOpenAI.call_args
|
||||
assert async_kwargs.get("http_client") is async_http_client
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_openai_llm_no_http_client_no_warning(mock_import: Mock) -> None:
|
||||
"""Test that omitting http_client does not emit a warning."""
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
OpenAILLM(model_name="gpt", api_key="my key")
|
||||
|
||||
assert not any("Invalid http_client" in str(w.message) for w in caught)
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_openai_llm_with_invalid_http_client_warns(mock_import: Mock) -> None:
|
||||
"""Test that a non-None invalid http_client type emits a warning."""
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
|
||||
with pytest.warns(UserWarning, match="Invalid http_client type"):
|
||||
OpenAILLM(model_name="gpt", api_key="my key", http_client="not-a-client")
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_azure_openai_llm_with_httpx_client(mock_import: Mock) -> None:
|
||||
"""Test that httpx.Client is forwarded only to the sync AzureOpenAI client without warning."""
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
|
||||
http_client = httpx.Client()
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
AzureOpenAILLM(
|
||||
model_name="gpt",
|
||||
azure_endpoint="https://test.openai.azure.com/",
|
||||
api_key="my key",
|
||||
api_version="version",
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
assert not any("Invalid http_client" in str(w.message) for w in caught)
|
||||
_, sync_kwargs = mock_openai.AzureOpenAI.call_args
|
||||
assert sync_kwargs.get("http_client") is http_client
|
||||
_, async_kwargs = mock_openai.AsyncAzureOpenAI.call_args
|
||||
assert async_kwargs.get("http_client") is None
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_azure_openai_llm_with_httpx_async_client(mock_import: Mock) -> None:
|
||||
"""Test that httpx.AsyncClient is forwarded only to the async AzureOpenAI client without warning."""
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
|
||||
async_http_client = httpx.AsyncClient()
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
AzureOpenAILLM(
|
||||
model_name="gpt",
|
||||
azure_endpoint="https://test.openai.azure.com/",
|
||||
api_key="my key",
|
||||
api_version="version",
|
||||
http_client=async_http_client,
|
||||
)
|
||||
|
||||
assert not any("Invalid http_client" in str(w.message) for w in caught)
|
||||
_, sync_kwargs = mock_openai.AzureOpenAI.call_args
|
||||
assert sync_kwargs.get("http_client") is None
|
||||
_, async_kwargs = mock_openai.AsyncAzureOpenAI.call_args
|
||||
assert async_kwargs.get("http_client") is async_http_client
|
||||
|
||||
|
||||
@patch("builtins.__import__")
|
||||
def test_openai_llm_with_default_aiohttp_client(mock_import: Mock) -> None:
|
||||
"""Test that DefaultAioHttpClient (subclass of httpx.AsyncClient) is forwarded to the async client.
|
||||
|
||||
DefaultAioHttpClient is a subclass of httpx.AsyncClient, so the isinstance check
|
||||
already handles it without any special-casing.
|
||||
"""
|
||||
mock_openai = get_mock_openai()
|
||||
mock_import.return_value = mock_openai
|
||||
|
||||
class _FakeAioHttpClient(httpx.AsyncClient):
|
||||
"""Minimal stand-in for openai.DefaultAioHttpClient."""
|
||||
|
||||
aiohttp_client = _FakeAioHttpClient()
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
OpenAILLM(model_name="gpt", api_key="my key", http_client=aiohttp_client)
|
||||
|
||||
assert not any("Invalid http_client" in str(w.message) for w in caught)
|
||||
_, sync_kwargs = mock_openai.OpenAI.call_args
|
||||
assert sync_kwargs.get("http_client") is None
|
||||
_, async_kwargs = mock_openai.AsyncOpenAI.call_args
|
||||
assert async_kwargs.get("http_client") is aiohttp_client
|
||||
Reference in New Issue
Block a user