Security:
- admin.py: move api_key from URL query params to POST body (litellm/models, tts/voices) — prevents key logging
- admin.py: sanitize exception messages in voice discovery — log internally, return generic errors
- teach.py: log LLM errors server-side, show friendly message to user
- nextcloud.py: normalize path with posixpath.normpath to prevent ../ traversal
- auth.py: check_rate_limit now accepts user param — admins/moderators/unthrottled always exempt
Performance:
- teach.py: make /chat endpoint async, use litellm.acompletion() — no longer blocks a uvicorn thread per request
Features:
- users: add is_unthrottled column (DB migration in setup_pgvector)
- admin.py: PUT /users/{id}/unthrottle endpoint
- AdminPage.jsx: Unlimited/Throttle toggle button per user, shows "unlimited" badge
- Rate limit messages improved with user-friendly context
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
86 lines
3.2 KiB
Python
86 lines
3.2 KiB
Python
from fastapi import APIRouter, Depends, HTTPException
|
|
from fastapi.responses import Response
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.orm import Session
|
|
|
|
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.services import ai_service
|
|
from app.utils.auth import get_current_user, check_rate_limit
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class TTSRequest(BaseModel):
|
|
text: str
|
|
voice: str | None = None # model_id override
|
|
|
|
|
|
def _polly_enabled() -> bool:
|
|
try:
|
|
import redis as redis_lib
|
|
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
|
val = r.get("settings:polly_enabled")
|
|
return val != "false"
|
|
except Exception:
|
|
return True
|
|
|
|
|
|
@router.get("/voices")
|
|
def get_voices(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Return available TTS voices from DB, excluding Polly if disabled."""
|
|
query = db.query(AIModelConfig).filter(
|
|
AIModelConfig.task == "tts",
|
|
AIModelConfig.is_active == True,
|
|
)
|
|
if not _polly_enabled():
|
|
query = query.filter(~AIModelConfig.model_id.like("polly/%"))
|
|
db_models = query.order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all()
|
|
return [{"id": m.model_id, "name": m.name, "is_default": m.is_default} for m in db_models]
|
|
|
|
|
|
@router.post("/speak")
|
|
def text_to_speech(
|
|
request: TTSRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Convert text to speech using configured or user-selected TTS model."""
|
|
# Rate limit: 60 TTS requests per user per hour (admins/unthrottled users exempt)
|
|
check_rate_limit(
|
|
key=f"tts_speak:{current_user.id}",
|
|
max_calls=60,
|
|
window_seconds=3600,
|
|
detail="You've reached the audio limit (60 clips/hour). The limit resets automatically — try again shortly. Contact an admin if you need this raised.",
|
|
user=current_user,
|
|
)
|
|
if not request.text.strip():
|
|
raise HTTPException(status_code=400, detail="Text cannot be empty")
|
|
|
|
text = request.text[:2000]
|
|
|
|
if request.voice:
|
|
if request.voice.startswith("polly/") and not _polly_enabled():
|
|
raise HTTPException(status_code=400, detail="AWS Polly is currently disabled")
|
|
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == request.voice).first()
|
|
model_id = config.model_id if config else request.voice
|
|
api_key = config.api_key if (config and config.api_key) else None
|
|
else:
|
|
config = db.query(AIModelConfig).filter(
|
|
AIModelConfig.task == "tts",
|
|
AIModelConfig.is_active == True,
|
|
AIModelConfig.is_default == True,
|
|
).first()
|
|
model_id = config.model_id if config else "tts-1:alloy"
|
|
api_key = config.api_key if (config and config.api_key) else None
|
|
|
|
audio = ai_service.generate_tts_audio(text, model_id=model_id, api_key=api_key)
|
|
if audio is None:
|
|
raise HTTPException(status_code=500, detail="TTS generation failed. Check model configuration.")
|
|
|
|
return Response(content=audio, media_type="audio/mpeg")
|