feat(admin): canaux IA par palier (z.ai/DeepSeek directs), emails generes par IA, reductions Stripe
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m40s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m40s
- Chaque modele d'un palier devient une route {canal, modele} : openrouter,
deepseek direct, zhipu (z.ai, nouveau canal), minimax, openai, xai ;
repli automatique sur la route suivante si la cle manque, compatibilite
ascendante avec les anciens reglages a chaines
- Page Fournisseurs refondue en sections ; interrupteurs et chaine de
secours retablis ; tests de connexion avec delai d'attente
- Emails de relance generes par IA (canal au choix, prompt construit depuis
le plan marketing, HTML sane, RTL arabe/persan, jamais d'envoi automatique)
- Codes promo : validation locale avant Stripe, Coupon+PromotionCode avec
double plafond, comptage par session (webhook+sync=1 fois), ecriture
atomique, relier-a-Stripe, suppression, validation publique au checkout
- Page Tarifs : champ code promo (?promo=) avec verification traduite
- Tests : 1357 verts (facturation par palier testee dans le worker,
point de contact providers/available, promos, generation)
This commit is contained in:
@@ -31,6 +31,18 @@ from models.subscription import (
|
||||
DEFAULT_AI_MODELS_PREMIUM,
|
||||
)
|
||||
from services import pricing_config as pricing_cfg
|
||||
from services.payment_service import (
|
||||
DuplicatePromoError,
|
||||
PromoValidationError,
|
||||
StripePromoCreationError,
|
||||
create_promo_code,
|
||||
deactivate_promo_code,
|
||||
delete_promo_code,
|
||||
link_promo_to_stripe,
|
||||
load_promo_codes,
|
||||
save_promo_codes,
|
||||
)
|
||||
from services.providers.config import LLM_CHANNELS
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
@@ -1042,14 +1054,37 @@ class SmtpSettings(BaseModel):
|
||||
use_tls: bool = True
|
||||
|
||||
|
||||
class AiTierSettings(BaseModel):
|
||||
"""Palier IA : modèles actifs (l'ordre = priorité de secours) + modèle par défaut.
|
||||
# Fournisseurs (canaux) utilisables dans une route de palier : source unique =
|
||||
# LLM_CHANNELS (services.providers.config). Les canaux « openrouter »/
|
||||
# « openrouter_premium » sont des passerelles multi-modèles ; les autres sont
|
||||
# des accès directs compatibles OpenAI.
|
||||
AI_TIER_PROVIDERS = tuple(LLM_CHANNELS.keys())
|
||||
|
||||
``default_model`` vide → premier de ``models`` → défaut du plan (PLANS).
|
||||
Source des valeurs par défaut : la gamme officielle de ``models/subscription.py``.
|
||||
|
||||
class AiTierRoute(BaseModel):
|
||||
"""Route d'un palier : un modèle via un canal donné.
|
||||
|
||||
``provider`` = canal (openrouter, deepseek, zhipu, minimax, openai, zai…),
|
||||
``model`` = identifiant du modèle chez ce canal, ``label`` = libellé libre.
|
||||
"""
|
||||
provider: str = "openrouter"
|
||||
model: str = ""
|
||||
label: str = ""
|
||||
|
||||
|
||||
class AiTierSettings(BaseModel):
|
||||
"""Palier IA : routes actives (l'ordre = priorité de secours) + modèle par défaut.
|
||||
|
||||
Deux écritures acceptées :
|
||||
- ``routes`` : liste de routes ``{provider, model, label}`` (multi-canaux) ;
|
||||
- ``models``/``default_model`` : ancien format (chaînes OpenRouter),
|
||||
encore accepté et routé via openrouter comme auparavant.
|
||||
``routes`` vide → repli sur ``default_model`` → premier de ``models`` →
|
||||
défaut du plan (PLANS).
|
||||
"""
|
||||
models: List[str] = Field(default_factory=list)
|
||||
default_model: str = ""
|
||||
routes: List[AiTierRoute] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AiTiersSettings(BaseModel):
|
||||
@@ -1076,6 +1111,7 @@ class SettingsConfig(BaseModel):
|
||||
openrouter: ProviderSettings = ProviderSettings() # "Traduction IA Essentielle"
|
||||
openrouter_premium: ProviderSettings = ProviderSettings() # "Traduction IA Premium"
|
||||
deepseek: ProviderSettings = ProviderSettings()
|
||||
zhipu: ProviderSettings = ProviderSettings() # z.ai / Zhipu (GLM, direct)
|
||||
minimax: ProviderSettings = ProviderSettings()
|
||||
zai: ProviderSettings = ProviderSettings()
|
||||
mistral: ProviderSettings = ProviderSettings() # OCR Mistral (PDF scannés)
|
||||
@@ -1089,29 +1125,66 @@ class SettingsConfig(BaseModel):
|
||||
def normalize_ai_tiers(ai_tiers: AiTiersSettings) -> AiTiersSettings:
|
||||
"""Déduplique, nettoie et complète un bloc ai_tiers avec la gamme officielle.
|
||||
|
||||
Routes : fournisseur connu obligatoire, modèle non vide, dédoublonnage
|
||||
(canal + modèle) — l'ordre est conservé (ordre = priorité de secours).
|
||||
Lève ValueError si le défaut d'un palier appartient à la gamme de l'autre
|
||||
palier (un défaut Essentielle doit rester un modèle Essentielle, et
|
||||
inversement) : un utilisateur Pro ne doit jamais déclencher un modèle
|
||||
Premium, et l'inverse serait une erreur de configuration.
|
||||
Premium, et l'inverse serait une erreur de configuration. La même garde
|
||||
s'applique aux routes openrouter (les modèles des autres canaux ont des
|
||||
identifiants propres, le croisement n'a pas de sens pour eux).
|
||||
"""
|
||||
essential_official = list(DEFAULT_AI_MODELS_ESSENTIAL)
|
||||
premium_official = list(DEFAULT_AI_MODELS_PREMIUM)
|
||||
|
||||
def _clean_routes(tier: AiTierSettings, tier_name: str, other_official: List[str],
|
||||
other_name: str) -> List[AiTierRoute]:
|
||||
routes: List[AiTierRoute] = []
|
||||
seen: set = set()
|
||||
for route in (tier.routes or []):
|
||||
provider = (route.provider or "").strip().lower()
|
||||
model = (route.model or "").strip()
|
||||
if not provider and not model:
|
||||
continue
|
||||
if provider not in AI_TIER_PROVIDERS:
|
||||
raise ValueError(
|
||||
f"ai_tiers.{tier_name}.routes : fournisseur « {provider} » inconnu "
|
||||
f"(canaux acceptés : {', '.join(AI_TIER_PROVIDERS)})."
|
||||
)
|
||||
if not model:
|
||||
raise ValueError(
|
||||
f"ai_tiers.{tier_name}.routes : une route du canal « {provider} » "
|
||||
"n'a pas de modèle."
|
||||
)
|
||||
key = (provider, model)
|
||||
if key in seen:
|
||||
continue
|
||||
if provider == "openrouter" and model in other_official:
|
||||
raise ValueError(
|
||||
f"ai_tiers.{tier_name}.routes : « {model} » appartient à la gamme "
|
||||
f"{other_name} : choisissez un modèle de la gamme {tier_name}."
|
||||
)
|
||||
seen.add(key)
|
||||
routes.append(AiTierRoute(provider=provider, model=model,
|
||||
label=(route.label or "").strip()))
|
||||
return routes
|
||||
|
||||
def _fill(tier: AiTierSettings, official: List[str], other_official: List[str],
|
||||
tier_name: str, other_name: str) -> AiTierSettings:
|
||||
routes = _clean_routes(tier, tier_name, other_official, other_name)
|
||||
models = [m.strip() for m in (tier.models or []) if m and m.strip()]
|
||||
models = list(dict.fromkeys(models)) # dédoublonnage, ordre conservé
|
||||
if not models:
|
||||
if not models and not routes:
|
||||
models = list(official)
|
||||
default = (tier.default_model or "").strip()
|
||||
if default and default not in models:
|
||||
if default and default not in models and not routes:
|
||||
models.insert(0, default)
|
||||
if default in other_official:
|
||||
raise ValueError(
|
||||
f"ai_tiers.{tier_name}.default_model « {default} » appartient à la "
|
||||
f"gamme {other_name} : choisissez un modèle de la gamme {tier_name}."
|
||||
)
|
||||
return AiTierSettings(models=models, default_model=default)
|
||||
return AiTierSettings(models=models, default_model=default, routes=routes)
|
||||
|
||||
return AiTiersSettings(
|
||||
essential=_fill(ai_tiers.essential, essential_official, premium_official,
|
||||
@@ -1182,7 +1255,8 @@ async def get_settings(admin_id: str = Depends(require_admin)):
|
||||
payload["openrouter_premium"] = _merge_env(settings.openrouter_premium, key_env="OPENROUTER_API_KEY", model_env="OPENROUTER_PREMIUM_MODEL", default_model="anthropic/claude-sonnet-5")
|
||||
payload["openai"] = _merge_env(settings.openai, key_env="OPENAI_API_KEY", model_env="OPENAI_MODEL", default_model="gpt-4o-mini")
|
||||
payload["deepseek"] = _merge_env(settings.deepseek, key_env="DEEPSEEK_API_KEY", model_env="DEEPSEEK_MODEL", default_model="deepseek-chat")
|
||||
payload["minimax"] = _merge_env(settings.minimax, key_env="MINIMAX_API_KEY", model_env="MINIMAX_MODEL", default_model="abab6.5s-chat")
|
||||
payload["zhipu"] = _merge_env(settings.zhipu, key_env="ZHIPU_API_KEY", model_env="ZHIPU_MODEL", url_env="ZHIPU_BASE_URL", default_model="glm-5.3", default_url="https://api.z.ai/api/paas/v4")
|
||||
payload["minimax"] = _merge_env(settings.minimax, key_env="MINIMAX_API_KEY", model_env="MINIMAX_MODEL", default_model="MiniMax-M3")
|
||||
payload["zai"] = _merge_env(settings.zai, key_env="ZAI_API_KEY", model_env="ZAI_MODEL", url_env="ZAI_BASE_URL", default_model="grok-2-1212", default_url="https://api.x.ai/v1")
|
||||
|
||||
payload["google_cloud"] = _merge_env(settings.google_cloud, key_env="GOOGLE_CLOUD_API_KEY")
|
||||
@@ -1216,6 +1290,7 @@ async def get_settings(admin_id: str = Depends(require_admin)):
|
||||
"openrouter": has_openrouter,
|
||||
"openrouter_premium": has_openrouter, # same key, different model
|
||||
"deepseek": bool(os.getenv("DEEPSEEK_API_KEY", "").strip()),
|
||||
"zhipu": bool(os.getenv("ZHIPU_API_KEY", "").strip()),
|
||||
"minimax": bool(os.getenv("MINIMAX_API_KEY", "").strip()),
|
||||
"zai": bool(os.getenv("ZAI_API_KEY", "").strip()),
|
||||
|
||||
@@ -1231,12 +1306,15 @@ async def get_settings(admin_id: str = Depends(require_admin)):
|
||||
|
||||
def _ai_tiers_is_pristine(ai_tiers: AiTiersSettings) -> bool:
|
||||
"""True si le bloc ai_tiers reçu est le défaut vierge (client qui ne gère
|
||||
pas les paliers : liste vide/par défaut et aucun modèle imposé)."""
|
||||
pas les paliers : liste vide/par défaut, aucune route et aucun modèle imposé)."""
|
||||
def _is_default(tier: AiTierSettings, official: List[str]) -> bool:
|
||||
models = [m.strip() for m in (tier.models or []) if m and m.strip()]
|
||||
return (not models or models == list(official)) and not (
|
||||
tier.default_model or ""
|
||||
).strip()
|
||||
routes = [r for r in (tier.routes or []) if (r.model or "").strip()]
|
||||
return (
|
||||
(not models or models == list(official))
|
||||
and not routes
|
||||
and not (tier.default_model or "").strip()
|
||||
)
|
||||
|
||||
return _is_default(ai_tiers.essential, list(DEFAULT_AI_MODELS_ESSENTIAL)) and _is_default(
|
||||
ai_tiers.premium, list(DEFAULT_AI_MODELS_PREMIUM)
|
||||
@@ -1413,14 +1491,6 @@ async def test_provider(
|
||||
)
|
||||
|
||||
|
||||
elif provider == "openrouter_premium":
|
||||
current.openrouter_premium = _update_provider(current.openrouter_premium, update_data)
|
||||
elif provider == "deepseek":
|
||||
current.deepseek = _update_provider(current.deepseek, update_data)
|
||||
elif provider == "minimax":
|
||||
current.minimax = _update_provider(current.minimax, update_data)
|
||||
elif provider == "zai":
|
||||
current.zai = _update_provider(current.zai, update_data)
|
||||
elif provider == "openai":
|
||||
api_key = _key(provider_config.api_key, "OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
@@ -1430,13 +1500,57 @@ async def test_provider(
|
||||
)
|
||||
import openai as _openai
|
||||
|
||||
client = _openai.OpenAI(api_key=api_key)
|
||||
client = _openai.OpenAI(api_key=api_key, timeout=15)
|
||||
models = list(client.models.list())
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"available": True, "models_count": len(models)},
|
||||
)
|
||||
|
||||
elif provider in ("deepseek", "minimax", "zhipu"):
|
||||
# Fournisseurs directs compatibles OpenAI : la liste des modèles
|
||||
# sert de test de connexion (clé + URL de base).
|
||||
key_env = {
|
||||
"deepseek": "DEEPSEEK_API_KEY",
|
||||
"minimax": "MINIMAX_API_KEY",
|
||||
"zhipu": "ZHIPU_API_KEY",
|
||||
}[provider]
|
||||
api_key = _key(provider_config.api_key, key_env)
|
||||
if not api_key:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"available": False,
|
||||
"error": f"Aucune clé API trouvée pour {provider} (JSON ou .env)",
|
||||
},
|
||||
)
|
||||
import openai as _openai
|
||||
|
||||
base_url = (provider_config.base_url or "").strip()
|
||||
if not base_url:
|
||||
from services.providers.config import LLM_CHANNELS
|
||||
|
||||
url_env = LLM_CHANNELS.get(provider, {}).get("url_env", "")
|
||||
base_url = (os.getenv(url_env, "").strip() if url_env else "") or LLM_CHANNELS[provider]["base_url"]
|
||||
client = _openai.OpenAI(api_key=api_key, base_url=base_url, timeout=15)
|
||||
try:
|
||||
models = list(client.models.list())
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"available": True,
|
||||
"models_count": len(models),
|
||||
"sample_models": [m.id for m in models[:5]],
|
||||
},
|
||||
)
|
||||
except Exception as api_err:
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={
|
||||
"available": False,
|
||||
"error": f"{provider} injoignable ({str(api_err)[:200]})",
|
||||
},
|
||||
)
|
||||
|
||||
elif provider in ("openrouter", "openrouter_premium"):
|
||||
api_key = _key(provider_config.api_key, "OPENROUTER_API_KEY")
|
||||
@@ -1475,7 +1589,7 @@ async def test_provider(
|
||||
import openai as _openai
|
||||
|
||||
base_url = (provider_config.base_url or "").strip() or os.getenv("ZAI_BASE_URL", "https://api.x.ai/v1")
|
||||
client = _openai.OpenAI(api_key=api_key, base_url=base_url)
|
||||
client = _openai.OpenAI(api_key=api_key, base_url=base_url, timeout=15)
|
||||
try:
|
||||
models = list(client.models.list())
|
||||
return JSONResponse(
|
||||
@@ -2731,6 +2845,614 @@ async def _execute_bulk_send(
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Génération d'email par IA (page Marketing) — jamais d'envoi
|
||||
# automatique : le serveur ne fait que produire {subject, html}.
|
||||
# ============================================================
|
||||
|
||||
MARKETING_PLAN_FILE = config.BASE_DIR / "MARKETING_PLAN.md"
|
||||
MARKETING_PLAN_PROMPT_MAX_CHARS = 4000
|
||||
GENERATED_SUBJECT_MAX_CHARS = 200
|
||||
GENERATED_HTML_MAX_CHARS = 100_000
|
||||
GENERATION_TIMEOUT_SECONDS = 90
|
||||
|
||||
|
||||
def _load_marketing_plan_excerpt() -> str:
|
||||
"""Extrait du plan marketing pour le prompt : positionnement (§1) et
|
||||
forfaits/chiffres autorisés (§2), tronqué (le prompt doit rester léger)."""
|
||||
try:
|
||||
text = MARKETING_PLAN_FILE.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.warning("Lecture MARKETING_PLAN.md impossible: %s", e)
|
||||
return ""
|
||||
match = re.search(r"^## 1\..*?(?=^## 3\.|\Z)", text, re.MULTILINE | re.DOTALL)
|
||||
excerpt = (match.group(0) if match else text).strip()
|
||||
if len(excerpt) > MARKETING_PLAN_PROMPT_MAX_CHARS:
|
||||
excerpt = excerpt[:MARKETING_PLAN_PROMPT_MAX_CHARS] + "\n[…]"
|
||||
return excerpt
|
||||
|
||||
|
||||
def _generation_model_for(admin_cfg, provider: str, requested: str) -> str:
|
||||
"""Modèle du canal : demande explicite > réglage admin > défaut du canal."""
|
||||
requested = (requested or "").strip()
|
||||
if requested:
|
||||
return requested
|
||||
provider_cfg = getattr(admin_cfg, provider, None)
|
||||
admin_model = (getattr(provider_cfg, "model", "") or "").strip() if provider_cfg else ""
|
||||
return admin_model or LLM_CHANNELS[provider]["default_model"]
|
||||
|
||||
|
||||
async def _call_llm_chat(
|
||||
admin_cfg, provider: str, model: str, system_prompt: str, user_prompt: str
|
||||
) -> str:
|
||||
"""Appel chat/completions (API compatible OpenAI) sur le canal choisi.
|
||||
|
||||
Délai d'attente et erreurs propagées : l'appelant renvoie une erreur 502
|
||||
claire (jamais d'HTTP 500 nu).
|
||||
"""
|
||||
import httpx
|
||||
|
||||
from routes.translate_routes import _channel_api_key, _channel_base_url
|
||||
|
||||
api_key = _channel_api_key(admin_cfg, provider)
|
||||
if not api_key:
|
||||
raise RuntimeError(f"Aucune clé API configurée pour le canal « {provider} ».")
|
||||
base_url = _channel_base_url(admin_cfg, provider).rstrip("/")
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4000,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=GENERATION_TIMEOUT_SECONDS) as client:
|
||||
resp = await client.post(
|
||||
f"{base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
json=payload,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
try:
|
||||
return data["choices"][0]["message"]["content"] or ""
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
raise RuntimeError(f"Réponse LLM inattendue: {e}") from e
|
||||
|
||||
|
||||
def _build_generation_system_prompt(
|
||||
language: str, tone: str, promo_line: str, promo_code: str = ""
|
||||
) -> str:
|
||||
plan_excerpt = _load_marketing_plan_excerpt()
|
||||
tone_line = f"Ton demandé : {tone}. " if tone else ""
|
||||
promo_block = ""
|
||||
if promo_line:
|
||||
site_url = os.getenv("FRONTEND_URL", "http://localhost:3000").rstrip("/")
|
||||
promo_block = (
|
||||
f"\nOffre à mettre en avant :\n{promo_line}\n"
|
||||
f"Termine l'email par un bouton d'appel à l'action bien visible "
|
||||
f"(styles en ligne) dont le lien pointe exactement vers "
|
||||
f"{site_url}/pricing?promo={promo_code} "
|
||||
"— utilise ce lien tel quel, sans en inventer un autre.\n"
|
||||
)
|
||||
if _language_is_rtl(language):
|
||||
promo_block += (
|
||||
"Cette langue se lit de droite à gauche : mets dir=\"rtl\" sur le "
|
||||
"conteneur principal du HTML et aligne le texte à droite.\n"
|
||||
)
|
||||
return (
|
||||
"Tu es le rédacteur marketing de Wordly.art (Office Translator), un "
|
||||
"service de traduction de documents qui préserve la mise en page.\n"
|
||||
"Rédige un email de relance en respectant STRICTEMENT le plan marketing "
|
||||
"ci-dessous : n'invente AUCUN chiffre, prix, pourcentage ou date absent "
|
||||
"du plan ; si une réduction est fournie, cite-la exactement.\n"
|
||||
f"Langue imposée de l'email : {language}. {tone_line}"
|
||||
"Le HTML doit rester simple : structure avec des div et des styles en "
|
||||
"ligne, largeur maximale ~600px, pas d'image externe, pas de "
|
||||
"JavaScript, pas de CSS externe. Ne mets ni pied de page de "
|
||||
"désabonnement ni coordonnées serveur : ils sont ajoutés par le "
|
||||
"système au moment de l'envoi.\n"
|
||||
f"{promo_block}"
|
||||
"\n--- Extrait du plan marketing (source des chiffres autorisés) ---\n"
|
||||
f"{plan_excerpt}\n"
|
||||
"--- Fin de l'extrait ---\n\n"
|
||||
'Réponds UNIQUEMENT avec un objet JSON : {"subject": "<sujet court>", '
|
||||
'"html": "<email HTML complet>"}'
|
||||
)
|
||||
|
||||
|
||||
def _parse_generated_email(raw: str) -> Optional[tuple]:
|
||||
"""Analyse robuste de la sortie du modèle : JSON direct, JSON dans un bloc
|
||||
de code, premier objet {...}, puis repli <title>/corps. Renvoie
|
||||
(subject, html) ou None si rien d'exploitable."""
|
||||
raw = (raw or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
def _from_json(text: str) -> Optional[tuple]:
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
subject = str(data.get("subject") or "").strip()
|
||||
html = str(data.get("html") or "").strip()
|
||||
if subject and html:
|
||||
return (subject, html)
|
||||
return None
|
||||
|
||||
# 1. JSON direct, 2. bloc de code markdown, 3. premier objet {...}
|
||||
parsed = _from_json(raw)
|
||||
if parsed:
|
||||
return parsed
|
||||
stripped = re.sub(r"^```[a-zA-Z]*\s*|\s*```$", "", raw, flags=re.MULTILINE).strip()
|
||||
parsed = _from_json(stripped)
|
||||
if parsed:
|
||||
return parsed
|
||||
brace = re.search(r"\{.*\}", raw, re.DOTALL)
|
||||
if brace:
|
||||
parsed = _from_json(brace.group(0))
|
||||
if parsed:
|
||||
return parsed
|
||||
|
||||
# 4. Repli texte : <title> = sujet, le reste = HTML.
|
||||
title_match = re.search(r"<title[^>]*>(.*?)</title>", raw, re.IGNORECASE | re.DOTALL)
|
||||
if title_match:
|
||||
subject = title_match.group(1).strip()
|
||||
html = raw
|
||||
if html.strip():
|
||||
return (subject, html)
|
||||
return None
|
||||
|
||||
|
||||
def _sanitize_generated_html(html: str) -> str:
|
||||
"""Nettoyage basique du HTML généré : pas de script (fermé ou non), pas
|
||||
d'iframe/object/embed/form, pas de gestionnaires d'événements, pas d'URL
|
||||
``javascript:`` ni ``data:`` (casse mixte et espaces intrusive neutralisés)."""
|
||||
# Scripts : balise fermée, balise orpheline (pas de </script>) et casse mixte.
|
||||
html = re.sub(
|
||||
r"<script\b[^>]*>.*?(?:</script\s*>|$)",
|
||||
"",
|
||||
html,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
html = re.sub(r"</?script\b[^>]*>", "", html, flags=re.IGNORECASE)
|
||||
# Éléments à contenu retirés en entier (avec repli si la balise fermante manque).
|
||||
for tag in ("iframe", "object", "embed", "form"):
|
||||
html = re.sub(
|
||||
rf"<{tag}\b[^>]*>.*?(?:</{tag}\s*>|$)",
|
||||
"",
|
||||
html,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
html = re.sub(rf"</?{tag}\b[^>]*>", "", html, flags=re.IGNORECASE)
|
||||
# Gestionnaires d'événements (onclick, onerror…).
|
||||
html = re.sub(
|
||||
r"\son\w+\s*=\s*(\"[^\"]*\"|'[^']*'|[^\s>]+)", "", html, flags=re.IGNORECASE
|
||||
)
|
||||
# URL dangereuses : javascript: quel que soit le style (espaces insérés,
|
||||
# casse mixte) et data: (data:text/html…).
|
||||
html = re.sub(r"(?i)j\s*a\s*v\s*a\s*s\s*c\s*r\s*i\s*p\s*t\s*:", "", html)
|
||||
html = re.sub(r"(?i)d\s*a\s*t\s*a\s*:", "", html)
|
||||
return html
|
||||
|
||||
|
||||
_RTL_GENERATION_LANGUAGES = {
|
||||
"ar", "fa", "arabe", "arabic", "العربية", "فارسی", "persan", "persian",
|
||||
}
|
||||
|
||||
|
||||
def _language_is_rtl(language: str) -> bool:
|
||||
return (language or "").strip().lower() in _RTL_GENERATION_LANGUAGES
|
||||
|
||||
|
||||
class MarketingEmailGenerateRequest(BaseModel):
|
||||
provider: str = "openrouter" # canal LLM (voir LLM_CHANNELS)
|
||||
model: str = Field(default="", max_length=120) # vide = modèle par défaut du canal
|
||||
brief: str = Field(..., min_length=1, max_length=4000)
|
||||
language: str = Field(default="Français", max_length=50)
|
||||
tone: str = Field(default="", max_length=50)
|
||||
promo_code: str = Field(default="", max_length=24)
|
||||
|
||||
|
||||
@router.post("/marketing/email/generate")
|
||||
async def marketing_email_generate(
|
||||
body: MarketingEmailGenerateRequest, admin_id: str = Depends(require_admin)
|
||||
):
|
||||
"""Génère un email de relance par IA (sujet + HTML), sans aucun envoi.
|
||||
|
||||
Le prompt système est construit depuis le plan marketing (positionnement,
|
||||
forfaits, chiffres autorisés) ; la sortie du modèle est analysée avec
|
||||
repli (JSON → <title>/corps) et nettoyée (pas de script). Une sortie non
|
||||
analysable renvoie 502 AI_GENERATION_FAILED — l'admin relit et envoie
|
||||
depuis la page Marketing, rien n'est envoyé automatiquement.
|
||||
"""
|
||||
provider = (body.provider or "").strip().lower()
|
||||
if provider not in LLM_CHANNELS:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "UNKNOWN_CHANNEL",
|
||||
"message": (
|
||||
f"Canal « {provider} » inconnu (canaux : "
|
||||
f"{', '.join(LLM_CHANNELS)})."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
admin_cfg = load_settings()
|
||||
from routes.translate_routes import _channel_has_key
|
||||
|
||||
if not _channel_has_key(admin_cfg, provider):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "PROVIDER_NOT_CONFIGURED",
|
||||
"message": (
|
||||
f"Aucune clé API configurée pour le canal « {provider} » "
|
||||
"(réglages Fournisseurs ou .env)."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# Sans plan marketing lisible, l'IA inventerait des chiffres : on refuse.
|
||||
if not _load_marketing_plan_excerpt().strip():
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "MARKETING_PLAN_MISSING",
|
||||
"message": (
|
||||
"MARKETING_PLAN.md est introuvable ou vide à la racine du "
|
||||
"projet : la génération est refusée pour ne pas diffuser de "
|
||||
"chiffres inventés."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
promo_line = ""
|
||||
promo_wanted = (body.promo_code or "").strip().upper()
|
||||
if promo_wanted:
|
||||
promo = next(
|
||||
(p for p in load_promo_codes() if (p.get("code") or "").upper() == promo_wanted),
|
||||
None,
|
||||
)
|
||||
if not promo:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "PROMO_NOT_FOUND",
|
||||
"message": f"Le code promo « {promo_wanted} » n'existe pas : "
|
||||
"corrigez ou videz le sélecteur de réduction.",
|
||||
},
|
||||
)
|
||||
if not promo.get("active", False):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "PROMO_NOT_FOUND",
|
||||
"message": f"Le code promo « {promo_wanted} » n'est plus actif : "
|
||||
"choisissez une autre réduction.",
|
||||
},
|
||||
)
|
||||
if promo.get("type") == "percent":
|
||||
value_desc = f"-{promo.get('value')} %"
|
||||
else:
|
||||
value_desc = f"-{promo.get('value')} €"
|
||||
promo_line = (
|
||||
f"Code {promo.get('code')} ({value_desc}) — à citer exactement, "
|
||||
f"valable pour : {', '.join(promo.get('plans') or ['all'])}."
|
||||
)
|
||||
|
||||
system_prompt = _build_generation_system_prompt(
|
||||
(body.language or "Français").strip() or "Français",
|
||||
(body.tone or "").strip(),
|
||||
promo_line,
|
||||
promo_wanted,
|
||||
)
|
||||
user_prompt = (
|
||||
f"Brief de l'email :\n{body.brief.strip()}\n\n"
|
||||
"Produis l'objet et le HTML de l'email au format JSON demandé."
|
||||
)
|
||||
model = _generation_model_for(admin_cfg, provider, body.model)
|
||||
|
||||
try:
|
||||
raw = await _call_llm_chat(admin_cfg, provider, model, system_prompt, user_prompt)
|
||||
except Exception as e:
|
||||
logger.error("marketing email generation failed (%s/%s): %s", provider, model, e)
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={
|
||||
"error": "AI_GENERATION_FAILED",
|
||||
"message": f"La génération a échoué ({str(e)[:300]}). Aucun email envoyé.",
|
||||
},
|
||||
)
|
||||
|
||||
parsed = _parse_generated_email(raw)
|
||||
if not parsed:
|
||||
logger.warning("marketing email generation unparseable output (%s/%s)", provider, model)
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={
|
||||
"error": "AI_GENERATION_FAILED",
|
||||
"message": (
|
||||
"La réponse du modèle n'a pas pu être analysée (sujet + HTML "
|
||||
"attendus). Aucun email envoyé — réessayez."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
subject, html = parsed
|
||||
subject = re.sub(r"\s+", " ", subject).strip()[:GENERATED_SUBJECT_MAX_CHARS]
|
||||
html = _sanitize_generated_html(html).strip()
|
||||
# Langues de droite à gauche : on garantit l'enveloppe dir="rtl" même si
|
||||
# le modèle l'a oubliée.
|
||||
if _language_is_rtl((body.language or "").strip()) and 'dir="rtl"' not in html.lower():
|
||||
html = f'<div dir="rtl">{html}</div>'
|
||||
if not subject or not html or len(html) > GENERATED_HTML_MAX_CHARS:
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={
|
||||
"error": "AI_GENERATION_FAILED",
|
||||
"message": (
|
||||
"Le contenu généré est vide ou dépasse la longueur maximale. "
|
||||
"Aucun email envoyé — réessayez."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"marketing email generated channel=%s model=%s by=%s", provider, model, admin_id
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"data": {
|
||||
"subject": subject,
|
||||
"html": html,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"language": body.language,
|
||||
},
|
||||
"meta": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Réductions : codes promo (data/promo_codes.json) + Stripe
|
||||
# ============================================================
|
||||
|
||||
PROMO_CODE_PATTERN = re.compile(r"^[A-Z0-9-]{3,24}$")
|
||||
_VALID_PROMO_PLANS = {"all"} | {pt.value for pt in PlanType}
|
||||
|
||||
|
||||
class PromoCreateRequest(BaseModel):
|
||||
code: str
|
||||
type: Literal["percent", "amount"] = "percent"
|
||||
value: float
|
||||
plans: List[str] = Field(default_factory=lambda: ["all"])
|
||||
max_redemptions: Optional[int] = None
|
||||
expires_at: Optional[str] = None # ISO (date ou date-heure), futur obligatoire
|
||||
|
||||
|
||||
@router.get("/marketing/promos")
|
||||
async def marketing_promos_list(admin_id: str = Depends(require_admin)):
|
||||
"""Liste des codes promo (compteur d'utilisations inclus, clés Stripe
|
||||
internes incluses : identifiants d'objets, jamais de valeur sensible)."""
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"data": {"promos": load_promo_codes()}, "meta": {}},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/marketing/promos")
|
||||
async def marketing_promos_create(
|
||||
body: PromoCreateRequest, admin_id: str = Depends(require_admin)
|
||||
):
|
||||
"""Crée un code promo : Coupon + Promotion Code Stripe si Stripe est
|
||||
configuré, sinon un code « local_only » (refusé au checkout avec un
|
||||
message clair). Plafonds posés côté local ET côté Stripe."""
|
||||
code = (body.code or "").strip().upper()
|
||||
if not PROMO_CODE_PATTERN.match(code):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_PROMO_CODE",
|
||||
"message": "Le code doit contenir 3 à 24 caractères : lettres majuscules, chiffres ou tirets.",
|
||||
},
|
||||
)
|
||||
if any((p.get("code") or "").upper() == code for p in load_promo_codes()):
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"error": "PROMO_ALREADY_EXISTS",
|
||||
"message": f"Le code promo « {code} » existe déjà.",
|
||||
},
|
||||
)
|
||||
|
||||
if body.type == "percent":
|
||||
if not (0 < float(body.value) <= 100):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_PROMO_VALUE",
|
||||
"message": "Pour un pourcentage, la valeur doit être entre 1 et 100.",
|
||||
},
|
||||
)
|
||||
else:
|
||||
if not (0.01 <= float(body.value) <= 10000):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_PROMO_VALUE",
|
||||
"message": "Pour un montant, la valeur doit être entre 0,01 et 10 000 EUR.",
|
||||
},
|
||||
)
|
||||
|
||||
plans = [p.strip().lower() for p in (body.plans or []) if p and p.strip()]
|
||||
if not plans:
|
||||
plans = ["all"]
|
||||
if "all" in plans:
|
||||
plans = ["all"]
|
||||
invalid = [p for p in plans if p not in _VALID_PROMO_PLANS]
|
||||
if invalid:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_PROMO_PLAN",
|
||||
"message": f"Forfaits inconnus : {', '.join(invalid)}.",
|
||||
},
|
||||
)
|
||||
if "free" in plans:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_PROMO_PLAN",
|
||||
"message": "Le forfait Gratuit n'est pas éligible aux codes promo "
|
||||
"(un code s'applique aux abonnements payants uniquement).",
|
||||
},
|
||||
)
|
||||
|
||||
if body.max_redemptions is not None and int(body.max_redemptions) < 1:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_PROMO_LIMIT",
|
||||
"message": "Le plafond d'utilisations doit être au moins 1.",
|
||||
},
|
||||
)
|
||||
|
||||
expires_at = None
|
||||
if body.expires_at and body.expires_at.strip():
|
||||
try:
|
||||
exp = datetime.fromisoformat(body.expires_at.strip())
|
||||
if exp.tzinfo is None:
|
||||
exp = exp.replace(tzinfo=timezone.utc)
|
||||
if exp <= datetime.now(timezone.utc):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_PROMO_EXPIRY",
|
||||
"message": "La date d'expiration doit être dans le futur.",
|
||||
},
|
||||
)
|
||||
expires_at = exp.isoformat()
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_PROMO_EXPIRY",
|
||||
"message": "Date d'expiration illisible (format ISO attendu).",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
entry = create_promo_code(
|
||||
code=code,
|
||||
type=body.type,
|
||||
value=float(body.value),
|
||||
plans=plans,
|
||||
max_redemptions=body.max_redemptions,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
except DuplicatePromoError:
|
||||
# Course perdue contre une création simultanée du même code : la
|
||||
# vérification faisant foi est celle sous le verrou (service).
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"error": "PROMO_ALREADY_EXISTS",
|
||||
"message": f"Le code promo « {code} » existe déjà.",
|
||||
},
|
||||
)
|
||||
except StripePromoCreationError as e:
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={
|
||||
"error": "STRIPE_PROMO_FAILED",
|
||||
"message": f"Stripe a refusé la création du code « {code} » : {str(e)[:200]}. "
|
||||
"Rien n'a été enregistré.",
|
||||
},
|
||||
)
|
||||
logger.info("admin_promo_created code=%s by=%s", code, admin_id)
|
||||
return JSONResponse(status_code=201, content={"data": entry, "meta": {}})
|
||||
|
||||
|
||||
@router.delete("/marketing/promos/{code}")
|
||||
async def marketing_promos_delete(code: str, admin_id: str = Depends(require_admin)):
|
||||
"""Supprime un code promo (localement, et la promotion code chez Stripe)."""
|
||||
promo = delete_promo_code(code)
|
||||
if not promo:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "PROMO_NOT_FOUND",
|
||||
"message": f"Le code promo « {code} » n'existe pas.",
|
||||
},
|
||||
)
|
||||
logger.info("admin_promo_deleted code=%s by=%s", code, admin_id)
|
||||
return JSONResponse(status_code=200, content={"data": promo, "meta": {}})
|
||||
|
||||
|
||||
@router.post("/marketing/promos/{code}/link-stripe")
|
||||
async def marketing_promos_link_stripe(
|
||||
code: str, admin_id: str = Depends(require_admin)
|
||||
):
|
||||
"""Relie un code « local_only » à Stripe : crée le Coupon + Promotion Code
|
||||
et met à jour l'enregistrement (le code devient applicable au checkout)."""
|
||||
try:
|
||||
promo = link_promo_to_stripe(code)
|
||||
except StripePromoCreationError as e:
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={
|
||||
"error": "STRIPE_PROMO_FAILED",
|
||||
"message": f"Stripe a refusé la création pour « {code} » : {str(e)[:200]}.",
|
||||
},
|
||||
)
|
||||
except PromoValidationError as e:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "PROMO_INACTIVE", "message": e.message},
|
||||
)
|
||||
except RuntimeError as e:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "STRIPE_NOT_CONFIGURED", "message": str(e)},
|
||||
)
|
||||
if not promo:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "PROMO_NOT_FOUND",
|
||||
"message": f"Le code promo « {code} » n'existe pas.",
|
||||
},
|
||||
)
|
||||
logger.info("admin_promo_linked_stripe code=%s by=%s", code, admin_id)
|
||||
return JSONResponse(status_code=200, content={"data": promo, "meta": {}})
|
||||
|
||||
|
||||
@router.post("/marketing/promos/{code}/deactivate")
|
||||
async def marketing_promos_deactivate(
|
||||
code: str, admin_id: str = Depends(require_admin)
|
||||
):
|
||||
"""Désactive un code promo localement ET chez Stripe (la réduction
|
||||
n'est plus applicable au checkout)."""
|
||||
promo = deactivate_promo_code(code)
|
||||
if not promo:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "PROMO_NOT_FOUND",
|
||||
"message": f"Le code promo « {code} » n'existe pas.",
|
||||
},
|
||||
)
|
||||
logger.info("admin_promo_deactivated code=%s by=%s", code, admin_id)
|
||||
return JSONResponse(status_code=200, content={"data": promo, "meta": {}})
|
||||
|
||||
|
||||
marketing_public_router = APIRouter(prefix="/api/v1/marketing", tags=["Marketing"])
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ router = APIRouter(tags=["API v1"])
|
||||
|
||||
from routes.translate_routes import router_v1 as translate_router
|
||||
from routes.auth_routes import router_v1 as auth_router
|
||||
from routes.auth_routes import promos_public_router
|
||||
from routes.api_key_routes import router as api_key_router
|
||||
from routes.admin_routes import router as admin_router
|
||||
from routes.admin_routes import marketing_public_router
|
||||
@@ -22,6 +23,7 @@ from routes.workspace_routes import router as workspace_router
|
||||
|
||||
router.include_router(translate_router, tags=["Translation"])
|
||||
router.include_router(auth_router, tags=["Authentication"])
|
||||
router.include_router(promos_public_router, tags=["Promotions"])
|
||||
router.include_router(api_key_router, tags=["API Keys"])
|
||||
router.include_router(admin_router, tags=["Admin"])
|
||||
router.include_router(marketing_public_router, tags=["Marketing"])
|
||||
|
||||
@@ -72,6 +72,9 @@ class GoogleAuthRequest(BaseModel):
|
||||
class CheckoutRequest(BaseModel):
|
||||
plan: PlanType
|
||||
billing_period: str = "monthly"
|
||||
# Code promo optionnel — validé localement AVANT toute création de session
|
||||
# Stripe (actif, forfait, expiration, plafond).
|
||||
promo_code: Optional[str] = None
|
||||
|
||||
|
||||
class CreditsCheckoutRequest(BaseModel):
|
||||
@@ -844,13 +847,19 @@ async def create_checkout_v1(request: CheckoutRequest, user=Depends(require_user
|
||||
user_id=user.id,
|
||||
plan=request.plan,
|
||||
billing_period=request.billing_period,
|
||||
promo_code=(request.promo_code or "").strip(),
|
||||
)
|
||||
if "error" in result and not result.get("demo_mode"):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "CHECKOUT_FAILED",
|
||||
"error": result.get("error_code") or "CHECKOUT_FAILED",
|
||||
"message": result["error"],
|
||||
**(
|
||||
{"promo_reason": result["promo_reason"]}
|
||||
if result.get("promo_reason")
|
||||
else {}
|
||||
),
|
||||
},
|
||||
)
|
||||
return JSONResponse(status_code=200, content={"data": result, "meta": {}})
|
||||
@@ -934,6 +943,56 @@ async def get_billing_portal_v1(user=Depends(require_user)):
|
||||
return JSONResponse(status_code=200, content={"data": {"url": url}, "meta": {}})
|
||||
|
||||
|
||||
# ============== Codes promo (validation publique avant paiement) ==============
|
||||
|
||||
promos_public_router = APIRouter(prefix="/api/v1/promos", tags=["Promotions"])
|
||||
|
||||
|
||||
@promos_public_router.get(
|
||||
"/validate",
|
||||
summary="Valider un code promo",
|
||||
description=(
|
||||
"Vérifie un code promo pour un forfait donné AVANT l'ouverture du "
|
||||
"paiement : renvoie valid, reason_code (cause explicite) et le montant "
|
||||
"de la remise. Aucune authentification requise."
|
||||
),
|
||||
)
|
||||
async def validate_promo_v1(
|
||||
code: str = Query(..., min_length=1, max_length=24),
|
||||
plan: str = Query("pro", min_length=1, max_length=32),
|
||||
):
|
||||
from services.payment_service import PromoValidationError, validate_promo_code
|
||||
|
||||
wanted = (code or "").strip().upper()
|
||||
plan_id = (plan or "").strip().lower()
|
||||
try:
|
||||
promo = validate_promo_code(wanted, plan_id)
|
||||
except PromoValidationError as e:
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"data": {
|
||||
"valid": False,
|
||||
"reason_code": e.reason,
|
||||
"message": e.message,
|
||||
},
|
||||
"meta": {},
|
||||
},
|
||||
)
|
||||
discount: Dict[str, Any] = {}
|
||||
if promo.get("type") == "percent":
|
||||
discount["percent_off"] = promo.get("value")
|
||||
else:
|
||||
discount["amount_off"] = promo.get("value")
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"data": {"valid": True, "reason_code": None, **discount},
|
||||
"meta": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ============== Forgot / Reset password ==============
|
||||
|
||||
|
||||
|
||||
@@ -88,7 +88,9 @@ async def get_available_providers(
|
||||
|
||||
# AI Essentielle (OpenRouter — cheap model / Eco)
|
||||
if _is_enabled("openrouter", key_var="OPENROUTER_API_KEY"):
|
||||
model = _resolve_tier_model(settings, user_plan, premium=False)
|
||||
# La résolution renvoie la route complète (canal, modèle) — le canal
|
||||
# réel (z.ai direct, DeepSeek direct…) peut différer d'openrouter.
|
||||
_prov, model = _resolve_tier_model(settings, user_plan, premium=False)
|
||||
available.append({
|
||||
"id": "openrouter",
|
||||
"label": "Traduction IA Éco",
|
||||
@@ -117,7 +119,7 @@ async def get_available_providers(
|
||||
|
||||
# AI Premium (OpenRouter — premium model)
|
||||
if _is_enabled("openrouter_premium", key_var="OPENROUTER_API_KEY") or _is_enabled("openrouter", key_var="OPENROUTER_API_KEY"):
|
||||
model = _resolve_tier_model(settings, user_plan, premium=True)
|
||||
_prov, model = _resolve_tier_model(settings, user_plan, premium=True)
|
||||
available.append({
|
||||
"id": "openrouter_premium",
|
||||
"label": "Traduction IA Premium",
|
||||
|
||||
@@ -217,52 +217,190 @@ def _plan_ai_models(plan: PlanType, premium: bool) -> list:
|
||||
return models
|
||||
|
||||
|
||||
def _resolve_tier_model(admin_cfg: Any, user_plan: Optional[str], premium: bool) -> str:
|
||||
"""Modèle réellement routé pour le palier IA du plan.
|
||||
def _tier_route_candidates(admin_cfg: Any, premium: bool) -> tuple:
|
||||
"""Routes candidates d'un palier : (routes_multi_canaux, réglage_hérité).
|
||||
|
||||
Ordre de résolution (spec « Routage réel par abonnement ») :
|
||||
1. réglages admin ``ai_tiers.<palier>.default_model`` (changement sans redéploiement) ;
|
||||
2. premier modèle de ``ai_tiers.<palier>.models`` (ordre = priorité de secours) ;
|
||||
3. défaut du plan (``PLANS[plan]["ai_models_essential"|"ai_models_premium"][0]``).
|
||||
Garde d'exécution : un modèle résolu dans la gamme de l'autre palier est
|
||||
rejeté et remplacé par le défaut du plan — un utilisateur Pro (moteur
|
||||
openrouter = Essentielle) ne peut pas déclencher un modèle Premium.
|
||||
- ``routes`` multi-canaux ``{provider, model}`` — l'ordre est la priorité ;
|
||||
- réglage hérité ``default_model`` puis ``models`` (chaînes OpenRouter),
|
||||
conservé pour compatibilité — routé via openrouter comme avant.
|
||||
Une chaîne simple (« z-ai/glm-5.3-flash ») vaut une route
|
||||
``{"provider": "openrouter", "model": …}`` (compatibilité ascendante).
|
||||
"""
|
||||
from models.subscription import (
|
||||
DEFAULT_AI_MODELS_ESSENTIAL,
|
||||
DEFAULT_AI_MODELS_PREMIUM,
|
||||
tier_cfg = getattr(
|
||||
getattr(admin_cfg, "ai_tiers", None), "premium" if premium else "essential", None
|
||||
)
|
||||
|
||||
tier_cfg = getattr(getattr(admin_cfg, "ai_tiers", None), "premium" if premium else "essential", None)
|
||||
resolved = ""
|
||||
routes: list = []
|
||||
legacy: list = []
|
||||
if tier_cfg is not None:
|
||||
for route in (getattr(tier_cfg, "routes", None) or []):
|
||||
provider = (getattr(route, "provider", "") or "").strip().lower()
|
||||
model = (getattr(route, "model", "") or "").strip()
|
||||
if provider and model:
|
||||
routes.append((provider, model))
|
||||
default = (getattr(tier_cfg, "default_model", "") or "").strip()
|
||||
if default:
|
||||
resolved = default
|
||||
else:
|
||||
tier_models = [m.strip() for m in (getattr(tier_cfg, "models", None) or []) if m and m.strip()]
|
||||
if tier_models:
|
||||
resolved = tier_models[0]
|
||||
if not resolved:
|
||||
resolved = _plan_ai_models(_plan_from_str(user_plan), premium)[0]
|
||||
legacy.append(("openrouter", default))
|
||||
for model in (getattr(tier_cfg, "models", None) or []):
|
||||
model = (model or "").strip()
|
||||
if model:
|
||||
legacy.append(("openrouter", model))
|
||||
return routes, legacy
|
||||
|
||||
# Garde : le modèle résolu doit appartenir au palier demandé.
|
||||
if premium and resolved in DEFAULT_AI_MODELS_ESSENTIAL:
|
||||
|
||||
def _resolve_tier_model(admin_cfg: Any, user_plan: Optional[str], premium: bool) -> tuple:
|
||||
"""Route réellement utilisée pour le palier IA du plan : ``(provider, model)``.
|
||||
|
||||
Fonction de résolution UNIQUE, utilisée par le worker ET par l'affichage
|
||||
public (/providers/available) : elle tient compte des clés disponibles,
|
||||
une route sans clé API étant sautée au profit de la suivante.
|
||||
|
||||
Ordre de résolution (spec « Routage réel par abonnement ») :
|
||||
1. routes multi-canaux ``ai_tiers.<palier>.routes`` (openrouter, deepseek,
|
||||
zhipu, minimax, openai, zai) ;
|
||||
2. ancien réglage hérité ``default_model``/``models`` (= openrouter,
|
||||
compatibilité ascendante) ;
|
||||
3. défaut du plan (``PLANS[plan]["ai_models_essential"|"ai_models_premium"][0]``).
|
||||
Garde d'exécution inchangée : un modèle openrouter résolu dans la gamme de
|
||||
l'autre palier est rejeté — un utilisateur Pro (moteur openrouter =
|
||||
Essentielle) ne peut pas déclencher un modèle Premium. Les canaux directs
|
||||
ont des identifiants propres : la garde ne s'applique pas à eux.
|
||||
Repli final : ``(openrouter, défaut du plan)`` même sans clé — c'est la
|
||||
référence d'affichage ; à l'exécution, l'instanciation échouera plus loin
|
||||
(moteur indisponible), comme avant.
|
||||
"""
|
||||
routes, legacy = _tier_route_candidates(admin_cfg, premium)
|
||||
|
||||
for provider, model in list(routes) + list(legacy):
|
||||
if provider == "openrouter" and _model_in_other_range(model, premium):
|
||||
logger.warning(
|
||||
"ai_tiers.%s route %s belongs to the other model range — skipped.",
|
||||
"premium" if premium else "essential", model,
|
||||
)
|
||||
continue
|
||||
if _channel_has_key(admin_cfg, provider):
|
||||
return (provider, model)
|
||||
logger.warning(
|
||||
"ai_tiers.premium resolved to an Essentielle model (%s) — using the "
|
||||
"plan Premium default instead.",
|
||||
resolved,
|
||||
"ai_tiers.%s route %s/%s has no API key — falling back to the next route.",
|
||||
"premium" if premium else "essential", provider, model,
|
||||
)
|
||||
resolved = _plan_ai_models(_plan_from_str(user_plan), premium=True)[0]
|
||||
elif not premium and resolved in DEFAULT_AI_MODELS_PREMIUM:
|
||||
logger.warning(
|
||||
"ai_tiers.essential resolved to a Premium model (%s) — refusing: a "
|
||||
"Pro subscription must never trigger a Premium model. Using the "
|
||||
"plan Essentielle default instead.",
|
||||
resolved,
|
||||
|
||||
return ("openrouter", _plan_ai_models(_plan_from_str(user_plan), premium)[0])
|
||||
|
||||
|
||||
def _model_in_other_range(model: str, premium: bool) -> bool:
|
||||
"""True si un modèle openrouter appartient à la gamme de l'autre palier."""
|
||||
from models.subscription import DEFAULT_AI_MODELS_ESSENTIAL, DEFAULT_AI_MODELS_PREMIUM
|
||||
|
||||
return model in (DEFAULT_AI_MODELS_ESSENTIAL if premium else DEFAULT_AI_MODELS_PREMIUM)
|
||||
|
||||
|
||||
def _channel_has_key(admin_cfg: Any, provider: str) -> bool:
|
||||
"""Clé API disponible pour un canal (réglages admin, sinon variable d'env) ?"""
|
||||
import os as _os
|
||||
|
||||
from services.providers.config import LLM_CHANNELS
|
||||
|
||||
channel = LLM_CHANNELS.get(provider)
|
||||
if not channel:
|
||||
return False
|
||||
admin_val = ""
|
||||
provider_cfg = getattr(admin_cfg, provider, None)
|
||||
if provider_cfg is not None:
|
||||
admin_val = (getattr(provider_cfg, "api_key", "") or "").strip()
|
||||
env_val = (_os.getenv(channel["key_env"], "") or "").strip()
|
||||
# openrouter_premium partage la clé openrouter.
|
||||
if not admin_val and not env_val and provider == "openrouter_premium":
|
||||
return _channel_has_key(admin_cfg, "openrouter")
|
||||
return bool(admin_val or env_val)
|
||||
|
||||
|
||||
def _channel_api_key(admin_cfg: Any, provider: str) -> str:
|
||||
"""Clé API d'un canal : réglages admin > variable d'environnement.
|
||||
|
||||
``openrouter_premium`` hérite de la clé openrouter (même passerelle).
|
||||
"""
|
||||
from services.providers.config import LLM_CHANNELS
|
||||
|
||||
channel = LLM_CHANNELS.get(provider)
|
||||
if not channel:
|
||||
return ""
|
||||
provider_cfg = getattr(admin_cfg, provider, None)
|
||||
admin_val = (getattr(provider_cfg, "api_key", "") or "").strip() if provider_cfg else ""
|
||||
if admin_val:
|
||||
return admin_val
|
||||
env_val = (os.getenv(channel["key_env"], "") or "").strip()
|
||||
if env_val:
|
||||
return env_val
|
||||
if provider == "openrouter_premium":
|
||||
return _channel_api_key(admin_cfg, "openrouter")
|
||||
return ""
|
||||
|
||||
|
||||
def _channel_base_url(admin_cfg: Any, provider: str) -> str:
|
||||
"""URL de base d'un canal : réglages admin > variable d'env > défaut."""
|
||||
from services.providers.config import LLM_CHANNELS
|
||||
|
||||
channel = LLM_CHANNELS.get(provider) or {}
|
||||
provider_cfg = getattr(admin_cfg, provider, None)
|
||||
admin_val = (getattr(provider_cfg, "base_url", "") or "").strip() if provider_cfg else ""
|
||||
if admin_val:
|
||||
return admin_val
|
||||
url_env = channel.get("url_env", "")
|
||||
env_val = (os.getenv(url_env, "") or "").strip() if url_env else ""
|
||||
return env_val or channel.get("base_url", "")
|
||||
|
||||
|
||||
def _instantiate_channel_provider(admin_cfg: Any, provider: str, model: str) -> Any:
|
||||
"""Instancie le fournisseur d'un canal donné.
|
||||
|
||||
deepseek → ``DeepSeekTranslationProvider`` ; tous les autres canaux
|
||||
(openrouter, openrouter_premium, zhipu, minimax, zai, openai) sont des
|
||||
API compatibles OpenAI → ``OpenAITranslationProvider`` avec leur base_url.
|
||||
Renvoie None si la clé du canal est absente.
|
||||
"""
|
||||
api_key = _channel_api_key(admin_cfg, provider)
|
||||
if not api_key:
|
||||
return None
|
||||
base_url = _channel_base_url(admin_cfg, provider)
|
||||
timeout = int(os.getenv(f"{provider.upper()}_TIMEOUT", "60") or 60)
|
||||
|
||||
if provider == "deepseek":
|
||||
from services.providers.deepseek_provider import DeepSeekTranslationProvider
|
||||
|
||||
return DeepSeekTranslationProvider(
|
||||
api_key=api_key, model=model, base_url=base_url, timeout=timeout
|
||||
)
|
||||
resolved = _plan_ai_models(_plan_from_str(user_plan), premium=False)[0]
|
||||
return resolved
|
||||
from services.providers.openai_provider import OpenAITranslationProvider
|
||||
|
||||
return OpenAITranslationProvider(
|
||||
api_key=api_key, model=model, base_url=base_url, timeout=timeout
|
||||
)
|
||||
|
||||
|
||||
def _instantiate_tier_routes(
|
||||
admin_cfg: Any, user_plan: Optional[str], premium: bool, job_id: str = ""
|
||||
) -> tuple:
|
||||
"""Choisit la route du palier via la résolution unique (clé-consciente,
|
||||
``_resolve_tier_model``) et instancie son fournisseur.
|
||||
|
||||
Renvoie ``(provider_instance, channel, model)`` ou ``(None, None, None)``
|
||||
si la route retenue n'a pas de clé utilisable (le job échouera alors
|
||||
comme avant, avec le message « moteur indisponible »).
|
||||
"""
|
||||
provider, model = _resolve_tier_model(admin_cfg, user_plan, premium)
|
||||
instance = _instantiate_channel_provider(admin_cfg, provider, model)
|
||||
tier_name = "premium" if premium else "essential"
|
||||
if instance is not None:
|
||||
logger.info(
|
||||
"Job %s: ai_tiers.%s routed via %s/%s",
|
||||
job_id, tier_name, provider, model,
|
||||
)
|
||||
return instance, provider, model
|
||||
logger.warning(
|
||||
"Job %s: ai_tiers.%s route %s/%s has no usable API key — engine unavailable.",
|
||||
job_id, tier_name, provider, model,
|
||||
)
|
||||
return None, None, None
|
||||
|
||||
|
||||
def _image_translation_allowed_for_plan(plan: PlanType) -> bool:
|
||||
@@ -642,15 +780,23 @@ def _provider_model(provider: Any) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _compute_cost_factor(provider: Any, provider_name: str = "") -> int:
|
||||
"""Billing cost factor (1 = standard, 5 = premium).
|
||||
def _compute_cost_factor(
|
||||
provider: Any, provider_name: str = "", premium: Optional[bool] = None
|
||||
) -> int:
|
||||
"""Billing cost factor (1 = standard/Essentielle, 5 = premium).
|
||||
|
||||
Premium models (Claude, GPT-4 family, etc.) cost more and are billed at a
|
||||
higher factor. Cheap variants are explicitly downgraded to 1 (``haiku``,
|
||||
and the small GPT-4 models such as ``gpt-4o-mini`` / ``gpt-4o-nano``).
|
||||
The provider may be passed by instance (model read off it) or only by
|
||||
name (e.g. the ``openrouter_premium`` alias).
|
||||
La facturation suit le PALIER, pas le canal : Premium = 5, Essentielle = 1,
|
||||
quel que soit le canal réellement utilisé (openrouter, z.ai direct, DeepSeek
|
||||
direct…). ``premium`` est imposé par l'appelant quand le palier est connu ;
|
||||
sans information de palier (``premium=None``, moteurs explicites hors
|
||||
palier), l'ancienne heuristique par nom de modèle s'applique (Claude,
|
||||
GPT-4 → 5 ; variantes « mini »/« nano »/« haiku » → 1).
|
||||
"""
|
||||
if premium is True:
|
||||
return 5
|
||||
if premium is False:
|
||||
return 1
|
||||
|
||||
model_lower = _provider_model(provider).lower()
|
||||
provider_lower = (provider_name or "").lower()
|
||||
|
||||
@@ -1358,9 +1504,8 @@ async def _run_translation_job(
|
||||
return (admin_val or "").strip() or os.getenv(env_var, default)
|
||||
|
||||
api_key = _cfg(_admin_cfg.openrouter.api_key, "OPENROUTER_API_KEY")
|
||||
# Routage réel par abonnement : le modèle vient du palier IA du plan
|
||||
# (réglages admin « ai_tiers » > défaut du plan) — voir _resolve_tier_model.
|
||||
model = _resolve_tier_model(_admin_cfg, user_plan, premium=False)
|
||||
# Routage réel par abonnement : la route (canal + modèle) vient du
|
||||
# palier IA du plan — voir _resolve_tier_model / _instantiate_tier_routes.
|
||||
|
||||
# Story 3.10: Retrieve and format glossary terms for LLM prompt
|
||||
glossary_terms = None
|
||||
@@ -1437,26 +1582,18 @@ async def _run_translation_job(
|
||||
)
|
||||
logger.info("google_provider_using_legacy", extra={"job_id": job_id})
|
||||
|
||||
elif _p in ("openrouter", "llm") and api_key:
|
||||
translation_provider = OpenAITranslationProvider(
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
timeout=int(os.getenv("OPENROUTER_TIMEOUT", "60")),
|
||||
elif _p in ("openrouter", "llm"):
|
||||
# Palier Essentielle : routes multi-canaux (z.ai direct, DeepSeek
|
||||
# direct…) avec repli automatique sur la route suivante si la clé
|
||||
# manque, puis sur l'ancien réglage hérité / le défaut du plan.
|
||||
translation_provider, _channel, _model = _instantiate_tier_routes(
|
||||
_admin_cfg, user_plan, premium=False, job_id=job_id
|
||||
)
|
||||
elif _p == "openrouter_premium":
|
||||
premium_key = _cfg(_admin_cfg.openrouter_premium.api_key, "OPENROUTER_API_KEY")
|
||||
# Palier Premium : résolu par abonnement (défaut anthropic/claude-sonnet-5).
|
||||
premium_model = _resolve_tier_model(_admin_cfg, user_plan, premium=True)
|
||||
if not premium_key:
|
||||
premium_key = api_key # fall back to main openrouter key
|
||||
if premium_key:
|
||||
translation_provider = OpenAITranslationProvider(
|
||||
api_key=premium_key,
|
||||
model=premium_model,
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
timeout=int(os.getenv("OPENROUTER_TIMEOUT", "60")),
|
||||
)
|
||||
# Palier Premium : mêmes routes multi-canaux, gamme Premium.
|
||||
translation_provider, _channel, _model = _instantiate_tier_routes(
|
||||
_admin_cfg, user_plan, premium=True, job_id=job_id
|
||||
)
|
||||
elif _p == "openai":
|
||||
openai_key = _cfg(_admin_cfg.openai.api_key, "OPENAI_API_KEY")
|
||||
openai_model = _cfg(_admin_cfg.openai.model, "OPENAI_MODEL", "gpt-4o-mini")
|
||||
@@ -1484,7 +1621,7 @@ async def _run_translation_job(
|
||||
)
|
||||
elif _p == "minimax":
|
||||
mm_key = _cfg(getattr(_admin_cfg, "minimax", None) and _admin_cfg.minimax.api_key, "MINIMAX_API_KEY")
|
||||
mm_model = _cfg(getattr(_admin_cfg, "minimax", None) and _admin_cfg.minimax.model, "MINIMAX_MODEL", "MiniMax-M1")
|
||||
mm_model = _cfg(getattr(_admin_cfg, "minimax", None) and _admin_cfg.minimax.model, "MINIMAX_MODEL", "MiniMax-M3")
|
||||
if mm_key:
|
||||
translation_provider = MinimaxTranslationProvider(
|
||||
api_key=mm_key,
|
||||
@@ -1947,10 +2084,17 @@ async def _run_translation_job(
|
||||
logger.warning(f"Job {job_id}: bilingual output failed: {bi_err}")
|
||||
|
||||
if user_id:
|
||||
# Determine cost factor based on selected provider and model.
|
||||
# Determine cost factor based on the subscription TIER (Premium = 5,
|
||||
# Essentielle/classique = 1), never on the channel name — a tier
|
||||
# routed via z.ai or DeepSeek direct is billed like its tier.
|
||||
# _compute_cost_factor reads the model off the provider robustly
|
||||
# (new-style providers store it in ``_model``, legacy in ``model``).
|
||||
cost_factor = _compute_cost_factor(translation_provider, provider or "")
|
||||
if _p in ("openrouter", "llm"):
|
||||
cost_factor = _compute_cost_factor(translation_provider, provider or "", premium=False)
|
||||
elif _p == "openrouter_premium":
|
||||
cost_factor = _compute_cost_factor(translation_provider, provider or "", premium=True)
|
||||
else:
|
||||
cost_factor = _compute_cost_factor(translation_provider, provider or "")
|
||||
|
||||
# Persist monthly usage counters in PostgreSQL (docs + pages)
|
||||
pages = await asyncio.to_thread(
|
||||
|
||||
Reference in New Issue
Block a user