test_tts_max_text_length.py
1 """Tests for per-provider TTS input-character limits. 2 3 Replaces the old global ``MAX_TEXT_LENGTH = 4000`` cap that truncated every 4 provider at 4000 chars even though OpenAI allows 4096, xAI allows 15000, 5 MiniMax allows 10000, and ElevenLabs allows 5000-40000 depending on model. 6 """ 7 8 import json 9 from unittest.mock import patch 10 11 import pytest 12 13 from tools.tts_tool import ( 14 ELEVENLABS_MODEL_MAX_TEXT_LENGTH, 15 FALLBACK_MAX_TEXT_LENGTH, 16 PROVIDER_MAX_TEXT_LENGTH, 17 _resolve_max_text_length, 18 ) 19 20 21 class TestResolveMaxTextLength: 22 def test_edge_default(self): 23 assert _resolve_max_text_length("edge", {}) == PROVIDER_MAX_TEXT_LENGTH["edge"] 24 25 def test_openai_default_is_4096(self): 26 assert _resolve_max_text_length("openai", {}) == 4096 27 28 def test_xai_default_is_15000(self): 29 assert _resolve_max_text_length("xai", {}) == 15000 30 31 def test_minimax_default_is_10000(self): 32 assert _resolve_max_text_length("minimax", {}) == 10000 33 34 def test_mistral_default(self): 35 assert _resolve_max_text_length("mistral", {}) == PROVIDER_MAX_TEXT_LENGTH["mistral"] 36 37 def test_gemini_default(self): 38 assert _resolve_max_text_length("gemini", {}) == PROVIDER_MAX_TEXT_LENGTH["gemini"] 39 40 def test_unknown_provider_falls_back(self): 41 assert _resolve_max_text_length("does-not-exist", {}) == FALLBACK_MAX_TEXT_LENGTH 42 43 def test_empty_provider_falls_back(self): 44 assert _resolve_max_text_length("", {}) == FALLBACK_MAX_TEXT_LENGTH 45 assert _resolve_max_text_length(None, {}) == FALLBACK_MAX_TEXT_LENGTH 46 47 def test_case_insensitive(self): 48 assert _resolve_max_text_length("OpenAI", {}) == 4096 49 assert _resolve_max_text_length(" XAI ", {}) == 15000 50 51 # --- Overrides --- 52 53 def test_override_wins(self): 54 cfg = {"openai": {"max_text_length": 9999}} 55 assert _resolve_max_text_length("openai", cfg) == 9999 56 57 def test_override_zero_falls_through(self): 58 # A broken/zero override must not disable truncation 59 cfg = {"openai": {"max_text_length": 0}} 60 assert _resolve_max_text_length("openai", cfg) == 4096 61 62 def test_override_negative_falls_through(self): 63 cfg = {"xai": {"max_text_length": -1}} 64 assert _resolve_max_text_length("xai", cfg) == 15000 65 66 def test_override_non_int_falls_through(self): 67 cfg = {"minimax": {"max_text_length": "lots"}} 68 assert _resolve_max_text_length("minimax", cfg) == 10000 69 70 def test_override_bool_falls_through(self): 71 # bool is technically an int; make sure we don't treat True as 1 char 72 cfg = {"openai": {"max_text_length": True}} 73 assert _resolve_max_text_length("openai", cfg) == 4096 74 75 def test_missing_provider_section_uses_default(self): 76 cfg = {"provider": "openai"} # no "openai" key 77 assert _resolve_max_text_length("openai", cfg) == 4096 78 79 # --- ElevenLabs model-aware --- 80 81 def test_elevenlabs_default_model_multilingual_v2(self): 82 cfg = {"elevenlabs": {"model_id": "eleven_multilingual_v2"}} 83 assert _resolve_max_text_length("elevenlabs", cfg) == 10000 84 85 def test_elevenlabs_flash_v2_5_gets_40k(self): 86 cfg = {"elevenlabs": {"model_id": "eleven_flash_v2_5"}} 87 assert _resolve_max_text_length("elevenlabs", cfg) == 40000 88 89 def test_elevenlabs_flash_v2_gets_30k(self): 90 cfg = {"elevenlabs": {"model_id": "eleven_flash_v2"}} 91 assert _resolve_max_text_length("elevenlabs", cfg) == 30000 92 93 def test_elevenlabs_v3_gets_5k(self): 94 cfg = {"elevenlabs": {"model_id": "eleven_v3"}} 95 assert _resolve_max_text_length("elevenlabs", cfg) == 5000 96 97 def test_elevenlabs_unknown_model_falls_back_to_provider_default(self): 98 cfg = {"elevenlabs": {"model_id": "eleven_experimental_xyz"}} 99 assert _resolve_max_text_length("elevenlabs", cfg) == PROVIDER_MAX_TEXT_LENGTH["elevenlabs"] 100 101 def test_elevenlabs_override_beats_model_lookup(self): 102 cfg = {"elevenlabs": {"model_id": "eleven_flash_v2_5", "max_text_length": 1000}} 103 assert _resolve_max_text_length("elevenlabs", cfg) == 1000 104 105 def test_elevenlabs_no_model_id_uses_default_model_mapping(self): 106 # Falls back to DEFAULT_ELEVENLABS_MODEL_ID = eleven_multilingual_v2 -> 10000 107 assert _resolve_max_text_length("elevenlabs", {}) == 10000 108 109 def test_provider_config_not_a_dict(self): 110 cfg = {"openai": "not-a-dict"} 111 assert _resolve_max_text_length("openai", cfg) == 4096 112 113 # --- Sanity: the table covers every provider listed in the schema --- 114 115 def test_all_documented_providers_have_defaults(self): 116 expected = {"edge", "openai", "xai", "minimax", "mistral", 117 "gemini", "elevenlabs", "neutts", "kittentts"} 118 assert expected.issubset(PROVIDER_MAX_TEXT_LENGTH.keys()) 119 120 121 class TestTextToSpeechToolTruncation: 122 """End-to-end: verify the resolver actually drives the text_to_speech_tool 123 truncation path rather than the old 4000-char global.""" 124 125 def test_openai_truncates_at_4096_not_4000(self, tmp_path, monkeypatch, caplog): 126 import logging 127 caplog.set_level(logging.WARNING, logger="tools.tts_tool") 128 129 # 5000 chars -- over OpenAI's 4096 limit but under xAI's 15k 130 text = "A" * 5000 131 captured_text = {} 132 133 def fake_openai(t, out, cfg): 134 captured_text["text"] = t 135 with open(out, "wb") as f: 136 f.write(b"\x00") 137 return out 138 139 monkeypatch.setattr("tools.tts_tool._generate_openai_tts", fake_openai) 140 monkeypatch.setattr("tools.tts_tool._load_tts_config", 141 lambda: {"provider": "openai"}) 142 143 from tools.tts_tool import text_to_speech_tool 144 out = str(tmp_path / "out.mp3") 145 result = json.loads(text_to_speech_tool(text=text, output_path=out)) 146 147 assert result["success"] is True 148 # Should be truncated to 4096, not the old 4000 149 assert len(captured_text["text"]) == 4096 150 # And the warning should mention the provider 151 assert any("openai" in rec.message.lower() for rec in caplog.records) 152 153 def test_xai_accepts_much_longer_input(self, tmp_path, monkeypatch): 154 # 12000 chars -- over old global 4000, under xAI's 15000 155 text = "B" * 12000 156 captured_text = {} 157 158 def fake_xai(t, out, cfg): 159 captured_text["text"] = t 160 with open(out, "wb") as f: 161 f.write(b"\x00") 162 return out 163 164 monkeypatch.setattr("tools.tts_tool._generate_xai_tts", fake_xai) 165 monkeypatch.setattr("tools.tts_tool._load_tts_config", 166 lambda: {"provider": "xai"}) 167 168 from tools.tts_tool import text_to_speech_tool 169 out = str(tmp_path / "out.mp3") 170 result = json.loads(text_to_speech_tool(text=text, output_path=out)) 171 172 assert result["success"] is True 173 # xAI should accept the full 12000 chars 174 assert len(captured_text["text"]) == 12000 175 176 def test_user_override_is_respected(self, tmp_path, monkeypatch): 177 # User says "cap openai at 100 chars" -- we must honor it 178 text = "C" * 500 179 captured_text = {} 180 181 def fake_openai(t, out, cfg): 182 captured_text["text"] = t 183 with open(out, "wb") as f: 184 f.write(b"\x00") 185 return out 186 187 monkeypatch.setattr("tools.tts_tool._generate_openai_tts", fake_openai) 188 monkeypatch.setattr("tools.tts_tool._load_tts_config", 189 lambda: {"provider": "openai", 190 "openai": {"max_text_length": 100}}) 191 192 from tools.tts_tool import text_to_speech_tool 193 out = str(tmp_path / "out.mp3") 194 result = json.loads(text_to_speech_tool(text=text, output_path=out)) 195 196 assert result["success"] is True 197 assert len(captured_text["text"]) == 100