Spaces:
Sleeping
Sleeping
| """ | |
| Client LLM OpenRouter — appels texte (+ multimodal) avec retries et tracking | |
| des coûts. Fusion de l'ancien utils/llm_client.py et de la variante | |
| « reasoning » (utils/llm_client_with_reasoning.py, jamais branchée) : le | |
| raisonnement est désormais un simple paramètre `reasoning=` contrôlé par | |
| config.USE_REASONING. | |
| """ | |
| import logging | |
| import os | |
| import random | |
| import time | |
| from typing import Optional, Tuple | |
| import requests | |
| from app.config import AVAILABLE_MODELS, REASONING_CONFIG | |
| from app.llm.cost import get_cost_tracker, CostTracker | |
| logger = logging.getLogger(__name__) | |
| SYSTEM_MSG = "Vous êtes un assistant pour la génération d'exercices MystMarkdown dynamiques." | |
| class LLMClient: | |
| """Client pour les appels LLM via OpenRouter avec tracking des coûts.""" | |
| def __init__(self, track_costs: bool = True): | |
| self.api_key = os.getenv("OPENROUTER_API_KEY", "") | |
| if not self.api_key: | |
| raise ValueError("OPENROUTER_API_KEY not found in environment variables") | |
| self.base_url = "https://openrouter.ai/api/v1/chat/completions" | |
| self.headers = { | |
| "Authorization": f"Bearer {self.api_key}", | |
| "HTTP-Referer": "http://localhost:5000", | |
| "Content-Type": "application/json", | |
| } | |
| self.max_retries = 3 | |
| self.retry_delay = 2 | |
| self.track_costs = track_costs | |
| self._cost_tracker: Optional[CostTracker] = None | |
| def cost_tracker(self) -> CostTracker: | |
| if self._cost_tracker is None: | |
| self._cost_tracker = get_cost_tracker() | |
| return self._cost_tracker | |
| def call_llm(self, prompt: str, model_idx: int = 0, temperature: float = 0.0, | |
| max_tokens: int = 4096, system_prompt: str = SYSTEM_MSG, | |
| reasoning: bool = False, model: str | None = None) -> str: | |
| """Appel LLM standard avec gestion des erreurs et retry. | |
| `model` (ID OpenRouter en chaîne, ex. "anthropic/claude-sonnet-5") | |
| court-circuite `model_idx` — c'est la voie de la policy par rôle.""" | |
| model = model or AVAILABLE_MODELS.get(model_idx) | |
| if model is None: | |
| raise ValueError(f"model_idx {model_idx} inexistant dans AVAILABLE_MODELS") | |
| payload = { | |
| "model": model, | |
| "temperature": temperature, | |
| "max_tokens": max_tokens, | |
| "messages": [ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": prompt}, | |
| ], | |
| } | |
| if reasoning: | |
| payload["reasoning"] = dict(REASONING_CONFIG) | |
| content, generation_id = self._make_request(payload) | |
| if self.track_costs and generation_id: | |
| self.cost_tracker.track_cost( | |
| generation_id=generation_id, | |
| model=model, | |
| is_image=False, | |
| prompt=prompt, | |
| response=content, | |
| ) | |
| return content | |
| def call_llm_multimodal(self, image_b64: str, prompt: str, model_idx: int, | |
| temperature: float = 0.0, max_tokens: int = 4096, | |
| system_prompt: str = SYSTEM_MSG) -> str: | |
| """Appel LLM multimodal avec image.""" | |
| model = AVAILABLE_MODELS.get(model_idx) | |
| if model is None: | |
| raise ValueError(f"model_idx {model_idx} inexistant dans AVAILABLE_MODELS") | |
| payload = { | |
| "model": model, | |
| "temperature": temperature, | |
| "max_tokens": max_tokens, | |
| "messages": [ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": [ | |
| {"type": "text", "text": prompt}, | |
| {"type": "image_url", "image_url": f"data:image/png;base64,{image_b64}"}, | |
| ]}, | |
| ], | |
| } | |
| content, generation_id = self._make_request(payload, is_multimodal=True) | |
| if self.track_costs and generation_id: | |
| self.cost_tracker.track_cost( | |
| generation_id=generation_id, | |
| model=model, | |
| is_image=True, | |
| prompt=prompt, | |
| response=content, | |
| ) | |
| return content | |
| def _make_request(self, payload: dict, is_multimodal: bool = False) -> Tuple[str, Optional[str]]: | |
| """Requête POST avec retries (backoff exponentiel + jitter, gestion 429).""" | |
| for attempt in range(self.max_retries): | |
| try: | |
| resp = requests.post(self.base_url, json=payload, headers=self.headers, timeout=180) | |
| if resp.status_code == 429: | |
| wait = self.retry_delay * (2 ** attempt) + random.uniform(0, 5) | |
| logger.warning("Rate-limited (%s) ; attente %.1fs (retry %d/%d)", | |
| "multimodal" if is_multimodal else "standard", | |
| wait, attempt + 1, self.max_retries) | |
| time.sleep(wait) | |
| continue | |
| resp.raise_for_status() | |
| data = resp.json() | |
| if "error" in data: | |
| raise RuntimeError(f"API error : {data['error']}") | |
| content = data["choices"][0]["message"]["content"] | |
| # Certains fournisseurs renvoient content:null (réponse vide, | |
| # reasoning seul…) : jamais exploitable en aval → retry, puis | |
| # RuntimeError propre (gérée par les appelants) au lieu d'un | |
| # AttributeError sur .strip(). | |
| if not isinstance(content, str) or not content.strip(): | |
| raise RuntimeError( | |
| f"Réponse vide du modèle {payload['model']} " | |
| f"(content={content!r})") | |
| generation_id = data.get("id") | |
| return content, generation_id | |
| except (requests.RequestException, RuntimeError) as e: | |
| if attempt == self.max_retries - 1: | |
| raise | |
| wait = self.retry_delay * (2 ** attempt) + random.uniform(0, 5) | |
| logger.warning("Erreur LLM : %s — retry dans %.1fs", e, wait) | |
| time.sleep(wait) | |
| raise RuntimeError("Échec après plusieurs tentatives") | |
| _llm_client: Optional[LLMClient] = None | |
| def get_llm_client() -> LLMClient: | |
| global _llm_client | |
| if _llm_client is None: | |
| _llm_client = LLMClient() | |
| return _llm_client | |
| def process_with_openrouter(prompt: str, model_idx: int = 0, temperature: float = 0.0, | |
| max_tokens: int = 4096, image_b64: str = None, | |
| system_prompt: str = SYSTEM_MSG, reasoning: bool = False, | |
| model: str | None = None) -> str: | |
| """Point d'entrée unique du pipeline pour tous les appels LLM. | |
| `model` (chaîne) prime sur `model_idx` (legacy).""" | |
| if image_b64: | |
| return get_llm_client().call_llm_multimodal( | |
| image_b64, prompt, model_idx, temperature, max_tokens, system_prompt) | |
| return get_llm_client().call_llm( | |
| prompt, model_idx, temperature, max_tokens, system_prompt, | |
| reasoning=reasoning, model=model) | |