feat(pool): bascule auto sur cle API invalide (400 INVALID_ARGUMENT)
Browse filesSi 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.
- app.py +57 -3
- jarvis.py +60 -0
- tests/test_jarvis_helpers.py +26 -0
|
@@ -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
|
|
@@ -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
|
|
@@ -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."""
|