merge: A51 — подключённый аккаунт реально попадает в цепочку роли
Проверено ревьюером исполнением: подключение с выбором роли кладёт
аккаунт в её цепочку (developer-1 → ['openrouter-1']). Раньше выбор роли
до цепочки не доходил, и ни один аккаунт владельца ни в одной цепочке не
состоял.
Роль на карточке берётся из живой цепочки: role_assignments.get(pid, [])
вместо подстановки догадки. Таблица DEFAULT_SLOT_ROLES удалена — ссылок
на неё в дереве не осталось.
На маршрутизации показывается «Сейчас ответит: <аккаунт>» либо «никто»
с причиной. В плагине Hermes учитываются вызовы мимо хаба
(record_bypass в четырёх местах, счётчик bypassed_calls_count).
Обратное чтение сделано честно: get_hermes_config_status читает
конфигурацию Hermes ТОЛЬКО на чтение, с отдельным тестом на это.
Разрешение конфликтов с A49 и A50 (ветка A51 отведена от 17b368a):
- action_handler: взят вариант A50 — он надмножество, содержит и
назначение в роль, и защиту слота от чужого провайдера;
- auto_assigner: взят вариант A51 — таблица-догадка удалена;
подпись llama.cpp от A50 сохранена, она вне конфликта;
- settings_service, unified_health, app.js: обе стороны, правки
дополняют друг друга;
- тест свежести памяти: взята строгая проверка, вариант A51 молча
пропускал отсутствие записанного коммита.
Две правки ревьюера по итогам слияния:
- в app.js при сложении потерялась закрывающая скобка блока настроек;
- в add_account сохранение учётных данных вызывалось дважды, и второй
вызов обращался к auth_data, которой у уже авторизованного аккаунта не
существует. Перевод аккаунта в другую роль падал с ошибкой, хотя ключ
вводить не требуется. Дубль убран, оба теста A51 проходят.
554 passed, 1 skipped, ruff clean, релизный гейт 10/10 и на конфигурации
владельца, и на пустой.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
5c2a0692d3
12 changed files with 1070 additions and 199 deletions
|
|
@ -43,6 +43,14 @@ def antigravity_llm_execution(**kwargs: Any) -> Any:
|
|||
)
|
||||
if not has_active_profiles:
|
||||
logger.info("Router has no active profiles; passing call downstream to Hermes")
|
||||
try:
|
||||
from antigravity_provider.router.telemetry_service import TelemetryService
|
||||
TelemetryService.get().record_bypass(
|
||||
role="unassigned",
|
||||
reason="В роутере нет активных профилей — вызов передан штатному Hermes",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if callable(next_call):
|
||||
return next_call(request)
|
||||
return request
|
||||
|
|
@ -53,6 +61,14 @@ def antigravity_llm_execution(**kwargs: Any) -> Any:
|
|||
|
||||
if provider and not role and not any(p.provider == provider for p in engine.config.profiles.values()):
|
||||
logger.info("Explicit provider %r not managed by router; passing call downstream to Hermes", provider)
|
||||
try:
|
||||
from antigravity_provider.router.telemetry_service import TelemetryService
|
||||
TelemetryService.get().record_bypass(
|
||||
role="unassigned",
|
||||
reason=f"Провайдер '{provider}' не управляется роутером — вызов передан штатному Hermes",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if callable(next_call):
|
||||
return next_call(request)
|
||||
return request
|
||||
|
|
@ -68,6 +84,14 @@ def antigravity_llm_execution(**kwargs: Any) -> Any:
|
|||
)
|
||||
|
||||
if not resolved_role:
|
||||
try:
|
||||
from antigravity_provider.router.telemetry_service import TelemetryService
|
||||
TelemetryService.get().record_bypass(
|
||||
role="unassigned",
|
||||
reason="Роль для запроса не определена — вызов передан штатному Hermes",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if callable(next_call):
|
||||
return next_call(request)
|
||||
return request
|
||||
|
|
@ -94,6 +118,15 @@ def antigravity_llm_execution(**kwargs: Any) -> Any:
|
|||
resolved_role,
|
||||
completion.get("failover_trail"),
|
||||
)
|
||||
try:
|
||||
from antigravity_provider.router.telemetry_service import TelemetryService
|
||||
TelemetryService.get().record_bypass(
|
||||
role=resolved_role,
|
||||
reason=f"Цепочка для роли '{resolved_role}' исчерпана — вызов передан штатному Hermes",
|
||||
resolution_source=resolution_source,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from antigravity_provider.router.unified_health import EventLogService
|
||||
EventLogService.get().log(
|
||||
|
|
|
|||
|
|
@ -692,11 +692,31 @@ class ActionExecutor:
|
|||
if not prov_norm:
|
||||
return {'ok': False, 'message': 'Провайдер не указан'}
|
||||
|
||||
if prov_norm in ('google-antigravity', 'agy'):
|
||||
prov_norm = 'antigravity'
|
||||
elif prov_norm in ('codex', 'openai'):
|
||||
prov_norm = 'openai-codex'
|
||||
elif prov_norm in ('opencode', 'opengo'):
|
||||
prov_norm = 'opencode-go'
|
||||
elif prov_norm in ('anthropic',):
|
||||
prov_norm = 'claude'
|
||||
elif prov_norm in ('xai',):
|
||||
prov_norm = 'grok'
|
||||
elif prov_norm in ('local-llm', 'llama.cpp'):
|
||||
prov_norm = 'local'
|
||||
elif prov_norm in ('nvidia-nim',):
|
||||
prov_norm = 'nvidia'
|
||||
|
||||
target_role = data.get('target_role', 'coder-primary')
|
||||
base_url = (data.get('base_url') or '').strip()
|
||||
token = (data.get('token') or data.get('api_key') or '').strip()
|
||||
slot = data.get('profile_id')
|
||||
|
||||
slot = slot or AutoAssigner.find_free_slot(prov_norm) or f'{prov_norm}-1'
|
||||
|
||||
status = ProfileAuthManager.get_profile_status(prov_norm, slot)
|
||||
is_authenticated = bool(status.get("authenticated"))
|
||||
|
||||
default_base_urls = {
|
||||
'openrouter': 'https://openrouter.ai/api/v1',
|
||||
'nvidia': 'https://integrate.api.nvidia.com/v1',
|
||||
|
|
@ -711,27 +731,31 @@ class ActionExecutor:
|
|||
if not base_url and prov_norm in default_base_urls:
|
||||
base_url = default_base_urls[prov_norm]
|
||||
|
||||
# Validate required credentials per provider
|
||||
if prov_norm in ('openrouter',):
|
||||
if not token:
|
||||
return {'ok': False, 'message': 'Не указан API-ключ для OpenRouter'}
|
||||
elif prov_norm in ('nvidia', 'nvidia-nim'):
|
||||
if not token:
|
||||
return {'ok': False, 'message': 'Не указан API-ключ для NVIDIA NIM'}
|
||||
elif prov_norm in ('claude', 'anthropic'):
|
||||
if not token:
|
||||
return {'ok': False, 'message': 'Не указан API-ключ для Claude'}
|
||||
elif prov_norm in ('opencode-go', 'opencode'):
|
||||
if not token:
|
||||
return {'ok': False, 'message': 'Не указан API-ключ для OpenCode Go'}
|
||||
elif prov_norm in ('local', 'local-llm', 'llama.cpp', 'ollama', 'vllm'):
|
||||
if not base_url:
|
||||
return {'ok': False, 'message': f'Не указан URL сервера для {prov_norm}'}
|
||||
elif prov_norm in ('openai-codex', 'codex', 'grok', 'xai'):
|
||||
if not token:
|
||||
return {'ok': False, 'message': f'Не указан API-ключ для {prov_norm}'}
|
||||
else:
|
||||
return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'}
|
||||
# Validate required credentials per provider only if NOT already authenticated
|
||||
if not is_authenticated:
|
||||
if prov_norm == 'openrouter':
|
||||
if not token:
|
||||
return {'ok': False, 'message': 'Не указан API-ключ для OpenRouter'}
|
||||
elif prov_norm in ('nvidia', 'nvidia-nim'):
|
||||
if not token:
|
||||
return {'ok': False, 'message': 'Не указан API-ключ для NVIDIA NIM'}
|
||||
elif prov_norm in ('claude', 'anthropic'):
|
||||
if not token:
|
||||
return {'ok': False, 'message': 'Не указан API-ключ для Claude'}
|
||||
elif prov_norm in ('opencode-go', 'opencode'):
|
||||
if not token:
|
||||
return {'ok': False, 'message': 'Не указан API-ключ для OpenCode Go'}
|
||||
elif prov_norm in ('local', 'local-llm', 'llama.cpp', 'ollama', 'vllm'):
|
||||
if not base_url:
|
||||
return {'ok': False, 'message': f'Не указан URL сервера для {prov_norm}'}
|
||||
elif prov_norm in ('openai-codex', 'codex', 'grok', 'xai'):
|
||||
if not token:
|
||||
return {'ok': False, 'message': f'Не указан API-ключ для {prov_norm}'}
|
||||
elif prov_norm in ('antigravity', 'google-antigravity'):
|
||||
if not token and not is_authenticated:
|
||||
return {'ok': False, 'message': 'Не выполнена авторизация для Antigravity'}
|
||||
else:
|
||||
return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'}
|
||||
|
||||
slot = slot or AutoAssigner.find_free_slot(prov_norm)
|
||||
if not slot:
|
||||
|
|
@ -740,18 +764,30 @@ class ActionExecutor:
|
|||
if not ok:
|
||||
return {'ok': False, 'message': def_msg}
|
||||
|
||||
auth_data: Dict[str, Any] = {
|
||||
"provider": prov_norm,
|
||||
"profile_id": slot,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
if base_url:
|
||||
auth_data["base_url"] = base_url
|
||||
if token:
|
||||
auth_data["api_key"] = token
|
||||
# Save credentials if new token / base_url provided or for local/token providers
|
||||
if token or base_url or not is_authenticated:
|
||||
existing_auth = ProfileAuthManager.load_profile_auth(prov_norm, slot) or {}
|
||||
auth_data: Dict[str, Any] = {
|
||||
"provider": prov_norm,
|
||||
"profile_id": slot,
|
||||
"created_at": existing_auth.get("created_at", time.time()),
|
||||
}
|
||||
auth_data.update(existing_auth)
|
||||
if base_url:
|
||||
auth_data["base_url"] = base_url
|
||||
if token:
|
||||
auth_data["api_key"] = token
|
||||
auth_data["token"] = token
|
||||
try:
|
||||
ProfileAuthManager.save_profile_auth(prov_norm, slot, auth_data)
|
||||
except Exception as e:
|
||||
return {'ok': False, 'message': f'Ошибка при сохранении учетных данных {slot}: {e}'}
|
||||
|
||||
try:
|
||||
ProfileAuthManager.save_profile_auth(prov_norm, slot, auth_data)
|
||||
# Учётные данные сохраняются выше и только при их наличии.
|
||||
# Повторный вызов здесь обращался к auth_data, которой у уже
|
||||
# авторизованного аккаунта не существует: перевод аккаунта в
|
||||
# другую роль падал с ошибкой, хотя ключ вводить не требуется.
|
||||
AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False)
|
||||
_rescan_after_auth()
|
||||
from antigravity_provider.router.account_probe_service import AccountProbeService
|
||||
|
|
@ -963,13 +999,43 @@ class ActionExecutor:
|
|||
)
|
||||
return {'ok': True, 'message': 'Сухой прогон выполнен', 'data': res}
|
||||
|
||||
elif action == 'preview_auto_assign':
|
||||
res = AutoAssigner.preview_auto_assign()
|
||||
return {'ok': res.get('success', False), 'message': res.get('message', ''), 'data': res}
|
||||
|
||||
elif action == 'set_default_role':
|
||||
role = (data.get('default_role') or data.get('role') or '').strip().lower()
|
||||
if not role:
|
||||
return {'ok': False, 'message': 'Не указана роль по умолчанию'}
|
||||
from antigravity_provider.router.role_registry import RoleRegistry
|
||||
canonical_role = RoleRegistry.resolve_canonical_role(role)
|
||||
rcfg = load_router_config()
|
||||
rcfg.default_role = canonical_role
|
||||
if not save_router_config(rcfg):
|
||||
return {'ok': False, 'message': 'Не удалось сохранить конфигурацию роутера'}
|
||||
do_save_settings({'default_role': canonical_role})
|
||||
try:
|
||||
from antigravity_provider.router.state_store import HubStateStore
|
||||
HubStateStore.get().refresh(force_scan=True)
|
||||
except Exception:
|
||||
pass
|
||||
EventLogService.get().log(
|
||||
'routing',
|
||||
f'Роль по умолчанию изменена на {canonical_role}.',
|
||||
level='info',
|
||||
actor=actor,
|
||||
action='set_default_role',
|
||||
outcome='success',
|
||||
)
|
||||
return {'ok': True, 'message': f'Роль по умолчанию изменена на {canonical_role}', 'data': {'default_role': canonical_role}}
|
||||
|
||||
elif action == 'auto_assign_all':
|
||||
if async_runner:
|
||||
async_runner(lambda: AutoAssigner.auto_assign_all(), 'AutoAssignAll')
|
||||
return {'ok': True, 'message': 'запущено'}
|
||||
else:
|
||||
AutoAssigner.auto_assign_all()
|
||||
return {'ok': True, 'message': 'Успешно'}
|
||||
res = AutoAssigner.auto_assign_all()
|
||||
return {'ok': res.get('success', False), 'message': res.get('message', 'Успешно'), 'data': res}
|
||||
|
||||
elif action == 'refresh_data':
|
||||
return {'ok': True, 'message': 'Обновление данных'}
|
||||
|
|
|
|||
|
|
@ -27,42 +27,6 @@ from antigravity_provider.router.router_config import (
|
|||
|
||||
logger = logging.getLogger("hermes.router.auto_assigner")
|
||||
|
||||
HUMAN_ROLE_LABELS = RoleRegistry.get_human_role_labels()
|
||||
|
||||
DEFAULT_SLOT_ROLES = {
|
||||
"codex-orch": ("Главный оркестратор", "orchestrator", "primary"),
|
||||
"ag-orch-fallback": ("Резервный оркестратор", "orchestrator", "fallback"),
|
||||
"claude-orch": ("Оркестратор (Claude)", "orchestrator", "fallback"),
|
||||
"grok-orch": ("Оркестратор (Grok)", "orchestrator", "fallback"),
|
||||
"codex-worker-1": ("Кодер 1", "coder", "primary"),
|
||||
"claude-worker-1": ("Кодер (Claude)", "coder", "primary"),
|
||||
"ag-w1": ("Кодер 2", "coder", "fallback"),
|
||||
"grok-worker-1": ("Кодер (Grok)", "coder", "fallback"),
|
||||
"codex-worker-2": ("Ревьюер", "reviewer", "primary"),
|
||||
"claude-worker-2": ("Ревьюер (Claude)", "reviewer", "primary"),
|
||||
"ag-w2": ("Исследователь", "researcher", "primary"),
|
||||
"grok-worker-2": ("Исследователь (Grok)", "researcher", "primary"),
|
||||
"ag-w3": ("Быстрый агент", "general", "primary"),
|
||||
"ag-w4": ("Универсальный субагент", "general", "primary"),
|
||||
"opengo-1": ("Кодер (OpenCode)", "coder", "fallback_2"),
|
||||
"opengo-2": ("Исследователь (OpenCode)", "researcher", "fallback"),
|
||||
"opengo-3": ("Резервный роутер (OpenCode)", "orchestrator", "fallback_2"),
|
||||
"local-1": ("llama.cpp 1", "coder", "primary"),
|
||||
"local-2": ("llama.cpp 2", "fast", "primary"),
|
||||
"openrouter-1": ("Кодер (OpenRouter 1)", "coder", "primary"),
|
||||
"openrouter-2": ("Исследователь (OpenRouter 2)", "researcher", "fallback"),
|
||||
"nvidia-1": ("Кодер (NVIDIA NIM 1)", "coder", "primary"),
|
||||
"nvidia-2": ("Быстрый агент (NVIDIA NIM 2)", "fast", "fallback"),
|
||||
"nvidia-nim-1": ("Кодер (NVIDIA NIM 1)", "coder", "primary"),
|
||||
"nvidia-nim-2": ("Быстрый агент (NVIDIA NIM 2)", "fast", "fallback"),
|
||||
"ag-spare-1": ("Резерв 1", "spare", "spare"),
|
||||
"ag-spare-2": ("Резерв 2", "spare", "spare"),
|
||||
"ag-cold-1": ("Холодный резерв 1", "spare", "cold"),
|
||||
"ag-cold-2": ("Холодный резерв 2", "spare", "cold"),
|
||||
"ag-cold-3": ("Холодный резерв 3", "spare", "cold"),
|
||||
}
|
||||
|
||||
|
||||
CANONICAL_ROLE_MAP = RoleRegistry.get_canonical_role_map()
|
||||
|
||||
|
||||
|
|
@ -72,8 +36,6 @@ class AutoAssigner:
|
|||
@staticmethod
|
||||
def get_display_name_and_role(profile_id: str) -> Tuple[str, str, str]:
|
||||
"""Get human-readable display name, logical role, and tier for a profile."""
|
||||
if profile_id in DEFAULT_SLOT_ROLES:
|
||||
return DEFAULT_SLOT_ROLES[profile_id]
|
||||
prov_map = {
|
||||
"ag": "Antigravity",
|
||||
"codex": "Codex",
|
||||
|
|
@ -89,9 +51,9 @@ class AutoAssigner:
|
|||
parts = profile_id.split("-")
|
||||
if len(parts) == 2 and parts[0] in prov_map and parts[1].isdigit():
|
||||
clean_name = f"{prov_map[parts[0]]} {parts[1]}"
|
||||
return (clean_name, "worker", "primary")
|
||||
return (clean_name, "unassigned", "spare")
|
||||
clean_name = profile_id.replace("-", " ").title()
|
||||
return (clean_name, "worker", "primary")
|
||||
return (clean_name, "unassigned", "spare")
|
||||
|
||||
@staticmethod
|
||||
def check_duplicate_identity(provider: str, email_or_id: str, exclude_profile_id: Optional[str] = None) -> Optional[str]:
|
||||
|
|
@ -361,6 +323,188 @@ class AutoAssigner:
|
|||
save_router_config(config)
|
||||
return True, f"Профиль '{profile_id}' назначен на роль '{canonical_role}' ({'основной' if is_primary else 'резервный'})"
|
||||
|
||||
@staticmethod
|
||||
def _calculate_auto_assignment(config: RouterConfig) -> Tuple[bool, List[Tuple[str, RouterProfileConfig]], Dict[str, List[str]], List[Dict[str, Any]]]:
|
||||
"""Compute candidate auto-assignment chains and changes without writing to disk."""
|
||||
authenticated_profiles: List[Tuple[str, RouterProfileConfig]] = []
|
||||
for pid, pcfg in config.profiles.items():
|
||||
if not pcfg.enabled:
|
||||
continue
|
||||
st = ProfileAuthManager.get_profile_status(pcfg.provider, pid)
|
||||
if st.get("authenticated"):
|
||||
authenticated_profiles.append((pid, pcfg))
|
||||
|
||||
if not authenticated_profiles:
|
||||
return False, [], {}, []
|
||||
|
||||
canonical_roles = [r for r in RoleRegistry.get_role_ids() if RoleRegistry.is_role_implemented(r)]
|
||||
proposed_chains: Dict[str, List[str]] = {}
|
||||
changes: List[Dict[str, Any]] = []
|
||||
|
||||
def _norm_prov(p: str) -> str:
|
||||
p = (p or "").lower().strip()
|
||||
if p in ("openai-codex", "codex"):
|
||||
return "codex"
|
||||
if p in ("opencode-go", "opencode"):
|
||||
return "opencode"
|
||||
if p in ("local-llm", "llama.cpp", "ollama", "vllm", "local"):
|
||||
return "local"
|
||||
if p in ("claude", "anthropic"):
|
||||
return "claude"
|
||||
if p in ("grok", "xai"):
|
||||
return "grok"
|
||||
if p in ("nvidia-nim", "nvidia"):
|
||||
return "nvidia"
|
||||
return p
|
||||
|
||||
unique_providers = set(_norm_prov(pcfg.provider) for _, pcfg in authenticated_profiles)
|
||||
|
||||
if len(authenticated_profiles) == 1:
|
||||
single_pid, _ = authenticated_profiles[0]
|
||||
for role_name in canonical_roles:
|
||||
proposed_chains[role_name] = [single_pid]
|
||||
curr = list(config.roles[role_name].preferred_chain) if role_name in config.roles else []
|
||||
changes.append({
|
||||
"role": role_name,
|
||||
"role_name_ru": RoleRegistry.get_role_name_ru(role_name, role_name),
|
||||
"current_chain": curr,
|
||||
"proposed_chain": [single_pid],
|
||||
"primary_profile": single_pid,
|
||||
"message": f"Профиль '{single_pid}' назначен основным на роль '{role_name}'",
|
||||
})
|
||||
elif len(unique_providers) == 1:
|
||||
pids = [pid for pid, _ in authenticated_profiles]
|
||||
num_accs = len(pids)
|
||||
for idx, role_name in enumerate(canonical_roles):
|
||||
primary_pid = pids[idx % num_accs]
|
||||
fallbacks = [p for p in pids if p != primary_pid]
|
||||
chain = [primary_pid] + fallbacks
|
||||
proposed_chains[role_name] = chain
|
||||
curr = list(config.roles[role_name].preferred_chain) if role_name in config.roles else []
|
||||
changes.append({
|
||||
"role": role_name,
|
||||
"role_name_ru": RoleRegistry.get_role_name_ru(role_name, role_name),
|
||||
"current_chain": curr,
|
||||
"proposed_chain": chain,
|
||||
"primary_profile": primary_pid,
|
||||
"message": f"Профиль '{primary_pid}' назначен на роль '{role_name}' с ротацией квот",
|
||||
})
|
||||
else:
|
||||
role_provider_preferences = {
|
||||
"manager": ["codex", "antigravity", "opencode", "claude", "grok", "local", "openrouter", "nvidia"],
|
||||
"developer-1": ["codex", "antigravity", "opencode", "claude", "grok", "local", "openrouter", "nvidia"],
|
||||
"developer-2": ["codex", "antigravity", "opencode", "claude", "grok", "local", "openrouter", "nvidia"],
|
||||
"code-reviewer": ["codex", "opencode", "antigravity", "claude", "grok", "local", "openrouter", "nvidia"],
|
||||
"researcher": ["opencode", "antigravity", "grok", "claude", "codex", "local", "openrouter", "nvidia"],
|
||||
"tester": ["opencode", "antigravity", "local", "grok", "codex", "claude", "openrouter", "nvidia"],
|
||||
"integration-expert": ["codex", "antigravity", "opencode", "claude", "grok", "local", "openrouter", "nvidia"],
|
||||
"security-expert": ["claude", "codex", "antigravity", "grok", "opencode", "local", "openrouter", "nvidia"],
|
||||
"tech-writer": ["claude", "antigravity", "codex", "grok", "opencode", "local", "openrouter", "nvidia"],
|
||||
"analyst": ["claude", "antigravity", "codex", "grok", "opencode", "local", "openrouter", "nvidia"],
|
||||
"dependency-agent": ["antigravity", "codex", "opencode", "local", "claude", "grok", "openrouter", "nvidia"],
|
||||
}
|
||||
|
||||
by_prov: Dict[str, List[str]] = {}
|
||||
for pid, pcfg in authenticated_profiles:
|
||||
norm = _norm_prov(pcfg.provider)
|
||||
by_prov.setdefault(norm, []).append(pid)
|
||||
|
||||
prov_cursors: Dict[str, int] = {k: 0 for k in by_prov}
|
||||
|
||||
for role_name in canonical_roles:
|
||||
pref_order = role_provider_preferences.get(role_name, ["codex", "antigravity", "opencode"])
|
||||
chain: List[str] = []
|
||||
|
||||
chosen_primary = None
|
||||
for prov in pref_order:
|
||||
if prov in by_prov and by_prov[prov]:
|
||||
acc_list = by_prov[prov]
|
||||
cur = prov_cursors[prov]
|
||||
chosen_primary = acc_list[cur % len(acc_list)]
|
||||
prov_cursors[prov] = cur + 1
|
||||
break
|
||||
|
||||
if not chosen_primary:
|
||||
all_pids = [pid for pid, _ in authenticated_profiles]
|
||||
chosen_primary = all_pids[0]
|
||||
|
||||
chain.append(chosen_primary)
|
||||
|
||||
for prov in pref_order:
|
||||
if prov in by_prov:
|
||||
for p in by_prov[prov]:
|
||||
if p not in chain:
|
||||
chain.append(p)
|
||||
for pid, _ in authenticated_profiles:
|
||||
if pid not in chain:
|
||||
chain.append(pid)
|
||||
|
||||
proposed_chains[role_name] = chain
|
||||
curr = list(config.roles[role_name].preferred_chain) if role_name in config.roles else []
|
||||
changes.append({
|
||||
"role": role_name,
|
||||
"role_name_ru": RoleRegistry.get_role_name_ru(role_name, role_name),
|
||||
"current_chain": curr,
|
||||
"proposed_chain": chain,
|
||||
"primary_profile": chosen_primary,
|
||||
"message": f"Профиль '{chosen_primary}' назначен основным на роль '{role_name}'",
|
||||
})
|
||||
|
||||
return True, authenticated_profiles, proposed_chains, changes
|
||||
|
||||
@staticmethod
|
||||
def preview_auto_assign() -> Dict[str, Any]:
|
||||
"""Calculate proposed auto-assignment changes without modifying configuration on disk."""
|
||||
config = load_router_config()
|
||||
# Scan on-disk profiles and ensure their definition in in-memory config
|
||||
prov_prefix_map = {
|
||||
"antigravity": "agy_profiles",
|
||||
"openai-codex": "codex_profiles",
|
||||
"opencode-go": "opengo_profiles",
|
||||
"claude": "claude_profiles",
|
||||
"grok": "grok_profiles",
|
||||
"local": "local_profiles",
|
||||
"openrouter": "openrouter_profiles",
|
||||
"nvidia": "nvidia_profiles",
|
||||
"ollama": "ollama_profiles",
|
||||
"vllm": "vllm_profiles",
|
||||
}
|
||||
for prov, subdir in prov_prefix_map.items():
|
||||
base_dir = paths.get_hermes_home() / subdir
|
||||
if base_dir.is_dir():
|
||||
for p_entry in base_dir.iterdir():
|
||||
if p_entry.is_dir():
|
||||
pid = p_entry.name
|
||||
if pid not in config.profiles:
|
||||
st = ProfileAuthManager.get_profile_status(prov, pid)
|
||||
if st.get("authenticated"):
|
||||
config.profiles[pid] = RouterProfileConfig(
|
||||
profile_id=pid,
|
||||
provider=prov,
|
||||
account_id=pid,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
ok, auth_profs, proposed_chains, changes = AutoAssigner._calculate_auto_assignment(config)
|
||||
if not ok or not auth_profs:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Нет подключённых аккаунтов для распределения",
|
||||
"total_authenticated": 0,
|
||||
"assigned_count": 0,
|
||||
"changes": [],
|
||||
"proposed_chains": {},
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total_authenticated": len(auth_profs),
|
||||
"assigned_count": len(changes),
|
||||
"changes": changes,
|
||||
"proposed_chains": proposed_chains,
|
||||
"message": f"Сформирован план распределения {len(auth_profs)} аккаунтов",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def auto_assign_all() -> Dict[str, Any]:
|
||||
"""Automatically distribute all authenticated profiles across canonical router roles (P0-4)."""
|
||||
|
|
@ -390,15 +534,8 @@ class AutoAssigner:
|
|||
AutoAssigner.ensure_profile_definition(prov, pid)
|
||||
|
||||
config = load_router_config()
|
||||
authenticated_profiles: List[Tuple[str, RouterProfileConfig]] = []
|
||||
for pid, pcfg in config.profiles.items():
|
||||
if not pcfg.enabled:
|
||||
continue
|
||||
st = ProfileAuthManager.get_profile_status(pcfg.provider, pid)
|
||||
if st.get("authenticated"):
|
||||
authenticated_profiles.append((pid, pcfg))
|
||||
|
||||
if not authenticated_profiles:
|
||||
ok, auth_profs, proposed_chains, changes = AutoAssigner._calculate_auto_assignment(config)
|
||||
if not ok or not auth_profs:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Нет подключённых аккаунтов для распределения",
|
||||
|
|
@ -407,107 +544,10 @@ class AutoAssigner:
|
|||
"changes": [],
|
||||
}
|
||||
|
||||
canonical_roles = [r for r in RoleRegistry.get_role_ids() if RoleRegistry.is_role_implemented(r)]
|
||||
changes: List[Dict[str, Any]] = []
|
||||
|
||||
# Ensure canonical roles exist in config
|
||||
for rname in canonical_roles:
|
||||
for rname, chain in proposed_chains.items():
|
||||
if rname not in config.roles:
|
||||
config.roles[rname] = RolePolicy(role_name=rname)
|
||||
|
||||
def _norm_prov(p: str) -> str:
|
||||
p = (p or "").lower().strip()
|
||||
if p in ("openai-codex", "codex"):
|
||||
return "codex"
|
||||
if p in ("opencode-go", "opencode"):
|
||||
return "opencode"
|
||||
if p in ("local-llm", "llama.cpp", "ollama", "vllm", "local"):
|
||||
return "local"
|
||||
if p in ("claude", "anthropic"):
|
||||
return "claude"
|
||||
if p in ("grok", "xai"):
|
||||
return "grok"
|
||||
return p
|
||||
|
||||
unique_providers = set(_norm_prov(pcfg.provider) for _, pcfg in authenticated_profiles)
|
||||
|
||||
if len(authenticated_profiles) == 1:
|
||||
# Case 1: Exactly 1 account connected -> assign as primary to all 6 roles
|
||||
single_pid, _ = authenticated_profiles[0]
|
||||
for role_name in canonical_roles:
|
||||
config.roles[role_name].preferred_chain = [single_pid]
|
||||
changes.append({
|
||||
"profile_id": single_pid,
|
||||
"role": role_name,
|
||||
"message": f"Профиль '{single_pid}' назначен основным во все 6 ролей",
|
||||
})
|
||||
elif len(unique_providers) == 1:
|
||||
# Case 2: Multiple accounts of the same provider -> distribute / rotate across roles
|
||||
pids = [pid for pid, _ in authenticated_profiles]
|
||||
num_accs = len(pids)
|
||||
for idx, role_name in enumerate(canonical_roles):
|
||||
primary_pid = pids[idx % num_accs]
|
||||
fallbacks = [p for p in pids if p != primary_pid]
|
||||
config.roles[role_name].preferred_chain = [primary_pid] + fallbacks
|
||||
changes.append({
|
||||
"profile_id": primary_pid,
|
||||
"role": role_name,
|
||||
"message": f"Профиль '{primary_pid}' назначен на роль '{role_name}' с ротацией квот",
|
||||
})
|
||||
else:
|
||||
# Case 3: Multiple providers connected -> distribute by role provider preferences
|
||||
role_provider_preferences = {
|
||||
"manager": ["codex", "antigravity", "opencode", "claude", "grok", "local"],
|
||||
"developer-1": ["codex", "antigravity", "opencode", "claude", "grok", "local"],
|
||||
"developer-2": ["codex", "antigravity", "opencode", "claude", "grok", "local"],
|
||||
"code-reviewer": ["codex", "opencode", "antigravity", "claude", "grok", "local"],
|
||||
"researcher": ["opencode", "antigravity", "grok", "claude", "codex", "local"],
|
||||
"tester": ["opencode", "antigravity", "local", "grok", "codex", "claude"],
|
||||
}
|
||||
|
||||
by_prov: Dict[str, List[str]] = {}
|
||||
for pid, pcfg in authenticated_profiles:
|
||||
norm = _norm_prov(pcfg.provider)
|
||||
by_prov.setdefault(norm, []).append(pid)
|
||||
|
||||
prov_cursors: Dict[str, int] = {k: 0 for k in by_prov}
|
||||
|
||||
for role_name in canonical_roles:
|
||||
pref_order = role_provider_preferences.get(role_name, ["codex", "antigravity", "opencode"])
|
||||
chain: List[str] = []
|
||||
|
||||
# Find primary for this role
|
||||
chosen_primary = None
|
||||
for prov in pref_order:
|
||||
if prov in by_prov and by_prov[prov]:
|
||||
acc_list = by_prov[prov]
|
||||
cur = prov_cursors[prov]
|
||||
chosen_primary = acc_list[cur % len(acc_list)]
|
||||
prov_cursors[prov] = cur + 1
|
||||
break
|
||||
|
||||
if not chosen_primary:
|
||||
all_pids = [pid for pid, _ in authenticated_profiles]
|
||||
chosen_primary = all_pids[0]
|
||||
|
||||
chain.append(chosen_primary)
|
||||
|
||||
# Add remaining profiles as fallbacks in preference order
|
||||
for prov in pref_order:
|
||||
if prov in by_prov:
|
||||
for p in by_prov[prov]:
|
||||
if p not in chain:
|
||||
chain.append(p)
|
||||
for pid, _ in authenticated_profiles:
|
||||
if pid not in chain:
|
||||
chain.append(pid)
|
||||
|
||||
config.roles[role_name].preferred_chain = chain
|
||||
changes.append({
|
||||
"profile_id": chosen_primary,
|
||||
"role": role_name,
|
||||
"message": f"Профиль '{chosen_primary}' назначен основным на роль '{role_name}'",
|
||||
})
|
||||
config.roles[rname].preferred_chain = list(chain)
|
||||
|
||||
save_router_config(config)
|
||||
|
||||
|
|
@ -521,7 +561,7 @@ class AutoAssigner:
|
|||
from antigravity_provider.router.unified_health import EventLogService
|
||||
EventLogService.get().log(
|
||||
"routing",
|
||||
f"Авто-распределение завершено: {len(authenticated_profiles)} аккаунтов распределены по 6 ролям.",
|
||||
f"Авто-распределение завершено: {len(auth_profs)} аккаунтов распределены по ролям.",
|
||||
level="info",
|
||||
)
|
||||
except Exception:
|
||||
|
|
@ -529,9 +569,10 @@ class AutoAssigner:
|
|||
|
||||
return {
|
||||
"success": True,
|
||||
"total_authenticated": len(authenticated_profiles),
|
||||
"total_authenticated": len(auth_profs),
|
||||
"assigned_count": len(changes),
|
||||
"changes": changes,
|
||||
"message": f"Успешно распределено {len(auth_profs)} аккаунтов",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -681,5 +722,21 @@ def ensure_profile_in_routing(profile_id: str) -> tuple[bool, str]:
|
|||
)
|
||||
if assigned_role:
|
||||
return True, f"Профиль уже входит в цепочку '{assigned_role}'"
|
||||
_display_name, role_code, tier = AutoAssigner.get_display_name_and_role(profile_id)
|
||||
return AutoAssigner.assign_profile_to_role(profile_id, role_code, is_primary=tier == "primary")
|
||||
|
||||
pcfg = config.get_profile(profile_id)
|
||||
provider = pcfg.provider if pcfg else ""
|
||||
if not provider:
|
||||
provider = profile_id.split("-")[0]
|
||||
|
||||
target_role = "developer-1"
|
||||
pid_lower = profile_id.lower()
|
||||
if "orch" in pid_lower or "manager" in pid_lower or provider in ("claude", "anthropic"):
|
||||
target_role = "manager"
|
||||
elif "worker" in pid_lower or "coder" in pid_lower or provider in ("grok", "xai", "openai-codex", "codex", "opencode-go", "opencode"):
|
||||
target_role = "developer-1"
|
||||
elif "research" in pid_lower or provider in ("openrouter",):
|
||||
target_role = "researcher"
|
||||
elif "reviewer" in pid_lower or provider in ("local", "ollama", "vllm"):
|
||||
target_role = "code-reviewer"
|
||||
|
||||
return AutoAssigner.assign_profile_to_role(profile_id, target_role, is_primary=True)
|
||||
|
|
|
|||
|
|
@ -274,3 +274,57 @@ def setup_memory_structure(
|
|||
"created_dirs": created_dirs,
|
||||
"existing_dirs": existing_dirs,
|
||||
}
|
||||
def get_hermes_config_status(config_path: Optional[Path] = None) -> Dict[str, Any]:
|
||||
"""Read Hermes configuration (~/.hermes/config.yaml) in read-only mode to provide status feedback."""
|
||||
import yaml
|
||||
if config_path is None:
|
||||
config_path = get_hermes_home() / "config.yaml"
|
||||
|
||||
if not config_path.exists():
|
||||
return {
|
||||
"exists": False,
|
||||
"model": None,
|
||||
"provider": None,
|
||||
"base_url": None,
|
||||
"path": str(config_path),
|
||||
"message": "Конфигурационный файл ~/.hermes/config.yaml не найден",
|
||||
}
|
||||
|
||||
try:
|
||||
raw_text = config_path.read_text(encoding="utf-8")
|
||||
data = yaml.safe_load(raw_text)
|
||||
if not isinstance(data, dict):
|
||||
return {
|
||||
"exists": True,
|
||||
"model": None,
|
||||
"provider": None,
|
||||
"base_url": None,
|
||||
"path": str(config_path),
|
||||
"message": "Конфигурационный файл пуст или некорректен",
|
||||
}
|
||||
|
||||
model_cfg = data.get("model", {})
|
||||
if not isinstance(model_cfg, dict):
|
||||
model_cfg = {}
|
||||
|
||||
default_model = model_cfg.get("default") or data.get("default_model") or data.get("model")
|
||||
provider = model_cfg.get("provider") or data.get("provider")
|
||||
base_url = model_cfg.get("base_url") or data.get("base_url")
|
||||
|
||||
return {
|
||||
"exists": True,
|
||||
"model": default_model,
|
||||
"provider": provider,
|
||||
"base_url": base_url,
|
||||
"path": str(config_path),
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"exists": True,
|
||||
"model": None,
|
||||
"provider": None,
|
||||
"base_url": None,
|
||||
"path": str(config_path),
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -200,6 +200,15 @@ class HubStateStore:
|
|||
active_leases_total = 0
|
||||
active_leases_by_profile = {}
|
||||
|
||||
try:
|
||||
from .settings_service import get_hermes_config_status, get_hub_settings
|
||||
hermes_cfg = get_hermes_config_status()
|
||||
hub_settings = get_hub_settings()
|
||||
default_role = hub_settings.get("default_role", "manager")
|
||||
except Exception:
|
||||
hermes_cfg = {"exists": False, "model": None, "provider": None}
|
||||
default_role = "manager"
|
||||
|
||||
metrics = {
|
||||
"generation": gen,
|
||||
"seq": request_seq,
|
||||
|
|
@ -214,6 +223,8 @@ class HubStateStore:
|
|||
"host": host_data,
|
||||
"active_calls_total": active_leases_total,
|
||||
"active_calls_by_profile": active_leases_by_profile,
|
||||
"hermes_config": hermes_cfg,
|
||||
"default_role": default_role,
|
||||
}
|
||||
try:
|
||||
from .workflow_service import WorkflowService
|
||||
|
|
|
|||
|
|
@ -96,6 +96,9 @@ class TelemetryAggregates:
|
|||
total_cost_usd: Optional[float] = None # Sum of calculated costs or None if no pricing available
|
||||
failovers_count: int = 0
|
||||
failover_reasons: Dict[str, int] = field(default_factory=dict)
|
||||
routed_calls_count: int = 0
|
||||
bypassed_calls_count: int = 0
|
||||
bypass_rate: Optional[float] = None
|
||||
source: str = "own_measurement"
|
||||
has_data: bool = True
|
||||
|
||||
|
|
@ -132,6 +135,11 @@ class TelemetryService:
|
|||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def reset_instance(cls) -> None:
|
||||
with cls._instance_lock:
|
||||
cls._instance = None
|
||||
|
||||
def set_pricing_table(self, pricing: Dict[str, Dict[str, float]]) -> None:
|
||||
"""Set or update the in-memory pricing table: {model_id_or_pattern: {input_cost_per_m: float, output_cost_per_m: float}}."""
|
||||
with self._lock:
|
||||
|
|
@ -268,6 +276,23 @@ class TelemetryService:
|
|||
self._append_to_disk(record)
|
||||
return record
|
||||
|
||||
def record_bypass(
|
||||
self,
|
||||
role: str,
|
||||
reason: str,
|
||||
resolution_source: Optional[str] = None,
|
||||
) -> TelemetryRecord:
|
||||
"""Record an invocation attempt that bypassed hub routing."""
|
||||
return self.record_call(
|
||||
role=role,
|
||||
profile_id="bypass",
|
||||
provider="hermes",
|
||||
model="bypass",
|
||||
outcome="bypass",
|
||||
latency_seconds=0.0,
|
||||
error_category=reason,
|
||||
)
|
||||
|
||||
def _append_to_disk(self, record: TelemetryRecord) -> None:
|
||||
"""Append record to disk with size-based log rotation."""
|
||||
try:
|
||||
|
|
@ -394,6 +419,9 @@ class TelemetryService:
|
|||
total_cost_usd=None,
|
||||
failovers_count=0,
|
||||
failover_reasons={},
|
||||
routed_calls_count=0,
|
||||
bypassed_calls_count=0,
|
||||
bypass_rate=None,
|
||||
source="own_measurement",
|
||||
has_data=False,
|
||||
)
|
||||
|
|
@ -402,6 +430,8 @@ class TelemetryService:
|
|||
|
||||
successful_calls = 0
|
||||
failed_calls = 0
|
||||
routed_calls_count = 0
|
||||
bypassed_calls_count = 0
|
||||
latencies_ms: List[float] = []
|
||||
prompt_tokens_sum = 0
|
||||
completion_tokens_sum = 0
|
||||
|
|
@ -421,11 +451,15 @@ class TelemetryService:
|
|||
failover_reasons: Dict[str, int] = collections.defaultdict(int)
|
||||
|
||||
for r in records:
|
||||
latencies_ms.append(r.latency_seconds * 1000.0)
|
||||
if r.outcome == "bypass":
|
||||
bypassed_calls_count += 1
|
||||
else:
|
||||
routed_calls_count += 1
|
||||
latencies_ms.append(r.latency_seconds * 1000.0)
|
||||
|
||||
if r.outcome in ("success", "failover"):
|
||||
successful_calls += 1
|
||||
else:
|
||||
elif r.outcome != "bypass":
|
||||
failed_calls += 1
|
||||
|
||||
if r.failover_count > 0 or r.outcome == "failover":
|
||||
|
|
@ -485,6 +519,7 @@ class TelemetryService:
|
|||
max_lat = latencies_ms[-1] if latencies_ms else None
|
||||
|
||||
error_rate = round(failed_calls / total_calls, 4) if total_calls > 0 else 0.0
|
||||
bypass_rate = round(bypassed_calls_count / total_calls, 4) if total_calls > 0 else 0.0
|
||||
total_tokens_sum = (prompt_tokens_sum + completion_tokens_sum) if has_any_token_data else None
|
||||
tot_meas_sum = (prompt_meas_sum + comp_meas_sum) if (has_meas_prompt or has_meas_comp) else None
|
||||
tot_est_sum = (prompt_est_sum + comp_est_sum) if (has_est_prompt or has_est_comp) else None
|
||||
|
|
@ -514,6 +549,9 @@ class TelemetryService:
|
|||
total_cost_usd=round(costs_sum, 4) if has_any_cost_data else None,
|
||||
failovers_count=failovers_count,
|
||||
failover_reasons=dict(failover_reasons),
|
||||
routed_calls_count=routed_calls_count,
|
||||
bypassed_calls_count=bypassed_calls_count,
|
||||
bypass_rate=bypass_rate,
|
||||
source="own_measurement",
|
||||
has_data=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -158,6 +158,10 @@ class RolePipeline:
|
|||
session_affinity: bool
|
||||
active_profile_id: str
|
||||
nodes: List[PipelineNode]
|
||||
effective_answering_profile: Optional[str] = None
|
||||
effective_answering_model: Optional[str] = None
|
||||
will_bypass: bool = False
|
||||
bypass_reason: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -351,8 +355,9 @@ class UnifiedHealthService:
|
|||
|
||||
role_assignments: Dict[str, List[str]] = {}
|
||||
for rname, rpol in config.roles.items():
|
||||
role_name_ru = RoleRegistry.get_role_short_name_ru(rname) or RoleRegistry.get_role_name_ru(rname, rname)
|
||||
for idx, pid in enumerate(rpol.preferred_chain):
|
||||
tag = f"{rname} (primary)" if idx == 0 else f"{rname} (fallback {idx})"
|
||||
tag = f"{role_name_ru} (Основной, #1)" if idx == 0 else f"{role_name_ru} (Запасной, #{idx + 1})"
|
||||
role_assignments.setdefault(pid, []).append(tag)
|
||||
|
||||
orch_role_name = RoleRegistry.resolve_canonical_role("orchestrator")
|
||||
|
|
@ -541,6 +546,9 @@ class UnifiedHealthService:
|
|||
health_state, health_lbl = STATUS_UNHEALTHY, "Проверен: не работает — " + check.get("message", "Причина Н/Д")
|
||||
elif check.get("state") == "working" and health_state in (STATUS_NOT_TESTED, STATUS_HEALTHY):
|
||||
health_state, health_lbl = STATUS_HEALTHY, "Проверен: работает"
|
||||
assigned = role_assignments.get(pid, [])
|
||||
primary_r = assigned[0] if assigned else "unassigned"
|
||||
|
||||
vm = ProfileViewModel(
|
||||
connection_check=check,
|
||||
model_discovery=model_meta,
|
||||
|
|
@ -549,8 +557,8 @@ class UnifiedHealthService:
|
|||
account_identity=ident.primary_identifier() if is_authenticated else identity,
|
||||
provider=prov,
|
||||
provider_display_name=prov_display,
|
||||
assigned_roles=role_assignments.get(pid, [log_role]),
|
||||
primary_role=log_role,
|
||||
assigned_roles=assigned,
|
||||
primary_role=primary_r,
|
||||
is_main_account=is_main_acc,
|
||||
is_main_orchestrator=is_main_orch,
|
||||
auth_state=auth_state,
|
||||
|
|
@ -911,13 +919,36 @@ class UnifiedHealthService:
|
|||
|
||||
for rname, rpol in config.roles.items():
|
||||
nodes: List[PipelineNode] = []
|
||||
active_pid = ""
|
||||
effective_answering_profile = None
|
||||
effective_answering_model = None
|
||||
will_bypass = False
|
||||
bypass_reason = None
|
||||
|
||||
for pid in rpol.preferred_chain:
|
||||
pvm = self._cached_profiles.get(pid)
|
||||
if pvm and pvm.health_state == STATUS_HEALTHY:
|
||||
active_pid = pid
|
||||
pcfg = config.profiles.get(pid)
|
||||
if (
|
||||
pcfg
|
||||
and pcfg.enabled
|
||||
and pvm
|
||||
and pvm.enabled
|
||||
and pvm.auth_state == "AUTHENTICATED"
|
||||
and pvm.health_state not in (STATUS_QUOTA_EXHAUSTED, STATUS_COOLDOWN, STATUS_RATE_LIMITED, STATUS_DISABLED, STATUS_AUTH_REQUIRED, STATUS_AUTH_EXPIRED)
|
||||
and pvm.cooldown_remaining_sec <= 0
|
||||
):
|
||||
effective_answering_profile = pid
|
||||
effective_answering_model = pvm.preferred_models[0] if pvm.preferred_models else (rpol.default_model or "default")
|
||||
break
|
||||
|
||||
if effective_answering_profile is None:
|
||||
will_bypass = True
|
||||
if not rpol.preferred_chain:
|
||||
bypass_reason = "Цепочка пуста — вызов уйдёт мимо хаба в Hermes"
|
||||
else:
|
||||
bypass_reason = "Все аккаунты цепочки недоступны или исчерпали квоту — вызов уйдёт мимо хаба в Hermes"
|
||||
|
||||
active_pid = effective_answering_profile or ""
|
||||
|
||||
for idx, pid in enumerate(rpol.preferred_chain):
|
||||
pvm = self._cached_profiles.get(pid)
|
||||
if pvm:
|
||||
|
|
@ -967,6 +998,10 @@ class UnifiedHealthService:
|
|||
session_affinity=rpol.session_affinity_enabled,
|
||||
active_profile_id=active_pid,
|
||||
nodes=nodes,
|
||||
effective_answering_profile=effective_answering_profile,
|
||||
effective_answering_model=effective_answering_model,
|
||||
will_bypass=will_bypass,
|
||||
bypass_reason=bypass_reason,
|
||||
)
|
||||
|
||||
return pipelines
|
||||
|
|
|
|||
|
|
@ -590,6 +590,21 @@ function updateGlobalHeader() {
|
|||
: 'Н/Д: состояние ещё не измерено';
|
||||
}
|
||||
|
||||
const hermesCfg = (currentSnapshot.metrics || {}).hermes_config;
|
||||
const hermesBadge = document.getElementById('header-hermes-config-badge');
|
||||
if (hermesBadge) {
|
||||
if (hermesCfg && hermesCfg.exists && hermesCfg.model) {
|
||||
hermesBadge.innerHTML = `🤖 В Hermes: <strong>${escapeHtml(hermesCfg.model)}</strong> (${escapeHtml(hermesCfg.provider || 'default')})`;
|
||||
hermesBadge.classList.remove('hidden');
|
||||
} else if (hermesCfg && hermesCfg.exists) {
|
||||
hermesBadge.innerHTML = `🤖 В Hermes: (модель не выбрана)`;
|
||||
hermesBadge.classList.remove('hidden');
|
||||
} else {
|
||||
hermesBadge.innerHTML = `🤖 В Hermes: конфигурация не найдена`;
|
||||
hermesBadge.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
const kpiReadiness = document.getElementById('kpi-system-readiness');
|
||||
const kpiSummary = document.getElementById('kpi-readiness-summary');
|
||||
const kpiTotalAccounts = document.getElementById('kpi-total-accounts');
|
||||
|
|
@ -764,7 +779,8 @@ function renderAccountsView() {
|
|||
|
||||
function renderAccountCard(profile) {
|
||||
const isMain = profile.is_main_account || profile.is_main_orchestrator;
|
||||
const roles = (profile.assigned_roles || []).join(', ') || 'Роль: Н/Д';
|
||||
const isAssigned = profile.assigned_roles && profile.assigned_roles.length > 0;
|
||||
const roles = isAssigned ? profile.assigned_roles.join(', ') : 'Не назначен';
|
||||
const identity = profile.email || profile.account_identity || profile.display_name || profile.profile_id;
|
||||
const healthState = profile.health_state || 'unknown';
|
||||
const healthLabel = profile.health_label_ru || 'Н/Д: состояние не проверялось';
|
||||
|
|
@ -808,6 +824,8 @@ function renderAccountCard(profile) {
|
|||
`;
|
||||
}
|
||||
|
||||
const unassignedBadge = !isAssigned ? '<span class="badge badge-unassigned">Не назначен</span>' : '';
|
||||
|
||||
return `
|
||||
<div class="account-card ${isMain ? 'main-account' : ''}" data-profile-id="${escapeHtml(profile.profile_id)}">
|
||||
<div class="account-card-header">
|
||||
|
|
@ -816,6 +834,7 @@ function renderAccountCard(profile) {
|
|||
</div>
|
||||
<div class="account-badges">
|
||||
${plan ? `<span class="badge badge-plan">${escapeHtml(plan)}</span>` : ''}
|
||||
${unassignedBadge}
|
||||
<span class="badge badge-status ${healthState}">● ${escapeHtml(healthLabel)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1272,6 +1291,77 @@ async function handleAddNodeToChain(roleId) {
|
|||
}
|
||||
}
|
||||
|
||||
async function openAutoAssignPreviewModal() {
|
||||
if (elements.modalTitle) elements.modalTitle.textContent = '⚡ Предварительный просмотр авто-распределения';
|
||||
elements.modalBody.innerHTML = '<div class="modal-feedback info">⏳ Расчёт плана распределения аккаунтов...</div>';
|
||||
elements.modalFooter.innerHTML = '<button class="btn btn-ghost" onclick="closeModal()">Отмена</button>';
|
||||
showModal();
|
||||
|
||||
const res = await executeAction('preview_auto_assign', {});
|
||||
if (!res || !res.ok || !res.data || !res.data.success) {
|
||||
elements.modalBody.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml((res && res.message) || 'Не удалось сформировать план авто-распределения')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data;
|
||||
const changes = data.changes || [];
|
||||
if (changes.length === 0) {
|
||||
elements.modalBody.innerHTML = '<div class="view-header-note">Нет изменений для применения. Все роли уже распределены оптимально.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let tableHtml = `
|
||||
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
|
||||
Будет распределено <strong>${data.total_authenticated}</strong> подключённых аккаунтов по ролям:
|
||||
</div>
|
||||
<div style="max-height:360px; overflow-y:auto; border:1px solid var(--border-subtle); border-radius:var(--radius-sm);">
|
||||
<table class="data-table" style="width:100%; font-size:12px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Роль</th>
|
||||
<th>Текущая цепочка</th>
|
||||
<th>Предлагаемая цепочка</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
changes.forEach((ch) => {
|
||||
const cur = (ch.current_chain && ch.current_chain.length) ? ch.current_chain.join(' → ') : '<span class="text-muted">пусто</span>';
|
||||
const prop = (ch.proposed_chain && ch.proposed_chain.length) ? ch.proposed_chain.join(' → ') : '<span class="text-muted">пусто</span>';
|
||||
tableHtml += `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(ch.role_name_ru || ch.role)}</strong></td>
|
||||
<td>${cur}</td>
|
||||
<td style="color:var(--status-healthy); font-weight:600;">${prop}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
tableHtml += '</tbody></table></div>';
|
||||
elements.modalBody.innerHTML = tableHtml;
|
||||
elements.modalFooter.innerHTML = `
|
||||
<button class="btn btn-ghost" onclick="closeModal()">Отмена</button>
|
||||
<button class="btn btn-primary" id="btn-apply-auto-assign" onclick="applyAutoAssign()">Применить</button>
|
||||
`;
|
||||
}
|
||||
|
||||
async function applyAutoAssign() {
|
||||
const btn = document.getElementById('btn-apply-auto-assign');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Применение...';
|
||||
}
|
||||
const res = await executeAction('auto_assign_all', {});
|
||||
if (res && res.ok) {
|
||||
showToast('Авто-распределение успешно применено', 'success');
|
||||
closeModal();
|
||||
await fetchSnapshot();
|
||||
} else {
|
||||
showToast((res && res.message) || 'Ошибка применения', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── ANALYTICS VIEW ──
|
||||
function renderAnalyticsView() {
|
||||
if (!currentSnapshot) return;
|
||||
|
|
@ -1290,10 +1380,12 @@ function renderAnalyticsView() {
|
|||
|
||||
if (global.total_calls !== undefined) {
|
||||
if (totalCallsEl) totalCallsEl.textContent = new Intl.NumberFormat('ru-RU').format(global.total_calls);
|
||||
if (callsBreakdownEl) callsBreakdownEl.textContent = `Успешно: ${global.successful_calls ?? 0} · Сбоев: ${global.failed_calls ?? 0}`;
|
||||
const routedCount = global.routed_calls_count ?? (global.total_calls - (global.bypassed_calls_count ?? 0));
|
||||
const bypassCount = global.bypassed_calls_count ?? 0;
|
||||
if (callsBreakdownEl) callsBreakdownEl.textContent = `Через хаб: ${routedCount} · Мимо хаба (Bypass): ${bypassCount}`;
|
||||
const errRate = global.total_calls > 0 && global.failed_calls != null ? ((global.failed_calls / global.total_calls) * 100).toFixed(1) : null;
|
||||
if (errorRateEl) errorRateEl.textContent = errRate === null ? 'Н/Д' : `${errRate}%`;
|
||||
if (errorRateSubEl) errorRateSubEl.textContent = `${global.failed_calls ?? 0} сбоев из ${global.total_calls} вызовов`;
|
||||
if (errorRateSubEl) errorRateSubEl.textContent = `${global.failed_calls ?? 0} сбоев из ${global.total_calls} вызовов (Bypass: ${bypassCount})`;
|
||||
} else {
|
||||
if (totalCallsEl) totalCallsEl.textContent = '—';
|
||||
if (callsBreakdownEl) callsBreakdownEl.textContent = 'Нет данных за 24 ч';
|
||||
|
|
@ -1537,6 +1629,7 @@ function renderSettingsView() {
|
|||
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
|
||||
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
|
||||
const accountIntervalInput = document.getElementById('setting-account-check-interval');
|
||||
const defaultRoleSel = document.getElementById('setting-default-role');
|
||||
|
||||
if (quotaThresholdSel && s.quota_threshold_percent !== undefined) {
|
||||
quotaThresholdSel.value = String(Math.round(s.quota_threshold_percent));
|
||||
|
|
@ -1566,6 +1659,10 @@ function renderSettingsView() {
|
|||
if (vaultPathInput) {
|
||||
vaultPathInput.value = s.obsidian_vault_path || '/srv/projects/AI-Memory';
|
||||
}
|
||||
if (defaultRoleSel) {
|
||||
const currentDef = s.default_role || currentSnapshot.metrics?.default_role || 'manager';
|
||||
defaultRoleSel.value = currentDef;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveHubServerSettings() {
|
||||
|
|
@ -1575,6 +1672,7 @@ async function saveHubServerSettings() {
|
|||
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
|
||||
const vaultPathInput = document.getElementById('setting-obsidian-vault-path');
|
||||
const accountIntervalInput = document.getElementById('setting-account-check-interval');
|
||||
const defaultRoleSel = document.getElementById('setting-default-role');
|
||||
|
||||
const newSettings = {};
|
||||
if (accountIntervalInput?.value) newSettings.account_check_interval_seconds = Math.max(60, Number(accountIntervalInput.value));
|
||||
|
|
@ -1583,6 +1681,7 @@ async function saveHubServerSettings() {
|
|||
if (emailMaskingSel?.value) newSettings.email_masking_mode = emailMaskingSel.value;
|
||||
if (monitorIntervalInput?.value) newSettings.monitoring_interval_seconds = Number(monitorIntervalInput.value);
|
||||
if (vaultPathInput?.value) newSettings.obsidian_vault_path = vaultPathInput.value.trim();
|
||||
if (defaultRoleSel?.value) newSettings.default_role = defaultRoleSel.value;
|
||||
if (!Object.keys(newSettings).length) { showToast('Нет выбранных изменений', 'info'); return; }
|
||||
|
||||
showToast('Сохранение настроек сервера...', 'info');
|
||||
|
|
@ -1853,7 +1952,7 @@ function openAccountDetailsModal(profileId, isRedraw = false) {
|
|||
Статус: <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>
|
||||
Назначенные роли: <strong>${escapeHtml((profile.assigned_roles && profile.assigned_roles.length > 0) ? profile.assigned_roles.join(', ') : 'Не назначен')}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@
|
|||
<span class="status-dot"></span>
|
||||
<span id="header-readiness-text">Инициализация...</span>
|
||||
</span>
|
||||
<span class="header-hermes-config-badge hidden" id="header-hermes-config-badge" style="display:inline-flex; align-items:center; font-size:12px; color:var(--text-muted); background:var(--surface-muted); padding:3px 8px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-left:6px;"></span>
|
||||
<button class="header-update-badge hidden" id="header-update-badge" title="Нажмите, чтобы посмотреть детали обновления">
|
||||
<span class="status-dot warning"></span>
|
||||
<span id="header-update-text">Доступно обновление</span>
|
||||
|
|
@ -201,6 +202,28 @@
|
|||
<!-- 3. ROUTING VIEW (Main Control Center) -->
|
||||
|
||||
<section id="view-routing" class="view-pane">
|
||||
<div class="routing-toolbar-actions">
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
<label for="routing-default-role-select" style="font-weight:600; font-size:12px;">Роль по умолчанию:</label>
|
||||
<select id="routing-default-role-select" class="select-filter" style="font-size:12px;">
|
||||
<option value="manager">Главный менеджер (manager)</option>
|
||||
<option value="developer-1">Кодер 1 (developer-1)</option>
|
||||
<option value="developer-2">Кодер 2 (developer-2)</option>
|
||||
<option value="code-reviewer">Ревьюер кода (code-reviewer)</option>
|
||||
<option value="researcher">Исследователь (researcher)</option>
|
||||
<option value="tester">Тестировщик (tester)</option>
|
||||
<option value="integration-expert">Интегратор (integration-expert)</option>
|
||||
<option value="security-expert">Эксперт по безопасности (security-expert)</option>
|
||||
<option value="tech-writer">Технический писатель (tech-writer)</option>
|
||||
<option value="analyst">Аналитик требований (analyst)</option>
|
||||
<option value="dependency-agent">Агент зависимостей (dependency-agent)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-routing-auto-assign" onclick="openAutoAssignPreviewModal()" title="Автоматическое распределение подключенных аккаунтов по ролям">
|
||||
<span>⚡ Авто-распределение</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="view-header-note" style="display:flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<i class="fa-solid fa-circle-info" style="color:var(--status-warning);"></i>
|
||||
|
|
@ -493,6 +516,27 @@
|
|||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">Роль по умолчанию (Default Role)</div>
|
||||
<div class="setting-desc">Целевая роль при отсутствии явного указания роли в запросе Hermes</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select id="setting-default-role" class="select-filter">
|
||||
<option value="manager" selected>Главный менеджер (manager)</option>
|
||||
<option value="developer-1">Кодер 1 (developer-1)</option>
|
||||
<option value="developer-2">Кодер 2 (developer-2)</option>
|
||||
<option value="code-reviewer">Ревьюер кода (code-reviewer)</option>
|
||||
<option value="researcher">Исследователь (researcher)</option>
|
||||
<option value="tester">Тестировщик (tester)</option>
|
||||
<option value="integration-expert">Интегратор (integration-expert)</option>
|
||||
<option value="security-expert">Эксперт по безопасности (security-expert)</option>
|
||||
<option value="tech-writer">Технический писатель (tech-writer)</option>
|
||||
<option value="analyst">Аналитик требований (analyst)</option>
|
||||
<option value="dependency-agent">Агент зависимостей (dependency-agent)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">Тема оформления</div>
|
||||
|
|
|
|||
|
|
@ -647,6 +647,46 @@ body {
|
|||
.badge-status.auth_required { color: var(--status-warning); }
|
||||
.badge-status.disabled { color: var(--status-disabled); }
|
||||
|
||||
.badge-unassigned {
|
||||
background-color: var(--surface-muted);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.role-answering-status {
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
margin: 6px 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.role-answering-status.healthy {
|
||||
background-color: rgba(114, 201, 67, 0.1);
|
||||
color: var(--status-healthy);
|
||||
border: 1px solid rgba(114, 201, 67, 0.25);
|
||||
}
|
||||
|
||||
.role-answering-status.warning {
|
||||
background-color: rgba(225, 166, 43, 0.1);
|
||||
color: var(--status-warning);
|
||||
border: 1px solid rgba(225, 166, 43, 0.25);
|
||||
}
|
||||
|
||||
.routing-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--surface-muted);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.account-identity-row {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,16 +128,43 @@ function renderAccountRouting() {
|
|||
const profiles = snapshot.all_profiles || {};
|
||||
const routing = snapshot.routing || {};
|
||||
const left = document.getElementById('routing-roles-container');
|
||||
|
||||
const defRoleSelect = document.getElementById('routing-default-role-select');
|
||||
if (defRoleSelect) {
|
||||
const currentDef = snapshot.metrics?.default_role || 'manager';
|
||||
defRoleSelect.value = currentDef;
|
||||
defRoleSelect.onchange = async () => {
|
||||
const res = await executeAction('set_default_role', { default_role: defRoleSelect.value });
|
||||
if (res && res.ok) {
|
||||
showToast(`Роль по умолчанию изменена на '${defRoleSelect.value}'`, 'success');
|
||||
await fetchSnapshot();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
left.innerHTML = Object.entries(routing).map(([roleId,pipeline]) => {
|
||||
const agent = (snapshot.workflow?.agents || []).find(item => item.role === roleId);
|
||||
const role = (snapshot.agents || []).find(item => item.role_id === roleId);
|
||||
|
||||
let responderStatusHtml = '';
|
||||
if (!pipeline.will_bypass && pipeline.effective_answering_profile) {
|
||||
const respProfile = profiles[pipeline.effective_answering_profile] || { profile_id: pipeline.effective_answering_profile };
|
||||
const respModel = pipeline.effective_answering_model || 'default';
|
||||
const nodeIdx = (pipeline.nodes || []).findIndex(n => n.profile_id === pipeline.effective_answering_profile);
|
||||
const prioLabel = nodeIdx === 0 ? 'Основной (#1)' : `Запасной (#${nodeIdx + 1})`;
|
||||
responderStatusHtml = `<div class="role-answering-status healthy">🟢 Сейчас ответит: <strong>${escapeHtml(respProfile.display_name || pipeline.effective_answering_profile)}</strong> (${escapeHtml(respModel)}) — ${prioLabel}</div>`;
|
||||
} else {
|
||||
const reason = pipeline.bypass_reason || 'цепочка пуста — вызов уйдёт мимо хаба в Hermes';
|
||||
responderStatusHtml = `<div class="role-answering-status warning">⚠️ Сейчас ответит: <strong>никто</strong> (${escapeHtml(reason)})</div>`;
|
||||
}
|
||||
|
||||
const rows = (pipeline.nodes || []).map((node,index) => {
|
||||
const profile = profiles[node.profile_id] || {profile_id:node.profile_id,provider:node.provider};
|
||||
const quota = profile.quota_snapshot || snapshot.quotas?.[node.profile_id] || {};
|
||||
const reset = (quota.buckets || []).map(b => b.reset_time_formatted || b.reset_after_formatted).filter(Boolean).join('; ');
|
||||
return `<div class="account-row draggable-item" draggable="true" data-pid="${escapeHtml(node.profile_id)}" data-role="${escapeHtml(roleId)}"><div class="drag-handle">⠿</div><div class="route-priority">${index ? 'Резерв '+index : 'Основной'}</div><div><strong>${escapeHtml(profile.display_name || node.profile_id)}</strong><div class="text-muted">${escapeHtml(node.account_identity || profile.account_identity || node.provider)}</div></div><div class="route-model-with-logo"><img class="brand-logo" src="/static/${getModelIcon(node.model)}" alt=""><select data-route-model="${escapeHtml(node.profile_id)}" data-role="${escapeHtml(roleId)}" aria-label="Модель ${escapeHtml(node.profile_id)}">${modelOptions(snapshot,profile,node.model)}</select></div><div>${routeQuota(profile,snapshot)}</div><div class="text-muted">${escapeHtml(reset || 'Н/Д: нет времени сброса')}</div><div><span class="status-dot ${healthDotClass(node.status)}"></span> ${escapeHtml(node.status_label_ru || 'Не проверялся')}${node.is_active ? '<br>Используется' : ''}</div><button class="route-remove" data-remove-profile="${escapeHtml(node.profile_id)}" data-role="${escapeHtml(roleId)}" aria-label="Удалить из маршрута">×</button></div>`;
|
||||
}).join('');
|
||||
return `<section class="role-section"><header class="role-header"><div><h4>${escapeHtml(agent?.name || pipeline.role_name_ru || roleId)}</h4><p class="text-muted">${escapeHtml(agent?.description || role?.role_description_ru || '')}</p></div><button class="btn btn-secondary btn-sm" data-add-role="${escapeHtml(roleId)}">+ Добавить аккаунт</button></header><div class="grid-header"><span></span><span>Приоритет</span><span>Аккаунт</span><span>Модель</span><span>Квота · остаток</span><span>Сброс</span><span>Статус</span><span></span></div><div class="role-chain-list">${rows || '<p class="inspector-value">Аккаунты не назначены. Добавьте подключённый аккаунт.</p>'}</div><div class="drop-zone" data-role="${escapeHtml(roleId)}">+ Перетащите аккаунт в конец маршрута</div><p class="inspector-value">Session Affinity: ${pipeline.session_affinity ? 'включена' : 'отключена'} · Порядок применяется к следующим назначениям</p></section>`;
|
||||
return `<section class="role-section"><header class="role-header"><div><h4>${escapeHtml(agent?.name || pipeline.role_name_ru || roleId)}</h4><p class="text-muted">${escapeHtml(agent?.description || role?.role_description_ru || '')}</p></div><button class="btn btn-secondary btn-sm" data-add-role="${escapeHtml(roleId)}">+ Добавить аккаунт</button></header>${responderStatusHtml}<div class="grid-header"><span></span><span>Приоритет</span><span>Аккаунт</span><span>Модель</span><span>Квота · остаток</span><span>Сброс</span><span>Статус</span><span></span></div><div class="role-chain-list">${rows || '<p class="inspector-value">Аккаунты не назначены. Добавьте подключённый аккаунт.</p>'}</div><div class="drop-zone" data-role="${escapeHtml(roleId)}">+ Перетащите аккаунт в конец маршрута</div><p class="inspector-value">Session Affinity: ${pipeline.session_affinity ? 'включена' : 'отключена'} · Порядок применяется к следующим назначениям</p></section>`;
|
||||
}).join('') || '<p class="view-header-note">Агенты ещё не созданы. Добавьте агента на «Обзоре».</p>';
|
||||
left.querySelectorAll('[data-add-role]').forEach(button => button.onclick = () => openAddNodeToChainModal(button.dataset.addRole));
|
||||
left.querySelectorAll('[data-remove-profile]').forEach(button => button.onclick = () => removeProfileFromChain(button.dataset.role,button.dataset.removeProfile));
|
||||
|
|
@ -199,7 +226,7 @@ function arrangeSettingsPanels() {
|
|||
const view = document.getElementById('view-settings');
|
||||
const first = view.querySelector('.settings-card');
|
||||
const groups = [
|
||||
['Общие настройки',['setting-theme']],
|
||||
['Общие настройки',['setting-default-role','setting-theme']],
|
||||
['Управление квотами',['setting-account-check-interval','setting-quota-interval','setting-quota-threshold-percent','setting-quota-threshold-action']],
|
||||
['Безопасность и API',['setting-server-host','setting-server-token-input','setting-email-masking-mode']],
|
||||
];
|
||||
|
|
|
|||
367
tests/test_a51_hub_controls_hermes.py
Normal file
367
tests/test_a51_hub_controls_hermes.py
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
"""Tests for Task A51 — Hub Controls Hermes.
|
||||
|
||||
Verifies:
|
||||
1. P0-1: Account addition, multi-provider credential saving, role assignment, already-authenticated account handling, spare pool removal, preview_auto_assign, and set_default_role.
|
||||
2. P0-2: Truthful assigned_roles and primary_role from preferred_chain without static fallback.
|
||||
3. P0-3: Dynamic calculation of effective_answering_profile, effective_answering_model, will_bypass, and bypass_reason.
|
||||
4. P0-4: Telemetry recording of routed vs bypassed calls and bypass_rate.
|
||||
5. P0-5: Read-only feedback of ~/.hermes/config.yaml status in HubSnapshot.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from antigravity_provider.router.action_handler import ActionExecutor
|
||||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||
from antigravity_provider.router.role_registry import RoleRegistry
|
||||
from antigravity_provider.router.router_config import (
|
||||
RolePolicy,
|
||||
RouterConfig,
|
||||
RouterProfileConfig,
|
||||
load_router_config,
|
||||
save_router_config,
|
||||
)
|
||||
from antigravity_provider.router.settings_service import (
|
||||
get_hermes_config_status,
|
||||
get_hub_settings,
|
||||
save_hub_settings,
|
||||
)
|
||||
from antigravity_provider.router.state_store import HubStateStore
|
||||
from antigravity_provider.router.telemetry_service import TelemetryService
|
||||
from antigravity_provider.router.unified_health import (
|
||||
STATUS_HEALTHY,
|
||||
STATUS_QUOTA_EXHAUSTED,
|
||||
UnifiedHealthService,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_isolated_env(tmp_path, monkeypatch):
|
||||
"""Set up isolated HERMES_HOME and router configuration for tests."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
profiles_yaml = hermes_home / "router_profiles.yaml"
|
||||
monkeypatch.setenv("HERMES_ROUTER_CONFIG", str(profiles_yaml))
|
||||
|
||||
# Initialize empty router config with canonical roles
|
||||
initial_config = RouterConfig(
|
||||
enabled=True,
|
||||
default_role="manager",
|
||||
roles=RoleRegistry.get_default_role_policies(),
|
||||
profiles={},
|
||||
)
|
||||
save_router_config(initial_config, profiles_yaml)
|
||||
|
||||
# Initialize telemetry service with isolated log path
|
||||
telemetry_log = hermes_home / "telemetry.jsonl"
|
||||
TelemetryService.reset_instance()
|
||||
TelemetryService._instance = TelemetryService(log_path=telemetry_log)
|
||||
UnifiedHealthService._instance = None
|
||||
HubStateStore._instance = None
|
||||
|
||||
yield {
|
||||
"hermes_home": hermes_home,
|
||||
"profiles_yaml": profiles_yaml,
|
||||
"telemetry_log": telemetry_log,
|
||||
}
|
||||
|
||||
|
||||
def test_p0_1_add_account_all_providers_and_assigned_to_role():
|
||||
"""Verify add_account properly creates profile definition, saves auth, and adds to target_role chain."""
|
||||
# 1. Add Grok account
|
||||
res_grok = ActionExecutor.execute("add_account", {
|
||||
"provider": "grok",
|
||||
"profile_id": "grok-1",
|
||||
"api_key": "xai-test-key",
|
||||
"target_role": "coder-primary",
|
||||
})
|
||||
assert res_grok["ok"] is True
|
||||
|
||||
# 2. Add OpenRouter account
|
||||
res_or = ActionExecutor.execute("add_account", {
|
||||
"provider": "openrouter",
|
||||
"profile_id": "openrouter-1",
|
||||
"api_key": "sk-or-test-key",
|
||||
"target_role": "research",
|
||||
})
|
||||
assert res_or["ok"] is True
|
||||
|
||||
# 3. Add Local server account
|
||||
res_loc = ActionExecutor.execute("add_account", {
|
||||
"provider": "local",
|
||||
"profile_id": "local-1",
|
||||
"base_url": "http://127.0.0.1:8081/v1",
|
||||
"target_role": "coder-primary",
|
||||
})
|
||||
assert res_loc["ok"] is True
|
||||
|
||||
cfg = load_router_config()
|
||||
assert "grok-1" in cfg.profiles
|
||||
assert "openrouter-1" in cfg.profiles
|
||||
assert "local-1" in cfg.profiles
|
||||
|
||||
# Check that coder-primary role has grok-1 and local-1 in preferred_chain
|
||||
coder_primary = RoleRegistry.resolve_canonical_role("coder-primary")
|
||||
assert "grok-1" in cfg.roles[coder_primary].preferred_chain
|
||||
assert "local-1" in cfg.roles[coder_primary].preferred_chain
|
||||
|
||||
# Check research role has openrouter-1 in preferred_chain
|
||||
research_role = RoleRegistry.resolve_canonical_role("research")
|
||||
assert "openrouter-1" in cfg.roles[research_role].preferred_chain
|
||||
|
||||
|
||||
def test_p0_1_add_already_authenticated_account():
|
||||
"""Verify that an account already authenticated on disk is not rejected for missing token and is assigned."""
|
||||
# Pre-populate auth data for antigravity profile
|
||||
ProfileAuthManager.save_profile_auth("antigravity", "ag-1", {
|
||||
"provider": "antigravity",
|
||||
"profile_id": "ag-1",
|
||||
"access_token": "oauth-token-123",
|
||||
"refresh_token": "refresh-token-123",
|
||||
"expires_at": 9999999999,
|
||||
"email": "user@gmail.com",
|
||||
})
|
||||
|
||||
# Call add_account without token
|
||||
res = ActionExecutor.execute("add_account", {
|
||||
"provider": "antigravity",
|
||||
"profile_id": "ag-1",
|
||||
"target_role": "manager",
|
||||
})
|
||||
assert res["ok"] is True
|
||||
|
||||
cfg = load_router_config()
|
||||
assert "ag-1" in cfg.profiles
|
||||
manager_role = RoleRegistry.resolve_canonical_role("manager")
|
||||
assert "ag-1" in cfg.roles[manager_role].preferred_chain
|
||||
|
||||
|
||||
def test_p0_1_add_account_spare_unassigned():
|
||||
"""Verify assigning to 'spare' or 'unassigned' removes account from active chains and preserves in profiles."""
|
||||
# First add account to coder-primary
|
||||
ActionExecutor.execute("add_account", {
|
||||
"provider": "openai-codex",
|
||||
"profile_id": "codex-1",
|
||||
"api_key": "sk-codex-key",
|
||||
"target_role": "coder-primary",
|
||||
})
|
||||
coder_role = RoleRegistry.resolve_canonical_role("coder-primary")
|
||||
cfg = load_router_config()
|
||||
assert "codex-1" in cfg.roles[coder_role].preferred_chain
|
||||
|
||||
# Re-assign to spare
|
||||
res_spare = ActionExecutor.execute("add_account", {
|
||||
"provider": "openai-codex",
|
||||
"profile_id": "codex-1",
|
||||
"target_role": "spare",
|
||||
})
|
||||
assert res_spare["ok"] is True
|
||||
|
||||
cfg = load_router_config()
|
||||
assert "codex-1" in cfg.profiles
|
||||
# Must not be in coder_role preferred_chain
|
||||
assert "codex-1" not in cfg.roles[coder_role].preferred_chain
|
||||
|
||||
|
||||
def test_p0_1_preview_auto_assign_is_read_only():
|
||||
"""Verify preview_auto_assign returns distribution plan without modifying router_profiles.yaml on disk."""
|
||||
# Pre-populate auth for 2 accounts
|
||||
ProfileAuthManager.save_profile_auth("openai-codex", "codex-1", {
|
||||
"provider": "openai-codex",
|
||||
"profile_id": "codex-1",
|
||||
"api_key": "key1",
|
||||
})
|
||||
ProfileAuthManager.save_profile_auth("claude", "claude-1", {
|
||||
"provider": "claude",
|
||||
"profile_id": "claude-1",
|
||||
"api_key": "key2",
|
||||
})
|
||||
AutoAssigner.ensure_profile_definition("openai-codex", "codex-1")
|
||||
AutoAssigner.ensure_profile_definition("claude", "claude-1")
|
||||
|
||||
# Capture config on disk before preview
|
||||
before_yaml = load_router_config()
|
||||
coder_role = RoleRegistry.resolve_canonical_role("coder-primary")
|
||||
assert len(before_yaml.roles[coder_role].preferred_chain) == 0
|
||||
|
||||
# Run preview_auto_assign
|
||||
preview_res = ActionExecutor.execute("preview_auto_assign", {})
|
||||
assert preview_res["ok"] is True
|
||||
assert preview_res["data"]["success"] is True
|
||||
assert preview_res["data"]["total_authenticated"] == 2
|
||||
assert len(preview_res["data"]["changes"]) > 0
|
||||
|
||||
# Ensure disk was NOT changed
|
||||
after_yaml = load_router_config()
|
||||
assert len(after_yaml.roles[coder_role].preferred_chain) == 0
|
||||
|
||||
# Now run real auto_assign_all
|
||||
apply_res = ActionExecutor.execute("auto_assign_all", {})
|
||||
assert apply_res["ok"] is True
|
||||
|
||||
# Check that disk WAS changed
|
||||
final_yaml = load_router_config()
|
||||
assert len(final_yaml.roles[coder_role].preferred_chain) > 0
|
||||
|
||||
|
||||
def test_p0_1_set_default_role():
|
||||
"""Verify set_default_role updates default_role in RouterConfig and hub_settings.json."""
|
||||
res = ActionExecutor.execute("set_default_role", {"default_role": "developer-1"})
|
||||
assert res["ok"] is True
|
||||
assert res["data"]["default_role"] == "developer-1"
|
||||
|
||||
cfg = load_router_config()
|
||||
assert cfg.default_role == "developer-1"
|
||||
|
||||
settings = get_hub_settings()
|
||||
assert settings["default_role"] == "developer-1"
|
||||
|
||||
|
||||
def test_p0_2_truthful_role_display_on_profiles():
|
||||
"""Verify assigned_roles is formed strictly from preferred_chain without static fake roles."""
|
||||
# Add profile not in any chain
|
||||
ProfileAuthManager.save_profile_auth("grok", "grok-1", {
|
||||
"provider": "grok",
|
||||
"profile_id": "grok-1",
|
||||
"api_key": "key",
|
||||
})
|
||||
AutoAssigner.ensure_profile_definition("grok", "grok-1")
|
||||
|
||||
# Add profile assigned to coder-primary (#1) and research (#2)
|
||||
ProfileAuthManager.save_profile_auth("openai-codex", "codex-1", {
|
||||
"provider": "openai-codex",
|
||||
"profile_id": "codex-1",
|
||||
"api_key": "key",
|
||||
})
|
||||
AutoAssigner.ensure_profile_definition("openai-codex", "codex-1")
|
||||
|
||||
coder_role = RoleRegistry.resolve_canonical_role("coder-primary")
|
||||
research_role = RoleRegistry.resolve_canonical_role("research")
|
||||
|
||||
cfg = load_router_config()
|
||||
cfg.roles[coder_role].preferred_chain = ["codex-1"]
|
||||
cfg.roles[research_role].preferred_chain = ["other-profile", "codex-1"]
|
||||
save_router_config(cfg)
|
||||
|
||||
profiles_by_prov = UnifiedHealthService.get().scan_all(force=True)
|
||||
|
||||
# grok-1 is unassigned
|
||||
grok_vm = next(p for p in profiles_by_prov["grok"] if p.profile_id == "grok-1")
|
||||
assert grok_vm.assigned_roles == []
|
||||
assert grok_vm.primary_role == "unassigned"
|
||||
|
||||
# codex-1 is primary in coder-primary and backup #2 in research
|
||||
codex_vm = next(p for p in profiles_by_prov["openai-codex"] if p.profile_id == "codex-1")
|
||||
assert len(codex_vm.assigned_roles) == 2
|
||||
assert "Основной, #1" in codex_vm.assigned_roles[0]
|
||||
assert "Запасной, #2" in codex_vm.assigned_roles[1]
|
||||
assert codex_vm.primary_role == codex_vm.assigned_roles[0]
|
||||
|
||||
|
||||
def test_p0_3_effective_answering_profile_and_bypass_reason():
|
||||
"""Verify RolePipeline calculates effective_answering_profile and will_bypass."""
|
||||
# Setup healthy profile
|
||||
ProfileAuthManager.save_profile_auth("claude", "claude-1", {
|
||||
"provider": "claude",
|
||||
"profile_id": "claude-1",
|
||||
"api_key": "key",
|
||||
})
|
||||
AutoAssigner.ensure_profile_definition("claude", "claude-1")
|
||||
cfg = load_router_config()
|
||||
cfg.profiles["claude-1"].preferred_models = ["claude-3-5-sonnet"]
|
||||
|
||||
coder_role = RoleRegistry.resolve_canonical_role("coder-primary")
|
||||
cfg.roles[coder_role].preferred_chain = ["claude-1"]
|
||||
|
||||
research_role = RoleRegistry.resolve_canonical_role("research")
|
||||
cfg.roles[research_role].preferred_chain = [] # Empty chain
|
||||
|
||||
save_router_config(cfg)
|
||||
|
||||
pipelines = UnifiedHealthService.get().get_routing_pipelines()
|
||||
|
||||
# Coder pipeline should have claude-1 as effective answering profile
|
||||
coder_pipe = pipelines[coder_role]
|
||||
assert coder_pipe.will_bypass is False
|
||||
assert coder_pipe.effective_answering_profile == "claude-1"
|
||||
assert coder_pipe.effective_answering_model == "claude-3-5-sonnet"
|
||||
assert coder_pipe.bypass_reason is None
|
||||
|
||||
# Research pipeline (empty chain) should bypass with clear reason
|
||||
research_pipe = pipelines[research_role]
|
||||
assert research_pipe.will_bypass is True
|
||||
assert research_pipe.effective_answering_profile is None
|
||||
assert research_pipe.bypass_reason is not None
|
||||
assert "Цепочка пуста" in research_pipe.bypass_reason
|
||||
|
||||
|
||||
def test_p0_4_telemetry_routed_vs_bypassed_calls():
|
||||
"""Verify TelemetryService records bypass calls and computes routed vs bypass aggregates."""
|
||||
svc = TelemetryService.get()
|
||||
|
||||
# 1. Record 2 successful routed calls
|
||||
svc.record_call(
|
||||
role="manager",
|
||||
profile_id="ag-1",
|
||||
provider="antigravity",
|
||||
model="gemini-2.5-pro",
|
||||
outcome="success",
|
||||
latency_seconds=0.45,
|
||||
)
|
||||
svc.record_call(
|
||||
role="developer-1",
|
||||
profile_id="codex-1",
|
||||
provider="openai-codex",
|
||||
model="gpt-4o",
|
||||
outcome="success",
|
||||
latency_seconds=0.55,
|
||||
)
|
||||
|
||||
# 2. Record 1 bypass call
|
||||
svc.record_bypass(
|
||||
role="researcher",
|
||||
reason="Цепочка пуста — вызов передан штатному Hermes",
|
||||
)
|
||||
|
||||
aggs = svc.get_aggregates(window_seconds=86400)
|
||||
assert aggs.total_calls == 3
|
||||
assert aggs.successful_calls == 2
|
||||
assert aggs.routed_calls_count == 2
|
||||
assert aggs.bypassed_calls_count == 1
|
||||
assert aggs.bypass_rate == round(1 / 3, 4)
|
||||
|
||||
breakdown = svc.get_breakdown(window_seconds=86400)
|
||||
assert breakdown["global"]["routed_calls_count"] == 2
|
||||
assert breakdown["global"]["bypassed_calls_count"] == 1
|
||||
|
||||
|
||||
def test_p0_5_hermes_config_status_read_only(tmp_path):
|
||||
"""Verify get_hermes_config_status safely reads ~/.hermes/config.yaml without writing."""
|
||||
hermes_cfg_file = tmp_path / "config.yaml"
|
||||
hermes_cfg_data = {
|
||||
"model": {
|
||||
"default": "anthropic/claude-3-7-sonnet",
|
||||
"provider": "anthropic",
|
||||
"base_url": "https://api.anthropic.com/v1",
|
||||
}
|
||||
}
|
||||
hermes_cfg_file.write_text(yaml.dump(hermes_cfg_data), encoding="utf-8")
|
||||
|
||||
st = get_hermes_config_status(hermes_cfg_file)
|
||||
assert st["exists"] is True
|
||||
assert st["model"] == "anthropic/claude-3-7-sonnet"
|
||||
assert st["provider"] == "anthropic"
|
||||
assert st["base_url"] == "https://api.anthropic.com/v1"
|
||||
|
||||
# Verify missing config returns exists=False
|
||||
missing_cfg = tmp_path / "non_existent.yaml"
|
||||
st_missing = get_hermes_config_status(missing_cfg)
|
||||
assert st_missing["exists"] is False
|
||||
assert st_missing["model"] is None
|
||||
Loading…
Reference in a new issue