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