expAge commited on
Commit
c44d7ec
·
1 Parent(s): 69c85d0

feat(pool): bascule auto sur cle API invalide (400 INVALID_ARGUMENT)

Browse files

Si une cle du pool est invalide (typo, revoquee, jamais activee pour
Gemini), Google renvoie 400 INVALID_ARGUMENT avec reason
'API_KEY_INVALID'. Avant : l'erreur remontait au LLM. Maintenant :
on marque la cle comme INVALIDE pour la session (permanent, pas reset
a minuit comme PerDay) et on bascule sur la suivante du pool.

- jarvis.is_invalid_api_key(exc) : detection via 'API_KEY_INVALID' ou
'API key not valid' dans le message.
- app._INVALID_KEYS : set des cles marquees invalides pour la session.
- app.mark_gemini_key_invalid(key) : ajoute au set.
- app.pick_unblown_gemini_key : exclut maintenant aussi les cles
invalides (avant les checks de PerDay).
- run_jarvis_flow + chat_with_agent : branche 'is_invalid_api_key'
AVANT 'is_per_day_quota_exhausted' (priorite plus haute). Meme
pattern : mark + pick next + rebuild LLM + agent + yield UI +
continue. UI message '🔑 Cle Google invalide detectee - bascule
sur une autre cle du pool'.

Si toutes les cles sont invalides ou epuisees : RuntimeError clair.

Tests : 3 nouveaux (detects 400, false on 429, false on generic).
183 tests verts.

Files changed (3) hide show
  1. app.py +57 -3
  2. jarvis.py +60 -0
  3. tests/test_jarvis_helpers.py +26 -0
app.py CHANGED
@@ -320,8 +320,6 @@ def _parse_google_keys() -> list[str]:
320
  return [single] if single else []
321
 
322
 
323
- # Marquage in-memory des clés épuisées par jour UTC.
324
- # Format : {(api_key, "YYYY-MM-DD"): True}
325
  # Marquage in-memory **par (clé, modèle)** : chaque modèle Gemini a
326
  # son propre quota PerDay → une clé peut être blown pour
327
  # gemini-3.1-flash-lite mais OK pour gemini-3.5-flash. Format :
@@ -329,6 +327,19 @@ def _parse_google_keys() -> list[str]:
329
  # Reset implicite à minuit UTC (l'entrée du jour précédent n'existe plus).
330
  _BLOWN_TODAY: dict[tuple[str, str, str], bool] = {}
331
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
 
333
  def _today_utc_str() -> str:
334
  from datetime import datetime, timezone
