tts-voice-creator-clone-and.../tests/test_engine_connections.py
mARTin-B78 0551b6651f feat(voices): redesigned My Voices list, working copy, engine-aware TTS availability
My Voices
- Top bar with search, filter toggle (active-count badge), synth-source drawer, group and table buttons;
  filters/sort in a collapsible labelled panel; one-line sort header with select-all/none checkbox
- Groups chip list is a collapsed accordion; bottom bar is Refresh/Sync/Import plus one Actions (N) menu
  (Edit / Audio / Share / Danger), disabled until voices are checked; touch targets stay 44px
- Copy selected works again (copyText was no longer exported), shows "N voice names copied to clipboard"
  and falls back to a copy dialog when the async clipboard is unavailable

TTS / engines
- Health probe works for /v1 card URLs and, behind a LiteLLM gateway, checks the selected model's own
  upstream so stopped containers are not reported as available
- Unhiding a voice publishes a missing entry to the engine's voices.json (no more silent default-voice fallback)
- Status-bar TTS picker also drives the My Voices and STT-test selectors; Use as TTS refreshes the status bar

Includes the engine connect/disconnect, custom TTS, STT footer and magazine auth work that was already
in the working tree, with their tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 04:44:27 +02:00

160 lines
10 KiB
Python

"""Provider credentials and model selections reach mocked inference calls."""
import asyncio
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
os.environ.setdefault('CONFIG_DIR', tempfile.mkdtemp(prefix='ttvc-engine-tests-'))
from fastapi import HTTPException
from routes import docker, stt, settings as settings_routes
from core import tts_helpers
from core.provider_health import provider_key_for_url, provider_request_params, validate_provider_params
class EngineConnectionTests(unittest.TestCase):
def test_extra_params_are_role_scoped_and_required_fields_protected(self):
settings = {'custom_engine_cards': [
{'id': 'chat', 'url': 'http://gateway/v1', 'role': 'llm', 'params': {'temperature': 0.7}},
{'id': 'speech', 'url': 'http://gateway/v1', 'role': 'tts', 'params': {'speed': 1.2}},
]}
payload = {'model': 'chosen', 'messages': [], 'temperature': 0.1}
merged = provider_request_params('http://gateway/v1/chat/completions', payload, settings)
self.assertEqual(merged['temperature'], 0.7)
self.assertNotIn('speed', merged)
self.assertEqual(merged['model'], 'chosen')
settings['custom_engine_cards'].append({'id': 'other-chat', 'url': 'http://gateway/v1', 'role': 'llm', 'params': {'top_p': 0.1}})
self.assertEqual(provider_request_params('http://gateway/v1/chat/completions', payload, settings), payload)
def test_params_follow_saved_endpoint_override(self):
settings = {'custom_engine_cards': [{'id': 'card', 'url': 'http://old', 'role': 'tts', 'params': {'speed': 1.2}}], 'engine_local_urls': {'dc-card': 'http://new/v1'}}
self.assertEqual(provider_request_params('http://new/v1/audio/speech', {'input': 'hello'}, settings)['speed'], 1.2)
self.assertEqual(provider_request_params('http://old/v1/audio/speech', {'input': 'hello'}, settings), {'input': 'hello'})
@patch('routes.settings._save_settings')
def test_invalid_or_secret_extra_params_are_rejected_before_save(self, save):
invalid = [[], {'model': 'replace'}, {'headers': {'x-api-key': 'dummy'}}, {'oauth': {'client_secret': 'dummy'}}, {'llm_api_key': 'dummy'}, {'nested': [{'BearerToken': 'dummy'}]}]
for params in invalid:
request = Mock(json=AsyncMock(return_value={'custom_engine_cards': [{'id': 'card', 'params': params}]}))
with self.assertRaises(HTTPException) as caught:
asyncio.run(settings_routes.post_settings(request))
self.assertEqual(caught.exception.status_code, 400)
save.assert_not_called()
validate_provider_params({'max_tokens': 100, 'temperature': 0.3})
@patch('routes.docker._save_settings')
@patch('routes.docker._load_settings')
def test_explicit_activation_selects_custom_stt_and_enables_ocr(self, load, save):
for role, setting_key in [('stt', 'faster_whisper_url'), ('ocr', 'vision_ocr_url'), ('tts', 'tts_url')]:
load.return_value = {'custom_engine_cards': [{'id': 'card', 'role': role}]}
request = Mock(json=AsyncMock(return_value={'url': 'http://engine', 'role': role, 'setting_key': setting_key, 'activate': True}))
asyncio.run(docker.use_as_engine('card', request, ''))
saved = save.call_args.args[0]
if role == 'stt':
self.assertEqual(saved['stt_preferred_backend'], 'custom:card')
elif role == 'ocr':
self.assertTrue(saved['vision_ocr_enabled'])
else:
self.assertEqual(saved['tts_backend'], 'openai')
@patch('routes.settings._save_settings')
@patch('routes.settings._load_settings')
def test_custom_rename_migrates_legacy_maps_without_exposing_key(self, load, save):
load.return_value = {'custom_engine_cards': [{'id': 'custom-1', 'name': 'Old'}], 'engine_api_keys': {'Old': 'dummy-secret'}, 'engine_models': {'Old': 'selected'}, 'engine_local_urls': {'dc-Old': 'http://engine'}}
request = Mock(json=AsyncMock(return_value={'custom_engine_cards': [{'id': 'custom-1', 'name': 'Renamed'}], 'engine_api_keys': {'custom-1': '__configured__'}}))
result = asyncio.run(settings_routes.post_settings(request))
saved = save.call_args.args[0]
self.assertEqual(saved['engine_api_keys']['custom-1'], 'dummy-secret')
self.assertEqual(saved['engine_models']['custom-1'], 'selected')
self.assertEqual(saved['engine_local_urls']['dc-custom-1'], 'http://engine')
self.assertNotIn('dummy-secret', str(result))
@patch('routes.settings._save_settings')
@patch('routes.settings._load_settings')
def test_custom_migration_preserves_id_values_and_normal_cards(self, load, save):
load.return_value = {'custom_engine_cards': [{'id': 'custom-1', 'name': 'Old'}], 'engine_api_keys': {'Old': 'legacy', 'custom-1': 'current', 'ollama': 'normal'}, 'engine_models': {'Old': 'legacy', 'custom-1': 'current'}}
request = Mock(json=AsyncMock(return_value={'custom_engine_cards': [{'id': 'custom-1', 'name': 'Renamed'}]}))
asyncio.run(settings_routes.post_settings(request))
saved = save.call_args.args[0]
self.assertEqual(saved['engine_api_keys']['custom-1'], 'current')
self.assertEqual(saved['engine_api_keys']['ollama'], 'normal')
self.assertEqual(saved['engine_models']['custom-1'], 'current')
def test_card_credentials_do_not_use_other_provider_key(self):
settings = {'llm_api_key': 'unrelated', 'engine_api_keys': {'card': 'saved'}}
self.assertEqual(docker._engine_api_key(settings, 'card', 'llm', 'typed'), 'typed')
self.assertEqual(docker._engine_api_key(settings, 'card', 'llm'), 'saved')
self.assertEqual(docker._engine_api_key(settings, 'empty', 'llm'), 'sk-dummy-key')
self.assertEqual(docker._engine_headers('typed', 'anthropic')['x-api-key'], 'typed')
@patch('routes.docker.requests.get')
@patch('routes.docker._load_settings')
def test_model_list_uses_saved_card_key_and_root_fallback(self, load, get):
load.return_value = {'engine_api_keys': {'card': 'saved'}, 'llm_api_key': 'other'}
get.side_effect = [Mock(status_code=404), Mock(status_code=200, json=Mock(return_value={'data': [{'id': 'selected'}]}))]
result = asyncio.run(docker.engine_models('http://engine/v1', 'stt', 'card', ''))
self.assertEqual(result['models'], ['selected'])
self.assertEqual(get.call_args.args[0], 'http://engine/models')
self.assertEqual(get.call_args.kwargs['headers']['Authorization'], 'Bearer saved')
@patch('routes.docker._save_settings')
@patch('routes.docker._load_settings')
def test_activation_scopes_key_and_clears_previous_model(self, load, save):
load.return_value = {'whisper_api_key': 'default', 'stt_models': {'faster_whisper': 'old'}}
request = Mock(json=AsyncMock(return_value={'url': 'http://engine', 'role': 'stt', 'setting_key': 'faster_whisper_url', 'model': ''}))
asyncio.run(docker.use_as_engine('card', request, 'typed'))
saved = save.call_args.args[0]
self.assertEqual(saved['whisper_api_key'], 'default')
self.assertEqual(saved['engine_api_keys']['card'], 'typed')
self.assertEqual(saved['stt_models']['faster_whisper'], '')
self.assertEqual(stt._stt_backend_api_key(saved, 'faster_whisper'), 'typed')
def test_activation_rejects_non_object_and_cross_role_setting(self):
for body in ([], {'role': 'ocr', 'setting_key': 'tts_url', 'url': 'http://engine'}):
request = Mock(json=AsyncMock(return_value=body))
with self.assertRaises(HTTPException) as caught:
asyncio.run(docker.use_as_engine('card', request, ''))
self.assertEqual(caught.exception.status_code, 400)
def test_endpoint_key_resolution_handles_ui_prefix(self):
settings = {'engine_local_urls': {'dc-card': 'http://engine/v1'}, 'engine_api_keys': {'card': 'saved'}}
self.assertEqual(provider_key_for_url(settings, 'http://engine', 'other'), 'saved')
settings['engine_api_keys'] = {}
self.assertEqual(provider_key_for_url(settings, 'http://engine', 'other'), '')
@patch('routes.stt.requests.post')
def test_stt_inference_uses_custom_card_key_and_model(self, post):
settings = {'custom_engine_cards': [{'id': 'card', 'url': 'http://engine/v1'}], 'engine_api_keys': {'card': 'saved'}, 'engine_models': {'card': 'selected'}, 'custom_engine_cards': [{'id': 'card', 'url': 'http://engine/v1', 'role': 'stt', 'params': {'language': 'en'}}]}
post.return_value = Mock(status_code=200, json=Mock(return_value={'text': 'heard'}))
with tempfile.TemporaryDirectory() as directory:
audio = Path(directory) / 'audio.wav'
audio.write_bytes(b'test')
stt._transcribe_audio(audio, settings, 'custom:card')
self.assertEqual(post.call_args.kwargs['headers']['Authorization'], 'Bearer saved')
self.assertEqual(post.call_args.kwargs['data']['model'], 'selected')
self.assertEqual(post.call_args.kwargs['data']['language'], 'en')
def test_tts_model_key_and_v1_base_reach_request_config(self):
settings = {'tts_url': 'http://engine/v1', 'tts_models': {'tts_url': 'selected'}, 'engine_local_urls': {'dc-card': 'http://engine/v1'}, 'engine_api_keys': {'card': 'saved'}, 'custom_engine_cards': [{'id': 'card', 'url': 'http://engine/v1', 'role': 'tts', 'params': {'speed': 1.2}}]}
endpoint, payload, headers = tts_helpers._tts_request_config('hello', 'voice', settings)
self.assertEqual(endpoint, 'http://engine/v1/audio/speech')
self.assertEqual(payload['model'], 'selected')
self.assertEqual(payload['speed'], 1.2)
self.assertEqual(headers['Authorization'], 'Bearer saved')
def test_explicit_tts_key_override_is_preserved(self):
settings = {'tts_url': 'http://primary', 'tts_api_key': 'default'}
_, _, headers = tts_helpers._tts_request_config('hello', 'voice', settings, url_override='http://other', api_key_override='default')
self.assertEqual(headers['Authorization'], 'Bearer default')
def test_sibling_backend_does_not_receive_default_credentials(self):
settings = {'tts_url': 'http://primary', 'tts_api_key': 'primary-key', 'whisper_url': 'http://primary', 'whisper_api_key': 'primary-key', 'faster_whisper_url': 'http://sibling'}
_, _, headers = tts_helpers._tts_request_config('hello', 'voice', settings, url_override='http://sibling')
self.assertEqual(headers, {})
self.assertEqual(stt._stt_backend_api_key(settings, 'faster_whisper'), '')
if __name__ == '__main__':
unittest.main()