diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py
index 7dbde56..1212f5a 100644
--- a/src/antigravity_provider/router/action_handler.py
+++ b/src/antigravity_provider/router/action_handler.py
@@ -277,6 +277,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, "Не удалось сохранить файл конфигурации"
+
+
# Подключённый аккаунт обязан появиться в списке сразу.
#
# Учётные данные сохраняются на диск, но состояние профилей берётся из кэша
@@ -665,6 +701,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/ui/add_account_wizard.py b/src/antigravity_provider/router/ui/add_account_wizard.py
index e1e5bdf..ea370f7 100644
--- a/src/antigravity_provider/router/ui/add_account_wizard.py
+++ b/src/antigravity_provider/router/ui/add_account_wizard.py
@@ -79,39 +79,43 @@ class AddAccountWizard(HubModal):
w.destroy()
def _cancel_active_sessions(self):
- if self.oauth_session_id:
+ oauth_id = getattr(self, "oauth_session_id", None)
+ if oauth_id:
try:
from antigravity_provider.router.profile_oauth import cancel_oauth_session
- cancel_oauth_session(self.oauth_session_id)
+ cancel_oauth_session(oauth_id)
except Exception:
pass
self.oauth_session_id = None
self.oauth_port = None
- if self.codex_session_id:
+ codex_id = getattr(self, "codex_session_id", None)
+ if codex_id:
try:
from antigravity_provider.router.codex_oauth import cancel_codex_oauth_session
- cancel_codex_oauth_session(self.codex_session_id)
+ cancel_codex_oauth_session(codex_id)
except Exception:
pass
self.codex_session_id = None
- if self.claude_session_id:
+ claude_id = getattr(self, "claude_session_id", None)
+ if claude_id:
try:
from antigravity_provider.router.claude_oauth import cancel_claude_oauth_session
- cancel_claude_oauth_session(self.claude_session_id)
+ cancel_claude_oauth_session(claude_id)
except Exception:
pass
self.claude_session_id = None
- if self.grok_session_id:
+ grok_id = getattr(self, "grok_session_id", None)
+ if grok_id:
try:
from antigravity_provider.router.grok_oauth import cancel_grok_oauth_session
- cancel_grok_oauth_session(self.grok_session_id)
+ cancel_grok_oauth_session(grok_id)
except Exception:
pass
self.grok_session_id = None
diff --git a/src/antigravity_provider/router/ui/components.py b/src/antigravity_provider/router/ui/components.py
index fe3e217..30f0dc4 100644
--- a/src/antigravity_provider/router/ui/components.py
+++ b/src/antigravity_provider/router/ui/components.py
@@ -470,7 +470,10 @@ class HubModal(ctk.CTkToplevel):
self.configure(fg_color=Theme.BG_WINDOW)
self.resizable(False, False)
self.transient(parent)
- self.grab_set()
+ try:
+ self.grab_set()
+ except Exception:
+ pass
self.update_idletasks()
px = parent.winfo_x() + (parent.winfo_width() - width) // 2
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 1c4958b..fca6911 100644
--- a/src/antigravity_provider/router/web/static/app.js
+++ b/src/antigravity_provider/router/web/static/app.js
@@ -1452,6 +1452,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}}).
+
+
+
+
+
+
+
+
+
+
Что отправится на сервер (/chat/completions):
+
+
+
+ `;
+ }
+
+ 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 = `❌ Некорректный JSON: ${escapeHtml(e.message)}
`;
+ }
+ 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 = `✓ ${escapeHtml(res.message || 'Параметры сохранены')}
`;
+ showToast('Параметры запроса сохранены', 'success');
+ if (currentSnapshot && currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) {
+ currentSnapshot.all_profiles[profileId].request_options = parsed;
+ }
+ } else {
+ feedbackArea.innerHTML = `❌ ${escapeHtml((res && res.message) || 'Ошибка сохранения')}
`;
+ }
+ }
+}
+
+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 = `✓ ${escapeHtml(res.message || 'Подключение успешно')}${dur}
`;
+ showToast('Проверка подключения успешна', 'success');
+ } else {
+ const errMsg = (res && (res.message || (res.data && res.data.error))) || 'Ошибка подключения';
+ feedbackArea.innerHTML = `❌ ${escapeHtml(errMsg)}${dur}
`;
+ 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 = `✓ ${escapeHtml(res.message || 'Модель сохранена')}
`;
+ if (currentSnapshot && currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) {
+ currentSnapshot.all_profiles[profileId].preferred_models = [model];
+ }
+ } else {
+ feedbackArea.innerHTML = `❌ ${escapeHtml((res && res.message) || 'Ошибка сохранения модели')}
`;
+ }
+ }
+}
+
+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 = `❌ ${escapeHtml((res && res.message) || 'Не удалось получить модели')}
`;
+ }
+ }
+}
+
// ── 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}"