Explorar o código

Clé Anthropic ou modèle faux : le bot s'arrête au démarrage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet hai 1 día
pai
achega
345cbc2cc7
Modificáronse 6 ficheiros con 104 adicións e 2 borrados
  1. 2 0
      CHANGELOG.md
  2. 1 1
      README.md
  3. 18 0
      bot/claude_client.py
  4. 6 0
      bot/discord_bot.py
  5. 3 1
      main.py
  6. 74 0
      tests/test_api_check.py

+ 2 - 0
CHANGELOG.md

@@ -34,6 +34,8 @@ Les versions les plus récentes sont en haut. Chaque entrée commence par un ré
 
 - **Un `CHANNEL_ID` faux arrête le bot.** Si le salon était introuvable (ID du serveur ou d'un autre salon collé par erreur), le bot restait connecté sans répondre nulle part : sur OpenShift, le pod paraissait « Running » alors que Rika était muette. Un ID de catégorie ou de forum faisait planter le message de démarrage, et une permission manquante n'était pas signalée. Le bot vérifie maintenant au démarrage que `CHANNEL_ID` désigne un salon textuel où il peut voir, écrire et lire l'historique, et sinon s'arrête avec un message « Erreur de configuration » qui dit quoi corriger. Si un serveur est seulement indisponible chez Discord (panne), il patiente et s'annonce dès son retour.
 
+- **Une clé Anthropic ou un modèle faux arrête le bot.** Une `ANTHROPIC_API_KEY` révoquée ou mal recopiée, ou un `CLAUDE_MODEL` inexistant, ne se voyait qu'au premier message : le bot s'annonçait « En ligne », puis répondait « Je suis mal configuré » à chaque message. Il vérifie maintenant la clé et le modèle au démarrage, sans consommer de tokens, et sinon s'arrête avec un message « Erreur de configuration » qui dit quoi corriger. Si l'API est seulement injoignable, il démarre quand même et le signale dans le journal. Un crédit épuisé ne peut pas être détecté gratuitement : il ne se voit toujours qu'au premier message.
+
 ---
 
 ## Version 5.11

+ 1 - 1
README.md

@@ -93,7 +93,7 @@ Le bot ne sert qu'un serveur, celui du salon `CHANNEL_ID` : ailleurs (autre serv
    ```
    python main.py
    ```
-   Il annonce son arrivée dans son salon avec sa version. En cas d'erreur de configuration (variable manquante, token invalide, intent non activé, `CHANNEL_ID` qui ne désigne pas un salon textuel où le bot peut écrire, permission manquante), il s'arrête en expliquant quoi corriger. Seule exception : si l'un de ses serveurs est indisponible chez Discord au démarrage (panne), il patiente et s'annonce dès le retour du serveur.
+   Il annonce son arrivée dans son salon avec sa version. En cas d'erreur de configuration (variable manquante, token invalide, clé Anthropic invalide, modèle inexistant, intent non activé, `CHANNEL_ID` qui ne désigne pas un salon textuel où le bot peut écrire, permission manquante), il s'arrête en expliquant quoi corriger. Seule exception : si l'un de ses serveurs est indisponible chez Discord au démarrage (panne), il patiente et s'annonce dès le retour du serveur.
 
 ## Configuration
 

+ 18 - 0
bot/claude_client.py

@@ -203,6 +203,24 @@ class ClaudeClient:
     async def close(self) -> None:
         await self._client.close()
 
+    async def check_access(self) -> str | None:
+        """Vérifie au démarrage la clé API et CLAUDE_MODEL, par un appel gratuit (aucun token consommé).
+        Renvoie le problème à corriger si le bot ne peut pas fonctionner, None sinon. Une panne passagère
+        de l'API n'empêche pas le démarrage. Un crédit épuisé ne se voit qu'au premier message."""
+        model = self.settings.model
+        try:
+            await asyncio.wait_for(self._client.models.retrieve(model), min(self.settings.api_timeout, 15))
+        except anthropic.AuthenticationError:
+            return "clé API Anthropic invalide : vérifie ANTHROPIC_API_KEY dans le .env."
+        except anthropic.PermissionDeniedError as exc:
+            return f"accès refusé par l'API Anthropic (clé désactivée ou sans accès ?) : {self._api_message(exc)}"
+        except anthropic.NotFoundError:
+            return f"modèle {model} introuvable chez Anthropic : vérifie CLAUDE_MODEL dans le .env."
+        except (anthropic.APIError, asyncio.TimeoutError) as exc:
+            logger.warning("Clé API et modèle non vérifiés au démarrage (API injoignable ?) : %s",
+                           str(exc) or "délai dépassé")
+        return None
+
     # ------------------------------------------------------------ personnalité
 
     def personality(self) -> str:

+ 6 - 0
bot/discord_bot.py

@@ -202,6 +202,12 @@ class ChatBot(commands.Bot):
 
     async def setup_hook(self) -> None:
         # Exécuté une seule fois au démarrage (contrairement à on_ready).
+        if problem := await self.claude.check_access():
+            # Sinon le bot s'annoncerait « En ligne » puis échouerait sur chaque message.
+            logger.critical("Erreur de configuration : %s", problem)
+            self.config_error = problem
+            await self.close()  # connect() voit le bot fermé et rend la main à main.py
+            return
         self.tree.add_command(reset_command)
         if self.presence:
             self.tree.add_command(skip_command)

+ 3 - 1
main.py

@@ -51,7 +51,9 @@ def main() -> None:
             "Discord (onglet Bot > Privileged Gateway Intents)."
         )
         sys.exit(1)
