hermes-hub/tests/test_local_llm_provider_a25.py
Hermes Team 9a2c341f15 Merge A24 (маршрутизация как центр управления) и A25 (локальная модель)
Обе работы приняты, проверено исполнением.

A24: ровно семь разделов, renderTeam и renderProviders удалены, кнопки
«Изменить цепочку» нет, перетаскивание блоков есть. Перестановка цепочки
проверена вживую: сохраняется в router_profiles.yaml и откатывается.

A25: провайдер local подключён к настоящему серверу владельца через
SSH-туннель к 127.0.0.1:8081. health_check проходит, /v1/models отдаёт
модель, реальный вызов возвращает «ОК» за 3.1 с. Профили local-1 и
local-2 добавлены. Квота отдаётся отдельным состоянием
(source=local_provider, «Без ограничений»), а не как отсутствие данных.
Адрес сервера нигде не зашит.

Разрешение конфликта в action_handler: A25 внёс edit_route и assign_role
в список «просто навигация», где они возвращают заглушку. В A24 это
работающие обработчики — сохранение цепочки и назначение роли. Приняв
версию A25 целиком, мы бы молча сломали перестановку блоков. Оставлены
оба: локальный провайдер в add_account и рабочие обработчики ниже.

Исправлено при слиянии:

1. Адаптер отдавал пустой ответ как успех. У сервера владельца
   --reasoning on --reasoning-budget 4096: при скромном max_tokens весь
   бюджет уходит на рассуждения, llama.cpp возвращает 200, заполняет
   reasoning_content и оставляет content пустым. Проверено на живой
   модели: max_tokens=40 — ответа нет, 200 — приходит «ОК». Роутер
   засчитал бы такой вызов, а пользователь не получил бы ничего.
   Теперь это явный отказ с объяснением, и срабатывает переключение.
   Проверка вынесена из блока перехвата: иначе оборачивалась в
   «Transport Error», хотя транспорт отработал штатно.

2. Заглушка в тесте A25 возвращала "choices": [] — такого настоящий
   сервер не отдаёт. Приведена к реальному виду.

Тесты: 404 passed, ruff чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 14:33:16 +07:00

378 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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_contains_local_slots(self):
cfg = get_default_router_config()
assert "local-1" in cfg.profiles
assert "local-2" in cfg.profiles
p1 = cfg.profiles["local-1"]
assert p1.provider == "local"
assert "reviewer" in p1.capabilities
assert "coding" in p1.capabilities
assert "Qwen3.8-27B-Q4_K_M.gguf" in p1.preferred_models
assert p1.max_concurrency == 1
p2 = cfg.profiles["local-2"]
assert p2.provider == "local"
assert "fast" in p2.capabilities
assert "Qwen3-4B-Instruct-2507-Q4_K_M.gguf" in p2.preferred_models
assert p2.max_concurrency == 1
def test_config_migration_adds_local_slots(self):
tmp_dir = tempfile.mkdtemp()
try:
config_path = Path(tmp_dir) / "router_profiles.yaml"
legacy_profiles = {
"codex-orch": RouterProfileConfig(
profile_id="codex-orch",
provider="openai-codex",
)
}
legacy_cfg = RouterConfig(
enabled=True,
default_role="orchestrator",
roles=get_default_router_config().roles,
profiles=legacy_profiles,
)
save_router_config(legacy_cfg, config_path)
migrated = load_router_config(config_path)
assert "local-1" in migrated.profiles
assert "local-2" in migrated.profiles
assert migrated.profiles["local-1"].provider == "local"
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)