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:
parent
dea6286775
commit
2479617964
@ -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`.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user