wilenPxs
Politique de modeles multi-fournisseurs + retrait de Fable + durcissements
81836d8
Raw
History Blame Contribute Delete
5.75 kB
"""
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")