fix: forward tts.openai.consent_attestation to every OpenAI-compatible TTS request

Self-hosted OpenAI-compatible TTS servers reject cloned voices with
400 consent_required unless the JSON body carries `consent_attestation`;
Hermes never sent it, so the request failed and TTS silently fell back to
Edge. Add an optional `tts.openai.consent_attestation` key (default "",
nothing sent) and forward it via `extra_body` from the one place that
already knew about `lang_code`: a small `_openai_extra_body()` builder in
tools/tts_tool_openai.py, now shared by the whole-file path
(`_generate_openai_tts`), the chunked streamer (`OpenAIStreamer.stream`,
which previously forwarded neither field) and the desktop client-direct
voice config (`extra_body` on the openai-speech wire, spread into the
request body by voice-client-direct.ts).

Slim redo of PR #99782 by @liuhao1024 (pre-refactor tts_tool.py base,
no config default / docs), credited as author.

Fixes #99775
This commit is contained in:
liuhao1024
2026-09-19 00:08:59 -07:00
committed by Teknium
parent fbcc605956
commit f16083763a
8 changed files with 74 additions and 4 deletions

View File

@@ -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<string, unknown>
}
interface RelayConfig {
@@ -282,6 +284,7 @@ export async function directTtsConfig(): Promise<DirectTtsConfig | null> {
export async function synthesizeSpeechClientDirect(tts: DirectTtsConfig, text: string): Promise<ArrayBuffer> {
if (tts.wire === 'openai-speech') {
const body: Record<string, unknown> = {
...(tts.extra_body ?? {}),
model: tts.model,
voice: tts.voice,
input: text,

View File

@@ -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",

View File

@@ -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,

View File

@@ -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 = {}

View File

@@ -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")

View File

@@ -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)

View File

@@ -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:

View File

@@ -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