merge: A39 поверх A34 — файлы десктопа остаются удалёнными
This commit is contained in:
commit
a677449b9b
6 changed files with 759 additions and 1 deletions
|
|
@ -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':
|
||||
# Поиск уже запущенных локальных серверов на машине.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<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 ──
|
||||
function showModal() {
|
||||
if (elements.modalBackdrop) elements.modalBackdrop.classList.remove('hidden');
|
||||
|
|
|
|||
360
tests/test_a39_local_request_options.py
Normal file
360
tests/test_a39_local_request_options.py
Normal 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}"
|
||||
Loading…
Reference in a new issue