fix(web-server): profile-scope the desktop audio endpoints

/api/audio/transcribe, /api/audio/speak, /api/audio/elevenlabs/voices, and
the /api/audio/speak-stream WebSocket resolved TTS/STT config from the
dashboard's own HERMES_HOME regardless of the active profile, so a
non-default profile's voice settings were silently ignored. Give all four
the same optional profile param as the rest of the dashboard surface,
entering _config_profile_scope (await-safe, config-only — the audio paths
touch no skills globals) inside their worker threads.

Backend half of the desktop fix; completes the renderer-side profileScoped()
threading. Fixes #53441 #45506 #66012 #64057.
This commit is contained in:
Teknium 2026-07-28 09:33:53 -07:00
parent c95f04501d
commit 5f1c400e72
2 changed files with 119 additions and 9 deletions

View file

@ -4371,7 +4371,9 @@ async def check_hermes_update(force: bool = False):
@app.post("/api/audio/transcribe")
async def transcribe_audio_upload(payload: AudioTranscriptionRequest):
async def transcribe_audio_upload(
payload: AudioTranscriptionRequest, profile: Optional[str] = None
):
data_url = (payload.data_url or "").strip()
if not data_url.startswith("data:") or "," not in data_url:
raise HTTPException(status_code=400, detail="Invalid audio payload")
@ -4421,8 +4423,17 @@ async def transcribe_audio_upload(payload: AudioTranscriptionRequest):
# and re-listens instead of surfacing a 400 on every quiet turn.
from tools.voice_mode import transcribe_recording
def _transcribe_scoped():
# Home-only scope (contextvar), NOT _profile_scope: transcription
# blocks for the provider round-trip and _profile_scope holds a
# process-global skills lock for its entire body (see the MCP
# probe above). STT only needs config/.env resolution, which the
# contextvar override provides inside this worker thread.
with _config_profile_scope(profile):
return transcribe_recording(temp_path)
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, transcribe_recording, temp_path)
result = await loop.run_in_executor(None, _transcribe_scoped)
except HTTPException:
raise
except Exception as exc:
@ -4482,13 +4493,16 @@ def _voice_list_error_logged_once(signature: Optional[str]) -> bool:
@app.get("/api/audio/elevenlabs/voices")
async def get_elevenlabs_voices():
async def get_elevenlabs_voices(profile: Optional[str] = None):
"""Return ElevenLabs voices when an API key is configured.
The desktop UI uses this for the ``tts.elevenlabs.voice_id`` dropdown.
Only non-secret voice metadata is returned; the API key stays server-side.
"""
api_key = (load_env().get("ELEVENLABS_API_KEY") or os.environ.get("ELEVENLABS_API_KEY") or "").strip()
# Config-only scope (await-safe): the key lookup reads the requested
# profile's .env, matching the profile the settings UI writes to.
with _config_profile_scope(profile):
api_key = (load_env().get("ELEVENLABS_API_KEY") or os.environ.get("ELEVENLABS_API_KEY") or "").strip()
if not api_key:
return {"available": False, "voices": []}
@ -4550,7 +4564,7 @@ async def get_elevenlabs_voices():
@app.post("/api/audio/speak")
async def speak_text(payload: TTSSpeakRequest):
async def speak_text(payload: TTSSpeakRequest, profile: Optional[str] = None):
"""Synthesize speech and return audio as base64 data URL.
Used by the desktop voice-conversation mode to play back assistant
@ -4564,8 +4578,21 @@ async def speak_text(payload: TTSSpeakRequest):
try:
from tools.tts_tool import text_to_speech_tool
def _speak_scoped():
# Home-only scope (contextvar), NOT _profile_scope: synthesis
# blocks for the provider round-trip and only needs config/.env
# resolution, so the task-local override inside this worker
# thread is sufficient (same reasoning as the MCP probe scope).
with _config_profile_scope(profile):
return text_to_speech_tool(text)
loop = asyncio.get_running_loop()
result_json = await loop.run_in_executor(None, text_to_speech_tool, text)
result_json = await loop.run_in_executor(None, _speak_scoped)
except HTTPException:
# _config_profile_scope raises 400/404 for a bad profile — pass it
# through instead of masking it as a 500 synthesis failure.
raise
except Exception as exc:
_log.exception("Desktop voice TTS failed")
raise HTTPException(status_code=500, detail=f"Speech synthesis failed: {exc}")
@ -4662,15 +4689,22 @@ async def speak_stream_ws(ws: "WebSocket") -> None:
return
await ws.accept()
# Profile via query param, like /api/pty and /api/console: the provider
# chain + API keys must resolve from the requesting profile's config, not
# the dashboard's own. The streamer captures its config at resolve time,
# so scoping resolution scopes the whole session.
profile = (ws.query_params.get("profile") or "").strip() or None
loop = asyncio.get_running_loop()
def _resolve():
from tools.tts_streaming import resolve_streaming_provider
from tools.tts_tool import _get_provider, _load_tts_config, _resolve_max_text_length
cfg = _load_tts_config()
streamer = resolve_streaming_provider(cfg)
cap = _resolve_max_text_length(_get_provider(cfg), cfg) if streamer else 0
with _config_profile_scope(profile):
cfg = _load_tts_config()
streamer = resolve_streaming_provider(cfg)
cap = _resolve_max_text_length(_get_provider(cfg), cfg) if streamer else 0
return streamer, cap
try:

View file

@ -697,3 +697,79 @@ class TestProfileScopedChatPty:
with pytest.raises(web_server.HTTPException) as exc:
web_server._resolve_chat_argv(profile="ghost")
assert exc.value.status_code == 404
class TestProfileScopedAudio:
"""Audio endpoints must honor ``profile`` like the rest of the dashboard.
Historically /api/audio/transcribe|speak|elevenlabs/voices took no profile
and always resolved the dashboard's own config/.env, so a non-default
profile's TTS/STT settings were silently ignored (#53441 #45506 #66012
#64057).
"""
def test_elevenlabs_voices_reads_target_profile_env(
self, client, isolated_profiles, monkeypatch
):
monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False)
# Key only in the DEFAULT profile's .env; worker_beta has none.
(isolated_profiles["default"] / ".env").write_text(
"ELEVENLABS_API_KEY=sk-default-profile\n", encoding="utf-8"
)
resp = client.get("/api/audio/elevenlabs/voices?profile=worker_beta")
assert resp.status_code == 200
# Scoped to worker_beta → no key found → unavailable, no network call.
assert resp.json() == {"available": False, "voices": []}
def test_speak_synthesizes_inside_target_profile_home(
self, client, isolated_profiles, monkeypatch
):
import tools.tts_tool as tts_tool
seen = {}
def _fake_tts(text):
from hermes_constants import get_hermes_home
seen["home"] = str(get_hermes_home())
out = isolated_profiles["worker_beta"] / "speech.mp3"
out.write_bytes(b"ID3fake")
return {"success": True, "file_path": str(out), "provider": "fake"}
monkeypatch.setattr(tts_tool, "text_to_speech_tool", _fake_tts)
resp = client.post(
"/api/audio/speak?profile=worker_beta", json={"text": "hello"}
)
assert resp.status_code == 200
assert resp.json()["ok"] is True
assert seen["home"] == str(isolated_profiles["worker_beta"])
def test_transcribe_runs_inside_target_profile_home(
self, client, isolated_profiles, monkeypatch
):
import base64
import tools.voice_mode as voice_mode
seen = {}
def _fake_transcribe(path):
from hermes_constants import get_hermes_home
seen["home"] = str(get_hermes_home())
return {"success": True, "transcript": "hi", "provider": "fake"}
monkeypatch.setattr(voice_mode, "transcribe_recording", _fake_transcribe)
payload = base64.b64encode(b"\x00fakeaudio").decode("ascii")
resp = client.post(
"/api/audio/transcribe?profile=worker_beta",
json={"data_url": f"data:audio/webm;base64,{payload}"},
)
assert resp.status_code == 200
assert resp.json()["transcript"] == "hi"
assert seen["home"] == str(isolated_profiles["worker_beta"])
def test_audio_endpoints_unknown_profile_404(self, client, isolated_profiles):
resp = client.get("/api/audio/elevenlabs/voices?profile=ghost")
assert resp.status_code == 404
resp = client.post("/api/audio/speak?profile=ghost", json={"text": "x"})
assert resp.status_code == 404