from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi.responses import Response from pydantic import BaseModel from sqlalchemy.orm import Session 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 MAX_AUDIO_UPLOAD_BYTES = 25 * 1024 * 1024 def _task_model(db: Session, task: str, fallback: str) -> tuple[str, str | None]: config = db.query(AIModelConfig).filter( AIModelConfig.task == task, AIModelConfig.is_active == True, AIModelConfig.is_default == True, ).first() if config: return config.model_id, config.api_key or None return fallback, None def _default_tts_model(db: Session) -> tuple[str, str | None]: config = db.query(AIModelConfig).filter( AIModelConfig.task == "tts", AIModelConfig.is_active == True, AIModelConfig.is_default == True, AIModelConfig.model_id.like("local-%"), ).first() if config: return config.model_id, config.api_key or None return "local-kokoro-tts:am_adam", None @router.get("/voices") def get_voices( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Return LiteLLM-routed TTS models only.""" query = db.query(AIModelConfig).filter( AIModelConfig.task == "tts", AIModelConfig.is_active == True, AIModelConfig.model_id.like("local-%"), ) 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=240, window_seconds=3600, detail="You've reached the audio limit. 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 and request.voice.startswith("local-"): config = db.query(AIModelConfig).filter( AIModelConfig.task == "tts", AIModelConfig.is_active == True, AIModelConfig.model_id == request.voice, ).first() if not config: model_id, api_key = _default_tts_model(db) else: model_id = config.model_id api_key = config.api_key or None else: model_id, api_key = _default_tts_model(db) 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") @router.post("/transcribe") def speech_to_text( file: UploadFile = File(...), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Transcribe microphone audio using the configured STT model.""" check_rate_limit( key=f"stt_transcribe:{current_user.id}", max_calls=120, window_seconds=3600, detail="You've reached the speech transcription limit. Try again shortly.", user=current_user, ) audio = file.file.read() if not audio: raise HTTPException(status_code=400, detail="Audio file is empty") if len(audio) > MAX_AUDIO_UPLOAD_BYTES: raise HTTPException(status_code=413, detail="Audio file is too large") model_id, api_key = _task_model(db, "stt", "local-parakeet-v3") text = ai_service.transcribe_audio( audio, filename=file.filename or "audio.webm", content_type=file.content_type or "audio/webm", model_id=model_id, api_key=api_key, ) if text is None: raise HTTPException(status_code=502, detail="Speech transcription failed. Check STT model configuration.") return {"text": text, "model": model_id}