maribakulj commited on
Commit
daf8420
·
unverified ·
2 Parent(s): b8a4385844e230

Merge pull request #20 from maribakulj/claude/review-and-plan-r20qn

Browse files

feat: supprimer la saisie de clé API dans l'UI — les clés viennent de…

backend/app/api/v1/models_api.py CHANGED
@@ -1,14 +1,13 @@
1
  """
2
  Endpoints de gestion des modèles IA (R10 — préfixe /api/v1/).
3
 
4
- POST /api/v1/settings/api-key valide la clé sans la stocker (R06)
5
- GET /api/v1/models → liste les modèles disponibles
6
  POST /api/v1/models/refresh → force la mise à jour de la liste
7
  PUT /api/v1/corpora/{id}/model → associe un modèle à un corpus
8
  GET /api/v1/corpora/{id}/model → modèle actif d'un corpus
9
 
10
- Règle R06 : la clé API ne transite jamais vers la BDD elle reste
11
- exclusivement dans les variables d'environnement.
12
  """
13
  # 1. stdlib
14
  import logging
@@ -32,18 +31,6 @@ router = APIRouter(tags=["models"])
32
 
33
  # ── Schémas ───────────────────────────────────────────────────────────────────
34
 
35
- class ApiKeyRequest(BaseModel):
36
- api_key: str
37
- provider_type: str = "google_ai_studio"
38
-
39
-
40
- class ApiKeyResponse(BaseModel):
41
- valid: bool
42
- provider: str
43
- model_count: int
44
- error: str | None = None
45
-
46
-
47
  class ModelSelectRequest(BaseModel):
48
  model_id: str
49
  provider_type: str
@@ -66,46 +53,11 @@ class ModelsRefreshResponse(BaseModel):
66
  refreshed_at: datetime
67
 
68
 
69
- # ── Validation de clé API (isolé pour les tests) ──────────────────────────────
70
-
71
- def _validate_api_key(api_key: str, provider_type: str) -> tuple[bool, int, str | None]:
72
- """Essaie de lister les modèles avec la clé fournie.
73
-
74
- Retourne (valid, model_count, error_message).
75
- Fonction isolée au niveau module pour être patchable dans les tests.
76
- """
77
- try:
78
- from google import genai # import local pour éviter l'import top-level
79
- client = genai.Client(api_key=api_key)
80
- raw_models = list(client.models.list())
81
- vision_count = sum(
82
- 1 for m in raw_models if "gemini" in (getattr(m, "name", "") or "").lower()
83
- )
84
- return True, vision_count, None
85
- except Exception as exc:
86
- return False, 0, str(exc)
87
-
88
-
89
  # ── Endpoints ─────────────────────────────────────────────────────────────────
90
 
91
- @router.post("/settings/api-key", response_model=ApiKeyResponse)
92
- async def validate_api_key(body: ApiKeyRequest) -> ApiKeyResponse:
93
- """Valide qu'une clé API fonctionne (appel list_models).
94
-
95
- La clé N'EST PAS stockée (R06). Elle reste dans les variables d'env.
96
- """
97
- valid, count, error = _validate_api_key(body.api_key, body.provider_type)
98
- return ApiKeyResponse(
99
- valid=valid,
100
- provider=body.provider_type,
101
- model_count=count,
102
- error=error,
103
- )
104
-
105
-
106
  @router.get("/models", response_model=list[dict])
107
  async def get_models() -> list[dict]:
108
- """Liste tous les modèles disponibles sur les providers configurés."""
109
  models = list_all_models()
110
  return [m.model_dump() for m in models]
111
 
 
1
  """
2
  Endpoints de gestion des modèles IA (R10 — préfixe /api/v1/).
3
 
4
+ GET /api/v1/models liste les modèles disponibles via les credentials env
 
5
  POST /api/v1/models/refresh → force la mise à jour de la liste
6
  PUT /api/v1/corpora/{id}/model → associe un modèle à un corpus
7
  GET /api/v1/corpora/{id}/model → modèle actif d'un corpus
8
 
9
+ Les clés API vivent exclusivement dans les secrets HuggingFace (variables d'environnement).
10
+ L'interface ne demande jamais de clé à l'utilisateur (R06).
11
  """
12
  # 1. stdlib
13
  import logging
 
31
 
32
  # ── Schémas ───────────────────────────────────────────────────────────────────
33
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  class ModelSelectRequest(BaseModel):
35
  model_id: str
36
  provider_type: str
 
53
  refreshed_at: datetime
54
 
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  # ── Endpoints ─────────────────────────────────────────────────────────────────
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  @router.get("/models", response_model=list[dict])
59
  async def get_models() -> list[dict]:
