From 2479617964878d72346fb5ee5163eb88eeee38eb Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Tue, 6 Oct 2026 00:40:34 +0200 Subject: [PATCH] fix: run blocking requests/sleep in async routes via to_thread Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 +++ routes/conversation.py | 34 +++++++++++++++++------------- routes/docker.py | 2 +- routes/sources.py | 47 +++++++++++++++++++----------------------- routes/tts.py | 7 ++++--- 5 files changed, 49 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ecb982..d211c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ### Added - Conversation Playground: a Thinking on/off switch next to the Language Model (default off, setting `conversation_thinking`). Off sends `chat_template_kwargs.enable_thinking=false` (plus `think:false` for Ollama) with each reply request and never touches the system prompt; the empty-reply error now says to turn Thinking off. +### Fixed +- Async routes no longer block the event loop on synchronous HTTP: seed proxy/samples, text refine/persona rewrite, voice matching (its retry back-off is now `asyncio.sleep`), character deep analysis, character image-from-URL, LLM/engine model listing, source audio import/proxy and ElevenLabs shared voices now run `requests` through `asyncio.to_thread` (same timeouts and responses). + ### Changed - Conversation Playground: the Language Model base URL is now picked from a dropdown of the language models configured under Engines (Docker LLM containers incl. saved URL overrides, and custom LLM cards; running/stopped marked), with a "Custom…" option that reveals the free-text URL. Selecting an entry saves `conv_llm_url` and fetches the models. - Playwright specs now import `test` from `tests/_guard.js`, whose auto fixture aborts and fails any unmocked non-GET request to `/api/**` so a spec can no longer modify live settings. See `architecture/qa.md`. diff --git a/routes/conversation.py b/routes/conversation.py index b182f4b..c8efee9 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -720,7 +720,8 @@ async def refine_text(request: Request): payload["model"] = model try: - resp = requests.post( + resp = await asyncio.to_thread( + requests.post, f"{llm_url}/chat/completions", json=payload, headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}, @@ -781,7 +782,8 @@ async def rewrite_with_persona(request: Request): payload["model"] = model try: - resp = requests.post( + resp = await asyncio.to_thread( + requests.post, f"{llm_url}/chat/completions", json=payload, headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}, @@ -1015,12 +1017,12 @@ async def match_characters_voices(request: Request): } if model: payload["model"] = model - import time as _time raw = None last_err: Exception | None = None for attempt in range(3): try: - resp = requests.post( + resp = await asyncio.to_thread( + requests.post, f"{llm_url}/chat/completions", json=payload, headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600, ) @@ -1032,7 +1034,7 @@ async def match_characters_voices(request: Request): # llama-swap (and similar) often drop the first request while swapping/loading # the model — wait and retry rather than failing the cast. last_err = e - _time.sleep(4 + attempt * 3) + await asyncio.sleep(4 + attempt * 3) except Exception as e: last_err = e break @@ -1670,7 +1672,8 @@ async def character_deep_analysis(request: Request): if model: payload["model"] = model try: - resp = requests.post( + resp = await asyncio.to_thread( + requests.post, f"{llm_url}/chat/completions", json=payload, headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600, ) @@ -4372,12 +4375,15 @@ async def character_image_from_url(request: Request): if not url: raise HTTPException(400, "Image URL required") try: - resp = requests.get(url, timeout=30, headers={"User-Agent": "TTS-Voice-Creator/image-fetch"}, stream=True) - resp.raise_for_status() - ctype = resp.headers.get("Content-Type", "") - if not ctype.startswith("image/"): - raise HTTPException(400, f"That URL didn't return an image (got {ctype or 'unknown content type'})") - content = resp.raw.read(15 * 1024 * 1024, decode_content=True) + def _fetch() -> tuple[str, bytes]: + resp = requests.get(url, timeout=30, headers={"User-Agent": "TTS-Voice-Creator/image-fetch"}, stream=True) + resp.raise_for_status() + ctype = resp.headers.get("Content-Type", "") + if not ctype.startswith("image/"): + raise HTTPException(400, f"That URL didn't return an image (got {ctype or 'unknown content type'})") + return ctype, resp.raw.read(15 * 1024 * 1024, decode_content=True) + + ctype, content = await asyncio.to_thread(_fetch) if not content: raise HTTPException(502, "Empty response from that URL") b64 = base64.b64encode(content).decode("ascii") @@ -6076,12 +6082,12 @@ async def conversation_llm_models( key_to_use = provider_api_key or settings.get("llm_api_key") or "sk-dummy-key" try: base = _validate_http_url(base, allow_private=True) - r = requests.get(f"{base}/models", timeout=5, headers={"Authorization": f"Bearer {key_to_use}"}) + r = await asyncio.to_thread(requests.get, f"{base}/models", timeout=5, headers={"Authorization": f"Bearer {key_to_use}"}) # Fallback for gateways like LiteLLM that might prefer /models over /v1/models if r.status_code != 200 and base.endswith("/v1"): fallback_base = base[:-3] - r2 = requests.get(f"{fallback_base}/models", timeout=5, headers={"Authorization": f"Bearer {key_to_use}"}) + r2 = await asyncio.to_thread(requests.get, f"{fallback_base}/models", timeout=5, headers={"Authorization": f"Bearer {key_to_use}"}) if r2.status_code == 200: r = r2 diff --git a/routes/docker.py b/routes/docker.py index 31d1d3a..e68c324 100644 --- a/routes/docker.py +++ b/routes/docker.py @@ -229,7 +229,7 @@ async def engine_models(url: str, type: str = "", provider_api_key: str = Header key_to_use = settings.get("llm_api_key") or provider_api_key or "sk-dummy-key" for endpoint in candidates: try: - r = requests.get(endpoint, timeout=6, headers={"Authorization": f"Bearer {key_to_use}", "User-Agent": "TTS-Voice-Creator/models"}) + r = await asyncio.to_thread(requests.get, endpoint, timeout=6, headers={"Authorization": f"Bearer {key_to_use}", "User-Agent": "TTS-Voice-Creator/models"}) if r.status_code >= 400: last_err = f"HTTP {r.status_code} from {endpoint}" continue diff --git a/routes/sources.py b/routes/sources.py index 9c5585c..9f9453b 100644 --- a/routes/sources.py +++ b/routes/sources.py @@ -554,6 +554,22 @@ async def open_voice_source_search(source: str, q: str = "", lang: str = ""): raise HTTPException(e.status, str(e)) +def _download_audio_wav(audio_url: str, dest: Path) -> Path: + """Blocking: stream audio_url to dest (size-capped) and convert to 24k wav. Run via to_thread.""" + with requests.get(audio_url, headers=_VOICE_SOURCE_HEADERS, timeout=30, stream=True) as r: + r.raise_for_status() + total = 0 + with dest.open("wb") as f: + for chunk in r.iter_content(1024 * 1024): + if not chunk: + continue + total += len(chunk) + if total > _MAX_UPLOAD_BYTES: + raise HTTPException(413, "Downloaded audio is too large") + f.write(chunk) + return _to_wav_24k(dest) + + @router.post("/api/import-source-audio") async def import_source_audio(request: Request): data = await request.json() @@ -564,18 +580,7 @@ async def import_source_audio(request: Request): suffix = ".bin" dest = TEMP_DIR / f"{uuid.uuid4().hex}{suffix}" try: - with requests.get(audio_url, headers=_VOICE_SOURCE_HEADERS, timeout=30, stream=True) as r: - r.raise_for_status() - total = 0 - with dest.open("wb") as f: - for chunk in r.iter_content(1024 * 1024): - if not chunk: - continue - total += len(chunk) - if total > _MAX_UPLOAD_BYTES: - raise HTTPException(413, "Downloaded audio is too large") - f.write(chunk) - wav = _to_wav_24k(dest) + wav = await asyncio.to_thread(_download_audio_wav, audio_url, dest) except HTTPException: raise except Exception as e: @@ -594,7 +599,7 @@ async def proxy_audio(url: str): if not any(domain == d or domain.endswith("." + d) for d in _PROXY_AUDIO_DOMAINS): raise HTTPException(403, f"Domain not in audio proxy allowlist: {parsed.netloc}") try: - resp = requests.get(url, headers=_VOICE_SOURCE_HEADERS, timeout=30, stream=True) + resp = await asyncio.to_thread(requests.get, url, headers=_VOICE_SOURCE_HEADERS, timeout=30, stream=True) resp.raise_for_status() except Exception as e: raise HTTPException(502, f"Proxy fetch failed: {e}") @@ -638,18 +643,7 @@ async def quick_import_voice(request: Request): suffix = ".bin" dest = TEMP_DIR / f"{uuid.uuid4().hex}{suffix}" try: - with requests.get(audio_url, headers=_VOICE_SOURCE_HEADERS, timeout=30, stream=True) as r: - r.raise_for_status() - total = 0 - with dest.open("wb") as f: - for chunk in r.iter_content(1024 * 1024): - if not chunk: - continue - total += len(chunk) - if total > _MAX_UPLOAD_BYTES: - raise HTTPException(413, "Downloaded audio is too large") - f.write(chunk) - wav = _to_wav_24k(dest) + wav = await asyncio.to_thread(_download_audio_wav, audio_url, dest) if source_kind: # sample clip: trim to [start, start+length] (length 3..120 s) from pydub import AudioSegment try: @@ -807,7 +801,8 @@ async def elevenlabs_shared_voices(request: Request): params.setdefault("page_size", "24") headers = {"xi-api-key": api_key} if api_key else {} try: - r = requests.get( + r = await asyncio.to_thread( + requests.get, "https://api.elevenlabs.io/v1/shared-voices", params=params, headers=headers, diff --git a/routes/tts.py b/routes/tts.py index 12e3fb8..26d2f6e 100644 --- a/routes/tts.py +++ b/routes/tts.py @@ -1306,7 +1306,8 @@ async def tts_voice_seed(request: Request): settings = _load_settings() tts_base = _preview_backend_base_url(settings, "voice_clone").rstrip("/") try: - resp = requests.post( + resp = await asyncio.to_thread( + requests.post, f"{tts_base}/voice-seed", json=data, timeout=10, @@ -1431,7 +1432,7 @@ async def list_seed_samples(voice_name: str): settings = _load_settings() tts_base = _preview_backend_base_url(settings, "voice_clone").rstrip("/") try: - resp = requests.get(f"{tts_base}/seed-samples/{voice_name}", timeout=5) + resp = await asyncio.to_thread(requests.get, f"{tts_base}/seed-samples/{voice_name}", timeout=5) resp.raise_for_status() return resp.json() except requests.exceptions.ConnectionError: @@ -1446,7 +1447,7 @@ async def get_seed_sample(voice_name: str, seed: int): settings = _load_settings() tts_base = _preview_backend_base_url(settings, "voice_clone").rstrip("/") try: - resp = requests.get(f"{tts_base}/seed-sample/{voice_name}/{seed}", timeout=30, stream=True) + resp = await asyncio.to_thread(requests.get, f"{tts_base}/seed-sample/{voice_name}/{seed}", timeout=30) # body is read inside the worker thread resp.raise_for_status() return Response(content=resp.content, media_type="audio/wav") except requests.exceptions.ConnectionError: