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:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user