""" Routage réel des modèles par palier IA (spec « Stratégie LLM par abonnement »). - PLANS expose ai_models_essential / ai_models_premium (gamme officielle). - Un plan Pro (moteur openrouter) résout toujours un modèle Essentielle, jamais un modèle Premium. - Le défaut admin (ai_tiers..default_model) prime sur le défaut du plan, sans redéploiement. """ import pytest from pathlib import Path from fastapi.testclient import TestClient from models.subscription import ( DEFAULT_AI_MODELS_ESSENTIAL, DEFAULT_AI_MODELS_PREMIUM, PLANS, PlanType, ) from routes.translate_routes import ( _allowed_providers_for_plan, _plan_ai_models, _resolve_tier_model, ) @pytest.fixture def client(monkeypatch, tmp_path: Path): """TestClient with JSON auth and rate limiting disabled.""" import services.auth_service as auth_svc from middleware.rate_limiting import RateLimitManager monkeypatch.setattr(auth_svc, "USERS_FILE", tmp_path / "users.json") monkeypatch.setattr(auth_svc, "USE_DATABASE", False) monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", False) async def _check_request_allow(self, request): return True, "ok", "test" async def _check_translation_allow(self, request, file_size_mb=0): return True, "ok" monkeypatch.setattr(RateLimitManager, "check_request", _check_request_allow) monkeypatch.setattr(RateLimitManager, "check_translation", _check_translation_allow) from main import app return TestClient(app, raise_server_exceptions=True) def _settings(ai_tiers: dict | None = None): from routes.admin_routes import SettingsConfig # La résolution est clé-consciente : les tests de résolution partent avec # une clé OpenRouter disponible (comme un serveur configuré). payload = {"openrouter": {"api_key": "sk-or-test"}} if ai_tiers is not None: payload["ai_tiers"] = ai_tiers return SettingsConfig.model_validate(payload) # --------------------------------------------------------------------------- # Gamme officielle dans PLANS # --------------------------------------------------------------------------- def test_plans_expose_official_model_ranges(): essential = PLANS[PlanType.PRO]["ai_models_essential"] assert essential == [ "deepseek/deepseek-v4-flash", "z-ai/glm-5.3-flash", "minimax/minimax-m3", ] business = PLANS[PlanType.BUSINESS] assert business["ai_models_essential"] == essential assert business["ai_models_premium"] == [ "anthropic/claude-sonnet-5", "deepseek/deepseek-v4-pro", "z-ai/glm-5.3", ] def test_old_single_model_keys_removed(): assert "ai_model_essential" not in PLANS[PlanType.PRO] assert "ai_model_essential" not in PLANS[PlanType.BUSINESS] assert "ai_model_premium" not in PLANS[PlanType.BUSINESS] # --------------------------------------------------------------------------- # Résolution par palier — renvoie la route complète (provider, model) # --------------------------------------------------------------------------- def test_pro_resolves_essential_plan_default(): assert _resolve_tier_model(_settings(), "pro", premium=False) == ( ("openrouter", "deepseek/deepseek-v4-flash") ) def test_business_resolves_premium_plan_default(): assert _resolve_tier_model(_settings(), "business", premium=True) == ( ("openrouter", "anthropic/claude-sonnet-5") ) def test_admin_tier_default_overrides_plan_default(): cfg = _settings( {"essential": {"models": [], "default_model": "z-ai/glm-5.3-flash"}} ) assert _resolve_tier_model(cfg, "pro", premium=False) == ( "openrouter", "z-ai/glm-5.3-flash" ) def test_admin_tier_models_first_used_when_default_empty(): cfg = _settings( { "essential": { "models": ["minimax/minimax-m3", "deepseek/deepseek-v4-flash"], "default_model": "", } } ) assert _resolve_tier_model(cfg, "pro", premium=False) == ( "openrouter", "minimax/minimax-m3" ) def test_pro_never_resolves_premium_model(): cfg = _settings( { "essential": {"models": [], "default_model": ""}, "premium": {"models": [], "default_model": ""}, } ) for plan_str in ("pro", "PlanType.PRO"): provider, model = _resolve_tier_model(cfg, plan_str, premium=False) assert provider == "openrouter" assert model in DEFAULT_AI_MODELS_ESSENTIAL assert model not in DEFAULT_AI_MODELS_PREMIUM def test_pro_plan_does_not_allow_premium_provider(): allowed = _allowed_providers_for_plan(PlanType.PRO) assert "openrouter" in allowed assert "openrouter_premium" not in allowed def test_plan_fallback_for_plans_without_range(): # Free/Enterprise n'ont pas de liste : retombée sur la gamme officielle. assert _plan_ai_models(PlanType.FREE, premium=False) == DEFAULT_AI_MODELS_ESSENTIAL assert _plan_ai_models(PlanType.ENTERPRISE, premium=True) == DEFAULT_AI_MODELS_PREMIUM # --------------------------------------------------------------------------- # GET /plans expose les gammes (affichage public) # --------------------------------------------------------------------------- def test_get_plans_exposes_model_lists(client): r = client.get("/api/v1/auth/plans") assert r.status_code == 200 plans = {p["id"]: p for p in r.json()["data"]["plans"]} assert plans["pro"]["ai_models_essential"] == [ "deepseek/deepseek-v4-flash", "z-ai/glm-5.3-flash", "minimax/minimax-m3", ] assert plans["business"]["ai_models_premium"][0] == "anthropic/claude-sonnet-5" assert "ai_model_essential" not in plans["pro"] # --------------------------------------------------------------------------- # Point de contact : GET /providers/available affiche le modèle (chaîne simple) # --------------------------------------------------------------------------- def test_available_providers_model_is_a_string(monkeypatch, tmp_path): """``_resolve_tier_model`` renvoie désormais (provider, model) : le champ « model » de /providers/available doit rester une chaîne, égale au modèle attendu pour les deux paliers (appelé ici avec un utilisateur Business, car la liste est filtrée par forfait).""" import routes.legacy_routes as legacy_mod import routes.admin_routes as admin_routes_mod settings = _settings( { "essential": {"models": [], "default_model": "z-ai/glm-5.3-flash"}, "premium": {"models": [], "default_model": "anthropic/claude-sonnet-5"}, } ) # Les deux passerelles sont affichées dès lors qu'elles sont actives côté # réglages (indépendamment des variables d'environnement de la machine). settings.openrouter.enabled = True settings.openrouter_premium.enabled = True monkeypatch.setattr(admin_routes_mod, "load_settings", lambda: settings, raising=True) from fastapi.testclient import TestClient from middleware.rate_limiting import RateLimitManager from models.subscription import PlanType async def _check_request_allow(self, request): return True, "ok", "test" async def _check_translation_allow(self, request, file_size_mb=0): return True, "ok" monkeypatch.setattr(RateLimitManager, "check_request", _check_request_allow) monkeypatch.setattr(RateLimitManager, "check_translation", _check_translation_allow) import services.auth_service as auth_svc monkeypatch.setattr(auth_svc, "USERS_FILE", tmp_path / "users.json") monkeypatch.setattr(auth_svc, "USE_DATABASE", False) monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", False) # Utilisateur Business factice (les moteurs Premium sont filtrés par plan). business_user = type("U", (), {"plan": PlanType.BUSINESS, "id": "u-biz"})() async def _fake_user(): return business_user from main import app app.dependency_overrides[legacy_mod.get_authenticated_user] = _fake_user try: with TestClient(app, raise_server_exceptions=True) as tc: r = tc.get("/api/v1/providers/available") finally: app.dependency_overrides.pop(legacy_mod.get_authenticated_user, None) assert r.status_code == 200, r.text engines = {e["id"]: e for e in r.json()["providers"]} assert isinstance(engines["openrouter"]["model"], str) assert engines["openrouter"]["model"] == "z-ai/glm-5.3-flash" assert isinstance(engines["openrouter_premium"]["model"], str) assert engines["openrouter_premium"]["model"] == "anthropic/claude-sonnet-5"