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

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