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)