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
|
### 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.
|
- 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
|
### 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.
|
- 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`.
|
- 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
|
payload["model"] = model
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = await asyncio.to_thread(
|
||||||
|
requests.post,
|
||||||
f"{llm_url}/chat/completions",
|
f"{llm_url}/chat/completions",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"},
|
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
|
payload["model"] = model
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = await asyncio.to_thread(
|
||||||
|
requests.post,
|
||||||
f"{llm_url}/chat/completions",
|
f"{llm_url}/chat/completions",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"},
|
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:
|
if model:
|
||||||
payload["model"] = model
|
payload["model"] = model
|
||||||
import time as _time
|
|
||||||
raw = None
|
raw = None
|
||||||
last_err: Exception | None = None
|
last_err: Exception | None = None
|
||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = await asyncio.to_thread(
|
||||||
|
requests.post,
|
||||||
f"{llm_url}/chat/completions", json=payload,
|
f"{llm_url}/chat/completions", json=payload,
|
||||||
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
|
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
|
# llama-swap (and similar) often drop the first request while swapping/loading
|
||||||
# the model — wait and retry rather than failing the cast.
|
# the model — wait and retry rather than failing the cast.
|
||||||
last_err = e
|
last_err = e
|
||||||
_time.sleep(4 + attempt * 3)
|
await asyncio.sleep(4 + attempt * 3)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_err = e
|
last_err = e
|
||||||
break
|
break
|
||||||
@ -1670,7 +1672,8 @@ async def character_deep_analysis(request: Request):
|
|||||||
if model:
|
if model:
|
||||||
payload["model"] = model
|
payload["model"] = model
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = await asyncio.to_thread(
|
||||||
|
requests.post,
|
||||||
f"{llm_url}/chat/completions", json=payload,
|
f"{llm_url}/chat/completions", json=payload,
|
||||||
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
|
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:
|
if not url:
|
||||||
raise HTTPException(400, "Image URL required")
|
raise HTTPException(400, "Image URL required")
|
||||||
try:
|
try:
|
||||||
resp = requests.get(url, timeout=30, headers={"User-Agent": "TTS-Voice-Creator/image-fetch"}, stream=True)
|
def _fetch() -> tuple[str, bytes]:
|
||||||
resp.raise_for_status()
|
resp = requests.get(url, timeout=30, headers={"User-Agent": "TTS-Voice-Creator/image-fetch"}, stream=True)
|
||||||
ctype = resp.headers.get("Content-Type", "")
|
resp.raise_for_status()
|
||||||
if not ctype.startswith("image/"):
|
ctype = resp.headers.get("Content-Type", "")
|
||||||
raise HTTPException(400, f"That URL didn't return an image (got {ctype or 'unknown content type'})")
|
if not ctype.startswith("image/"):
|
||||||
content = resp.raw.read(15 * 1024 * 1024, decode_content=True)
|
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:
|
if not content:
|
||||||
raise HTTPException(502, "Empty response from that URL")
|
raise HTTPException(502, "Empty response from that URL")
|
||||||
b64 = base64.b64encode(content).decode("ascii")
|
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"
|
key_to_use = provider_api_key or settings.get("llm_api_key") or "sk-dummy-key"
|
||||||
try:
|
try:
|
||||||
base = _validate_http_url(base, allow_private=True)
|
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
|
# Fallback for gateways like LiteLLM that might prefer /models over /v1/models
|
||||||
if r.status_code != 200 and base.endswith("/v1"):
|
if r.status_code != 200 and base.endswith("/v1"):
|
||||||
fallback_base = base[:-3]
|
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:
|
if r2.status_code == 200:
|
||||||
r = r2
|
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"
|
key_to_use = settings.get("llm_api_key") or provider_api_key or "sk-dummy-key"
|
||||||
for endpoint in candidates:
|
for endpoint in candidates:
|
||||||
try:
|
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:
|
if r.status_code >= 400:
|
||||||
last_err = f"HTTP {r.status_code} from {endpoint}"
|
last_err = f"HTTP {r.status_code} from {endpoint}"
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -554,6 +554,22 @@ async def open_voice_source_search(source: str, q: str = "", lang: str = ""):
|
|||||||
raise HTTPException(e.status, str(e))
|
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")
|
@router.post("/api/import-source-audio")
|
||||||
async def import_source_audio(request: Request):
|
async def import_source_audio(request: Request):
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
@ -564,18 +580,7 @@ async def import_source_audio(request: Request):
|
|||||||
suffix = ".bin"
|
suffix = ".bin"
|
||||||
dest = TEMP_DIR / f"{uuid.uuid4().hex}{suffix}"
|
dest = TEMP_DIR / f"{uuid.uuid4().hex}{suffix}"
|
||||||
try:
|
try:
|
||||||
with requests.get(audio_url, headers=_VOICE_SOURCE_HEADERS, timeout=30, stream=True) as r:
|
wav = await asyncio.to_thread(_download_audio_wav, audio_url, dest)
|
||||||
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)
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
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):
|
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}")
|
raise HTTPException(403, f"Domain not in audio proxy allowlist: {parsed.netloc}")
|
||||||
try:
|
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()
|
resp.raise_for_status()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(502, f"Proxy fetch failed: {e}")
|
raise HTTPException(502, f"Proxy fetch failed: {e}")
|
||||||
@ -638,18 +643,7 @@ async def quick_import_voice(request: Request):
|
|||||||
suffix = ".bin"
|
suffix = ".bin"
|
||||||
dest = TEMP_DIR / f"{uuid.uuid4().hex}{suffix}"
|
dest = TEMP_DIR / f"{uuid.uuid4().hex}{suffix}"
|
||||||
try:
|
try:
|
||||||
with requests.get(audio_url, headers=_VOICE_SOURCE_HEADERS, timeout=30, stream=True) as r:
|
wav = await asyncio.to_thread(_download_audio_wav, audio_url, dest)
|
||||||
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)
|
|
||||||
if source_kind: # sample clip: trim to [start, start+length] (length 3..120 s)
|
if source_kind: # sample clip: trim to [start, start+length] (length 3..120 s)
|
||||||
from pydub import AudioSegment
|
from pydub import AudioSegment
|
||||||
try:
|
try:
|
||||||
@ -807,7 +801,8 @@ async def elevenlabs_shared_voices(request: Request):
|
|||||||
params.setdefault("page_size", "24")
|
params.setdefault("page_size", "24")
|
||||||
headers = {"xi-api-key": api_key} if api_key else {}
|
headers = {"xi-api-key": api_key} if api_key else {}
|
||||||
try:
|
try:
|
||||||
r = requests.get(
|
r = await asyncio.to_thread(
|
||||||
|
requests.get,
|
||||||
"https://api.elevenlabs.io/v1/shared-voices",
|
"https://api.elevenlabs.io/v1/shared-voices",
|
||||||
params=params,
|
params=params,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
|
|||||||
@ -1306,7 +1306,8 @@ async def tts_voice_seed(request: Request):
|
|||||||
settings = _load_settings()
|
settings = _load_settings()
|
||||||
tts_base = _preview_backend_base_url(settings, "voice_clone").rstrip("/")
|
tts_base = _preview_backend_base_url(settings, "voice_clone").rstrip("/")
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = await asyncio.to_thread(
|
||||||
|
requests.post,
|
||||||
f"{tts_base}/voice-seed",
|
f"{tts_base}/voice-seed",
|
||||||
json=data,
|
json=data,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
@ -1431,7 +1432,7 @@ async def list_seed_samples(voice_name: str):
|
|||||||
settings = _load_settings()
|
settings = _load_settings()
|
||||||
tts_base = _preview_backend_base_url(settings, "voice_clone").rstrip("/")
|
tts_base = _preview_backend_base_url(settings, "voice_clone").rstrip("/")
|
||||||
try:
|
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()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
except requests.exceptions.ConnectionError:
|
except requests.exceptions.ConnectionError:
|
||||||
@ -1446,7 +1447,7 @@ async def get_seed_sample(voice_name: str, seed: int):
|
|||||||
settings = _load_settings()
|
settings = _load_settings()
|
||||||
tts_base = _preview_backend_base_url(settings, "voice_clone").rstrip("/")
|
tts_base = _preview_backend_base_url(settings, "voice_clone").rstrip("/")
|
||||||
try:
|
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()
|
resp.raise_for_status()
|
||||||
return Response(content=resp.content, media_type="audio/wav")
|
return Response(content=resp.content, media_type="audio/wav")
|
||||||
except requests.exceptions.ConnectionError:
|
except requests.exceptions.ConnectionError:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user