feat: unify multimodels translation providers, remove self-hosting (Ollama/LibreTranslate), and fix local SQLite configuration
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m21s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m21s
This commit is contained in:
@@ -345,7 +345,7 @@ class TestTranslateWithFallbackByMode:
|
||||
mock_translate.return_value = MagicMock()
|
||||
|
||||
with patch("services.providers.config.ProvidersConfig") as mock_config:
|
||||
mock_config.get_fallback_chain.return_value = ["ollama", "openai"]
|
||||
mock_config.get_fallback_chain.return_value = ["openrouter", "openai"]
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
translate_with_fallback_by_mode(request, mode="llm")
|
||||
|
||||
@@ -1,493 +0,0 @@
|
||||
"""
|
||||
Tests for the OllamaTranslationProvider.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from requests.exceptions import Timeout, ConnectionError as RequestsConnectionError
|
||||
|
||||
from services.providers.ollama_provider import (
|
||||
OllamaTranslationProvider,
|
||||
OllamaProviderError,
|
||||
get_ollama_provider,
|
||||
register_ollama_provider,
|
||||
_build_system_prompt,
|
||||
_get_language_name,
|
||||
OLLAMA_UNAVAILABLE,
|
||||
OLLAMA_MODEL_NOT_FOUND,
|
||||
OLLAMA_TIMEOUT,
|
||||
OLLAMA_GENERATION_ERROR,
|
||||
OLLAMA_CONTEXT_TOO_LONG,
|
||||
)
|
||||
from services.providers.schemas import TranslationRequest, TranslationResponse
|
||||
|
||||
|
||||
class TestOllamaProviderError:
|
||||
"""Tests for OllamaProviderError exception."""
|
||||
|
||||
def test_error_creation(self):
|
||||
"""Test error creation with all fields."""
|
||||
error = OllamaProviderError(
|
||||
code=OLLAMA_UNAVAILABLE,
|
||||
message="Ollama unavailable",
|
||||
details={"provider": "ollama"},
|
||||
)
|
||||
|
||||
assert error.code == OLLAMA_UNAVAILABLE
|
||||
assert error.message == "Ollama unavailable"
|
||||
assert error.details == {"provider": "ollama"}
|
||||
|
||||
def test_error_to_dict(self):
|
||||
"""Test error serialization."""
|
||||
error = OllamaProviderError(
|
||||
code=OLLAMA_MODEL_NOT_FOUND,
|
||||
message="Model not found",
|
||||
details={"model": "llama3"},
|
||||
)
|
||||
|
||||
result = error.to_dict()
|
||||
|
||||
assert result["error"] == OLLAMA_MODEL_NOT_FOUND
|
||||
assert result["message"] == "Model not found"
|
||||
assert result["details"]["model"] == "llama3"
|
||||
|
||||
def test_error_to_dict_no_details(self):
|
||||
"""Test error serialization without details."""
|
||||
error = OllamaProviderError(
|
||||
code=OLLAMA_GENERATION_ERROR,
|
||||
message="Generation error",
|
||||
)
|
||||
|
||||
result = error.to_dict()
|
||||
|
||||
assert result["error"] == OLLAMA_GENERATION_ERROR
|
||||
assert result["message"] == "Generation error"
|
||||
assert "details" not in result
|
||||
|
||||
|
||||
class TestHelperFunctions:
|
||||
"""Tests for helper functions."""
|
||||
|
||||
def test_get_language_name_common(self):
|
||||
"""Test language name lookup for common languages."""
|
||||
assert _get_language_name("en") == "English"
|
||||
assert _get_language_name("fr") == "French"
|
||||
assert _get_language_name("es") == "Spanish"
|
||||
assert _get_language_name("de") == "German"
|
||||
assert _get_language_name("zh") == "Chinese"
|
||||
assert _get_language_name("ja") == "Japanese"
|
||||
|
||||
def test_get_language_name_with_variant(self):
|
||||
"""Test language name lookup with variant codes."""
|
||||
assert _get_language_name("en-US") == "English"
|
||||
assert _get_language_name("pt-BR") == "Portuguese"
|
||||
|
||||
def test_get_language_name_unknown(self):
|
||||
"""Test language name lookup for unknown codes."""
|
||||
assert _get_language_name("xx") == "xx"
|
||||
|
||||
def test_build_system_prompt_default(self):
|
||||
"""Test default system prompt generation."""
|
||||
prompt = _build_system_prompt("English", "French")
|
||||
|
||||
assert "English" in prompt
|
||||
assert "French" in prompt
|
||||
assert "translator" in prompt.lower()
|
||||
|
||||
def test_build_system_prompt_custom(self):
|
||||
"""Test custom system prompt."""
|
||||
custom = "Translate this text formally for business context."
|
||||
prompt = _build_system_prompt("English", "French", custom)
|
||||
|
||||
assert prompt == custom
|
||||
|
||||
|
||||
class TestOllamaTranslationProvider:
|
||||
"""Tests for OllamaTranslationProvider."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
"""Create an Ollama provider instance."""
|
||||
return OllamaTranslationProvider(
|
||||
base_url="http://localhost:11434",
|
||||
model="llama3",
|
||||
timeout=120,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def provider_with_retries(self):
|
||||
"""Create an Ollama provider with retries."""
|
||||
return OllamaTranslationProvider(
|
||||
base_url="http://localhost:11434",
|
||||
model="llama3",
|
||||
timeout=120,
|
||||
max_retries=2,
|
||||
retry_delay=0.01,
|
||||
)
|
||||
|
||||
def test_init(self, provider):
|
||||
"""Test provider initialization."""
|
||||
assert provider._base_url == "http://localhost:11434"
|
||||
assert provider._model == "llama3"
|
||||
assert provider.timeout == 120
|
||||
assert provider._provider_name == "ollama"
|
||||
|
||||
def test_get_name(self, provider):
|
||||
"""Test provider name."""
|
||||
assert provider.get_name() == "ollama"
|
||||
|
||||
def test_translate_text_empty(self, provider):
|
||||
"""Test translating empty text."""
|
||||
request = TranslationRequest(text="", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == ""
|
||||
assert response.provider_name == "ollama"
|
||||
assert response.from_cache is False
|
||||
|
||||
def test_translate_text_whitespace(self, provider):
|
||||
"""Test translating whitespace-only text."""
|
||||
request = TranslationRequest(text=" ", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == " "
|
||||
|
||||
@patch.object(OllamaTranslationProvider, "_fetch_available_models")
|
||||
@patch.object(OllamaTranslationProvider, "_make_api_request")
|
||||
def test_translate_text_success(self, mock_request, mock_models, provider):
|
||||
"""Test successful translation."""
|
||||
mock_models.return_value = ["llama3", "mistral"]
|
||||
mock_request.return_value = "Bonjour"
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == "Bonjour"
|
||||
assert response.provider_name == "ollama"
|
||||
assert response.from_cache is False
|
||||
|
||||
@patch.object(OllamaTranslationProvider, "_fetch_available_models")
|
||||
@patch.object(OllamaTranslationProvider, "_make_api_request")
|
||||
def test_translate_text_with_custom_prompt(
|
||||
self, mock_request, mock_models, provider
|
||||
):
|
||||
"""Test translation with custom system prompt."""
|
||||
mock_models.return_value = ["llama3"]
|
||||
mock_request.return_value = "Bonjour (formal)"
|
||||
|
||||
request = TranslationRequest(
|
||||
text="Hello",
|
||||
target_language="fr",
|
||||
metadata={"custom_prompt": "Translate formally for business"},
|
||||
)
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == "Bonjour (formal)"
|
||||
mock_request.assert_called_once()
|
||||
call_args = mock_request.call_args
|
||||
assert "Translate formally for business" in call_args[0][1]
|
||||
|
||||
def test_translate_batch_empty(self, provider):
|
||||
"""Test batch translation with empty list."""
|
||||
responses = provider.translate_batch([])
|
||||
assert responses == []
|
||||
|
||||
@patch.object(OllamaTranslationProvider, "translate_text")
|
||||
def test_translate_batch(self, mock_translate, provider):
|
||||
"""Test batch translation."""
|
||||
mock_translate.side_effect = [
|
||||
TranslationResponse(translated_text="Bonjour", provider_name="ollama"),
|
||||
TranslationResponse(translated_text="Monde", provider_name="ollama"),
|
||||
]
|
||||
|
||||
requests = [
|
||||
TranslationRequest(text="Hello", target_language="fr"),
|
||||
TranslationRequest(text="World", target_language="fr"),
|
||||
]
|
||||
responses = provider.translate_batch(requests)
|
||||
|
||||
assert len(responses) == 2
|
||||
assert responses[0].translated_text == "Bonjour"
|
||||
assert responses[1].translated_text == "Monde"
|
||||
|
||||
|
||||
class TestOllamaErrorCodes:
|
||||
"""Tests for Ollama error code handling."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
"""Create an Ollama provider with no retries for error testing."""
|
||||
return OllamaTranslationProvider(
|
||||
base_url="http://localhost:11434",
|
||||
model="llama3",
|
||||
timeout=120,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
def test_context_too_long_error(self, provider):
|
||||
"""Test context too long error."""
|
||||
long_text = "x" * 130000
|
||||
request = TranslationRequest(text=long_text, target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == OLLAMA_CONTEXT_TOO_LONG
|
||||
assert response.error is not None
|
||||
|
||||
@patch.object(OllamaTranslationProvider, "_fetch_available_models")
|
||||
def test_model_not_found_error(self, mock_models, provider):
|
||||
"""Test model not found error."""
|
||||
mock_models.return_value = ["mistral", "qwen2"]
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == OLLAMA_MODEL_NOT_FOUND
|
||||
assert "llama3" in response.error
|
||||
|
||||
@patch.object(OllamaTranslationProvider, "_check_model_available")
|
||||
@patch("services.providers.ollama_provider.requests")
|
||||
def test_unavailable_error(self, mock_requests, mock_model_available, provider):
|
||||
"""Test Ollama unavailable error."""
|
||||
mock_model_available.return_value = True
|
||||
mock_requests.post.side_effect = RequestsConnectionError("Connection refused")
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == OLLAMA_UNAVAILABLE
|
||||
assert "indisponible" in response.error.lower()
|
||||
|
||||
|
||||
class TestOllamaHealthCheck:
|
||||
"""Tests for Ollama health check functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
"""Create an Ollama provider instance."""
|
||||
return OllamaTranslationProvider(
|
||||
base_url="http://localhost:11434",
|
||||
model="llama3",
|
||||
)
|
||||
|
||||
@patch("services.providers.ollama_provider.requests")
|
||||
def test_health_check_available(self, mock_requests, provider):
|
||||
"""Test health check when Ollama is available."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"models": [{"name": "llama3"}, {"name": "mistral"}]
|
||||
}
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
status = provider.health_check()
|
||||
|
||||
assert status.name == "ollama"
|
||||
assert status.available is True
|
||||
assert status.latency_ms is not None
|
||||
assert status.model == "llama3"
|
||||
assert status.model_available is True
|
||||
|
||||
@patch("services.providers.ollama_provider.requests")
|
||||
def test_health_check_model_not_pulled(self, mock_requests, provider):
|
||||
"""Test health check when model is not pulled."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"models": [{"name": "mistral"}]}
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
status = provider.health_check()
|
||||
|
||||
assert status.available is False
|
||||
assert "llama3" in status.error
|
||||
assert status.model == "llama3"
|
||||
assert status.model_available is False
|
||||
|
||||
@patch("services.providers.ollama_provider.requests")
|
||||
def test_health_check_unavailable(self, mock_requests, provider):
|
||||
"""Test health check when Ollama is unavailable."""
|
||||
mock_requests.get.side_effect = RequestsConnectionError("Connection refused")
|
||||
|
||||
status = provider.health_check()
|
||||
|
||||
assert status.available is False
|
||||
|
||||
@patch("services.providers.ollama_provider.requests")
|
||||
def test_health_check_caching(self, mock_requests, provider):
|
||||
"""Test that health check results are cached (no API call when cache valid)."""
|
||||
import time
|
||||
from services.providers.schemas import ProviderHealthStatus
|
||||
|
||||
current_time = time.time()
|
||||
cached_status = ProviderHealthStatus(
|
||||
name="ollama",
|
||||
available=True,
|
||||
latency_ms=50.0,
|
||||
error=None,
|
||||
last_check="2024-01-15T10:00:00Z",
|
||||
)
|
||||
provider._health_cache["health_check"] = {
|
||||
"value": cached_status,
|
||||
"timestamp": current_time,
|
||||
}
|
||||
|
||||
status = provider.health_check()
|
||||
|
||||
assert status.available is True
|
||||
mock_requests.get.assert_not_called()
|
||||
|
||||
|
||||
class TestOllamaProviderRetry:
|
||||
"""Tests for Ollama provider retry logic."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
"""Create an Ollama provider with retry enabled."""
|
||||
return OllamaTranslationProvider(
|
||||
base_url="http://localhost:11434",
|
||||
model="llama3",
|
||||
max_retries=2,
|
||||
retry_delay=0.01,
|
||||
)
|
||||
|
||||
@patch.object(OllamaTranslationProvider, "_fetch_available_models")
|
||||
@patch.object(OllamaTranslationProvider, "_make_api_request")
|
||||
def test_retry_on_timeout(self, mock_request, mock_models, provider):
|
||||
"""Test that timeout errors trigger retry."""
|
||||
mock_models.return_value = ["llama3"]
|
||||
mock_request.side_effect = [
|
||||
OllamaProviderError(OLLAMA_TIMEOUT, "Timeout"),
|
||||
"Bonjour",
|
||||
]
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.translated_text == "Bonjour"
|
||||
assert mock_request.call_count == 2
|
||||
|
||||
@patch.object(OllamaTranslationProvider, "_fetch_available_models")
|
||||
@patch.object(OllamaTranslationProvider, "_make_api_request")
|
||||
def test_no_retry_on_model_not_found(self, mock_request, mock_models, provider):
|
||||
"""Test that model not found errors do not trigger retry."""
|
||||
mock_models.return_value = ["llama3"]
|
||||
mock_request.side_effect = OllamaProviderError(
|
||||
OLLAMA_MODEL_NOT_FOUND, "Model not found"
|
||||
)
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
provider.translate_text(request)
|
||||
|
||||
assert mock_request.call_count == 1
|
||||
|
||||
@patch.object(OllamaTranslationProvider, "_fetch_available_models")
|
||||
@patch.object(OllamaTranslationProvider, "_make_api_request")
|
||||
def test_timeout_returns_ollama_timeout_error(self, mock_request, mock_models):
|
||||
"""Test that timeout without retry returns OLLAMA_TIMEOUT in response."""
|
||||
provider = OllamaTranslationProvider(
|
||||
base_url="http://localhost:11434",
|
||||
model="llama3",
|
||||
timeout=120,
|
||||
max_retries=0,
|
||||
)
|
||||
mock_models.return_value = ["llama3"]
|
||||
mock_request.side_effect = OllamaProviderError(
|
||||
OLLAMA_TIMEOUT,
|
||||
"Délai d'attente Ollama dépassé. Réessayez avec un texte plus court.",
|
||||
)
|
||||
|
||||
request = TranslationRequest(text="Hello", target_language="fr")
|
||||
response = provider.translate_text(request)
|
||||
|
||||
assert response.error_code == OLLAMA_TIMEOUT
|
||||
assert response.error is not None
|
||||
assert "Délai" in response.error or "timeout" in response.error.lower()
|
||||
|
||||
|
||||
class TestOllamaProviderSingleton:
|
||||
"""Tests for Ollama provider singleton functions."""
|
||||
|
||||
def test_get_ollama_provider(self):
|
||||
"""Test get_ollama_provider creates instance with config."""
|
||||
import services.providers.ollama_provider as ollama_module
|
||||
|
||||
ollama_module._provider_instance = None
|
||||
|
||||
with patch(
|
||||
"services.providers.config.ProvidersConfig"
|
||||
) as mock_config:
|
||||
mock_config.OLLAMA_BASE_URL = "http://localhost:11434"
|
||||
mock_config.OLLAMA_MODEL = "llama3"
|
||||
mock_config.OLLAMA_TIMEOUT = 120
|
||||
mock_config.OLLAMA_MAX_RETRIES = 2
|
||||
mock_config.OLLAMA_RETRY_DELAY = 2.0
|
||||
|
||||
provider = ollama_module.get_ollama_provider()
|
||||
|
||||
assert provider is not None
|
||||
assert provider._model == "llama3"
|
||||
|
||||
ollama_module._provider_instance = None
|
||||
|
||||
|
||||
class TestOllamaRegistryIntegration:
|
||||
"""Tests for Ollama provider registry integration."""
|
||||
|
||||
def test_register_ollama_provider(self):
|
||||
"""Test provider registration."""
|
||||
from services.providers.registry import registry
|
||||
|
||||
registry.unregister("ollama")
|
||||
|
||||
with patch(
|
||||
"services.providers.ollama_provider.get_ollama_provider"
|
||||
) as mock_get:
|
||||
mock_provider = MagicMock()
|
||||
mock_get.return_value = mock_provider
|
||||
|
||||
result = register_ollama_provider()
|
||||
|
||||
assert result == mock_provider
|
||||
assert "ollama" in registry
|
||||
registry.unregister("ollama")
|
||||
|
||||
|
||||
class TestOllamaModelCheck:
|
||||
"""Tests for Ollama model availability checking."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
"""Create an Ollama provider instance."""
|
||||
return OllamaTranslationProvider(
|
||||
base_url="http://localhost:11434",
|
||||
model="llama3",
|
||||
)
|
||||
|
||||
@patch("services.providers.ollama_provider.requests")
|
||||
def test_fetch_available_models(self, mock_requests, provider):
|
||||
"""Test fetching available models."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"models": [
|
||||
{"name": "llama3:latest"},
|
||||
{"name": "mistral:latest"},
|
||||
]
|
||||
}
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
models = provider._fetch_available_models()
|
||||
|
||||
assert "llama3:latest" in models
|
||||
assert "mistral:latest" in models
|
||||
|
||||
def test_check_model_available(self, provider):
|
||||
"""Test model availability checking."""
|
||||
import time
|
||||
|
||||
provider._available_models = ["llama3:latest", "mistral:latest"]
|
||||
provider._models_cache_time = time.time()
|
||||
|
||||
assert provider._check_model_available("llama3") is True
|
||||
assert provider._check_model_available("mistral") is True
|
||||
assert provider._check_model_available("qwen2") is False
|
||||
@@ -106,17 +106,6 @@ class TestAPIVersioning:
|
||||
response = client.get("/rate-limit/status")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_versioned_ollama_models_accessible(self, client):
|
||||
"""AC1: Ollama models should be accessible under /api/v1/ollama/models"""
|
||||
response = client.get("/api/v1/ollama/models")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "models" in data
|
||||
|
||||
def test_unversioned_ollama_models_returns_404(self, client):
|
||||
"""AC2: Unversioned /ollama/models should return 404"""
|
||||
response = client.get("/ollama/models")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestAPIVersioningAdminEndpoints:
|
||||
|
||||
@@ -771,21 +771,6 @@ class TestProviderParameter:
|
||||
)
|
||||
assert response.status_code == 202
|
||||
|
||||
def test_accepts_provider_ollama(self, authenticated_client):
|
||||
"""Accepts provider='ollama'"""
|
||||
excel_content = create_valid_excel()
|
||||
response = authenticated_client.post(
|
||||
TRANSLATE_URL,
|
||||
files={
|
||||
"file": (
|
||||
"test.xlsx",
|
||||
io.BytesIO(excel_content),
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
},
|
||||
data={"target_lang": "fr", "provider": "ollama"},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
|
||||
|
||||
class TestSourceLangValidation:
|
||||
|
||||
Reference in New Issue
Block a user