Spaces:
Runtime error
Runtime error
File size: 7,219 Bytes
77a0a72 81836d8 77a0a72 81836d8 77a0a72 81836d8 77a0a72 81836d8 77a0a72 81836d8 77a0a72 81836d8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 | """
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)
|