Spaces:
Build error
fix(vertex): corriger le routing API pour VERTEX_API_KEY — ajouter vertexai=True
Browse filesCause racine du 403 PERMISSION_DENIED dans les logs de production :
genai.Client(api_key=VERTEX_API_KEY)
→ route vers generativelanguage.googleapis.com (Gemini Developer API)
→ rejette les clés Vertex Express avec 403 car elles ne sont pas
autorisées sur cette API
Fix :
genai.Client(vertexai=True, api_key=VERTEX_API_KEY)
→ route vers aiplatform.googleapis.com (Vertex AI endpoint correct)
→ project/location omis car mutually exclusive avec api_key dans le
constructeur SDK (la clé Express encode le projet)
Confirmé par le code source du SDK google-genai 1.67.0 :
"api_key: Applies to the Gemini Developer API only"
"project/location and API key are mutually exclusive"
Fichiers modifiés :
- provider_vertex_key.py : _build_client() avec vertexai=True,
fallback modèles Vertex sans supported_generation_methods (nom "gemini")
- client_factory.py : même correction pour cohérence
- test_ai_providers.py : 3 nouveaux tests Vertex (vertexai flag,
modèles sans méthodes, generate_content), assertion mise à jour
- test_ai_analyzer.py : assertion MockClient mise à jour
https://claude.ai/code/session_018woyEHc8HG2th7V4ewJ4Kg
|
@@ -57,8 +57,12 @@ def build_client(provider_type: ProviderType) -> genai.Client:
|
|
| 57 |
raise RuntimeError(
|
| 58 |
"Variable d'environnement manquante : VERTEX_API_KEY"
|
| 59 |
)
|
| 60 |
-
logger.debug("Client Vertex AI (clé API) créé")
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
if provider_type == ProviderType.VERTEX_SERVICE_ACCOUNT:
|
| 64 |
sa_json_str = os.environ.get("VERTEX_SERVICE_ACCOUNT_JSON")
|
|
|
|
| 57 |
raise RuntimeError(
|
| 58 |
"Variable d'environnement manquante : VERTEX_API_KEY"
|
| 59 |
)
|
| 60 |
+
logger.debug("Client Vertex AI Express (clé API) créé")
|
| 61 |
+
# vertexai=True route vers aiplatform.googleapis.com (Vertex AI).
|
| 62 |
+
# Sans vertexai=True, le SDK route vers generativelanguage.googleapis.com
|
| 63 |
+
# (Gemini Developer API) qui rejette les clés Vertex Express avec 403.
|
| 64 |
+
# project/location sont omis : mutually exclusive avec api_key dans le SDK.
|
| 65 |
+
return genai.Client(vertexai=True, api_key=api_key)
|
| 66 |
|
| 67 |
if provider_type == ProviderType.VERTEX_SERVICE_ACCOUNT:
|
| 68 |
sa_json_str = os.environ.get("VERTEX_SERVICE_ACCOUNT_JSON")
|
|
@@ -1,5 +1,14 @@
|
|
| 1 |
"""
|
| 2 |
-
Provider Vertex AI — authentification via clé API
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
# 1. stdlib
|
| 5 |
import logging
|
|
@@ -19,10 +28,12 @@ _ENV_KEY = "VERTEX_API_KEY"
|
|
| 19 |
|
| 20 |
|
| 21 |
class VertexAPIKeyProvider(AIProvider):
|
| 22 |
-
"""Provider Vertex AI via clé API
|
| 23 |
|
| 24 |
-
Utilise
|
| 25 |
-
|
|
|
|
|
|
|
| 26 |
"""
|
| 27 |
|
| 28 |
@property
|
|
@@ -32,16 +43,36 @@ class VertexAPIKeyProvider(AIProvider):
|
|
| 32 |
def is_configured(self) -> bool:
|
| 33 |
return bool(os.environ.get(_ENV_KEY))
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
def list_models(self) -> list[ModelInfo]:
|
| 36 |
if not self.is_configured():
|
| 37 |
raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
|
| 38 |
|
| 39 |
-
client =
|
| 40 |
result: list[ModelInfo] = []
|
| 41 |
|
| 42 |
for model in client.models.list():
|
| 43 |
methods = getattr(model, "supported_generation_methods", []) or []
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
continue
|
| 46 |
|
| 47 |
result.append(ModelInfo(
|
|
@@ -54,15 +85,15 @@ class VertexAPIKeyProvider(AIProvider):
|
|
| 54 |
))
|
| 55 |
|
| 56 |
logger.info(
|
| 57 |
-
"Vertex API key models fetched",
|
| 58 |
-
extra={"provider": self.provider_type, "count": len(result)},
|
| 59 |
)
|
| 60 |
return result
|
| 61 |
|
| 62 |
def generate_content(self, image_bytes: bytes, prompt: str, model_id: str) -> str:
|
| 63 |
if not self.is_configured():
|
| 64 |
raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
|
| 65 |
-
client =
|
| 66 |
image_part = types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")
|
| 67 |
response = client.models.generate_content(
|
| 68 |
model=model_id,
|
|
|
|
| 1 |
"""
|
| 2 |
+
Provider Vertex AI — authentification via clé API Express Vertex (VERTEX_API_KEY).
|
| 3 |
+
|
| 4 |
+
La clé Vertex Express (format AQ.Ab...) encode le projet GCP ; elle est utilisée
|
| 5 |
+
avec vertexai=True pour router vers aiplatform.googleapis.com (et non vers
|
| 6 |
+
generativelanguage.googleapis.com qui est l'endpoint Google AI Studio).
|
| 7 |
+
|
| 8 |
+
Référence SDK google-genai :
|
| 9 |
+
api_key seul → Gemini Developer API (generativelanguage)
|
| 10 |
+
vertexai=True + api_key → Vertex AI Express mode (aiplatform)
|
| 11 |
+
project/location + api_key → ValueError (mutually exclusive dans le constructeur)
|
| 12 |
"""
|
| 13 |
# 1. stdlib
|
| 14 |
import logging
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
class VertexAPIKeyProvider(AIProvider):
|
| 31 |
+
"""Provider Vertex AI via clé API Express (VERTEX_API_KEY).
|
| 32 |
|
| 33 |
+
Utilise genai.Client(vertexai=True, api_key=...) pour router vers
|
| 34 |
+
l'endpoint Vertex AI (aiplatform.googleapis.com). La clé Express encode
|
| 35 |
+
le projet GCP ; project/location explicites sont omis car ils sont
|
| 36 |
+
mutually exclusive avec api_key dans le constructeur SDK.
|
| 37 |
"""
|
| 38 |
|
| 39 |
@property
|
|
|
|
| 43 |
def is_configured(self) -> bool:
|
| 44 |
return bool(os.environ.get(_ENV_KEY))
|
| 45 |
|
| 46 |
+
def _build_client(self) -> genai.Client:
|
| 47 |
+
"""Construit un client Vertex AI en mode Express API key.
|
| 48 |
+
|
| 49 |
+
vertexai=True route vers aiplatform.googleapis.com.
|
| 50 |
+
project/location sont omis : mutually exclusive avec api_key
|
| 51 |
+
dans le SDK (la clé Express encode le projet).
|
| 52 |
+
"""
|
| 53 |
+
return genai.Client(
|
| 54 |
+
vertexai=True,
|
| 55 |
+
api_key=os.environ[_ENV_KEY],
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
def list_models(self) -> list[ModelInfo]:
|
| 59 |
if not self.is_configured():
|
| 60 |
raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
|
| 61 |
|
| 62 |
+
client = self._build_client()
|
| 63 |
result: list[ModelInfo] = []
|
| 64 |
|
| 65 |
for model in client.models.list():
|
| 66 |
methods = getattr(model, "supported_generation_methods", []) or []
|
| 67 |
+
# Pour Vertex, certains modèles peuvent ne pas avoir
|
| 68 |
+
# supported_generation_methods renseigné ; on les inclut
|
| 69 |
+
# s'ils contiennent "gemini" dans le nom (modèles génératifs Vertex).
|
| 70 |
+
name_lower = (getattr(model, "name", "") or "").lower()
|
| 71 |
+
is_generative = (
|
| 72 |
+
"generateContent" in methods
|
| 73 |
+
or (not methods and "gemini" in name_lower)
|
| 74 |
+
)
|
| 75 |
+
if not is_generative:
|
| 76 |
continue
|
| 77 |
|
| 78 |
result.append(ModelInfo(
|
|
|
|
| 85 |
))
|
| 86 |
|
| 87 |
logger.info(
|
| 88 |
+
"Vertex API key (Express) models fetched",
|
| 89 |
+
extra={"provider": self.provider_type.value, "count": len(result)},
|
| 90 |
)
|
| 91 |
return result
|
| 92 |
|
| 93 |
def generate_content(self, image_bytes: bytes, prompt: str, model_id: str) -> str:
|
| 94 |
if not self.is_configured():
|
| 95 |
raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
|
| 96 |
+
client = self._build_client()
|
| 97 |
image_part = types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")
|
| 98 |
response = client.models.generate_content(
|
| 99 |
model=model_id,
|
|
@@ -198,7 +198,7 @@ def test_build_client_vertex_api_key(monkeypatch):
|
|
| 198 |
mock_cls.return_value = MagicMock()
|
| 199 |
client = build_client(ProviderType.VERTEX_API_KEY)
|
| 200 |
|
| 201 |
-
mock_cls.assert_called_once_with(api_key="fake-vertex-key")
|
| 202 |
assert client is mock_cls.return_value
|
| 203 |
|
| 204 |
|
|
|
|
| 198 |
mock_cls.return_value = MagicMock()
|
| 199 |
client = build_client(ProviderType.VERTEX_API_KEY)
|
| 200 |
|
| 201 |
+
mock_cls.assert_called_once_with(vertexai=True, api_key="fake-vertex-key")
|
| 202 |
assert client is mock_cls.return_value
|
| 203 |
|
| 204 |
|
|
@@ -261,7 +261,46 @@ def test_vertex_key_provider_list_models_success(monkeypatch):
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
|
| 266 |
|
| 267 |
# ---------------------------------------------------------------------------
|
|
|
|
| 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 |
+
# vertexai=True est obligatoire pour router vers aiplatform.googleapis.com
|
| 265 |
+
# (sans ça, le SDK route vers generativelanguage.googleapis.com → 403)
|
| 266 |
+
MockClient.assert_called_once_with(vertexai=True, api_key="fake-vertex-key")
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def test_vertex_key_provider_list_models_includes_gemini_without_methods(monkeypatch):
|
| 270 |
+
"""Vertex peut retourner des modèles sans supported_generation_methods.
|
| 271 |
+
Si le nom contient 'gemini', on les inclut quand même."""
|
| 272 |
+
monkeypatch.setenv("VERTEX_API_KEY", "fake-vertex-key")
|
| 273 |
+
model_no_methods = _make_mock_model(
|
| 274 |
+
name="publishers/google/models/gemini-1.5-pro-002",
|
| 275 |
+
display_name="Gemini 1.5 Pro 002",
|
| 276 |
+
methods=[],
|
| 277 |
+
)
|
| 278 |
+
model_non_gemini = _make_mock_model(
|
| 279 |
+
name="publishers/google/models/text-bison",
|
| 280 |
+
display_name="Text Bison",
|
| 281 |
+
methods=[],
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
with patch("app.services.ai.provider_vertex_key.genai.Client") as MockClient:
|
| 285 |
+
MockClient.return_value.models.list.return_value = [model_no_methods, model_non_gemini]
|
| 286 |
+
models = VertexAPIKeyProvider().list_models()
|
| 287 |
+
|
| 288 |
+
assert len(models) == 1
|
| 289 |
+
assert "gemini" in models[0].model_id.lower()
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def test_vertex_key_provider_generate_content_uses_vertexai(monkeypatch):
|
| 293 |
+
"""generate_content doit aussi utiliser vertexai=True."""
|
| 294 |
+
monkeypatch.setenv("VERTEX_API_KEY", "fake-vertex-key")
|
| 295 |
+
|
| 296 |
+
with patch("app.services.ai.provider_vertex_key.genai.Client") as MockClient:
|
| 297 |
+
with patch("app.services.ai.provider_vertex_key.types.Part.from_bytes") as mock_part:
|
| 298 |
+
mock_part.return_value = "fake-part"
|
| 299 |
+
MockClient.return_value.models.generate_content.return_value.text = "result"
|
| 300 |
+
result = VertexAPIKeyProvider().generate_content(b"img", "prompt", "gemini-2.0-flash")
|
| 301 |
+
|
| 302 |
+
MockClient.assert_called_once_with(vertexai=True, api_key="fake-vertex-key")
|
| 303 |
+
assert result == "result"
|
| 304 |
|
| 305 |
|
| 306 |
# ---------------------------------------------------------------------------
|