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
470 lines
25 KiB
Python
470 lines
25 KiB
Python
"""Cloud STT providers.
|
|
|
|
OpenAI-SDK-shaped backends (groq, openai, deepinfra), Mistral Voxtral, REST multipart
|
|
backends (xAI, ElevenLabs), and OpenAI audio credential resolution (config > keyless
|
|
local server > env > managed Nous gateway). Facade-owned state and helpers
|
|
(``_HAS_OPENAI``, ``_resolve_provider_key``, ``_resolve_stt_language``, ``_load_stt_config``)
|
|
are read lazily from ``tools.transcription_tools``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Dict, Optional
|
|
from urllib.parse import urljoin
|
|
|
|
from utils import is_truthy_value
|
|
from tools.transcription_audio import _transcode_audio_for_stt
|
|
from tools.transcription_common import (
|
|
DEFAULT_GROQ_STT_MODEL, DEFAULT_STT_MODEL, ELEVENLABS_STT_BASE_URL, GROQ_BASE_URL, GROQ_MODELS,
|
|
OPENAI_BASE_URL, OPENAI_MODELS, STTResponseError, XAI_STT_BASE_URL, _error_result, _get_stt_section,
|
|
_lazy_ensure_quietly, _log_prompt_unsupported, _ok_result)
|
|
|
|
# Log-record parity with the origin module.
|
|
logger = logging.getLogger("tools.transcription_tools")
|
|
# Voxtral-style ``language xx <asr_text> ...`` prefix some SDK responses carry.
|
|
_ASR_TEXT_RE = re.compile(
|
|
r"\s*language\s+[\w.-]+(?:\s*<audio_language>[^<]*</audio_language>)?\s*<asr_text>\s*(?P<text>.*)",
|
|
flags=re.IGNORECASE | re.DOTALL)
|
|
|
|
|
|
def _has_xai_stt_credentials() -> bool:
|
|
from tools.xai_http import resolve_xai_http_credentials
|
|
return bool(resolve_xai_http_credentials().get("api_key"))
|
|
|
|
|
|
def _with_openai_client(api_key: str, base_url: Optional[str], file_path: str, log_label: str, body):
|
|
"""Run ``body(client)`` on a fresh OpenAI SDK client; always closed. Transport shape comes from
|
|
``stt.openai.timeout`` / ``stt.openai.max_retries`` (defaults 60s, 1 retry; #112939) for every
|
|
rider of this helper — openai, groq and deepinfra — because a self-hosted endpoint's model cold
|
|
start exceeds the old fixed 30s and lost the voice message at the first attempt.
|
|
Errors map to the shared envelope. APIConnectionError is checked before APITimeoutError (its
|
|
subclass) so timeouts report as connection errors, as they always have."""
|
|
try:
|
|
from openai import OpenAI
|
|
from tools.transcription_common import DEFAULT_STT_TIMEOUT, _config_number
|
|
from tools.transcription_tools import _load_stt_config
|
|
openai_config = _get_stt_section(_load_stt_config(), "openai")
|
|
client = OpenAI(
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
timeout=_config_number(openai_config, "timeout", DEFAULT_STT_TIMEOUT),
|
|
max_retries=_config_number(openai_config, "max_retries", 1, cast=int),
|
|
)
|
|
try:
|
|
return body(client)
|
|
finally:
|
|
close = getattr(client, "close", None)
|
|
if callable(close):
|
|
close()
|
|
except Exception as exc:
|
|
try:
|
|
from openai import APIError, APIConnectionError, APITimeoutError
|
|
except ImportError: # pragma: no cover — callers gate on _HAS_OPENAI
|
|
APIError = APIConnectionError = APITimeoutError = ()
|
|
if isinstance(exc, PermissionError):
|
|
return _error_result(f"Permission denied: {file_path}")
|
|
if isinstance(exc, STTResponseError):
|
|
# The provider's own message already reads as an error; no "Transcription failed:" prefix.
|
|
return _error_result(str(exc))
|
|
for cls, label in ((APIConnectionError, "Connection error"), (APITimeoutError, "Request timeout"),
|
|
(APIError, "API error")):
|
|
if isinstance(exc, cls):
|
|
return _error_result(f"{label}: {exc}")
|
|
logger.error("%s transcription failed: %s", log_label, exc, exc_info=True)
|
|
return _error_result(f"Transcription failed: {exc}")
|
|
|
|
|
|
def _cloud_failure(exc: BaseException, file_path: str, label: str, detail: Optional[str] = None) -> Dict[str, Any]:
|
|
"""Map a REST/SDK provider exception to the shared envelope (``label`` e.g. ``"xAI STT transcription"``)."""
|
|
if isinstance(exc, PermissionError):
|
|
return _error_result(f"Permission denied: {file_path}")
|
|
if isinstance(exc, STTResponseError):
|
|
return _error_result(str(exc))
|
|
logger.error("%s failed: %s", label, exc, exc_info=True)
|
|
return _error_result(f"{label} failed: {exc if detail is None else detail}")
|
|
|
|
|
|
def _sdk_prompt_kwargs(language: Optional[str], prompt: Optional[str]) -> Dict[str, Any]:
|
|
"""``language``/``prompt`` create-kwargs, each only when set so the bare request stays byte-identical."""
|
|
return {key: value for key, value in (("language", language), ("prompt", prompt)) if value}
|
|
|
|
|
|
def _transcribe_groq(
|
|
file_path: str, model_name: str, *, language: Optional[str] = None, prompt: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""Transcribe via the Groq Whisper API; language: hook > ``stt.groq.language`` > ``stt.language`` > env > auto."""
|
|
from tools.transcription_tools import _HAS_OPENAI, _resolve_provider_key, _resolve_stt_language
|
|
api_key = _resolve_provider_key("GROQ_API_KEY", "groq")
|
|
if not api_key:
|
|
return _error_result("GROQ_API_KEY not set")
|
|
if not _HAS_OPENAI:
|
|
return _error_result("openai package not installed")
|
|
if model_name in OPENAI_MODELS: # auto-correct an OpenAI-only model
|
|
logger.info("Model %s not available on Groq, using %s", model_name, DEFAULT_GROQ_STT_MODEL)
|
|
model_name = DEFAULT_GROQ_STT_MODEL
|
|
language = language or _resolve_stt_language("groq")
|
|
|
|
def _run(client):
|
|
with open(file_path, "rb") as audio_file:
|
|
transcription = client.audio.transcriptions.create(file=audio_file, model=model_name,
|
|
response_format="text",
|
|
**_sdk_prompt_kwargs(language, prompt))
|
|
# Shared normalizer, not ``str(transcription)``: a Groq-compatible endpoint answering with a
|
|
# structured error object must not have its repr logged and returned as the transcript (#78098).
|
|
transcript_text = _extract_transcript_text(transcription)
|
|
logger.info("Transcribed %s via Groq API (%s, lang=%s, %d chars)",
|
|
Path(file_path).name, model_name, language or "auto", len(transcript_text))
|
|
return _ok_result(transcript_text, "groq")
|
|
return _with_openai_client(api_key, GROQ_BASE_URL, file_path, "Groq", _run)
|
|
|
|
|
|
def _transcribe_openai(
|
|
file_path: str, model_name: str, *, api_key: Optional[str] = None,
|
|
base_url: Optional[str] = None, provider_label: str = "openai", language: Optional[str] = None,
|
|
prompt: Optional[str] = None) -> Dict[str, Any]:
|
|
"""Transcribe via the OpenAI ``audio.transcriptions.create`` SDK shape, shared by every
|
|
OpenAI-compatible endpoint (DeepInfra etc.): explicit ``api_key``/``base_url`` skip the
|
|
OpenAI-only auth chain; ``provider_label`` names the response's provider."""
|
|
from tools.transcription_tools import _HAS_OPENAI, _resolve_stt_language
|
|
if api_key is None:
|
|
try:
|
|
api_key, fallback_base = _resolve_openai_audio_client_config()
|
|
except ValueError as exc:
|
|
return _error_result(str(exc))
|
|
base_url = base_url or fallback_base
|
|
# Language: hook override > stt.<provider>.language > stt.language > env > auto.
|
|
language = language or _resolve_stt_language(provider_label)
|
|
if not _HAS_OPENAI:
|
|
return _error_result("openai package not installed")
|
|
# Auto-correct a Groq-only model on the native OpenAI path only (third-party endpoints may serve it).
|
|
if provider_label == "openai" and model_name in GROQ_MODELS:
|
|
logger.info("Model %s not available on OpenAI, using %s", model_name, DEFAULT_STT_MODEL)
|
|
model_name = DEFAULT_STT_MODEL
|
|
|
|
def _run(client):
|
|
from openai import APIStatusError
|
|
|
|
def _create_transcription(path: str):
|
|
create_kwargs: Dict[str, Any] = {
|
|
"model": model_name, "response_format": "text" if model_name == "whisper-1" else "json",
|
|
}
|
|
if language:
|
|
# gpt-transcribe takes a ``languages`` list and rejects the legacy field.
|
|
if model_name == "gpt-transcribe":
|
|
create_kwargs["extra_body"] = {"languages": [language]}
|
|
else:
|
|
create_kwargs["language"] = language
|
|
logger.debug("Using language hint '%s' for OpenAI STT", language)
|
|
if prompt: # only when set so the bare request stays byte-identical
|
|
create_kwargs["prompt"] = prompt
|
|
with open(path, "rb") as audio_file:
|
|
return client.audio.transcriptions.create(file=audio_file, **create_kwargs)
|
|
with tempfile.TemporaryDirectory(prefix="hermes-stt-") as work_dir:
|
|
try:
|
|
transcription = _create_transcription(file_path)
|
|
except APIStatusError as exc:
|
|
# 400 + container hint is the documented rejection; some OpenAI-compatible endpoints
|
|
# reject a container with a bare 5xx instead (#81644). A 5xx is ambiguous, so it earns
|
|
# the same single transcode retry and, when no transcode is possible, its own error.
|
|
is_server_error = (exc.status_code or 0) >= 500
|
|
if not is_server_error and not any(k in str(exc).lower() for k in ("unsupported", "corrupted", "invalid file")):
|
|
raise
|
|
# Newer models reject containers whisper-1 accepted (Ogg/Opus voice notes): transcode, retry once.
|
|
converted_path, transcode_error = _transcode_audio_for_stt(file_path, work_dir)
|
|
if transcode_error:
|
|
if is_server_error:
|
|
raise
|
|
return _error_result(transcode_error)
|
|
logger.info("Retrying %s STT after transcoding %s to m4a (API rejected the original container)",
|
|
provider_label, Path(file_path).name)
|
|
transcription = _create_transcription(converted_path)
|
|
transcript_text = _extract_transcript_text(transcription)
|
|
logger.info("Transcribed %s via %s (%s, %d chars)",
|
|
Path(file_path).name, provider_label, model_name, len(transcript_text))
|
|
return _ok_result(transcript_text, provider_label)
|
|
return _with_openai_client(api_key, base_url, file_path, provider_label, _run)
|
|
|
|
|
|
def _transcribe_mistral(
|
|
file_path: str, model_name: str, *, language: Optional[str] = None, prompt: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""Transcribe with the ``mistralai`` SDK (``/v1/audio/transcriptions``); requires ``MISTRAL_API_KEY``."""
|
|
from tools.transcription_tools import _resolve_provider_key, _resolve_stt_language
|
|
api_key = _resolve_provider_key("MISTRAL_API_KEY", "mistral")
|
|
if not api_key:
|
|
return _error_result("MISTRAL_API_KEY not set")
|
|
try:
|
|
_lazy_ensure_quietly("mistral")
|
|
from mistralai.client import Mistral
|
|
with Mistral(api_key=api_key) as client, open(file_path, "rb") as audio_file:
|
|
# Language: hook override > stt.mistral.language > stt.language > env > auto.
|
|
language = language or _resolve_stt_language("mistral")
|
|
result = client.audio.transcriptions.complete(
|
|
model=model_name, file={"content": audio_file, "file_name": Path(file_path).name},
|
|
**_sdk_prompt_kwargs(language, prompt))
|
|
transcript_text = _extract_transcript_text(result)
|
|
logger.info("Transcribed %s via Mistral API (%s, %d chars)",
|
|
Path(file_path).name, model_name, len(transcript_text))
|
|
return _ok_result(transcript_text, "mistral")
|
|
except Exception as e:
|
|
return _cloud_failure(e, file_path, "Mistral transcription", type(e).__name__)
|
|
|
|
|
|
# ---- REST multipart backends (xAI, ElevenLabs) ----------------------------
|
|
def _post_audio_multipart(url: str, headers: Dict[str, str], file_path: str, data: Dict[str, str]):
|
|
import requests
|
|
with open(file_path, "rb") as audio_file:
|
|
return requests.post(url, headers=headers, files={"file": (Path(file_path).name, audio_file)},
|
|
data=data, timeout=120)
|
|
|
|
|
|
def _rest_provider(
|
|
file_path: str, provider: str, label: str, post: Callable[[], Any], extract_detail,
|
|
extract_text, log: Callable[[str, Dict[str, Any]], None]) -> Dict[str, Any]:
|
|
"""Shared multipart REST flow: ``post()`` -> ``log(text, body)`` -> ok envelope. Non-200 ->
|
|
``"<label> API error (HTTP n): detail"`` (JSON detail via *extract_detail*, else the first 300
|
|
body chars); empty text -> the ``no_speech`` envelope (silence is non-fatal); exceptions ->
|
|
``_cloud_failure``."""
|
|
try:
|
|
response = post()
|
|
if response.status_code != 200:
|
|
try:
|
|
detail = extract_detail(response.json()) or response.text[:300]
|
|
except Exception:
|
|
detail = response.text[:300]
|
|
return _error_result(f"{label} API error (HTTP {response.status_code}): {detail}")
|
|
body = response.json()
|
|
transcript_text = extract_text(body)
|
|
if not transcript_text:
|
|
return _error_result(f"{label} returned empty transcript", no_speech=True)
|
|
log(transcript_text, body)
|
|
return _ok_result(transcript_text, provider)
|
|
except Exception as e:
|
|
return _cloud_failure(e, file_path, f"{label} transcription")
|
|
|
|
|
|
def _transcribe_xai(
|
|
file_path: str, model_name: str, *, language: Optional[str] = None, prompt: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""Transcribe via xAI ``POST /v1/stt`` (multipart). Supports ITN, diarization, word timestamps."""
|
|
from hermes_cli.config import get_env_value
|
|
from tools.transcription_tools import _load_stt_config, _resolve_stt_language
|
|
from tools.xai_http import resolve_xai_http_credentials
|
|
if prompt:
|
|
_log_prompt_unsupported("STT provider 'xai'")
|
|
# STT is API-billed: prefer the explicit XAI_API_KEY over the xAI OAuth/Grok-subscription
|
|
# credential, which may be valid for Grok yet hit spending-limit errors on /v1/stt.
|
|
direct_api_key = str(get_env_value("XAI_API_KEY") or "").strip()
|
|
creds = {"provider": "xai", "api_key": direct_api_key,
|
|
"base_url": str(get_env_value("XAI_BASE_URL") or "https://api.x.ai/v1").strip().rstrip("/")
|
|
} if direct_api_key else resolve_xai_http_credentials()
|
|
api_key = str(creds.get("api_key") or "").strip()
|
|
if not api_key:
|
|
return _error_result("No xAI credentials found. Configure xAI OAuth in `hermes model` or set XAI_API_KEY")
|
|
stt_config = _load_stt_config()
|
|
xai_config = stt_config.get("xai") or {}
|
|
|
|
def _resolve_base_url(resolved_creds: Dict[str, str]) -> str:
|
|
# OAuth bearers are pinned to the resolver-validated origin; overrides apply to API keys only.
|
|
url = resolved_creds.get("base_url")
|
|
if resolved_creds.get("provider") != "xai-oauth":
|
|
url = xai_config.get("base_url") or get_env_value("XAI_STT_BASE_URL") or url
|
|
return str(url or XAI_STT_BASE_URL).strip().rstrip("/")
|
|
|
|
# Language: hook override > stt.xai.language > stt.language > env.
|
|
language = language or _resolve_stt_language("xai", stt_config) or ""
|
|
|
|
def _post() -> Any:
|
|
from tools.xai_http import hermes_xai_user_agent
|
|
data: Dict[str, str] = {"language": language} if language else {}
|
|
data.update({flag: "true" for flag, default in (("format", True), ("diarize", False))
|
|
if is_truthy_value(xai_config.get(flag, default))})
|
|
|
|
def _post_transcription(bearer: str, endpoint_base_url: str):
|
|
headers = {"Authorization": f"Bearer {bearer}", "User-Agent": hermes_xai_user_agent()}
|
|
return _post_audio_multipart(f"{endpoint_base_url}/stt", headers, file_path, data)
|
|
|
|
response = _post_transcription(api_key, _resolve_base_url(creds))
|
|
if response.status_code in {401, 403} and creds.get("provider") == "xai-oauth":
|
|
logger.info("xAI STT got HTTP %d; refreshing OAuth credentials and retrying once", response.status_code)
|
|
try:
|
|
refreshed_creds = resolve_xai_http_credentials(force_refresh=True, api_key_hint=api_key)
|
|
refreshed_key = str(refreshed_creds.get("api_key") or "").strip()
|
|
if refreshed_key and refreshed_key != api_key:
|
|
response = _post_transcription(refreshed_key, _resolve_base_url(refreshed_creds))
|
|
except Exception as retry_exc:
|
|
logger.warning("xAI STT OAuth refresh-and-retry after HTTP %d failed: %s",
|
|
response.status_code, retry_exc)
|
|
return response
|
|
|
|
def _log(transcript_text: str, result: Dict[str, Any]) -> None:
|
|
logger.info("Transcribed %s via xAI Grok STT (lang=%s, %.1fs audio, %d chars)", Path(file_path).name,
|
|
result.get("language", language), result.get("duration", 0), len(transcript_text))
|
|
|
|
return _rest_provider(file_path, "xai", "xAI STT", _post, lambda body: body.get("error", {}).get("message", ""),
|
|
lambda body: body.get("text", "").strip(), _log)
|
|
|
|
|
|
def _elevenlabs_error_detail(err_body: Dict[str, Any]) -> str:
|
|
error_value = err_body.get("detail") or err_body.get("error")
|
|
if isinstance(error_value, dict):
|
|
return str(error_value.get("message") or error_value)
|
|
return str(error_value) if error_value else ""
|
|
|
|
|
|
def _transcribe_elevenlabs(
|
|
file_path: str, model_name: str, *, language: Optional[str] = None, prompt: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""Transcribe using ElevenLabs Scribe STT API."""
|
|
from hermes_cli.config import get_env_value
|
|
from tools.transcription_tools import _load_stt_config, _resolve_provider_key, _resolve_stt_language
|
|
if prompt:
|
|
_log_prompt_unsupported("STT provider 'elevenlabs'")
|
|
api_key = _resolve_provider_key("ELEVENLABS_API_KEY", "elevenlabs")
|
|
if not api_key:
|
|
return _error_result("ELEVENLABS_API_KEY not set")
|
|
stt_config = _load_stt_config()
|
|
elevenlabs_config = stt_config.get("elevenlabs") or {}
|
|
base_url = str(
|
|
elevenlabs_config.get("base_url") or get_env_value("ELEVENLABS_STT_BASE_URL") or ELEVENLABS_STT_BASE_URL
|
|
).strip().rstrip("/")
|
|
# Language: hook override > stt.elevenlabs.language(_code) > stt.language.
|
|
language_code = language or _resolve_stt_language("elevenlabs", stt_config, extra_keys=("language_code",)) or ""
|
|
|
|
def _post() -> Any:
|
|
data: Dict[str, str] = {
|
|
"model_id": model_name,
|
|
"tag_audio_events": str(is_truthy_value(elevenlabs_config.get("tag_audio_events", False))).lower(),
|
|
"diarize": str(is_truthy_value(elevenlabs_config.get("diarize", False))).lower(),
|
|
**({"language_code": language_code} if language_code else {})}
|
|
return _post_audio_multipart(f"{base_url}/speech-to-text", {"xi-api-key": api_key}, file_path, data)
|
|
|
|
def _log(transcript_text: str, _body: Dict[str, Any]) -> None:
|
|
logger.info("Transcribed %s via ElevenLabs Scribe (%s, %d chars)",
|
|
Path(file_path).name, model_name, len(transcript_text))
|
|
|
|
return _rest_provider(file_path, "elevenlabs", "ElevenLabs STT", _post, _elevenlabs_error_detail,
|
|
_extract_transcript_text, _log)
|
|
|
|
|
|
def _transcribe_deepinfra(
|
|
file_path: str, model_name: str, *, language: Optional[str] = None, prompt: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""Resolve DeepInfra credentials/model (shared ``hermes_cli.models`` helpers), then delegate to :func:`_transcribe_openai`."""
|
|
from tools.transcription_tools import _load_stt_config, _resolve_provider_key
|
|
api_key = _resolve_provider_key("DEEPINFRA_API_KEY", "deepinfra")
|
|
if not api_key:
|
|
return _error_result("DEEPINFRA_API_KEY not set")
|
|
from hermes_cli.models import deepinfra_base_url, deepinfra_model_ids
|
|
# ``stt.deepinfra: null`` in YAML yields None, not {} — coalesce.
|
|
base_url = deepinfra_base_url(_get_stt_section(_load_stt_config(), "deepinfra"))
|
|
model_name = model_name or next(iter(deepinfra_model_ids("stt")), None)
|
|
if not model_name:
|
|
return _error_result(
|
|
"No DeepInfra STT model available. Pin one in config.yaml under stt.deepinfra.model, "
|
|
"or check connectivity to api.deepinfra.com so the live catalog can be fetched.")
|
|
return _transcribe_openai(file_path, model_name, api_key=api_key, base_url=base_url,
|
|
provider_label="deepinfra", language=language, prompt=prompt)
|
|
|
|
|
|
# ---- OpenAI audio credential resolution -----------------------------------
|
|
def _is_local_or_private_url(url: str) -> bool:
|
|
"""True for loopback/RFC-1918/LAN-internal hosts, where an empty ``stt.openai.api_key`` is acceptable
|
|
(local OpenAI-compatible servers ignore the auth header — no sham ``api_key: not-needed`` needed)."""
|
|
try:
|
|
from urllib.parse import urlparse
|
|
import ipaddress
|
|
host = (urlparse(url).hostname or "").lower()
|
|
if host == "localhost" or host.endswith((".local", ".lan", ".internal")):
|
|
return True
|
|
addr = ipaddress.ip_address(host) # raises for "" and non-IP hostnames
|
|
return addr.is_private or addr.is_loopback
|
|
except Exception: # unparsable URL or non-IP hostname
|
|
return False
|
|
|
|
|
|
def _direct_openai_credentials(cfg_api_key: str, cfg_base_url: str) -> Optional[tuple[str, str]]:
|
|
"""Direct-credential ladder: config key > keyless local base_url (placeholder key so the SDK
|
|
constructs a client) > env key; None if none apply."""
|
|
from tools.tool_backend_helpers import resolve_openai_audio_api_key
|
|
if cfg_api_key:
|
|
return cfg_api_key, (cfg_base_url or OPENAI_BASE_URL)
|
|
# A local OpenAI-compatible server needs no key — send a placeholder so the SDK doesn't refuse to
|
|
# construct a client (#25193, credit @nnnet).
|
|
if cfg_base_url and _is_local_or_private_url(cfg_base_url):
|
|
return "not-needed", cfg_base_url
|
|
direct_api_key = resolve_openai_audio_api_key()
|
|
return (direct_api_key, OPENAI_BASE_URL) if direct_api_key else None
|
|
|
|
|
|
def _resolve_openai_audio_client_config() -> tuple[str, str]:
|
|
"""``(api_key, base_url)`` for the OpenAI STT client, strict on the stored ``stt`` selection:
|
|
``"nous"`` -> managed gateway ONLY (a direct OPENAI_API_KEY must NOT override it); any other
|
|
stored provider -> direct credentials ONLY (no silent managed fallback); never-configured ->
|
|
legacy ladder: direct credentials, then the managed gateway. Failures raise ValueError."""
|
|
from tools.transcription_tools import _load_stt_config
|
|
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
|
from tools.tool_backend_helpers import (
|
|
NOUS_MANAGED_PROVIDER, managed_nous_tools_enabled, nous_tool_gateway_unavailable_message,
|
|
read_selection, selection_error)
|
|
openai_cfg = _load_stt_config().get("openai") or {}
|
|
selected = read_selection("stt")
|
|
|
|
def _managed() -> Optional[tuple[str, str]]:
|
|
gateway = resolve_managed_tool_gateway("openai-audio")
|
|
if gateway is None:
|
|
return None
|
|
return gateway.nous_user_token, urljoin(f"{gateway.gateway_origin.rstrip('/')}/", "v1")
|
|
|
|
if selected == NOUS_MANAGED_PROVIDER:
|
|
managed = _managed()
|
|
if managed is None:
|
|
raise ValueError(selection_error("stt", NOUS_MANAGED_PROVIDER,
|
|
"the Nous Tool Gateway is not available (not entitled or unreachable)"))
|
|
return managed
|
|
direct = _direct_openai_credentials(openai_cfg.get("api_key", ""), openai_cfg.get("base_url", ""))
|
|
if direct is not None:
|
|
return direct
|
|
if selected is not None:
|
|
raise ValueError(selection_error(
|
|
"stt", selected,
|
|
"neither stt.openai.api_key in config nor VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY is set"))
|
|
managed = _managed()
|
|
if managed is None:
|
|
message = "Neither stt.openai.api_key in config nor VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY is set"
|
|
if managed_nous_tools_enabled():
|
|
message += ". " + nous_tool_gateway_unavailable_message("managed OpenAI audio for transcription")
|
|
raise ValueError(message)
|
|
return managed
|
|
|
|
|
|
def _extract_transcript_text(transcription: Any) -> str:
|
|
"""Normalize text / object / dict transcription responses to a plain string.
|
|
|
|
A *structured* response (SDK object or JSON dict) whose ``text`` is missing or
|
|
non-string must never reach ``str(transcription)``: that renders the object repr
|
|
(``Transcription(text=None, logprobs=None, usage=None, error='...')``), which
|
|
every caller then logged and returned as a successful transcript, and the desktop
|
|
injected as the user's message (#78098). Such a response raises its provider
|
|
``error`` instead, so the callers' existing failure paths report it; a structured
|
|
response carrying neither text nor error is an error in its own right.
|
|
Unrecognized scalars keep the legacy stringification."""
|
|
if isinstance(transcription, str):
|
|
text = transcription.strip()
|
|
else:
|
|
is_mapping = isinstance(transcription, dict)
|
|
value = transcription.get("text") if is_mapping else getattr(transcription, "text", None)
|
|
if isinstance(value, str):
|
|
text = value.strip()
|
|
elif is_mapping or hasattr(transcription, "text"):
|
|
error = transcription.get("error") if is_mapping else getattr(transcription, "error", None)
|
|
raise STTResponseError(str(error) if error else "Transcription response contained no text")
|
|
else:
|
|
text = str(transcription).strip()
|
|
match = _ASR_TEXT_RE.match(text)
|
|
return match.group("text").strip() if match else text
|