fix: run blocking requests/sleep in async routes via to_thread

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-06 00:40:34 +02:00
parent dea6286775
commit 2479617964
5 changed files with 49 additions and 44 deletions

View File

@ -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`.

View File

@ -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:
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'})")
content = resp.raw.read(15 * 1024 * 1024, decode_content=True)
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

View File

@ -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

View File

@ -554,16 +554,8 @@ async def open_voice_source_search(source: str, q: str = "", lang: str = ""):
raise HTTPException(e.status, str(e))
@router.post("/api/import-source-audio")
async def import_source_audio(request: Request):
data = await request.json()
audio_url = _source_import_url(str(data.get("audio_url") or ""))
filename = str(data.get("name") or Path(urlparse(audio_url).path).name or "source-audio").strip()
suffix = Path(urlparse(audio_url).path).suffix.lower()
if suffix not in _AUDIO_EXTS:
suffix = ".bin"
dest = TEMP_DIR / f"{uuid.uuid4().hex}{suffix}"
try:
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
@ -575,7 +567,20 @@ async def import_source_audio(request: Request):
if total > _MAX_UPLOAD_BYTES:
raise HTTPException(413, "Downloaded audio is too large")
f.write(chunk)
wav = _to_wav_24k(dest)
return _to_wav_24k(dest)
@router.post("/api/import-source-audio")
async def import_source_audio(request: Request):
data = await request.json()
audio_url = _source_import_url(str(data.get("audio_url") or ""))
filename = str(data.get("name") or Path(urlparse(audio_url).path).name or "source-audio").strip()
suffix = Path(urlparse(audio_url).path).suffix.lower()
if suffix not in _AUDIO_EXTS:
suffix = ".bin"
dest = TEMP_DIR / f"{uuid.uuid4().hex}{suffix}"
try:
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,

View File

@ -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: