Files
office_translator/routes/admin_routes.py
2026-03-07 11:42:58 +01:00

978 lines
35 KiB
Python

"""
Admin API v1 Endpoints
All admin endpoints under /api/v1/admin/
Story 3.5: API Versioning - Migrated from main.py
"""
import os
import secrets
import time
import logging
from datetime import datetime, timezone
from typing import Optional, Literal
from fastapi import APIRouter, Depends, Header, HTTPException, Form, Request, Query
from fastapi.responses import JSONResponse
from passlib.context import CryptContext
from pydantic import BaseModel
from config import config
from models.subscription import PlanType, PLANS
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/admin", tags=["Admin"])
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME")
ADMIN_PASSWORD_HASH = os.getenv("ADMIN_PASSWORD_HASH")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD")
if not ADMIN_PASSWORD_HASH and not ADMIN_PASSWORD:
ADMIN_PASSWORD = os.getenv("ADMIN_DEV_DEFAULT")
_admin_token_secret = os.getenv("ADMIN_TOKEN_SECRET")
if not _admin_token_secret:
_admin_token_secret = secrets.token_hex(32)
logger.critical(
"SECURITY: ADMIN_TOKEN_SECRET is not configured! Using an ephemeral random key. "
"ALL ADMIN SESSIONS WILL BE INVALIDATED ON EVERY RESTART. "
"Set ADMIN_TOKEN_SECRET in your .env file immediately."
)
ADMIN_TOKEN_SECRET = _admin_token_secret
REDIS_URL = os.getenv("REDIS_URL", "")
_redis_client = None
_memory_sessions: dict = {}
# Brute-force protection: IP → (failed_count, first_fail_ts)
_login_attempts: dict[str, tuple[int, float]] = {}
_MAX_LOGIN_ATTEMPTS = 5
_LOCKOUT_SECONDS = 300 # 5 minutes
def get_redis_client():
global _redis_client
if _redis_client is None and REDIS_URL:
try:
import redis
_redis_client = redis.from_url(REDIS_URL, decode_responses=True)
_redis_client.ping()
logger.info("Connected to Redis for session storage")
except Exception as e:
logger.warning(f"Redis connection failed: {e}. Using in-memory sessions.")
_redis_client = False
return _redis_client if _redis_client else None
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_admin_password(password: str) -> bool:
if not ADMIN_PASSWORD_HASH and not ADMIN_PASSWORD:
return False
p = (password or "").strip()
if ADMIN_PASSWORD_HASH:
try:
return pwd_context.verify(p, ADMIN_PASSWORD_HASH)
except Exception:
return False
return p == (ADMIN_PASSWORD or "").strip()
def _get_session_key(token: str) -> str:
return f"admin_session:{token}"
def create_admin_token() -> str:
token = secrets.token_urlsafe(32)
expiry = int(time.time()) + (24 * 60 * 60)
redis_client = get_redis_client()
if redis_client:
try:
redis_client.setex(_get_session_key(token), 24 * 60 * 60, str(expiry))
except Exception as e:
logger.warning(f"Redis session save failed: {e}")
_memory_sessions[token] = expiry
else:
_memory_sessions[token] = expiry
return token
def verify_admin_token(token: str) -> bool:
redis_client = get_redis_client()
if redis_client:
try:
expiry = redis_client.get(_get_session_key(token))
if expiry and int(expiry) > time.time():
return True
return False
except Exception as e:
logger.warning(f"Redis session check failed: {e}")
if token not in _memory_sessions:
return False
if time.time() > _memory_sessions[token]:
del _memory_sessions[token]
return False
return True
def delete_admin_token(token: str):
redis_client = get_redis_client()
if redis_client:
try:
redis_client.delete(_get_session_key(token))
except Exception:
pass
if token in _memory_sessions:
del _memory_sessions[token]
async def require_admin(authorization: Optional[str] = Header(None)) -> str:
if not ADMIN_USERNAME or (not ADMIN_PASSWORD_HASH and not ADMIN_PASSWORD):
raise HTTPException(
status_code=503, detail="Admin authentication not configured"
)
if not authorization:
raise HTTPException(status_code=401, detail="Authorization header required")
parts = authorization.split(" ")
if len(parts) != 2 or parts[0].lower() != "bearer":
raise HTTPException(
status_code=401, detail="Invalid authorization format. Use: Bearer <token>"
)
token = parts[1]
if not verify_admin_token(token):
raise HTTPException(status_code=401, detail="Invalid or expired token")
return ADMIN_USERNAME
class AdminLoginRequest(BaseModel):
password: str
class AdminUpdateUserTierRequest(BaseModel):
plan: Literal["free", "starter", "pro", "business", "enterprise"]
@router.post("/login")
async def admin_login(request: AdminLoginRequest, req: Request):
"""Admin login endpoint - Returns a bearer token for authenticated admin access"""
client_ip = req.client.host if req.client else "unknown"
# Brute-force protection
now = time.time()
attempts, first_fail = _login_attempts.get(client_ip, (0, now))
if attempts >= _MAX_LOGIN_ATTEMPTS:
elapsed = now - first_fail
if elapsed < _LOCKOUT_SECONDS:
remaining = int(_LOCKOUT_SECONDS - elapsed)
logger.warning(f"Admin login blocked (brute-force) for IP {client_ip}")
raise HTTPException(
status_code=429,
detail=f"Too many failed attempts. Try again in {remaining}s.",
headers={"Retry-After": str(remaining)},
)
else:
_login_attempts.pop(client_ip, None)
if not verify_admin_password(request.password):
count, first = _login_attempts.get(client_ip, (0, now))
_login_attempts[client_ip] = (count + 1, first if count > 0 else now)
logger.warning(f"Failed admin login attempt from {client_ip} ({count + 1}/{_MAX_LOGIN_ATTEMPTS})")
raise HTTPException(status_code=401, detail="Invalid credentials")
_login_attempts.pop(client_ip, None)
token = create_admin_token()
logger.info(f"Admin login successful from {client_ip}")
return {
"status": "success",
"access_token": token,
"token_type": "bearer",
"expires_in": 86400,
"message": "Login successful",
}
@router.post("/logout")
async def admin_logout(authorization: Optional[str] = Header(None)):
"""Logout and invalidate admin token"""
if authorization:
parts = authorization.split(" ")
if len(parts) == 2 and parts[0].lower() == "bearer":
token = parts[1]
delete_admin_token(token)
logger.info("Admin logout successful")
return {"status": "success", "message": "Logged out"}
@router.get("/verify")
async def verify_admin_session(is_admin: bool = Depends(require_admin)):
"""Verify admin token is still valid"""
return {"status": "valid", "authenticated": True}
@router.get("/dashboard")
async def get_admin_dashboard(is_admin: bool = Depends(require_admin)):
"""Get comprehensive admin dashboard data"""
from middleware.cleanup import create_cleanup_manager
from middleware.rate_limiting import RateLimitManager, RateLimitConfig
from services.translation_service import _translation_cache
cleanup_manager = create_cleanup_manager(config)
rate_limit_config = RateLimitConfig(
requests_per_minute=int(os.getenv("RATE_LIMIT_PER_MINUTE", "30")),
requests_per_hour=int(os.getenv("RATE_LIMIT_PER_HOUR", "200")),
translations_per_minute=int(os.getenv("TRANSLATIONS_PER_MINUTE", "10")),
translations_per_hour=int(os.getenv("TRANSLATIONS_PER_HOUR", "50")),
max_concurrent_translations=int(os.getenv("MAX_CONCURRENT_TRANSLATIONS", "5")),
)
rate_limit_manager = RateLimitManager(rate_limit_config)
health_status = {
"status": "healthy",
"timestamp": datetime.now(timezone.utc).isoformat(),
}
cleanup_stats = cleanup_manager.get_stats()
rate_limit_stats = rate_limit_manager.get_stats()
tracked_files = cleanup_manager.get_tracked_files()
providers_status = {}
try:
from services.providers.google_provider import get_google_provider
google_health = get_google_provider().health_check()
providers_status["google"] = google_health.model_dump()
except Exception as e:
providers_status["google"] = {
"name": "google",
"available": False,
"error": str(e)[:100],
"last_check": None,
}
return {
"timestamp": health_status.get("timestamp"),
"status": health_status.get("status"),
"system": {"memory": {}, "disk": {}},
"providers": providers_status,
"cleanup": {**cleanup_stats, "tracked_files_count": len(tracked_files)},
"rate_limits": rate_limit_stats,
"config": {
"max_file_size_mb": config.MAX_FILE_SIZE_MB,
"supported_extensions": list(config.SUPPORTED_EXTENSIONS),
"translation_service": config.TRANSLATION_SERVICE,
},
}
@router.get("/users")
async def get_admin_users(is_admin: bool = Depends(require_admin)):
"""Get all users with their usage stats"""
from services.auth_service import USE_DATABASE, DATABASE_AVAILABLE, load_users
from database.connection import get_sync_session
from database.models import ApiKey
users_list = []
with get_sync_session() as session:
if USE_DATABASE and DATABASE_AVAILABLE:
from database.models import User as DBUser
db_users = session.query(DBUser).order_by(DBUser.created_at.desc()).all()
for db_user in db_users:
plan = db_user.plan or "free"
plan_info = PLANS.get(plan, PLANS["free"])
active_api_keys = (
session.query(ApiKey)
.filter(ApiKey.user_id == db_user.id, ApiKey.is_active == True)
.all()
)
users_list.append(
{
"id": str(db_user.id),
"email": db_user.email or "",
"name": db_user.name or "",
"plan": plan,
"subscription_status": db_user.subscription_status or "active",
"docs_translated_this_month": db_user.docs_translated_this_month or 0,
"pages_translated_this_month": db_user.pages_translated_this_month or 0,
"extra_credits": db_user.extra_credits or 0,
"created_at": db_user.created_at.isoformat() if db_user.created_at else "",
"plan_limits": {
"docs_per_month": plan_info.get("docs_per_month", 0),
"max_pages_per_doc": plan_info.get("max_pages_per_doc", 0),
},
"api_keys_count": len(active_api_keys),
"api_key_ids": [key.id for key in active_api_keys],
}
)
else:
users_data = load_users()
for user_id, user_data in users_data.items():
plan = user_data.get("plan", "free")
plan_info = PLANS.get(plan, PLANS["free"])
active_api_keys = (
session.query(ApiKey)
.filter(ApiKey.user_id == user_id, ApiKey.is_active == True)
.all()
)
users_list.append(
{
"id": user_id,
"email": user_data.get("email", ""),
"name": user_data.get("name", ""),
"plan": plan,
"subscription_status": user_data.get("subscription_status", "active"),
"docs_translated_this_month": user_data.get("docs_translated_this_month", 0),
"pages_translated_this_month": user_data.get("pages_translated_this_month", 0),
"extra_credits": user_data.get("extra_credits", 0),
"created_at": user_data.get("created_at", ""),
"plan_limits": {
"docs_per_month": plan_info.get("docs_per_month", 0),
"max_pages_per_doc": plan_info.get("max_pages_per_doc", 0),
},
"api_keys_count": len(active_api_keys),
"api_key_ids": [key.id for key in active_api_keys],
}
)
users_list.sort(key=lambda x: x.get("created_at", ""), reverse=True)
return {"total": len(users_list), "users": users_list}
@router.patch("/users/{user_id}")
async def patch_admin_user_tier(
user_id: str,
body: AdminUpdateUserTierRequest,
is_admin: bool = Depends(require_admin),
):
"""Update a user's plan/tier - Admin only"""
from services.auth_service import get_user_by_id, update_user_plan
user = get_user_by_id(user_id)
if not user:
return JSONResponse(
status_code=404,
content={"error": "NOT_FOUND", "message": "User not found"},
)
updated = update_user_plan(user_id, body.plan)
if not updated:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_PLAN",
"message": "Invalid plan. Allowed: free, starter, pro, business, enterprise",
"details": {
"allowed": ["free", "starter", "pro", "business", "enterprise"]
},
},
)
plan_value = (
updated.plan.value if hasattr(updated.plan, "value") else str(updated.plan)
)
new_tier = (
"pro"
if updated.plan in (PlanType.PRO, PlanType.BUSINESS, PlanType.ENTERPRISE)
else "free"
)
logger.info(
"admin_tier_change",
extra={
"event": "admin_tier_change",
"target_user_id": user_id,
"new_tier": new_tier,
"new_plan": plan_value,
"admin_id": "admin_session",
"timestamp": datetime.now(timezone.utc).isoformat(),
},
)
return {
"data": {
"id": updated.id,
"email": updated.email,
"name": getattr(updated, "name", ""),
"plan": plan_value,
"tier": new_tier,
},
"meta": {},
}
@router.get("/stats")
async def get_admin_stats(is_admin: bool = Depends(require_admin)):
"""Get comprehensive admin statistics"""
from services.auth_service import load_users
from services.translation_service import _translation_cache
users_data = load_users()
total_users = len(users_data)
plan_distribution = {}
total_docs_translated = 0
total_pages_translated = 0
active_users = 0
for user_data in users_data.values():
plan = user_data.get("plan", "free")
plan_distribution[plan] = plan_distribution.get(plan, 0) + 1
docs = user_data.get("docs_translated_this_month", 0)
pages = user_data.get("pages_translated_this_month", 0)
total_docs_translated += docs
total_pages_translated += pages
if docs > 0:
active_users += 1
cache_stats = _translation_cache.get_stats()
return {
"users": {
"total": total_users,
"active_this_month": active_users,
"by_plan": plan_distribution,
},
"translations": {
"docs_this_month": total_docs_translated,
"pages_this_month": total_pages_translated,
},
"cache": cache_stats,
"config": {
"translation_service": config.TRANSLATION_SERVICE,
"max_file_size_mb": config.MAX_FILE_SIZE_MB,
"supported_extensions": list(config.SUPPORTED_EXTENSIONS),
},
}
@router.post("/cleanup/trigger")
async def trigger_cleanup(is_admin: bool = Depends(require_admin)):
"""Trigger manual cleanup of expired files"""
from middleware.cleanup import create_cleanup_manager
cleanup_manager = create_cleanup_manager(config)
try:
cleaned = await cleanup_manager.cleanup_expired()
return {
"status": "success",
"files_cleaned": cleaned,
"message": f"Cleaned up {cleaned} expired files",
}
except Exception as e:
logger.error(f"Manual cleanup failed: {str(e)}")
raise HTTPException(status_code=500, detail=f"Cleanup failed: {str(e)}")
@router.get("/files/tracked")
async def get_tracked_files(is_admin: bool = Depends(require_admin)):
"""Get list of currently tracked files"""
from middleware.cleanup import create_cleanup_manager
cleanup_manager = create_cleanup_manager(config)
tracked = cleanup_manager.get_tracked_files()
return {"count": len(tracked), "files": tracked}
@router.post("/config/provider")
async def update_default_provider(
provider: str = Form(...),
is_admin: bool = Depends(require_admin),
):
"""Update the default translation provider"""
valid_providers = [
"google",
"deepl",
"openai",
"ollama",
"openrouter",
"zai",
"libre",
"classic",
"llm",
]
if provider not in valid_providers:
raise HTTPException(
status_code=400,
detail=f"Invalid provider. Must be one of: {valid_providers}",
)
config.TRANSLATION_SERVICE = provider
return {
"status": "success",
"message": f"Default provider updated to {provider}",
"provider": provider,
}
class AdminRevokeApiKeyRequest(BaseModel):
reason: Optional[str] = None
@router.delete("/api-keys/{key_id}")
async def admin_revoke_api_key(
key_id: str,
body: Optional[AdminRevokeApiKeyRequest] = None,
admin_id: str = Depends(require_admin),
):
"""Revoke any user's API key - Admin only"""
from database.connection import get_sync_session
from database.models import ApiKey
revoke_reason = body.reason if body else None
with get_sync_session() as session:
api_key = (
session.query(ApiKey)
.filter(ApiKey.id == key_id, ApiKey.is_active == True)
.first()
)
if not api_key:
return JSONResponse(
status_code=404,
content={
"error": "API_KEY_NOT_FOUND",
"message": "Clé API non trouvée ou déjà révoquée",
},
)
owner_user_id = api_key.user_id
api_key.is_active = False
api_key.revoked_at = datetime.now(timezone.utc)
session.commit()
logger.info(
"admin_api_key_revoked",
extra={
"admin_id": admin_id,
"key_id": key_id,
"owner_user_id": owner_user_id,
"reason": revoke_reason,
"timestamp": datetime.now(timezone.utc).isoformat(),
},
)
return JSONResponse(
status_code=200,
content={
"data": {
"id": api_key.id,
"revoked": True,
"revoked_at": datetime.now(timezone.utc).isoformat(),
"owner_user_id": owner_user_id,
"reason": revoke_reason,
},
"meta": {},
},
)
def _extract_error_code(error_message: Optional[str]) -> Optional[str]:
"""Extract a short error code from error message for log display (NFR: no document content)."""
if not error_message or not error_message.strip():
return None
import re
m = re.search(r"\b([A-Z][A-Z0-9_]{2,})\b", error_message)
if m:
return m.group(1)
first = error_message.strip().split()[0] if error_message.strip() else ""
if first:
return first.upper()[:20]
return None
@router.get("/logs")
def get_admin_logs(
is_admin: str = Depends(require_admin),
level: str = Query(default="all", pattern="^(all|error|warning|info)$"),
search: str = Query(default="", max_length=200),
page: int = Query(default=1, ge=1),
per_page: int = Query(default=50, ge=1, le=200),
):
"""Get admin error logs from failed translations. No document content or original_filename exposed (NFR11, NFR16).
Search matches user_id and error_message (error codes typically appear in error_message)."""
from database.connection import get_sync_session
from database.models import Translation
from sqlalchemy import or_, desc
if level == "warning" or level == "info":
return {
"data": {
"logs": [],
"total": 0,
"page": page,
"per_page": per_page,
},
"meta": {"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")},
}
with get_sync_session() as session:
base = session.query(Translation).filter(Translation.status == "failed")
if search and search.strip():
term = f"%{search.strip()}%"
base = base.filter(
or_(
Translation.user_id.ilike(term),
Translation.error_message.ilike(term),
)
)
total = base.count()
rows = (
base.order_by(desc(Translation.created_at))
.offset((page - 1) * per_page)
.limit(per_page)
.all()
)
def _ts(created_at):
if not created_at:
return ""
s = created_at.isoformat()
return s.replace("+00:00", "Z") if "+00:00" in s else s + "Z"
logs = [
{
"timestamp": _ts(t.created_at),
"level": "error",
"message": (t.error_message or "Translation failed").strip()[:500],
"user_id": t.user_id,
"error_code": _extract_error_code(t.error_message),
"provider": t.provider,
"file_type": t.file_type,
}
for t in rows
]
return {
"data": {
"logs": logs,
"total": total,
"page": page,
"per_page": per_page,
},
"meta": {"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")},
}
SETTINGS_FILE = "data/provider_settings.json"
class ProviderSettings(BaseModel):
enabled: bool = False
api_key: Optional[str] = None
base_url: Optional[str] = None
model: Optional[str] = None
timeout: int = 30
max_retries: int = 3
class SettingsConfig(BaseModel):
google: ProviderSettings = ProviderSettings(enabled=True)
deepl: ProviderSettings = ProviderSettings()
openai: ProviderSettings = ProviderSettings()
ollama: ProviderSettings = ProviderSettings() # dev-only in UI
openrouter: ProviderSettings = ProviderSettings() # "Traduction IA Essentielle"
openrouter_premium: ProviderSettings = ProviderSettings() # "Traduction IA Premium"
zai: ProviderSettings = ProviderSettings()
fallback_chain: str = "google,deepl,openai,ollama,openrouter,openrouter_premium,zai"
fallback_chain_classic: str = "google,deepl"
fallback_chain_llm: str = "openrouter,openrouter_premium,openai,zai,ollama"
def load_settings() -> SettingsConfig:
try:
import json
from pathlib import Path
settings_path = Path(SETTINGS_FILE)
if settings_path.exists():
with open(settings_path) as f:
data = json.load(f)
return SettingsConfig(**data)
except Exception as e:
logger.warning(f"Failed to load settings: {e}")
return SettingsConfig()
def save_settings(settings: SettingsConfig):
import json
from pathlib import Path
settings_path = Path(SETTINGS_FILE)
settings_path.parent.mkdir(exist_ok=True)
with open(settings_path, "w") as f:
json.dump(settings.model_dump(), f, indent=2)
@router.get("/settings")
async def get_settings(admin_id: str = Depends(require_admin)):
settings = load_settings()
# Merge env-var values into provider configs when JSON has no value.
# Env vars fill models/URLs; API keys are never exposed (only hinted via env_info).
# If an admin explicitly saves a value in the UI, JSON takes priority.
def _merge_env(
provider_settings: ProviderSettings,
key_env: str = "",
model_env: str = "",
url_env: str = "",
default_model: str = "",
default_url: str = "",
) -> dict:
d = provider_settings.model_dump()
# Model: env var > JSON null > code default
if model_env and not d.get("model"):
d["model"] = os.getenv(model_env, "").strip() or default_model or None
elif not d.get("model") and default_model:
d["model"] = default_model
# Base URL: env var > JSON null > code default
if url_env and not d.get("base_url"):
d["base_url"] = os.getenv(url_env, "").strip() or default_url or None
elif not d.get("base_url") and default_url:
d["base_url"] = default_url
# API key: never expose; leave empty (UI shows "clé dans .env" badge via env_info)
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")
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["zai"] = _merge_env(settings.zai, key_env="ZAI_API_KEY", model_env="ZAI_MODEL", url_env="ZAI_BASE_URL", default_model="grok-2-1212", default_url="https://api.x.ai/v1")
payload["ollama"] = _merge_env(settings.ollama, url_env="OLLAMA_BASE_URL", model_env="OLLAMA_MODEL", default_url="http://localhost:11434", default_model="llama3")
# Inform the frontend which providers have API keys configured via env vars
# (boolean only — never expose actual values)
has_openrouter = bool(os.getenv("OPENROUTER_API_KEY", "").strip())
env_info = {
"deepl": bool(os.getenv("DEEPL_API_KEY", "").strip()),
"openai": bool(os.getenv("OPENAI_API_KEY", "").strip()),
"openrouter": has_openrouter,
"openrouter_premium": has_openrouter, # same key, different model
"zai": bool(os.getenv("ZAI_API_KEY", "").strip()),
"ollama": bool(os.getenv("OLLAMA_BASE_URL", "").strip()),
}
return JSONResponse(
status_code=200,
content={"data": payload, "env_info": env_info, "meta": {}},
)
@router.put("/settings")
async def update_settings(
settings: SettingsConfig, admin_id: str = Depends(require_admin)
):
save_settings(settings)
logger.info(f"admin_settings_updated by {admin_id}")
return JSONResponse(
status_code=200, content={"data": settings.model_dump(), "meta": {}}
)
@router.post("/providers/{provider}/test")
async def test_provider(provider: str, admin_id: str = Depends(require_admin)):
"""Test a provider connection. Works even when provider is disabled.
Always falls back to env vars when the JSON api_key is empty."""
settings = load_settings()
provider_config = getattr(settings, provider, None)
if not provider_config:
return JSONResponse(
status_code=404,
content={
"error": "PROVIDER_NOT_FOUND",
"message": f"Provider {provider} not found",
},
)
# Helper: resolve api_key from JSON config first, then env var
def _key(json_val: Optional[str], env_var: str) -> str:
return (json_val or "").strip() or os.getenv(env_var, "").strip()
try:
if provider == "google":
from deep_translator import GoogleTranslator
result = GoogleTranslator(source="auto", target="en").translate("bonjour")
return JSONResponse(
status_code=200, content={"available": True, "test_result": result}
)
elif provider == "deepl":
api_key = _key(provider_config.api_key, "DEEPL_API_KEY")
if not api_key:
return JSONResponse(
status_code=400,
content={"available": False, "error": "Aucune clé API DeepL trouvée (JSON ou .env)"},
)
import deepl
translator = deepl.Translator(api_key)
usage = translator.get_usage()
return JSONResponse(
status_code=200, content={"available": True, "usage": str(usage)}
)
elif provider == "openai":
api_key = _key(provider_config.api_key, "OPENAI_API_KEY")
if not api_key:
return JSONResponse(
status_code=400,
content={"available": False, "error": "Aucune clé API OpenAI trouvée (JSON ou .env)"},
)
import openai as _openai
client = _openai.OpenAI(api_key=api_key)
models = list(client.models.list())
return JSONResponse(
status_code=200,
content={"available": True, "models_count": len(models)},
)
elif provider == "ollama":
import requests as _requests
base_url = (provider_config.base_url or "").strip() or os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
resp = _requests.get(f"{base_url}/api/tags", timeout=5)
if resp.ok:
return JSONResponse(
status_code=200,
content={
"available": True,
"models": resp.json().get("models", []),
},
)
return JSONResponse(
status_code=500, content={"available": False, "error": str(resp.text)}
)
elif provider in ("openrouter", "openrouter_premium"):
api_key = _key(provider_config.api_key, "OPENROUTER_API_KEY")
if not api_key:
# openrouter_premium shares the same key as openrouter
api_key = os.getenv("OPENROUTER_API_KEY", "").strip()
if not api_key:
return JSONResponse(
status_code=400,
content={"available": False, "error": "Aucune clé API OpenRouter trouvée (JSON ou .env)"},
)
import requests as _requests
resp = _requests.get(
"https://openrouter.ai/api/v1/auth/key",
headers={"Authorization": f"Bearer {api_key}"},
timeout=10,
)
if resp.ok:
data = resp.json()
return JSONResponse(
status_code=200,
content={"available": True, "label": data.get("data", {}).get("label", "OK")},
)
return JSONResponse(
status_code=500, content={"available": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
)
elif provider == "zai":
api_key = _key(provider_config.api_key, "ZAI_API_KEY")
if not api_key:
return JSONResponse(
status_code=400,
content={"available": False, "error": "Aucune clé API xAI trouvée (JSON ou .env)"},
)
import openai as _openai
base_url = (provider_config.base_url or "").strip() or os.getenv("ZAI_BASE_URL", "https://api.x.ai/v1")
client = _openai.OpenAI(api_key=api_key, base_url=base_url)
try:
models = list(client.models.list())
return JSONResponse(
status_code=200,
content={
"available": True,
"models_count": len(models),
"sample_models": [m.id for m in models[:5]],
},
)
except _openai.AuthenticationError:
return JSONResponse(
status_code=401,
content={"available": False, "error": "Clé xAI invalide"},
)
else:
return JSONResponse(
status_code=404,
content={"available": False, "error": "Provider inconnu"},
)
except Exception as e:
logger.error(f"Provider test failed: {e}")
return JSONResponse(
status_code=500, content={"available": False, "error": str(e)}
)
@router.get("/providers/ollama/models")
async def list_ollama_models(admin_id: str = Depends(require_admin)):
"""List available models from Ollama server"""
import requests
from config import config as app_config
settings = load_settings()
base_url = (
settings.ollama.base_url
or app_config.OLLAMA_BASE_URL
or "http://localhost:11434"
)
try:
response = requests.get(f"{base_url}/api/tags", timeout=5)
if response.ok:
data = response.json()
models = []
for model in data.get("models", []):
models.append(
{
"name": model.get("name", ""),
"size": model.get("size", 0),
"modified_at": model.get("modified_at", ""),
}
)
return JSONResponse(status_code=200, content={"data": models, "meta": {}})
return JSONResponse(
status_code=500,
content={
"error": "OLLAMA_UNAVAILABLE",
"message": f"Ollama returned: {response.text}",
},
)
except requests.exceptions.ConnectionError:
return JSONResponse(
status_code=503,
content={
"error": "OLLAMA_CONNECTION_ERROR",
"message": f"Cannot connect to Ollama at {base_url}",
},
)
except Exception as e:
logger.error(f"List Ollama models failed: {e}")
return JSONResponse(
status_code=500,
content={"error": "INTERNAL_ERROR", "message": str(e)},
)