"""Disconnect gates inference without destroying saved engine configuration.""" import asyncio import os import tempfile import unittest from unittest.mock import AsyncMock, Mock, patch os.environ.setdefault('CONFIG_DIR', tempfile.mkdtemp(prefix='ttvc-disconnect-tests-')) from fastapi import HTTPException from core.provider_health import engine_disconnected, provider_request_params from routes import docker, stt, tts class EngineDisconnectTests(unittest.TestCase): def test_legacy_native_and_cross_role_endpoint_matching(self): self.assertTrue(engine_disconnected({'engine_disabled': {'ollama': True}}, 'http://localhost:11434/v1', 'llm')) self.assertTrue(engine_disconnected({'engine_disabled': {'ollama': True}}, 'http://host.docker.internal:11434/v1', 'llm')) settings = {'engine_disabled': {'litellm': True}, 'engine_local_urls': {'litellm': 'http://gateway'}} self.assertFalse(engine_disconnected(settings, 'http://gateway', 'tts')) self.assertFalse(engine_disconnected(settings, 'http://gateway', 'ocr')) @patch('routes.tts._backend_health_detail', return_value={'ok': True}) @patch('routes.tts._fetch_backend_voices', return_value=[]) @patch('routes.tts._load_settings') def test_native_alias_of_disabled_custom_tts_is_unavailable(self, load, voices, health): load.return_value = {'tts_url': 'http://gateway', 'engine_active': {'tts_url': 'custom-card'}, 'engine_disabled': {'custom-card': True}, 'custom_engine_cards': [{'id': 'custom-card', 'role': 'tts', 'url': 'http://gateway'}]} payload = asyncio.run(tts.tts_backends()) item = next(item for item in payload['backends'] if item['id'] == 'voice_clone') self.assertFalse(item['available']) self.assertTrue(item['disabled']) self.assertNotIn('http://gateway', [call.args[0] for call in health.call_args_list]) def test_shared_endpoint_uses_selected_identity(self): settings = {'llm_url': 'http://gateway/v1', 'engine_active': {'llm_url': 'enabled'}, 'engine_local_urls': {'disabled': 'http://gateway', 'enabled': 'http://gateway'}, 'engine_disabled': {'disabled': True}} self.assertFalse(engine_disconnected(settings, 'http://gateway/v1', 'llm')) provider_request_params('http://gateway/v1/chat/completions', {}, settings) settings['engine_active']['llm_url'] = 'disabled' with self.assertRaisesRegex(ValueError, 'disconnected'): provider_request_params('http://gateway/v1/chat/completions', {}, settings) @patch('routes.docker._save_settings') @patch('routes.docker._load_settings') def test_disconnect_retains_saved_configuration(self, load, save): load.return_value = {'llm_url': 'http://gateway/v1', 'llm_model': 'chosen', 'engine_api_keys': {'card': 'dummy'}, 'custom_engine_cards': [{'id': 'card', 'role': 'llm'}]} request = Mock(json=AsyncMock(return_value={'connected': False})) asyncio.run(docker.engine_connection('card', request)) saved = save.call_args.args[0] self.assertEqual(saved['llm_url'], 'http://gateway/v1') self.assertEqual(saved['llm_model'], 'chosen') self.assertEqual(saved['engine_api_keys']['card'], 'dummy') self.assertTrue(saved['engine_disabled']['card']) @patch('routes.docker._save_settings') @patch('routes.docker._load_settings') def test_current_dc_url_overrides_stale_bare_url(self, load, save): load.return_value = {'llm_url': 'http://current/v1', 'engine_local_urls': {'dc-card': 'http://current', 'card': 'http://stale'}, 'custom_engine_cards': [{'id': 'card', 'role': 'llm'}], 'engine_disabled': {'card': True}} self.assertTrue(engine_disconnected(load.return_value, 'http://current', 'llm')) self.assertFalse(engine_disconnected(load.return_value, 'http://stale', 'llm')) request = Mock(json=AsyncMock(return_value={'connected': False})) asyncio.run(docker.engine_connection('card', request)) self.assertEqual(save.call_args.args[0]['engine_active']['llm_url'], 'card') @patch('routes.docker._save_settings') @patch('routes.docker._load_settings') def test_disconnect_records_native_default_selection(self, load, save): load.return_value = {'llm_url': 'http://host.docker.internal:11434/v1'} request = Mock(json=AsyncMock(return_value={'connected': False})) asyncio.run(docker.engine_connection('ollama', request)) self.assertEqual(save.call_args.args[0]['engine_active']['llm_url'], 'ollama') @patch('routes.docker._save_settings') @patch('routes.docker._load_settings') def test_disconnect_records_unique_legacy_selection_for_both_roles(self, load, save): for role, setting in [('llm', 'llm_url'), ('ocr', 'vision_ocr_url')]: load.return_value = {setting: 'http://lan:14000/v1', 'engine_local_urls': {'dc-card': 'http://lan:14000'}, 'custom_engine_cards': [{'id': 'card', 'role': role}]} request = Mock(json=AsyncMock(return_value={'connected': False})) asyncio.run(docker.engine_connection('card', request)) self.assertEqual(save.call_args.args[0]['engine_active'][setting], 'card') @patch('routes.docker._save_settings') @patch('routes.docker._load_settings') def test_disconnect_preserves_other_selection_on_shared_endpoint(self, load, save): load.return_value = {'llm_url': 'http://gateway/v1', 'engine_active': {'llm_url': 'other'}, 'engine_local_urls': {'card': 'http://gateway', 'other': 'http://gateway'}, 'custom_engine_cards': [{'id': 'card', 'role': 'llm'}, {'id': 'other', 'role': 'llm'}]} request = Mock(json=AsyncMock(return_value={'connected': False})) asyncio.run(docker.engine_connection('card', request)) self.assertEqual(save.call_args.args[0]['engine_active']['llm_url'], 'other') @patch('routes.stt._stt_backend_health') @patch('routes.stt._load_settings') def test_disabled_stt_skips_probe_and_rejects_inference(self, load, health): load.return_value = {'engine_disabled': {'faster-whisper': True}, 'faster_whisper_url': 'http://whisper'} health.return_value = {'ok': True, 'models': []} payload = asyncio.run(stt.stt_backends()) item = next(item for item in payload['backends'] if item['id'] == 'faster_whisper') self.assertFalse(item['available']) self.assertFalse(item['connected']) self.assertNotIn('http://whisper', [call.args[0] for call in health.call_args_list]) with self.assertRaises(HTTPException) as caught: stt._transcribe_audio(None, load.return_value, 'faster_whisper') self.assertEqual(caught.exception.status_code, 409)