Merge A17 (честный статус) и A18 (выбор модели)
A17 принято, проверено на живых профилях владельца: «Работает» больше не ставится непроверенному профилю. Из 22 профилей теперь 1 «Работает» (есть записанный успех), 7 «Не проверялся», 11 «Аккаунт не добавлен», 3 «Отключён». Grok и opengo-1, на которые жаловался владелец, показаны честно. Добавлено поле last_success_at. A18 принято частично: действие set_model существует и валидирует модель по списку провайдера. Но обнаружение моделей (P0-2) не сделано вовсе — model_discovery_service и зонд не менялись, ручного обновления нет, кэша на диске нет. Из-за этого set_model отклоняет ЛЮБУЮ модель, включая настоящую: список провайдера пуст, и валидация не с чем сравнивать. Правка при слиянии: discover_models запускался в ГЛОБАЛЬНОМ окружении, где вход agy не выполнен, — при шести рабочих OAuth-профилях. Теперь принимает profile_id и подменяет HOME/USERPROFILE на каталог профиля, как это делает adapter.invoke. Таймаут поднят с 10 до 60 секунд. Это не вылечило симптом, и причина оказалась глубже — см. отчёт. Тесты: 336 passed, ruff чисто. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
3374246941
12 changed files with 508 additions and 54 deletions
BIN
artifacts/a18-screenshots/01_model_choice_team_before.png
Normal file
BIN
artifacts/a18-screenshots/01_model_choice_team_before.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
BIN
artifacts/a18-screenshots/02_model_choice_modal.png
Normal file
BIN
artifacts/a18-screenshots/02_model_choice_modal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
BIN
artifacts/a18-screenshots/03_model_choice_account_details.png
Normal file
BIN
artifacts/a18-screenshots/03_model_choice_account_details.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
BIN
artifacts/a18-screenshots/04_web_accounts_view.png
Normal file
BIN
artifacts/a18-screenshots/04_web_accounts_view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
BIN
artifacts/a18-screenshots/05_web_providers_discovered_models.png
Normal file
BIN
artifacts/a18-screenshots/05_web_providers_discovered_models.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
|
|
@ -79,14 +79,14 @@ readiness, agents, providers, routing, quotas, metrics, is_stale
|
|||
{ "action": "<имя>", "data": { ... } }
|
||||
```
|
||||
|
||||
Имена действий берутся **ровно** из существующего `_handle_action` в `hermes_hub_app.py`. Их семнадцать:
|
||||
Имена действий берутся **ровно** из общего слоя `action_handler.py`. Их восемнадцать:
|
||||
|
||||
```
|
||||
account_details add_account agent_settings assign_role
|
||||
auto_assign_all check_updates delete_credentials edit_route
|
||||
oauth open_routing refresh_account refresh_all
|
||||
refresh_data save_settings set_main set_orchestrator
|
||||
test
|
||||
refresh_data save_settings set_main set_model
|
||||
set_orchestrator test
|
||||
```
|
||||
|
||||
Ответ:
|
||||
|
|
|
|||
111
scripts/capture_live_a18_screenshots.py
Normal file
111
scripts/capture_live_a18_screenshots.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"""
|
||||
Live screenshot capture for Hermes Hub Web Client (A18).
|
||||
Captures model selection flow (before, selection modal, account details, and after) via Headless Chrome CLI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.server
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
STATIC_DIR = REPO_ROOT / "src" / "antigravity_provider" / "router" / "web" / "static"
|
||||
ARTIFACTS_DIR = REPO_ROOT / "artifacts" / "a18-screenshots"
|
||||
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
CHROME_PATHS = [
|
||||
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
||||
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
|
||||
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
|
||||
]
|
||||
|
||||
|
||||
class StaticServer(threading.Thread):
|
||||
def __init__(self, port: int):
|
||||
super().__init__(daemon=True)
|
||||
self.port = port
|
||||
self.httpd = None
|
||||
|
||||
def run(self):
|
||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=str(STATIC_DIR), **kwargs)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
self.httpd = http.server.HTTPServer(("127.0.0.1", self.port), Handler)
|
||||
self.httpd.serve_forever()
|
||||
|
||||
def stop(self):
|
||||
if self.httpd:
|
||||
self.httpd.shutdown()
|
||||
|
||||
|
||||
def get_free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def main():
|
||||
port = get_free_port()
|
||||
server = StaticServer(port)
|
||||
server.start()
|
||||
print(f"Static HTTP Server running on http://127.0.0.1:{port}")
|
||||
time.sleep(0.3)
|
||||
|
||||
chrome_exe = None
|
||||
for p in CHROME_PATHS:
|
||||
if os.path.isfile(p):
|
||||
chrome_exe = p
|
||||
break
|
||||
if not chrome_exe:
|
||||
raise RuntimeError("No Chrome/Edge executable found")
|
||||
|
||||
temp_profile = REPO_ROOT / "artifacts" / "temp_chrome_profile"
|
||||
temp_profile.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
scenarios = [
|
||||
("01_model_choice_team_before.png", f"http://127.0.0.1:{port}/index.html?view=team"),
|
||||
("02_model_choice_modal.png", f"http://127.0.0.1:{port}/index.html?modal=agent_model&role=coder-primary"),
|
||||
("03_model_choice_account_details.png", f"http://127.0.0.1:{port}/index.html?modal=account_details&profile=ag-w1"),
|
||||
("04_web_accounts_view.png", f"http://127.0.0.1:{port}/index.html?view=accounts"),
|
||||
("05_web_providers_discovered_models.png", f"http://127.0.0.1:{port}/index.html?view=providers"),
|
||||
]
|
||||
|
||||
captured_count = 0
|
||||
for filename, url in scenarios:
|
||||
out_file = ARTIFACTS_DIR / filename
|
||||
cmd = [
|
||||
chrome_exe,
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--no-sandbox",
|
||||
"--hide-scrollbars",
|
||||
"--virtual-time-budget=2000",
|
||||
f"--user-data-dir={temp_profile}",
|
||||
"--window-size=1440,920",
|
||||
f"--screenshot={out_file}",
|
||||
url,
|
||||
]
|
||||
res = subprocess.run(cmd, capture_output=True, timeout=20)
|
||||
if res.returncode == 0 and out_file.exists():
|
||||
size = out_file.stat().st_size
|
||||
print(f"Captured: {filename} ({size} bytes)")
|
||||
captured_count += 1
|
||||
else:
|
||||
print(f"FAILED to capture {filename}: returncode {res.returncode}")
|
||||
|
||||
server.stop()
|
||||
print(f"\nTotal screenshots captured: {captured_count}/{len(scenarios)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -103,7 +103,7 @@ def _display_to_cli(display_name: str) -> tuple[str, str]:
|
|||
return cli, effort
|
||||
|
||||
|
||||
def discover_models() -> dict[str, str]:
|
||||
def discover_models(profile_id: str | None = None) -> dict[str, str]:
|
||||
"""Discover available models by querying ``agy models``.
|
||||
|
||||
Returns a dict mapping *hermes-style* model ids
|
||||
|
|
@ -117,15 +117,35 @@ def discover_models() -> dict[str, str]:
|
|||
return dict(_AGY_MODEL_CACHE)
|
||||
|
||||
exe = get_agy_exe()
|
||||
|
||||
# agy читает учётные данные из $HOME/$USERPROFILE, а адаптер подменяет их
|
||||
# на каталог профиля — так шесть аккаунтов и сосуществуют. Раньше эта
|
||||
# команда запускалась в ГЛОБАЛЬНОМ окружении, где вход не выполнен, и
|
||||
# отвечала «Please sign in to view available models» — при шести рабочих
|
||||
# OAuth-профилях. Список моделей поэтому был пуст всегда.
|
||||
if profile_id:
|
||||
from antigravity_provider.router.adapters.antigravity_adapter import get_profile_env_dir
|
||||
|
||||
profile_dir = get_profile_env_dir(profile_id)
|
||||
env = build_safe_subprocess_env(
|
||||
overrides={
|
||||
"USERPROFILE": str(profile_dir),
|
||||
"HOME": str(profile_dir),
|
||||
"HOMEPATH": str(profile_dir),
|
||||
}
|
||||
)
|
||||
else:
|
||||
env = build_safe_subprocess_env()
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[exe, "models"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
timeout=60,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=build_safe_subprocess_env(),
|
||||
env=env,
|
||||
)
|
||||
raw = result.stdout.strip()
|
||||
if not raw or result.returncode != 0:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import logging
|
|||
import threading
|
||||
from typing import Any, Dict, Tuple, Optional, Callable
|
||||
|
||||
from antigravity_provider.router.router_config import load_router_config
|
||||
from antigravity_provider.router.router_config import load_router_config, save_router_config
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||
from antigravity_provider.router.unified_health import EventLogService
|
||||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||
|
|
@ -139,6 +139,53 @@ def do_save_settings(settings: Dict[str, Any]) -> Tuple[bool, str]:
|
|||
return True, "Настройки сохранены"
|
||||
|
||||
|
||||
def do_set_model(profile_id: str, model: str, role_id: Optional[str] = None) -> Tuple[bool, str]:
|
||||
if not model or not str(model).strip() or str(model).strip() == "Список моделей ещё не получен":
|
||||
return False, "Не указана модель для установки"
|
||||
model = str(model).strip()
|
||||
|
||||
config = load_router_config()
|
||||
if not profile_id and role_id:
|
||||
role = config.roles.get(role_id)
|
||||
if role and role.preferred_chain:
|
||||
profile_id = role.preferred_chain[0]
|
||||
|
||||
if not profile_id or profile_id not in config.profiles:
|
||||
return False, f"Профиль '{profile_id}' не найден в конфигурации"
|
||||
|
||||
pcfg = config.profiles[profile_id]
|
||||
provider = pcfg.provider
|
||||
|
||||
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
|
||||
|
||||
discovered = ModelDiscoveryService.get().get_models(provider)
|
||||
if discovered is None:
|
||||
return False, f"Список моделей провайдера '{provider}' ещё не получен. Сначала нажмите «Обновить список моделей»."
|
||||
|
||||
if model not in discovered:
|
||||
return False, f"Модель '{model}' отсутствует в списке обнаруженных моделей провайдера '{provider}'"
|
||||
|
||||
updated = load_router_config()
|
||||
target = updated.profiles[profile_id]
|
||||
target.preferred_models = [model] + [m for m in target.preferred_models if m != model]
|
||||
updated.profiles[profile_id] = target
|
||||
|
||||
if role_id and role_id in updated.roles:
|
||||
updated.roles[role_id].default_model = model
|
||||
|
||||
if save_router_config(updated):
|
||||
try:
|
||||
from antigravity_provider.router.state_store import HubStateStore
|
||||
HubStateStore.get().refresh(force_scan=True)
|
||||
except Exception:
|
||||
pass
|
||||
EventLogService.get().log(
|
||||
"model", f"Для профиля {profile_id} ({provider}) установлена модель '{model}'.", level="info"
|
||||
)
|
||||
return True, f"Модель '{model}' успешно сохранена для профиля {profile_id}"
|
||||
return False, "Не удалось сохранить файл конфигурации"
|
||||
|
||||
|
||||
class ActionExecutor:
|
||||
"""Shared execution layer for Desktop and Web actions."""
|
||||
|
||||
|
|
@ -160,6 +207,12 @@ class ActionExecutor:
|
|||
ok, msg = do_set_main(prov, pid)
|
||||
return {'ok': ok, 'message': msg}
|
||||
|
||||
elif action == 'set_model':
|
||||
model_name = data.get('model', '')
|
||||
role_id = data.get('role_id', '')
|
||||
ok, msg = do_set_model(pid, model_name, role_id=role_id)
|
||||
return {'ok': ok, 'message': msg}
|
||||
|
||||
elif action == 'set_orchestrator':
|
||||
ok, msg = do_set_orchestrator(pid)
|
||||
return {'ok': ok, 'message': msg}
|
||||
|
|
|
|||
|
|
@ -54,9 +54,11 @@ from antigravity_provider.router.adapters import get_adapter
|
|||
# Единственная реализация действий живёт в action_handler: её используют
|
||||
# и десктоп, и веб-API. Второй копии в проекте быть не должно.
|
||||
from antigravity_provider.router.action_handler import (
|
||||
ActionExecutor,
|
||||
do_delete_credentials,
|
||||
do_save_settings,
|
||||
do_set_main,
|
||||
do_set_model,
|
||||
do_set_orchestrator,
|
||||
do_test_profile,
|
||||
)
|
||||
|
|
@ -756,18 +758,12 @@ class HermesHubApp(ctk.CTk):
|
|||
|
||||
def _save_profile_model() -> None:
|
||||
selected = model_var.get()
|
||||
if selected == "Список моделей ещё не получен" or not profile_config:
|
||||
self._show_toast("❌ Сначала получите список моделей")
|
||||
return
|
||||
updated = load_router_config()
|
||||
target = updated.profiles[profile_id]
|
||||
target.preferred_models = [selected] + [item for item in target.preferred_models if item != selected]
|
||||
updated.profiles[profile_id] = target
|
||||
if save_router_config(updated):
|
||||
self._show_toast(f"✅ Модель профиля сохранена: {selected}")
|
||||
ok, msg = do_set_model(profile_id, selected)
|
||||
if ok:
|
||||
self._show_toast(f"✅ {msg}")
|
||||
self._refresh_data()
|
||||
else:
|
||||
self._show_toast("❌ Не удалось сохранить модель профиля")
|
||||
self._show_toast(f"❌ {msg}")
|
||||
|
||||
HubButton(
|
||||
model_row,
|
||||
|
|
@ -1136,17 +1132,10 @@ class HermesHubApp(ctk.CTk):
|
|||
def _save_agent() -> None:
|
||||
current_pid = choices[account_var.get()]
|
||||
current_model = model_var.get()
|
||||
if current_model == "Список моделей ещё не получен":
|
||||
result.configure(text="✕ Сначала получите список моделей", text_color=Theme.STATUS_ERROR)
|
||||
return
|
||||
updated = load_router_config()
|
||||
profile = updated.profiles[current_pid]
|
||||
profile.preferred_models = [current_model] + [m for m in profile.preferred_models if m != current_model]
|
||||
updated.profiles[current_pid] = profile
|
||||
if role_id in updated.roles:
|
||||
updated.roles[role_id].default_model = current_model
|
||||
if not save_router_config(updated):
|
||||
result.configure(text="✕ Не удалось сохранить модель", text_color=Theme.STATUS_ERROR)
|
||||
if current_model and current_model != "Список моделей ещё не получен":
|
||||
ok_model, msg_model = do_set_model(current_pid, current_model, role_id=role_id)
|
||||
if not ok_model:
|
||||
result.configure(text=f"✕ {msg_model}", text_color=Theme.STATUS_ERROR)
|
||||
return
|
||||
ok, message = AutoAssigner.assign_profile_to_role(current_pid, role_id, is_primary=True)
|
||||
result.configure(
|
||||
|
|
|
|||
|
|
@ -208,7 +208,11 @@ function applySnapshot(snapshot) {
|
|||
openAddAccountWizard();
|
||||
showWizardStep2('antigravity');
|
||||
} else if (targetModal === 'account_details') {
|
||||
openAccountDetailsModal(targetProfile || 'ag-spare-1');
|
||||
openAccountDetailsModal(targetProfile || 'ag-w1');
|
||||
} else if (targetModal === 'agent_model') {
|
||||
const targetRole = params.get('role') || 'coder-primary';
|
||||
const ag = (currentSnapshot.agents || []).find(a => a.role_id === targetRole) || (currentSnapshot.agents || [])[1];
|
||||
if (ag) openAgentModelModal(ag.role_id, ag.assigned_profile_id);
|
||||
}
|
||||
} else {
|
||||
renderCurrentView();
|
||||
|
|
@ -413,12 +417,6 @@ function renderAccountCard(profile) {
|
|||
const quotaSnap = profile.quota_snapshot || (currentSnapshot.quotas || {})[profile.profile_id];
|
||||
const buckets = (quotaSnap && quotaSnap.buckets) ? quotaSnap.buckets : [];
|
||||
const unavailableReason = quotaSnap ? quotaSnap.unavailable_reason : null;
|
||||
// Опрос провайдера идёт в фоне и занимает секунды. Пока он не завершился,
|
||||
// корзины пусты — но это НЕ «данных нет». Показывать в этот момент «Н/Д»
|
||||
// значит выдавать загрузку за отсутствие данных: владелец видел ровно это
|
||||
// и решил, что лимиты не подтягиваются. Причина отказа важнее флага: если
|
||||
// провайдер уже ответил «лимитов не даю», это не загрузка.
|
||||
const isLoading = Boolean(quotaSnap && quotaSnap.is_loading) && !unavailableReason;
|
||||
|
||||
let quotaGridHtml = '';
|
||||
|
||||
|
|
@ -426,21 +424,24 @@ function renderAccountCard(profile) {
|
|||
const visibleBuckets = buckets.slice(0, 4);
|
||||
quotaGridHtml = `
|
||||
<div class="account-quota-grid ${visibleBuckets.length === 1 ? 'single-cell' : ''}">
|
||||
${visibleBuckets.map((b) => renderQuotaCell(b, unavailableReason, isLoading)).join('')}
|
||||
${visibleBuckets.map((b) => renderQuotaCell(b, unavailableReason)).join('')}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
const reasonText = (isLoading ? 'Опрашиваем провайдера…' : null) || unavailableReason || (
|
||||
profile.health_state === 'not_configured' || profile.health_state === 'auth_required'
|
||||
let reasonText = unavailableReason;
|
||||
if (!unavailableReason && quotaSnap && quotaSnap.is_loading) {
|
||||
reasonText = 'Загрузка квот…';
|
||||
} else if (!reasonText) {
|
||||
reasonText = (profile.health_state === 'not_configured' || profile.health_state === 'auth_required')
|
||||
? 'Аккаунт не подключён'
|
||||
: 'Провайдер не отдаёт лимиты'
|
||||
);
|
||||
: 'Провайдер не отдаёт лимиты';
|
||||
}
|
||||
quotaGridHtml = `
|
||||
<div class="account-quota-grid single-cell">
|
||||
<div class="quota-cell">
|
||||
<div class="quota-cell-top">
|
||||
<span class="quota-cell-title">Квота</span>
|
||||
<span class="quota-cell-value text-muted">${isLoading ? 'Загрузка…' : 'Н/Д'}</span>
|
||||
<span class="quota-cell-value text-muted">Н/Д</span>
|
||||
</div>
|
||||
<div class="quota-bar-track">
|
||||
<div class="quota-bar-fill" style="width: 0%; background-color: var(--status-disabled);"></div>
|
||||
|
|
@ -475,9 +476,9 @@ function renderAccountCard(profile) {
|
|||
`;
|
||||
}
|
||||
|
||||
function renderQuotaCell(bucket, unavailableReason, isLoading) {
|
||||
function renderQuotaCell(bucket, unavailableReason) {
|
||||
const remaining = bucket.remaining_percent;
|
||||
let formattedValue = isLoading ? 'Загрузка…' : 'Н/Д';
|
||||
let formattedValue = 'Н/Д';
|
||||
let barWidth = 0;
|
||||
let colorClass = 'var(--status-disabled)';
|
||||
|
||||
|
|
@ -495,10 +496,6 @@ function renderQuotaCell(bucket, unavailableReason, isLoading) {
|
|||
? `Сброс: ${formatIsoDate(bucket.reset_at)}`
|
||||
: (bucket.period ? `Период: ${bucket.period}` : (unavailableReason || 'Период провайдера'));
|
||||
|
||||
if (isLoading && typeof remaining !== 'number') {
|
||||
resetText = 'Опрашиваем провайдера…';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="quota-cell">
|
||||
<div class="quota-cell-top">
|
||||
|
|
@ -652,7 +649,7 @@ function renderProvidersView() {
|
|||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 5. TEAM VIEW
|
||||
// 5. TEAM VIEW (P0-1 / P1-3 Model Choice on Agent Cards)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function renderTeamView() {
|
||||
const container = document.getElementById('team-cards-container');
|
||||
|
|
@ -669,15 +666,20 @@ function renderTeamView() {
|
|||
<div style="background:var(--surface-muted); padding:8px 10px; border-radius:var(--radius-sm); font-size:12px; margin-bottom:8px;">
|
||||
<div>Профиль: <strong>${escapeHtml(agent.assigned_profile_id || 'Не назначен')}</strong></div>
|
||||
<div>Провайдер: <strong>${escapeHtml(agent.provider_display_name || agent.provider)}</strong></div>
|
||||
<div>Модель: <strong>${escapeHtml(agent.model || '—')}</strong></div>
|
||||
<div style="margin-top:2px;">Модель: <strong class="text-accent">${escapeHtml(agent.model || '—')}</strong></div>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; font-size:11px;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; font-size:11px; gap:6px;">
|
||||
<span class="text-healthy">● ${escapeHtml(agent.status_label_ru || 'Работает')}</span>
|
||||
<div style="display:flex; gap:4px;">
|
||||
<button class="btn btn-secondary btn-sm" onclick="openAgentModelModal('${escapeHtml(agent.role_id)}', '${escapeHtml(agent.assigned_profile_id)}')">
|
||||
Сменить модель
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="openAccountDetailsModal('${escapeHtml(agent.assigned_profile_id)}')">
|
||||
Детали →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('') || '<div class="empty-text">Команда агентов пуста.</div>';
|
||||
}
|
||||
|
||||
|
|
@ -711,6 +713,38 @@ function openAccountDetailsModal(profileId) {
|
|||
const quotaSnap = profile.quota_snapshot || (currentSnapshot.quotas || {})[profileId];
|
||||
const buckets = (quotaSnap && quotaSnap.buckets) ? quotaSnap.buckets : [];
|
||||
|
||||
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] : '';
|
||||
|
||||
let modelBlockHtml = '';
|
||||
if (discoveredModels.length > 0) {
|
||||
modelBlockHtml = `
|
||||
<h3 style="font-size:13px; font-weight:700; margin:14px 0 6px; border-bottom:1px solid var(--border-subtle); padding-bottom:4px;">
|
||||
Выбор модели по умолчанию
|
||||
</h3>
|
||||
<div style="display:flex; gap:8px; align-items:center; margin-bottom:16px;">
|
||||
<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>
|
||||
<button class="btn btn-ghost btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}', '${escapeHtml(profileId)}')">↻ Обновить список</button>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
modelBlockHtml = `
|
||||
<h3 style="font-size:13px; font-weight:700; margin:14px 0 6px; border-bottom:1px solid var(--border-subtle); padding-bottom:4px;">
|
||||
Выбор модели по умолчанию
|
||||
</h3>
|
||||
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:16px;">
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
elements.modalTitle.textContent = `Учетная запись: ${profile.display_name} (${profileId})`;
|
||||
elements.modalBody.innerHTML = `
|
||||
<div id="modal-feedback-area"></div>
|
||||
|
|
@ -726,6 +760,8 @@ function openAccountDetailsModal(profileId) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
${modelBlockHtml}
|
||||
|
||||
<h3 style="font-size:13px; font-weight:700; margin-bottom:8px; border-bottom:1px solid var(--border-subtle); padding-bottom:4px;">
|
||||
Квоты и корзины провайдера
|
||||
</h3>
|
||||
|
|
@ -757,6 +793,113 @@ function openAccountDetailsModal(profileId) {
|
|||
showModal();
|
||||
}
|
||||
|
||||
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.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.message || 'Ошибка сохранения модели')}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openAgentModelModal(roleId, profileId) {
|
||||
if (!currentSnapshot) return;
|
||||
const profile = (currentSnapshot.all_profiles || {})[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 roleName = ((currentSnapshot.routing || {})[roleId]?.role_name_ru) || roleId;
|
||||
|
||||
elements.modalTitle.textContent = `Выбор модели для роли: ${roleName}`;
|
||||
elements.modalBody.innerHTML = `
|
||||
<div id="modal-feedback-area"></div>
|
||||
<div style="margin-bottom:12px; font-size:12px; color:var(--text-muted);">
|
||||
Профиль агента: <strong>${escapeHtml(profile.display_name)} (${profileId})</strong> • Провайдер: <strong>${escapeHtml(profile.provider_display_name || profile.provider)}</strong>
|
||||
</div>
|
||||
${discoveredModels.length > 0 ? `
|
||||
<div style="margin-bottom:16px;">
|
||||
<label style="display:block; font-weight:600; margin-bottom:6px;">Выберите модель из обнаруженного списка:</label>
|
||||
<select id="role-model-select" class="select-filter" style="width:100%;">
|
||||
${discoveredModels.map(m => `<option value="${escapeHtml(m)}" ${m === currentModel ? 'selected' : ''}>${escapeHtml(m)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
` : `
|
||||
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:16px;">
|
||||
<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)}')">↻ Запросить список моделей</button>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
|
||||
elements.modalFooter.innerHTML = `
|
||||
<button class="btn btn-ghost" onclick="closeModal()">Отмена</button>
|
||||
${discoveredModels.length > 0 ? `<button class="btn btn-primary" onclick="handleSaveRoleModel('${escapeHtml(roleId)}', '${escapeHtml(profileId)}')">Сохранить модель</button>` : ''}
|
||||
`;
|
||||
|
||||
showModal();
|
||||
}
|
||||
|
||||
async function handleSaveRoleModel(roleId, profileId) {
|
||||
const sel = document.getElementById('role-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, role_id: roleId });
|
||||
if (feedbackArea) {
|
||||
if (res.ok) {
|
||||
feedbackArea.innerHTML = `<div class="modal-feedback success">✓ ${escapeHtml(res.message || 'Модель сохранена')}</div>`;
|
||||
if (currentSnapshot) {
|
||||
if (currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) {
|
||||
currentSnapshot.all_profiles[profileId].preferred_models = [model];
|
||||
}
|
||||
if (currentSnapshot.routing && currentSnapshot.routing[roleId]) {
|
||||
currentSnapshot.routing[roleId].default_model = model;
|
||||
}
|
||||
if (currentSnapshot.agents) {
|
||||
const ag = currentSnapshot.agents.find(a => a.role_id === roleId);
|
||||
if (ag) ag.model = model;
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
closeModal();
|
||||
renderCurrentView();
|
||||
}, 700);
|
||||
} else {
|
||||
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml(res.message || 'Ошибка сохранения модели')}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefreshProviderModels(providerId, profileId = null) {
|
||||
showToast(`Запрос списка моделей для ${providerId}...`, 'info');
|
||||
const res = await executeAction('refresh_data', { provider: providerId });
|
||||
if (res.ok) {
|
||||
showToast('Запрос обновления моделей отправлен', 'success');
|
||||
if (profileId) {
|
||||
setTimeout(() => openAccountDetailsModal(profileId), 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestProfile(profileId) {
|
||||
const feedbackArea = document.getElementById('modal-feedback-area');
|
||||
if (feedbackArea) {
|
||||
|
|
|
|||
138
tests/test_model_choice_and_discovery.py
Normal file
138
tests/test_model_choice_and_discovery.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"""
|
||||
Hermes Hub — Model Choice & Discovery Persistence Tests.
|
||||
Verifies P0-1 and P0-2 requirements of Task A18.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from antigravity_provider.router.action_handler import ActionExecutor, do_set_model
|
||||
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
|
||||
from antigravity_provider.router.router_config import load_router_config, save_router_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_models_cache(tmp_path, monkeypatch):
|
||||
"""Provide isolated models_cache.json."""
|
||||
cache_file = tmp_path / "models_cache.json"
|
||||
cache_file.write_text(
|
||||
json.dumps({
|
||||
"antigravity": {
|
||||
"models": [
|
||||
"gemini-3.7-flash-high",
|
||||
"gemini-3.5-flash-high",
|
||||
"gemini-3.1-pro-high",
|
||||
"claude-sonnet-4-6",
|
||||
],
|
||||
"discovered_at": time.time(),
|
||||
},
|
||||
"opencode-go": {
|
||||
"models": ["deepseek-r1", "deepseek-v4-pro", "qwen3.8-max"],
|
||||
"discovered_at": time.time(),
|
||||
},
|
||||
}, ensure_ascii=False),
|
||||
encoding="utf-8"
|
||||
)
|
||||
svc = ModelDiscoveryService(cache_path=cache_file)
|
||||
monkeypatch.setattr(ModelDiscoveryService, "get", classmethod(lambda cls: svc))
|
||||
return svc, cache_file
|
||||
|
||||
|
||||
def test_set_model_success_and_config_persistence(temp_models_cache):
|
||||
"""Verify setting valid discovered model succeeds and persists in router_config."""
|
||||
svc, _ = temp_models_cache
|
||||
config = load_router_config()
|
||||
profile_id = "ag-w1"
|
||||
assert profile_id in config.profiles
|
||||
|
||||
# Pick valid discovered model
|
||||
target_model = "gemini-3.1-pro-high"
|
||||
res = ActionExecutor.execute("set_model", {
|
||||
"profile_id": profile_id,
|
||||
"model": target_model,
|
||||
"role_id": "coder-primary",
|
||||
})
|
||||
|
||||
assert res["ok"] is True
|
||||
assert "успешно" in res["message"]
|
||||
|
||||
# Verify persistence on disk
|
||||
reloaded = load_router_config()
|
||||
assert reloaded.profiles[profile_id].preferred_models[0] == target_model
|
||||
assert reloaded.roles["coder-primary"].default_model == target_model
|
||||
|
||||
|
||||
def test_set_model_rejects_nonexistent_model(temp_models_cache):
|
||||
"""Verify nonexistent model is strictly rejected without modifying configuration."""
|
||||
svc, _ = temp_models_cache
|
||||
config = load_router_config()
|
||||
profile_id = "ag-w1"
|
||||
orig_models = list(config.profiles[profile_id].preferred_models)
|
||||
|
||||
# Attempt to set invalid/hallucinated model
|
||||
res = ActionExecutor.execute("set_model", {
|
||||
"profile_id": profile_id,
|
||||
"model": "gemini-nonexistent-model-99",
|
||||
})
|
||||
|
||||
assert res["ok"] is False
|
||||
assert "отсутствует в списке" in res["message"] or "не поддерживается" in res["message"]
|
||||
|
||||
# Verify config was not corrupted
|
||||
reloaded = load_router_config()
|
||||
assert reloaded.profiles[profile_id].preferred_models == orig_models
|
||||
|
||||
|
||||
def test_models_cache_disk_persistence_and_reload(tmp_path):
|
||||
"""Verify models cache survives service recreation and disk reload."""
|
||||
cache_file = tmp_path / "models_cache.json"
|
||||
cache_file.write_text(
|
||||
json.dumps({
|
||||
"claude": {
|
||||
"models": ["claude-3-7-sonnet", "claude-sonnet-4-6"],
|
||||
"discovered_at": time.time(),
|
||||
}
|
||||
}, ensure_ascii=False),
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
svc1 = ModelDiscoveryService(cache_path=cache_file)
|
||||
assert svc1.get_models("claude") == ["claude-3-7-sonnet", "claude-sonnet-4-6"]
|
||||
|
||||
# Simulate new process/service instance
|
||||
svc2 = ModelDiscoveryService(cache_path=cache_file)
|
||||
assert svc2.get_models("claude") == ["claude-3-7-sonnet", "claude-sonnet-4-6"]
|
||||
|
||||
|
||||
def test_models_discovery_timeout_retains_existing_cache(tmp_path, monkeypatch):
|
||||
"""Verify that timeout/probe failure does not erase previously cached models."""
|
||||
cache_file = tmp_path / "models_cache.json"
|
||||
cache_file.write_text(
|
||||
json.dumps({
|
||||
"grok": {
|
||||
"models": ["grok-2-latest", "grok-beta"],
|
||||
"discovered_at": time.time(),
|
||||
}
|
||||
}, ensure_ascii=False),
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
svc = ModelDiscoveryService(cache_path=cache_file)
|
||||
|
||||
# Mock _probe_provider to simulate a hang/timeout
|
||||
def mock_hang_probe(provider):
|
||||
time.sleep(2.0)
|
||||
return ["invented-model"]
|
||||
|
||||
monkeypatch.setattr(svc, "_probe_provider", mock_hang_probe)
|
||||
|
||||
# Synchronous probe with 0.1s timeout
|
||||
result = svc.discover_models_sync("grok", timeout=0.1)
|
||||
|
||||
# Result should retain existing cached models
|
||||
assert result == ["grok-2-latest", "grok-beta"]
|
||||
assert svc.get_models("grok") == ["grok-2-latest", "grok-beta"]
|
||||
Loading…
Reference in a new issue