@@ -348,12 +359,17 @@ def pick_unblown_gemini_key(model: str,
348
  (utilisé pour ne pas re-choisir celle qui vient de hit le PerDay).
349
 
350
  `model` est requis : chaque modèle Gemini a son propre quota PerDay,
351
- donc une clé peut être blown pour un modèle et OK pour un autre."""
 
 
 
352
  keys = _parse_google_keys()
353
  today = _today_utc_str()
354
  for k in keys:
355
  if k == skip:
356
  continue
 
 
357
  if not _BLOWN_TODAY.get((k, model, today), False):
358
  return k
359
  return None
@@ -807,6 +823,44 @@ def chat_with_agent(message: str, history: list[dict], api_key: str, model: str,
807
  # Sortie normale → on quitte le while
808
  break
809
  except Exception as e:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
810
  # 1) Quota QUOTIDIEN épuisé SUR LE MODÈLE COURANT.
811
  # On filtre par expected_model pour ignorer les PerDay
812
  # parasites venant d'un autre modèle (ex : un quota
 
320
  return [single] if single else []
321
 
322
 
 
 
323
  # Marquage in-memory **par (clé, modèle)** : chaque modèle Gemini a
324
  # son propre quota PerDay → une clé peut être blown pour
325
  # gemini-3.1-flash-lite mais OK pour gemini-3.5-flash. Format :
 
327
  # Reset implicite à minuit UTC (l'entrée du jour précédent n'existe plus).
328
  _BLOWN_TODAY: dict[tuple[str, str, str], bool] = {}
329
 
330
+ # Clés marquées comme INVALIDES (typo, révoquée, etc.) — exclusion
331
+ # PERMANENTE pour la session courante (pas reset à minuit). Détecté
332
+ # via le code 400 INVALID_ARGUMENT / API_KEY_INVALID renvoyé par
333
+ # l'API Google quand on tente d'utiliser une clé bidon.
334
+ _INVALID_KEYS: set[str] = set()
335
+
336
+
337
+ def mark_gemini_key_invalid(key: str) -> None:
338
+ """Marque une clé comme définitivement invalide pour la session
339
+ (typo, révoquée, jamais activée pour Gemini, etc.)."""
340
+ if key:
341
+ _INVALID_KEYS.add(key)
342
+
343
 
344
  def _today_utc_str() -> str:
345
  from datetime import datetime, timezone
 
359
  (utilisé pour ne pas re-choisir celle qui vient de hit le PerDay).
360
 
361
  `model` est requis : chaque modèle Gemini a son propre quota PerDay,
362
+ donc une clé peut être blown pour un modèle et OK pour un autre.
363
+
364
+ Exclut aussi les clés marquées comme INVALIDES (API_KEY_INVALID)
365
+ pour la session courante."""
366
  keys = _parse_google_keys()
367
  today = _today_utc_str()
368
  for k in keys:
369
  if k == skip:
370
  continue
371
+ if k in _INVALID_KEYS:
372
+ continue
373
  if not _BLOWN_TODAY.get((k, model, today), False):
374
  return k
375
  return None
 
823
  # Sortie normale → on quitte le while
824
  break
825
  except Exception as e:
826
+ # 0) Clé API invalide → marque + switch.
827
+ from jarvis import is_invalid_api_key
828
+ if is_invalid_api_key(e):
829
+ switched = False
830
+ try:
831
+ if current_gemini_key:
832
+ mark_gemini_key_invalid(current_gemini_key)
833
+ next_key = pick_unblown_gemini_key(
834
+ model, skip=current_gemini_key
835
+ )
836
+ if next_key:
837
+ pool_n = gemini_pool_size()
838
+ current_gemini_key = next_key
839
+ llm = _build_llm(
840
+ model, api_key,
841
+ use_thinking=use_thinking,
842
+ gemini_key_override=current_gemini_key,
843
+ )
844
+ agent = build_jdm_agent(
845
+ client=get_client(), llm=llm
846
+ )
847
+ switch_msg = (
848
+ f"\n\n*🔑 Clé Google invalide détectée — "
849
+ f"bascule sur une autre clé du pool "
850
+ f"(pool : {pool_n} clés).*"
851
+ )
852
+ current_progress = "\n\n".join(progress_live)
853
+ yield current_progress + switch_msg, _NOOP_FILE
854
+ switched = True
855
+ except Exception:
856
+ pass
857
+ if switched:
858
+ continue
859
+ raise RuntimeError(
860
+ "Toutes les clés du pool Google sont soit "
861
+ "invalides, soit épuisées pour aujourd'hui. "
862
+ "Vérifie GOOGLE_API_KEYS."
863
+ ) from e
864
  # 1) Quota QUOTIDIEN épuisé SUR LE MODÈLE COURANT.
865
  # On filtre par expected_model pour ignorer les PerDay
866
  # parasites venant d'un autre modèle (ex : un quota
jarvis.py CHANGED
@@ -253,6 +253,15 @@ def count_consolidated_in_messages(messages: list) -> int:
253
  return n
254
 
255
 
 
 
 
 
 
 
 
 
 
256
  def _extract_quota_model(exc) -> Optional[str]:
257
  """Extrait l'identifiant du modèle concerné par un quota épuisé,
258
  via `quotaDimensions.model` dans le message d'erreur.
@@ -1315,6 +1324,57 @@ def run_jarvis_flow(
1315
  # tente de basculer sur la suivante non-blown.
1316
  # Sinon (pool vide / toutes blown), on signale
1317
  # et on stop.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1318
  # On filtre par expected_model : un PerDay sur
1319
  # un AUTRE modèle (ex. gemini-2.5 alors qu'on
1320
  # est sur 3.1) ne nous concerne pas, on l'ignore
 
253
  return n
254
 
255
 
256
+ def is_invalid_api_key(exc) -> bool:
257
+ """Détecte une clé API invalide (typo, révoquée, jamais activée
258
+ pour Gemini, etc.). Google renvoie alors 400 INVALID_ARGUMENT avec
259
+ `reason: 'API_KEY_INVALID'`. À traiter comme exclusion permanente
260
+ (la clé ne deviendra pas valide en attendant) → bascule de clé."""
261
+ msg = str(exc)
262
+ return "API_KEY_INVALID" in msg or "API key not valid" in msg
263
+
264
+
265
  def _extract_quota_model(exc) -> Optional[str]:
266
  """Extrait l'identifiant du modèle concerné par un quota épuisé,
267
  via `quotaDimensions.model` dans le message d'erreur.
 
1324
  # tente de basculer sur la suivante non-blown.
1325
  # Sinon (pool vide / toutes blown), on signale
1326
  # et on stop.
1327
+ # 0) Clé API invalide (typo, révoquée). Marquer
1328
+ # la clé courante comme invalide pour la session
1329
+ # et basculer sur la suivante du pool. Même
1330
+ # logique que PerDay (rebuild LLM + agent +
1331
+ # continue), mais marquage différent (permanent).
1332
+ if is_invalid_api_key(e):
1333
+ switched = False
1334
+ try:
1335
+ from app import (
1336
+ mark_gemini_key_invalid,
1337
+ pick_unblown_gemini_key,
1338
+ gemini_pool_size,
1339
+ )
1340
+ if current_gemini_key:
1341
+ mark_gemini_key_invalid(current_gemini_key)
1342
+ next_key = pick_unblown_gemini_key(
1343
+ model, skip=current_gemini_key
1344
+ )
1345
+ if next_key:
1346
+ pool_n = gemini_pool_size()
1347
+ current_gemini_key = next_key
1348
+ llm = build_llm_fn(
1349
+ model, api_key,
1350
+ use_thinking=use_thinking,
1351
+ gemini_key_override=current_gemini_key,
1352
+ )
1353
+ agent = build_agent_fn(
1354
+ client=get_client_fn(), llm=llm
1355
+ )
1356
+ _add_line(
1357
+ f"*🔑 Clé Google invalide détectée — "
1358
+ f"bascule sur une autre clé du pool "
1359
+ f"(pool : {pool_n} clés).*"
1360
+ )
1361
+ yield (
1362
+ [{"role": "user", "content": user_display},
1363
+ {"role": "assistant",
1364
+ "content": "\n\n".join(progress_live)}],
1365
+ last_file_path,
1366
+ _read_file_preview(last_file_path),
1367
+ )
1368
+ switched = True
1369
+ except Exception:
1370
+ pass
1371
+ if switched:
1372
+ continue
1373
+ raise RuntimeError(
1374
+ "Toutes les clés du pool Google sont soit "
1375
+ "invalides, soit épuisées pour aujourd'hui. "
1376
+ "Vérifie GOOGLE_API_KEYS dans tes secrets / .env."
1377
+ ) from e
1378
  # On filtre par expected_model : un PerDay sur
1379
  # un AUTRE modèle (ex. gemini-2.5 alors qu'on
1380
  # est sur 3.1) ne nous concerne pas, on l'ignore
tests/test_jarvis_helpers.py CHANGED
@@ -19,6 +19,7 @@ from jarvis import (
19
  build_signalement_prompt,
20
  build_stats_prompt,
21
  detect_rate_limit_retry,
 
22
  is_per_day_quota_exhausted,
23
  )
24
 
@@ -68,6 +69,31 @@ def test_is_per_day_quota_exhausted_false_on_non_quota():
68
  assert is_per_day_quota_exhausted(ValueError("foo")) is False
69
 
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  def test_is_per_day_quota_exhausted_filter_by_model():
72
  """Avec expected_model, ne renvoie True QUE si le quota PerDay
73
  concerne le modèle attendu."""
 
19
  build_signalement_prompt,
20
  build_stats_prompt,
21
  detect_rate_limit_retry,
22
+ is_invalid_api_key,
23
  is_per_day_quota_exhausted,
24
  )
