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)
491 lines
18 KiB
Python
491 lines
18 KiB
Python
"""
|
|
Routage multi-canaux des paliers IA (spec « Fournisseur par palier IA »).
|
|
|
|
- Chaque route d'un palier est un couple {provider, model} : une route peut
|
|
partir par z.ai direct (zhipu), DeepSeek direct, etc. ;
|
|
- une route dont la clé API manque est sautée au profit de la suivante ;
|
|
- l'ancien réglage à chaînes reste routé via openrouter (compatibilité) ;
|
|
- la facturation suit le PALIER (Premium = 5, Essentielle = 1), pas le canal.
|
|
"""
|
|
|
|
import asyncio
|
|
import pytest
|
|
from pathlib import Path
|
|
|
|
import routes.translate_routes as tr
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Résolution unitaire : (provider, model)
|
|
# ---------------------------------------------------------------------------
|
|
def _settings(ai_tiers: dict | None = None, **providers):
|
|
from routes.admin_routes import SettingsConfig
|
|
|
|
payload: dict = {"ai_tiers": ai_tiers} if ai_tiers is not None else {}
|
|
for provider, cfg in providers.items():
|
|
payload[provider] = cfg
|
|
return SettingsConfig.model_validate(payload)
|
|
|
|
|
|
def test_route_with_configured_key_is_used(monkeypatch):
|
|
monkeypatch.delenv("ZHIPU_API_KEY", raising=False)
|
|
cfg = _settings(
|
|
{
|
|
"essential": {
|
|
"models": [],
|
|
"default_model": "",
|
|
"routes": [{"provider": "zhipu", "model": "glm-5.3-flash"}],
|
|
},
|
|
"premium": {"models": [], "default_model": ""},
|
|
},
|
|
zhipu={"api_key": "zk-test"},
|
|
)
|
|
assert tr._resolve_tier_model(cfg, "pro", premium=False) == (
|
|
"zhipu",
|
|
"glm-5.3-flash",
|
|
)
|
|
|
|
|
|
def test_route_without_key_falls_back_to_next(monkeypatch):
|
|
monkeypatch.delenv("ZHIPU_API_KEY", raising=False)
|
|
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
|
|
cfg = _settings(
|
|
{
|
|
"essential": {
|
|
"models": [],
|
|
"default_model": "",
|
|
"routes": [
|
|
{"provider": "zhipu", "model": "glm-5.3-flash"},
|
|
{"provider": "openrouter", "model": "deepseek/deepseek-v4-flash"},
|
|
],
|
|
},
|
|
"premium": {"models": [], "default_model": ""},
|
|
},
|
|
openrouter={"api_key": "sk-or-test"},
|
|
)
|
|
assert tr._resolve_tier_model(cfg, "pro", premium=False) == (
|
|
"openrouter",
|
|
"deepseek/deepseek-v4-flash",
|
|
)
|
|
|
|
|
|
def test_legacy_string_models_still_route_via_openrouter(monkeypatch):
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
cfg = _settings(
|
|
{
|
|
"essential": {"models": ["deepseek/deepseek-v4-flash"], "default_model": ""},
|
|
"premium": {"models": [], "default_model": ""},
|
|
},
|
|
openrouter={"api_key": "sk-or-test"},
|
|
)
|
|
provider, model = tr._resolve_tier_model(cfg, "pro", premium=False)
|
|
assert (provider, model) == ("openrouter", "deepseek/deepseek-v4-flash")
|
|
|
|
|
|
def test_premium_route_resolves_premium_tier(monkeypatch):
|
|
monkeypatch.delenv("ZHIPU_API_KEY", raising=False)
|
|
cfg = _settings(
|
|
{
|
|
"essential": {"models": [], "default_model": ""},
|
|
"premium": {
|
|
"models": [],
|
|
"default_model": "",
|
|
"routes": [{"provider": "deepseek", "model": "deepseek-reasoner"}],
|
|
},
|
|
},
|
|
deepseek={"api_key": "dk-test"},
|
|
)
|
|
assert tr._resolve_tier_model(cfg, "business", premium=True) == (
|
|
"deepseek",
|
|
"deepseek-reasoner",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Facturation par palier (pas par canal)
|
|
# ---------------------------------------------------------------------------
|
|
def test_cost_factor_follows_tier_not_channel():
|
|
assert tr._compute_cost_factor(None, "zhipu", premium=True) == 5
|
|
assert tr._compute_cost_factor(None, "deepseek", premium=True) == 5
|
|
assert tr._compute_cost_factor(None, "zhipu", premium=False) == 1
|
|
assert tr._compute_cost_factor(None, "openrouter_premium", premium=False) == 1
|
|
|
|
|
|
def test_cost_factor_legacy_heuristic_kept_without_tier():
|
|
class _P:
|
|
_model = "anthropic/claude-sonnet-5"
|
|
|
|
assert tr._compute_cost_factor(_P(), "zai") == 5
|
|
assert tr._compute_cost_factor(None, "openrouter_premium") == 5
|
|
assert tr._compute_cost_factor(None, "openrouter") == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validation admin des routes (normalize_ai_tiers)
|
|
# ---------------------------------------------------------------------------
|
|
def test_normalize_rejects_unknown_route_provider():
|
|
from routes.admin_routes import normalize_ai_tiers
|
|
|
|
cfg = _settings(
|
|
{
|
|
"essential": {
|
|
"models": [],
|
|
"default_model": "",
|
|
"routes": [{"provider": "anthropic", "model": "claude"}],
|
|
},
|
|
"premium": {"models": [], "default_model": ""},
|
|
}
|
|
)
|
|
with pytest.raises(ValueError, match="inconnu"):
|
|
normalize_ai_tiers(cfg.ai_tiers)
|
|
|
|
|
|
def test_normalize_rejects_route_without_model():
|
|
from routes.admin_routes import normalize_ai_tiers
|
|
|
|
cfg = _settings(
|
|
{
|
|
"essential": {
|
|
"models": [],
|
|
"default_model": "",
|
|
"routes": [{"provider": "zhipu", "model": " "}],
|
|
},
|
|
"premium": {"models": [], "default_model": ""},
|
|
}
|
|
)
|
|
with pytest.raises(ValueError, match="modèle"):
|
|
normalize_ai_tiers(cfg.ai_tiers)
|
|
|
|
|
|
def test_normalize_deduplicates_routes_and_keeps_order():
|
|
from routes.admin_routes import normalize_ai_tiers
|
|
|
|
cfg = _settings(
|
|
{
|
|
"essential": {
|
|
"models": [],
|
|
"default_model": "",
|
|
"routes": [
|
|
{"provider": "zhipu", "model": "glm-5.3-flash"},
|
|
{"provider": "zhipu", "model": "glm-5.3-flash", "label": "doublon"},
|
|
{"provider": "openrouter", "model": "deepseek/deepseek-v4-flash"},
|
|
],
|
|
},
|
|
"premium": {"models": [], "default_model": ""},
|
|
}
|
|
)
|
|
normalized = normalize_ai_tiers(cfg.ai_tiers)
|
|
assert [(r.provider, r.model) for r in normalized.essential.routes] == [
|
|
("zhipu", "glm-5.3-flash"),
|
|
("openrouter", "deepseek/deepseek-v4-flash"),
|
|
]
|
|
|
|
|
|
def test_normalize_route_cross_tier_guard():
|
|
"""Une route openrouter pointant un modèle Premium dans le palier
|
|
Essentielle est refusée (anti-croisement inchangé)."""
|
|
from routes.admin_routes import normalize_ai_tiers
|
|
|
|
cfg = _settings(
|
|
{
|
|
"essential": {
|
|
"models": [],
|
|
"default_model": "",
|
|
"routes": [{"provider": "openrouter", "model": "anthropic/claude-sonnet-5"}],
|
|
},
|
|
"premium": {"models": [], "default_model": ""},
|
|
}
|
|
)
|
|
with pytest.raises(ValueError, match="Premium"):
|
|
normalize_ai_tiers(cfg.ai_tiers)
|
|
|
|
|
|
def test_normalize_empty_routes_and_models_fills_official_range():
|
|
from routes.admin_routes import normalize_ai_tiers
|
|
from models.subscription import DEFAULT_AI_MODELS_ESSENTIAL
|
|
|
|
cfg = _settings(
|
|
{
|
|
"essential": {"models": [], "default_model": ""},
|
|
"premium": {"models": [], "default_model": ""},
|
|
}
|
|
)
|
|
normalized = normalize_ai_tiers(cfg.ai_tiers)
|
|
assert normalized.essential.models == list(DEFAULT_AI_MODELS_ESSENTIAL)
|
|
assert normalized.essential.routes == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Intégration worker : le canal choisi instancie le bon fournisseur
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.fixture
|
|
def minimal_xlsx(tmp_path):
|
|
try:
|
|
import openpyxl
|
|
|
|
wb = openpyxl.Workbook()
|
|
wb.active["A1"] = "Hello"
|
|
p = tmp_path / "minimal.xlsx"
|
|
wb.save(p)
|
|
return p
|
|
except ImportError:
|
|
pytest.skip("openpyxl required")
|
|
|
|
|
|
def _register_job(job_id: str) -> None:
|
|
tr._translation_jobs[job_id] = {
|
|
"id": job_id,
|
|
"status": "processing",
|
|
"progress_percent": 0,
|
|
"current_step": "",
|
|
"provider": "openrouter",
|
|
"created_at": "2026-09-05T00:00:00+00:00",
|
|
}
|
|
|
|
|
|
def _run_job(monkeypatch, tmp_path, minimal_xlsx, admin_settings, provider: str,
|
|
user_plan: str, user_id=None, cost_capture: dict | None = None) -> list:
|
|
"""Lance _run_translation_job avec un faux fournisseur et renvoie les
|
|
instanciations capturées (model, base_url, api_key).
|
|
|
|
``user_id`` + ``cost_capture`` : enregistre le facteur de facturation
|
|
transmis à ``record_usage`` (substitué — aucun accès à la base)."""
|
|
import routes.admin_routes as admin_routes_mod
|
|
import services.providers.openai_provider as openai_provider_mod
|
|
import services.providers.deepseek_provider as deepseek_provider_mod
|
|
|
|
captured: list = []
|
|
|
|
class FakeProvider:
|
|
def __init__(self, api_key=None, model=None, base_url=None, timeout=60, **kwargs):
|
|
self.model = model
|
|
self._model = model
|
|
self.base_url = base_url
|
|
captured.append({"model": model, "base_url": base_url, "api_key": api_key})
|
|
|
|
class FakeExcelTranslator:
|
|
def __init__(self, provider=None):
|
|
self.provider = provider
|
|
|
|
def translate_file(self, input_path, output_path, target_lang,
|
|
source_lang="auto", progress_callback=None,
|
|
translate_images=False, **kwargs):
|
|
out = Path(output_path)
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
out.write_bytes(minimal_xlsx.read_bytes())
|
|
|
|
def get_translation_stats(self):
|
|
return {"attempted": 1, "changed": 1}
|
|
|
|
def _fake_record_usage(uid, pages, cost_factor, reserved_docs=1):
|
|
if cost_capture is not None:
|
|
cost_capture["cost_factor"] = cost_factor
|
|
|
|
monkeypatch.setattr(openai_provider_mod, "OpenAITranslationProvider", FakeProvider)
|
|
monkeypatch.setattr(deepseek_provider_mod, "DeepSeekTranslationProvider", FakeProvider)
|
|
monkeypatch.setattr(tr, "ExcelTranslator", FakeExcelTranslator)
|
|
monkeypatch.setattr(tr, "record_usage", _fake_record_usage)
|
|
monkeypatch.setattr(
|
|
admin_routes_mod, "load_settings", lambda: admin_settings, raising=True
|
|
)
|
|
|
|
_register_job("job-routes")
|
|
try:
|
|
asyncio.run(
|
|
tr._run_translation_job(
|
|
job_id="job-routes",
|
|
input_path=Path(minimal_xlsx),
|
|
file_extension=".xlsx",
|
|
target_lang="fr",
|
|
source_lang="en",
|
|
provider=provider,
|
|
user_id=user_id,
|
|
custom_prompt=None,
|
|
glossary_id=None,
|
|
prompt_id=None,
|
|
webhook_url=None,
|
|
user_plan=user_plan,
|
|
)
|
|
)
|
|
finally:
|
|
tr._translation_jobs.pop("job-routes", None)
|
|
return captured
|
|
|
|
|
|
def test_worker_routes_zhipu_channel(monkeypatch, tmp_path, minimal_xlsx):
|
|
monkeypatch.delenv("ZHIPU_API_KEY", raising=False)
|
|
settings = _settings(
|
|
{
|
|
"essential": {
|
|
"models": [],
|
|
"default_model": "",
|
|
"routes": [{"provider": "zhipu", "model": "glm-5.3-flash"}],
|
|
},
|
|
"premium": {"models": [], "default_model": ""},
|
|
},
|
|
zhipu={"api_key": "zk-test"},
|
|
)
|
|
captured = _run_job(monkeypatch, tmp_path, minimal_xlsx, settings,
|
|
provider="openrouter", user_plan="PlanType.PRO")
|
|
assert captured, "le fournisseur n'a pas été instancié"
|
|
assert captured[0]["model"] == "glm-5.3-flash"
|
|
assert captured[0]["base_url"] == "https://api.z.ai/api/paas/v4"
|
|
|
|
|
|
def test_worker_skips_route_without_key(monkeypatch, tmp_path, minimal_xlsx):
|
|
"""Clé zhipu absente → route suivante (openrouter) comme le veut la spec."""
|
|
monkeypatch.delenv("ZHIPU_API_KEY", raising=False)
|
|
settings = _settings(
|
|
{
|
|
"essential": {
|
|
"models": [],
|
|
"default_model": "",
|
|
"routes": [
|
|
{"provider": "zhipu", "model": "glm-5.3-flash"},
|
|
{"provider": "openrouter", "model": "deepseek/deepseek-v4-flash"},
|
|
],
|
|
},
|
|
"premium": {"models": [], "default_model": ""},
|
|
},
|
|
openrouter={"api_key": "sk-or-test"},
|
|
)
|
|
captured = _run_job(monkeypatch, tmp_path, minimal_xlsx, settings,
|
|
provider="openrouter", user_plan="PlanType.PRO")
|
|
assert captured[0]["base_url"] == "https://openrouter.ai/api/v1"
|
|
assert captured[0]["model"] == "deepseek/deepseek-v4-flash"
|
|
|
|
|
|
def test_worker_legacy_settings_keep_openrouter(monkeypatch, tmp_path, minimal_xlsx):
|
|
"""Ancien réglage (chaînes) : routé via openrouter comme aujourd'hui."""
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
settings = _settings(
|
|
{
|
|
"essential": {"models": ["deepseek/deepseek-v4-flash"], "default_model": ""},
|
|
"premium": {"models": [], "default_model": ""},
|
|
},
|
|
openrouter={"api_key": "sk-or-test"},
|
|
)
|
|
captured = _run_job(monkeypatch, tmp_path, minimal_xlsx, settings,
|
|
provider="openrouter", user_plan="PlanType.PRO")
|
|
assert captured[0]["base_url"] == "https://openrouter.ai/api/v1"
|
|
assert captured[0]["model"] == "deepseek/deepseek-v4-flash"
|
|
|
|
|
|
def test_worker_deepseek_route_uses_deepseek_provider(monkeypatch, tmp_path, minimal_xlsx):
|
|
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
|
|
settings = _settings(
|
|
{
|
|
"essential": {
|
|
"models": [],
|
|
"default_model": "",
|
|
"routes": [{"provider": "deepseek", "model": "deepseek-chat"}],
|
|
},
|
|
"premium": {"models": [], "default_model": ""},
|
|
},
|
|
deepseek={"api_key": "dk-test"},
|
|
)
|
|
captured = _run_job(monkeypatch, tmp_path, minimal_xlsx, settings,
|
|
provider="openrouter", user_plan="PlanType.PRO")
|
|
assert captured[0]["base_url"] == "https://api.deepseek.com/v1"
|
|
assert captured[0]["api_key"] == "dk-test"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Facturation exécutée dans le worker : palier Essentielle = 1, Premium = 5
|
|
# ---------------------------------------------------------------------------
|
|
def test_worker_bills_essential_tier_at_cost_1(monkeypatch, tmp_path, minimal_xlsx):
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
settings = _settings(
|
|
{
|
|
"essential": {"models": ["deepseek/deepseek-v4-flash"], "default_model": ""},
|
|
"premium": {"models": [], "default_model": ""},
|
|
},
|
|
openrouter={"api_key": "sk-or-test"},
|
|
)
|
|
cost: dict = {}
|
|
_run_job(monkeypatch, tmp_path, minimal_xlsx, settings,
|
|
provider="openrouter", user_plan="PlanType.PRO",
|
|
user_id="u1", cost_capture=cost)
|
|
assert cost["cost_factor"] == 1
|
|
|
|
|
|
def test_worker_bills_premium_tier_at_cost_5(monkeypatch, tmp_path, minimal_xlsx):
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
settings = _settings(
|
|
{
|
|
"essential": {"models": [], "default_model": ""},
|
|
"premium": {"models": ["anthropic/claude-sonnet-5"], "default_model": ""},
|
|
},
|
|
openrouter={"api_key": "sk-or-test"},
|
|
)
|
|
cost: dict = {}
|
|
_run_job(monkeypatch, tmp_path, minimal_xlsx, settings,
|
|
provider="openrouter_premium", user_plan="PlanType.BUSINESS",
|
|
user_id="u1", cost_capture=cost)
|
|
assert cost["cost_factor"] == 5
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Première sauvegarde depuis l'éditeur : migration fidèle hérité → routes
|
|
# ---------------------------------------------------------------------------
|
|
def test_first_editor_save_migrates_legacy_settings_without_loss(monkeypatch):
|
|
"""Des réglages à chaînes (models/default_model) convertis en routes par
|
|
l'éditeur, puis normalisés, doivent résoudre EXACTEMENT les mêmes
|
|
canaux/modèles qu'avant la migration."""
|
|
from routes.admin_routes import SettingsConfig, normalize_ai_tiers
|
|
|
|
legacy = _settings(
|
|
{
|
|
"essential": {
|
|
"models": ["z-ai/glm-5.3-flash", "deepseek/deepseek-v4-flash"],
|
|
"default_model": "z-ai/glm-5.3-flash",
|
|
},
|
|
"premium": {
|
|
"models": ["anthropic/claude-sonnet-5", "deepseek/deepseek-v4-pro"],
|
|
"default_model": "anthropic/claude-sonnet-5",
|
|
},
|
|
},
|
|
openrouter={"api_key": "sk-or-test"},
|
|
)
|
|
before_essential = tr._resolve_tier_model(legacy, "pro", premium=False)
|
|
before_premium = tr._resolve_tier_model(legacy, "business", premium=True)
|
|
|
|
def _editor_payload(tier):
|
|
"""Miroir de la conversion faite par la page Modèles : le défaut en
|
|
tête, puis le reste de la liste — le tout en routes openrouter, avec
|
|
le miroir hérité conservé (models/default_model)."""
|
|
ordered = [tier.default_model] + [
|
|
m for m in tier.models if m != tier.default_model
|
|
]
|
|
routes = [{"provider": "openrouter", "model": m} for m in ordered]
|
|
return {"models": ordered, "default_model": tier.default_model, "routes": routes}
|
|
|
|
payload = SettingsConfig.model_validate(
|
|
{
|
|
"openrouter": {"api_key": "sk-or-test"},
|
|
"ai_tiers": {
|
|
"essential": _editor_payload(legacy.ai_tiers.essential),
|
|
"premium": _editor_payload(legacy.ai_tiers.premium),
|
|
},
|
|
}
|
|
)
|
|
normalized = normalize_ai_tiers(payload.ai_tiers)
|
|
migrated = SettingsConfig.model_validate(
|
|
{"openrouter": {"api_key": "sk-or-test"}, "ai_tiers": normalized.model_dump()}
|
|
)
|
|
|
|
after_essential = tr._resolve_tier_model(migrated, "pro", premium=False)
|
|
after_premium = tr._resolve_tier_model(migrated, "business", premium=True)
|
|
|
|
assert after_essential == before_essential == ("openrouter", "z-ai/glm-5.3-flash")
|
|
assert after_premium == before_premium == ("openrouter", "anthropic/claude-sonnet-5")
|
|
# Aucune perte : tous les modèles d'origine restent résolubles dans l'ordre.
|
|
assert [r.model for r in normalized.essential.routes] == [
|
|
"z-ai/glm-5.3-flash",
|
|
"deepseek/deepseek-v4-flash",
|
|
]
|
|
assert [r.model for r in normalized.premium.routes] == [
|
|
"anthropic/claude-sonnet-5",
|
|
"deepseek/deepseek-v4-pro",
|
|
]
|