Files
hermes-agent/tools/transcription_common.py
Austin Pickett 59b2aeef6c fix(stt): never stringify a structured STT error response into the transcript
An OpenAI-SDK-shaped transcription response can be a structured object whose
``text`` is None and whose ``error`` carries the provider's failure. Every
caller fell back to ``str(transcription)``, so the object repr —
``Transcription(text=None, logprobs=None, usage=None, error='Transcription failed')``
— was logged as a successful transcript and returned in the
``{"success": true, "transcript": ...}`` envelope. Desktop conversation mode
then injected that repr as the user's message instead of the audio.

``_extract_transcript_text`` now raises ``STTResponseError`` (a ``ValueError``)
for any structured response — SDK object or JSON dict — with a missing or
non-string ``text``: the provider's ``error`` when there is one, else
"Transcription response contained no text". ``_with_openai_client`` and
``_cloud_failure`` surface that message verbatim, so the openai, groq,
deepinfra, mistral, xAI and ElevenLabs paths all return their existing failure
envelope instead. ``_transcribe_groq`` uses the shared normalizer rather than
its own ``str(transcription)``. Plain strings, objects/dicts with a string
``text`` (including ``""``, so silence stays non-fatal) and unknown scalars are
unchanged; only the repr fallback for structured responses is gone. No desktop
change is required.

Fixes #78098
2026-09-27 03:45:07 -04:00

104 lines
4.5 KiB
Python

"""Constants, result envelopes and tiny config readers shared by every STT module."""
from __future__ import annotations
import logging
import os
import subprocess
from typing import Any, Dict
from tools.tts_command_provider import _get_provider_section as _get_stt_section
# Log-record parity with the origin module.
logger = logging.getLogger("tools.transcription_tools")
DEFAULT_PROVIDER = "local"
DEFAULT_LOCAL_MODEL = "base"
DEFAULT_LOCAL_STT_LANGUAGE = "en"
DEFAULT_STT_MODEL = os.getenv("STT_OPENAI_MODEL", "whisper-1")
DEFAULT_GROQ_STT_MODEL = os.getenv("STT_GROQ_MODEL", "whisper-large-v3-turbo")
DEFAULT_MISTRAL_STT_MODEL = os.getenv("STT_MISTRAL_MODEL", "voxtral-mini-latest")
DEFAULT_ELEVENLABS_STT_MODEL = os.getenv("STT_ELEVENLABS_MODEL", "scribe_v2")
# Seconds for one STT HTTP request; shared by the OpenAI-SDK path and the QQ adapter so a
# self-hosted model's cold start is not cut off at the old fixed 30s (#112939).
DEFAULT_STT_TIMEOUT = 60.0
LOCAL_STT_COMMAND_ENV = "HERMES_LOCAL_STT_COMMAND"
LOCAL_STT_LANGUAGE_ENV = "HERMES_LOCAL_STT_LANGUAGE"
COMMON_LOCAL_BIN_DIRS = ("/opt/homebrew/bin", "/usr/local/bin")
GROQ_BASE_URL = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1")
OPENAI_BASE_URL = os.getenv("STT_OPENAI_BASE_URL", "https://api.openai.com/v1")
XAI_STT_BASE_URL = os.getenv("XAI_STT_BASE_URL", "https://api.x.ai/v1")
ELEVENLABS_STT_BASE_URL = os.getenv("ELEVENLABS_STT_BASE_URL", "https://api.elevenlabs.io/v1")
# DeepInfra STT base URL is resolved via hermes_cli.models.deepinfra_base_url (shared).
SUPPORTED_FORMATS = {".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm", ".ogg", ".oga", ".opus", ".aac", ".flac", ".caf"}
LOCAL_NATIVE_AUDIO_FORMATS = {".wav", ".aiff", ".aif"}
MAX_FILE_SIZE = 25 * 1024 * 1024 # 25 MB
# Known model sets for auto-correction
OPENAI_MODELS = {"whisper-1", "gpt-4o-mini-transcribe", "gpt-4o-transcribe", "gpt-transcribe"}
GROQ_MODELS = {"whisper-large-v3", "whisper-large-v3-turbo", "distil-whisper-large-v3-en"}
# Providers with native handlers. Kept in sync with ``agent.transcription_registry._BUILTIN_NAMES``
# (a regression test fails on drift); plugins may not register under these names and the
# dispatcher short-circuits them before command/plugin lookup.
# The plugin hook from issue #30398-style follow-up rejects plugins registering under any of these names;
# the dispatcher in ``transcribe_audio`` short-circuits them defensively as well.
BUILTIN_STT_PROVIDERS = frozenset({
"local", "local_command", "groq", "openai", "mistral", "xai", "elevenlabs", "deepinfra"})
# Built-in providers that upload audio to a remote API.
CLOUD_STT_PROVIDERS = frozenset(BUILTIN_STT_PROVIDERS - {"local", "local_command"})
def _error_result(error: str, **extra: Any) -> Dict[str, Any]:
"""Standard failure envelope shared by every provider and validator."""
return {"success": False, "transcript": "", "error": error, **extra}
class STTResponseError(ValueError):
"""A provider answered with a structured object carrying no transcript text.
Raised by ``_extract_transcript_text`` when an SDK object or JSON body reports an
``error`` (or neither ``text`` nor ``error``) instead of a usable transcript. The
message is the provider's own, so the STT failure paths surface it verbatim rather
than stringifying the response object into its repr (#78098)."""
def _ok_result(transcript: str, provider: str) -> Dict[str, Any]:
return {"success": True, "transcript": transcript, "provider": provider}
def _lazy_ensure_quietly(extra: str) -> None:
"""Best-effort ``pm.ensure_import(extra)``; failures are swallowed.
Installs are gated by ``security.allow_lazy_installs`` inside pm."""
try:
import pm
pm.ensure_import(extra)
except Exception:
pass
def _process_error_detail(exc: "subprocess.CalledProcessError") -> str:
"""stderr > stdout > str(exc) for a failed helper binary."""
for output in (exc.stderr, exc.stdout):
if isinstance(output, bytes):
detail = output.decode("utf-8", errors="replace").strip()
else:
detail = str(output or "").strip()
if detail:
return detail
return str(exc)
def _log_prompt_unsupported(label: str) -> None:
logger.debug("%s does not support transcription prompts — proceeding without the prompt.", label)
def _config_number(cfg: Dict[str, Any], key: str, default, cast=float):
"""Read ``cfg[key]`` through *cast*, falling back to *default* on bad values."""
try:
return cast(cfg.get(key, default))
except (TypeError, ValueError):
return default