mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
c95f04501d
commit
5f1c400e72
2 changed files with 119 additions and 9 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue