diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index 7daa835..91e4cb1 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -280,6 +280,42 @@ def do_set_model(profile_id: str, model: str, role_id: Optional[str] = None) -> return False, "Не удалось сохранить файл конфигурации" +def do_save_request_options(profile_id: str, request_options: Any) -> Tuple[bool, str]: + if not profile_id or not str(profile_id).strip(): + return False, "Не указан идентификатор профиля" + + if isinstance(request_options, str): + try: + request_options = json.loads(request_options) + except Exception as exc: + return False, f"Некорректный JSON параметров запроса: {exc}" + + if not isinstance(request_options, dict): + return False, "Параметры запроса должны быть объектом (словарём)" + + cfg = load_router_config() + if profile_id not in cfg.profiles: + return False, f"Профиль '{profile_id}' не найден в конфигурации" + + pcfg = cfg.profiles[profile_id] + pcfg.request_options = request_options + cfg.profiles[profile_id] = pcfg + + if save_router_config(cfg): + try: + from antigravity_provider.router.state_store import HubStateStore + HubStateStore.get().refresh(force_scan=True) + except Exception: + pass + EventLogService.get().log( + "account", + f"Параметры запроса для профиля {profile_id} ({pcfg.provider}) сохранены.", + level="info", + ) + return True, f"Параметры запроса для профиля {profile_id} успешно сохранены" + return False, "Не удалось сохранить файл конфигурации" + + # Подключённый аккаунт обязан появиться в списке сразу. # # Учётные данные сохраняются на диск, но состояние профилей берётся из кэша @@ -799,6 +835,12 @@ class ActionExecutor: elif action == 'save_settings': ok, msg = do_save_settings(data) return {'ok': ok, 'message': msg} + + elif action in ['save_request_options', 'set_request_options']: + options = data.get('request_options', {}) + target_pid = pid or data.get('profile_id', '') + ok, msg = do_save_request_options(target_pid, options) + return {'ok': ok, 'message': msg} elif action == 'discover_local_models': # Поиск уже запущенных локальных серверов на машине. diff --git a/src/antigravity_provider/router/adapters/local_adapter.py b/src/antigravity_provider/router/adapters/local_adapter.py index a632cad..5a39986 100644 --- a/src/antigravity_provider/router/adapters/local_adapter.py +++ b/src/antigravity_provider/router/adapters/local_adapter.py @@ -164,8 +164,14 @@ class LocalLLMAdapter(BaseProviderAdapter): payload: Dict[str, Any] = { "model": model, "messages": messages, - "temperature": request.get("temperature", 0.7), } + if "temperature" in request: + payload["temperature"] = request["temperature"] + elif isinstance(profile.request_options, dict) and "temperature" in profile.request_options: + payload["temperature"] = profile.request_options["temperature"] + else: + payload["temperature"] = request.get("temperature", 0.7) + if "tools" in request and request["tools"]: payload["tools"] = request["tools"] if "tool_choice" in request: @@ -179,6 +185,42 @@ class LocalLLMAdapter(BaseProviderAdapter): if "stop" in request: payload["stop"] = request["stop"] + # Mix in request_options from profile (generic, supports any arbitrary keys & nested structures) + req_options = profile.request_options if isinstance(profile.request_options, dict) else {} + for opt_key, opt_val in req_options.items(): + if opt_key == "temperature": + if "temperature" in request and request["temperature"] != opt_val: + logger.warning( + "Profile %s request_option '%s' (%r) ignored: request specified explicit value (%r)", + profile.profile_id, + opt_key, + opt_val, + request["temperature"], + ) + continue + + if opt_key in request: + req_val = request[opt_key] + if req_val != opt_val: + logger.warning( + "Profile %s request_option '%s' (%r) ignored: request specified explicit value (%r)", + profile.profile_id, + opt_key, + opt_val, + req_val, + ) + elif opt_key in payload: + if payload[opt_key] != opt_val: + logger.warning( + "Profile %s request_option '%s' (%r) ignored: payload contains (%r)", + profile.profile_id, + opt_key, + opt_val, + payload[opt_key], + ) + else: + payload[opt_key] = opt_val + headers: Dict[str, str] = { "Content-Type": "application/json", "User-Agent": "hermes-router/1.0", @@ -328,6 +370,14 @@ class LocalLLMAdapter(BaseProviderAdapter): message=err_msg, ) + # 400 / Bad request / Invalid parameter / Unknown parameter + if any(k in err_lower for k in ("400", "invalid_request", "bad request", "unknown parameter", "invalid parameter", "unknown field", "unrecognized field")): + return ErrorClassification( + category=ErrorCategory.INVALID_REQUEST, + message=err_msg, + retry_delay_seconds=300, + ) + # Quota exhausted if any(k in err_lower for k in ("quota", "insufficient balance", "insufficient_quota")): return ErrorClassification( diff --git a/src/antigravity_provider/router/router_config.py b/src/antigravity_provider/router/router_config.py index 50bcbb6..f423521 100644 --- a/src/antigravity_provider/router/router_config.py +++ b/src/antigravity_provider/router/router_config.py @@ -21,6 +21,7 @@ class RouterProfileConfig: enabled: bool = True max_concurrency: int = 1 # 1 for stateful process, >1 for stateless REST custom_base_url: Optional[str] = None + request_options: dict[str, Any] = field(default_factory=dict) @dataclass @@ -307,6 +308,9 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig: max_concurrency = int(pdata.get("max_concurrency", 1)) if provider == "local": max_concurrency = 1 + req_opts = pdata.get("request_options") + if not isinstance(req_opts, dict): + req_opts = {} profiles[pid] = RouterProfileConfig( profile_id=pid, provider=provider, @@ -318,6 +322,7 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig: enabled=bool(pdata.get("enabled", True)), max_concurrency=max_concurrency, custom_base_url=pdata.get("custom_base_url"), + request_options=dict(req_opts), ) roles_raw = data.get("roles", {}) @@ -483,6 +488,8 @@ def save_router_config(config: RouterConfig, config_path: Optional[Path] = None) } if pcfg.custom_base_url: profiles_data[pid]["custom_base_url"] = pcfg.custom_base_url + if pcfg.request_options: + profiles_data[pid]["request_options"] = pcfg.request_options roles_data = {} for rname, rpol in config.roles.items(): diff --git a/src/antigravity_provider/router/unified_health.py b/src/antigravity_provider/router/unified_health.py index 3b21b18..24e7cd1 100644 --- a/src/antigravity_provider/router/unified_health.py +++ b/src/antigravity_provider/router/unified_health.py @@ -98,6 +98,7 @@ class ProfileViewModel: quota_snapshot: Optional[Any] = None preferred_models: List[str] = field(default_factory=list) active_leases: int = 0 + request_options: dict[str, Any] = field(default_factory=dict) @property def auth_label_ru(self) -> str: @@ -529,6 +530,7 @@ class UnifiedHealthService: quota_snapshot=snap, preferred_models=pcfg.preferred_models, active_leases=precord.active_leases, + request_options=dict(pcfg.request_options or {}), ) result.setdefault(prov, []).append(vm) diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 7637815..62e0753 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -1460,6 +1460,303 @@ function initSettings() { } } +function openAccountDetailsModal(profileId, isRedraw = false) { + _openAccountModalProfile = profileId; + if (!currentSnapshot) return; + const allProfiles = currentSnapshot.all_profiles || {}; + const profile = allProfiles[profileId]; + if (!profile) return; + + const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider); + const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : []; + const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : ''; + const qs = profile.quota_snapshot; + const buckets = (qs && qs.buckets) ? qs.buckets : []; + + let modelBlockHtml = ''; + if (discoveredModels.length > 0) { + modelBlockHtml = ` +
+ +
+ + +
+
+ `; + } else { + modelBlockHtml = ` +
+
+ ⚠ Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}. +
+ +
+ `; + } + + // Local request options section + let requestOptionsHtml = ''; + if (profile.provider === 'local') { + const rawOptions = profile.request_options || {}; + const formattedJson = JSON.stringify(rawOptions, null, 2); + requestOptionsHtml = ` +
+
+ + ✓ JSON валиден +
+
+ Произвольные параметры, подмешиваемые в тело запроса (например, {"chat_template_kwargs": {"enable_thinking": false}}). +
+ + +
+ + +
+ + +
+ `; + } + + let quotasHtml = ''; + if (profile.provider !== 'local') { + quotasHtml = ` +

