from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy.orm import Session import httpx from app.config import settings from app.database import get_db from app.models.user import User from app.models.ai_model_config import AIModelConfig from app.schemas.auth import UserResponse, UserUpdateRole, UserCreate from app.schemas.admin import AIModelConfigCreate, AIModelConfigResponse, AIModelConfigUpdate from app.utils.auth import require_admin, get_current_user, get_password_hash router = APIRouter() # --- User Management --- @router.get("/users", response_model=list[UserResponse]) def list_users( db: Session = Depends(get_db), admin: User = Depends(require_admin), ): return db.query(User).order_by(User.created_at.desc()).all() @router.put("/users/{user_id}/role", response_model=UserResponse) def update_user_role( user_id: int, role_data: UserUpdateRole, db: Session = Depends(get_db), admin: User = Depends(require_admin), ): if role_data.role not in ("admin", "moderator", "user"): raise HTTPException(status_code=400, detail="Role must be admin, moderator, or user") user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") if user.id == admin.id: raise HTTPException(status_code=400, detail="Cannot change your own role") user.role = role_data.role db.commit() db.refresh(user) return user @router.put("/users/{user_id}/unthrottle", response_model=UserResponse) def set_user_unthrottle( user_id: int, data: dict, db: Session = Depends(get_db), admin: User = Depends(require_admin), ): """Set or clear the unthrottle flag for a user — exempt from AI/TTS rate limits.""" user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") user.is_unthrottled = 1 if data.get("unthrottled") else 0 db.commit() db.refresh(user) return user @router.delete("/users/{user_id}", status_code=204) def delete_user( user_id: int, db: Session = Depends(get_db), admin: User = Depends(require_admin), ): """Delete a user and all their data (attempts, favorites, settings).""" user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") if user.id == admin.id: raise HTTPException(status_code=400, detail="Cannot delete yourself") # Nullify non-cascading FKs before delete from sqlalchemy import text db.execute(text("UPDATE question_categories SET user_id = NULL WHERE user_id = :uid"), {"uid": user_id}) db.execute(text("UPDATE quiz_categories SET user_id = NULL WHERE user_id = :uid"), {"uid": user_id}) db.delete(user) db.commit() @router.post("/users", response_model=UserResponse) def create_user( user_data: UserCreate, db: Session = Depends(get_db), admin: User = Depends(require_admin), ): """Admin creates a user directly — email is auto-verified.""" from app.models.email_verification import EmailVerification from datetime import datetime email_normalized = user_data.email.lower().strip() if db.query(User).filter(User.email == email_normalized).first(): raise HTTPException(status_code=400, detail="Email already registered") user = User( email=email_normalized, hashed_password=get_password_hash(user_data.password), name=user_data.name, role="user", ) db.add(user) db.flush() db.add(EmailVerification( user_id=user.id, token=f"admin_created_{user.id}", expires_at=datetime.utcnow(), verified_at=datetime.utcnow(), )) db.commit() db.refresh(user) return user # --- AI Model Configuration --- @router.get("/models/available") def list_available_models( task: str = Query("extraction"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Returns active models for a given task — for users to choose when taking/creating a quiz.""" models = db.query(AIModelConfig).filter( AIModelConfig.task == task, AIModelConfig.is_active == True, ).order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all() result = [{"id": m.id, "name": m.name, "model_id": m.model_id, "is_default": m.is_default} for m in models] # Always include env default as fallback if nothing configured if not result: result.append({"id": None, "name": "Default (from config)", "model_id": settings.LITELLM_MODEL, "is_default": True}) return result class LiteLLMSearchRequest(BaseModel): api_key: str | None = None api_base: str | None = None mode: str | None = None @router.post("/litellm/models") def search_litellm_models( data: LiteLLMSearchRequest, admin: User = Depends(require_admin), ): """Query available models from LiteLLM proxy or OpenAI-compatible API.""" import logging log = logging.getLogger(__name__) base = (data.api_base or settings.LITELLM_API_BASE or "").rstrip("/") key = data.api_key or settings.LITELLM_API_KEY if base: try: headers = {"Authorization": f"Bearer {key}"} if key else {} if data.mode: info_base = base.rstrip("/").removesuffix("/v1") resp = httpx.get(f"{info_base}/model/info", headers=headers, timeout=10) resp.raise_for_status() models = sorted([ m.get("model_name") for m in resp.json().get("data", []) if m.get("model_name") and (m.get("model_info") or {}).get("mode") == data.mode ]) return {"models": models, "source": info_base, "mode": data.mode} resp = httpx.get(f"{base}/v1/models", headers=headers, timeout=10) resp.raise_for_status() models = sorted([m["id"] for m in resp.json().get("data", [])]) return {"models": models, "source": base} except Exception as e: log.warning(f"LiteLLM model search failed: {e}") raise HTTPException(status_code=400, detail=f"Failed to query models API: {e}") try: import litellm models = sorted(litellm.utils.get_valid_models()) return {"models": models, "source": "litellm-builtin"} except Exception as e: log.warning(f"LiteLLM builtin model list failed: {e}") raise HTTPException(status_code=500, detail="Failed to retrieve LiteLLM built-in model list.") @router.get("/models", response_model=list[AIModelConfigResponse]) def list_models( db: Session = Depends(get_db), admin: User = Depends(require_admin), ): return db.query(AIModelConfig).order_by(AIModelConfig.task, AIModelConfig.name).all() @router.post("/models", response_model=AIModelConfigResponse) def create_model( data: AIModelConfigCreate, db: Session = Depends(get_db), admin: User = Depends(require_admin), ): valid_tasks = ("extraction", "tts", "stt", "teach", "keyword", "flashcard") if data.task not in valid_tasks: raise HTTPException(status_code=400, detail=f"Task must be one of: {', '.join(valid_tasks)}") if data.is_default: db.query(AIModelConfig).filter( AIModelConfig.task == data.task, AIModelConfig.is_default == True, ).update({"is_default": False}) # Auto-set as default if this is the first model for the task existing = db.query(AIModelConfig).filter( AIModelConfig.task == data.task, AIModelConfig.is_active == True, ).count() if existing == 0: data_dict = data.model_dump() data_dict["is_default"] = True else: data_dict = data.model_dump() model = AIModelConfig(**data_dict) db.add(model) try: db.commit() except Exception as e: db.rollback() if "uq_model_task" in str(e).lower() or "unique" in str(e).lower(): raise HTTPException(status_code=409, detail=f"Model '{data.model_id}' already exists for task '{data.task}'") raise HTTPException(status_code=500, detail=str(e)) db.refresh(model) return model @router.put("/models/{model_id}", response_model=AIModelConfigResponse) def update_model( model_id: int, data: AIModelConfigUpdate, db: Session = Depends(get_db), admin: User = Depends(require_admin), ): model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first() if not model: raise HTTPException(status_code=404, detail="Model config not found") update_data = data.model_dump(exclude_unset=True) task = update_data.get("task", model.task) if update_data.get("is_default"): db.query(AIModelConfig).filter( AIModelConfig.task == task, AIModelConfig.is_default == True, AIModelConfig.id != model_id, ).update({"is_default": False}) for key, value in update_data.items(): setattr(model, key, value) db.commit() db.refresh(model) return model @router.delete("/models/{model_id}", status_code=204) def delete_model( model_id: int, db: Session = Depends(get_db), admin: User = Depends(require_admin), ): model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first() if not model: raise HTTPException(status_code=404, detail="Model config not found") db.delete(model) db.commit() @router.post("/models/{model_id}/test") def test_model( model_id: int, db: Session = Depends(get_db), admin: User = Depends(require_admin), ): """Send a simple test completion to verify an LLM model is reachable. TTS models are previewed via /tts/speak instead.""" model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first() if not model: raise HTTPException(status_code=404, detail="Model config not found") if model.task == "tts": raise HTTPException(status_code=400, detail="Use the Preview button to test TTS voices — it plays audio directly.") if model.task == "stt": base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1") key = model.api_key or settings.LITELLM_API_KEY if not base: raise HTTPException(status_code=400, detail="LiteLLM API base is not configured") try: headers = {"Authorization": f"Bearer {key}"} if key else {} resp = httpx.get(f"{base}/model/info", headers=headers, timeout=10) resp.raise_for_status() found = any( m.get("model_name") == model.model_id and (m.get("model_info") or {}).get("mode") == "audio_transcription" for m in resp.json().get("data", []) ) if not found: raise HTTPException(status_code=404, detail=f"{model.model_id} was not found as an audio_transcription model in LiteLLM") return {"message": f"✓ {model.model_id} is available for speech transcription"} except HTTPException: raise except Exception as e: raise HTTPException(status_code=502, detail=str(e)) try: import litellm from app.services.ai_service import _proxy_model use_model = _proxy_model(model.model_id) kwargs = { "model": use_model, "messages": [{"role": "user", "content": "Reply with only the word: OK"}], "max_tokens": 10, } if model.api_key: kwargs["api_key"] = model.api_key elif settings.LITELLM_API_KEY: kwargs["api_key"] = settings.LITELLM_API_KEY if settings.LITELLM_API_BASE: kwargs["api_base"] = settings.LITELLM_API_BASE response = litellm.completion(**kwargs) reply = response.choices[0].message.content.strip() return {"message": f"✓ {model.model_id} → {reply!r}"} except Exception as e: raise HTTPException(status_code=502, detail=str(e)) class TTSVoiceSearchRequest(BaseModel): provider: str api_key: str | None = None region: str | None = None KOKORO_VOICE_FALLBACKS = [ ("am_adam", "Kokoro Adam"), ("am_michael", "Kokoro Michael"), ("af_bella", "Kokoro Bella"), ("af_nicole", "Kokoro Nicole"), ("bf_emma", "Kokoro Emma"), ("bm_lewis", "Kokoro Lewis"), ] def _kokoro_voice_options(model_name: str) -> list[dict]: base = settings.LOCAL_SPEECH_GATEWAY_URL.rstrip("/") voices = [] friendly_names = {voice_id: name for voice_id, name in KOKORO_VOICE_FALLBACKS} if base: try: resp = httpx.get(f"{base}/v1/audio/voices", timeout=10) resp.raise_for_status() voices = [ v for v in resp.json().get("voices", []) if v.get("profile") == "kokoro" and v.get("voice") ] except Exception: voices = [] if not voices: voices = [ {"voice": voice_id, "name": name, "profile": "kokoro"} for voice_id, name in KOKORO_VOICE_FALLBACKS ] return [ { "model_id": f"{model_name}:{v['voice']}", "name": friendly_names.get(v["voice"], v.get("name") or f"Kokoro {v['voice']}"), "labels": {"provider": "litellm", "model": model_name, "voice": v["voice"]}, } for v in voices ] @router.post("/tts/voices") def search_tts_voices( data: TTSVoiceSearchRequest, admin: User = Depends(require_admin), ): """Discover local TTS voices/models from LiteLLM or the local speech gateway.""" import logging log = logging.getLogger(__name__) provider = data.provider api_key = data.api_key region = data.region if provider != "litellm": raise HTTPException(status_code=400, detail="TTS discovery is routed through LiteLLM only") if provider == "litellm": base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1") key = api_key or settings.LITELLM_API_KEY if not base: raise HTTPException(status_code=400, detail="LiteLLM API base is not configured") try: headers = {"Authorization": f"Bearer {key}"} if key else {} resp = httpx.get(f"{base}/model/info", headers=headers, timeout=10) resp.raise_for_status() models = resp.json().get("data", []) voices = [] for m in models: model_name = m.get("model_name") if not model_name or (m.get("model_info") or {}).get("mode") != "audio_speech": continue if model_name == "local-kokoro-tts": voices.extend(_kokoro_voice_options(model_name)) continue voices.append({ "model_id": model_name, "name": model_name, "labels": {"provider": "litellm", "mode": "audio_speech"}, }) return {"voices": voices} except HTTPException: raise except Exception as e: log.warning(f"LiteLLM local TTS discovery failed: {e}") raise HTTPException(status_code=400, detail=f"LiteLLM TTS discovery error: {e}") raise HTTPException(status_code=400, detail=f"Unknown provider '{provider}'. Valid: litellm") # --- System Settings --- @router.get("/settings") def get_settings(admin: User = Depends(require_admin)): """Get system settings.""" try: import redis as redis_lib r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) registration_enabled = r.get("settings:registration_enabled") embedding_model = r.get("settings:embedding_model") sso_only = r.get("settings:sso_only") return { "registration_enabled": registration_enabled != "false", "embedding_model": embedding_model or settings.LITELLM_EMBEDDING_MODEL or "", "sso_only": sso_only == "true", "sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID), "sso_provider_name": settings.OIDC_PROVIDER_NAME, } except Exception: return { "registration_enabled": True, "embedding_model": settings.LITELLM_EMBEDDING_MODEL or "", "sso_only": False, "sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID), "sso_provider_name": settings.OIDC_PROVIDER_NAME, } @router.put("/settings") def update_settings( settings_data: dict, admin: User = Depends(require_admin), ): """Update system settings.""" try: import redis as redis_lib r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) if "registration_enabled" in settings_data: value = "true" if settings_data["registration_enabled"] else "false" r.set("settings:registration_enabled", value) if "embedding_model" in settings_data: r.set("settings:embedding_model", settings_data["embedding_model"]) if "sso_only" in settings_data: value = "true" if settings_data["sso_only"] else "false" r.set("settings:sso_only", value) return {"success": True, "message": "Settings updated"} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to update settings: {str(e)}") @router.post("/embedding/test") def test_embedding(admin: User = Depends(require_admin)): """Test the currently configured embedding model.""" from app.services.embedding_service import generate_embedding, _get_embedding_model model = _get_embedding_model() if not model: raise HTTPException(status_code=400, detail="No embedding model configured") result = generate_embedding("The quick brown fox jumps over the lazy dog") if result is None: raise HTTPException(status_code=500, detail=f"Embedding failed for model: {model}") return {"model": model, "dimensions": len(result), "status": "ok"} @router.post("/embedding/regenerate") def regenerate_embeddings(admin: User = Depends(require_admin)): """Queue a background Celery task to regenerate all question embeddings.""" import uuid from app.tasks.quiz_tasks import regenerate_embeddings as regen_task job_id = str(uuid.uuid4()) regen_task.delay(job_id, admin.id) return {"job_id": job_id, "message": "Regeneration started — progress visible in the Jobs badge."}