"""Local Docker container management and URL probe endpoints.""" from __future__ import annotations import os from pathlib import Path from urllib.parse import quote import requests from fastapi import APIRouter, HTTPException from core.docker_client import _docker_get_json, _docker_post from core.validation import _normalize_service_url router = APIRouter() _LOCAL_CONTAINER_DEFS: list[dict] = [ {"name": "litellm", "label": "LiteLLM Gateway", "role": "llm", "port": 14000, "stack": "dgx-spark_lite-llm_llama-swap_vllm_llama-cpp_ollama", "image": "ghcr.io/berriai/litellm:main-latest", "repo": "https://github.com/BerriAI/litellm", "description": "OpenAI-compatible LLM gateway. Use for character analysis, casting, and text refinement."}, {"name": "llama.cpp", "label": "llama.cpp Server", "role": "llm", "port": 19000, "stack": "dgx-spark_lite-llm_llama-swap_vllm_llama-cpp_ollama", "image": "ghcr.io/ggml-org/llama.cpp:server", "repo": "https://github.com/ggml-org/llama.cpp", "description": "OpenAI-compatible local LLM server when launched with its server API."}, {"name": "ollama", "label": "Ollama", "role": "llm", "port": 11434, "stack": "dgx-spark_lite-llm_llama-swap_vllm_llama-cpp_ollama", "image": "ollama/ollama", "repo": "https://ollama.com", "description": "Local LLM runtime. The app uses its OpenAI-compatible /v1 endpoint."}, {"name": "llama-swap", "label": "llama-swap", "role": "llm", "port": 28080, "stack": "dgx-spark_lite-llm_llama-swap_vllm_llama-cpp_ollama", "image": "ghcr.io/mostlygeek/llama-swap:cuda", "repo": "https://github.com/mostlygeek/llama-swap", "description": "Model swapping proxy for local LLMs. Useful as the default Rehearser LLM endpoint."}, {"name": "llama-qwen35-4b", "label": "Qwen3.5 4B LLM", "role": "llm", "port": 19001, "stack": "dgx-spark_lite-llm_llama-swap_vllm_llama-cpp_ollama", "image": "local", "repo": "", "description": "Dedicated Qwen LLM container. Use if it exposes an OpenAI-compatible /v1 API."}, {"name": "faster-qwen3-tts-voiceclone", "label": "Qwen3 TTS · Voice Clone", "role": "tts", "port": 8020, "stack": "faster-qwen3-tts-dgx-spark", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "WAV voice cloning. Scans active_voices at startup; restart after adding or editing voices."}, {"name": "faster-qwen3-tts-voicedesign", "label": "Qwen3 TTS · Voice Design", "role": "tts", "port": 8021, "stack": "faster-qwen3-tts-dgx-spark", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Instruction-based voice design. Best Qwen path for tone, style, and character prompts."}, {"name": "faster-qwen3-tts-customvoice", "label": "Qwen3 TTS · Custom Voice", "role": "tts", "port": 8022, "stack": "faster-qwen3-tts-dgx-spark", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "CustomVoice speakers with per-request instruct overrides for tone/style."}, {"name": "faster-qwen3-tts-streaming", "label": "Qwen3 TTS · Streaming", "role": "tts", "port": 8023, "stack": "faster-qwen3-tts-dgx-spark", "image": "martinb78/faster-qwen3-tts-dgx-spark:streaming", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Low-latency progressive WAV streaming for voice clone voices."}, {"name": "faster-whisper", "label": "faster-whisper", "role": "stt", "port": 8010, "stack": "faster-whisper-dgx-spark", "image": "local", "repo": "https://github.com/SYSTRAN/faster-whisper", "description": "Fast local Whisper transcription endpoint."}, {"name": "fish-speech-api", "label": "Fish-Speech API", "role": "tts", "port": 38080, "stack": "fish-speech-dgx-spark", "image": "local", "repo": "https://github.com/fishaudio/fish-speech", "description": "Tone-aware cloning backend. Recommended for Rehearser character voices when you need both identity and emotion."}, {"name": "nemotron-asr-streaming", "label": "Nemotron ASR Streaming", "role": "stt", "port": 8006, "stack": "nemotron-asr-streaming", "image": "local", "repo": "https://github.com/pipecat-ai/nemotron-january-2026", "description": "Streaming ASR service. Use as a custom STT endpoint if it exposes OpenAI transcription or /transcribe."}, {"name": "parakeet-stt-multi-9011", "label": "NVIDIA Parakeet STT", "role": "stt", "port": 8007, "stack": "nvidia-parakeet-voice-recognition", "image": "local", "repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "Parakeet STT container on port 8007."}, {"name": "nvidia-magpie-tts", "label": "NVIDIA Magpie TTS", "role": "tts", "port": 8091, "stack": "nvidia-speech-gateway", "image": "nvcr.io/nim/nvidia/magpie-tts-multilingual:latest", "repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "NVIDIA neural TTS. Fixed speaker voices, fast multilingual synthesis."}, {"name": "nvidia-parakeet-asr", "label": "NVIDIA Parakeet Router ASR", "role": "stt", "port": 8090, "stack": "nvidia-speech-gateway", "image": "parakeet-tdt-v3-spark:latest", "repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "NVIDIA Parakeet speech router endpoint."}, {"name": "nvidia-parakeet-rnnt-nim", "label": "NVIDIA Parakeet RNNT NIM", "role": "stt", "port": 8092, "stack": "nvidia-speech-gateway", "image": "nvcr.io/nim/nvidia/parakeet-1b-rnnt-multilingual:latest", "repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "Parakeet RNNT NIM multilingual ASR on port 8092."}, {"name": "vibevoice-msft-tts", "label": "VibeVoice TTS", "role": "tts", "port": 8027, "stack": "vibevoice", "image": "local", "repo": "", "description": "VibeVoice REST service. Simple text-to-speech backend."}, {"name": "voicebox", "label": "Voicebox", "role": "llm", "port": 17493, "stack": "voicebox", "image": "local", "repo": "", "description": "Companion voice app/control plane. Not used as a normal TTS engine unless wrapped as an API."}, {"name": "whisperx-blackwell", "label": "WhisperX Blackwell", "role": "stt", "port": 8003, "stack": "whisperx-blackwell", "image": "local", "repo": "https://github.com/m-bain/whisperX", "description": "WhisperX transcription/alignment service. Use as custom STT if its API is OpenAI-compatible or /transcribe."}, {"name": "xtts-dgx-spark", "label": "XTTS v2", "role": "tts", "port": 8005, "stack": "xtts_v2", "image": "local", "repo": "https://github.com/daswer123/xtts-api-server", "description": "XTTS v2 OpenAI-compatible speaker endpoint."}, ] def _container_status(name: str) -> dict: try: code, data = _docker_get_json(f"/containers/{quote(name, safe='')}/json") if code == 404: return {"status": "not_found"} if code == 200 and isinstance(data, dict): state = data.get("State", {}) return { "status": state.get("Status", "unknown"), "running": state.get("Running", False), "paused": state.get("Paused", False), "started_at": state.get("StartedAt", ""), "image": data.get("Config", {}).get("Image", ""), } return {"status": "error", "detail": f"HTTP {code}"} except Exception as e: return {"status": "error", "detail": str(e)} @router.get("/api/local-containers") async def get_local_containers(): sock_ok = Path(os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")).exists() results = [] for defn in _LOCAL_CONTAINER_DEFS: entry = {k: v for k, v in defn.items()} if sock_ok: entry.update(_container_status(defn["name"])) else: entry["status"] = "no_socket" results.append(entry) return {"containers": results, "socket_available": sock_ok} @router.post("/api/local-containers/{name}/start") async def start_local_container(name: str): if not any(c["name"] == name for c in _LOCAL_CONTAINER_DEFS): raise HTTPException(404, f"Unknown container: {name}") try: code, _ = _docker_post(f"/containers/{quote(name, safe='')}/start") except Exception as e: raise HTTPException(502, f"Docker start failed: {e}") if code not in (204, 304): raise HTTPException(502, f"Docker API returned HTTP {code}") return {"ok": True, "name": name, **_container_status(name)} @router.post("/api/local-containers/{name}/stop") async def stop_local_container(name: str): if not any(c["name"] == name for c in _LOCAL_CONTAINER_DEFS): raise HTTPException(404, f"Unknown container: {name}") try: code, _ = _docker_post(f"/containers/{quote(name, safe='')}/stop?t=10") except Exception as e: raise HTTPException(502, f"Docker stop failed: {e}") if code not in (204, 304): raise HTTPException(502, f"Docker API returned HTTP {code}") return {"ok": True, "name": name, **_container_status(name)} @router.post("/api/local-containers/{name}/restart") async def restart_local_container(name: str): if not any(c["name"] == name for c in _LOCAL_CONTAINER_DEFS): raise HTTPException(404, f"Unknown container: {name}") try: code, _ = _docker_post(f"/containers/{quote(name, safe='')}/restart?t=10") except Exception as e: raise HTTPException(502, f"Docker restart failed: {e}") if code not in (204, 304): raise HTTPException(502, f"Docker API returned HTTP {code}") return {"ok": True, "name": name, **_container_status(name)} @router.get("/api/probe-url") async def probe_url(url: str, type: str = ""): """Server-side API probe — checks service-specific endpoints and validates JSON responses.""" base = _normalize_service_url(url).rstrip("/") for _suffix in ("/v1", "/api/v1"): if base.endswith(_suffix): base = base[: -len(_suffix)] break hdrs = {"User-Agent": "TTS-Voice-Creator/probe"} if type == "llm": checks = [("/v1/models", "data"), ("/api/tags", "models"), ("/api/version", None)] elif type == "stt": checks = [("/health", None), ("/v1/models", "data"), ("/v1/audio/transcriptions", None)] elif type == "tts": checks = [("/health", None), ("/v1/health", None), ("/v1/audio/voices", None), ("/speakers", None), ("/voices", None)] else: checks = [("", None)] last_err = "No response" for path, json_key in checks: try: r = requests.get(base + path, timeout=5, headers=hdrs, allow_redirects=True) if r.status_code >= 500: last_err = f"HTTP {r.status_code} on {path or '/'}" continue if r.status_code >= 400 and path: continue if json_key: try: data = r.json() if json_key not in data: last_err = f"Unexpected response from {path} (missing '{json_key}')" continue except Exception: last_err = f"{path} returned non-JSON (HTTP {r.status_code})" continue return {"ok": True, "status": r.status_code, "endpoint": path or "/"} except requests.exceptions.ConnectionError: return {"ok": False, "error": "Connection refused"} except requests.exceptions.Timeout: return {"ok": False, "error": "Timeout (5 s)"} except Exception as e: last_err = str(e) return {"ok": False, "error": last_err}