Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
"""Resolve the active profile's STT/TTS config for CLIENT-DIRECT voice.
|
||||
|
||||
The desktop app can cut the audio relay hop (mic → gateway → provider and
|
||||
provider → gateway → speaker) by calling the voice providers directly with
|
||||
the profile's own credentials, fetched over the authenticated REST channel
|
||||
at voice-session start. This module is the single resolver behind
|
||||
``GET /api/audio/voice-config``: it reuses the exact provider/key/model/
|
||||
language resolution chains ``tools.transcription_tools`` and
|
||||
``tools.tts_tool`` use, so what the client receives is byte-for-byte what
|
||||
the gateway itself would use for the same request.
|
||||
|
||||
Design rules:
|
||||
|
||||
* **Same-trust boundary.** The endpoint is profile-scoped and rides the
|
||||
same auth as every other REST route. A client that can reach it can
|
||||
already drive the agent (terminal included), so handing it the voice
|
||||
key is not a privilege escalation — but keys still never touch client
|
||||
disk (the desktop holds them in renderer memory only) and are never
|
||||
logged here.
|
||||
* **Relay is the floor, not an error.** Providers that can only run on
|
||||
the gateway host (local whisper, edge-tts, command providers, plugins)
|
||||
resolve to ``{"mode": "relay"}`` and the desktop falls back to the
|
||||
existing ``/api/audio/*`` relay endpoints. A resolution failure also
|
||||
degrades to relay — the relay endpoint will surface the real error.
|
||||
* **No new key stores.** Everything is read through the live resolvers;
|
||||
nothing is persisted anywhere new.
|
||||
|
||||
Config gate: ``voice.client_direct`` (config.yaml, default ``true``).
|
||||
When false every provider reports relay and the desktop behaves exactly
|
||||
as before this feature.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Wire shapes the desktop knows how to speak. Anything else → relay.
|
||||
# openai-multipart : POST {base_url}/audio/transcriptions (multipart, Bearer)
|
||||
# xai-stt : POST {base_url}/stt (multipart, Bearer, format=true)
|
||||
# elevenlabs-stt : POST {base_url}/speech-to-text (multipart, xi-api-key)
|
||||
# openai-speech : POST {base_url}/audio/speech (JSON, Bearer) → audio bytes
|
||||
# elevenlabs-tts : POST {base_url}/text-to-speech/{voice_id} (JSON, xi-api-key)
|
||||
STT_WIRE_OPENAI = "openai-multipart"
|
||||
STT_WIRE_XAI = "xai-stt"
|
||||
STT_WIRE_ELEVENLABS = "elevenlabs-stt"
|
||||
TTS_WIRE_OPENAI = "openai-speech"
|
||||
TTS_WIRE_ELEVENLABS = "elevenlabs-tts"
|
||||
|
||||
_RELAY: Dict[str, Any] = {"mode": "relay"}
|
||||
|
||||
|
||||
def _client_direct_enabled() -> bool:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
voice_cfg = load_config().get("voice") or {}
|
||||
if not isinstance(voice_cfg, dict):
|
||||
return True
|
||||
value = voice_cfg.get("client_direct", True)
|
||||
except Exception:
|
||||
return True
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() not in {"0", "false", "no", "off"}
|
||||
return True
|
||||
|
||||
|
||||
def _relay(reason: str) -> Dict[str, Any]:
|
||||
"""A relay verdict that tells the client (and logs) WHY, without secrets."""
|
||||
return {"mode": "relay", "reason": reason}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_stt_client_config() -> Dict[str, Any]:
|
||||
from tools import transcription_tools as tt
|
||||
|
||||
stt_config = tt._load_stt_config()
|
||||
if not tt.is_stt_enabled(stt_config):
|
||||
return _relay("stt disabled")
|
||||
|
||||
provider = tt._get_provider(stt_config)
|
||||
|
||||
# Server-host-only providers: local whisper, the env-var command escape
|
||||
# hatch, declared command providers, and anything plugin-registered.
|
||||
if tt._is_local_stt_provider(provider, stt_config):
|
||||
return _relay("local provider")
|
||||
if provider not in tt.BUILTIN_STT_PROVIDERS:
|
||||
return _relay("command/plugin provider")
|
||||
|
||||
language = tt._resolve_stt_language(
|
||||
provider, stt_config,
|
||||
extra_keys=("language_code",) if provider == "elevenlabs" else (),
|
||||
)
|
||||
section = stt_config.get(provider) if isinstance(stt_config, dict) else None
|
||||
section = section if isinstance(section, dict) else {}
|
||||
|
||||
if provider == "groq":
|
||||
api_key = tt._resolve_provider_key("GROQ_API_KEY", "groq")
|
||||
if not api_key:
|
||||
return _relay("no credentials")
|
||||
return {
|
||||
"mode": "direct",
|
||||
"wire": STT_WIRE_OPENAI,
|
||||
"provider": "groq",
|
||||
"base_url": tt.GROQ_BASE_URL,
|
||||
"api_key": api_key,
|
||||
"model": section.get("model") or tt.DEFAULT_GROQ_STT_MODEL,
|
||||
"language": language,
|
||||
}
|
||||
|
||||
if provider == "openai":
|
||||
# Handles the Nous-managed selection too: the resolver returns the
|
||||
# user's own gateway token + managed base URL, which is exactly the
|
||||
# credential the client should use.
|
||||
try:
|
||||
api_key, base_url = tt._resolve_openai_audio_client_config()
|
||||
except ValueError as exc:
|
||||
return _relay(f"openai resolution failed: {exc}")
|
||||
return {
|
||||
"mode": "direct",
|
||||
"wire": STT_WIRE_OPENAI,
|
||||
"provider": "openai",
|
||||
"base_url": base_url,
|
||||
"api_key": api_key,
|
||||
"model": section.get("model") or tt.DEFAULT_STT_MODEL,
|
||||
"language": language,
|
||||
}
|
||||
|
||||
if provider == "mistral":
|
||||
api_key = tt._resolve_provider_key("MISTRAL_API_KEY", "mistral")
|
||||
if not api_key:
|
||||
return _relay("no credentials")
|
||||
return {
|
||||
"mode": "direct",
|
||||
"wire": STT_WIRE_OPENAI,
|
||||
"provider": "mistral",
|
||||
"base_url": "https://api.mistral.ai/v1",
|
||||
"api_key": api_key,
|
||||
"model": section.get("model") or tt.DEFAULT_MISTRAL_STT_MODEL,
|
||||
"language": language,
|
||||
}
|
||||
|
||||
if provider == "xai":
|
||||
# API key only. An xAI OAuth bearer refreshes server-side mid-session;
|
||||
# handing it out strands the client on the first 401. Relay instead.
|
||||
api_key = str(tt.get_env_value("XAI_API_KEY") or "").strip()
|
||||
if not api_key:
|
||||
return _relay("xai oauth (server-managed) or no credentials")
|
||||
base_url = str(
|
||||
section.get("base_url")
|
||||
or tt.get_env_value("XAI_STT_BASE_URL")
|
||||
or tt.XAI_STT_BASE_URL
|
||||
).strip().rstrip("/")
|
||||
return {
|
||||
"mode": "direct",
|
||||
"wire": STT_WIRE_XAI,
|
||||
"provider": "xai",
|
||||
"base_url": base_url,
|
||||
"api_key": api_key,
|
||||
"model": None,
|
||||
"language": language,
|
||||
}
|
||||
|
||||
if provider == "elevenlabs":
|
||||
api_key = tt._resolve_provider_key("ELEVENLABS_API_KEY", "elevenlabs")
|
||||
if not api_key:
|
||||
return _relay("no credentials")
|
||||
base_url = str(
|
||||
section.get("base_url")
|
||||
or tt.get_env_value("ELEVENLABS_STT_BASE_URL")
|
||||
or tt.ELEVENLABS_STT_BASE_URL
|
||||
).strip().rstrip("/")
|
||||
return {
|
||||
"mode": "direct",
|
||||
"wire": STT_WIRE_ELEVENLABS,
|
||||
"provider": "elevenlabs",
|
||||
"base_url": base_url,
|
||||
"api_key": api_key,
|
||||
"model": section.get("model") or tt.DEFAULT_ELEVENLABS_STT_MODEL,
|
||||
"language": language,
|
||||
}
|
||||
|
||||
if provider == "deepinfra":
|
||||
api_key = tt._resolve_provider_key("DEEPINFRA_API_KEY", "deepinfra")
|
||||
if not api_key:
|
||||
return _relay("no credentials")
|
||||
from hermes_cli.models import deepinfra_base_url, deepinfra_model_ids
|
||||
|
||||
model = section.get("model")
|
||||
if not model:
|
||||
candidates = deepinfra_model_ids("stt")
|
||||
model = candidates[0] if candidates else None
|
||||
if not model:
|
||||
return _relay("no deepinfra stt model")
|
||||
return {
|
||||
"mode": "direct",
|
||||
"wire": STT_WIRE_OPENAI,
|
||||
"provider": "deepinfra",
|
||||
"base_url": deepinfra_base_url(section),
|
||||
"api_key": api_key,
|
||||
"model": model,
|
||||
"language": language,
|
||||
}
|
||||
|
||||
return _relay(f"provider {provider!r} has no client wire")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TTS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_tts_client_config() -> Dict[str, Any]:
|
||||
from tools import tts_tool as tts
|
||||
|
||||
tts_config = tts._load_tts_config()
|
||||
provider = tts._get_provider(tts_config)
|
||||
|
||||
if provider not in tts.BUILTIN_TTS_PROVIDERS:
|
||||
return _relay("command/plugin provider")
|
||||
|
||||
if provider == "openai":
|
||||
# Covers the direct-key, custom-base_url, and Nous-managed selections.
|
||||
try:
|
||||
api_key, base_url, is_managed = tts._resolve_openai_audio_client_config()
|
||||
except ValueError as exc:
|
||||
return _relay(f"openai resolution failed: {exc}")
|
||||
oai = tts_config.get("openai") if isinstance(tts_config, dict) else None
|
||||
oai = oai if isinstance(oai, dict) else {}
|
||||
model = oai.get("model") or tts.DEFAULT_OPENAI_MODEL
|
||||
config_base = oai.get("base_url")
|
||||
if config_base:
|
||||
base_url = config_base
|
||||
# The managed gateway only proxies MANAGED_OPENAI_TTS_MODELS — same
|
||||
# coercion text_to_speech applies server-side.
|
||||
if is_managed and not config_base and model not in tts.MANAGED_OPENAI_TTS_MODELS:
|
||||
model = tts.DEFAULT_OPENAI_MODEL
|
||||
speed_default = tts_config.get("speed", 1.0) if isinstance(tts_config, dict) else 1.0
|
||||
try:
|
||||
speed = float(oai.get("speed", speed_default))
|
||||
except (TypeError, ValueError):
|
||||
speed = 1.0
|
||||
return {
|
||||
"mode": "direct",
|
||||
"wire": TTS_WIRE_OPENAI,
|
||||
"provider": "openai",
|
||||
"base_url": base_url,
|
||||
"api_key": api_key,
|
||||
"model": model,
|
||||
"voice": oai.get("voice") or tts.DEFAULT_OPENAI_VOICE,
|
||||
"speed": speed,
|
||||
}
|
||||
|
||||
if provider == "elevenlabs":
|
||||
api_key = tts._resolve_provider_key("ELEVENLABS_API_KEY", "elevenlabs")
|
||||
if not api_key:
|
||||
return _relay("no credentials")
|
||||
el = tts_config.get("elevenlabs") if isinstance(tts_config, dict) else None
|
||||
el = el if isinstance(el, dict) else {}
|
||||
return {
|
||||
"mode": "direct",
|
||||
"wire": TTS_WIRE_ELEVENLABS,
|
||||
"provider": "elevenlabs",
|
||||
"base_url": str(el.get("base_url") or "https://api.elevenlabs.io/v1").rstrip("/"),
|
||||
"api_key": api_key,
|
||||
"model": el.get("model_id") or tts.DEFAULT_ELEVENLABS_MODEL_ID,
|
||||
"voice": el.get("voice_id") or tts.DEFAULT_ELEVENLABS_VOICE_ID,
|
||||
"speed": None,
|
||||
}
|
||||
|
||||
if provider == "deepinfra":
|
||||
api_key = tts._resolve_provider_key("DEEPINFRA_API_KEY", "deepinfra")
|
||||
if not api_key:
|
||||
return _relay("no credentials")
|
||||
from hermes_cli.models import deepinfra_base_url, deepinfra_model_ids
|
||||
|
||||
di = tts_config.get("deepinfra") if isinstance(tts_config, dict) else None
|
||||
di = di if isinstance(di, dict) else {}
|
||||
model = di.get("model")
|
||||
if not model:
|
||||
candidates = deepinfra_model_ids("tts")
|
||||
model = candidates[0] if candidates else None
|
||||
if not model:
|
||||
return _relay("no deepinfra tts model")
|
||||
return {
|
||||
"mode": "direct",
|
||||
"wire": TTS_WIRE_OPENAI,
|
||||
"provider": "deepinfra",
|
||||
"base_url": deepinfra_base_url(di),
|
||||
"api_key": api_key,
|
||||
"model": model,
|
||||
"voice": di.get("voice") or "af_bella",
|
||||
"speed": None,
|
||||
}
|
||||
|
||||
# edge / minimax / xai / mistral / gemini / neutts / kittentts / piper:
|
||||
# either server-host-only engines or wire shapes the desktop doesn't
|
||||
# speak yet. The relay path (speak-stream WS + POST fallback) serves them.
|
||||
return _relay(f"provider {provider!r} has no client wire")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resolve_client_voice_config() -> Dict[str, Any]:
|
||||
"""Resolve both directions for the CURRENT profile scope.
|
||||
|
||||
Callers scope the profile via ``hermes_constants.set_hermes_home_override``
|
||||
(the web server's ``_config_profile_scope``) before calling — identical to
|
||||
how ``/api/audio/transcribe`` scopes ``transcribe_recording``.
|
||||
"""
|
||||
if not _client_direct_enabled():
|
||||
disabled = _relay("voice.client_direct disabled")
|
||||
return {"stt": disabled, "tts": disabled}
|
||||
|
||||
try:
|
||||
stt = _resolve_stt_client_config()
|
||||
except Exception:
|
||||
logger.exception("client voice-config STT resolution failed")
|
||||
stt = _relay("resolution error")
|
||||
try:
|
||||
tts = _resolve_tts_client_config()
|
||||
except Exception:
|
||||
logger.exception("client voice-config TTS resolution failed")
|
||||
tts = _relay("resolution error")
|
||||
|
||||
return {"stt": stt, "tts": tts}
|
||||
Reference in New Issue
Block a user