60
+ """Liste tous les modèles disponibles sur les providers configurés en environnement."""
61
  models = list_all_models()
62
  return [m.model_dump() for m in models]
63
 
backend/tests/test_api_models.py CHANGED
@@ -1,12 +1,11 @@
1
  """
2
- Tests des endpoints /api/v1/models et /api/v1/settings/api-key (Sprint 4 — Session B).
3
 
4
  Stratégie :
5
- - Appels Google AI mockés via monkeypatch sur _validate_api_key et list_all_models
6
  - BDD SQLite en mémoire pour les endpoints qui touchent la BDD (PUT/GET model)
7
 
8
  Vérifie :
9
- - POST /api/v1/settings/api-key → valid/invalid
10
  - GET /api/v1/models → liste mockée
11
  - POST /api/v1/models/refresh → mise à jour + timestamp
12
  - PUT /api/v1/corpora/{id}/model → création + mise à jour
@@ -63,83 +62,17 @@ async def _make_corpus(db, slug="models-test"):
63
 
64
 
65
  # ---------------------------------------------------------------------------
66
- # POST /api/v1/settings/api-key
67
  # ---------------------------------------------------------------------------
68
 
69
  @pytest.mark.asyncio
70
- async def test_api_key_valid(async_client, monkeypatch):
71
- monkeypatch.setattr(
72
- models_api_module, "_validate_api_key",
73
- lambda key, provider: (True, 3, None),
74
- )
75
- response = await async_client.post(
76
- "/api/v1/settings/api-key",
77
- json={"api_key": "AIza-test-key", "provider_type": "google_ai_studio"},
78
- )
79
- assert response.status_code == 200
80
- data = response.json()
81
- assert data["valid"] is True
82
- assert data["model_count"] == 3
83
- assert data["provider"] == "google_ai_studio"
84
- assert data["error"] is None
85
-
86
-
87
- @pytest.mark.asyncio
88
- async def test_api_key_invalid(async_client, monkeypatch):
89
- monkeypatch.setattr(
90
- models_api_module, "_validate_api_key",
91
- lambda key, provider: (False, 0, "API key not valid"),
92
- )
93
  response = await async_client.post(
94
  "/api/v1/settings/api-key",
95
- json={"api_key": "bad-key", "provider_type": "google_ai_studio"},
96
  )
97
- assert response.status_code == 200
98
- data = response.json()
99
- assert data["valid"] is False
100
- assert data["model_count"] == 0
101
- assert data["error"] is not None
102
-
103
-
104
- @pytest.mark.asyncio
105
- async def test_api_key_not_stored_in_db(async_client, db_session, monkeypatch):
106
- """La clé ne doit apparaître nulle part dans la BDD (R06)."""
107
- monkeypatch.setattr(
108
- models_api_module, "_validate_api_key",
109
- lambda key, provider: (True, 2, None),
110
- )
111
- await async_client.post(
112
- "/api/v1/settings/api-key",
113
- json={"api_key": "secret-key-AIza123", "provider_type": "google_ai_studio"},
114
- )
115
- # Vérifie que la clé n'est pas dans model_configs
116
- from sqlalchemy import text
117
- result = await db_session.execute(text("SELECT * FROM model_configs"))
118
- rows = result.fetchall()
119
- for row in rows:
120
- row_str = str(row)
121
- assert "secret-key-AIza123" not in row_str
122
-
123
-
124
- @pytest.mark.asyncio
125
- async def test_api_key_missing_body_422(async_client):
126
- response = await async_client.post("/api/v1/settings/api-key", json={})
127
- assert response.status_code == 422
128
-
129
-
130
- @pytest.mark.asyncio
131
- async def test_api_key_default_provider_type(async_client, monkeypatch):
132
- """provider_type est optionnel (default: google_ai_studio)."""
133
- monkeypatch.setattr(
134
- models_api_module, "_validate_api_key",
135
- lambda key, provider: (True, 1, None),
136
- )
137
- response = await async_client.post(
138
- "/api/v1/settings/api-key",
139
- json={"api_key": "AIza-test"},
140
- )
141
- assert response.status_code == 200
142
- assert response.json()["provider"] == "google_ai_studio"
143
 
144
 
145
  # ---------------------------------------------------------------------------
 
1
  """
2
+ Tests des endpoints /api/v1/models (Sprint 4 — Session B).
3
 
4
  Stratégie :
5
+ - Appels Google AI mockés via monkeypatch sur list_all_models
6
  - BDD SQLite en mémoire pour les endpoints qui touchent la BDD (PUT/GET model)
