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

- 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:
2026-09-05 19:46:33 +02:00
parent 6c26712687
commit 0a013c679d
47 changed files with 7210 additions and 679 deletions

View File

@@ -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"])

View File

@@ -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"])

View File

@@ -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 ==============

View File

@@ -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",

View File

@@ -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(