diff --git a/src/antigravity_provider/hermes_plugin.py b/src/antigravity_provider/hermes_plugin.py index ece2aaf..60e75d3 100644 --- a/src/antigravity_provider/hermes_plugin.py +++ b/src/antigravity_provider/hermes_plugin.py @@ -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( diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index 37faa22..c232fbd 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -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': 'Обновление данных'} diff --git a/src/antigravity_provider/router/auto_assigner.py b/src/antigravity_provider/router/auto_assigner.py index 8d02a07..60e398c 100644 --- a/src/antigravity_provider/router/auto_assigner.py +++ b/src/antigravity_provider/router/auto_assigner.py @@ -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) diff --git a/src/antigravity_provider/router/settings_service.py b/src/antigravity_provider/router/settings_service.py index e7f1e41..509b56e 100644 --- a/src/antigravity_provider/router/settings_service.py +++ b/src/antigravity_provider/router/settings_service.py @@ -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), + } + diff --git a/src/antigravity_provider/router/state_store.py b/src/antigravity_provider/router/state_store.py index 2cb1ac4..704c054 100644 --- a/src/antigravity_provider/router/state_store.py +++ b/src/antigravity_provider/router/state_store.py @@ -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 diff --git a/src/antigravity_provider/router/telemetry_service.py b/src/antigravity_provider/router/telemetry_service.py index 5562078..05d7e21 100644 --- a/src/antigravity_provider/router/telemetry_service.py +++ b/src/antigravity_provider/router/telemetry_service.py @@ -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, ) diff --git a/src/antigravity_provider/router/unified_health.py b/src/antigravity_provider/router/unified_health.py index 60de5c6..fcdcdcb 100644 --- a/src/antigravity_provider/router/unified_health.py +++ b/src/antigravity_provider/router/unified_health.py @@ -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 diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index ae65cdf..cdd2018 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -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: ${escapeHtml(hermesCfg.model)} (${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 ? 'Не назначен' : ''; + return `
@@ -776,6 +794,7 @@ function renderAccountCard(profile) {
${plan ? `${escapeHtml(plan)}` : ''} + ${unassignedBadge} ● ${escapeHtml(healthLabel)}
@@ -1191,6 +1210,77 @@ async function handleAddNodeToChain(roleId) { } } +async function openAutoAssignPreviewModal() { + if (elements.modalTitle) elements.modalTitle.textContent = '⚡ Предварительный просмотр авто-распределения'; + elements.modalBody.innerHTML = ''; + elements.modalFooter.innerHTML = ''; + showModal(); + + const res = await executeAction('preview_auto_assign', {}); + if (!res || !res.ok || !res.data || !res.data.success) { + elements.modalBody.innerHTML = ``; + return; + } + + const data = res.data; + const changes = data.changes || []; + if (changes.length === 0) { + elements.modalBody.innerHTML = '
Нет изменений для применения. Все роли уже распределены оптимально.
'; + return; + } + + let tableHtml = ` +
+ Будет распределено ${data.total_authenticated} подключённых аккаунтов по ролям: +
+
+ + + + + + + + + + `; + + changes.forEach((ch) => { + const cur = (ch.current_chain && ch.current_chain.length) ? ch.current_chain.join(' → ') : 'пусто'; + const prop = (ch.proposed_chain && ch.proposed_chain.length) ? ch.proposed_chain.join(' → ') : 'пусто'; + tableHtml += ` + + + + + + `; + }); + + tableHtml += '
РольТекущая цепочкаПредлагаемая цепочка
${escapeHtml(ch.role_name_ru || ch.role)}${cur}${prop}
'; + elements.modalBody.innerHTML = tableHtml; + elements.modalFooter.innerHTML = ` + + + `; +} + +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) { Статус: ${escapeHtml(profile.health_label_ru || 'Работает')}
- Назначенные роли: ${escapeHtml((profile.assigned_roles || []).join(', ') || 'Нет')} + Назначенные роли: ${escapeHtml((profile.assigned_roles && profile.assigned_roles.length > 0) ? profile.assigned_roles.join(', ') : 'Не назначен')}
diff --git a/src/antigravity_provider/router/web/static/index.html b/src/antigravity_provider/router/web/static/index.html index 1c75406..3dc546d 100644 --- a/src/antigravity_provider/router/web/static/index.html +++ b/src/antigravity_provider/router/web/static/index.html @@ -76,6 +76,7 @@ Инициализация... + + +
@@ -441,6 +464,27 @@
+
+
+
Роль по умолчанию (Default Role)
+
Целевая роль при отсутствии явного указания роли в запросе Hermes
+
+
+ +
+
Тема оформления
diff --git a/src/antigravity_provider/router/web/static/style.css b/src/antigravity_provider/router/web/static/style.css index b677047..249a91d 100644 --- a/src/antigravity_provider/router/web/static/style.css +++ b/src/antigravity_provider/router/web/static/style.css @@ -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; } diff --git a/src/antigravity_provider/router/web/static/workspace.js b/src/antigravity_provider/router/web/static/workspace.js index edb881e..03561d5 100644 --- a/src/antigravity_provider/router/web/static/workspace.js +++ b/src/antigravity_provider/router/web/static/workspace.js @@ -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 = `
🟢 Сейчас ответит: ${escapeHtml(respProfile.display_name || pipeline.effective_answering_profile)} (${escapeHtml(respModel)}) — ${prioLabel}
`; + } else { + const reason = pipeline.bypass_reason || 'цепочка пуста — вызов уйдёт мимо хаба в Hermes'; + responderStatusHtml = `
⚠️ Сейчас ответит: никто (${escapeHtml(reason)})
`; + } + 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 ``; }).join(''); - return `

${escapeHtml(agent?.name || pipeline.role_name_ru || roleId)}

${escapeHtml(agent?.description || role?.role_description_ru || '')}

ПриоритетАккаунтМодельКвота · остатокСбросСтатус
${rows || '

Аккаунты не назначены. Добавьте подключённый аккаунт.

'}
+ Перетащите аккаунт в конец маршрута

Session Affinity: ${pipeline.session_affinity ? 'включена' : 'отключена'} · Порядок применяется к следующим назначениям

`; + return `

${escapeHtml(agent?.name || pipeline.role_name_ru || roleId)}

${escapeHtml(agent?.description || role?.role_description_ru || '')}

${responderStatusHtml}
ПриоритетАккаунтМодельКвота · остатокСбросСтатус
${rows || '

Аккаунты не назначены. Добавьте подключённый аккаунт.

'}
+ Перетащите аккаунт в конец маршрута

Session Affinity: ${pipeline.session_affinity ? 'включена' : 'отключена'} · Порядок применяется к следующим назначениям

`; }).join('') || '

Агенты ещё не созданы. Добавьте агента на «Обзоре».

'; 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']], ]; diff --git a/tests/test_a51_hub_controls_hermes.py b/tests/test_a51_hub_controls_hermes.py new file mode 100644 index 0000000..48672e6 --- /dev/null +++ b/tests/test_a51_hub_controls_hermes.py @@ -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 diff --git a/tests/test_memory_freshness_a47.py b/tests/test_memory_freshness_a47.py index 9a22a7e..50d7c41 100644 --- a/tests/test_memory_freshness_a47.py +++ b/tests/test_memory_freshness_a47.py @@ -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):