501 lines
18 KiB
Python
501 lines
18 KiB
Python
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."}
|