"""Local Docker container management and URL probe endpoints.""" from __future__ import annotations import asyncio import os from pathlib import Path from urllib.parse import quote, urlsplit import requests from fastapi import APIRouter, Header, HTTPException, Request from core.docker_client import _docker_available, _docker_get_json, _docker_post from core.config import _load_settings, _save_settings, _normalize_settings, _tts_api_key, _engine_card_api_key from core.validation import _normalize_service_url from core.provider_health import probe_provider, _litellm_model_aliases 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": "vllm", "label": "vLLM", "role": "llm", "port": 8000, "stack": "dgx-spark_lite-llm_llama-swap_vllm_llama-cpp_ollama", "image": "vllm/vllm-openai:latest", "repo": "https://github.com/vllm-project/vllm", "description": "High-throughput GPU inference. Serve any HuggingFace model with PagedAttention."}, {"name": "lmstudio", "label": "LM Studio", "role": "llm", "port": 1234, "stack": "", "image": "local", "repo": "https://lmstudio.ai", "description": "GUI app for running quantized models. Built-in model browser, chat UI, and a local server with OpenAI API."}, {"name": "tts-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": "tts-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": "tts-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": "tts-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": "whisper-cpp", "label": "whisper.cpp", "role": "stt", "port": 8085, "stack": "", "image": "local", "repo": "https://github.com/ggml-org/whisper.cpp", "description": "Minimal C++ Whisper with a built-in HTTP server. CPU or Metal/CUDA. Low memory, fast on consumer hardware."}, {"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": "kokoro-tts", "label": "Kokoro FastAPI", "role": "tts", "port": 8880, "stack": "kokoro-tts", "image": "local", "repo": "", "description": "OpenAI-compatible TTS. Built-in voices: af_bella, bf_emma, am_adam... ~300 MB RAM."}, {"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": "nvidia-zeroshot-nim", "label": "NVIDIA Zeroshot NIM", "role": "tts", "port": 8093, "stack": "nvidia-speech-gateway", "image": "local", "repo": "", "description": "Magpie Zeroshot clone endpoint with audio_prompt."}, {"name": "nvidia-flow-nim", "label": "NVIDIA Flow NIM", "role": "tts", "port": 8094, "stack": "nvidia-speech-gateway", "image": "local", "repo": "", "description": "Magpie Flow clone endpoint with audio_prompt and transcript."}, {"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": "stt-whisperx-blackwell", "label": "WhisperX · faster-whisper (Blackwell GPU)", "role": "stt", "port": 8003, "stack": "whisperx-blackwell", "image": "local", "repo": "https://github.com/m-bain/whisperX", "description": "WhisperX transcription/alignment service using faster-whisper on the Blackwell GPU. 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."}, {"name": "ocr-kdl-frontier-parser-nano", "label": "KDL Frontier Parser nano", "role": "ocr", "port": 8099, "stack": "", "image": "local", "repo": "https://huggingface.co/KDLAI/KDL-Frontier-Parser-nano", "description": "Local 1.2B document parser for layout detection, OCR, tables, figures, and reading order."}, {"name": "ocr-baidu-unlimited-ocr", "label": "Baidu Unlimited-OCR", "role": "ocr", "port": 8098, "stack": "", "image": "local", "repo": "https://github.com/baidu/Unlimited-OCR", "description": "Local long-document OCR and layout parser with single-image and multi-page/PDF workflows."}, ] 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 = _docker_available() async def _check(defn: dict) -> dict: entry = {k: v for k, v in defn.items()} if sock_ok: status = await asyncio.to_thread(_container_status, defn["name"]) entry.update(status) else: entry["status"] = "no_socket" return entry results = await asyncio.gather(*[_check(d) for d in _LOCAL_CONTAINER_DEFS]) return {"containers": list(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, _ = await asyncio.to_thread(_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, _ = await asyncio.to_thread(_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, _ = await asyncio.to_thread(_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)} def _engine_api_key(settings: dict, name: str, role: str, supplied: str = "", url: str = "") -> str: card_key = _engine_card_api_key(settings, name) if role == "tts": return supplied or _tts_api_key(settings, url, name) or "sk-dummy-key" if name: return supplied or card_key or "sk-dummy-key" fallback = { "anthropic": "llm_api_key", "llm": "llm_api_key", "openai": "llm_api_key", "ollama": "llm_api_key", "stt": "whisper_api_key", "tts": "tts_api_key", "ocr": "vision_ocr_api_key", }.get(role, "") key = supplied or card_key or settings.get(fallback, "") return key or "sk-dummy-key" def _engine_headers(key: str, role: str) -> dict: if role == "anthropic": return {"x-api-key": key, "anthropic-version": "2023-06-01"} return {"Authorization": f"Bearer {key}"} @router.post("/api/local-containers/{name}/connection") async def engine_connection(name: str, request: Request): body = await request.json() if not isinstance(body, dict) or not isinstance(body.get("connected"), bool): raise HTTPException(400, "connected must be a boolean") settings = _load_settings() known = {card["name"] for card in _LOCAL_CONTAINER_DEFS} known.update(str(card.get("id") or card.get("name")) for card in settings.get("custom_engine_cards", [])) if name not in known: raise HTTPException(404, "Unknown engine") active = dict(settings.get("engine_active") or {}) urls = dict(settings.get("engine_local_urls") or {}) urls.update({str(identity).removeprefix("dc-"): url for identity, url in list(urls.items()) if str(identity).startswith("dc-")}) urls = {identity: url for identity, url in urls.items() if not str(identity).startswith("dc-")} roles = {card["name"]: card["role"] for card in _LOCAL_CONTAINER_DEFS} for card in settings.get("custom_engine_cards", []): identity = str(card.get("id") or card.get("name")) urls.setdefault(identity, card.get("url")) roles[identity] = card.get("role", "") def endpoint(url): return str(url or "").rstrip("/").removesuffix("/v1") for role, setting in (("llm", "llm_url"), ("ocr", "vision_ocr_url")): selected_url = endpoint(settings.get(setting)) if setting not in active and selected_url: candidates = dict(urls) parsed = urlsplit(selected_url) if parsed.hostname in {"localhost", "127.0.0.1", "::1", "host.docker.internal"}: for card in _LOCAL_CONTAINER_DEFS: if card["port"] == parsed.port: candidates.setdefault(card["name"], selected_url) matches = [identity for identity, url in candidates.items() if role in roles.get(identity, "").split("+") and endpoint(url) == selected_url] if matches == [name]: active[setting] = name settings["engine_active"] = active disabled = dict(settings.get("engine_disabled") or {}) if body["connected"]: disabled.pop(name, None) else: disabled[name] = True settings["engine_disabled"] = disabled _save_settings(_normalize_settings(settings)) return {"ok": True, "connected": body["connected"]} @router.get("/api/probe-url") async def probe_url(url: str, type: str = "", name: str = "", provider_api_key: str = Header("", alias="X-Provider-API-Key")): """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 settings = _load_settings() key_to_use = _engine_api_key(settings, name, type, provider_api_key, base) hdrs = {"User-Agent": "TTS-Voice-Creator/probe", **_engine_headers(key_to_use, type)} if type == "anthropic": checks = [("/v1/models", "data")] elif 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/models", "data"), ("/v1/health", None), ("/v1/audio/voices", None), ("/speakers", None), ("/voices", None)] elif type == "ocr": checks = [("/health", None), ("/v1/models", "data"), ("/v1/chat/completions", None), ("/parse", None), ("/ocr", None)] elif type == "comfyui": checks = [("/system_stats", "system")] else: checks = [("", None)] result = await asyncio.to_thread(probe_provider, base, checks, headers=hdrs, timeout=5) return {key: value for key, value in result.items() if key != 'payload'} @router.post("/api/local-containers/{name}/use-as-llm") async def use_as_llm(name: str, request: Request, provider_api_key: str = Header("", alias="X-Provider-API-Key")): """Make an engine card the active LLM, saving the same credential Connect just verified — not a possibly-stale global llm_api_key. The URL always comes from the request body. The key comes from (in order): an explicit header (a freshly typed, not-yet-saved key), then this card's own saved engine_api_keys[name] — the browser cannot supply that value itself once saved, since it is masked before ever reaching the browser. """ body = await request.json() url = _normalize_service_url(str(body.get("url") or "")).strip() if not url: raise HTTPException(400, "URL is required") s = _load_settings() card_key = _engine_card_api_key(s, name) key_to_use = provider_api_key or card_key or "" s["llm_url"] = url s["engine_active"] = {**(s.get("engine_active") or {}), "llm_url": name} s["engine_disabled"] = {key: value for key, value in (s.get("engine_disabled") or {}).items() if key != name} s["llm_api_key"] = key_to_use s["llm_model"] = str(body.get("model") or "").strip() if key_to_use: if provider_api_key: # Keep this card's own saved key in sync with what was just typed. engine_keys = dict(s.get("engine_api_keys") or {}) engine_keys[name] = provider_api_key s["engine_api_keys"] = engine_keys s = _normalize_settings(s) _save_settings(s) return {"ok": True, "llm_url": url, "key_set": bool(key_to_use)} @router.post("/api/local-containers/{name}/use-as-engine") async def use_as_engine(name: str, request: Request, provider_api_key: str = Header("", alias="X-Provider-API-Key")): body = await request.json() if not isinstance(body, dict): raise HTTPException(400, "JSON object is required") role = str(body.get("role") or "") setting_key = str(body.get("setting_key") or "") targets = { "stt": {"whisper_url", "faster_whisper_url", "whisper_cpp_url", "nvidia_asr_url", "nvidia_router_url"}, "tts": {"tts_url", "tts_stream_url", "voice_design_url", "customvoice_url", "kokoro_url", "vibevoice_url", "xtts_url", "fishspeech_url", "nvidia_tts_url", "nvidia_clone_url", "nvidia_zeroshot_url", "nvidia_flow_url"}, "ocr": {"vision_ocr_url"}, } if setting_key not in targets.get(role, set()): raise HTTPException(400, "Unsupported engine setting") url = _normalize_service_url(str(body.get("url") or "")).strip() if not url: raise HTTPException(400, "URL is required") settings = _load_settings() key = provider_api_key or _engine_card_api_key(settings, name) if role == "tts" and not key: key = _tts_api_key(settings, url, name) settings[setting_key] = url settings["engine_active"] = {**(settings.get("engine_active") or {}), setting_key: name} settings["engine_disabled"] = {key: value for key, value in (settings.get("engine_disabled") or {}).items() if key != name} settings["engine_local_urls"] = {**(settings.get("engine_local_urls") or {}), name: url} credential_setting = "voice_design_api_key" if setting_key == "voice_design_url" else { "stt": "whisper_api_key", "tts": "tts_api_key", "ocr": "vision_ocr_api_key", }[role] if setting_key in {"whisper_url", "tts_url", "voice_design_url", "vision_ocr_url"}: settings[credential_setting] = key settings["engine_api_keys"] = {**(settings.get("engine_api_keys") or {}), name: key} model = str(body.get("model") or "").strip() settings["engine_models"] = {**(settings.get("engine_models") or {}), name: model} if role == "ocr": from core.constants import _VISION_OCR_MODEL_DEFAULT settings["vision_ocr_model"] = model or _VISION_OCR_MODEL_DEFAULT elif role == "tts": settings["tts_models"] = {**(settings.get("tts_models") or {}), setting_key: model} else: backend = {"whisper_url": "configured", "faster_whisper_url": "faster_whisper", "whisper_cpp_url": "whisper_cpp", "nvidia_asr_url": "nvidia_parakeet", "nvidia_router_url": "nvidia_router"}[setting_key] settings["stt_models"] = {**(settings.get("stt_models") or {}), backend: model} if body.get("activate") is True: if role == "stt": custom = any(str(card.get("id") or card.get("name")) == name for card in settings.get("custom_engine_cards", [])) settings["stt_preferred_backend"] = "custom:" + name if custom else backend elif role == "tts": settings["tts_backend"] = "openai" elif role == "ocr": settings["vision_ocr_enabled"] = True _save_settings(_normalize_settings(settings)) return {"ok": True, "key_set": bool(key)} @router.get("/api/engine-models") async def engine_models(url: str, type: str = "", name: str = "", provider_api_key: str = Header("", alias="X-Provider-API-Key")): """Return model IDs from an OpenAI-style engine URL without exposing host-only URLs to the browser.""" base = _normalize_service_url(url).rstrip("/") if not base: raise HTTPException(400, "URL is required") candidates: list[str] = [] if base.endswith("/v1"): candidates = [base + "/models", base[:-3].rstrip("/") + "/models"] else: candidates = [base.rstrip("/") + "/v1/models", base.rstrip("/") + "/models"] if type == "ollama": candidates.append(base.rstrip("/") + "/api/tags") last_err = "No response" seen: set[str] = set() models: list[str] = [] settings = _load_settings() key_to_use = _engine_api_key(settings, name, type, provider_api_key, base) for endpoint in candidates: try: r = await asyncio.to_thread(requests.get, endpoint, timeout=6, headers={**_engine_headers(key_to_use, type), "User-Agent": "TTS-Voice-Creator/models"}) if r.status_code >= 400: last_err = f"HTTP {r.status_code} from {endpoint}" continue payload = r.json() data = payload.get("data") if isinstance(payload, dict) else None if data is None and isinstance(payload, dict): data = payload.get("models") if data is None and isinstance(payload, list): data = payload if not isinstance(data, list): last_err = f"Unexpected model response from {endpoint}" continue for item in data: mid = "" if isinstance(item, str): mid = item elif isinstance(item, dict): mid = str(item.get("id") or item.get("name") or item.get("model") or "") mid = mid.strip() if mid and mid not in seen: seen.add(mid) models.append(mid) if models: if type in {"llm", "openai", "ollama"}: aliases = await asyncio.to_thread(_litellm_model_aliases, base, key_to_use) models = [alias for alias in aliases if alias not in models] + models return {"ok": True, "url": base, "endpoint": endpoint, "models": models} except requests.exceptions.Timeout: last_err = "Timeout fetching models" except Exception as e: last_err = str(e) return {"ok": False, "url": base, "models": models, "error": last_err}