Spaces:
Build error
Build error
Claude
refactor: Sprint 3b — remove dead code, non-functional provider, unused fields
11f019c unverified | """ | |
| Tests des providers Google AI et du registre de modèles. | |
| Aucun appel réseau réel — tous les clients SDK sont mockés. | |
| """ | |
| # 1. stdlib | |
| import json | |
| from datetime import datetime, timezone | |
| from unittest.mock import MagicMock, patch | |
| # 2. third-party | |
| import pytest | |
| from pydantic import ValidationError | |
| # 3. local | |
| from app.schemas.model_config import ModelConfig, ModelInfo, ProviderType | |
| from app.services.ai.base import is_vision_model | |
| from app.services.ai.model_registry import build_model_config, list_all_models | |
| from app.services.ai.provider_google_ai import GoogleAIProvider | |
| from app.services.ai.provider_vertex_sa import VertexServiceAccountProvider | |
| # --------------------------------------------------------------------------- | |
| # Données de test partagées | |
| # --------------------------------------------------------------------------- | |
| FAKE_SA_JSON = { | |
| "type": "service_account", | |
| "project_id": "test-project-123", | |
| "private_key_id": "key-abc", | |
| "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n-----END RSA PRIVATE KEY-----\n", | |
| "client_email": "test-sa@test-project-123.iam.gserviceaccount.com", | |
| "client_id": "123456789", | |
| "auth_uri": "https://accounts.google.com/o/oauth2/auth", | |
| "token_uri": "https://oauth2.googleapis.com/token", | |
| } | |
| def _make_mock_model( | |
| name: str = "models/gemini-1.5-pro", | |
| display_name: str = "Gemini 1.5 Pro", | |
| methods: list[str] | None = None, | |
| input_token_limit: int = 1_000_000, | |
| output_token_limit: int = 8192, | |
| ) -> MagicMock: | |
| """Construit un objet modèle factice imitant google.genai.types.Model.""" | |
| m = MagicMock() | |
| m.name = name | |
| m.display_name = display_name | |
| m.supported_generation_methods = methods if methods is not None else ["generateContent"] | |
| m.input_token_limit = input_token_limit | |
| m.output_token_limit = output_token_limit | |
| return m | |
| # --------------------------------------------------------------------------- | |
| # Tests — ModelInfo (schéma) | |
| # --------------------------------------------------------------------------- | |
| def test_model_info_valid(): | |
| info = ModelInfo( | |
| model_id="models/gemini-1.5-pro", | |
| display_name="Gemini 1.5 Pro", | |
| provider=ProviderType.GOOGLE_AI_STUDIO, | |
| supports_vision=True, | |
| input_token_limit=1_000_000, | |
| output_token_limit=8192, | |
| ) | |
| assert info.model_id == "models/gemini-1.5-pro" | |
| assert info.supports_vision is True | |
| def test_model_info_is_frozen(): | |
| info = ModelInfo( | |
| model_id="models/gemini-1.5-pro", | |
| display_name="Gemini 1.5 Pro", | |
| provider=ProviderType.GOOGLE_AI_STUDIO, | |
| supports_vision=True, | |
| ) | |
| with pytest.raises((TypeError, ValidationError)): | |
| info.model_id = "changed" # type: ignore[misc] | |
| def test_model_info_optional_token_limits(): | |
| info = ModelInfo( | |
| model_id="models/gemini-2.0-flash", | |
| display_name="Gemini 2.0 Flash", | |
| provider=ProviderType.VERTEX_SERVICE_ACCOUNT, | |
| supports_vision=True, | |
| ) | |
| assert info.input_token_limit is None | |
| assert info.output_token_limit is None | |
| def test_model_info_all_provider_types(): | |
| for ptype in ProviderType: | |
| info = ModelInfo( | |
| model_id=f"models/test-{ptype.value}", | |
| display_name="Test", | |
| provider=ptype, | |
| supports_vision=False, | |
| ) | |
| assert info.provider == ptype | |
| # --------------------------------------------------------------------------- | |
| # Tests — ModelConfig (schéma) | |
| # --------------------------------------------------------------------------- | |
| def test_model_config_valid(): | |
| cfg = ModelConfig( | |
| corpus_id="corpus-001", | |
| selected_model_id="models/gemini-1.5-pro", | |
| selected_model_display_name="Gemini 1.5 Pro", | |
| provider=ProviderType.GOOGLE_AI_STUDIO, | |
| supports_vision=True, | |
| last_fetched_at=datetime(2026, 3, 17, tzinfo=timezone.utc), | |
| ) | |
| assert cfg.corpus_id == "corpus-001" | |
| assert cfg.supports_vision is True | |
| def test_model_config_missing_required_field(): | |
| with pytest.raises(ValidationError): | |
| ModelConfig.model_validate({"corpus_id": "x"}) | |
| # --------------------------------------------------------------------------- | |
| # Tests — is_vision_model helper | |
| # --------------------------------------------------------------------------- | |
| def test_is_vision_model_gemini(): | |
| m = MagicMock() | |
| m.name = "models/gemini-1.5-pro" | |
| m.display_name = "Gemini 1.5 Pro" | |
| assert is_vision_model(m) is True | |
| def test_is_vision_model_vision_in_name(): | |
| m = MagicMock() | |
| m.name = "models/some-vision-model" | |
| m.display_name = "Some Model" | |
| assert is_vision_model(m) is True | |
| def test_is_vision_model_vision_in_display(): | |
| m = MagicMock() | |
| m.name = "models/some-model" | |
| m.display_name = "Some Vision Model" | |
| assert is_vision_model(m) is True | |
| def test_is_vision_model_text_only(): | |
| m = MagicMock() | |
| m.name = "models/text-embedding-004" | |
| m.display_name = "Text Embedding" | |
| assert is_vision_model(m) is False | |
| # --------------------------------------------------------------------------- | |
| # Tests — GoogleAIProvider | |
| # --------------------------------------------------------------------------- | |
| def test_google_ai_provider_not_configured(monkeypatch): | |
| monkeypatch.delenv("GOOGLE_AI_STUDIO_API_KEY", raising=False) | |
| provider = GoogleAIProvider() | |
| assert provider.is_configured() is False | |
| def test_google_ai_provider_configured(monkeypatch): | |
| monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "fake-key") | |
| provider = GoogleAIProvider() | |
| assert provider.is_configured() is True | |
| def test_google_ai_provider_type(): | |
| assert GoogleAIProvider().provider_type == ProviderType.GOOGLE_AI_STUDIO | |
| def test_google_ai_provider_list_models_not_configured(monkeypatch): | |
| monkeypatch.delenv("GOOGLE_AI_STUDIO_API_KEY", raising=False) | |
| with pytest.raises(RuntimeError, match="GOOGLE_AI_STUDIO_API_KEY"): | |
| GoogleAIProvider().list_models() | |
| def test_google_ai_provider_list_models_success(monkeypatch): | |
| monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "fake-key") | |
| mock_model = _make_mock_model() | |
| with patch("app.services.ai.provider_google_ai.genai.Client") as MockClient: | |
| MockClient.return_value.models.list.return_value = [mock_model] | |
| models = GoogleAIProvider().list_models() | |
| assert len(models) == 1 | |
| assert models[0].model_id == "models/gemini-1.5-pro" | |
| assert models[0].provider == ProviderType.GOOGLE_AI_STUDIO | |
| assert models[0].supports_vision is True | |
| MockClient.assert_called_once_with(api_key="fake-key") | |
| def test_google_ai_provider_filters_non_generate_content(monkeypatch): | |
| monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "fake-key") | |
| embedding = _make_mock_model( | |
| name="models/text-embedding-004", | |
| display_name="Text Embedding", | |
| methods=["embedContent"], | |
| ) | |
| gemini = _make_mock_model() | |
| with patch("app.services.ai.provider_google_ai.genai.Client") as MockClient: | |
| MockClient.return_value.models.list.return_value = [embedding, gemini] | |
| models = GoogleAIProvider().list_models() | |
| assert len(models) == 1 | |
| assert models[0].model_id == "models/gemini-1.5-pro" | |
| def test_google_ai_provider_empty_list(monkeypatch): | |
| monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "fake-key") | |
| with patch("app.services.ai.provider_google_ai.genai.Client") as MockClient: | |
| MockClient.return_value.models.list.return_value = [] | |
| models = GoogleAIProvider().list_models() | |
| assert models == [] | |
| # --------------------------------------------------------------------------- | |
| # Tests — VertexServiceAccountProvider | |
| # --------------------------------------------------------------------------- | |
| def test_vertex_sa_provider_not_configured(monkeypatch): | |
| monkeypatch.delenv("VERTEX_SERVICE_ACCOUNT_JSON", raising=False) | |
| assert VertexServiceAccountProvider().is_configured() is False | |
| def test_vertex_sa_provider_configured(monkeypatch): | |
| monkeypatch.setenv("VERTEX_SERVICE_ACCOUNT_JSON", json.dumps(FAKE_SA_JSON)) | |
| assert VertexServiceAccountProvider().is_configured() is True | |
| def test_vertex_sa_provider_type(): | |
| assert VertexServiceAccountProvider().provider_type == ProviderType.VERTEX_SERVICE_ACCOUNT | |
| def test_vertex_sa_provider_list_models_not_configured(monkeypatch): | |
| monkeypatch.delenv("VERTEX_SERVICE_ACCOUNT_JSON", raising=False) | |
| with pytest.raises(RuntimeError, match="VERTEX_SERVICE_ACCOUNT_JSON"): | |
| VertexServiceAccountProvider().list_models() | |
| def test_vertex_sa_provider_invalid_json(monkeypatch): | |
| monkeypatch.setenv("VERTEX_SERVICE_ACCOUNT_JSON", "not-valid-json{{{") | |
| with pytest.raises(ValueError, match="JSON invalide"): | |
| VertexServiceAccountProvider().list_models() | |
| def test_vertex_sa_provider_missing_project_id(monkeypatch): | |
| sa_no_project = {k: v for k, v in FAKE_SA_JSON.items() if k != "project_id"} | |
| monkeypatch.setenv("VERTEX_SERVICE_ACCOUNT_JSON", json.dumps(sa_no_project)) | |
| with pytest.raises(ValueError, match="project_id"): | |
| VertexServiceAccountProvider().list_models() | |
| def test_vertex_sa_provider_list_models_success(monkeypatch): | |
| monkeypatch.setenv("VERTEX_SERVICE_ACCOUNT_JSON", json.dumps(FAKE_SA_JSON)) | |
| mock_model = _make_mock_model( | |
| name="models/gemini-1.5-pro-002", | |
| display_name="Gemini 1.5 Pro 002", | |
| ) | |
| mock_credentials = MagicMock() | |
| with patch( | |
| "app.services.ai.provider_vertex_sa.service_account.Credentials.from_service_account_info", | |
| return_value=mock_credentials, | |
| ) as mock_creds_factory: | |
| with patch("app.services.ai.provider_vertex_sa.genai.Client") as MockClient: | |
| MockClient.return_value.models.list.return_value = [mock_model] | |
| models = VertexServiceAccountProvider().list_models() | |
| assert len(models) == 1 | |
| assert models[0].model_id == "models/gemini-1.5-pro-002" | |
| assert models[0].provider == ProviderType.VERTEX_SERVICE_ACCOUNT | |
| mock_creds_factory.assert_called_once_with( | |
| FAKE_SA_JSON, | |
| scopes=["https://www.googleapis.com/auth/cloud-platform"], | |
| ) | |
| MockClient.assert_called_once_with( | |
| vertexai=True, | |
| project="test-project-123", | |
| location="us-central1", | |
| credentials=mock_credentials, | |
| ) | |
| def test_vertex_sa_provider_filters_non_generate_content(monkeypatch): | |
| monkeypatch.setenv("VERTEX_SERVICE_ACCOUNT_JSON", json.dumps(FAKE_SA_JSON)) | |
| embedding = _make_mock_model( | |
| name="models/textembedding-gecko", | |
| display_name="Text Embedding Gecko", | |
| methods=["embedContent"], | |
| ) | |
| with patch( | |
| "app.services.ai.provider_vertex_sa.service_account.Credentials.from_service_account_info", | |
| return_value=MagicMock(), | |
| ): | |
| with patch("app.services.ai.provider_vertex_sa.genai.Client") as MockClient: | |
| MockClient.return_value.models.list.return_value = [embedding] | |
| models = VertexServiceAccountProvider().list_models() | |
| assert models == [] | |
| # --------------------------------------------------------------------------- | |
| # Tests — model_registry.list_all_models | |
| # --------------------------------------------------------------------------- | |
| def test_list_all_models_no_providers_configured(monkeypatch): | |
| monkeypatch.delenv("GOOGLE_AI_STUDIO_API_KEY", raising=False) | |
| monkeypatch.delenv("VERTEX_SERVICE_ACCOUNT_JSON", raising=False) | |
| result = list_all_models() | |
| assert result == [] | |
| def test_list_all_models_one_provider(monkeypatch): | |
| monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "fake-key") | |
| monkeypatch.delenv("VERTEX_SERVICE_ACCOUNT_JSON", raising=False) | |
| mock_model = _make_mock_model() | |
| with patch("app.services.ai.provider_google_ai.genai.Client") as MockClient: | |
| MockClient.return_value.models.list.return_value = [mock_model] | |
| result = list_all_models() | |
| assert len(result) == 1 | |
| assert result[0].provider == ProviderType.GOOGLE_AI_STUDIO | |
| def test_list_all_models_aggregates_multiple_providers(monkeypatch): | |
| """Deux providers configurés → les deux listes sont agrégées. | |
| On utilise Google AI Studio + Vertex Service Account pour tester l'agrégation. | |
| """ | |
| monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "fake-key-ai") | |
| monkeypatch.setenv("VERTEX_SERVICE_ACCOUNT_JSON", "{}") # déclenche is_configured() | |
| models_ai = [ModelInfo( | |
| model_id="models/gemini-1.5-pro", | |
| display_name="Gemini 1.5 Pro", | |
| provider=ProviderType.GOOGLE_AI_STUDIO, | |
| supports_vision=True, | |
| )] | |
| models_sa = [ModelInfo( | |
| model_id="models/gemini-2.0-flash", | |
| display_name="Gemini 2.0 Flash", | |
| provider=ProviderType.VERTEX_SERVICE_ACCOUNT, | |
| supports_vision=True, | |
| )] | |
| with patch.object(GoogleAIProvider, "list_models", return_value=models_ai): | |
| with patch.object(VertexServiceAccountProvider, "list_models", return_value=models_sa): | |
| result = list_all_models() | |
| assert len(result) == 2 | |
| providers = {m.provider for m in result} | |
| assert ProviderType.GOOGLE_AI_STUDIO in providers | |
| assert ProviderType.VERTEX_SERVICE_ACCOUNT in providers | |
| def test_list_all_models_failing_provider_is_skipped(monkeypatch): | |
| """Un provider configuré qui échoue est ignoré ; l'autre est retourné.""" | |
| monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "bad-key") | |
| monkeypatch.setenv("VERTEX_SERVICE_ACCOUNT_JSON", "{}") | |
| models_sa = [ModelInfo( | |
| model_id="models/gemini-2.0-flash", | |
| display_name="Gemini 2.0 Flash", | |
| provider=ProviderType.VERTEX_SERVICE_ACCOUNT, | |
| supports_vision=True, | |
| )] | |
| with patch.object(GoogleAIProvider, "list_models", side_effect=Exception("API key invalid")): | |
| with patch.object(VertexServiceAccountProvider, "list_models", return_value=models_sa): | |
| result = list_all_models() | |
| assert len(result) == 1 | |
| assert result[0].provider == ProviderType.VERTEX_SERVICE_ACCOUNT | |
| # --------------------------------------------------------------------------- | |
| # Tests — model_registry.build_model_config | |
| # --------------------------------------------------------------------------- | |
| def test_build_model_config_valid(monkeypatch): | |
| monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "fake-key") | |
| monkeypatch.delenv("VERTEX_SERVICE_ACCOUNT_JSON", raising=False) | |
| mock_model = _make_mock_model() | |
| with patch("app.services.ai.provider_google_ai.genai.Client") as MockClient: | |
| MockClient.return_value.models.list.return_value = [mock_model] | |
| cfg = build_model_config("corpus-001", "models/gemini-1.5-pro") | |
| assert cfg.corpus_id == "corpus-001" | |
| assert cfg.selected_model_id == "models/gemini-1.5-pro" | |
| assert cfg.selected_model_display_name == "Gemini 1.5 Pro" | |
| assert cfg.provider == ProviderType.GOOGLE_AI_STUDIO | |
| assert cfg.supports_vision is True | |
| def test_build_model_config_unknown_model(monkeypatch): | |
| monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "fake-key") | |
| monkeypatch.delenv("VERTEX_SERVICE_ACCOUNT_JSON", raising=False) | |
| mock_model = _make_mock_model() | |
| with patch("app.services.ai.provider_google_ai.genai.Client") as MockClient: | |
| MockClient.return_value.models.list.return_value = [mock_model] | |
| with pytest.raises(ValueError, match="non disponible"): | |
| build_model_config("corpus-001", "models/nonexistent-model") | |
| def test_build_model_config_no_providers(monkeypatch): | |
| monkeypatch.delenv("GOOGLE_AI_STUDIO_API_KEY", raising=False) | |
| monkeypatch.delenv("VERTEX_SERVICE_ACCOUNT_JSON", raising=False) | |
| with pytest.raises(ValueError, match="non disponible"): | |
| build_model_config("corpus-001", "models/gemini-1.5-pro") | |