test: add comprehensive test suite with 89% coverage
- Add tests for all backend modules: config, database, models, services, routes, middleware, and providers - Separate dev dependencies using dependency-groups - Update CLAUDE.md with test commands and project structure - Add .coverage and .pytest_cache to gitignore 88 tests covering: - Authentication and authorization - Model routing and key selection - Async logging with batch processing - All API endpoints (chat, openai, anthropic, health) - LiteLLM wrapper (mocked external calls) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
206
backend/tests/test_litellm_wrapper.py
Normal file
206
backend/tests/test_litellm_wrapper.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""Tests for LiteLLM wrapper."""
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
from app.providers.litellm_wrapper import LiteLLMWrapper
|
||||
from app.models import Provider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def openai_provider():
|
||||
"""Return an OpenAI provider."""
|
||||
return Provider(
|
||||
id=1,
|
||||
name="openai",
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_type="openai",
|
||||
is_active=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anthropic_provider():
|
||||
"""Return an Anthropic provider."""
|
||||
return Provider(
|
||||
id=2,
|
||||
name="anthropic",
|
||||
base_url="https://api.anthropic.com/v1",
|
||||
api_type="anthropic",
|
||||
is_active=True
|
||||
)
|
||||
|
||||
|
||||
class TestBuildModelString:
|
||||
"""Test _build_model_string method."""
|
||||
|
||||
def test_openai_model(self, openai_provider):
|
||||
"""Test building model string for OpenAI."""
|
||||
result = LiteLLMWrapper._build_model_string(openai_provider, "gpt-4o")
|
||||
assert result == "openai/gpt-4o"
|
||||
|
||||
def test_anthropic_model(self, anthropic_provider):
|
||||
"""Test building model string for Anthropic."""
|
||||
result = LiteLLMWrapper._build_model_string(anthropic_provider, "claude-3-opus")
|
||||
assert result == "anthropic/claude-3-opus"
|
||||
|
||||
def test_unknown_provider_type(self):
|
||||
"""Test with unknown provider type."""
|
||||
provider = Provider(
|
||||
id=3,
|
||||
name="unknown",
|
||||
base_url="https://api.example.com",
|
||||
api_type="unknown",
|
||||
is_active=True
|
||||
)
|
||||
result = LiteLLMWrapper._build_model_string(provider, "model-name")
|
||||
assert result == "openai/model-name" # Falls back to openai
|
||||
|
||||
|
||||
class TestChatCompletion:
|
||||
"""Test chat_completion method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_completion_non_streaming(self, openai_provider):
|
||||
"""Test non-streaming chat completion."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.usage = MagicMock()
|
||||
mock_response.usage.prompt_tokens = 15
|
||||
mock_response.usage.completion_tokens = 25
|
||||
|
||||
with patch("app.providers.litellm_wrapper.acompletion", new_callable=AsyncMock) as mock_acompletion:
|
||||
mock_acompletion.return_value = mock_response
|
||||
|
||||
response, prompt_tokens, completion_tokens = await LiteLLMWrapper.chat_completion(
|
||||
provider=openai_provider,
|
||||
api_key="sk-test",
|
||||
model="gpt-4o",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=False
|
||||
)
|
||||
|
||||
assert response == mock_response
|
||||
assert prompt_tokens == 15
|
||||
assert completion_tokens == 25
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_completion_streaming(self, openai_provider):
|
||||
"""Test streaming chat completion."""
|
||||
mock_stream = MagicMock()
|
||||
|
||||
with patch("app.providers.litellm_wrapper.acompletion", new_callable=AsyncMock) as mock_acompletion:
|
||||
mock_acompletion.return_value = mock_stream
|
||||
|
||||
response, prompt_tokens, completion_tokens = await LiteLLMWrapper.chat_completion(
|
||||
provider=openai_provider,
|
||||
api_key="sk-test",
|
||||
model="gpt-4o",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=True
|
||||
)
|
||||
|
||||
# For streaming, token counts are 0 initially
|
||||
assert prompt_tokens == 0
|
||||
assert completion_tokens == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_completion_with_kwargs(self, openai_provider):
|
||||
"""Test chat completion with additional kwargs."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.usage = MagicMock()
|
||||
mock_response.usage.prompt_tokens = 10
|
||||
mock_response.usage.completion_tokens = 20
|
||||
|
||||
with patch("app.providers.litellm_wrapper.acompletion", new_callable=AsyncMock) as mock_acompletion:
|
||||
mock_acompletion.return_value = mock_response
|
||||
|
||||
await LiteLLMWrapper.chat_completion(
|
||||
provider=openai_provider,
|
||||
api_key="sk-test",
|
||||
model="gpt-4o",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=False,
|
||||
temperature=0.7,
|
||||
max_tokens=100
|
||||
)
|
||||
|
||||
# Verify kwargs were passed
|
||||
call_kwargs = mock_acompletion.call_args[1]
|
||||
assert call_kwargs["temperature"] == 0.7
|
||||
assert call_kwargs["max_tokens"] == 100
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_completion_anthropic(self, anthropic_provider):
|
||||
"""Test chat completion with Anthropic provider."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.usage = MagicMock()
|
||||
mock_response.usage.prompt_tokens = 10
|
||||
mock_response.usage.completion_tokens = 30
|
||||
|
||||
with patch("app.providers.litellm_wrapper.acompletion", new_callable=AsyncMock) as mock_acompletion:
|
||||
mock_acompletion.return_value = mock_response
|
||||
|
||||
response, prompt_tokens, completion_tokens = await LiteLLMWrapper.chat_completion(
|
||||
provider=anthropic_provider,
|
||||
api_key="sk-ant-test",
|
||||
model="claude-3-opus",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=False
|
||||
)
|
||||
|
||||
# Verify model string was built correctly
|
||||
call_kwargs = mock_acompletion.call_args[1]
|
||||
assert call_kwargs["model"] == "anthropic/claude-3-opus"
|
||||
|
||||
|
||||
class TestStreamResponse:
|
||||
"""Test stream_response method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_response_yields_chunks(self, openai_provider):
|
||||
"""Test stream_response yields SSE-formatted chunks."""
|
||||
mock_chunk1 = MagicMock()
|
||||
mock_chunk1.model_dump.return_value = {"choices": [{"delta": {"content": "Hello"}}]}
|
||||
mock_chunk1.usage = None
|
||||
|
||||
mock_chunk2 = MagicMock()
|
||||
mock_chunk2.model_dump.return_value = {"choices": [{"delta": {"content": "!"}}]}
|
||||
mock_chunk2.usage = MagicMock(prompt_tokens=5, completion_tokens=2)
|
||||
|
||||
async def mock_stream():
|
||||
yield mock_chunk1
|
||||
yield mock_chunk2
|
||||
|
||||
with patch("app.providers.litellm_wrapper.async_logger.log", new_callable=AsyncMock):
|
||||
chunks = []
|
||||
async for chunk in LiteLLMWrapper.stream_response(
|
||||
mock_stream(),
|
||||
client_key_id=1,
|
||||
provider_id=1,
|
||||
model="gpt-4o",
|
||||
start_time=0
|
||||
):
|
||||
chunks.append(chunk)
|
||||
|
||||
assert len(chunks) >= 2 # At least 2 chunks + [DONE]
|
||||
assert any("Hello" in chunk for chunk in chunks)
|
||||
assert "data: [DONE]" in chunks[-1]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_response_handles_error(self, openai_provider):
|
||||
"""Test stream_response handles errors."""
|
||||
async def mock_stream_with_error():
|
||||
yield MagicMock(model_dump=lambda: {"choices": []})
|
||||
raise Exception("Stream error")
|
||||
|
||||
with patch("app.providers.litellm_wrapper.async_logger.log", new_callable=AsyncMock):
|
||||
chunks = []
|
||||
async for chunk in LiteLLMWrapper.stream_response(
|
||||
mock_stream_with_error(),
|
||||
client_key_id=1,
|
||||
provider_id=1,
|
||||
model="gpt-4o",
|
||||
start_time=0
|
||||
):
|
||||
chunks.append(chunk)
|
||||
|
||||
# Should include error message
|
||||
assert any("error" in chunk.lower() for chunk in chunks)
|
||||
Reference in New Issue
Block a user