"""Unit and integration tests for Task A25: Local LLM Provider Integration. Tests: 1. LocalLLMAdapter.invoke with mock HTTP server / urllib. 2. Error classification and instant failover on network / auth / rate limit errors. 3. discover_models and ModelDiscoveryService for 'local'. 4. find_free_slot("local") and config migration for local-1 / local-2. 5. Quota snapshot state "Без ограничений" (is_loading=False, status=unlimited). 6. Configurable custom_base_url and absence of hardcoded unconfigurable IP/ports. 7. ProfileAuthManager local endpoint verification and profile status. """ from __future__ import annotations import io import json import os import shutil import sys import tempfile import urllib.error from pathlib import Path from unittest.mock import MagicMock, patch import pytest sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) from antigravity_provider.router.adapters import get_adapter from antigravity_provider.router.adapters.base_adapter import ErrorCategory from antigravity_provider.router.adapters.local_adapter import LocalLLMAdapter from antigravity_provider.router.auto_assigner import AutoAssigner from antigravity_provider.router.model_discovery_service import ModelDiscoveryService from antigravity_provider.router.profile_manager import ProfileAuthManager from antigravity_provider.router.quota_collector import AccountQuotaService from antigravity_provider.router.router_config import ( RouterConfig, RouterProfileConfig, get_default_router_config, load_router_config, save_router_config, ) class TestLocalLLMAdapterInvoke: """Test LocalLLMAdapter invocation and parameters.""" def test_adapter_registration(self): """Adapter must be registered for local, local-llm, llama.cpp, ollama, vllm.""" for key in ["local", "local-llm", "llama.cpp", "ollama", "vllm"]: adapter = get_adapter(key) assert isinstance(adapter, LocalLLMAdapter) def test_invoke_success_without_api_key(self): """Invoke should perform POST to {base_url}/chat/completions without Authorization header if no key.""" adapter = LocalLLMAdapter() profile = RouterProfileConfig( profile_id="local-1", provider="local", custom_base_url="http://127.0.0.1:8081/v1", preferred_models=["Qwen3.8-27B-Q4_K_M.gguf"], ) request = { "messages": [{"role": "user", "content": "Hello"}], "temperature": 0.5, } mock_resp_data = { "id": "chatcmpl-123", "choices": [ { "index": 0, "message": {"role": "assistant", "content": "Hi there!"}, "finish_reason": "stop", } ], "usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}, } mock_response = MagicMock() mock_response.read.return_value = json.dumps(mock_resp_data).encode("utf-8") mock_response.__enter__.return_value = mock_response with patch("urllib.request.urlopen", return_value=mock_response) as mock_urlopen: resp = adapter.invoke(profile, request) assert resp == mock_resp_data mock_urlopen.assert_called_once() req_arg = mock_urlopen.call_args[0][0] assert req_arg.get_full_url() == "http://127.0.0.1:8081/v1/chat/completions" assert req_arg.get_method() == "POST" # Verify no Authorization header sent assert "Authorization" not in req_arg.headers payload = json.loads(req_arg.data.decode("utf-8")) assert payload["model"] == "Qwen3.8-27B-Q4_K_M.gguf" assert payload["messages"] == [{"role": "user", "content": "Hello"}] assert payload["temperature"] == 0.5 def test_invoke_with_api_key_and_custom_base_url(self): """Invoke should include Bearer token and use custom base url when provided.""" adapter = LocalLLMAdapter() profile = RouterProfileConfig( profile_id="local-2", provider="local", custom_base_url="http://192.168.1.50:11434/v1", preferred_models=["llama3:8b"], auth_config={"api_key": "secret-token-123"}, ) request = { "model": "llama3:8b", "messages": [{"role": "user", "content": "Test"}], "tools": [{"type": "function", "function": {"name": "search"}}], } # Заглушка должна быть похожа на настоящий ответ: сервер всегда # возвращает хотя бы один choice с текстом. Пустой choices означает # отказ, и адаптер обязан его отвергнуть, а не выдать за успех. mock_resp_data = { "id": "chatcmpl-456", "choices": [ { "index": 0, "message": {"role": "assistant", "content": "OK"}, "finish_reason": "stop", } ], } mock_response = MagicMock() mock_response.read.return_value = json.dumps(mock_resp_data).encode("utf-8") mock_response.__enter__.return_value = mock_response with patch("urllib.request.urlopen", return_value=mock_response) as mock_urlopen: resp = adapter.invoke(profile, request) assert resp == mock_resp_data req_arg = mock_urlopen.call_args[0][0] assert req_arg.get_full_url() == "http://192.168.1.50:11434/v1/chat/completions" assert req_arg.headers.get("Authorization") == "Bearer secret-token-123" payload = json.loads(req_arg.data.decode("utf-8")) assert payload["tools"] == [{"type": "function", "function": {"name": "search"}}] class TestLocalLLMErrorClassification: """Test error classification for instant failover and auth errors.""" def test_classify_connection_refused_as_transient(self): adapter = LocalLLMAdapter() exc = urllib.error.URLError("Connection refused [WinError 10061]") classification = adapter.classify_error(exc) assert classification.category == ErrorCategory.TRANSIENT assert classification.retry_delay_seconds <= 5 def test_classify_timeout_as_transient(self): adapter = LocalLLMAdapter() exc = TimeoutError("The read operation timed out") classification = adapter.classify_error(exc) assert classification.category == ErrorCategory.TRANSIENT assert classification.retry_delay_seconds <= 5 def test_classify_http_502_503_as_transient(self): adapter = LocalLLMAdapter() exc = RuntimeError("Local LLM API Error (502): Bad Gateway") classification = adapter.classify_error(exc) assert classification.category == ErrorCategory.TRANSIENT def test_classify_http_401_403_as_auth_required(self): adapter = LocalLLMAdapter() exc = RuntimeError("Local LLM API Error (401): Unauthorized access") classification = adapter.classify_error(exc) assert classification.category == ErrorCategory.AUTH_REQUIRED def test_classify_http_429_as_rate_limited(self): adapter = LocalLLMAdapter() exc = RuntimeError("Local LLM API Error (429): Too Many Requests") classification = adapter.classify_error(exc) assert classification.category == ErrorCategory.RATE_LIMITED class TestLocalLLMModelDiscovery: """Test discover_models and ModelDiscoveryService for local provider.""" def test_adapter_discover_models_success(self): adapter = LocalLLMAdapter() profile = RouterProfileConfig( profile_id="local-1", provider="local", custom_base_url="http://127.0.0.1:8081/v1", ) mock_models_resp = { "data": [ {"id": "Qwen3.8-27B-Q4_K_M.gguf", "object": "model"}, {"id": "deepseek-coder-6.7b.gguf", "object": "model"}, ] } mock_response = MagicMock() mock_response.read.return_value = json.dumps(mock_models_resp).encode("utf-8") mock_response.__enter__.return_value = mock_response with patch("urllib.request.urlopen", return_value=mock_response): models = adapter.discover_models(profile) assert "Qwen3.8-27B-Q4_K_M.gguf" in models assert "deepseek-coder-6.7b.gguf" in models def test_adapter_health_check(self): adapter = LocalLLMAdapter() profile = RouterProfileConfig( profile_id="local-1", provider="local", custom_base_url="http://127.0.0.1:8081/v1", ) mock_response = MagicMock() mock_response.status = 200 mock_response.__enter__.return_value = mock_response with patch("urllib.request.urlopen", return_value=mock_response): assert adapter.health_check(profile) is True with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("Connection refused")): assert adapter.health_check(profile) is False def test_model_discovery_service_local_probe(self): tmp_dir = tempfile.mkdtemp() try: cache_file = Path(tmp_dir) / "models_cache.json" service = ModelDiscoveryService(cache_path=cache_file) mock_models_resp = { "models": [ {"name": "local-qwen-27b"}, {"name": "local-mistral-7b"}, ] } mock_response = MagicMock() mock_response.read.return_value = json.dumps(mock_models_resp).encode("utf-8") mock_response.__enter__.return_value = mock_response with patch("urllib.request.urlopen", return_value=mock_response): models = service.discover_models_sync("local") assert models is not None assert "local-qwen-27b" in models assert "local-mistral-7b" in models assert service.get_models("local") == sorted(["local-qwen-27b", "local-mistral-7b"]) finally: shutil.rmtree(tmp_dir, ignore_errors=True) class TestLocalLLMConfigAndAutoAssigner: """Test default configuration, migration, and slot auto-assignment.""" def test_default_config_clean_roles_and_local_registration(self): cfg = get_default_router_config() assert len(cfg.profiles) == 0 assert len(cfg.roles) == 14 slot = AutoAssigner.find_free_slot("local") assert slot == "local-1" reloaded = load_router_config() assert "local-1" in reloaded.profiles p1 = reloaded.profiles["local-1"] assert p1.provider == "local" assert "coding" in p1.capabilities def test_config_migration_preserves_user_profiles_without_injecting_stubs(self): tmp_dir = tempfile.mkdtemp() try: config_path = Path(tmp_dir) / "router_profiles.yaml" legacy_profiles = { "custom-codex": RouterProfileConfig( profile_id="custom-codex", provider="openai-codex", ) } legacy_cfg = RouterConfig( enabled=True, default_role="manager", roles={"manager": get_default_router_config().roles["manager"]}, profiles=legacy_profiles, ) save_router_config(legacy_cfg, config_path) migrated = load_router_config(config_path) # User profile is preserved assert "custom-codex" in migrated.profiles # 14 canonical roles are migrated assert len(migrated.roles) == 14 # No dummy local profiles injected assert "local-1" not in migrated.profiles finally: shutil.rmtree(tmp_dir, ignore_errors=True) def test_auto_assigner_find_free_slot_local(self): slot = AutoAssigner.find_free_slot("local") assert slot in ("local-1", "local-2") class TestLocalLLMQuotaHonesty: """Test honest display of 'Без ограничений' for Local LLM quota.""" def test_local_quota_snapshot_structure(self): snap = AccountQuotaService.get().fetch_account_quota("local", "local-1") assert snap is not None assert snap.is_loading is False assert snap.source in ("local_provider", "baseline") assert "Без ограничений" in (snap.unavailable_reason or "") assert len(snap.buckets) == 1 bucket = snap.buckets[0] assert bucket.id == "local.unlimited" assert bucket.status == "unlimited" assert bucket.period == "unlimited" assert bucket.used_percent == 0.0 assert bucket.remaining_percent == 100.0 assert bucket.formatted_remaining() == "Без ограничений" class TestProfileManagerLocalVerification: """Test ProfileAuthManager local endpoint verification and base_url synchronization.""" def test_verify_local_endpoint_success(self): mock_data = { "data": [ {"id": "qwen3.8-27b"}, {"id": "deepseek-r1-distill"}, ] } mock_response = MagicMock() mock_response.status = 200 mock_response.read.return_value = json.dumps(mock_data).encode("utf-8") mock_response.__enter__.return_value = mock_response with patch("urllib.request.urlopen", return_value=mock_response): ok, dname, models, err = ProfileAuthManager.verify_local_endpoint( "http://127.0.0.1:8081/v1", api_key="my-key" ) assert ok is True assert dname is not None and "127.0.0.1:8081" in dname assert "qwen3.8-27b" in models assert "deepseek-r1-distill" in models assert err is None def test_verify_local_endpoint_connection_refused(self): with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("Connection refused")): ok, dname, models, err = ProfileAuthManager.verify_local_endpoint("http://127.0.0.1:9999/v1") assert ok is False assert dname is None assert models == [] assert err is not None assert "Connection refused" in err def test_save_profile_auth_syncs_custom_base_url(self): tmp_dir = tempfile.mkdtemp() try: config_path = Path(tmp_dir) / "router_profiles.yaml" save_router_config(get_default_router_config(), config_path) with patch.dict(os.environ, {"HERMES_ROUTER_CONFIG": str(config_path)}): auth_data = { "provider": "local", "profile_id": "local-1", "base_url": "http://192.168.1.120:8000/v1", "models": ["my-custom-model"], } ProfileAuthManager.save_profile_auth("local", "local-1", auth_data) # Verify custom_base_url synced in router_profiles.yaml reloaded = load_router_config(config_path) p1 = reloaded.get_profile("local-1") assert p1 is not None assert p1.custom_base_url == "http://192.168.1.120:8000/v1" assert p1.preferred_models == ["my-custom-model"] status = ProfileAuthManager.get_profile_status("local", "local-1") assert status["authenticated"] is True assert "192.168.1.120:8000" in status["account_id_masked"] finally: shutil.rmtree(tmp_dir, ignore_errors=True)