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