7
 
8
  Vérifie :
 
9
  - GET /api/v1/models → liste mockée
10
  - POST /api/v1/models/refresh → mise à jour + timestamp
11
  - PUT /api/v1/corpora/{id}/model → création + mise à jour
 
62
 
63
 
64
  # ---------------------------------------------------------------------------
65
+ # POST /api/v1/settings/api-key → supprimé (clés dans secrets HF, R06)
66
  # ---------------------------------------------------------------------------
67
 
68
  @pytest.mark.asyncio
69
+ async def test_settings_api_key_endpoint_removed(async_client):
70
+ """L'endpoint /api/v1/settings/api-key ne doit plus exister (404 ou 405)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  response = await async_client.post(
72
  "/api/v1/settings/api-key",
73
+ json={"api_key": "AIza-test", "provider_type": "google_ai_studio"},
74
  )
75
+ assert response.status_code in (404, 405)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
 
78
  # ---------------------------------------------------------------------------
frontend/src/lib/api.ts CHANGED
@@ -2,13 +2,6 @@ const BASE_URL: string = import.meta.env.VITE_API_URL ?? ''
2
 
3
  // ── Types ─────────────────────────────────────────────────────────────────────
4
 
5
- export interface ApiKeyResponse {
6
- valid: boolean
7
- provider: string
8
- model_count: number
9
- error: string | null
10
- }
11
-
12
  export interface ModelInfo {
13
  model_id: string
14
  display_name: string
@@ -240,9 +233,6 @@ export const listProfiles = (): Promise<CorpusProfile[]> =>
240
  export const createCorpus = (input: CreateCorpusInput): Promise<Corpus> =>
241
  post('/api/v1/corpora', input)
242
 
243
- export const validateApiKey = (apiKey: string): Promise<ApiKeyResponse> =>
244
- post('/api/v1/settings/api-key', { api_key: apiKey })
245
-
246
  export const listModels = (): Promise<ModelInfo[]> =>
247
  get('/api/v1/models')
248
 
 
2
 
3
  // ── Types ─────────────────────────────────────────────────────────────────────
4
 
 
 
 
 
 
 
 
5
  export interface ModelInfo {
6
  model_id: string
7
  display_name: string
 
233
  export const createCorpus = (input: CreateCorpusInput): Promise<Corpus> =>
234
  post('/api/v1/corpora', input)
235
 
 
 
 
236
  export const listModels = (): Promise<ModelInfo[]> =>
237
  get('/api/v1/models')
238
 
frontend/src/pages/Admin.tsx CHANGED
@@ -3,7 +3,6 @@ import {
3
  fetchCorpora,
4
  listProfiles,
5
  createCorpus,
6
- validateApiKey,
7
  listModels,
8
  selectModel,
9
  ingestImages,
@@ -202,41 +201,27 @@ interface ModelSectionProps {
202
  }
203
 
204
  function ModelSection({ corpora, selectedCorpusId, onSelectCorpus }: ModelSectionProps) {
205
- const [apiKey, setApiKey] = useState('')
206
- const [validating, setValidating] = useState(false)
207
- const [apiKeyValid, setApiKeyValid] = useState(false)
208
- const [validateError, setValidateError] = useState<string | null>(null)
209
- const [validateInfo, setValidateInfo] = useState<string | null>(null)
210
  const [models, setModels] = useState<ModelInfo[]>([])
 
 
211
  const [selectedModelId, setSelectedModelId] = useState('')
212
  const [savingModel, setSavingModel] = useState(false)
213
  const [saveError, setSaveError] = useState<string | null>(null)
214
  const [saveSuccess, setSaveSuccess] = useState<string | null>(null)
215
 
216
- const handleValidate = async (e: FormEvent) => {
217
- e.preventDefault()
218
- setValidateError(null)
219
- setValidateInfo(null)
220
- setApiKeyValid(false)
221
- setModels([])
222
- setValidating(true)
223
- try {
224
- const resp = await validateApiKey(apiKey)
225
- if (resp.valid) {
226
- setApiKeyValid(true)
227
- setValidateInfo(`Clé valide — ${resp.model_count} modèle(s) détecté(s)`)
228
- const ms = await listModels()
229
  setModels(ms)
230
  if (ms.length > 0) setSelectedModelId(ms[0].model_id)
231
- } else {
232
- setValidateError(resp.error ?? 'Clé API invalide')
233
- }
234
- } catch (err) {
235
- setValidateError(err instanceof Error ? err.message : 'Erreur inconnue')
236
- } finally {
237
- setValidating(false)
238
- }
239
- }
240
 
241
  const handleSelectModel = async (e: FormEvent) => {
242
  e.preventDefault()
@@ -266,35 +251,25 @@ function ModelSection({ corpora, selectedCorpusId, onSelectCorpus }: ModelSectio
266
  <h2 className="text-lg font-semibold text-stone-800 mb-6">Configurer le modèle IA</h2>
267
  <CorpusSelector corpora={corpora} value={selectedCorpusId} onChange={onSelectCorpus} />
268
 
269
- {/* Validation clé API */}
270
- <form onSubmit={(e) => void handleValidate(e)} className="space-y-4 max-w-md mb-8">
271
- <div>
272
- <label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
273
- Clé API Google AI
274
- </label>
275
- <input
276
- type="password"
277
- value={apiKey}
278
- onChange={(e) => setApiKey(e.target.value)}
279
- required
280
- autoComplete="off"
281
- placeholder="AIza…"
282
- className="border border-stone-300 rounded px-3 py-2 text-sm w-full font-mono focus:outline-none focus:ring-2 focus:ring-stone-400"
283
- />
284
- </div>
285
- {validateError && <ErrorMsg message={validateError} />}
286
- {validateInfo && <SuccessMsg message={validateInfo} />}
287
- <button
288
- type="submit"
289
- disabled={validating || !apiKey}
290
- className="bg-stone-800 text-white px-5 py-2 rounded text-sm font-medium hover:bg-stone-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
291
- >
292
- {validating ? 'Vérification…' : 'Vérifier la clé'}
293
- </button>
294
- </form>
295
 
296
- {/* Sélection du modèle */}
297
- {apiKeyValid && models.length > 0 && (
298
  <form onSubmit={(e) => void handleSelectModel(e)} className="space-y-4 max-w-md">
299
  <div>
300
  <label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
 
3
  fetchCorpora,
4
  listProfiles,
5
  createCorpus,
 
6
  listModels,
7
  selectModel,
8
  ingestImages,
 
201
  }
202
 
203
  function ModelSection({ corpora, selectedCorpusId, onSelectCorpus }: ModelSectionProps) {
 
 
 
 
 
204
  const [models, setModels] = useState<ModelInfo[]>([])
205
+ const [loadingModels, setLoadingModels] = useState(true)
206
+ const [loadError, setLoadError] = useState<string | null>(null)
207
  const [selectedModelId, setSelectedModelId] = useState('')
208
  const [savingModel, setSavingModel] = useState(false)
209
  const [saveError, setSaveError] = useState<string | null>(null)
210
  const [saveSuccess, setSaveSuccess] = useState<string | null>(null)
211
 
212
+ useEffect(() => {
213
+ setLoadingModels(true)
214
+ setLoadError(null)
215
+ listModels()
216
+ .then((ms) => {
 
 
 
 
 
 
 
 
217
  setModels(ms)
218
  if (ms.length > 0) setSelectedModelId(ms[0].model_id)
219
+ })
220
+ .catch((err) => {
221
+ setLoadError(err instanceof Error ? err.message : 'Erreur inconnue')
222
+ })
223
+ .finally(() => setLoadingModels(false))
224
+ }, [])
 
 
 
225
 
226
  const handleSelectModel = async (e: FormEvent) => {
227
  e.preventDefault()
 
251
  <h2 className="text-lg font-semibold text-stone-800 mb-6">Configurer le modèle IA</h2>
252
  <CorpusSelector corpora={corpora} value={selectedCorpusId} onChange={onSelectCorpus} />
253
 
254
+ {loadingModels && (
255
+ <p className="text-sm text-stone-400">Chargement des modèles disponibles…</p>
256
+ )}
257
+
258
+ {!loadingModels && loadError && (
259
+ <ErrorMsg message={loadError} />
260
+ )}
261
+
262
+ {!loadingModels && !loadError && models.length === 0 && (
263
+ <p className="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded px-3 py-2">
264
+ Aucun modèle détecté. Vérifiez que les secrets{' '}
265
+ <code className="font-mono">AI_PROVIDER</code> et{' '}
266
+ <code className="font-mono">VERTEX_API_KEY</code> (ou{' '}
267
+ <code className="font-mono">GOOGLE_AI_STUDIO_API_KEY</code>) sont bien configurés
268
+ dans les secrets HuggingFace.
269
+ </p>
270
+ )}
 
 
 
 
 
 
 
 
 
271
 
272
+ {!loadingModels && models.length > 0 && (
 
273
  <form onSubmit={(e) => void handleSelectModel(e)} className="space-y-4 max-w-md">
274
  <div>
275
  <label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">