25
 
 
69
  assert is_per_day_quota_exhausted(ValueError("foo")) is False
70
 
71
 
72
+ _GEMINI_400_INVALID_KEY = (
73
+ "Error calling model 'gemini-3.1-flash-lite' (INVALID_ARGUMENT): 400 "
74
+ "INVALID_ARGUMENT. {'error': {'code': 400, 'message': 'API key not "
75
+ "valid. Please pass a valid API key.', 'status': 'INVALID_ARGUMENT', "
76
+ "'details': [{'reason': 'API_KEY_INVALID', "
77
+ "'domain': 'googleapis.com'}]}}"
78
+ )
79
+
80
+
81
+ def test_is_invalid_api_key_detects_400():
82
+ """Détection de la clé Google invalide (400 INVALID_ARGUMENT)."""
83
+ assert is_invalid_api_key(Exception(_GEMINI_400_INVALID_KEY)) is True
84
+
85
+
86
+ def test_is_invalid_api_key_false_on_quota():
87
+ """Une 429 quota n'est PAS une clé invalide."""
88
+ assert is_invalid_api_key(Exception(_GEMINI_429_PERMINUTE)) is False
89
+
90
+
91
+ def test_is_invalid_api_key_false_on_other():
92
+ """Erreur générique : pas une clé invalide."""
93
+ assert is_invalid_api_key(ValueError("foo")) is False
94
+ assert is_invalid_api_key(Exception("Connection refused")) is False
95
+
96
+
97
  def test_is_per_day_quota_exhausted_filter_by_model():
98
  """Avec expected_model, ne renvoie True QUE si le quota PerDay
99
  concerne le modèle attendu."""