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
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.
Follow-up to the #60063 salvage: the curated gemini list now carries
gemini-3.6-flash (aux default, #70416) and the vertex list carries
gemini-3.5-flash-lite (#68767) — both need snapshot pricing so direct
Gemini/Vertex sessions don't report cost=unknown.
Rates verified against https://ai.google.dev/gemini-api/docs/pricing
(2026-07-28): 3.6-flash $1.50/$7.50, cache read $0.15;
3.5-flash-lite $0.30/$2.50, cache read $0.03.
Main gained its own vertex curated list (df051c17cc) two days after
PR #68767 was opened, so the cherry-pick produced a duplicate 'vertex'
dict key (later key silently wins in Python dict literals). Merge the
two into one list: union of both, existing entries preserved, contributor's
live-validated additions (gemini-3.6-flash, 3.5-flash-lite, 3.1-flash-lite)
folded in.
Add a "vertex" key to _PROVIDER_MODELS with 6 Gemini 3.x models that
were validated live against the Vertex AI OpenAI-compatible endpoint
(aiplatform.googleapis.com, global region, project antse-tooling) on
2026-07-21. All entries returned HTTP 200; 8 other candidates (e.g.
gemini-3.1-flash, gemini-3-pro-preview) returned 404 and were excluded.
Validated models (google/ prefix required by Vertex endpoint):
- google/gemini-3.5-flash (frontier Flash, agentic)
- google/gemini-3.6-flash (newer incremental over 3.5)
- google/gemini-3.5-flash-lite (lighter/cheaper 3.5 variant)
- google/gemini-3.1-pro-preview (3.1 Pro preview)
- google/gemini-3-flash-preview (3.0 Flash preview)
- google/gemini-3.1-flash-lite (most cost-efficient 3.x model)
Context lengths are covered by the existing "gemini" prefix entry
(1_048_576) in agent/model_metadata.py DEFAULT_CONTEXT_LENGTHS — no
additional entries needed.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
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.
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.
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>
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
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.