Claude commited on
Commit
02197c5
·
unverified ·
1 Parent(s): 43afaf6

Sprint 2 Session A — Couche providers Google AI (3 modes d'auth)

Browse files

Schéma :
- model_config.py : ProviderType, ModelInfo (frozen), ModelConfig

Service AI :
- base.py : ABC AIProvider + helper is_vision_model
- provider_google_ai.py : Mode 1 — GOOGLE_AI_STUDIO_API_KEY (google-genai SDK)
- provider_vertex_key.py : Mode 2 — VERTEX_API_KEY (google-genai SDK)
- provider_vertex_sa.py : Mode 3 — VERTEX_SERVICE_ACCOUNT_JSON (google-auth SA)
- model_registry.py : list_all_models() + build_model_config() agrégés

Règles respectées :
- R06 : clés API uniquement via os.environ
- R08 : 37 tests pytest, 91/91 passés
- R11 : import exclusivement `from google import genai`
- Providers non configurés ignorés silencieusement
- Providers défaillants loggés en warning, non propagés

Deps : google-genai>=1.0, google-auth>=2.0 (httpx déjà présent)

https://claude.ai/code/session_018woyEHc8HG2th7V4ewJ4Kg

backend/app/schemas/model_config.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Schémas Pydantic pour la configuration et la découverte des modèles IA.
3
+ """
4
+ # 1. stdlib
5
+ from datetime import datetime
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+ # 2. third-party
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+
13
+ class ProviderType(str, Enum):
14
+ GOOGLE_AI_STUDIO = "google_ai_studio"
15
+ VERTEX_API_KEY = "vertex_api_key"
16
+ VERTEX_SERVICE_ACCOUNT = "vertex_service_account"
17
+
18
+
19
+ class ModelInfo(BaseModel):
20
+ """Décrit un modèle IA disponible chez un provider."""
21
+
22
+ model_config = ConfigDict(frozen=True)
23
+
24
+ model_id: str
25
+ display_name: str
26
+ provider: ProviderType
27
+ supports_vision: bool
28
+ input_token_limit: int | None = None
29
+ output_token_limit: int | None = None
30
+
31
+
32
+ class ModelConfig(BaseModel):
33
+ """Configuration du modèle sélectionné pour un corpus (CLAUDE.md §9)."""
34
+
35
+ corpus_id: str
36
+ selected_model_id: str
37
+ selected_model_display_name: str
38
+ provider: ProviderType
39
+ supports_vision: bool
40
+ last_fetched_at: datetime
41
+ available_models: list[dict[str, Any]] # cache sérialisé des ModelInfo
backend/app/services/ai/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Services AI — providers Google AI et registre de modèles.
3
+ """
4
+ from app.services.ai.model_registry import build_model_config, list_all_models
5
+ from app.services.ai.provider_google_ai import GoogleAIProvider
6
+ from app.services.ai.provider_vertex_key import VertexAPIKeyProvider
7
+ from app.services.ai.provider_vertex_sa import VertexServiceAccountProvider
8
+
9
+ __all__ = [
10
+ "GoogleAIProvider",
11
+ "VertexAPIKeyProvider",
12
+ "VertexServiceAccountProvider",
13
+ "list_all_models",
14
+ "build_model_config",
15
+ ]
backend/app/services/ai/base.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Interface abstraite commune à tous les providers Google AI.
3
+ """
4
+ # 1. stdlib
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any
7
+
8
+ # 2. local
9
+ from app.schemas.model_config import ModelInfo, ProviderType
10
+
11
+
12
+ def is_vision_model(model: Any) -> bool:
13
+ """Détermine si un modèle supporte les entrées image.
14
+
15
+ Les modèles Gemini sont tous multimodaux ; les modèles texte-only (ex :
16
+ embedding, AQA) ne contiennent pas 'gemini' dans leur identifiant.
17
+ """
18
+ name = (getattr(model, "name", "") or "").lower()
19
+ display = (getattr(model, "display_name", "") or "").lower()
20
+ return "gemini" in name or "vision" in name or "vision" in display
21
+
22
+
23
+ class AIProvider(ABC):
24
+ """Interface commune à tous les providers Google AI."""
25
+
26
+ @property
27
+ @abstractmethod
28
+ def provider_type(self) -> ProviderType: ...
29
+
30
+ @abstractmethod
31
+ def is_configured(self) -> bool:
32
+ """Retourne True si les credentials nécessaires sont présents en environnement."""
33
+ ...
34
+
35
+ @abstractmethod
36
+ def list_models(self) -> list[ModelInfo]:
37
+ """Liste les modèles filtrés (generateContent présent dans les méthodes supportées).
38
+
39
+ Lève RuntimeError si le provider n'est pas configuré.
40
+ Propage les exceptions réseau/API sans les masquer.
41
+ """
42
+ ...
backend/app/services/ai/model_registry.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Registre agrégé des modèles disponibles tous providers confondus.
3
+ """
4
+ # 1. stdlib
5
+ import logging
6
+ from datetime import datetime, timezone
7
+
8
+ # 2. local
9
+ from app.schemas.model_config import ModelConfig, ModelInfo
10
+ from app.services.ai.base import AIProvider
11
+ from app.services.ai.provider_google_ai import GoogleAIProvider
12
+ from app.services.ai.provider_vertex_key import VertexAPIKeyProvider
13
+ from app.services.ai.provider_vertex_sa import VertexServiceAccountProvider
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _build_providers() -> list[AIProvider]:
19
+ return [
20
+ GoogleAIProvider(),
21
+ VertexAPIKeyProvider(),
22
+ VertexServiceAccountProvider(),
23
+ ]
24
+
25
+
26
+ def list_all_models() -> list[ModelInfo]:
27
+ """Interroge tous les providers configurés et retourne la liste agrégée.
28
+
29
+ - Un provider non configuré (credentials absentes) est silencieusement ignoré.
30
+ - Un provider défaillant (clé invalide, erreur réseau) logue un warning et est ignoré.
31
+ """
32
+ result: list[ModelInfo] = []
33
+
34
+ for provider in _build_providers():
35
+ if not provider.is_configured():
36
+ logger.debug(
37
+ "Provider non configuré, ignoré",
38
+ extra={"provider": provider.provider_type},
39
+ )
40
+ continue
41
+
42
+ try:
43
+ models = provider.list_models()
44
+ result.extend(models)
45
+ logger.info(
46
+ "Provider interrogé avec succès",
47
+ extra={"provider": provider.provider_type, "count": len(models)},
48
+ )
49
+ except Exception as exc:
50
+ logger.warning(
51
+ "Provider inaccessible",
52
+ extra={"provider": provider.provider_type, "error": str(exc)},
53
+ )
54
+
55
+ return result
56
+
57
+
58
+ def build_model_config(corpus_id: str, selected_model_id: str) -> ModelConfig:
59
+ """Construit un ModelConfig à partir d'un model_id sélectionné.
60
+
61
+ Lève ValueError si le modèle n'est pas dans la liste des disponibles.
62
+ """
63
+ models = list_all_models()
64
+ model_map = {m.model_id: m for m in models}
65
+
66
+ if selected_model_id not in model_map:
67
+ available = sorted(model_map.keys())
68
+ raise ValueError(
69
+ f"Modèle '{selected_model_id}' non disponible. "
70
+ f"Modèles disponibles : {available}"
71
+ )
72
+
73
+ selected = model_map[selected_model_id]
74
+ return ModelConfig(
75
+ corpus_id=corpus_id,
76
+ selected_model_id=selected.model_id,
77
+ selected_model_display_name=selected.display_name,
78
+ provider=selected.provider,
79
+ supports_vision=selected.supports_vision,
80
+ last_fetched_at=datetime.now(tz=timezone.utc),
81
+ available_models=[m.model_dump() for m in models],
82
+ )
backend/app/services/ai/provider_google_ai.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Provider Google AI Studio — authentification via GOOGLE_AI_STUDIO_API_KEY.
3
+ """
4
+ # 1. stdlib
5
+ import logging
6
+ import os
7
+
8
+ # 2. third-party
9
+ from google import genai
10
+
11
+ # 3. local
12
+ from app.schemas.model_config import ModelInfo, ProviderType
13
+ from app.services.ai.base import AIProvider, is_vision_model
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _ENV_KEY = "GOOGLE_AI_STUDIO_API_KEY"
18
+
19
+
20
+ class GoogleAIProvider(AIProvider):
21
+ """Provider Google AI Studio (clé API GOOGLE_AI_STUDIO_API_KEY)."""
22
+
23
+ @property
24
+ def provider_type(self) -> ProviderType:
25
+ return ProviderType.GOOGLE_AI_STUDIO
26
+
27
+ def is_configured(self) -> bool:
28
+ return bool(os.environ.get(_ENV_KEY))
29
+
30
+ def list_models(self) -> list[ModelInfo]:
31
+ if not self.is_configured():
32
+ raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
33
+
34
+ client = genai.Client(api_key=os.environ[_ENV_KEY])
35
+ result: list[ModelInfo] = []
36
+
37
+ for model in client.models.list():
38
+ methods = getattr(model, "supported_generation_methods", []) or []
39
+ if "generateContent" not in methods:
40
+ continue
41
+
42
+ result.append(ModelInfo(
43
+ model_id=model.name,
44
+ display_name=getattr(model, "display_name", model.name),
45
+ provider=self.provider_type,
46
+ supports_vision=is_vision_model(model),
47
+ input_token_limit=getattr(model, "input_token_limit", None),
48
+ output_token_limit=getattr(model, "output_token_limit", None),
49
+ ))
50
+
51
+ logger.info(
52
+ "Google AI Studio models fetched",
53
+ extra={"provider": self.provider_type, "count": len(result)},
54
+ )
55
+ return result
backend/app/services/ai/provider_vertex_key.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Provider Vertex AI — authentification via clé API GCP (VERTEX_API_KEY).
3
+ """
4
+ # 1. stdlib
5
+ import logging
6
+ import os
7
+
8
+ # 2. third-party
9
+ from google import genai
10
+
11
+ # 3. local
12
+ from app.schemas.model_config import ModelInfo, ProviderType
13
+ from app.services.ai.base import AIProvider, is_vision_model
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _ENV_KEY = "VERTEX_API_KEY"
18
+
19
+
20
+ class VertexAPIKeyProvider(AIProvider):
21
+ """Provider Vertex AI via clé API GCP (VERTEX_API_KEY).
22
+
23
+ Utilise le SDK google-genai avec la clé GCP. La clé doit être autorisée
24
+ sur l'API Generative Language dans la console GCP.
25
+ """
26
+
27
+ @property
28
+ def provider_type(self) -> ProviderType:
29
+ return ProviderType.VERTEX_API_KEY
30
+
31
+ def is_configured(self) -> bool:
32
+ return bool(os.environ.get(_ENV_KEY))
33
+
34
+ def list_models(self) -> list[ModelInfo]:
35
+ if not self.is_configured():
36
+ raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
37
+
38
+ client = genai.Client(api_key=os.environ[_ENV_KEY])
39
+ result: list[ModelInfo] = []
40
+
41
+ for model in client.models.list():
42
+ methods = getattr(model, "supported_generation_methods", []) or []
43
+ if "generateContent" not in methods:
44
+ continue
45
+
46
+ result.append(ModelInfo(
47
+ model_id=model.name,
48
+ display_name=getattr(model, "display_name", model.name),
49
+ provider=self.provider_type,
50
+ supports_vision=is_vision_model(model),
51
+ input_token_limit=getattr(model, "input_token_limit", None),
52
+ output_token_limit=getattr(model, "output_token_limit", None),
53
+ ))
54
+
55
+ logger.info(
56
+ "Vertex API key models fetched",
57
+ extra={"provider": self.provider_type, "count": len(result)},
58
+ )
59
+ return result
backend/app/services/ai/provider_vertex_sa.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Provider Vertex AI — authentification via compte de service JSON (VERTEX_SERVICE_ACCOUNT_JSON).
3
+ """
4
+ # 1. stdlib
5
+ import json
6
+ import logging
7
+ import os
8
+
9
+ # 2. third-party
10
+ from google import genai
11
+ from google.oauth2 import service_account
12
+
13
+ # 3. local
14
+ from app.schemas.model_config import ModelInfo, ProviderType
15
+ from app.services.ai.base import AIProvider, is_vision_model
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _ENV_KEY = "VERTEX_SERVICE_ACCOUNT_JSON"
20
+ _VERTEX_SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
21
+ _DEFAULT_LOCATION = "us-central1"
22
+
23
+
24
+ class VertexServiceAccountProvider(AIProvider):
25
+ """Provider Vertex AI via compte de service JSON (VERTEX_SERVICE_ACCOUNT_JSON).
26
+
27
+ Le JSON complet du compte de service est lu depuis la variable d'environnement.
28
+ Le project_id est extrait du JSON ; la localisation par défaut est us-central1.
29
+ """
30
+
31
+ @property
32
+ def provider_type(self) -> ProviderType:
33
+ return ProviderType.VERTEX_SERVICE_ACCOUNT
34
+
35
+ def is_configured(self) -> bool:
36
+ return bool(os.environ.get(_ENV_KEY))
37
+
38
+ def list_models(self) -> list[ModelInfo]:
39
+ if not self.is_configured():
40
+ raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
41
+
42
+ sa_json_str = os.environ[_ENV_KEY]
43
+ try:
44
+ sa_info = json.loads(sa_json_str)
45
+ except json.JSONDecodeError as exc:
46
+ raise ValueError(
47
+ f"{_ENV_KEY} : JSON invalide — {exc}"
48
+ ) from exc
49
+
50
+ project_id: str | None = sa_info.get("project_id")
51
+ if not project_id:
52
+ raise ValueError(
53
+ f"{_ENV_KEY} : champ 'project_id' manquant dans le JSON"
54
+ )
55
+
56
+ credentials = service_account.Credentials.from_service_account_info(
57
+ sa_info,
58
+ scopes=_VERTEX_SCOPES,
59
+ )
60
+ client = genai.Client(
61
+ vertexai=True,
62
+ project=project_id,
63
+ location=_DEFAULT_LOCATION,
64
+ credentials=credentials,
65
+ )
66
+ result: list[ModelInfo] = []
67
+
68
+ for model in client.models.list():
69
+ methods = getattr(model, "supported_generation_methods", []) or []
70
+ if "generateContent" not in methods:
71
+ continue
72
+
73
+ result.append(ModelInfo(
74
+ model_id=model.name,
75
+ display_name=getattr(model, "display_name", model.name),
76
+ provider=self.provider_type,
77
+ supports_vision=is_vision_model(model),
78
+ input_token_limit=getattr(model, "input_token_limit", None),
79
+ output_token_limit=getattr(model, "output_token_limit", None),
80
+ ))
81
+
82
+ logger.info(
83
+ "Vertex service account models fetched",
84
+ extra={
85
+ "provider": self.provider_type,
86
+ "project": project_id,
87
+ "count": len(result),
88
+ },
89
+ )
90
+ return result
backend/pyproject.toml CHANGED
@@ -13,7 +13,9 @@ dependencies = [
13
  "pydantic>=2.7",
14
  "sqlalchemy>=2.0",
15
  "aiosqlite>=0.20",
16
- "google-generativeai>=0.3",
 
 
17
  "lxml>=5.2",
18
  "Pillow>=10.3",
19
  ]
 
13
  "pydantic>=2.7",
14
  "sqlalchemy>=2.0",
15
  "aiosqlite>=0.20",
16
+ "google-genai>=1.0",
17
+ "google-auth>=2.0",
18
+ "httpx>=0.27",
19
  "lxml>=5.2",
20
  "Pillow>=10.3",
21
  ]
backend/tests/test_ai_providers.py ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests des providers Google AI et du registre de modèles.
3
+ Aucun appel réseau réel — tous les clients SDK sont mockés.
4
+ """
5
+ # 1. stdlib
6
+ import json
7
+ from datetime import datetime, timezone
8
+ from unittest.mock import MagicMock, patch
9
+
10
+ # 2. third-party
11
+ import pytest
12
+ from pydantic import ValidationError
13
+
14
+ # 3. local
15
+ from app.schemas.model_config import ModelConfig, ModelInfo, ProviderType
16
+ from app.services.ai.base import is_vision_model
17
+ from app.services.ai.model_registry import build_model_config, list_all_models
18
+ from app.services.ai.provider_google_ai import GoogleAIProvider
19
+ from app.services.ai.provider_vertex_key import VertexAPIKeyProvider
20
+ from app.services.ai.provider_vertex_sa import VertexServiceAccountProvider
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Données de test partagées
24
+ # ---------------------------------------------------------------------------
25
+
26
+ FAKE_SA_JSON = {
27
+ "type": "service_account",
28
+ "project_id": "test-project-123",
29
+ "private_key_id": "key-abc",
30
+ "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n-----END RSA PRIVATE KEY-----\n",
31
+ "client_email": "test-sa@test-project-123.iam.gserviceaccount.com",
32
+ "client_id": "123456789",
33
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
34
+ "token_uri": "https://oauth2.googleapis.com/token",
35
+ }
36
+
37
+
38
+ def _make_mock_model(
39
+ name: str = "models/gemini-1.5-pro",
40
+ display_name: str = "Gemini 1.5 Pro",
41
+ methods: list[str] | None = None,
42
+ input_token_limit: int = 1_000_000,
43
+ output_token_limit: int = 8192,
44
+ ) -> MagicMock:
45
+ """Construit un objet modèle factice imitant google.genai.types.Model."""
46
+ m = MagicMock()
47
+ m.name = name
48
+ m.display_name = display_name
49
+ m.supported_generation_methods = methods if methods is not None else ["generateContent"]
50
+ m.input_token_limit = input_token_limit
51
+ m.output_token_limit = output_token_limit
52
+ return m
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Tests — ModelInfo (schéma)
57
+ # ---------------------------------------------------------------------------
58
+
59
+ def test_model_info_valid():
60
+ info = ModelInfo(
61
+ model_id="models/gemini-1.5-pro",
62
+ display_name="Gemini 1.5 Pro",
63
+ provider=ProviderType.GOOGLE_AI_STUDIO,
64
+ supports_vision=True,
65
+ input_token_limit=1_000_000,
66
+ output_token_limit=8192,
67
+ )
68
+ assert info.model_id == "models/gemini-1.5-pro"
69
+ assert info.supports_vision is True
70
+
71
+
72
+ def test_model_info_is_frozen():
73
+ info = ModelInfo(
74
+ model_id="models/gemini-1.5-pro",
75
+ display_name="Gemini 1.5 Pro",
76
+ provider=ProviderType.GOOGLE_AI_STUDIO,
77
+ supports_vision=True,
78
+ )
79
+ with pytest.raises((TypeError, ValidationError)):
80
+ info.model_id = "changed" # type: ignore[misc]
81
+
82
+
83
+ def test_model_info_optional_token_limits():
84
+ info = ModelInfo(
85
+ model_id="models/gemini-2.0-flash",
86
+ display_name="Gemini 2.0 Flash",
87
+ provider=ProviderType.VERTEX_SERVICE_ACCOUNT,
88
+ supports_vision=True,
89
+ )
90
+ assert info.input_token_limit is None
91
+ assert info.output_token_limit is None
92
+
93
+
94
+ def test_model_info_all_provider_types():
95
+ for ptype in ProviderType:
96
+ info = ModelInfo(
97
+ model_id=f"models/test-{ptype.value}",
98
+ display_name="Test",
99
+ provider=ptype,
100
+ supports_vision=False,
101
+ )
102
+ assert info.provider == ptype
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Tests — ModelConfig (schéma)
107
+ # ---------------------------------------------------------------------------
108
+
109
+ def test_model_config_valid():
110
+ cfg = ModelConfig(
111
+ corpus_id="corpus-001",
112
+ selected_model_id="models/gemini-1.5-pro",
113
+ selected_model_display_name="Gemini 1.5 Pro",
114
+ provider=ProviderType.GOOGLE_AI_STUDIO,
115
+ supports_vision=True,
116
+ last_fetched_at=datetime(2026, 3, 17, tzinfo=timezone.utc),
117
+ available_models=[],
118
+ )
119
+ assert cfg.corpus_id == "corpus-001"
120
+ assert cfg.supports_vision is True
121
+
122
+
123
+ def test_model_config_missing_required_field():
124
+ with pytest.raises(ValidationError):
125
+ ModelConfig.model_validate({"corpus_id": "x"})
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Tests — is_vision_model helper
130
+ # ---------------------------------------------------------------------------
131
+
132
+ def test_is_vision_model_gemini():
133
+ m = MagicMock()
134
+ m.name = "models/gemini-1.5-pro"
135
+ m.display_name = "Gemini 1.5 Pro"
136
+ assert is_vision_model(m) is True
137
+
138
+
139
+ def test_is_vision_model_vision_in_name():
140
+ m = MagicMock()
141
+ m.name = "models/some-vision-model"
142
+ m.display_name = "Some Model"
143
+ assert is_vision_model(m) is True
144
+
145
+
146
+ def test_is_vision_model_vision_in_display():
147
+ m = MagicMock()
148
+ m.name = "models/some-model"
149
+ m.display_name = "Some Vision Model"
150
+ assert is_vision_model(m) is True
151
+
152
+
153
+ def test_is_vision_model_text_only():
154
+ m = MagicMock()
155
+ m.name = "models/text-embedding-004"
156
+ m.display_name = "Text Embedding"
157
+ assert is_vision_model(m) is False
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Tests — GoogleAIProvider
162
+ # ---------------------------------------------------------------------------
163
+
164
+ def test_google_ai_provider_not_configured(monkeypatch):
165
+ monkeypatch.delenv("GOOGLE_AI_STUDIO_API_KEY", raising=False)
166
+ provider = GoogleAIProvider()
167
+ assert provider.is_configured() is False
168
+
169
+
170
+ def test_google_ai_provider_configured(monkeypatch):
171
+ monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "fake-key")
172
+ provider = GoogleAIProvider()
173
+ assert provider.is_configured() is True
174
+
175
+
176
+ def test_google_ai_provider_type():
177
+ assert GoogleAIProvider().provider_type == ProviderType.GOOGLE_AI_STUDIO
178
+
179
+
180
+ def test_google_ai_provider_list_models_not_configured(monkeypatch):
181
+ monkeypatch.delenv("GOOGLE_AI_STUDIO_API_KEY", raising=False)
182
+ with pytest.raises(RuntimeError, match="GOOGLE_AI_STUDIO_API_KEY"):
183
+ GoogleAIProvider().list_models()
184
+
185
+
186
+ def test_google_ai_provider_list_models_success(monkeypatch):
187
+ monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "fake-key")
188
+ mock_model = _make_mock_model()
189
+
190
+ with patch("app.services.ai.provider_google_ai.genai.Client") as MockClient:
191
+ MockClient.return_value.models.list.return_value = [mock_model]
192
+ models = GoogleAIProvider().list_models()
193
+
194
+ assert len(models) == 1
195
+ assert models[0].model_id == "models/gemini-1.5-pro"
196
+ assert models[0].provider == ProviderType.GOOGLE_AI_STUDIO
197
+ assert models[0].supports_vision is True
198
+ MockClient.assert_called_once_with(api_key="fake-key")
199
+
200
+
201
+ def test_google_ai_provider_filters_non_generate_content(monkeypatch):
202
+ monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "fake-key")
203
+ embedding = _make_mock_model(
204
+ name="models/text-embedding-004",
205
+ display_name="Text Embedding",
206
+ methods=["embedContent"],
207
+ )
208
+ gemini = _make_mock_model()
209
+
210
+ with patch("app.services.ai.provider_google_ai.genai.Client") as MockClient:
211
+ MockClient.return_value.models.list.return_value = [embedding, gemini]
212
+ models = GoogleAIProvider().list_models()
213
+
214
+ assert len(models) == 1
215
+ assert models[0].model_id == "models/gemini-1.5-pro"
216
+
217
+
218
+ def test_google_ai_provider_empty_list(monkeypatch):
219
+ monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "fake-key")
220
+ with patch("app.services.ai.provider_google_ai.genai.Client") as MockClient:
221
+ MockClient.return_value.models.list.return_value = []
222
+ models = GoogleAIProvider().list_models()
223
+ assert models == []
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Tests — VertexAPIKeyProvider
228
+ # ---------------------------------------------------------------------------
229
+
230
+ def test_vertex_key_provider_not_configured(monkeypatch):
231
+ monkeypatch.delenv("VERTEX_API_KEY", raising=False)
232
+ assert VertexAPIKeyProvider().is_configured() is False
233
+
234
+
235
+ def test_vertex_key_provider_configured(monkeypatch):
236
+ monkeypatch.setenv("VERTEX_API_KEY", "fake-vertex-key")
237
+ assert VertexAPIKeyProvider().is_configured() is True
238
+
239
+
240
+ def test_vertex_key_provider_type():
241
+ assert VertexAPIKeyProvider().provider_type == ProviderType.VERTEX_API_KEY
242
+
243
+
244
+ def test_vertex_key_provider_list_models_not_configured(monkeypatch):
245
+ monkeypatch.delenv("VERTEX_API_KEY", raising=False)
246
+ with pytest.raises(RuntimeError, match="VERTEX_API_KEY"):
247
+ VertexAPIKeyProvider().list_models()
248
+
249
+
250
+ def test_vertex_key_provider_list_models_success(monkeypatch):
251
+ monkeypatch.setenv("VERTEX_API_KEY", "fake-vertex-key")
252
+ mock_model = _make_mock_model(
253
+ name="models/gemini-2.0-flash",
254
+ display_name="Gemini 2.0 Flash",
255
+ )
256
+
257
+ with patch("app.services.ai.provider_vertex_key.genai.Client") as MockClient:
258
+ MockClient.return_value.models.list.return_value = [mock_model]
259
+ models = VertexAPIKeyProvider().list_models()
260
+
261
+ assert len(models) == 1
262
+ assert models[0].model_id == "models/gemini-2.0-flash"
263
+ assert models[0].provider == ProviderType.VERTEX_API_KEY
264
+ MockClient.assert_called_once_with(api_key="fake-vertex-key")
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # Tests — VertexServiceAccountProvider
269
+ # ---------------------------------------------------------------------------
270
+
271
+ def test_vertex_sa_provider_not_configured(monkeypatch):
272
+ monkeypatch.delenv("VERTEX_SERVICE_ACCOUNT_JSON", raising=False)
273
+ assert VertexServiceAccountProvider().is_configured() is False
274
+
275
+
276
+ def test_vertex_sa_provider_configured(monkeypatch):
277
+ monkeypatch.setenv("VERTEX_SERVICE_ACCOUNT_JSON", json.dumps(FAKE_SA_JSON))
278
+ assert VertexServiceAccountProvider().is_configured() is True
279
+
280
+
281
+ def test_vertex_sa_provider_type():
282
+ assert VertexServiceAccountProvider().provider_type == ProviderType.VERTEX_SERVICE_ACCOUNT
283
+
284
+
285
+ def test_vertex_sa_provider_list_models_not_configured(monkeypatch):
286
+ monkeypatch.delenv("VERTEX_SERVICE_ACCOUNT_JSON", raising=False)
287
+ with pytest.raises(RuntimeError, match="VERTEX_SERVICE_ACCOUNT_JSON"):
288
+ VertexServiceAccountProvider().list_models()
289
+
290
+
291
+ def test_vertex_sa_provider_invalid_json(monkeypatch):
292
+ monkeypatch.setenv("VERTEX_SERVICE_ACCOUNT_JSON", "not-valid-json{{{")
293
+ with pytest.raises(ValueError, match="JSON invalide"):
294
+ VertexServiceAccountProvider().list_models()
295
+
296
+
297
+ def test_vertex_sa_provider_missing_project_id(monkeypatch):
298
+ sa_no_project = {k: v for k, v in FAKE_SA_JSON.items() if k != "project_id"}
299
+ monkeypatch.setenv("VERTEX_SERVICE_ACCOUNT_JSON", json.dumps(sa_no_project))
300
+ with pytest.raises(ValueError, match="project_id"):
301
+ VertexServiceAccountProvider().list_models()
302
+
303
+
304
+ def test_vertex_sa_provider_list_models_success(monkeypatch):
305
+ monkeypatch.setenv("VERTEX_SERVICE_ACCOUNT_JSON", json.dumps(FAKE_SA_JSON))
306
+ mock_model = _make_mock_model(
307
+ name="models/gemini-1.5-pro-002",
308
+ display_name="Gemini 1.5 Pro 002",
309
+ )
310
+ mock_credentials = MagicMock()
311
+
312
+ with patch(
313
+ "app.services.ai.provider_vertex_sa.service_account.Credentials.from_service_account_info",
314
+ return_value=mock_credentials,
315
+ ) as mock_creds_factory:
316
+ with patch("app.services.ai.provider_vertex_sa.genai.Client") as MockClient:
317
+ MockClient.return_value.models.list.return_value = [mock_model]
318
+ models = VertexServiceAccountProvider().list_models()
319
+
320
+ assert len(models) == 1
321
+ assert models[0].model_id == "models/gemini-1.5-pro-002"
322
+ assert models[0].provider == ProviderType.VERTEX_SERVICE_ACCOUNT
323
+ mock_creds_factory.assert_called_once_with(
324
+ FAKE_SA_JSON,
325
+ scopes=["https://www.googleapis.com/auth/cloud-platform"],
326
+ )
327
+ MockClient.assert_called_once_with(
328
+ vertexai=True,
329
+ project="test-project-123",
330
+ location="us-central1",
331
+ credentials=mock_credentials,
332
+ )
333
+
334
+
335
+ def test_vertex_sa_provider_filters_non_generate_content(monkeypatch):
336
+ monkeypatch.setenv("VERTEX_SERVICE_ACCOUNT_JSON", json.dumps(FAKE_SA_JSON))
337
+ embedding = _make_mock_model(
338
+ name="models/textembedding-gecko",
339
+ display_name="Text Embedding Gecko",
340
+ methods=["embedContent"],
341
+ )
342
+
343
+ with patch(
344
+ "app.services.ai.provider_vertex_sa.service_account.Credentials.from_service_account_info",
345
+ return_value=MagicMock(),
346
+ ):
347
+ with patch("app.services.ai.provider_vertex_sa.genai.Client") as MockClient:
348
+ MockClient.return_value.models.list.return_value = [embedding]
349
+ models = VertexServiceAccountProvider().list_models()
350
+
351
+ assert models == []
352
+
353
+
354
+ # ---------------------------------------------------------------------------
355
+ # Tests — model_registry.list_all_models
356
+ # ---------------------------------------------------------------------------
357
+
358
+ def test_list_all_models_no_providers_configured(monkeypatch):
359
+ monkeypatch.delenv("GOOGLE_AI_STUDIO_API_KEY", raising=False)
360
+ monkeypatch.delenv("VERTEX_API_KEY", raising=False)
361
+ monkeypatch.delenv("VERTEX_SERVICE_ACCOUNT_JSON", raising=False)
362
+ result = list_all_models()
363
+ assert result == []
364
+
365
+
366
+ def test_list_all_models_one_provider(monkeypatch):
367
+ monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "fake-key")
368
+ monkeypatch.delenv("VERTEX_API_KEY", raising=False)
369
+ monkeypatch.delenv("VERTEX_SERVICE_ACCOUNT_JSON", raising=False)
370
+ mock_model = _make_mock_model()
371
+
372
+ with patch("app.services.ai.provider_google_ai.genai.Client") as MockClient:
373
+ MockClient.return_value.models.list.return_value = [mock_model]
374
+ result = list_all_models()
375
+
376
+ assert len(result) == 1
377
+ assert result[0].provider == ProviderType.GOOGLE_AI_STUDIO
378
+
379
+
380
+ def test_list_all_models_aggregates_multiple_providers(monkeypatch):
381
+ # Note : provider_google_ai et provider_vertex_key partagent le même objet
382
+ # google.genai (import module). On patch au niveau des méthodes pour éviter
383
+ # que le second patch.object("...genai.Client") écrase le premier.
384
+ monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "fake-key-ai")
385
+ monkeypatch.setenv("VERTEX_API_KEY", "fake-key-vertex")
386
+ monkeypatch.delenv("VERTEX_SERVICE_ACCOUNT_JSON", raising=False)
387
+
388
+ models_ai = [ModelInfo(
389
+ model_id="models/gemini-1.5-pro",
390
+ display_name="Gemini 1.5 Pro",
391
+ provider=ProviderType.GOOGLE_AI_STUDIO,
392
+ supports_vision=True,
393
+ )]
394
+ models_vertex = [ModelInfo(
395
+ model_id="models/gemini-2.0-flash",
396
+ display_name="Gemini 2.0 Flash",
397
+ provider=ProviderType.VERTEX_API_KEY,
398
+ supports_vision=True,
399
+ )]
400
+
401
+ with patch.object(GoogleAIProvider, "list_models", return_value=models_ai):
402
+ with patch.object(VertexAPIKeyProvider, "list_models", return_value=models_vertex):
403
+ result = list_all_models()
404
+
405
+ assert len(result) == 2
406
+ providers = {m.provider for m in result}
407
+ assert ProviderType.GOOGLE_AI_STUDIO in providers
408
+ assert ProviderType.VERTEX_API_KEY in providers
409
+
410
+
411
+ def test_list_all_models_failing_provider_is_skipped(monkeypatch):
412
+ monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "bad-key")
413
+ monkeypatch.setenv("VERTEX_API_KEY", "good-key")
414
+ monkeypatch.delenv("VERTEX_SERVICE_ACCOUNT_JSON", raising=False)
415
+
416
+ models_vertex = [ModelInfo(
417
+ model_id="models/gemini-2.0-flash",
418
+ display_name="Gemini 2.0 Flash",
419
+ provider=ProviderType.VERTEX_API_KEY,
420
+ supports_vision=True,
421
+ )]
422
+
423
+ with patch.object(GoogleAIProvider, "list_models", side_effect=Exception("API key invalid")):
424
+ with patch.object(VertexAPIKeyProvider, "list_models", return_value=models_vertex):
425
+ result = list_all_models()
426
+
427
+ assert len(result) == 1
428
+ assert result[0].provider == ProviderType.VERTEX_API_KEY
429
+
430
+
431
+ # ---------------------------------------------------------------------------
432
+ # Tests — model_registry.build_model_config
433
+ # ---------------------------------------------------------------------------
434
+
435
+ def test_build_model_config_valid(monkeypatch):
436
+ monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "fake-key")
437
+ monkeypatch.delenv("VERTEX_API_KEY", raising=False)
438
+ monkeypatch.delenv("VERTEX_SERVICE_ACCOUNT_JSON", raising=False)
439
+ mock_model = _make_mock_model()
440
+
441
+ with patch("app.services.ai.provider_google_ai.genai.Client") as MockClient:
442
+ MockClient.return_value.models.list.return_value = [mock_model]
443
+ cfg = build_model_config("corpus-001", "models/gemini-1.5-pro")
444
+
445
+ assert cfg.corpus_id == "corpus-001"
446
+ assert cfg.selected_model_id == "models/gemini-1.5-pro"
447
+ assert cfg.selected_model_display_name == "Gemini 1.5 Pro"
448
+ assert cfg.provider == ProviderType.GOOGLE_AI_STUDIO
449
+ assert cfg.supports_vision is True
450
+ assert len(cfg.available_models) == 1
451
+ assert isinstance(cfg.available_models[0], dict)
452
+
453
+
454
+ def test_build_model_config_unknown_model(monkeypatch):
455
+ monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "fake-key")
456
+ monkeypatch.delenv("VERTEX_API_KEY", raising=False)
457
+ monkeypatch.delenv("VERTEX_SERVICE_ACCOUNT_JSON", raising=False)
458
+ mock_model = _make_mock_model()
459
+
460
+ with patch("app.services.ai.provider_google_ai.genai.Client") as MockClient:
461
+ MockClient.return_value.models.list.return_value = [mock_model]
462
+ with pytest.raises(ValueError, match="non disponible"):
463
+ build_model_config("corpus-001", "models/nonexistent-model")
464
+
465
+
466
+ def test_build_model_config_no_providers(monkeypatch):
467
+ monkeypatch.delenv("GOOGLE_AI_STUDIO_API_KEY", raising=False)
468
+ monkeypatch.delenv("VERTEX_API_KEY", raising=False)
469
+ monkeypatch.delenv("VERTEX_SERVICE_ACCOUNT_JSON", raising=False)
470
+ with pytest.raises(ValueError, match="non disponible"):
471
+ build_model_config("corpus-001", "models/gemini-1.5-pro")