- parametrize the OAuth retry regression over HTTP 401 and 403 so both
documented rejection statuses are exercised
- reword the retry-failure warning: the except block also covers the
retried request, not just the credential refresh
- drop docs/proof/ from the PR diff; the live-proof screenshot now lives
on the fork's proof-assets-xai-stt-oauth-retry branch and stays linked
from the PR description
Follow-ups for the salvaged wave: the auto-detect legacy-error test now
stubs the split validators, the unknown-command-provider test expects
the new provider_not_registered error, and _transcribe_local tolerates
a null stt.local config section again.
Two small fresh fixes on top of the salvage wave:
- Wrap the check-then-load of the module-global faster-whisper model in a
double-checked threading.Lock so concurrent voice messages can't both
download/load the model (#24767).
- Treat an empty stt.openai.api_key as no-auth when stt.openai.base_url
points at a loopback/RFC-1918/.local host, so local OpenAI-compatible
STT servers (faster-whisper-server, speaches, vLLM whisper) work
without a sham api_key value. Reimplements the idea from PR #25193 —
credit @nnnet.
Co-authored-by: nnnet <nnnet@users.noreply.github.com>
Newer OpenAI transcription models (gpt-4o-transcribe, gpt-4o-mini-transcribe)
reject some containers the legacy whisper-1 endpoint accepted -- notably the
Ogg/Opus voice notes messaging platforms deliver -- returning a 400
'corrupted or unsupported' error, so voice-note transcription fails for users
on those models even though SUPPORTED_FORMATS still advertises .ogg/.aac/.flac.
Wrap the OpenAI upload: on a format-related BadRequestError, transcode the
source to a compact 16 kHz mono AAC .m4a via ffmpeg and retry once. This is
model-agnostic (no per-model format table to maintain) and adds no cost for
formats the endpoint already accepts.
Fixes#68719
Decode WeChat/QQ SILK v3 voice notes to WAV inside transcribe_audio so
any platform that caches a .silk file gets STT for free (same central-
normalization philosophy as the outbound container repair). pilk is
lazy-installed on first use (stt.silk in tools/lazy_deps.py) instead of
being added to the voice extra.
Fixes the inbound half of #32196.
(cherry picked from commit e5db79369d; reworked to compose with the
provider-scoped upload size cap and to lazy-dep pilk)
Log lazy-install failures at WARNING instead of DEBUG, with actionable
guidance about venv write-permission issues (the most common cause of
silent STT failures).
Salvaged from PR #46127 (transcription_tools half only — the gateway DM
hunks are superseded by main's neutral-marker enrichment design, and the
Docker/CI files were unrelated scope).
(cherry picked from commit d3e07bdaaa, reduced)
The /voice status command only checked for 'local', 'groq', and 'openai'
providers. Any other valid provider (local_command, mistral, xai,
elevenlabs, or custom command providers) fell through to the generic
MISSING message — even when transcription worked perfectly.
- Import _has_any_command_stt_provider (already defined, never imported)
- Add elif branches for local_command, mistral, xai, elevenlabs
- Add generic catch-all via _has_any_command_stt_provider() for
arbitrary custom command providers
Normalize the structured <asr_text> marker after extracting text from string, SDK object, and dictionary transcription responses. Preserve the current provider-aware STT configuration architecture.
Refreshes #8773 on current main.
Co-authored-by: angelos <angelos@oikos.lan.home.malaiwah.com>
Assisted-by: Codex:gpt-5.6
Replace
aise ImportError(str(e)) with pass in the except Exception
handler of _import_edge_tts(), _import_elevenlabs(), and
_import_mistral_client() so packages installed via PYTHONPATH or Docker
layered filesystems still work when lazy_deps.ensure() raises.
Also fix the Mistral STT path in transcription_tools.py which only
caught ImportError, not FeatureUnavailable.
Adds 6 regression tests using sys.modules fixtures (no
builtins.__import__ patching).
Force CPU (int8) for faster-whisper on Apple Silicon / Rosetta, where
ctranslate2's device=auto path can hard-abort in native code. Salvaged
from PR #28624 without the numpy pin change (main already moved on).
(cherry picked from commit 7edf2d5196, pyproject.toml hunk dropped)
- Add Blackwell-specific cuBLAS error marker to _CUDA_LIB_ERROR_MARKERS
- Allows CPU fallback on RTX 5090 (sm_120) when faster-whisper
reports CUBLAS_STATUS_NOT_SUPPORTED instead of loading successfully
- Add regression test for CUBLAS_STATUS_NOT_SUPPORTED path
Closes#17526
The local STT transcription function hardcoded device="auto" and
compute_type="auto" when instantiating WhisperModel, ignoring the
user's stt.local.device and stt.local.compute_type config values.
Closes#8319
Rework of #68509 per triage: hoist the duplicated per-tool
_resolve_provider_key helpers into one owner,
tools.tool_backend_helpers.resolve_provider_secret(), and migrate every
STT/TTS key lookup site to it.
Resolution order: explicit config.yaml value > profile secret scope /
env / ~/.hermes/.env > credential pool (checks both '<provider>' and
'custom:<provider>' pool keys, so keys added via 'hermes auth add
mistral' or declared under providers.<name> both resolve). Under an
active multiplex turn the profile scope stays authoritative — no pool
or .env fallback that could borrow another profile's key (composes with
the #69469 scope fix).
Coverage now includes GROQ_API_KEY, MISTRAL_API_KEY, ELEVENLABS_API_KEY,
DEEPINFRA_API_KEY, MINIMAX_API_KEY, GEMINI_API_KEY/GOOGLE_API_KEY, the
XAI_API_KEY fallback in resolve_xai_http_credentials, and the OpenAI
audio key (resolve_openai_audio_api_key now pool-aware for
OPENAI_API_KEY via 'hermes auth add openai-api').
Unit tests: fake pool entry proves each provider resolves from the pool
when env is empty; env still wins when set; config wins over both; a
multiplex scope miss never borrows the pool; pool read failures never
raise; tool-level wiring for STT, TTS, xAI, and OpenAI audio.
Fixes#68003
TTS/STT providers (Mistral, ElevenLabs) only checked env vars and
.env files via get_env_value(), ignoring keys stored via
'hermes auth add mistral' / 'hermes auth add elevenlabs'.
Add _resolve_provider_key() helper that falls back to the credential
pool (agent.credential_pool.load_pool) when the env var is unset.
Affected:
- tools/transcription_tools.py: 6 sites (provider selection + auto-detect)
- tools/tts_tool.py: 6 sites (synthesis + availability check)
Fixes#68003
`resolve_openai_audio_api_key()` reads the key that authenticates the audio
client straight from the process environment:
return (
os.getenv("VOICE_TOOLS_OPENAI_KEY", "")
or os.getenv("OPENAI_API_KEY", "")
).strip()
That value is not advisory. It flows through
`_resolve_openai_audio_client_config()` into `OpenAIClient(api_key=...)` for
TTS, and through `transcription_tools` for voice-note STT — both on the
per-turn tool path, inside the profile secret scope the gateway installs.
`agent/vertex_adapter` states the contract this breaks:
in a multiplex gateway serving several profiles from one process,
os.environ reflects whichever profile's .env happened to be loaded at
boot, not the profile the current turn belongs to. Reading it directly
here would let one profile mint tokens from — and get billed against —
a different profile's service-account file.
Reproduced with the real resolver, multiplexing on and profile A's scope
installed:
scope-aware get_secret -> sk-PROFILE-A-key
voice/STT resolver -> sk-PROFILE-B-key
So profile A's spoken reply and its users' voice notes are sent to OpenAI on
profile B's account, and billed there.
Route both reads through `agent.secret_scope.get_secret`, the same fix already
merged for the WeChat send path (#59662) and pending for QQ (#60420) — neither
covers the audio credential family. Under multiplexing the scope stays
authoritative, so a scope miss now yields no key instead of borrowing another
profile's; with multiplexing off `get_secret` falls through to `os.environ`
exactly as before, so single-profile deployments are untouched. The
VOICE_TOOLS_OPENAI_KEY > OPENAI_API_KEY precedence is unchanged.
Deliberately narrow: `fal_key_is_configured()` and
`has_direct_modal_credentials()` in this file are presence checks, not
authentication, and the former is already being reworked in open PR #20929.
tests/tools/test_tool_backend_helpers.py: the scope wins over another
profile's `os.environ`; a scope miss does not borrow another profile's key;
voice-key precedence holds inside a scope; and a control proves the
single-profile path still reads `os.environ`. The three isolation tests fail
on main; the control passes there. 320 passed across the helper, secret-scope,
and consumer suites (the fluctuating voice_mode/voice_cli failures are
pre-existing PulseAudio/ordering artifacts — the differing test passes 3/3 in
isolation on both main and this branch).
Per-job cron inference pins are now user-owned: the agent-facing cronjob
tool schema no longer exposes model/provider/base_url, and the registered
handler ignores them even if a model hallucinates the old parameters.
Users set pins via the dashboard, hermes cron create/edit --model/--provider,
or jobs.json directly — and once set, a pin sticks until the user changes it.
Existing agent-era pins are grandfathered untouched.
New cron.model / cron.model_provider config keys give the cron fleet its
own default model, independent of the chat model. Fire-time resolution:
per-job pin > cron.model > HERMES_MODEL > model.default. An axis covered
by the explicit cron-fleet default is deliberate routing, not drift, so
the #44585 fail-closed guard skips it — switching your chat model with
/model or hermes model no longer breaks unpinned cron fleets.
- tools/cronjob_tools.py: drop model param from agent schema + handler;
remove now-dead _resolve_model_override
- cron/scheduler.py: cron.model/model_provider resolution + per-axis
drift-guard skip
- cron/jobs.py: snapshot resolution mirrors the new precedence
- hermes_cli/subcommands/cron.py + hermes_cli/cron.py: --model/--provider
on hermes cron create/edit
- hermes_cli/config.py: cron.model / cron.model_provider defaults
- docs: cron.md model-resolution tip rewritten
One sniffer owns magic-byte container detection (Teknium's one-concept-
one-owner rule): the new tools/audio_container.py is used by
- gateway/platforms/base.py _sniff_audio_ext (inbound cache — PR #36166's
central sniffer, now covering AAC/ADTS, MP4-brand disambiguation, webm)
- gateway/platforms/signal.py _guess_extension (audio/AV branches
delegated; RIFF/WAVE fix from PR #50690 and M4A-brand fix from
PR #72490 now live centrally)
- tools/tts_tool.py _sniff_audio_container (outbound repair, PR #73072)
cache_audio_from_url inherits the sniff via cache_audio_from_bytes.
Adds tests/tools/test_audio_container.py covering every magic-byte type,
wrong-extension repair on the inbound cache, unknown passthrough, the
URL path, and Signal's delegation.
Telegram sends voice notes as .oga (OGG/Opus). SUPPORTED_FORMATS listed
.ogg but not .oga, so transcribe_audio rejected every Telegram voice note
with "Unsupported format: .oga" before reaching any STT backend. Add .oga
and .opus to the allowlist, with a regression test.
Skip hashing active copies when the bundled origin is unchanged, build rename and optional migration indexes lazily, and reuse one optional-skill directory index per sync.
Add regression coverage for unchanged copies, deferred modification detection, lazy rename recovery, and optional provenance scans.
Figma's mcp.figma.com register endpoint is a client_name allowlist
(Claude Code / Codex succeed; Hermes Agent 403s) and returns a client
secret while advertising auth_method=none, then requires the secret on
token exchange. Auto-set client_name + client_secret_post for Figma
hosts, pass oauth cfg through login/add paths, force interactive OAuth
for hermes mcp login from non-TTY desktop shells, and ship a catalog
entry. Proven: hermes mcp login figma → 26 tools.
Saying EXACTLY a configured stop phrase (default: 'stop') and nothing
else now ends the voice conversation instead of being sent to the agent
as a prompt. Match is deliberately strict — whole utterance,
case-insensitive, surrounding punctuation stripped — so 'stop doing
that and try X' still reaches the agent.
- tools/voice_mode.py: is_voice_stop_phrase() + voice.stop_phrases
config loader (default ['stop'], [] disables, malformed config falls
back safely).
- hermes_cli/voice.py: shared continuous loop (TUI + desktop) halts on
a stop phrase exactly like the silent-cycle limit (fires
on_silent_limit so every UI turns voice off); stop_continuous
force-transcribe path swallows the phrase without counting a silent
cycle.
- cli.py: classic CLI push-to-talk/continuous path and barge-in
utterance path disable voice mode on a stop phrase.
- Config default voice.stop_phrases: ['stop']; docs updated.
Tests: tests/tools/test_voice_stop_phrase.py (27 tests,
sabotage-verified: loop test fails when detection is disabled);
voice suites green (110 + 44 passed).
Root-cause fix for the 'TTS voice bubble broken' issue family (#57048,
#54589, #57213, #58845, #14841, #45557, #57049). Two class-level defects:
1. Several backends silently write MP3/WAV bytes into a .ogg output path
(Edge only emits MP3, Piper writes WAV, xAI writes MP3, some
OpenAI-compatible servers ignore response_format=opus). Platforms that
need real Ogg/Opus render 0-second/broken voice bubbles. Instead of
per-provider patches, text_to_speech_tool now sniffs magic bytes once
after synthesis (_sniff_audio_container) and repairs the container
centrally (_repair_ogg_container): ffmpeg transcode in place, or rename
to the honest extension when ffmpeg is unavailable. Covers every
current and future provider, including command providers and plugins.
2. want_opus only recognized Telegram, so Matrix/Feishu/WhatsApp/Signal
auto-TTS voice replies were synthesized as MP3 and delivered as broken
attachments. New OPUS_VOICE_PLATFORMS set covers all voice-bubble
platforms.
_convert_to_opus refactored onto a shared _ffmpeg_transcode_to_opus that
supports safe in-place transcodes (-f ogg forced muxer, temp file +
os.replace).
Tests: tests/tools/test_tts_container_repair.py (13 tests incl. a live
ffmpeg round-trip); full TTS suite green (153 + 187 passed).
Class-level fix for the 'STT transcribes the wrong language' issue family
(#55551, #50181 and siblings). Previously language handling was per-provider
chaos: local honoured stt.local.language, Groq/OpenAI/Mistral/DeepInfra sent
no language hint at all, xAI silently forced 'en', ElevenLabs used its own
language_code key, and there was no global setting.
- New _resolve_stt_language() helper: stt.<provider>.language >
stt.language (new global key) > HERMES_LOCAL_STT_LANGUAGE > auto-detect.
- Threaded through ALL providers: local, local_command, groq, openai,
mistral, xai, elevenlabs, deepinfra (shared OpenAI handler), command
providers, and plugin dispatch.
- xAI no longer forces English when nothing is configured (auto-detect).
- Mistral Voxtral now receives a language hint when configured.
- stt.groq.model is now honoured from config (previously env-only).
- DEFAULT_CONFIG gains stt.language, stt.groq, stt.xai, stt.mistral.language.
- Tests: tests/tools/test_stt_language_resolution.py (11 tests, sabotage-
verified) + full transcription suite green (236 passed).
Builds on cherry-picked contributor work from #19786 (@zombopanda),
#23161 (@materemias), #50684 (@BlackishGreen33).
`stt.groq: null` in config.yaml yields `groq_cfg = None`, so the
subsequent `.get("language")` raised AttributeError. Use `or {}`
(matching main's widened xai/local provider guards) and add a
`{"groq": None}` regression test confirming auto-detect stays intact.
Addresses hermes-sweeper review on #23161.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zc7Tgei4kav4n6WSsBq5F
- Normalize stt.groq.language: cast to str, strip, treat
empty/whitespace as unset (parity with xAI's str().strip()).
- Clarify "blank = auto-detect" inline comments in 4 docs/configs to
reflect the env-var fallback (HERMES_LOCAL_STT_LANGUAGE).
- Document that HERMES_LOCAL_STT_LANGUAGE also drives the local
faster-whisper provider, not just the CLI fallback.
- Add unit tests covering: omitted language when unset, config-supplied
language, env fallback, config-over-env precedence, whitespace
normalized to unset.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Read stt.groq.language from config.yaml (with HERMES_LOCAL_STT_LANGUAGE
env fallback) and forward it to the Groq Whisper API to skip
auto-detection on known-language audio. Omit when unset so Groq
auto-detects, preserving today's behavior. Bonus: swap xAI's hardcoded
env literal for the LOCAL_STT_LANGUAGE_ENV constant for consistency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add optional stt.openai.language config for OpenAI transcription. Forward non-empty hints to the API while preserving auto-detection when unset. Document the config-only setting, add its default, and cover configured and unset request arguments.
Direct tool HTTP calls already identified as Hermes-Agent, but the main
OpenAI-SDK chat path still sent OpenAI/Python. Set Hermes-Agent/<ver> for
api.x.ai clients (xai + xai-oauth) so normal text traffic is attributed correctly.
Tool-schema assembly at CLI/Desktop startup runs the browser-family
check_fns (browser, browser_cdp, browser_dialog, browser_vision). Each
of those gates called _get_cdp_override(), which resolves the configured
endpoint over HTTP (GET /json/version, timeout=10) — so a *stale*
browser.cdp_url pointing at a dead debug browser cost ~7 serial blocking
socket connects before the banner rendered. Measured on a real Windows
install with a dead http://[::1]:9222 config: 15.1s of an 18s launch,
with no warning or error — just mystery slowness. The value is easy to
leave behind: /browser connect writes a session-scoped env override, but
'hermes config set browser.cdp_url' persists forever while the debug
Chrome it pointed at dies on the next browser restart.
Split the helper:
- _get_cdp_override_raw() — returns the configured value (env var or
config.yaml) with zero network I/O. Used by every is-it-configured
gate: check_browser_requirements, _browser_cdp_check, _is_local_mode,
_is_local_backend, _navigation_session_key, _should_inject_engine
(via _is_local_mode), and the hermes doctor chromium-skip check.
- _get_cdp_override() — unchanged contract (raw + /json/version
resolution), now only called on paths that are about to connect:
session creation and the dialog-supervisor attach.
This follows the existing rule in check_browser_requirements ('do not
execute agent-browser --version here') and the browser.manage status
path, which already banned _get_cdp_override for exactly this reason
(test_browser_manage_status_does_not_call_get_cdp_override): schema
assembly must not perform blocking I/O.
A/B on the same machine, same dead endpoint: get_tool_definitions()
15.08s unpatched -> 1.89s patched, with browser_cdp/browser_dialog
still advertised (gate now keys off configuration, not reachability —
matching the documented lazy-supervisor contract in
_browser_dialog_check).
Adds a regression test asserting the browser_cdp check_fn never touches
the network.