-    if bot.config_error:  # salon dédié inutilisable (voir ChatBot.announce), déjà expliqué dans le journal
+    # Clé API ou modèle refusés (ChatBot.setup_hook), salon dédié inutilisable (ChatBot.announce) :
+    # déjà expliqué dans le journal.
+    if bot.config_error:
         sys.exit(1)
 
 

+ 74 - 0
tests/test_api_check.py

@@ -0,0 +1,74 @@
+"""Tests de la vérification de la clé API et du modèle au démarrage. Lancer depuis la racine du projet : python -m unittest"""
+
+import asyncio
+import unittest
+from types import SimpleNamespace
+from unittest import mock
+
+import anthropic
+
+from bot.claude_client import ClaudeClient
+from bot.discord_bot import ChatBot
+
+
+def fake_client(retrieve) -> ClaudeClient:
+    # Sans __init__ : pas de client Anthropic à créer.
+    client = ClaudeClient.__new__(ClaudeClient)
+    client.settings = SimpleNamespace(api_timeout=0.2, model="claude-opus-5-5")
+    client._client = SimpleNamespace(models=SimpleNamespace(retrieve=retrieve))
+    return client
+
+
+def api_error(cls, status: int):
+    response = mock.Mock(status_code=status, headers={})
+    return cls("erreur", response=response, body={"error": {"message": "détail de l'API"}})
+
+
+class CheckAccessTest(unittest.IsolatedAsyncioTestCase):
+    async def check(self, error):
+        return await fake_client(mock.AsyncMock(side_effect=error)).check_access()
+
+    async def test_valid_key_and_model(self):
+        self.assertIsNone(await fake_client(mock.AsyncMock()).check_access())
+
+    async def test_invalid_key(self):
+        self.assertIn("ANTHROPIC_API_KEY", await self.check(api_error(anthropic.AuthenticationError, 401)))
+
+    async def test_permission_denied(self):
+        self.assertIn("détail de l'API", await self.check(api_error(anthropic.PermissionDeniedError, 403)))
+
+    async def test_unknown_model(self):
+        problem = await self.check(api_error(anthropic.NotFoundError, 404))
+        self.assertIn("CLAUDE_MODEL", problem)
+        self.assertIn("claude-opus-5-5", problem)
+
+    async def test_api_down_does_not_block(self):
+        # Panne passagère : on démarre quand même, avec un avertissement.
+        for error in (api_error(anthropic.InternalServerError, 500),
+                      anthropic.APIConnectionError(request=mock.Mock())):
+            with self.subTest(error=type(error).__name__), self.assertLogs("chatbot.claude", "WARNING"):
+                self.assertIsNone(await self.check(error))
+
+    async def test_slow_api_does_not_block(self):
+        async def slow(_):
+            await asyncio.sleep(10)
+
+        with self.assertLogs("chatbot.claude", "WARNING") as logs:
+            self.assertIsNone(await fake_client(slow).check_access())
+        self.assertIn("délai dépassé", logs.output[0])
+
+
+class SetupHookTest(unittest.IsolatedAsyncioTestCase):
+    async def test_bad_key_stops_bot(self):
+        bot = mock.Mock(config_error=None)
+        bot.claude.check_access = mock.AsyncMock(return_value="clé API Anthropic invalide")
+        bot.close = mock.AsyncMock()
+        with self.assertLogs("chatbot", "CRITICAL"):
+            await ChatBot.setup_hook(bot)
+        self.assertEqual(bot.config_error, "clé API Anthropic invalide")
+        bot.close.assert_awaited_once()
+        bot.tree.add_command.assert_not_called()
+
+
+if __name__ == "__main__":
+    unittest.main()