+ Квоты и корзины провайдера +

+
+ ${buckets.map((b) => ` +
+
+ ${escapeHtml(b.display_name)} + ${b.remaining_percent !== null && b.remaining_percent !== undefined ? `${b.remaining_percent.toFixed(1)}%` : 'Н/Д'} +
+
+
+
+
+ ${b.reset_at ? `Сброс: ${formatIsoDate(b.reset_at)}` : (b.period ? `Период: ${b.period}` : 'Без отметки сброса')} +
+
+ `).join('') || '
Данные о квотах отсутствуют.
'} +
+ `; + } + + elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`; + elements.modalBody.innerHTML = ` + +
+
${escapeHtml(profile.account_identity || profile.email || profileId)}
+
+ Провайдер: ${escapeHtml(profile.provider_display_name || profile.provider)} • + Тариф: ${escapeHtml(profile.plan || 'Неизвестen')} • + Статус: ${escapeHtml(profile.health_label_ru || 'Работает')} +
+
+ Назначенные роли: ${escapeHtml((profile.assigned_roles || []).join(', ') || 'Нет')} +
+
+ + ${modelBlockHtml} + ${requestOptionsHtml} + ${quotasHtml} + `; + + elements.modalFooter.innerHTML = ` + + + + + `; + + if (!isRedraw) { + showModal(); + } + if (profile.provider === 'local') { + updateRequestOptionsPreview(profileId); + } +} + +function updateRequestOptionsPreview(profileId) { + const input = document.getElementById('modal-request-options-input'); + const statusEl = document.getElementById('modal-options-validation-status'); + const previewContent = document.getElementById('modal-payload-preview-content'); + if (!input) return; + + const raw = input.value.trim(); + let parsed = {}; + let isValid = true; + let errorMsg = ''; + + if (raw) { + try { + parsed = JSON.parse(raw); + if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) { + isValid = false; + errorMsg = 'JSON должен быть объектом {...}'; + } + } catch (e) { + isValid = false; + errorMsg = e.message; + } + } + + if (statusEl) { + if (isValid) { + statusEl.style.color = 'var(--status-healthy)'; + statusEl.textContent = '✓ JSON валиден'; + } else { + statusEl.style.color = 'var(--status-error)'; + statusEl.textContent = `⚠ Ошибка: ${errorMsg}`; + } + } + + if (previewContent) { + const profile = (currentSnapshot && currentSnapshot.all_profiles) ? currentSnapshot.all_profiles[profileId] : null; + const model = (profile && profile.preferred_models && profile.preferred_models[0]) || 'default'; + const samplePayload = { + model: model, + messages: [{ role: 'user', content: 'Тестовое сообщение' }], + temperature: 0.7, + max_tokens: 1500, + }; + if (isValid && typeof parsed === 'object' && parsed !== null) { + Object.assign(samplePayload, parsed); + } + previewContent.textContent = JSON.stringify(samplePayload, null, 2); + } +} + +function toggleRequestOptionsPreview() { + const box = document.getElementById('modal-payload-preview-box'); + if (box) { + box.style.display = box.style.display === 'none' ? 'block' : 'none'; + } +} + +async function handleSaveRequestOptions(profileId) { + const input = document.getElementById('modal-request-options-input'); + const feedbackArea = document.getElementById('modal-feedback-area'); + if (!input) return; + + const raw = input.value.trim(); + let parsed = {}; + if (raw) { + try { + parsed = JSON.parse(raw); + if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) { + throw new Error('Параметры должны быть JSON-объектом {...}'); + } + } catch (e) { + if (feedbackArea) { + feedbackArea.innerHTML = ``; + } + return; + } + } + + if (feedbackArea) { + feedbackArea.innerHTML = ''; + } + + const res = await executeAction('save_request_options', { + profile_id: profileId, + request_options: parsed, + }); + + if (feedbackArea) { + if (res && res.ok) { + feedbackArea.innerHTML = ``; + showToast('Параметры запроса сохранены', 'success'); + if (currentSnapshot && currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) { + currentSnapshot.all_profiles[profileId].request_options = parsed; + } + } else { + feedbackArea.innerHTML = ``; + } + } +} + +async function handleTestProfile(profileId) { + const feedbackArea = document.getElementById('modal-feedback-area'); + const btn = document.getElementById('btn-modal-test-profile'); + if (btn) btn.disabled = true; + if (feedbackArea) { + feedbackArea.innerHTML = ''; + } + + const profile = (currentSnapshot && currentSnapshot.all_profiles) ? currentSnapshot.all_profiles[profileId] : null; + const prov = profile ? profile.provider : ''; + + const res = await executeAction('test', { + profile_id: profileId, + provider: prov, + }); + + if (btn) btn.disabled = false; + if (feedbackArea) { + const data = (res && res.data) || {}; + const dur = data.duration_sec ? ` (${data.duration_sec}с)` : ''; + if (res && res.ok) { + feedbackArea.innerHTML = ``; + showToast('Проверка подключения успешна', 'success'); + } else { + const errMsg = (res && (res.message || (res.data && res.data.error))) || 'Ошибка подключения'; + feedbackArea.innerHTML = ``; + showToast(`Сбой проверки: ${errMsg}`, 'error'); + } + } +} + +async function handleSaveProfileModel(profileId) { + const sel = document.getElementById('modal-model-select'); + if (!sel) return; + const model = sel.value; + const feedbackArea = document.getElementById('modal-feedback-area'); + if (feedbackArea) { + feedbackArea.innerHTML = ''; + } + const res = await executeAction('set_model', { profile_id: profileId, model: model }); + if (feedbackArea) { + if (res && res.ok) { + feedbackArea.innerHTML = ``; + if (currentSnapshot && currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) { + currentSnapshot.all_profiles[profileId].preferred_models = [model]; + } + } else { + feedbackArea.innerHTML = ``; + } + } +} + +async function handleRefreshProviderModels(provider, profileId) { + const feedbackArea = document.getElementById('modal-feedback-area'); + if (feedbackArea) { + feedbackArea.innerHTML = ''; + } + const res = await executeAction('refresh_models', { provider: provider }); + if (res && res.ok) { + showToast('Список моделей обновлен', 'success'); + await fetchSnapshot(); + if (_openAccountModalProfile) { + openAccountDetailsModal(_openAccountModalProfile, true); + } + } else { + if (feedbackArea) { + feedbackArea.innerHTML = ``; + } + } +} + // ── MODAL HELPERS ── function showModal() { if (elements.modalBackdrop) elements.modalBackdrop.classList.remove('hidden'); diff --git a/tests/test_a39_local_request_options.py b/tests/test_a39_local_request_options.py new file mode 100644 index 0000000..a0a81ac --- /dev/null +++ b/tests/test_a39_local_request_options.py @@ -0,0 +1,360 @@ +"""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}"