hermes-hub/tests/test_a39_local_request_options.py
ochenstarik-ui 5ac01e0a15 feat(local): параметры запроса (request_options) для локальных профилей
Реализована поддержка произвольных параметров запроса (request_options) для
локальных профилей:

1. RouterProfileConfig и ProfileViewModel дополнены полем request_options
   с полной поддержкой вложенных словарей и сериализацией в YAML.
2. LocalLLMAdapter подмешивает request_options в тело POST /chat/completions.
   Явные поля запроса имеют приоритет, при расхождениях пишется warning.
   Изоляция провайдеров сохранена: другие адаптеры не трогают request_options.
3. Неизвестные/некорректные параметры обрабатываются чисто и классифицируются
   как INVALID_REQUEST с извлечением текста ошибки сервера.
4. В веб-интерфейсе реализована карточка профиля с редактором JSON параметров,
   живой валидацией синтаксиса, предпросмотром тела запроса и проверкой
   подключения.
5. Отсутствие зашитых параметров enable_thinking и reasoning_effort в логике.
6. 526 passed, 3 skipped, 4 deselected, ruff чисто.
2026-08-30 23:49:57 +07:00

360 lines
15 KiB
Python

"""Unit and integration tests for Task A39: Request options for local profiles.
Tests:
1. RouterProfileConfig schema, YAML serialization, and persistence across save/load.
2. LocalLLMAdapter payload merging with nested structures (e.g. chat_template_kwargs).
3. Precedence of explicit request parameters over request_options and warning logging.
4. Provider isolation: ensure non-local adapters never leak request_options.
5. Error parsing and classification for invalid/unknown parameters.
6. Action handler save_request_options execution.
7. Web UI contract in app.js for request options editor, live preview, and validation.
8. Clean codebase: absence of hardcoded parameter keys in adapter/router logic.
"""
from __future__ import annotations
import json
import logging
import pathlib
import tempfile
from unittest.mock import MagicMock, patch
import pytest
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.router_config import (
RouterConfig,
RouterProfileConfig,
load_router_config,
save_router_config,
)
from antigravity_provider.router.action_handler import ActionExecutor, do_save_request_options
from antigravity_provider.router.unified_health import UnifiedHealthService
class TestProfileConfigAndPersistence:
"""Test RouterProfileConfig schema and YAML persistence."""
def test_profile_config_defaults(self):
pcfg = RouterProfileConfig(
profile_id="local-test",
provider="local",
)
assert pcfg.request_options == {}
def test_profile_config_custom_options_and_nested_dict(self):
options = {
"chat_template_kwargs": {"enable_thinking": False},
"seed": 42,
"top_k": 40,
}
pcfg = RouterProfileConfig(
profile_id="local-test",
provider="local",
request_options=options,
)
assert pcfg.request_options == options
assert pcfg.request_options["chat_template_kwargs"]["enable_thinking"] is False
def test_yaml_roundtrip_preserves_nested_request_options(self):
with tempfile.TemporaryDirectory() as tmpdir:
cfg_path = pathlib.Path(tmpdir) / "router_profiles.yaml"
cfg = RouterConfig(
profiles={
"local-1": RouterProfileConfig(
profile_id="local-1",
provider="local",
preferred_models=["Qwen3.8-27B-Q4_K_M.gguf"],
request_options={
"chat_template_kwargs": {"enable_thinking": False},
"seed": 1234,
"custom_flag": True,
},
),
"ag-w1": RouterProfileConfig(
profile_id="ag-w1",
provider="antigravity",
request_options={},
),
}
)
assert save_router_config(cfg, cfg_path)
assert cfg_path.is_file()
loaded = load_router_config(cfg_path)
loaded_p = loaded.get_profile("local-1")
assert loaded_p is not None
assert loaded_p.request_options == {
"chat_template_kwargs": {"enable_thinking": False},
"seed": 1234,
"custom_flag": True,
}
assert loaded_p.request_options["chat_template_kwargs"]["enable_thinking"] is False
ag_p = loaded.get_profile("ag-w1")
assert ag_p is not None
assert ag_p.request_options == {}
def test_unified_health_profile_view_model_includes_request_options(self):
with tempfile.TemporaryDirectory() as tmpdir:
cfg_path = pathlib.Path(tmpdir) / "router_profiles.yaml"
cfg = RouterConfig(
profiles={
"local-1": RouterProfileConfig(
profile_id="local-1",
provider="local",
request_options={"chat_template_kwargs": {"enable_thinking": False}},
)
}
)
save_router_config(cfg, cfg_path)
with patch.dict("os.environ", {"HERMES_ROUTER_CONFIG": str(cfg_path)}):
uh = UnifiedHealthService.get()
profs = uh.scan_all(force=True)
local_list = profs.get("local", [])
matching = [p for p in local_list if p.profile_id == "local-1"]
assert len(matching) == 1
assert matching[0].request_options == {"chat_template_kwargs": {"enable_thinking": False}}
class TestLocalLLMAdapterRequestOptions:
"""Test LocalLLMAdapter.invoke merging of request_options and precedence handling."""
def test_invoke_merges_nested_request_options_into_payload(self):
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_options={
"chat_template_kwargs": {"enable_thinking": False},
"presence_penalty": 0.5,
},
)
request = {
"messages": [{"role": "user", "content": "Hello world"}],
"max_tokens": 500,
}
mock_resp_data = {
"id": "chatcmpl-1",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "Hello!"}, "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]
payload = json.loads(req_arg.data.decode("utf-8"))
assert payload["chat_template_kwargs"] == {"enable_thinking": False}
assert payload["presence_penalty"] == 0.5
assert payload["max_tokens"] == 500
assert payload["messages"] == [{"role": "user", "content": "Hello world"}]
assert payload["model"] == "Qwen3.8-27B-Q4_K_M.gguf"
def test_explicit_request_parameters_override_request_options_and_log_warning(self, caplog):
adapter = LocalLLMAdapter()
profile = RouterProfileConfig(
profile_id="local-1",
provider="local",
request_options={
"max_tokens": 4096,
"temperature": 0.1,
"presence_penalty": 0.2,
},
)
request = {
"messages": [{"role": "user", "content": "test"}],
"max_tokens": 1500, # Explicit request parameter wins
"temperature": 0.8, # Explicit request parameter wins
}
mock_resp_data = {
"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 caplog.at_level(logging.WARNING):
with patch("urllib.request.urlopen", return_value=mock_response) as mock_urlopen:
adapter.invoke(profile, request)
req_arg = mock_urlopen.call_args[0][0]
payload = json.loads(req_arg.data.decode("utf-8"))
assert payload["max_tokens"] == 1500
assert payload["temperature"] == 0.8
assert payload["presence_penalty"] == 0.2
# Verify warnings logged for conflicts
warnings = [record.message for record in caplog.records if record.levelno >= logging.WARNING]
assert any("max_tokens" in w for w in warnings)
assert any("temperature" in w for w in warnings)
def test_temperature_from_request_options_used_when_request_omits_temperature(self):
adapter = LocalLLMAdapter()
profile = RouterProfileConfig(
profile_id="local-1",
provider="local",
request_options={"temperature": 0.2},
)
request = {
"messages": [{"role": "user", "content": "test"}],
}
mock_resp_data = {
"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:
adapter.invoke(profile, request)
req_arg = mock_urlopen.call_args[0][0]
payload = json.loads(req_arg.data.decode("utf-8"))
assert payload["temperature"] == 0.2
class TestProviderIsolation:
"""Verify other provider adapters never leak request_options."""
@pytest.mark.parametrize(
"provider_name",
["antigravity", "openai-codex", "claude", "grok", "opencode-go"],
)
def test_non_local_adapters_do_not_inject_arbitrary_request_options(self, provider_name):
adapter = get_adapter(provider_name)
assert not isinstance(adapter, LocalLLMAdapter)
import inspect
src = inspect.getsource(adapter.invoke)
assert "request_options" not in src, f"{provider_name} adapter must not reference request_options"
class TestErrorHandlingAndClassification:
"""Test error parsing and error classification for local provider."""
def test_unknown_parameter_http_400_raises_runtime_error_with_extracted_message(self):
adapter = LocalLLMAdapter()
profile = RouterProfileConfig(
profile_id="local-1",
provider="local",
request_options={"invalid_param": 123},
)
request = {"messages": [{"role": "user", "content": "hi"}]}
import io
import urllib.error
error_body = json.dumps({"error": {"message": "unknown parameter 'invalid_param'", "type": "invalid_request_error"}}).encode("utf-8")
http_err = urllib.error.HTTPError("http://127.0.0.1:8081/v1/chat/completions", 400, "Bad Request", {}, io.BytesIO(error_body))
with patch("urllib.request.urlopen", side_effect=http_err):
with pytest.raises(RuntimeError) as exc_info:
adapter.invoke(profile, request)
err_str = str(exc_info.value)
assert "Local LLM API Error (400)" in err_str
assert "unknown parameter 'invalid_param'" in err_str
def test_classify_error_for_invalid_request(self):
adapter = LocalLLMAdapter()
exc = RuntimeError("Local LLM API Error (400): unknown parameter 'foo'")
classification = adapter.classify_error(exc)
assert classification.category == ErrorCategory.INVALID_REQUEST
assert "unknown parameter" in classification.message
class TestActionHandlerAndWebUI:
"""Test action execution and web client contracts for request_options."""
def test_do_save_request_options(self):
with tempfile.TemporaryDirectory() as tmpdir:
cfg_path = pathlib.Path(tmpdir) / "router_profiles.yaml"
cfg = RouterConfig(
profiles={
"local-1": RouterProfileConfig(
profile_id="local-1",
provider="local",
preferred_models=["default"],
)
}
)
save_router_config(cfg, cfg_path)
with patch.dict("os.environ", {"HERMES_ROUTER_CONFIG": str(cfg_path)}):
ok, msg = do_save_request_options("local-1", {"chat_template_kwargs": {"enable_thinking": False}})
assert ok is True
assert "успешно сохранены" in msg
# Verify persistence
loaded = load_router_config(cfg_path)
assert loaded.get_profile("local-1").request_options == {
"chat_template_kwargs": {"enable_thinking": False}
}
def test_action_executor_save_request_options(self):
with tempfile.TemporaryDirectory() as tmpdir:
cfg_path = pathlib.Path(tmpdir) / "router_profiles.yaml"
cfg = RouterConfig(
profiles={
"local-1": RouterProfileConfig(
profile_id="local-1",
provider="local",
)
}
)
save_router_config(cfg, cfg_path)
with patch.dict("os.environ", {"HERMES_ROUTER_CONFIG": str(cfg_path)}):
res = ActionExecutor.execute(
"save_request_options",
{
"profile_id": "local-1",
"request_options": {"chat_template_kwargs": {"enable_thinking": False}},
},
)
assert res.get("ok") is True
def test_action_executor_invalid_json_fails_gracefully(self):
ok, msg = do_save_request_options("local-1", "{invalid json...")
assert ok is False
assert "Некорректный JSON" in msg
def test_app_js_contains_request_options_modal_and_validation(self):
app_js = pathlib.Path("src/antigravity_provider/router/web/static/app.js").read_text(encoding="utf-8")
assert "modal-request-options-input" in app_js
assert "updateRequestOptionsPreview" in app_js
assert "handleSaveRequestOptions" in app_js
assert "modal-payload-preview-content" in app_js
assert "save_request_options" in app_js
assert "openAccountDetailsModal" in app_js
class TestNoHardcodedConstants:
"""Verify neither enable_thinking nor reasoning_effort is hardcoded in local adapter or router config logic."""
def test_no_hardcoded_keys_in_local_adapter_and_router_config(self):
src_files = [
pathlib.Path("src/antigravity_provider/router/adapters/local_adapter.py"),
pathlib.Path("src/antigravity_provider/router/router_config.py"),
pathlib.Path("src/antigravity_provider/router/action_handler.py"),
]
for py_file in src_files:
text = py_file.read_text(encoding="utf-8")
lines = text.splitlines()
for idx, line in enumerate(lines, 1):
clean = line.strip()
if clean.startswith("#"):
continue
assert "enable_thinking" not in clean, f"Hardcoded enable_thinking found in {py_file.name}:{idx}: {line}"
assert "reasoning_effort" not in clean, f"Hardcoded reasoning_effort found in {py_file.name}:{idx}: {line}"