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 чисто.
This commit is contained in:
ochenstarik-ui 2026-08-30 23:49:57 +07:00
parent 88d579af08
commit 5ac01e0a15
8 changed files with 775 additions and 10 deletions

View file

@ -277,6 +277,42 @@ def do_set_model(profile_id: str, model: str, role_id: Optional[str] = None) ->
return False, "Не удалось сохранить файл конфигурации" 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': elif action == 'save_settings':
ok, msg = do_save_settings(data) ok, msg = do_save_settings(data)
return {'ok': ok, 'message': msg} 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': elif action == 'discover_local_models':
# Поиск уже запущенных локальных серверов на машине. # Поиск уже запущенных локальных серверов на машине.

View file

@ -164,8 +164,14 @@ class LocalLLMAdapter(BaseProviderAdapter):
payload: Dict[str, Any] = { payload: Dict[str, Any] = {
"model": model, "model": model,
"messages": messages, "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"]: if "tools" in request and request["tools"]:
payload["tools"] = request["tools"] payload["tools"] = request["tools"]
if "tool_choice" in request: if "tool_choice" in request:
@ -179,6 +185,42 @@ class LocalLLMAdapter(BaseProviderAdapter):
if "stop" in request: if "stop" in request:
payload["stop"] = request["stop"] 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] = { headers: Dict[str, str] = {
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": "hermes-router/1.0", "User-Agent": "hermes-router/1.0",
@ -328,6 +370,14 @@ class LocalLLMAdapter(BaseProviderAdapter):
message=err_msg, 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 # Quota exhausted
if any(k in err_lower for k in ("quota", "insufficient balance", "insufficient_quota")): if any(k in err_lower for k in ("quota", "insufficient balance", "insufficient_quota")):
return ErrorClassification( return ErrorClassification(

View file

@ -21,6 +21,7 @@ class RouterProfileConfig:
enabled: bool = True enabled: bool = True
max_concurrency: int = 1 # 1 for stateful process, >1 for stateless REST max_concurrency: int = 1 # 1 for stateful process, >1 for stateless REST
custom_base_url: Optional[str] = None custom_base_url: Optional[str] = None
request_options: dict[str, Any] = field(default_factory=dict)
@dataclass @dataclass
@ -307,6 +308,9 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig:
max_concurrency = int(pdata.get("max_concurrency", 1)) max_concurrency = int(pdata.get("max_concurrency", 1))
if provider == "local": if provider == "local":
max_concurrency = 1 max_concurrency = 1
req_opts = pdata.get("request_options")
if not isinstance(req_opts, dict):
req_opts = {}
profiles[pid] = RouterProfileConfig( profiles[pid] = RouterProfileConfig(
profile_id=pid, profile_id=pid,
provider=provider, provider=provider,
@ -318,6 +322,7 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig:
enabled=bool(pdata.get("enabled", True)), enabled=bool(pdata.get("enabled", True)),
max_concurrency=max_concurrency, max_concurrency=max_concurrency,
custom_base_url=pdata.get("custom_base_url"), custom_base_url=pdata.get("custom_base_url"),
request_options=dict(req_opts),
) )
roles_raw = data.get("roles", {}) 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: if pcfg.custom_base_url:
profiles_data[pid]["custom_base_url"] = 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 = {} roles_data = {}
for rname, rpol in config.roles.items(): for rname, rpol in config.roles.items():

View file

@ -79,39 +79,43 @@ class AddAccountWizard(HubModal):
w.destroy() w.destroy()
def _cancel_active_sessions(self): def _cancel_active_sessions(self):
if self.oauth_session_id: oauth_id = getattr(self, "oauth_session_id", None)
if oauth_id:
try: try:
from antigravity_provider.router.profile_oauth import cancel_oauth_session from antigravity_provider.router.profile_oauth import cancel_oauth_session
cancel_oauth_session(self.oauth_session_id) cancel_oauth_session(oauth_id)
except Exception: except Exception:
pass pass
self.oauth_session_id = None self.oauth_session_id = None
self.oauth_port = None self.oauth_port = None
if self.codex_session_id: codex_id = getattr(self, "codex_session_id", None)
if codex_id:
try: try:
from antigravity_provider.router.codex_oauth import cancel_codex_oauth_session 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: except Exception:
pass pass
self.codex_session_id = None self.codex_session_id = None
if self.claude_session_id: claude_id = getattr(self, "claude_session_id", None)
if claude_id:
try: try:
from antigravity_provider.router.claude_oauth import cancel_claude_oauth_session 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: except Exception:
pass pass
self.claude_session_id = None self.claude_session_id = None
if self.grok_session_id: grok_id = getattr(self, "grok_session_id", None)
if grok_id:
try: try:
from antigravity_provider.router.grok_oauth import cancel_grok_oauth_session 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: except Exception:
pass pass
self.grok_session_id = None self.grok_session_id = None

View file

@ -470,7 +470,10 @@ class HubModal(ctk.CTkToplevel):
self.configure(fg_color=Theme.BG_WINDOW) self.configure(fg_color=Theme.BG_WINDOW)
self.resizable(False, False) self.resizable(False, False)
self.transient(parent) self.transient(parent)
self.grab_set() try:
self.grab_set()
except Exception:
pass
self.update_idletasks() self.update_idletasks()
px = parent.winfo_x() + (parent.winfo_width() - width) // 2 px = parent.winfo_x() + (parent.winfo_width() - width) // 2

View file

@ -98,6 +98,7 @@ class ProfileViewModel:
quota_snapshot: Optional[Any] = None quota_snapshot: Optional[Any] = None
preferred_models: List[str] = field(default_factory=list) preferred_models: List[str] = field(default_factory=list)
active_leases: int = 0 active_leases: int = 0
request_options: dict[str, Any] = field(default_factory=dict)
@property @property
def auth_label_ru(self) -> str: def auth_label_ru(self) -> str:
@ -529,6 +530,7 @@ class UnifiedHealthService:
quota_snapshot=snap, quota_snapshot=snap,
preferred_models=pcfg.preferred_models, preferred_models=pcfg.preferred_models,
active_leases=precord.active_leases, active_leases=precord.active_leases,
request_options=dict(pcfg.request_options or {}),
) )
result.setdefault(prov, []).append(vm) result.setdefault(prov, []).append(vm)

View file

@ -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 = `
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:14px;">
<label style="display:block; font-weight:600; font-size:12px; margin-bottom:6px;">Предпочитаемая модель профиля:</label>
<div style="display:flex; gap:8px;">
<select id="modal-model-select" class="select-filter" style="flex:1;">
${discoveredModels.map((m) => `<option value="${escapeHtml(m)}" ${m === currentModel ? 'selected' : ''}>${escapeHtml(m)}</option>`).join('')}
</select>
<button class="btn btn-secondary btn-sm" onclick="handleSaveProfileModel('${escapeHtml(profileId)}')">Сохранить</button>
</div>
</div>
`;
} else {
modelBlockHtml = `
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:14px;">
<div style="font-size:12px; color:var(--status-warning); margin-bottom:6px;">
Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
</div>
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}', '${escapeHtml(profileId)}')"> Запросить список моделей</button>
</div>
`;
}
// Local request options section
let requestOptionsHtml = '';
if (profile.provider === 'local') {
const rawOptions = profile.request_options || {};
const formattedJson = JSON.stringify(rawOptions, null, 2);
requestOptionsHtml = `
<div style="background:var(--surface-muted); padding:12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:14px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
<label style="font-weight:700; font-size:12px;">Параметры запроса (JSON request_options):</label>
<span id="modal-options-validation-status" style="font-size:11px; color:var(--status-healthy);"> JSON валиден</span>
</div>
<div style="font-size:11px; color:var(--text-muted); margin-bottom:8px;">
Произвольные параметры, подмешиваемые в тело запроса (например, <code>{"chat_template_kwargs": {"enable_thinking": false}}</code>).
</div>
<textarea id="modal-request-options-input" class="input-text" style="width:100%; height:90px; font-family:var(--font-mono); font-size:11px; resize:vertical;" placeholder="{}" oninput="updateRequestOptionsPreview('${escapeHtml(profileId)}')">${escapeHtml(formattedJson)}</textarea>
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:8px;">
<button class="btn btn-secondary btn-sm" onclick="handleSaveRequestOptions('${escapeHtml(profileId)}')">💾 Сохранить параметры</button>
<button class="btn btn-ghost btn-sm" onclick="toggleRequestOptionsPreview()">👁 Предпросмотр тела запроса</button>
</div>
<div id="modal-payload-preview-box" style="display:none; margin-top:10px;">
<div style="font-size:11px; font-weight:600; color:var(--text-secondary); margin-bottom:4px;">Что отправится на сервер (/chat/completions):</div>
<pre id="modal-payload-preview-content" style="background:var(--surface-card); padding:8px 10px; border-radius:4px; font-size:11px; font-family:var(--font-mono); color:var(--text-accent); max-height:140px; overflow-y:auto; margin:0; border:1px solid var(--border-subtle);"></pre>
</div>
</div>
`;
}
let quotasHtml = '';
if (profile.provider !== 'local') {
quotasHtml = `
<h3 style="font-size:13px; font-weight:700; margin-bottom:8px; border-bottom:1px solid var(--border-subtle); padding-bottom:4px;">
Квоты и корзины провайдера
</h3>
<div style="display:flex; flex-direction:column; gap:8px; margin-bottom:16px;">
${buckets.map((b) => `
<div style="background:var(--surface-muted); padding:8px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle);">
<div style="display:flex; justify-content:space-between; font-weight:600;">
<span>${escapeHtml(b.display_name)}</span>
<span>${b.remaining_percent !== null && b.remaining_percent !== undefined ? `${b.remaining_percent.toFixed(1)}%` : 'Н/Д'}</span>
</div>
<div class="quota-bar-track" style="margin:4px 0;">
<div class="quota-bar-fill" style="width:${b.remaining_percent || 0}%; background-color:var(--status-healthy);"></div>
</div>
<div style="font-size:10px; color:var(--text-muted);">
${b.reset_at ? `Сброс: ${formatIsoDate(b.reset_at)}` : (b.period ? `Период: ${b.period}` : 'Без отметки сброса')}
</div>
</div>
`).join('') || '<div class="empty-text">Данные о квотах отсутствуют.</div>'}
</div>
`;
}
elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`;
elements.modalBody.innerHTML = `
<div id="modal-feedback-area"></div>
<div style="margin-bottom:14px;">
<div style="font-size:14px; font-weight:700;">${escapeHtml(profile.account_identity || profile.email || profileId)}</div>
<div style="font-size:12px; color:var(--text-muted);">
Провайдер: <strong>${escapeHtml(profile.provider_display_name || profile.provider)}</strong>
Тариф: <strong>${escapeHtml(profile.plan || 'Неизвестen')}</strong>
Статус: <strong class="text-healthy">${escapeHtml(profile.health_label_ru || 'Работает')}</strong>
</div>
<div style="font-size:12px; color:var(--text-secondary); margin-top:4px;">
Назначенные роли: <strong>${escapeHtml((profile.assigned_roles || []).join(', ') || 'Нет')}</strong>
</div>
</div>
${modelBlockHtml}
${requestOptionsHtml}
${quotasHtml}
`;
elements.modalFooter.innerHTML = `
<button class="btn btn-secondary" id="btn-modal-test-profile" onclick="handleTestProfile('${escapeHtml(profileId)}')"> Проверить подключение</button>
<button class="btn btn-secondary" onclick="executeAction('set_main', { profile_id: '${escapeHtml(profileId)}' })"> Сделать основным</button>
<button class="btn btn-secondary" onclick="handleDeleteCredentials('${escapeHtml(profileId)}')">Удалить ключ</button>
<button class="btn btn-primary" onclick="closeModal()">Закрыть</button>
`;
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 = `<div class="modal-feedback error">❌ Некорректный JSON: ${escapeHtml(e.message)}</div>`;
}
return;
}
}
if (feedbackArea) {
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Сохранение параметров запроса...</div>';
}
const res = await executeAction('save_request_options', {
profile_id: profileId,
request_options: parsed,
});
if (feedbackArea) {
if (res && res.ok) {
feedbackArea.innerHTML = `<div class="modal-feedback success">✓ ${escapeHtml(res.message || 'Параметры сохранены')}</div>`;
showToast('Параметры запроса сохранены', 'success');
if (currentSnapshot && currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) {
currentSnapshot.all_profiles[profileId].request_options = parsed;
}
} else {
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml((res && res.message) || 'Ошибка сохранения')}</div>`;
}
}
}
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 = '<div class="modal-feedback info">⏳ Выполняется проверка подключения и тестовый запрос...</div>';
}
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 = `<div class="modal-feedback success">✓ ${escapeHtml(res.message || 'Подключение успешно')}${dur}</div>`;
showToast('Проверка подключения успешна', 'success');
} else {
const errMsg = (res && (res.message || (res.data && res.data.error))) || 'Ошибка подключения';
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml(errMsg)}${dur}</div>`;
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 = '<div class="modal-feedback info">⏳ Сохранение модели...</div>';
}
const res = await executeAction('set_model', { profile_id: profileId, model: model });
if (feedbackArea) {
if (res && res.ok) {
feedbackArea.innerHTML = `<div class="modal-feedback success">✓ ${escapeHtml(res.message || 'Модель сохранена')}</div>`;
if (currentSnapshot && currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) {
currentSnapshot.all_profiles[profileId].preferred_models = [model];
}
} else {
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml((res && res.message) || 'Ошибка сохранения модели')}</div>`;
}
}
}
async function handleRefreshProviderModels(provider, profileId) {
const feedbackArea = document.getElementById('modal-feedback-area');
if (feedbackArea) {
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Запрос списка моделей от сервера...</div>';
}
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 = `<div class="modal-feedback error">❌ ${escapeHtml((res && res.message) || 'Не удалось получить модели')}</div>`;
}
}
}
// ── MODAL HELPERS ── // ── MODAL HELPERS ──
function showModal() { function showModal() {
if (elements.modalBackdrop) elements.modalBackdrop.classList.remove('hidden'); if (elements.modalBackdrop) elements.modalBackdrop.classList.remove('hidden');

View file

@ -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}"