diff --git a/apps/desktop/src/lib/voice-client-direct.ts b/apps/desktop/src/lib/voice-client-direct.ts index ae13747783..e980c2ad13 100644 --- a/apps/desktop/src/lib/voice-client-direct.ts +++ b/apps/desktop/src/lib/voice-client-direct.ts @@ -38,6 +38,8 @@ export interface DirectTtsConfig { model: null | string voice: null | string speed: null | number + /** Optional tts.openai fields the server forwards verbatim (lang_code, consent_attestation). */ + extra_body?: Record } interface RelayConfig { @@ -282,6 +284,7 @@ export async function directTtsConfig(): Promise { export async function synthesizeSpeechClientDirect(tts: DirectTtsConfig, text: string): Promise { if (tts.wire === 'openai-speech') { const body: Record = { + ...(tts.extra_body ?? {}), model: tts.model, voice: tts.voice, input: text, diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 77ba613e94..b02d49c413 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -1042,6 +1042,9 @@ DEFAULT_CONFIG = { # gpt-4o-mini-tts voices: alloy, ash, ballad, cedar, coral, echo, fable, marin, nova, # onyx, sage, shimmer, verse "voice": "alloy", + # Forwarded verbatim in the request body for OpenAI-compatible servers whose cloned + # voices demand it (400 consent_required otherwise); "" sends nothing. + "consent_attestation": "", }, "gemini": { "model": "gemini-2.5-flash-preview-tts", diff --git a/tests/tools/test_tts_speed.py b/tests/tools/test_tts_speed.py index f3826f3fcc..97ab7f0f7f 100644 --- a/tests/tools/test_tts_speed.py +++ b/tests/tools/test_tts_speed.py @@ -114,6 +114,14 @@ class TestOpenaiTtsLangCode: assert kwargs["extra_body"] == {"lang_code": "es"} assert kwargs["speed"] == 2.0 + def test_consent_attestation_merges_into_extra_body(self, tmp_path, monkeypatch): + """tts.openai.consent_attestation rides in the JSON body next to lang_code (#99775): + OpenAI-compatible servers 400 ``consent_required`` on cloned voices without it.""" + create = self._run({"openai": {"language": "es", "consent_attestation": "I have consent"}}, + tmp_path, monkeypatch) + assert create.call_args[1]["extra_body"] == { + "lang_code": "es", "consent_attestation": "I have consent"} + # --------------------------------------------------------------------------- # MiniMax TTS (t2a_v2 endpoint: nested voice_setting/audio_setting, diff --git a/tests/tools/test_tts_streaming.py b/tests/tools/test_tts_streaming.py index 58fa2f5731..71b1dbf966 100644 --- a/tests/tools/test_tts_streaming.py +++ b/tests/tools/test_tts_streaming.py @@ -116,6 +116,44 @@ def test_openai_available_reflects_audio_key_resolution(monkeypatch): assert ts.OpenAIStreamer.available() is True +def test_openai_streamer_forwards_consent_attestation(monkeypatch): + """The chunked path sends the same optional tts.openai body fields as the sync path (#99775); + an unset key adds no extra_body so strict servers see an unchanged request.""" + captured = {} + + class _Response: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def iter_bytes(self): + yield b"\x01\x00" + + class _StreamingCreate: + @staticmethod + def create(**kwargs): + captured["create"] = kwargs + return _Response() + + class _OpenAI: + def __init__(self, **kwargs): + self.audio = MagicMock() + self.audio.speech.with_streaming_response = _StreamingCreate() + + monkeypatch.setattr(ts, "resolve_openai_audio_api_key", lambda: "env-key") + monkeypatch.setattr("hermes_cli.config.get_env_value", lambda key, *args: None) + monkeypatch.setattr("openai.OpenAI", _OpenAI) + + section = {"api_key": "k", "consent_attestation": "I have consent"} + list(ts.OpenAIStreamer({"openai": section}, section).stream("hi")) + assert captured["create"]["extra_body"] == {"consent_attestation": "I have consent"} + + list(ts.OpenAIStreamer({"openai": {"api_key": "k"}}, {"api_key": "k"}).stream("hi")) + assert "extra_body" not in captured["create"] + + def test_openai_streamer_prefers_configured_api_key(monkeypatch): captured = {} diff --git a/tools/tts_streaming.py b/tools/tts_streaming.py index 71bc4a2cee..838cd7e853 100644 --- a/tools/tts_streaming.py +++ b/tools/tts_streaming.py @@ -215,9 +215,11 @@ class OpenAIStreamer(StreamingTTSProvider): client = OpenAI( api_key=(self.section.get("api_key") or resolve_openai_audio_api_key()), base_url=(self.section.get("base_url") or get_env_value("OPENAI_BASE_URL") or None)) + from tools.tts_tool_openai import _openai_extra_body + extra = {"extra_body": body} if (body := _openai_extra_body(self.section)) else {} with client.audio.speech.with_streaming_response.create( model=self.section.get("model", "gpt-4o-mini-tts"), voice=self.section.get("voice", "alloy"), - input=text, response_format="pcm", + input=text, response_format="pcm", **extra, ) as response: yield from _capped(response.iter_bytes(), "OpenAI streaming TTS") diff --git a/tools/tts_tool_openai.py b/tools/tts_tool_openai.py index c131b89f3f..189b10c08e 100644 --- a/tools/tts_tool_openai.py +++ b/tools/tts_tool_openai.py @@ -79,6 +79,18 @@ def _has_openai_audio_backend() -> bool: return False +def _openai_extra_body(oai_config: Dict[str, Any]) -> Dict[str, Any]: + """Optional ``tts.openai`` fields OpenAI-compatible servers read from the JSON body: ``language`` + (sent as ``lang_code``) and ``consent_attestation`` (cloned voices). Unset keys are omitted so + the official API and strict servers never see unknown fields.""" + extra_body: Dict[str, Any] = {} + if oai_config.get("language"): + extra_body["lang_code"] = oai_config["language"] + if oai_config.get("consent_attestation"): + extra_body["consent_attestation"] = oai_config["consent_attestation"] + return extra_body + + def _generate_openai_tts( text: str, output_path: str, tts_config: Dict[str, Any], *, api_key: Optional[str] = None, base_url: Optional[str] = None, model: Optional[str] = None, voice: Optional[str] = None, @@ -122,8 +134,8 @@ def _generate_openai_tts( create_kwargs["speed"] = max(0.25, min(4.0, speed)) if instructions: create_kwargs["instructions"] = instructions - if oai_config.get("language"): - create_kwargs["extra_body"] = {"lang_code": oai_config["language"]} + if extra_body := _openai_extra_body(oai_config): + create_kwargs["extra_body"] = extra_body client = _origin()._import_openai_client()(api_key=api_key, base_url=base_url) try: client.audio.speech.create(**create_kwargs).stream_to_file(output_path) diff --git a/tools/voice_client_config.py b/tools/voice_client_config.py index 87b201dac8..b73e5be415 100644 --- a/tools/voice_client_config.py +++ b/tools/voice_client_config.py @@ -174,7 +174,8 @@ def _resolve_tts_client_config() -> Dict[str, Any]: except (TypeError, ValueError): speed = 1.0 return _direct(TTS_WIRE_OPENAI, "openai", base_url, api_key, model, - voice=oai.get("voice") or tts_tool_openai.DEFAULT_OPENAI_VOICE, speed=speed) + voice=oai.get("voice") or tts_tool_openai.DEFAULT_OPENAI_VOICE, speed=speed, + extra_body=tts_tool_openai._openai_extra_body(oai)) if provider == "elevenlabs": api_key = tts._resolve_provider_key("ELEVENLABS_API_KEY", "elevenlabs") if not api_key: diff --git a/website/docs/user-guide/features/tts.md b/website/docs/user-guide/features/tts.md index ecff510d47..bd94b29f49 100644 --- a/website/docs/user-guide/features/tts.md +++ b/website/docs/user-guide/features/tts.md @@ -58,6 +58,7 @@ tts: base_url: "https://api.openai.com/v1" # Override for OpenAI-compatible TTS endpoints speed: 1.0 # 0.25 - 4.0 # language: "es" # Sent as lang_code — only for OpenAI-compatible endpoints that support it (e.g. Kokoro) + # consent_attestation: "I have the speaker's consent" # Required by some OpenAI-compatible servers for cloned voices minimax: region: "global" # "global" or "cn"; see selection rules below model: "speech-02-hd" # speech-02-hd (default), speech-02-turbo @@ -146,6 +147,8 @@ The rewrite uses `auxiliary.tts_audio_tags` and defaults to your main chat model **Language (OpenAI-compatible endpoints)**: `tts.openai.language` is forwarded to the endpoint as a `lang_code` request parameter. It is intended for OpenAI-compatible TTS servers that support `lang_code` — for example [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI), where `language: "es"` selects the Spanish phonemizer instead of the English default. Leave it unset when using the official OpenAI API, which does not accept this parameter. When unset, nothing extra is sent. +**Cloned-voice consent (OpenAI-compatible endpoints)**: some self-hosted OpenAI-compatible TTS servers reject a cloned voice with `400 consent_required` unless the request carries a `consent_attestation` field. Set `tts.openai.consent_attestation` to the attestation text your server expects; Hermes forwards it verbatim in the request body on every OpenAI-compatible path (whole-file synthesis, streaming, and the desktop's client-direct voice). Leave it unset for the official OpenAI API — when unset, the field is not sent. + ### Input length limits