""" Suivi des coûts des appels OpenRouter (ex utils/cost_estimator.py). Coûts RÉELS récupérés via GET /api/v1/generation?id=… (tokens + coût exacts) ; estimation len//4 utilisée uniquement en fallback temporaire, remplacée dès que la réponse de l'API arrive. """ import logging import os import threading import time from dataclasses import dataclass, field from datetime import datetime from typing import Any, Dict, Optional import requests from app.config import MODEL_PRICING logger = logging.getLogger(__name__) IMAGE_COST_PER_IMAGE = 0.002 # ~$0.002 par image pour la plupart des modèles @dataclass class CostRecord: """Enregistrement d'un coût pour une génération.""" generation_id: str model: str timestamp: datetime input_tokens: int = 0 output_tokens: int = 0 total_cost: float = 0.0 is_estimated: bool = True is_image: bool = False raw_stats: Dict = field(default_factory=dict) class CostEstimator: """Estime et enregistre les coûts des appels LLM.""" def __init__(self): self.actual_costs: Dict[str, CostRecord] = {} self.estimated_costs: Dict[str, CostRecord] = {} self.session_total: float = 0.0 self.session_start: datetime = datetime.now() self._lock = threading.Lock() def estimate_tokens(self, text: str) -> int: """Approximation ~4 caractères/token (fallback uniquement).""" if not text: return 0 return max(1, len(text) // 4) def get_model_pricing(self, model: str) -> Dict[str, float]: return MODEL_PRICING.get(model, {"input": 1.0, "output": 3.0}) def estimate_cost(self, model: str, prompt: str, response: str, is_image: bool = False) -> CostRecord: input_tokens = self.estimate_tokens(prompt) output_tokens = self.estimate_tokens(response) pricing = self.get_model_pricing(model) total_cost = ( (input_tokens / 1_000_000) * pricing["input"] + (output_tokens / 1_000_000) * pricing["output"] ) if is_image: total_cost += IMAGE_COST_PER_IMAGE record = CostRecord( generation_id=f"est_{int(time.time() * 1000)}", model=model, timestamp=datetime.now(), input_tokens=input_tokens, output_tokens=output_tokens, total_cost=total_cost, is_estimated=True, is_image=is_image, ) with self._lock: self.estimated_costs[record.generation_id] = record self.session_total += total_cost return record def record_actual_cost(self, model: str, generation_id: str, stats: Dict, is_image: bool = False) -> bool: try: usage = stats.get("usage", stats.get("data", {})) input_tokens = usage.get("prompt_tokens", 0) or usage.get("tokens_prompt", 0) output_tokens = usage.get("completion_tokens", 0) or usage.get("tokens_completion", 0) total_cost = ( stats.get("total_cost") or stats.get("cost") or usage.get("total_cost") or 0.0 ) if total_cost == 0 and (input_tokens > 0 or output_tokens > 0): pricing = self.get_model_pricing(model) total_cost = ( (input_tokens / 1_000_000) * pricing["input"] + (output_tokens / 1_000_000) * pricing["output"] ) if is_image: total_cost += IMAGE_COST_PER_IMAGE record = CostRecord( generation_id=generation_id, model=model, timestamp=datetime.now(), input_tokens=input_tokens, output_tokens=output_tokens, total_cost=total_cost, is_estimated=False, is_image=is_image, raw_stats=stats, ) with self._lock: # Remplacer l'estimation provisoire la plus proche dans le temps. for est_id, est_record in list(self.estimated_costs.items()): if est_record.model == model and abs( (est_record.timestamp - record.timestamp).total_seconds() ) < 60: del self.estimated_costs[est_id] self.session_total -= est_record.total_cost break self.actual_costs[generation_id] = record self.session_total += total_cost return True except (KeyError, TypeError, ValueError) as e: logger.warning("Enregistrement du coût réel impossible : %s", e) return False def get_session_summary(self) -> Dict[str, Any]: with self._lock: costs_by_model: Dict[str, Dict] = {} for record in list(self.actual_costs.values()) + list(self.estimated_costs.values()): entry = costs_by_model.setdefault(record.model, { "requests": 0, "input_tokens": 0, "output_tokens": 0, "total_cost": 0.0, }) entry["requests"] += 1 entry["input_tokens"] += record.input_tokens entry["output_tokens"] += record.output_tokens entry["total_cost"] += record.total_cost return { "session_start": self.session_start.isoformat(), "total_requests": len(self.actual_costs) + len(self.estimated_costs), "actual_costs_count": len(self.actual_costs), "estimated_costs_count": len(self.estimated_costs), "total_cost_usd": round(self.session_total, 6), "total_cost_eur": round(self.session_total * 0.92, 6), "costs_by_model": costs_by_model, } def reset_session(self): with self._lock: self.actual_costs.clear() self.estimated_costs.clear() self.session_total = 0.0 self.session_start = datetime.now() class CostTracker: """Tracker de coûts avec récupération asynchrone des stats réelles.""" def __init__(self, api_key: str = None): self.api_key = api_key or os.getenv("OPENROUTER_API_KEY") self.cost_estimator = CostEstimator() self.base_url = "https://openrouter.ai/api/v1" self._pending_fetches: Dict[str, threading.Thread] = {} def fetch_generation_stats(self, generation_id: str) -> Optional[Dict]: """Stats réelles (tokens + coût) d'une génération via l'API OpenRouter.""" if not generation_id: return None try: headers = { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", } url = f"{self.base_url}/generation?id={generation_id}" response = requests.get(url, headers=headers, timeout=10) if response.status_code == 200: return response.json() logger.debug("API stats %s : %s", response.status_code, response.text[:200]) return None except requests.RequestException as e: logger.debug("Récupération des stats impossible : %s", e) return None def _schedule_generation_stats_fetch(self, generation_id: str, model: str, is_image: bool): """Récupération asynchrone différée (les stats arrivent avec ~2 s de retard).""" def fetch_delayed(): time.sleep(2) try: stats = self.fetch_generation_stats(generation_id) if stats: self.cost_estimator.record_actual_cost( model=model, generation_id=generation_id, stats=stats, is_image=is_image, ) finally: self._pending_fetches.pop(generation_id, None) thread = threading.Thread(target=fetch_delayed, daemon=True) self._pending_fetches[generation_id] = thread thread.start() def track_cost(self, generation_id: str, model: str, is_image: bool, prompt: str = "", response: str = "") -> bool: """Coût réel si dispo immédiatement, sinon estimation + fetch async.""" try: stats = self.fetch_generation_stats(generation_id) if stats and ("data" in stats or "cost" in stats or "total_cost" in stats or "usage" in stats): if self.cost_estimator.record_actual_cost( model=model, generation_id=generation_id, stats=stats, is_image=is_image, ): return True except Exception as e: logger.debug("Fetch immédiat du coût raté : %s", e) self._schedule_generation_stats_fetch(generation_id, model, is_image) if generation_id not in self.cost_estimator.actual_costs: self.cost_estimator.estimate_cost( model=model, prompt=prompt, response=response, is_image=is_image) return False def get_summary(self) -> Dict[str, Any]: return self.cost_estimator.get_session_summary() def reset(self): self.cost_estimator.reset_session() _cost_tracker: Optional[CostTracker] = None def get_cost_tracker() -> CostTracker: global _cost_tracker if _cost_tracker is None: _cost_tracker = CostTracker() return _cost_tracker def cost_snapshot() -> dict: """Photo des totaux de session — l'orchestrateur fait before/after pour attribuer un coût par job (mono-poste : pas de jobs LLM concurrents).""" s = get_cost_tracker().get_summary() return {"usd": s["total_cost_usd"], "requests": s["total_requests"]} def cost_delta(before: dict) -> dict: """Coût (USD/EUR) et nb de requêtes depuis `before` (cf. cost_snapshot).""" after = cost_snapshot() usd = max(0.0, round(after["usd"] - before["usd"], 6)) return { "usd": usd, "eur": round(usd * 0.92, 6), "requests": after["requests"] - before["requests"], }