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, "Не удалось сохранить файл конфигурации"
|
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':
|
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':
|
||||||
# Поиск уже запущенных локальных серверов на машине.
|
# Поиск уже запущенных локальных серверов на машине.
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 ──
|
// ── MODAL HELPERS ──
|
||||||
function showModal() {
|
function showModal() {
|
||||||
if (elements.modalBackdrop) elements.modalBackdrop.classList.remove('hidden');
|
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