diff --git a/data/provider_settings.json b/data/provider_settings.json index e22b5e2..5bf3333 100644 --- a/data/provider_settings.json +++ b/data/provider_settings.json @@ -43,7 +43,7 @@ "enabled": true, "api_key": null, "base_url": null, - "model": "deepseek/deepseek-v3.2", + "model": "google/gemini-3.5-flash", "timeout": 30, "max_retries": 3 }, @@ -51,7 +51,7 @@ "enabled": false, "api_key": null, "base_url": null, - "model": "anthropic/claude-3.5-haiku", + "model": "anthropic/claude-sonnet-4.6", "timeout": 30, "max_retries": 3 }, diff --git a/frontend/src/app/admin/settings/page.tsx b/frontend/src/app/admin/settings/page.tsx index 1e7e4b9..2993ad6 100644 --- a/frontend/src/app/admin/settings/page.tsx +++ b/frontend/src/app/admin/settings/page.tsx @@ -535,7 +535,7 @@ export default function AdminSettingsPage() { updateProvider("openrouter_premium", { enabled })} onTest={() => testProvider("openrouter_premium")} @@ -552,10 +552,10 @@ export default function AdminSettingsPage() { isLoading={loadingModelsProvider === "openrouter"} onFetchModels={() => fetchModels("openrouter")} providerLabel="OpenRouter" - placeholder="openai/gpt-4o-mini" + placeholder="anthropic/claude-sonnet-4.6" />

- Recommandé : openai/gpt-4o-mini (~€0.15/doc) ou anthropic/claude-3.5-haiku (~€0.20/doc) + Recommandé : anthropic/claude-sonnet-4.6 (~€0.20/doc) ou openai/gpt-4o (~€0.30/doc)

diff --git a/frontend/src/app/dashboard/glossaries/page.tsx b/frontend/src/app/dashboard/glossaries/page.tsx index 53831a4..fc3e634 100644 --- a/frontend/src/app/dashboard/glossaries/page.tsx +++ b/frontend/src/app/dashboard/glossaries/page.tsx @@ -296,13 +296,22 @@ export default function GlossariesPage() { )} {glossaries.length > 0 && ( - - - {t('glossaries.grid.goToTranslate')} - + <> + + + + {t('glossaries.grid.goToTranslate')} + + )} diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts index 61ed2de..e2aa8df 100644 --- a/frontend/src/lib/store.ts +++ b/frontend/src/lib/store.ts @@ -8,9 +8,9 @@ export const openaiModels = [ ]; export const openrouterModels = [ - { id: "deepseek/deepseek-chat-v3-0324", name: "DeepSeek V3" }, - { id: "anthropic/claude-3.5-haiku", name: "Claude 3.5 Haiku" }, - { id: "google/gemini-2.0-flash-001", name: "Gemini 2.0 Flash" }, + { id: "google/gemini-3.5-flash", name: "Gemini 3.5 Flash" }, + { id: "deepseek/deepseek-chat", name: "DeepSeek Chat" }, + { id: "anthropic/claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, ]; interface TranslationSettings { diff --git a/models/subscription.py b/models/subscription.py index 80a1525..3fb7c7f 100644 --- a/models/subscription.py +++ b/models/subscription.py @@ -31,10 +31,10 @@ import os # NOTE: Stripe price IDs must be set via env vars in production. # Create products/prices in Stripe Dashboard: https://dashboard.stripe.com/products # -# LLM models used (via OpenRouter — March 2026): -# Essentielle : deepseek/deepseek-v3.2 ($0.25 / $0.38 per 1M tokens) -# Premium : anthropic/claude-3.5-haiku ($0.25 / $1.25 per 1M tokens) -# or google/gemini-3-flash ($0.15 / $0.60) +# LLM models used (via OpenRouter — June 2026): +# Essentielle : deepseek/deepseek-chat (DeepSeek V4/V3) +# Premium : anthropic/claude-sonnet-4.6 (Claude Sonnet 4.6) +# or google/gemini-3.5-flash (Gemini 3.5 Flash) PLANS = { PlanType.FREE: { "name": "Free", @@ -97,7 +97,7 @@ PLANS = { "max_file_size_mb": 25, "max_chars_per_month": 2_000_000, "providers": ["google", "google_cloud", "deepl", "openrouter"], - "ai_model_essential": "deepseek/deepseek-v3.2", + "ai_model_essential": "deepseek/deepseek-chat", "features": [ "pricing.plans.pro.feat1", "pricing.plans.pro.feat2", @@ -127,8 +127,8 @@ PLANS = { "max_file_size_mb": 50, "max_chars_per_month": 10_000_000, "providers": ["google", "google_cloud", "deepl", "openrouter", "openrouter_premium", "openai", "zai"], - "ai_model_essential": "deepseek/deepseek-v3.2", - "ai_model_premium": "anthropic/claude-3.5-haiku", + "ai_model_essential": "deepseek/deepseek-chat", + "ai_model_premium": "anthropic/claude-sonnet-4.6", "features": [ "pricing.plans.business.feat1", "pricing.plans.business.feat2", diff --git a/routes/admin_routes.py b/routes/admin_routes.py index fe71630..8b3da16 100644 --- a/routes/admin_routes.py +++ b/routes/admin_routes.py @@ -913,10 +913,10 @@ async def get_settings(admin_id: str = Depends(require_admin)): return d payload = settings.model_dump() - # Essentielle : DeepSeek V3.2 — meilleur rapport qualité/prix (mars 2026) - payload["openrouter"] = _merge_env(settings.openrouter, key_env="OPENROUTER_API_KEY", model_env="OPENROUTER_MODEL", default_model="deepseek/deepseek-v3.2") - # Premium : Claude 3.5 Haiku — précision maximale sur documents complexes - payload["openrouter_premium"] = _merge_env(settings.openrouter_premium, key_env="OPENROUTER_API_KEY", model_env="OPENROUTER_PREMIUM_MODEL", default_model="anthropic/claude-3.5-haiku") + # Essentielle : Gemini 3.5 Flash / DeepSeek Chat — meilleur rapport qualité/prix (juin 2026) + payload["openrouter"] = _merge_env(settings.openrouter, key_env="OPENROUTER_API_KEY", model_env="OPENROUTER_MODEL", default_model="google/gemini-3.5-flash") + # Premium : Claude Sonnet 4.6 — précision maximale sur documents complexes + payload["openrouter_premium"] = _merge_env(settings.openrouter_premium, key_env="OPENROUTER_API_KEY", model_env="OPENROUTER_PREMIUM_MODEL", default_model="anthropic/claude-sonnet-4.6") payload["openai"] = _merge_env(settings.openai, key_env="OPENAI_API_KEY", model_env="OPENAI_MODEL", default_model="gpt-4o-mini") payload["deepl"] = _merge_env(settings.deepl, key_env="DEEPL_API_KEY") payload["deepseek"] = _merge_env(settings.deepseek, key_env="DEEPSEEK_API_KEY", model_env="DEEPSEEK_MODEL", default_model="deepseek-chat") diff --git a/routes/legacy_routes.py b/routes/legacy_routes.py index e98b8a5..cfbd5ea 100644 --- a/routes/legacy_routes.py +++ b/routes/legacy_routes.py @@ -105,41 +105,58 @@ async def get_available_providers(): "tier": "pro", }) - # AI Essentielle (OpenRouter — cheap model) + # AI Essentielle (OpenRouter — cheap model / Eco) if _is_enabled("openrouter", key_var="OPENROUTER_API_KEY"): or_cfg = getattr(settings, "openrouter", None) model = _resolve_model( or_cfg.model if or_cfg else None, "OPENROUTER_MODEL", - "deepseek/deepseek-v3.2", + "google/gemini-3.5-flash", ) available.append({ "id": "openrouter", - "label": "Traduction IA Essentielle", - "description": "IA rapide et économique — idéale pour documents techniques", + "label": "Traduction IA Éco", + "description": "IA rapide, économique et complète — supporte les images", + "mode": "llm", + "tier": "pro", + "model": model, + }) + + # AI Standard (DeepSeek via OpenRouter or direct DeepSeek API) + if _is_enabled("openrouter", key_var="OPENROUTER_API_KEY") or _is_enabled("deepseek", key_var="DEEPSEEK_API_KEY"): + ds_cfg = getattr(settings, "deepseek", None) + model = _resolve_model( + ds_cfg.model if ds_cfg else None, + "DEEPSEEK_MODEL", + "deepseek/deepseek-chat", + ) + available.append({ + "id": "deepseek", + "label": "Traduction IA Standard", + "description": "IA ultra-précise pour le texte (ne traduit pas les images)", "mode": "llm", "tier": "pro", "model": model, }) # AI Premium (OpenRouter — premium model) - if _is_enabled("openrouter_premium", key_var="OPENROUTER_API_KEY"): + if _is_enabled("openrouter_premium", key_var="OPENROUTER_API_KEY") or _is_enabled("openrouter", key_var="OPENROUTER_API_KEY"): orp_cfg = getattr(settings, "openrouter_premium", None) model = _resolve_model( orp_cfg.model if orp_cfg else None, "OPENROUTER_PREMIUM_MODEL", - "anthropic/claude-3.5-haiku", + "anthropic/claude-sonnet-4.6", ) available.append({ "id": "openrouter_premium", "label": "Traduction IA Premium", - "description": "IA haute précision (GPT-4, Claude) — meilleure qualité littéraire", + "description": "IA haut de gamme — excellente qualité littéraire et multimodal", "mode": "llm", "tier": "business", "model": model, }) - # OpenAI direct — if configured + # OpenAI direct — if configured with direct API key if _is_enabled("openai", key_var="OPENAI_API_KEY"): oai_cfg = getattr(settings, "openai", None) model = _resolve_model( @@ -156,24 +173,7 @@ async def get_available_providers(): "model": model, }) - # DeepSeek direct — if configured - if _is_enabled("deepseek", key_var="DEEPSEEK_API_KEY"): - ds_cfg = getattr(settings, "deepseek", None) - model = _resolve_model( - ds_cfg.model if ds_cfg else None, - "DEEPSEEK_MODEL", - "deepseek-chat", - ) - available.append({ - "id": "deepseek", - "label": "Traduction IA Express", - "description": "Traduction IA rapide et optimisée", - "mode": "llm", - "tier": "pro", - "model": model, - }) - - # MiniMax direct — if configured + # MiniMax direct — if configured with direct API key if _is_enabled("minimax", key_var="MINIMAX_API_KEY"): mm_cfg = getattr(settings, "minimax", None) model = _resolve_model( @@ -190,7 +190,7 @@ async def get_available_providers(): "model": model, }) - # z.AI / xAI Grok — if configured + # z.AI / xAI Grok — if configured with direct API key if _is_enabled("zai", key_var="ZAI_API_KEY"): zai_cfg = getattr(settings, "zai", None) model = _resolve_model( diff --git a/routes/translate_routes.py b/routes/translate_routes.py index 662e0fa..10e4ee9 100644 --- a/routes/translate_routes.py +++ b/routes/translate_routes.py @@ -45,7 +45,7 @@ from config import config from translators import ExcelTranslator, WordTranslator, PowerPointTranslator from models.subscription import PlanType from middleware.tier_quota import tier_quota_service -from services.auth_service import record_usage +from services.auth_service import record_usage, check_usage_limits from middleware.validation import FileValidator, ValidationError, LanguageValidator, webhook_validator from middleware.api_key_auth import get_authenticated_user, get_user_from_api_key from utils import file_handler @@ -135,9 +135,11 @@ file_handler_util = FileHandler() def _tier_for_quota(plan) -> str: - """Map plan to quota tier: pro (and equivalent) = unlimited, else free.""" + """Map plan to quota tier: pro/starter (and equivalent) = unlimited, else free.""" if plan in (PlanType.PRO, PlanType.BUSINESS, PlanType.ENTERPRISE): return "pro" + if plan == PlanType.STARTER: + return "starter" return "free" @@ -630,6 +632,22 @@ async def translate_document_v1( }, headers={"Retry-After": str(retry_after)}, ) + + # Strict database plan limit check (Starter, Pro, Business, Enterprise) + usage = check_usage_limits(current_user) + if not usage["can_translate"]: + raise HTTPException( + status_code=429, + detail={ + "error": "QUOTA_EXCEEDED", + "message": f"Monthly limit reached ({usage['docs_used']}/{usage['docs_limit']} documents). Upgrade your plan for more.", + "details": { + "current_usage": usage["docs_used"], + "limit": usage["docs_limit"], + "tier": tier, + }, + }, + ) rate_limit_remaining = quota.remaining else: rate_limit_remaining = -1 @@ -993,7 +1011,9 @@ 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") - model = _cfg(_admin_cfg.openrouter.model, "OPENROUTER_MODEL", "deepseek/deepseek-v3.2") + model = _cfg(_admin_cfg.openrouter.model, "OPENROUTER_MODEL", "google/gemini-3.5-flash") + if model in ("deepseek/deepseek-v3.2", "google/gemini-2.0-flash-001"): + model = "google/gemini-3.5-flash" # Story 3.10: Retrieve and format glossary terms for LLM prompt glossary_terms = None @@ -1075,7 +1095,7 @@ async def _run_translation_job( ) elif _p == "openrouter_premium": premium_key = _cfg(_admin_cfg.openrouter_premium.api_key, "OPENROUTER_API_KEY") - premium_model = _cfg(_admin_cfg.openrouter_premium.model, "OPENROUTER_PREMIUM_MODEL", "anthropic/claude-3.5-haiku") + premium_model = _cfg(_admin_cfg.openrouter_premium.model, "OPENROUTER_PREMIUM_MODEL", "anthropic/claude-sonnet-4.6") if not premium_key: premium_key = api_key # fall back to main openrouter key if premium_key: @@ -1096,8 +1116,15 @@ async def _run_translation_job( ) elif _p == "deepseek": ds_key = _cfg(getattr(_admin_cfg, "deepseek", None) and _admin_cfg.deepseek.api_key, "DEEPSEEK_API_KEY") - ds_model = _cfg(getattr(_admin_cfg, "deepseek", None) and _admin_cfg.deepseek.model, "DEEPSEEK_MODEL", "deepseek-chat") - if ds_key: + ds_model = _cfg(getattr(_admin_cfg, "deepseek", None) and _admin_cfg.deepseek.model, "DEEPSEEK_MODEL", "deepseek/deepseek-chat") + if not ds_key and api_key: + translation_provider = OpenAITranslationProvider( + api_key=api_key, + model=ds_model, + base_url="https://openrouter.ai/api/v1", + timeout=int(os.getenv("OPENROUTER_TIMEOUT", "60")), + ) + elif ds_key: translation_provider = DeepSeekTranslationProvider( api_key=ds_key, model=ds_model, @@ -1295,13 +1322,30 @@ async def _run_translation_job( ) if user_id: - await tier_quota_service.increment_on_success(user_id) + # Determine cost factor based on selected provider and model + cost_factor = 1 + provider_lower = (provider or "").lower() + + prov_model = "" + if translation_provider: + prov_model = getattr(translation_provider, "model", "") or "" + + prov_model_lower = prov_model.lower() + if any(k in prov_model_lower for k in ["claude", "fable", "gpt-4"]) or provider_lower == "openrouter_premium": + if "haiku" in prov_model_lower: + cost_factor = 1 + else: + cost_factor = 5 + + for _ in range(cost_factor): + await tier_quota_service.increment_on_success(user_id) + # Persist monthly usage counters in PostgreSQL (docs + pages) pages = await asyncio.to_thread( _estimate_pages, input_path, file_extension ) - await asyncio.to_thread(record_usage, user_id, pages) - logger.info(f"Job {job_id}: usage recorded — {pages} page(s)") + await asyncio.to_thread(record_usage, user_id, pages, False, cost_factor) + logger.info(f"Job {job_id}: usage recorded — {pages} page(s) with cost factor {cost_factor}") # Apply watermark for Free-tier users plan_name = (user_plan or "free").lower() diff --git a/services/auth_service.py b/services/auth_service.py index b7cbf8d..f7c22bc 100644 --- a/services/auth_service.py +++ b/services/auth_service.py @@ -501,19 +501,21 @@ def check_usage_limits(user: User) -> Dict[str, Any]: } -def record_usage(user_id: str, pages_count: int, use_credits: bool = False) -> bool: - """Record document translation usage""" +def record_usage( + user_id: str, pages_count: int, use_credits: bool = False, cost_factor: int = 1 +) -> bool: + """Record document translation usage with optional cost factor depending on AI model""" user = get_user_by_id(user_id) if not user: return False updates = { - "docs_translated_this_month": user.docs_translated_this_month + 1, - "pages_translated_this_month": user.pages_translated_this_month + pages_count, + "docs_translated_this_month": user.docs_translated_this_month + cost_factor, + "pages_translated_this_month": user.pages_translated_this_month + (pages_count * cost_factor), } if use_credits: - updates["extra_credits"] = max(0, user.extra_credits - pages_count) + updates["extra_credits"] = max(0, user.extra_credits - (pages_count * cost_factor)) result = update_user(user_id, updates) return result is not None diff --git a/services/translation_service.py b/services/translation_service.py index 5442f02..6fa19c2 100644 --- a/services/translation_service.py +++ b/services/translation_service.py @@ -903,11 +903,12 @@ RULES: else "image/png" ) - # Determine a vision model. If the current model doesn't support vision, - # use a fast vision fallback model like google/gemini-2.0-flash-001 vision_model = self.model - if "deepseek" in vision_model: - vision_model = "google/gemini-2.0-flash-001" + vision_supported_keywords = ["gpt-4", "claude", "gemini", "fable", "pixtral", "llama-3.2", "grok-2"] + has_vision = any(kw in vision_model.lower() for kw in vision_supported_keywords) + + if not has_vision: + vision_model = "google/gemini-3.5-flash" session = self._get_session() payload = { @@ -1083,7 +1084,10 @@ ADDITIONAL CONTEXT AND INSTRUCTIONS: try: import openai - client = openai.OpenAI(api_key=self.api_key) + client_kwargs = {"api_key": self.api_key} + if self.base_url: + client_kwargs["base_url"] = self.base_url + client = openai.OpenAI(**client_kwargs) # Read and encode image with open(image_path, "rb") as img_file: @@ -1097,8 +1101,20 @@ ADDITIONAL CONTEXT AND INSTRUCTIONS: else "image/png" ) + # Determine a vision model. If the current model doesn't support vision, + # use a fast vision fallback model + vision_model = self.model + vision_supported_keywords = ["gpt-4", "claude", "gemini", "fable", "pixtral", "llama-3.2", "grok-2"] + has_vision = any(kw in vision_model.lower() for kw in vision_supported_keywords) + + if not has_vision: + if self.base_url and "openrouter.ai" in self.base_url: + vision_model = "google/gemini-3.5-flash" + else: + vision_model = "gpt-4o-mini" + response = client.chat.completions.create( - model=self.model, # gpt-4o and gpt-4o-mini support vision + model=vision_model, messages=[ { "role": "user", @@ -1121,7 +1137,7 @@ ADDITIONAL CONTEXT AND INSTRUCTIONS: return response.choices[0].message.content.strip() except Exception as e: - logger.warning("openai_vision_error", error_type=type(e).__name__) + logger.warning("openai_vision_error", error_type=type(e).__name__, error=str(e)) return ""