claude_client.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. """Communication avec l'API de Claude (Anthropic)."""
  2. from __future__ import annotations
  3. import asyncio
  4. import base64
  5. import logging
  6. import re
  7. from dataclasses import dataclass
  8. from pathlib import Path
  9. import anthropic
  10. from .config import Settings
  11. from .emojis import DISCORD_EMOJI, SHORT_EMOJI
  12. from .errors import UserFacingError
  13. from .media import ImageData
  14. from .memory import MAX_NICKNAME_LENGTH, MAX_NICKNAMES, MEMORY_TOOL, MemoryStore, ToolResult
  15. from .models import thinking_off
  16. from .prompts import (CUSTOM_STATUS_PROMPT, CUSTOM_STATUS_REQUESTS, DEFAULT_PERSONALITY, EMOJI_NOTE, GROUP_NOTE,
  17. MEMBER_MEMORY_NOTE, MEMORY_NOTE, OUTPUT_NOTE, PRESENCE_NOTE, TOOL_NOTE, WEB_NOTE)
  18. from .text_utils import strip_reasoning
  19. logger = logging.getLogger("chatbot.claude")
  20. # Erreur due à la configuration (clé, crédit, modèle, réglages) : seul l'administrateur peut la corriger.
  21. CONFIG_ERROR = "Je suis mal configuré, préviens l'administrateur du bot."
  22. TRUNCATED_ERROR = ("Ma réponse a été coupée avant que j'aie pu l'écrire. Si cela se répète, "
  23. "préviens l'administrateur du bot.")
  24. # Nombre maximal de relances quand l'API met la réponse en pause pendant les recherches.
  25. MAX_CONTINUATIONS = 3
  26. class ClaudeError(UserFacingError):
  27. """Erreur lors de l'appel à Claude, avec un message affichable sur Discord."""
  28. @dataclass
  29. class Answer:
  30. text: str
  31. web_used: bool # Claude a fait au moins une recherche web ou lu une page
  32. class ClaudeClient:
  33. def __init__(self, settings: Settings, memory: MemoryStore | None = None):
  34. self.settings = settings
  35. self.memory = memory # None : mémoire des membres désactivée
  36. # Le SDK réessaie tout seul les erreurs temporaires (surcharge, limite de requêtes…),
  37. # dans la limite de API_TIMEOUT au total (voir _call).
  38. self._client = anthropic.AsyncAnthropic(
  39. api_key=settings.anthropic_api_key,
  40. timeout=settings.api_timeout,
  41. max_retries=3,
  42. )
  43. self._personality_cache: tuple[float, str] | None = None
  44. async def close(self) -> None:
  45. await self._client.close()
  46. async def check_access(self) -> str | None:
  47. """Vérifie au démarrage la clé API et CLAUDE_MODEL, par un appel gratuit (aucun token consommé).
  48. Renvoie le problème à corriger si le bot ne peut pas fonctionner, None sinon. Une panne passagère
  49. de l'API n'empêche pas le démarrage. Un crédit épuisé ne se voit qu'au premier message."""
  50. model = self.settings.model
  51. try:
  52. await asyncio.wait_for(self._client.models.retrieve(model), min(self.settings.api_timeout, 15))
  53. except anthropic.AuthenticationError:
  54. return "clé API Anthropic invalide : vérifie ANTHROPIC_API_KEY dans le .env."
  55. except anthropic.PermissionDeniedError as exc:
  56. return f"accès refusé par l'API Anthropic (clé désactivée ou sans accès ?) : {self._api_message(exc)}"
  57. except anthropic.NotFoundError:
  58. return f"modèle {model} introuvable chez Anthropic : vérifie CLAUDE_MODEL dans le .env."
  59. except (anthropic.APIError, asyncio.TimeoutError) as exc:
  60. logger.warning("Clé API et modèle non vérifiés au démarrage (API injoignable ?) : %s",
  61. str(exc) or "délai dépassé")
  62. return None
  63. # ------------------------------------------------------------ personnalité
  64. def personality(self) -> str:
  65. """Lit personality_prompt.txt (relu automatiquement s'il a été modifié)."""
  66. path: Path = self.settings.personality_file
  67. try:
  68. mtime = path.stat().st_mtime
  69. except FileNotFoundError:
  70. if self._personality_cache != (0.0, DEFAULT_PERSONALITY):
  71. logger.warning("%s introuvable : utilisation de la personnalité par défaut.", path)
  72. self._personality_cache = (0.0, DEFAULT_PERSONALITY)
  73. return DEFAULT_PERSONALITY
  74. except OSError as e:
  75. return self._personality_unreadable(-1.0, f"{path} illisible ({e})")
  76. if self._personality_cache is not None and self._personality_cache[0] == mtime:
  77. return self._personality_cache[1]
  78. if path.is_dir(): # ConfigMap montée comme dossier (Windows lèverait une PermissionError peu parlante)
  79. return self._personality_unreadable(
  80. mtime, f"{path} est un dossier : PERSONALITY_FILE doit désigner le fichier lui-même")
  81. try:
  82. text = path.read_text(encoding="utf-8").strip()
  83. except UnicodeDecodeError:
  84. return self._personality_unreadable(
  85. mtime, f"{path} n'est pas en UTF-8 : enregistre-le en UTF-8 (Bloc-notes : Enregistrer sous, Encodage)")
  86. except OSError as e:
  87. return self._personality_unreadable(mtime, f"{path} illisible ({e})")
  88. self._personality_cache = (mtime, text or DEFAULT_PERSONALITY)
  89. logger.info("Personnalité chargée depuis %s", path)
  90. return self._personality_cache[1]
  91. def _personality_unreadable(self, mtime: float, reason: str) -> str:
  92. """Garde la dernière personnalité valide (ou celle par défaut) ; une seule erreur par version du fichier."""
  93. text = self._personality_cache[1] if self._personality_cache else DEFAULT_PERSONALITY
  94. if self._personality_cache is None or self._personality_cache[0] != mtime:
  95. kept = "celle par défaut" if text == DEFAULT_PERSONALITY else "la dernière valide"
  96. logger.error("%s. Personnalité conservée : %s.", reason, kept)
  97. self._personality_cache = (mtime, text)
  98. return text
  99. # ------------------------------------------------------------------ appel
  100. @staticmethod
  101. def build_user_content(parts: list[tuple[str, list[ImageData]]]) -> str | list[dict]:
  102. """Un ou plusieurs messages à la suite, chacun précédé de ses images (ou de son PDF) éventuels."""
  103. if not any(images for _, images in parts):
  104. return "\n".join(text for text, _ in parts)
  105. content: list[dict] = []
  106. for text, images in parts:
  107. for number, image in enumerate(images, 1):
  108. if len(images) > 1: # numérotées : Claude peut parler de « la deuxième image »
  109. content.append({"type": "text", "text": f"Image {number} :"})
  110. content.append({
  111. "type": "document" if image.is_pdf else "image",
  112. "source": {
  113. "type": "base64",
  114. "media_type": image.media_type,
  115. "data": base64.standard_b64encode(image.data).decode("ascii"),
  116. },
  117. })
  118. content.append({"type": "text", "text": text})
  119. return content
  120. async def ask(self, history: list[dict], parts: list[tuple[str, list[ImageData]]],
  121. emojis: list[str] | None = None, cache_history: bool = True, member_notes: str = "") -> Answer:
  122. """Envoie l'historique + le ou les nouveaux messages (texte, images ou PDF), et renvoie la réponse de Claude.
  123. emojis : noms des emojis du serveur que Claude peut écrire (voir EMOJI_NOTE).
  124. cache_history=False évite d'écrire les messages en cache quand l'appel suivant ne les
  125. reprendra pas (mentions : contexte différent à chaque fois).
  126. member_notes : fiches des membres concernés (voir MemoryStore.notes_for), jointes au message
  127. mais absentes de l'historique."""
  128. content = self.build_user_content(parts)
  129. # Les fiches, absentes de l'historique enregistré, viennent après le marqueur de cache :
  130. # placées avant, elles empêcheraient le message suivant de relire l'historique en cache.
  131. history_marker = cache_history
  132. if member_notes:
  133. if isinstance(content, str):
  134. content = [{"type": "text", "text": content}]
  135. if cache_history:
  136. content[-1] = {**content[-1], "cache_control": {"type": "ephemeral"}}
  137. history_marker = False # marqueur déjà placé, avant les fiches
  138. content.append({"type": "text", "text": member_notes})
  139. messages = list(history) + [{"role": "user", "content": content}]
  140. attachments = {("pdf" if image.is_pdf else "image") for _, images in parts for image in images}
  141. system = self.personality() + MEMORY_NOTE + GROUP_NOTE + OUTPUT_NOTE
  142. if self.settings.web_search:
  143. system += WEB_NOTE
  144. if self.memory:
  145. system += MEMBER_MEMORY_NOTE.format(member_limit=self.memory.member_limit,
  146. server_limit=self.memory.server_limit,
  147. max_nicknames=MAX_NICKNAMES,
  148. max_nickname_length=MAX_NICKNAME_LENGTH)
  149. if self.settings.web_search or self.memory:
  150. system += TOOL_NOTE
  151. if self.settings.presence:
  152. system += PRESENCE_NOTE
  153. if emojis:
  154. system += EMOJI_NOTE.format(example=emojis[0], names=" ".join(f":{name}:" for name in emojis))
  155. if self.memory:
  156. # En dernier : la liste des membres et le fichier général sont ce qui change le plus souvent.
  157. # Le prompt système n'a pourtant qu'un marqueur de cache : tout changement ici fait réécrire
  158. # en cache tout le prompt, historique compris, à la requête suivante.
  159. system += self.memory.system_context()
  160. # Cache de prompt : les outils et la personnalité, identiques d'un message à l'autre, sont
  161. # relus depuis le cache à 10 % du prix (pendant 5 minutes, prolongées à chaque message).
  162. # Le marqueur sur "system" couvre ce bloc fixe ; celui de premier niveau (ou celui placé avant
  163. # les fiches) couvre en plus l'historique, qui change à chaque message mais reprend le début
  164. # de la requête précédente.
  165. # Une écriture en cache coûte 25 % de plus : inutile si elle ne sera jamais relue.
  166. params: dict = {
  167. "model": self.settings.model,
  168. "max_tokens": self.settings.max_tokens,
  169. "system": [{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}],
  170. "messages": messages,
  171. }
  172. if history_marker:
  173. params["cache_control"] = {"type": "ephemeral"}
  174. # Par défaut, les modèles récents réfléchissent avant de répondre, et cette réflexion
  175. # consomme le budget MAX_TOKENS. Avec THINKING=off, on la coupe de la façon propre au
  176. # modèle (voir models.py) ; sur Opus 5.5, impossible : seul EFFORT la réduit.
  177. if self.settings.thinking:
  178. params["thinking"] = {"type": "adaptive"}
  179. elif (off := thinking_off(self.settings.model)) is not None:
  180. params["thinking"] = off
  181. if self.settings.effort:
  182. params["extra_body"] = {"output_config": {"effort": self.settings.effort}}
  183. tools: list[dict] = []
  184. if self.settings.web_search:
  185. # Outils exécutés par Anthropic : leurs appels se font sans nous.
  186. tools += [
  187. {"type": "web_search_20260209", "name": "web_search", "max_uses": self.settings.web_max_uses},
  188. {"type": "web_fetch_20260209", "name": "web_fetch", "max_uses": self.settings.web_max_uses},
  189. ]
  190. if self.memory:
  191. tools.append(MEMORY_TOOL) # exécuté par le bot : voir _run_tools
  192. if tools:
  193. params["tools"] = tools
  194. responses = [await self._call(params, attachments)]
  195. pauses = operations = 0
  196. while True:
  197. last = responses[-1]
  198. # Lors de longues recherches, l'API peut rendre la main avant la fin (pause_turn) :
  199. # on renvoie alors sa réponse partielle telle quelle pour qu'elle continue.
  200. if last.stop_reason == "pause_turn" and pauses < MAX_CONTINUATIONS:
  201. pauses += 1
  202. follow_up = []
  203. # Appel à l'outil « memoire » : le bot l'exécute et renvoie le résultat. Une fois la limite
  204. # d'opérations atteinte, un dernier tour lui demande de répondre, puis la boucle s'arrête.
  205. elif last.stop_reason == "tool_use" and self.memory and operations <= self.settings.memory_max_operations:
  206. results = await self._run_tools(last, operations)
  207. follow_up = [{"role": "user", "content": results}]
  208. operations += len(results)
  209. else:
  210. break
  211. # Réponse renvoyée telle quelle, réflexion comprise : l'API l'exige pour continuer.
  212. messages = messages + [{"role": "assistant", "content": last.content}, *follow_up]
  213. responses.append(await self._call({**params, "messages": messages}, attachments))
  214. text = self._final_text(responses)
  215. web_used = self._log_usage(responses)
  216. # Sans ce nettoyage, la réflexion affichée resterait en mémoire et le modèle l'imiterait ensuite.
  217. if (cleaned := strip_reasoning(text)) != text:
  218. logger.warning("Réflexion écrite dans la réponse, retirée avant l'envoi : %r", text[:500])
  219. text = cleaned
  220. stop_reason = responses[-1].stop_reason
  221. if stop_reason == "max_tokens":
  222. # Un appel d'outil coupé n'est pas exécuté : sans ce message, la note perdue passerait inaperçue.
  223. cut = [block.name for block in responses[-1].content if block.type in ("tool_use", "server_tool_use")]
  224. if cut:
  225. logger.warning("Appel à l'outil « %s » coupé par MAX_TOKENS (%s), donc pas exécuté : augmente "
  226. "MAX_TOKENS dans le .env.", ", ".join(cut), self.settings.max_tokens)
  227. else:
  228. logger.warning("Réponse coupée par MAX_TOKENS (%s) : augmente MAX_TOKENS dans le .env "
  229. "si cela arrive souvent.", self.settings.max_tokens)
  230. # Coupée avant son premier mot (réflexion ou appel d'outil trop long) : « […] » seul
  231. # n'aurait aucun sens sur Discord ni dans l'historique.
  232. if not text:
  233. raise ClaudeError(TRUNCATED_ERROR)
  234. text += " […]"
  235. elif stop_reason == "pause_turn":
  236. logger.warning("Recherche web toujours en cours après %s relances : réponse partielle.", MAX_CONTINUATIONS)
  237. if not text:
  238. logger.warning("Réponse vide de Claude (stop_reason=%s).", stop_reason)
  239. raise ClaudeError("Je n'ai rien trouvé à répondre à ça.")
  240. return Answer(text, web_used)
  241. async def _run_tools(self, response, done: int) -> list[dict]:
  242. """Exécute les appels à l'outil « memoire » d'une réponse. Renvoie leurs résultats, à envoyer
  243. tous dans un même message comme l'exige l'API.
  244. done : opérations déjà faites pour cette réponse."""
  245. results = []
  246. for block in response.content:
  247. if block.type != "tool_use":
  248. continue
  249. if done >= self.settings.memory_max_operations:
  250. result = ToolResult("Limite d'opérations atteinte pour ce message : réponds maintenant, "
  251. "sans rien noter de plus.", is_error=True)
  252. elif block.name != MEMORY_TOOL["name"] or not isinstance(block.input, dict):
  253. result = ToolResult(f"Outil inconnu : {block.name}.", is_error=True)
  254. else:
  255. result = await self.memory.run(block.input)
  256. done += 1
  257. results.append({"type": "tool_result", "tool_use_id": block.id, "content": result.content,
  258. "is_error": result.is_error})
  259. return results
  260. async def write_custom_status(self, kind: str, now: str, duration: str, recent: list[str],
  261. max_length: int) -> str | None:
  262. """Statut personnalisé du bot (voir CUSTOM_STATUS_REQUESTS), ou None en cas d'échec."""
  263. recent_part = ""
  264. if recent:
  265. recent_part = " Ne reprends pas tes statuts récents : " + ", ".join(f"« {r} »" for r in recent) + "."
  266. prompt = CUSTOM_STATUS_PROMPT.format(
  267. now=now, request=CUSTOM_STATUS_REQUESTS[kind].format(duration=duration), recent=recent_part,
  268. )
  269. params: dict = {
  270. "model": self.settings.model,
  271. "max_tokens": 1000,
  272. "system": self.personality() + OUTPUT_NOTE,
  273. "messages": [{"role": "user", "content": prompt}],
  274. # Une phrase courte : inutile de réfléchir longtemps.
  275. "extra_body": {"output_config": {"effort": "low"}},
  276. }
  277. if (off := thinking_off(self.settings.model)) is not None:
  278. params["thinking"] = off
  279. try:
  280. response = await self._call(params)
  281. except ClaudeError:
  282. return None
  283. self._log_usage([response])
  284. lines = [line.strip() for line in self._final_text([response]).splitlines() if line.strip()]
  285. # Un emoji du serveur s'afficherait sous forme de code (<:nom:123…> ou :nom:) : on le retire.
  286. thought = SHORT_EMOJI.sub("", DISCORD_EMOJI.sub("", lines[0])).strip("\"'«» ") if lines else ""
  287. thought = re.sub(r"\s{2,}", " ", thought)
  288. if not thought or "<" in thought: # vide, ou balise interne écrite par erreur
  289. logger.warning("Statut personnalisé inutilisable : %r", thought)
  290. return None
  291. return thought[:max_length]
  292. async def _call(self, params: dict, attachments: set[str] | frozenset[str] = frozenset()):
  293. """Appel à l'API ; attachments : pièces jointes envoyées ("image", "pdf"), pour les messages d'erreur."""
  294. try:
  295. # API_TIMEOUT borne l'attente totale, relances du SDK comprises : une erreur rapide (surcharge)
  296. # est encore réessayée, mais une requête trop lente n'est pas relancée (recherches refacturées).
  297. return await asyncio.wait_for(self._client.messages.create(**params), self.settings.api_timeout)
  298. except anthropic.RateLimitError:
  299. logger.warning("Limite de requêtes Anthropic atteinte.")
  300. raise ClaudeError("Je reçois trop de messages en ce moment, réessaie dans une minute.")
  301. except (anthropic.APITimeoutError, asyncio.TimeoutError):
  302. logger.warning("Délai dépassé lors de l'appel à Claude (API_TIMEOUT=%g s).", self.settings.api_timeout)
  303. raise ClaudeError("Je mets trop de temps à répondre, réessaie dans un instant.")
  304. except anthropic.APIConnectionError as exc:
  305. logger.error("Connexion à l'API impossible : %s", exc)
  306. raise ClaudeError("Je n'arrive pas à joindre mon cerveau pour l'instant, réessaie plus tard.")
  307. except anthropic.BadRequestError as exc:
  308. # Toutes les erreurs 400 ont le même type : seul le texte de l'API en donne la cause.
  309. message = self._api_message(exc).lower()
  310. if "pdf" in attachments and ("pdf" in message or "document" in message):
  311. logger.error("PDF refusé par l'API : %s", exc)
  312. raise ClaudeError("Je n'ai pas réussi à lire ce PDF, essaie avec un autre.")
  313. if "image" in attachments and "image" in message:
  314. logger.error("Image refusée par l'API : %s", exc)
  315. raise ClaudeError("Je n'ai pas réussi à lire cette image, essaie avec une autre.")
  316. if "prompt is too long" in message:
  317. logger.error("Requête trop longue pour le modèle : %s", exc)
  318. raise ClaudeError("Ce message est trop long pour moi avec la conversation en cours. "
  319. "Un administrateur peut vider ma mémoire avec /reset.")
  320. if "credit balance" in message:
  321. logger.critical("Crédit Anthropic épuisé : %s", exc)
  322. raise ClaudeError(CONFIG_ERROR)
  323. logger.critical("Requête refusée par l'API, sans doute à cause d'un réglage du .env "
  324. "(CLAUDE_MODEL, MAX_TOKENS, THINKING, EFFORT, WEB_SEARCH…) : %s", exc)
  325. raise ClaudeError(CONFIG_ERROR)
  326. except anthropic.AuthenticationError:
  327. logger.critical("Clé API Anthropic invalide : vérifie ANTHROPIC_API_KEY dans le .env.")
  328. raise ClaudeError(CONFIG_ERROR)
  329. except anthropic.PermissionDeniedError as exc:
  330. logger.critical("Accès refusé par l'API (clé sans accès à ce modèle ou à cet outil ?) : %s", exc)
  331. raise ClaudeError(CONFIG_ERROR)
  332. except anthropic.NotFoundError as exc:
  333. logger.critical("Modèle introuvable : vérifie CLAUDE_MODEL dans le .env (%s).", exc)
  334. raise ClaudeError(CONFIG_ERROR)
  335. except anthropic.APIStatusError as exc:
  336. logger.error("Erreur de l'API (%s) : %s", exc.status_code, exc)
  337. raise ClaudeError("Une erreur est survenue de mon côté, réessaie dans un instant.")
  338. @staticmethod
  339. def _api_message(exc: anthropic.APIStatusError) -> str:
  340. """Texte de l'erreur renvoyée par l'API ({"error": {"message": …}})."""
  341. body = exc.body
  342. if isinstance(body, dict) and isinstance(body.get("error"), dict):
  343. return str(body["error"].get("message", ""))
  344. return str(exc)
  345. @staticmethod
  346. def _final_text(responses) -> str:
  347. """Texte de la réponse finale, sans les commentaires écrits entre deux recherches."""
  348. parts: list[str] = []
  349. for response in responses:
  350. for block in response.content:
  351. if block.type == "text":
  352. parts.append(block.text)
  353. elif block.type not in ("thinking", "redacted_thinking"):
  354. # Appel ou résultat d'outil : le texte écrit avant n'était qu'un commentaire.
  355. parts = []
  356. return "".join(parts).strip()
  357. def _log_usage(self, responses) -> bool:
  358. """Journalise la consommation et indique si Claude a utilisé Internet."""
  359. input_tokens = sum(r.usage.input_tokens for r in responses)
  360. cache_write = sum(r.usage.cache_creation_input_tokens or 0 for r in responses)
  361. cache_read = sum(r.usage.cache_read_input_tokens or 0 for r in responses)
  362. output_tokens = sum(r.usage.output_tokens for r in responses)
  363. searches = fetches = 0
  364. for r in responses:
  365. if r.usage.server_tool_use:
  366. searches += r.usage.server_tool_use.web_search_requests or 0
  367. fetches += r.usage.server_tool_use.web_fetch_requests or 0
  368. price_input, price_output = self.settings.price_input_per_mtok, self.settings.price_output_per_mtok
  369. if price_input is None or price_output is None:
  370. cost = "inconnu (renseigne PRICE_INPUT_PER_MTOK et PRICE_OUTPUT_PER_MTOK dans le .env)"
  371. else:
  372. # Écriture en cache : 125 % du prix d'entrée ; lecture depuis le cache : 10 %.
  373. amount = ((input_tokens + cache_write * 1.25 + cache_read * 0.1) * price_input
  374. + output_tokens * price_output) / 1_000_000
  375. amount += searches * self.settings.price_web_search_per_1000 / 1000
  376. cost = f"{amount:.5f} $"
  377. web = f", recherches web : {searches}, pages lues : {fetches}" if searches or fetches else ""
  378. logger.info(
  379. "Appel Claude (%s) — entrée : %s tokens (+ cache : %s lus, %s écrits), sortie : %s tokens%s, "
  380. "coût estimé : %s",
  381. responses[-1].model, input_tokens, cache_read, cache_write, output_tokens, web, cost,
  382. )
  383. return bool(searches or fetches)