pythonise-exercice / app /llm /client.py
wilenPxs
Politique de modeles multi-fournisseurs + retrait de Fable + durcissements
81836d8
Raw
History Blame Contribute Delete
7.22 kB
"""
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
@property
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)