Spaces:
Sleeping
Sleeping
| """ | |
| client.py — adaptateur fournisseur unifié (§3.1). | |
| Une seule interface : | |
| ModelClient().complete(system, messages, model_key, **opts) | |
| -> {"text", "usage_in", "usage_out", "latency_ms", "route"} | |
| Dispatch par entrée de catalogue : {api_format ∈ {anthropic, openai}, base_url, | |
| api_key_env, model_id, openrouter_id}. Résolution de route : | |
| 1. clé DIRECTE du fournisseur présente (api_key_env) → endpoint natif ; | |
| 2. sinon OPENROUTER_API_KEY présente → route OpenRouter (format openai) ; | |
| 3. sinon MissingKeyError — le banc marque « non testé (clé absente) », | |
| jamais une erreur bloquante. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| import random | |
| import time | |
| import requests | |
| from app.models.catalog import model_info | |
| logger = logging.getLogger(__name__) | |
| OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" | |
| class MissingKeyError(RuntimeError): | |
| """Aucune clé disponible pour ce modèle (direct + OpenRouter absents).""" | |
| def resolve_route(model_key: str) -> dict: | |
| """Route effective pour un modèle : direct si sa clé est là, sinon | |
| OpenRouter. Lève MissingKeyError si aucune clé.""" | |
| info = model_info(model_key) | |
| direct_key = os.getenv(info["api_key_env"], "") | |
| if direct_key: | |
| return {"kind": "direct", "api_format": info["api_format"], | |
| "base_url": info["base_url"], "model": info["model_id"], | |
| "key": direct_key} | |
| or_key = os.getenv("OPENROUTER_API_KEY", "") | |
| if or_key: | |
| return {"kind": "openrouter", "api_format": "openai", | |
| "base_url": OPENROUTER_URL, "model": info["openrouter_id"], | |
| "key": or_key} | |
| raise MissingKeyError( | |
| f"{model_key}: ni {info['api_key_env']} ni OPENROUTER_API_KEY présente") | |
| def is_available(model_key: str) -> bool: | |
| try: | |
| resolve_route(model_key) | |
| return True | |
| except (MissingKeyError, KeyError): | |
| return False | |
| class ModelClient: | |
| """Client fin multi-fournisseurs avec retries (backoff + jitter, 429).""" | |
| def __init__(self, max_retries: int = 3, timeout: int = 180): | |
| self.max_retries = max_retries | |
| self.timeout = timeout | |
| def complete(self, system: str, messages: list, model_key: str, | |
| temperature: float = 0.0, max_tokens: int = 4096) -> dict: | |
| route = resolve_route(model_key) | |
| t0 = time.time() | |
| if route["api_format"] == "anthropic" and route["kind"] == "direct": | |
| out = self._anthropic(route, system, messages, temperature, max_tokens) | |
| else: | |
| out = self._openai(route, system, messages, temperature, max_tokens) | |
| out["latency_ms"] = int((time.time() - t0) * 1000) | |
| out["route"] = route["kind"] | |
| return out | |
| # ── formats ────────────────────────────────────────────────────────────── | |
| def _anthropic(self, route, system, messages, temperature, max_tokens) -> dict: | |
| url = route["base_url"].rstrip("/") + "/messages" | |
| headers = {"x-api-key": route["key"], | |
| "anthropic-version": "2023-06-01", | |
| "content-type": "application/json"} | |
| payload = {"model": route["model"], "system": system, | |
| "messages": messages, "temperature": temperature, | |
| "max_tokens": max_tokens} | |
| data = self._post(url, headers, payload) | |
| text = "".join(b.get("text", "") for b in data.get("content", []) | |
| if b.get("type") == "text") | |
| usage = data.get("usage", {}) | |
| return {"text": text, | |
| "usage_in": usage.get("input_tokens", 0), | |
| "usage_out": usage.get("output_tokens", 0)} | |
| def _openai(self, route, system, messages, temperature, max_tokens) -> dict: | |
| url = (route["base_url"] if route["kind"] == "openrouter" | |
| else route["base_url"].rstrip("/") + "/chat/completions") | |
| headers = {"Authorization": f"Bearer {route['key']}", | |
| "Content-Type": "application/json"} | |
| payload = {"model": route["model"], | |
| "messages": [{"role": "system", "content": system}] + messages, | |
| "temperature": temperature, "max_tokens": max_tokens} | |
| data = self._post(url, headers, payload) | |
| text = data["choices"][0]["message"]["content"] | |
| usage = data.get("usage", {}) | |
| return {"text": text, | |
| "usage_in": usage.get("prompt_tokens", 0), | |
| "usage_out": usage.get("completion_tokens", 0)} | |
| def _post(self, url, headers, payload) -> dict: | |
| for attempt in range(self.max_retries): | |
| try: | |
| resp = requests.post(url, json=payload, headers=headers, | |
| timeout=self.timeout) | |
| if resp.status_code == 429: | |
| wait = 2 * (2 ** attempt) + random.uniform(0, 4) | |
| logger.warning("429 sur %s — retry dans %.1fs", url, wait) | |
| time.sleep(wait) | |
| continue | |
| resp.raise_for_status() | |
| data = resp.json() | |
| if "error" in data: | |
| raise RuntimeError(f"API error: {data['error']}") | |
| return data | |
| except (requests.RequestException, RuntimeError) as e: | |
| if attempt == self.max_retries - 1: | |
| raise | |
| wait = 2 * (2 ** attempt) + random.uniform(0, 4) | |
| logger.warning("Erreur %s — retry dans %.1fs", e, wait) | |
| time.sleep(wait) | |
| raise RuntimeError("Échec après retries") | |