Completes the half-done move snapshotted by the parent (wip commit 283f63b):
the tts.providers.<name> config layer (_get_named_provider_config,
_resolve_command_provider_config, _generate_command_tts, ...) now lives in
tools/tts_command_provider.py; tts_tool re-imports the historical names.
DEFAULT_OUTPUT_DIR was resolved once at import time, so long-lived
multi-profile runtimes (dashboard console, TUI/Desktop backend, cron,
kanban workers) kept writing synthesized audio into the launch
profile's cache/audio instead of the requesting profile's (#98749).
Same bug class and fix as skills_tool (f8723c478) and skills_sync
(#65828): keep the legacy module attribute for tests and external
patchers, but re-resolve from the live profile-scoped HERMES_HOME on
every synthesis call.
Extends the TTS lease from #100912 (4e3feb8bbb) beyond built-in local
engines: when the configured tts.provider is user-declared, acquiring
the first lease and releasing the last one now reach it, so a
self-hosted TTS server can preload its model when read-aloud / voice
conversation turns on and unload when it turns off (Discord request).
- agent/tts_provider.py: TTSProvider gains concrete no-op warm() /
release() (not abstract — existing plugins are unaffected).
- tools/tts_tool.py: _signal_user_tts_provider() forwards the lease
hook; plugin providers get warm()/release(), command providers run
optional `warm_command` / `release_command` (config.yaml, under
tts.providers.<name>) through the existing _run_command_tts helper
on a daemon thread — best-effort, output discarded, failures at
debug. warm_tts_provider() and release_tts_provider() call it.
- tests/tools/test_tts_lifecycle_leases.py: fake plugin provider and
fake command provider observe warm/release through acquire/release
lease (both fail on main with action == "noop").
- docs: features/tts.md — lease section, command-provider optional
keys table, plugin optional hooks.
Desktop "Read replies aloud" / voice conversation, TUI and CLI /voice tts
now hold a lease on the TTS engine. Acquiring pre-loads the configured
provider (piper/kittentts model into the same LRU slot synthesis reads;
lazily-installed cloud SDKs), so the first spoken reply no longer pays the
model load as dead air. Releasing the last lease across surfaces unloads
resident local models.
- tools/tts_tool.py: warm_tts_provider / release_tts_provider /
acquire_tts_lease / release_tts_lease over a _LOCAL_TTS_MODEL_CACHES
registry; piper/kittentts loaders extracted so warm-up and synthesis
share one resolution path.
- web_server: POST /api/audio/tts-lease (profile-scoped, off-loop,
failures reported in body never as HTTP errors).
- tui_gateway voice.toggle + cli.py /voice tts|on|off wire the lease.
- desktop: lib/tts-lease.ts (dedupe, per-lease serialization, latest
intent wins) driven from useComposerVoice; setTtsLease API client.
- docs: features/tts.md section.
Live (real piper, isolated HERMES_HOME): first synthesis 988ms cold →
92ms after the toggle warmed the engine; release drops the model.
Both _resolve_openai_audio_client_config resolvers now switch on the
stored provider string: 'nous' (or legacy use_gateway: true) => managed
openai-audio gateway only, erroring by selection name when unentitled —
the STT twin previously never read the stored gateway intent at all, so
a direct OPENAI_API_KEY silently overrode the Nous Subscription pick;
stored vendor => direct credentials only with a selection-naming error
on missing keys (no silent managed fallback); never-configured keeps the
legacy ladder. DEFAULT_CONFIG stops seeding stt.provider: local, and the
seeded value on existing configs is treated as no-selection so autodetect
keeps working for that installed base.
Rework of the #88049 inline early-return per review:
- resolve_xai_http_credentials gains an opt-in prefer_api_key flag that
checks the explicit XAI_API_KEY first and falls back to OAuth. The key
is read through tools.tool_backend_helpers.resolve_provider_secret
(config -> profile secret scope -> env/.env -> credential pool) so the
preferred path enforces the same scope policy as the existing fallback
branch, including failing closed under a multiplexed gateway turn.
- The preferred path's base URL honors HERMES_XAI_BASE_URL then
XAI_BASE_URL behind hermes_cli.auth._xai_validate_inference_base_url,
mirroring the OAuth branch (a foreign origin can't exfiltrate the key).
- x_search's _resolve_xai_bearer now calls the shared resolver with
prefer_api_key=True instead of re-implementing precedence inline (#88040).
- tools/tts_tool.py _generate_xai_tts converted to the same flag — same
root cause for /v1/tts 403s (#87045, supersedes the inline shape in
#87081 by @enwaiax).
- Regression tests retargeted at the tools.xai_http.get_env_value seam and
the shared resolver; added coverage for the flag's OAuth fallback,
HERMES_XAI_BASE_URL + origin validation, default-order stability, and a
profile-scope-only key on the preferred path.
- Docs: x-search authentication section now states the explicit API key
wins (metered billing implication).
The write_file / patch file tools hard-denied ~/.ssh/config as a
"protected system/credential file", while the terminal tool only
*asked* for approval on ~/.ssh writes. That inconsistency meant a write
to ~/.ssh/config was refused via write_file but succeeded via terminal
after an approval prompt -- the same operation flip-flopping between
denied and OK depending on which tool ran it.
The SSH client config carries no private-key material, and editing it
(host aliases, ProxyJump, VS Code Remote-SSH targets) is a routine,
user-initiated task. It CAN carry ProxyCommand / Match exec directives
that run commands, so a free write is still inappropriate -- approval,
not a flat refusal, is the right policy, matching what the terminal tool
already does.
Changes:
- agent/file_safety.py: remove ~/.ssh/config from the flat credential
deny; add build_write_approval_paths() + is_write_approval_required(),
and short-circuit it out of the ~/.ssh/ prefix deny so the file is
allowed at the classifier layer. Private keys, authorized_keys, and
everything else under ~/.ssh/ stay hard-denied.
- tools/file_tools.py: _check_approval_required_write() routes ssh config
writes through the shared _run_approval_gate (once/session/always,
honors --yolo, fail-closed with no human), wired into write_file_tool
and patch_tool right after the protected-instruction gate.
- Non-interactive consumers fail closed: the ACP file bridge
(copilot_acp_client) rejects approval-required paths outright, and the
TTS output-path picker refuses them as before.
- Docs + tests updated (security.md exception note;
TestSshConfigApprovalGate covers config approval-gated, keys still
hard-denied).
Cold CLI time-to-banner was ~1.8s (hermes) / ~2.8s (hermes -w). The banner
path was paying for work the session doesn't need before first input:
- aux availability probes built REAL OpenAI/httpx clients (openai import
~0.3s + SSL context) just to answer check_fns. New aux_probe_mode()
returns a cache-excluded stub; resolution policy unchanged.
- tools/mcp_tool imported the mcp SDK (~260ms, mcp.types pydantic model
construction) at module import even with zero MCP servers configured.
SDK import is now lazy behind _ensure_mcp_sdk(); _MCP_AVAILABLE is a
find_spec probe so every existing gate/test keeps its semantics.
- banner blocked 500ms on the update-check prefetch; now waits 50ms and
defers the warning line to a daemon thread (prints above the prompt).
- banner recomputed get_tool_definitions + skills scan + git state every
launch; now snapshotted to ~/.hermes/cache/banner_snapshot.json keyed on
(config.yaml, .env, checkout rev, toolsets) and replayed on warm launches
with a background refresh. Agent tool list is still computed fresh.
- _resolve_active_context_length probed the Nous portal /models (~200ms
network) per launch; the tool-search gate now prefers the on-disk
context cache when present.
- schema reconciliation re-executed SCHEMA_SQL in a scratch SQLite DB
(~85ms) per SessionDB(); the reference parse is now disk-memoized by
DDL hash (live-DB diffing still runs every startup).
- bundled-skills sync (~120-170ms rglob/hash) moved off the startup path
to a daemon thread; plugin discovery starts in the background and every
synchronous consumer joins via discover_plugins().
- hermes_cli.auth imported httpx eagerly (~30ms); now a lazy proxy that
test monkeypatching still reaches (setattr forwards to the real module).
- fast chat launch: unambiguous 'hermes'/'hermes chat' invocations skip
building all ~40 subcommand parsers (bails to full dispatch on anything
else, incl. container mode).
- -w path: git worktree add runs with checkout.workers=8 (0.6s→0.2s) and
overlaps HermesCLI construction; --skills preload runs in the background
and is folded in at agent init (finalize_preloaded_skills, same
fail-loud contract for fully-unknown skill lists); stale-worktree prune
moved off the banner path.
Warm results (PTY time-to-banner, 5-run): hermes 1.80s → 0.38-0.40s;
hermes -w -s hermes-agent-dev --yolo 2.82s → 0.57-0.69s.
Salvage of PR #17973 by @TKCen (Sebastian Hänisch), re-implemented on
current main to preserve speed/instructions/provider params,
prepare_spoken_text normalization, OPUS_VOICE_PLATFORMS, is_write_denied
path security, microsecond timestamps, and the streaming-TTS gate.
- Split long TTS text into provider-safe chunks instead of truncating
- Pack generated audio against platform upload limits (Discord 10MB,
Telegram 50MB, configurable via tts.delivery_profiles)
- Combine chunks with ffmpeg (OGG/Opus re-encoded, MP3 stream-copied)
- Multi-file delivery when combination fails or would exceed limits
- Remove hard [:4000] truncation from all callers (cli.py, voice.py,
gateway/run.py, gateway/platforms/base.py)
- Gemini TTS raises ValueError instead of silently truncating when
composed prompt exceeds the provider limit
Simplify-code fixes: removed dead all_touched_paths set, added
try/finally for scratch file cleanup on exception, clean error response
on chunk failure instead of leaking stale file_path.
The universal sync fallback in stream_tts_to_speaker ran strictly serially
per sentence — synthesize, play, and only then start synthesizing the next
sentence — so every sentence boundary added a full synthesis-time of dead
air. Chunked streamers (elevenlabs/openai/gemini/xai) already avoid this;
every other provider (edge, piper, plugin providers) paid it on each reply
in voice mode and the wake-word loop.
_SyncSentencePipeline overlaps the two: one single-threaded synthesis
worker (sentences stay FIFO; providers never see concurrent calls from
this loop — same effective concurrency as before) feeds one playback
worker through a small bounded queue, so sentence n+1 synthesizes while
sentence n plays. Lookahead is bounded (backpressure + at most a couple of
temp files), stop_event short-circuits both stages, synthesis failures are
isolated per sentence, temp files are always unlinked, and the finally
block flushes the pipeline BEFORE tts_done_event fires so continuous voice
mode never reopens the mic over its own voice. synthesize/play are
resolved late so existing monkeypatch-based tests work unchanged.
Measured with a real local model provider (OmniVoice plugin, Apple
Silicon), same 3-sentence reply, playback simulated at the produced clips'
true durations, best-of-2 interleaved runs under identical load:
serial pipelined
time to first word 10.8s 4.4s
mid-reply dead air 11.2s 1.8s (second gap: 0.03s)
full reply wall 33.2s 17.0s
Tests: 4 new (timestamp-proven overlap, order + per-sentence failure
isolation, stop skips queued playback, temp-file hygiene); the existing
sync-fallback and display-callback tests pass unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the synchronous per-sentence streamer.stream() loop in
stream_tts_to_speaker with a per-sentence prefetch pipeline. Each
sentence gets its own background thread that fires the HTTP request
immediately, buffering PCM chunks into a per-segment queue (capped at
3 concurrent via semaphore). A single playback worker drains segments
in FIFO order with PortAudio error recovery (reinit up to 3 attempts,
then temp-file fallback). Also fixes PCM chunk alignment for odd-byte
HTTP chunks and increases the worker join timeout from 30s to 300s.
Salvage of PR #71084 onto current main.
Closes#71084
Long thinking/tool stretches in a voice conversation are dead air — the
user cannot tell whether the agent is alive. New: quiet, repeating soft
bubble blips while the agent works and no speech audio is flowing.
- tools/voice_mode.py: numpy-synthesized blips (no binary assets) — two
alternating low pitches (G4/E4) with pitch glide + smooth attack/decay
envelopes, ~0.8-1.2s randomized spacing, volume = voice.beep_volume * 0.5.
start_thinking_sound(should_play=...) / stop_thinking_sound() daemon-loop
lifecycle; macOS-TCC-safe (sounddevice output gated there → silent skip,
no per-second afplay churn). New mark_audio_output_active()/
is_audio_output_active() ref-count wraps play_audio_file and the
streaming OutputStream sentence writes so "audio is flowing" is accurate.
- Config: voice.thinking_sound (default true) off-switch.
- cli.py: starts when a voice-mode turn begins, per-blip gate skips while
TTS speaks / mic records / barge capture owns the mic; stopped in the
chat() finally on every exit path.
- tui_gateway/server.py: same lifecycle around _run_prompt_submit turns
(voice mode on), gated on is_audio_output_active + continuous capture.
- Desktop: renderer owns voice-conversation audio, so a matching WebAudio
implementation (src/lib/thinking-sound.ts, same envelope/pitches) runs
while conversation status === "thinking"; honors voice.thinking_sound
(via config store) and the shared sound-mute toggle; stops instantly on
speaking/listening/end.
Command providers legitimately reference their own API keys in shell
templates (curl one-liners). The #70342 scrub removes ALL provider keys,
which would break such setups. Add a per-provider env_passthrough list
(TTS + STT) that copies named variables back from the parent env, plus
docs and tests. Scrub stays the default; passthrough is explicit opt-in.
Command-type TTS providers validated output_format against a hardcoded
{mp3,wav,ogg,flac} set; any other value was silently coerced back to mp3,
which then mismatched the output path the post-run check expects. This
blocked common ffmpeg-producible containers/codecs — notably m4a (AAC),
the portable choice for WeChat/iOS/mobile voice files — with no
config-only path (only a local source patch, lost on every update).
Widen COMMAND_TTS_OUTPUT_FORMATS to add m4a, aac, amr, opus. This only
permits a command provider to declare these; the user's command still
produces the file (e.g. via ffmpeg). No built-in provider behavior
changes and no new required config.
Update the two tests that pinned the old set, and add a positive case
covering the new formats. Document the supported output_format values.
Salvage incomplete #56332: route command TTS/STT through hermes_subprocess_env
while preserving delegated-child lineage, and close the sibling local-whisper
subprocess.run path that still inherited the full process environment.
Co-authored-by: Cursor <cursoragent@cursor.com>
`_play_via_tempfile` passes the NamedTemporaryFile *object* to `wave.open()`.
`wave.open()` flushes but does not close a file it did not open itself (by
name), so the OS handle to the temp WAV stays open. On Windows that open write
handle blocks the system player from reading the file and blocks the
`os.unlink()` cleanup (WinError 32, silently swallowed by `except OSError:
pass`), leaving orphaned temp .wav files piling up. The fallback runs whenever
no sounddevice output stream is available (sounddevice not installed / no audio
device), which is common on headless and Windows setups.
Close the temp file handle before invoking the player and again in the finally
block (idempotent), so the player can read the file and `os.unlink()` can
remove it.
Regression test drives `stream_tts_to_speaker` with the sounddevice path forced
unavailable and asserts the temp handle is closed before `play_audio_file` is
called (mutation-verified: reverting the close leaves the handle open at play
time and the test fails). Cross-platform.
Concurrent TTS requests within the same second (e.g. from voice prefetch)
generated identical tts_YYYYMMDD_HHMMSS.ogg paths, causing a race where
one request would unlink the file while another was still writing — resulting
in 'produced no output' errors.
Adding %f (microseconds) to the strftime format makes collisions practically
impossible. Registered as PATCH-006.
Addresses review on #62601. Applies a single rule — no sounddevice for
audio OUTPUT on macOS (PortAudio/CoreAudio init triggers a
kTCCServiceMediaLibrary prompt) — consistently at all three output sites:
- play_audio_file: WAV playback (already routed to afplay) now uses the
shared _sounddevice_output_allowed() helper.
- play_beep: synthesize the tone with numpy only, then on macOS play it
via a temp WAV through afplay instead of sounddevice.
- stream_tts_to_speaker (tts_tool): on macOS, skip the sounddevice
OutputStream so playback falls through to the existing tempfile/afplay path.
Audio INPUT (recording) is untouched — it legitimately needs mic permission.
Tests: TestMacOSAudioOutputPolicy (voice_mode) proves WAV + beep routing
does not import sounddevice on Darwin and still uses it off Darwin;
test_tts_macos_output proves streaming TTS skips the OutputStream on Darwin.
Existing test_play_wav_via_sounddevice pinned to non-Darwin for determinism.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Salvaged from PR #18861 (@shellybotmoyer). Switch the central Opus
transcoders from CBR 64k (-vbr off) to VBR 48k with -application voip and
-compression_level 10. The old CBR settings produced lower-quality speech
audio and occasionally failed to trigger Telegram's native voice-bubble
rendering. Applied to _ffmpeg_transcode_to_opus (the single transcoder
behind _convert_to_opus and _repair_ogg_container), the Gemini WAV→OGG
path, and the Matrix adapter boundary transcoder added in this branch.
Fixes#18818
Consolidates all TTS text-preparation paths onto
tools/tts_text_normalize.prepare_spoken_text:
- strip_nonspoken_blocks: removes <think> reasoning blocks (#34213,
incl. unterminated streaming blocks) and the end-of-turn
file-mutation verifier footer emitted by run_agent.py (#40772).
- flatten_newlines_for_payload: collapses newlines into sentence
breaks so newline-sensitive OpenAI-compatible providers (Kokoro)
speak the whole script instead of truncating at the first newline
(#9004).
- tools/tts_tool._strip_markdown_for_tts (voice-mode streaming + web
dashboard path) now delegates to the shared cleaner, with the legacy
regex pipeline kept as a best-effort fallback.
- hermes_cli/voice.py speak_text and cli.py _voice_speak_response now
use the shared cleaner instead of their own duplicated regex
pipelines.
- gateway auto-TTS fallback also strips think blocks.
Tests: tests/tools/test_tts_prepare_spoken.py covers think blocks,
verifier footer, emoji, newline flattening, and the shared-cleaner
wiring on the tool/streaming/gateway paths. Updated the header
expectation in test_voice_cli_integration.py for the heading-fold
behavior of the shared cleaner.
Closes#34213, #9004, #40772
The 'language' config field in tts.openai.language was read but never passed
to the API. This caused Kokoro (and other OpenAI-compatible TTS backends that
support lang_code) to default to English phonemization regardless of the
configured language.
Now passes lang_code via extra_body when language is set in config.
xAI TTS supports a text_normalization boolean that normalizes
written-form text (numbers, abbreviations, symbols) into spoken-form
before synthesis. This parameter was not being sent, leaving users
with literal number/symbol pronunciation.
- Add DEFAULT_XAI_TEXT_NORMALIZATION_DEFAULT = False
- Read tts.xai.text_normalization from config (via _xai_bool_config)
- Attach text_normalization: true to the POST payload when enabled
- Omit the field entirely when unset or explicitly false (API default)
- Add 3 tests: default omission, enabled passthrough, explicit false
Adds an optional provider parameter to the text_to_speech tool that lets the model select a TTS provider per-call instead of always using the globally configured tts.provider.
When provider is set, it bypasses the configured default and routes directly to the specified backend.
When omitted (the default), the tool behaves exactly as before.
Closes#47459
The `text_to_speech` tool schema accepted only `text` and `output_path`,
so style direction (tone, emotion, pacing, whispering) could never reach
the OpenAI backend — even though `gpt-4o-mini-tts` (Hermes's OpenAI
provider default) treats `instructions` as its primary voice-design
control.
This plumbs an optional `instructions` argument through the tool schema,
the handler lambda, and `text_to_speech_tool()` into
`_generate_openai_tts`, where it is forwarded to
`client.audio.speech.create()` only when truthy. Empty/None values still
omit the key entirely, preserving behavior on `tts-1`/`tts-1-hd` and
strict OpenAI-compatible servers.
The same passthrough unblocks self-hosted OpenAI-compatible voice-design
servers (Qwen3-TTS-VoiceDesign on oMLX, etc.) that are already wired in
via `tts.openai.base_url` — the established convention per #9004 and the
TTS config docs — without inventing a new provider backend.
Tests: `tests/tools/test_tts_instructions.py` covers backend passthrough,
tool-level threading, schema declaration, and the empty-string/absent
omission cases. `tests/tools/test_tts_max_text_length.py` fake_openai
signature widened to accept the new kwarg.
Refs NousResearch/hermes-agent#14196
Add optional 'speed' parameter (0.25-4.0) to the text_to_speech tool
schema and handler. When provided by the model, it overrides the
config-level tts.speed setting, enabling per-request speed control
without config changes.
Use cases:
- Language learning: slow playback (0.5x) for pronunciation practice
- Accessibility: adjustable speed for hearing preferences
- Content review: accelerated playback for long text
The speed value is clamped to [0.25, 4.0] and injected into tts_config
before dispatching to any provider (Edge, OpenAI, MiniMax, etc.), so
all existing provider-level speed handling works transparently.
Includes 3 new tests for tool-level speed injection, clamping, and
config preservation when speed is not specified.
When /reasoning show is enabled, the model's <think> blocks appear in
the final assistant message. TTS reads these aloud, which is unwanted —
users want to see reasoning but not hear it spoken.
- Add _THINK_BLOCK regex to _strip_markdown_for_tts() (tools/tts_tool.py)
— the general TTS text-preparation path used by all TTS providers
- Add <think> stripping to prepare_tts_text() (gateway/platforms/base.py)
— the gateway auto-TTS path
- Reuses the existing regex pattern from stream_tts_to_speaker()
Closes#34213
Auto-TTS previously fed raw chat Markdown and compact symbols straight to the
speech provider, so units were read as stray letters and headings or bullets ran
together. This routes spoken text through a new normalizer,
tools/tts_text_normalize.prepare_spoken_text, that expands units (for example a
temperature written with the degree symbol becomes "degrees Celsius") and
flattens Markdown into a transcript-like script with sentence pauses.
The normalizer is best-effort: if it ever fails the code falls back to the
previous markdown-strip behavior, so auto-TTS keeps working. The Telegram voice
caption uses the same normalized text. Includes a unit test.
Salvaged from PR #62977 (@Vissirexa) — TTS model-cache half only (the
hindsight turn-buffer half is a different subsystem and was dropped).
_piper_voice_cache and _kittentts_model_cache were keyed by voice/model
with no eviction, and each entry is a whole loaded model (tens of MB).
A surface that sweeps voices pinned one model per voice for the process
lifetime. New _tts_cache_get_or_load() get-or-loads through a small LRU
(_TTS_MODEL_CACHE_MAX=3), refreshing recency on a hit and evicting the
least-recently-used model on a cold miss.
Class-level sweep following the ElevenLabs salvage (#66311): every cloud
TTS provider section now honors tts.<provider>.base_url. xAI, MiniMax,
Gemini, OpenAI and DeepInfra already did; Mistral (SDK server_url) was
the remaining gap. Adds per-provider config tests locking in the
ElevenLabs environment plumbing and the Mistral server_url passthrough.
Salvaged from PR #66311 (@moeadham), rebased onto the current streaming
registry. tts.elevenlabs.base_url (+ optional wss_url, derived from
base_url when omitted) routes both the sync ElevenLabs path and the
chunked ElevenLabsStreamer through an ElevenLabsEnvironment, matching
the STT side's ELEVENLABS_STT_BASE_URL/config override pattern.
Salvaged from PR #26233 (@LeonSGP43), rebased onto the current 3-tuple
_resolve_openai_audio_client_config (is_managed flag, post-#73072 layout).
Same fix independently submitted earlier in PR #26209 (@zccyman) — credit
to both.
Resolution order now mirrors the STT resolver: tts.openai.api_key/base_url
from config.yaml -> VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY env (still
honoring config base_url) -> managed gateway. _has_openai_audio_backend
also counts a config api_key as an available backend.
Fixes#26175
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).
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