|
|
@@ -1,15 +1,38 @@
|
|
|
-from unittest.mock import patch
|
|
|
+from unittest.mock import MagicMock, patch
|
|
|
|
|
|
import pytest
|
|
|
+from litellm.exceptions import (
|
|
|
+ APIConnectionError,
|
|
|
+ ContentPolicyViolationError,
|
|
|
+ InternalServerError,
|
|
|
+ OpenAIError,
|
|
|
+ RateLimitError,
|
|
|
+)
|
|
|
|
|
|
from openhands.core.config import LLMConfig
|
|
|
+from openhands.core.exceptions import OperationCancelled
|
|
|
from openhands.core.metrics import Metrics
|
|
|
from openhands.llm.llm import LLM
|
|
|
|
|
|
|
|
|
+@pytest.fixture(autouse=True)
|
|
|
+def mock_logger(monkeypatch):
|
|
|
+ # suppress logging of completion data to file
|
|
|
+ mock_logger = MagicMock()
|
|
|
+ monkeypatch.setattr('openhands.llm.llm.llm_prompt_logger', mock_logger)
|
|
|
+ monkeypatch.setattr('openhands.llm.llm.llm_response_logger', mock_logger)
|
|
|
+ return mock_logger
|
|
|
+
|
|
|
+
|
|
|
@pytest.fixture
|
|
|
def default_config():
|
|
|
- return LLMConfig(model='gpt-4o', api_key='test_key')
|
|
|
+ return LLMConfig(
|
|
|
+ model='gpt-4o',
|
|
|
+ api_key='test_key',
|
|
|
+ num_retries=2,
|
|
|
+ retry_min_wait=1,
|
|
|
+ retry_max_wait=2,
|
|
|
+ )
|
|
|
|
|
|
|
|
|
def test_llm_init_with_default_config(default_config):
|
|
|
@@ -64,7 +87,7 @@ def test_llm_init_with_metrics():
|
|
|
|
|
|
|
|
|
def test_llm_reset():
|
|
|
- llm = LLM(LLMConfig(model='gpt-3.5-turbo', api_key='test_key'))
|
|
|
+ llm = LLM(LLMConfig(model='gpt-4o-mini', api_key='test_key'))
|
|
|
initial_metrics = llm.metrics
|
|
|
llm.reset()
|
|
|
assert llm.metrics is not initial_metrics
|
|
|
@@ -73,7 +96,7 @@ def test_llm_reset():
|
|
|
|
|
|
@patch('openhands.llm.llm.litellm.get_model_info')
|
|
|
def test_llm_init_with_openrouter_model(mock_get_model_info, default_config):
|
|
|
- default_config.model = 'openrouter:gpt-3.5-turbo'
|
|
|
+ default_config.model = 'openrouter:gpt-4o-mini'
|
|
|
mock_get_model_info.return_value = {
|
|
|
'max_input_tokens': 7000,
|
|
|
'max_output_tokens': 1500,
|
|
|
@@ -81,4 +104,197 @@ def test_llm_init_with_openrouter_model(mock_get_model_info, default_config):
|
|
|
llm = LLM(default_config)
|
|
|
assert llm.config.max_input_tokens == 7000
|
|
|
assert llm.config.max_output_tokens == 1500
|
|
|
- mock_get_model_info.assert_called_once_with('openrouter:gpt-3.5-turbo')
|
|
|
+ mock_get_model_info.assert_called_once_with('openrouter:gpt-4o-mini')
|
|
|
+
|
|
|
+
|
|
|
+# Tests involving completion and retries
|
|
|
+
|
|
|
+
|
|
|
+@patch('openhands.llm.llm.litellm_completion')
|
|
|
+def test_completion_with_mocked_logger(
|
|
|
+ mock_litellm_completion, default_config, mock_logger
|
|
|
+):
|
|
|
+ mock_litellm_completion.return_value = {
|
|
|
+ 'choices': [{'message': {'content': 'Test response'}}]
|
|
|
+ }
|
|
|
+
|
|
|
+ llm = LLM(config=default_config)
|
|
|
+ response = llm.completion(
|
|
|
+ messages=[{'role': 'user', 'content': 'Hello!'}],
|
|
|
+ stream=False,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert response['choices'][0]['message']['content'] == 'Test response'
|
|
|
+ assert mock_litellm_completion.call_count == 1
|
|
|
+
|
|
|
+ mock_logger.debug.assert_called()
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.parametrize(
|
|
|
+ 'exception_class,extra_args,expected_retries',
|
|
|
+ [
|
|
|
+ (
|
|
|
+ APIConnectionError,
|
|
|
+ {'llm_provider': 'test_provider', 'model': 'test_model'},
|
|
|
+ 2,
|
|
|
+ ),
|
|
|
+ (
|
|
|
+ ContentPolicyViolationError,
|
|
|
+ {'model': 'test_model', 'llm_provider': 'test_provider'},
|
|
|
+ 2,
|
|
|
+ ),
|
|
|
+ (
|
|
|
+ InternalServerError,
|
|
|
+ {'llm_provider': 'test_provider', 'model': 'test_model'},
|
|
|
+ 2,
|
|
|
+ ),
|
|
|
+ (OpenAIError, {}, 2),
|
|
|
+ (RateLimitError, {'llm_provider': 'test_provider', 'model': 'test_model'}, 2),
|
|
|
+ ],
|
|
|
+)
|
|
|
+@patch('openhands.llm.llm.litellm_completion')
|
|
|
+def test_completion_retries(
|
|
|
+ mock_litellm_completion,
|
|
|
+ default_config,
|
|
|
+ exception_class,
|
|
|
+ extra_args,
|
|
|
+ expected_retries,
|
|
|
+):
|
|
|
+ mock_litellm_completion.side_effect = [
|
|
|
+ exception_class('Test error message', **extra_args),
|
|
|
+ {'choices': [{'message': {'content': 'Retry successful'}}]},
|
|
|
+ ]
|
|
|
+
|
|
|
+ llm = LLM(config=default_config)
|
|
|
+ response = llm.completion(
|
|
|
+ messages=[{'role': 'user', 'content': 'Hello!'}],
|
|
|
+ stream=False,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert response['choices'][0]['message']['content'] == 'Retry successful'
|
|
|
+ assert mock_litellm_completion.call_count == expected_retries
|
|
|
+
|
|
|
+
|
|
|
+@patch('openhands.llm.llm.litellm_completion')
|
|
|
+def test_completion_rate_limit_wait_time(mock_litellm_completion, default_config):
|
|
|
+ with patch('time.sleep') as mock_sleep:
|
|
|
+ mock_litellm_completion.side_effect = [
|
|
|
+ RateLimitError(
|
|
|
+ 'Rate limit exceeded', llm_provider='test_provider', model='test_model'
|
|
|
+ ),
|
|
|
+ {'choices': [{'message': {'content': 'Retry successful'}}]},
|
|
|
+ ]
|
|
|
+
|
|
|
+ llm = LLM(config=default_config)
|
|
|
+ response = llm.completion(
|
|
|
+ messages=[{'role': 'user', 'content': 'Hello!'}],
|
|
|
+ stream=False,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert response['choices'][0]['message']['content'] == 'Retry successful'
|
|
|
+ assert mock_litellm_completion.call_count == 2
|
|
|
+
|
|
|
+ mock_sleep.assert_called_once()
|
|
|
+ wait_time = mock_sleep.call_args[0][0]
|
|
|
+ assert (
|
|
|
+ 60 <= wait_time <= 240
|
|
|
+ ), f'Expected wait time between 60 and 240 seconds, but got {wait_time}'
|
|
|
+
|
|
|
+
|
|
|
+@patch('openhands.llm.llm.litellm_completion')
|
|
|
+def test_completion_exhausts_retries(mock_litellm_completion, default_config):
|
|
|
+ mock_litellm_completion.side_effect = APIConnectionError(
|
|
|
+ 'Persistent error', llm_provider='test_provider', model='test_model'
|
|
|
+ )
|
|
|
+
|
|
|
+ llm = LLM(config=default_config)
|
|
|
+ with pytest.raises(APIConnectionError):
|
|
|
+ llm.completion(
|
|
|
+ messages=[{'role': 'user', 'content': 'Hello!'}],
|
|
|
+ stream=False,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert mock_litellm_completion.call_count == llm.config.num_retries
|
|
|
+
|
|
|
+
|
|
|
+@patch('openhands.llm.llm.litellm_completion')
|
|
|
+def test_completion_operation_cancelled(mock_litellm_completion, default_config):
|
|
|
+ mock_litellm_completion.side_effect = OperationCancelled('Operation cancelled')
|
|
|
+
|
|
|
+ llm = LLM(config=default_config)
|
|
|
+ with pytest.raises(OperationCancelled):
|
|
|
+ llm.completion(
|
|
|
+ messages=[{'role': 'user', 'content': 'Hello!'}],
|
|
|
+ stream=False,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert mock_litellm_completion.call_count == 1
|
|
|
+
|
|
|
+
|
|
|
+@patch('openhands.llm.llm.litellm_completion')
|
|
|
+def test_completion_keyboard_interrupt(mock_litellm_completion, default_config):
|
|
|
+ def side_effect(*args, **kwargs):
|
|
|
+ raise KeyboardInterrupt('Simulated KeyboardInterrupt')
|
|
|
+
|
|
|
+ mock_litellm_completion.side_effect = side_effect
|
|
|
+
|
|
|
+ llm = LLM(config=default_config)
|
|
|
+ with pytest.raises(OperationCancelled):
|
|
|
+ try:
|
|
|
+ llm.completion(
|
|
|
+ messages=[{'role': 'user', 'content': 'Hello!'}],
|
|
|
+ stream=False,
|
|
|
+ )
|
|
|
+ except KeyboardInterrupt:
|
|
|
+ raise OperationCancelled('Operation cancelled due to KeyboardInterrupt')
|
|
|
+
|
|
|
+ assert mock_litellm_completion.call_count == 1
|
|
|
+
|
|
|
+
|
|
|
+@patch('openhands.llm.llm.litellm_completion')
|
|
|
+def test_completion_keyboard_interrupt_handler(mock_litellm_completion, default_config):
|
|
|
+ global _should_exit
|
|
|
+
|
|
|
+ def side_effect(*args, **kwargs):
|
|
|
+ global _should_exit
|
|
|
+ _should_exit = True
|
|
|
+ return {'choices': [{'message': {'content': 'Simulated interrupt response'}}]}
|
|
|
+
|
|
|
+ mock_litellm_completion.side_effect = side_effect
|
|
|
+
|
|
|
+ llm = LLM(config=default_config)
|
|
|
+ result = llm.completion(
|
|
|
+ messages=[{'role': 'user', 'content': 'Hello!'}],
|
|
|
+ stream=False,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert mock_litellm_completion.call_count == 1
|
|
|
+ assert result['choices'][0]['message']['content'] == 'Simulated interrupt response'
|
|
|
+ assert _should_exit
|
|
|
+
|
|
|
+ _should_exit = False
|
|
|
+
|
|
|
+
|
|
|
+@patch('openhands.llm.llm.litellm_completion')
|
|
|
+def test_completion_with_litellm_mock(mock_litellm_completion, default_config):
|
|
|
+ mock_response = {
|
|
|
+ 'choices': [{'message': {'content': 'This is a mocked response.'}}]
|
|
|
+ }
|
|
|
+ mock_litellm_completion.return_value = mock_response
|
|
|
+
|
|
|
+ test_llm = LLM(config=default_config)
|
|
|
+ response = test_llm.completion(
|
|
|
+ messages=[{'role': 'user', 'content': 'Hello!'}],
|
|
|
+ stream=False,
|
|
|
+ drop_params=True,
|
|
|
+ )
|
|
|
+
|
|
|
+ # Assertions
|
|
|
+ assert response['choices'][0]['message']['content'] == 'This is a mocked response.'
|
|
|
+ mock_litellm_completion.assert_called_once()
|
|
|
+
|
|
|
+ # Check if the correct arguments were passed to litellm_completion
|
|
|
+ call_args = mock_litellm_completion.call_args[1] # Get keyword arguments
|
|
|
+ assert call_args['model'] == default_config.model
|
|
|
+ assert call_args['messages'] == [{'role': 'user', 'content': 'Hello!'}]
|
|
|
+ assert not call_args['stream']
|