IIIF-Studio / backend /tests /test_ai_providers.py
Claude
refactor: Sprint 3b — remove dead code, non-functional provider, unused fields
11f019c unverified
Raw
History Blame Contribute Delete
15.8 kB
"""
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")