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.
Review follow-up: the spoken script is for synthesis only. Caption
eligibility and payload stay on the original reply, so a long reply
whose normalized script fits the 1024 char limit is still delivered
in full as its own message. Adds the long-original/short-normalized
regression case to the auto-TTS caption tests.
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.
The native Gemini provider profile's default_aux_model and the curated
model picker catalog were still pinned to gemini-3.5-flash, a stale
generation now superseded by gemini-3.6-flash (documented GA). Bump
both so the auxiliary-task default and the picker stay in sync with
the current model.
Contract test asserts the durable lockstep invariant only
(default_aux_model is a member of _PROVIDER_MODELS["gemini"]) rather
than pinning either side to a frozen model-name string, so it doesn't
need updating on the next model-generation bump.
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.
Follow-up to salvaged PR #70307 (@aml1973): OpenAIStreamer now checks
tts.openai.api_key (config.yaml) ahead of the env resolver in both
available() and stream(), completing config parity between the sync
and streaming OpenAI TTS paths.
Use the shared OpenAI audio key resolver and prefer tts.openai.base_url over the global environment fallback in the Desktop streaming path. Add focused regression coverage for credential and endpoint propagation.
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
Use mark_provider_active_if_unset after dashboard token save, unsuppress
device_code after TTS setup login, and lock default-active plus refresh
active_provider contracts in tests.
Save side-tool OAuth tokens without promoting xai-oauth via active_provider
or model.provider so hermes setup tts login no longer hijacks inference routing.
- 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>
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)
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
Add isolated test where ensure('stt.mistral') raises FeatureUnavailable
but the raw mistralai.client.Mistral import succeeds, verifying the
transcription_tools.py fallthrough path introduced in the same PR.
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
Existing installs predate the install.sh hermes-acp launcher, and hermes
update never re-runs setup_path, so ACP hosts (Zed, JetBrains, Buzz)
still resolve Hermes as unavailable until a reinstall. _ensure_acp_launcher()
writes the launcher next to an existing hermes command in ~/.local/bin or
/usr/local/bin during hermes update — delegating to the sibling launcher so
it is correct for every install layout. Never follows symlinks (#21454),
skips unwritable dirs, no-op on Windows (venv Scripts is already on PATH).
Docs: add a Buzz Desktop section to the ACP page (en + zh-Hans).
setup_path() wrote a `hermes` launcher to ~/.local/bin but nothing for
`hermes-acp`. That console script exists only inside the venv, which is
not on the login-shell PATH.
ACP hosts resolve the agent by command name against that PATH, so an
otherwise healthy install looks absent to them. Buzz Desktop ships a
Hermes preset that spawns `hermes-acp` and reports the runtime as
unavailable; Zed and JetBrains configs that name the bare command have
the same problem.
Write a hermes-acp launcher next to the hermes one, dispatching to the
acp subcommand. Same PYTHONPATH/PYTHONHOME clearing, and the same rm -f
before cat > so an older symlink into the venv cannot be followed and
stomp the console script (#21454). Uninstall removes both launchers.
tests/test_install_sh_acp_launcher.py drives the block out of install.sh
rather than asserting on a copy, covering the venv and non-venv branches
plus the symlink-stomp case. Reverting the install.sh change turns all
three red.
Signed-off-by: SHL0MS <SHL0MS@users.noreply.github.com>
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
`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.
iOS Signal delivers voice notes as MP4-container AAC carrying an audio
ftyp brand ("M4A "). `_guess_extension()` returned ".mp4" for every
`ftyp` file regardless of brand, so those attachments were cached as
documents instead of audio and STT rejected the upload:
API error: Error code: 400 - Invalid file format.
Supported formats: ['flac','m4a','mp3','mp4','mpeg','mpga','oga',
'ogg','wav','webm']
Read the 4-byte brand at offset 8 and return ".m4a" for audio brands
("M4A ", "M4B ") so they satisfy `_is_audio_ext()` and route to
`cache_audio_from_bytes()`. Video brands (isom/mp42/avc1/qt) still
return ".mp4".
This mirrors the existing brand/form-type disambiguation already used
in this function for RIFF (WEBP vs WAVE) and for ADTS AAC vs MP3.
Verified against a real iOS Signal voice note: pre-fix the upload was
rejected with the 400 above; post-fix the same bytes transcribe
successfully.
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.
The grant_spent notice fired for every subscription user with top-up
funds the moment their cap was reached and camped in the CLI/TUI status
bar and desktop toasts with no action to take — the account keeps
working off top-up. Remove it everywhere:
- agent/credits_tracker.py: drop grant_cond + the emit/clear block;
the dev fixture state now (correctly) produces no notice
- TUI: keep the turn-start clear of credits.grant_spent as back-compat
for older backends that still emit the key
- Desktop: drop the demo step and stale comment references
- Docs/config comments: remove grant-spent from credits_notices text
- Tests updated: policy/cold-start now assert the key never fires
Usage bands, depleted, and restored notices are unchanged; /usage still
reports the full balance breakdown.
Responds to hermes-sweeper review on #54717: existing tests only mocked
platform_registry.get(). Add a hermetic fake deferred-loader test that
runs real PlatformRegistry resolution → PluginContext.register_cli_command
→ argparse subparser/choices visibility, without Photon SDK imports.