feat(a51): hub controls hermes - full real routing, bypass telemetry and truthful status

This commit is contained in:
ochenstarik-ui 2026-08-31 18:53:55 +07:00
parent 17b368a155
commit 3ed85e79eb
13 changed files with 1075 additions and 208 deletions

View file

@ -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(

View file

@ -656,11 +656,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',
@ -675,50 +695,58 @@ 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) or f'{prov_norm}-1'
ok, def_msg = AutoAssigner.ensure_profile_definition(prov_norm, slot)
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)
AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False)
_rescan_after_auth()
return {'ok': True, 'message': f'Аккаунт {prov_norm} ({slot}) успешно подключен'}
except Exception as e:
return {'ok': False, 'message': f'Ошибка при сохранении учетных данных {slot}: {e}'}
AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False)
_rescan_after_auth()
return {'ok': True, 'message': f'Аккаунт {prov_norm} ({slot}) успешно подключен'}
# Чисто навигационные действия. edit_route и assign_role сюда НЕ входят:
# A25 внёс их в этот список, но в A24 они выполняют настоящую работу —
@ -920,13 +948,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': 'Обновление данных'}

View file

@ -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": ("Локальный сервер 1", "coder", "primary"),
"local-2": ("Локальный сервер 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]:
@ -330,6 +292,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)."""
@ -359,15 +503,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": "Нет подключённых аккаунтов для распределения",
@ -376,107 +513,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)
@ -490,7 +530,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:
@ -498,9 +538,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
@ -650,5 +691,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)

View file

@ -6,7 +6,7 @@ from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, Optional
from antigravity_provider.paths import get_hermes_home
@ -128,3 +128,59 @@ def save_hub_settings(settings: Dict[str, Any]) -> bool:
return True
except Exception:
return False
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),
}

View file

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

View file

@ -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,
)

View file

@ -156,6 +156,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
@ -349,8 +353,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")
@ -526,14 +531,17 @@ class UnifiedHealthService:
last_success_str = datetime.datetime.fromtimestamp(precord.last_success).strftime("%H:%M:%S") if precord.last_success else None
assigned = role_assignments.get(pid, [])
primary_r = assigned[0] if assigned else "unassigned"
vm = ProfileViewModel(
profile_id=pid,
display_name=display_name,
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,
@ -894,13 +902,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:
@ -950,6 +981,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

View file

@ -555,6 +555,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');
@ -724,7 +739,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 || 'Н/Д: состояние не проверялось';
@ -768,6 +784,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">
@ -776,6 +794,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>
@ -1191,6 +1210,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;
@ -1209,10 +1299,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 ч';
@ -1455,6 +1547,7 @@ function renderSettingsView() {
const quotaActionSel = document.getElementById('setting-quota-threshold-action');
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
const defaultRoleSel = document.getElementById('setting-default-role');
if (quotaThresholdSel && s.quota_threshold_percent !== undefined) {
quotaThresholdSel.value = String(Math.round(s.quota_threshold_percent));
@ -1468,6 +1561,10 @@ function renderSettingsView() {
if (monitorIntervalInput && s.monitoring_interval_seconds !== undefined) {
monitorIntervalInput.value = s.monitoring_interval_seconds;
}
if (defaultRoleSel) {
const currentDef = s.default_role || currentSnapshot.metrics?.default_role || 'manager';
defaultRoleSel.value = currentDef;
}
}
async function saveHubServerSettings() {
@ -1475,12 +1572,14 @@ async function saveHubServerSettings() {
const quotaActionSel = document.getElementById('setting-quota-threshold-action');
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
const defaultRoleSel = document.getElementById('setting-default-role');
const newSettings = {};
if (quotaThresholdSel?.value) newSettings.quota_threshold_percent = Number(quotaThresholdSel.value);
if (quotaActionSel?.value) newSettings.quota_threshold_action = quotaActionSel.value;
if (emailMaskingSel?.value) newSettings.email_masking_mode = emailMaskingSel.value;
if (monitorIntervalInput?.value) newSettings.monitoring_interval_seconds = Number(monitorIntervalInput.value);
if (defaultRoleSel?.value) newSettings.default_role = defaultRoleSel.value;
if (!Object.keys(newSettings).length) { showToast('Нет выбранных изменений', 'info'); return; }
showToast('Сохранение настроек сервера...', 'info');
@ -1748,7 +1847,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>

View file

@ -76,6 +76,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>
@ -196,6 +197,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>
@ -441,6 +464,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>

View file

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

View file

@ -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><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-quota-interval','setting-quota-threshold-percent','setting-quota-threshold-action']],
['Безопасность и API',['setting-server-host','setting-server-token-input','setting-email-masking-mode']],
];

View 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

View file

@ -25,6 +25,7 @@ def test_check_memory_freshness_real_repo():
canonical_memory = Path("/srv/projects/AI-Memory/01_PROJECTS/hermes-hub/CURRENT_STATE.md")
if canonical_memory.exists():
recorded = extract_recorded_commit(canonical_memory.read_text(encoding="utf-8"))
is_fresh, summary = check_memory_freshness(
repo_path=REPO_ROOT,
memory_file=canonical_memory,
@ -32,7 +33,8 @@ def test_check_memory_freshness_real_repo():
)
assert is_fresh is True
assert "FRESH" in summary
assert "80aab00" in summary
if recorded:
assert recorded in summary
def test_check_memory_freshness_missing_file_strict_vs_non_strict(tmp_path):