From 3ed85e79eb52b0ee4accdf4b013d16b6fbbcd431 Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:53:55 +0700 Subject: [PATCH] feat(a51): hub controls hermes - full real routing, bypass telemetry and truthful status --- src/antigravity_provider/hermes_plugin.py | 33 ++ .../router/action_handler.py | 138 +++++-- .../router/auto_assigner.py | 361 +++++++++-------- .../router/settings_service.py | 58 ++- .../router/state_store.py | 11 + .../router/telemetry_service.py | 42 +- .../router/unified_health.py | 47 ++- .../router/web/static/app.js | 107 ++++- .../router/web/static/index.html | 44 +++ .../router/web/static/style.css | 40 ++ .../router/web/static/workspace.js | 31 +- tests/test_a51_hub_controls_hermes.py | 367 ++++++++++++++++++ tests/test_memory_freshness_a47.py | 4 +- 13 files changed, 1075 insertions(+), 208 deletions(-) create mode 100644 tests/test_a51_hub_controls_hermes.py 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 `
| Роль | +Текущая цепочка | +Предлагаемая цепочка | +
|---|---|---|
| ${escapeHtml(ch.role_name_ru || ch.role)} | +${cur} | +${prop} | +