diff --git a/docs/web-api/CONTRACT.md b/docs/web-api/CONTRACT.md index 542088b..0ac8825 100644 --- a/docs/web-api/CONTRACT.md +++ b/docs/web-api/CONTRACT.md @@ -102,6 +102,7 @@ readiness, agents, providers, routing, quotas, metrics, is_stale | `save_workflow` | Валидировать и сохранить узлы, рёбра, layout и предел итераций | `edges`, `agents`, `max_iterations` | | `start_workflow` | Запустить реальную задачу через RouterEngine | `task` | | `stop_workflow` | Запросить остановку текущего запуска | — | +| `run_preflight` | Запустить zero-quota проверку зависимостей, CLI, Python окружения и локальных серверов | — | Состояние графа и LIVE-журнал приходят в поле `workflow` ответа `GET /api/snapshot`. `workflow.run.status=loading` означает загрузку; @@ -113,10 +114,11 @@ account_details add_account agent_settings apply_update assign_role auto_assign_all check_updates delete_credentials edit_route get_update_status oauth open_routing refresh_account refresh_all refresh_data refresh_models -reorder_chain save_chain save_settings set_main -set_model set_orchestrator test +reorder_chain run_preflight save_chain save_settings +set_main set_model set_orchestrator test ``` + Ответ: ```json diff --git a/src/antigravity_provider/paths.py b/src/antigravity_provider/paths.py index 5088675..e116928 100644 --- a/src/antigravity_provider/paths.py +++ b/src/antigravity_provider/paths.py @@ -95,6 +95,11 @@ def get_workflow_state_path() -> Path: return get_config_dir() / "workflow_state.json" +def get_workflow_run_state_path() -> Path: + """Return the persisted active workflow run state file.""" + return get_config_dir() / "workflow_run_state.json" + + def get_agent_files_dir() -> Path: """Return the user-editable directory containing real Agent Files.""" directory = get_hermes_home() / "agents" diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index d9f2aba..f68fd23 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -630,5 +630,12 @@ class ActionExecutor: status = mgr.get_status_dict() return {'ok': True, 'message': status.get('message') or 'Статус получен', 'data': status} + elif action == 'run_preflight': + from antigravity_provider.router.preflight_service import PreflightCheckService + service = PreflightCheckService.get() + report = service.run_all_checks() + msg = f"Проверка готовности: {report.passed_count} успешно, {report.failed_count} ошибок, {report.warn_count} предупреждений" + return {'ok': report.success, 'message': msg, 'data': report.to_dict()} + else: return {'ok': False, 'message': f'Неизвестное действие: {action}', 'unknown': True} diff --git a/src/antigravity_provider/router/adapters/local_adapter.py b/src/antigravity_provider/router/adapters/local_adapter.py index ad61621..a632cad 100644 --- a/src/antigravity_provider/router/adapters/local_adapter.py +++ b/src/antigravity_provider/router/adapters/local_adapter.py @@ -46,6 +46,79 @@ class LocalLLMAdapter(BaseProviderAdapter): return val return None + _context_window_cache: Dict[str, int] = {} + + def get_context_window( + self, + profile: RouterProfileConfig, + model: Optional[str] = None, + query_remote: bool = False, + ) -> Optional[int]: + """Fetch actual context_window / max_context_length from profile config or /models endpoint. + + Never invents or hardcodes defaults. Returns None if unknown. + """ + # 1. Profile auth_config / custom settings + for key in ("context_window", "context_length", "max_context_length", "max_tokens_limit", "n_ctx"): + if key in profile.auth_config and profile.auth_config[key]: + try: + return int(profile.auth_config[key]) + except (ValueError, TypeError): + pass + + # 2. In-memory cache from previous model discovery + cache_key = f"{profile.profile_id}:{model or 'default'}" + if cache_key in self._context_window_cache: + return self._context_window_cache[cache_key] + if f"{profile.profile_id}:all" in self._context_window_cache: + return self._context_window_cache[f"{profile.profile_id}:all"] + + if not query_remote: + return None + + # 3. Query /models endpoint + base_url = self._resolve_base_url(profile) + api_key = self._resolve_api_key(profile) + headers = {"Accept": "application/json", "User-Agent": "hermes-router/1.0"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + req = urllib.request.Request(f"{base_url}/models", headers=headers, method="GET") + try: + with urllib.request.urlopen(req, timeout=3) as resp: + data = json.loads(resp.read().decode("utf-8", errors="replace")) + items = data.get("data") or data.get("models") or [] + if isinstance(items, list): + for m in items: + if isinstance(m, dict): + m_id = str(m.get("id") or m.get("name") or "") + for ck in ("context_window", "context_length", "max_model_len", "max_context_length", "n_ctx"): + if ck in m and m[ck]: + try: + ctx_val = int(m[ck]) + self._context_window_cache[f"{profile.profile_id}:{m_id}"] = ctx_val + self._context_window_cache[f"{profile.profile_id}:all"] = ctx_val + if not model or m_id == model or model in m_id or m_id in model or len(items) == 1: + return ctx_val + except (ValueError, TypeError): + pass + meta = m.get("meta") or {} + if isinstance(meta, dict): + for ck in ("n_ctx", "context_length", "max_context_length"): + if ck in meta and meta[ck]: + try: + ctx_val = int(meta[ck]) + self._context_window_cache[f"{profile.profile_id}:{m_id}"] = ctx_val + self._context_window_cache[f"{profile.profile_id}:all"] = ctx_val + if not model or m_id == model or model in m_id or m_id in model or len(items) == 1: + return ctx_val + except (ValueError, TypeError): + pass + except Exception as exc: + logger.debug("Failed to query context window from server for %s: %s", profile.profile_id, exc) + + return None + def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]: base_url = self._resolve_base_url(profile) api_key = self._resolve_api_key(profile) @@ -54,9 +127,43 @@ class LocalLLMAdapter(BaseProviderAdapter): if not model or model == "default": model = profile.preferred_models[0] if profile.preferred_models else "default" + messages = list(request.get("messages", [])) + + # Context Truncation Guard: safely bound prompt if context_window is known to prevent VRAM overflow + context_window = self.get_context_window(profile, model, query_remote=False) + if context_window is not None and context_window > 0 and len(messages) > 1: + max_tok = int(request.get("max_tokens", 0) or 0) + token_budget = context_window - max_tok - 64 + if token_budget > 100: + def _est_tok(msgs: list) -> int: + total_chars = sum(len(str(m.get("content", ""))) for m in msgs if isinstance(m, dict)) + return int(total_chars / 3.5) + len(msgs) * 4 + + if _est_tok(messages) > token_budget: + logger.warning( + "Context truncation guard active for %s: prompt exceeds context window (%d). Truncating middle messages.", + profile.profile_id, + context_window, + ) + system_msg = [messages[0]] if messages and messages[0].get("role") == "system" else [] + last_msg = messages[-1] + middle = messages[1:-1] if system_msg else messages[:-1] + + while middle and _est_tok(system_msg + middle + [last_msg]) > token_budget: + middle.pop(0) + + if _est_tok(system_msg + middle + [last_msg]) > token_budget: + avail_chars = max(100, int(token_budget * 3.0)) + last_copy = dict(last_msg) + last_copy["content"] = str(last_copy.get("content", ""))[-avail_chars:] + messages = system_msg + middle + [last_copy] + else: + messages = system_msg + middle + [last_msg] + + payload: Dict[str, Any] = { "model": model, - "messages": request.get("messages", []), + "messages": messages, "temperature": request.get("temperature", 0.7), } if "tools" in request and request["tools"]: @@ -105,6 +212,7 @@ class LocalLLMAdapter(BaseProviderAdapter): return data + @staticmethod def _reject_empty_answer(data: Dict[str, Any]) -> None: """Пустой ответ — это отказ, а не успех. diff --git a/src/antigravity_provider/router/preflight_service.py b/src/antigravity_provider/router/preflight_service.py new file mode 100644 index 0000000..e22c00b --- /dev/null +++ b/src/antigravity_provider/router/preflight_service.py @@ -0,0 +1,394 @@ +"""Hermes Hub — Preflight Dependency & Readiness Check Service (Dependency Agent). + +Performs comprehensive zero-quota preflight validation of local environment, CLI tools, +Python dependencies, local inference endpoints, role chain credentials, and disk permissions. +""" +from __future__ import annotations + +import importlib.util +import logging +import os +import shutil +import urllib.error +import urllib.request +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, List, Optional + +from antigravity_provider import paths +from antigravity_provider.router.profile_manager import ProfileAuthManager +from antigravity_provider.router.router_config import load_router_config + +logger = logging.getLogger("hermes.router.preflight") + + +@dataclass +class PreflightItem: + check_id: str + name: str + status: str # "PASS" | "FAIL" | "WARN" + message: str + remediation: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +@dataclass +class PreflightReport: + success: bool + passed_count: int + failed_count: int + warn_count: int + checks: List[PreflightItem] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return { + "success": self.success, + "passed_count": self.passed_count, + "failed_count": self.failed_count, + "warn_count": self.warn_count, + "checks": [c.to_dict() for c in self.checks], + } + + +class PreflightCheckService: + """Zero-quota dependency and readiness inspection service.""" + + _instance: Optional[PreflightCheckService] = None + + @classmethod + def get(cls) -> PreflightCheckService: + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def check_cli_dependencies(self) -> List[PreflightItem]: + """Check for external CLI executables and critical Python packages.""" + items: List[PreflightItem] = [] + + # 1. Antigravity CLI (agy) + agy_path = shutil.which("agy") or shutil.which("agy.exe") + if agy_path: + items.append( + PreflightItem( + check_id="cli_agy", + name="CLI Antigravity (agy)", + status="PASS", + message=f"Исполняемый файл agy найден: {agy_path}", + ) + ) + else: + items.append( + PreflightItem( + check_id="cli_agy", + name="CLI Antigravity (agy)", + status="WARN", + message="Утилита 'agy' не найдена в системном PATH.", + remediation="Установите agy CLI или добавьте каталог установки в системную переменную PATH.", + ) + ) + + # 2. Python package: fastapi + fastapi_spec = importlib.util.find_spec("fastapi") + if fastapi_spec is not None: + items.append( + PreflightItem( + check_id="pkg_fastapi", + name="Библиотека FastAPI", + status="PASS", + message="Пакет fastapi успешно импортируется в окружении.", + ) + ) + else: + items.append( + PreflightItem( + check_id="pkg_fastapi", + name="Библиотека FastAPI", + status="FAIL", + message="Пакет 'fastapi' не установлен в текущем Python окружении.", + remediation="Выполните 'pip install fastapi' для работы веб-интерфейса и REST API.", + ) + ) + + # 3. Python package: uvicorn + uvicorn_spec = importlib.util.find_spec("uvicorn") + if uvicorn_spec is not None: + items.append( + PreflightItem( + check_id="pkg_uvicorn", + name="Библиотека Uvicorn", + status="PASS", + message="Пакет uvicorn успешно импортируется в окружении.", + ) + ) + else: + items.append( + PreflightItem( + check_id="pkg_uvicorn", + name="Библиотека Uvicorn", + status="FAIL", + message="Пакет 'uvicorn' не установлен в текущем Python окружении.", + remediation="Выполните 'pip install uvicorn' для запуска веб-сервера.", + ) + ) + + return items + + def check_local_servers(self) -> List[PreflightItem]: + """Poll {base_url}/models with 2.0s timeout for active local provider profiles.""" + items: List[PreflightItem] = [] + config = load_router_config() + local_profiles = [p for p in config.profiles.values() if p.provider == "local" and p.enabled] + + if not local_profiles: + items.append( + PreflightItem( + check_id="local_servers_none", + name="Локальные серверы LLM", + status="PASS", + message="Активные локальные профили (llama.cpp/vLLM) не настроены.", + ) + ) + return items + + from antigravity_provider.router.adapters.local_adapter import LocalLLMAdapter + + adapter = LocalLLMAdapter() + + for pcfg in local_profiles: + base_url = adapter._resolve_base_url(pcfg) + api_key = adapter._resolve_api_key(pcfg) + headers = {"Accept": "application/json", "User-Agent": "hermes-preflight/1.0"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + models_url = f"{base_url}/models" + try: + req = urllib.request.Request(models_url, headers=headers, method="GET") + with urllib.request.urlopen(req, timeout=2.0) as resp: + if resp.status in (200, 204): + items.append( + PreflightItem( + check_id=f"local_srv_{pcfg.profile_id}", + name=f"Локальный сервер {pcfg.profile_id} ({base_url})", + status="PASS", + message=f"Локальный сервер доступен (HTTP {resp.status}).", + ) + ) + else: + items.append( + PreflightItem( + check_id=f"local_srv_{pcfg.profile_id}", + name=f"Локальный сервер {pcfg.profile_id} ({base_url})", + status="FAIL", + message=f"Сервер вернул неожиданный статус HTTP {resp.status}", + remediation=f"Проверьте настройки и логи сервера {base_url}.", + ) + ) + except urllib.error.HTTPError as http_err: + items.append( + PreflightItem( + check_id=f"local_srv_{pcfg.profile_id}", + name=f"Локальный сервер {pcfg.profile_id} ({base_url})", + status="FAIL", + message=f"HTTP ошибка при обращении к {models_url}: {http_err.code} {http_err.reason}", + remediation=f"Убедитесь, что сервер на {base_url} поддерживает OpenAI-совместимый эндпоинт /v1/models.", + ) + ) + except Exception as exc: + items.append( + PreflightItem( + check_id=f"local_srv_{pcfg.profile_id}", + name=f"Локальный сервер {pcfg.profile_id} ({base_url})", + status="FAIL", + message=f"Не удалось подключиться к {base_url}: {exc}", + remediation=f"Запустите локальный сервер llama.cpp / vLLM / Ollama по адресу {base_url}.", + ) + ) + + return items + + def check_auth_credentials(self) -> List[PreflightItem]: + """Verify credential presence for all profiles referenced in active role chains. + + ZERO QUOTA BURN: Only inspects local auth files and keyring status. Never calls paid APIs. + """ + items: List[PreflightItem] = [] + config = load_router_config() + + # Collect all profile IDs in active role chains + referenced_pids: set[str] = set() + for role_policy in config.roles.values(): + for pid in role_policy.preferred_chain: + referenced_pids.add(pid) + + if not referenced_pids: + items.append( + PreflightItem( + check_id="auth_chains_empty", + name="Учетные данные цепочек ролей", + status="WARN", + message="В активных ролях не настроены цепочки профилей.", + remediation="Настройте цепочки профилей в разделе Маршрутизация.", + ) + ) + return items + + for pid in sorted(referenced_pids): + pcfg = config.get_profile(pid) + if not pcfg: + items.append( + PreflightItem( + check_id=f"auth_{pid}", + name=f"Профиль {pid}", + status="FAIL", + message=f"Профиль '{pid}' указан в цепочке роли, но отсутствует в конфигурации.", + remediation=f"Удалите '{pid}' из цепочки роли или настройте профиль в router_profiles.yaml.", + ) + ) + continue + + status = ProfileAuthManager.get_profile_status(pcfg.provider, pid) + is_authenticated = status.get("authenticated", False) + is_expired = status.get("is_expired", False) or status.get("expired", False) or status.get("status") == "EXPIRED" + + if is_authenticated and not is_expired: + items.append( + PreflightItem( + check_id=f"auth_{pid}", + name=f"Авторизация {pid} ({pcfg.provider})", + status="PASS", + message="Учетные данные действительны и сохранены локально.", + ) + ) + elif is_expired: + items.append( + PreflightItem( + check_id=f"auth_{pid}", + name=f"Авторизация {pid} ({pcfg.provider})", + status="FAIL", + message=f"Срок действия авторизации для профиля '{pid}' истек.", + remediation=f"Выполните повторный вход для профиля {pid} в разделе Аккаунты.", + ) + ) + else: + items.append( + PreflightItem( + check_id=f"auth_{pid}", + name=f"Авторизация {pid} ({pcfg.provider})", + status="FAIL", + message=f"Учетные данные для профиля '{pid}' ({pcfg.provider}) не найдены.", + remediation=f"Подключите профиль {pid} через кнопку 'Добавить аккаунт' или 'hermes router login'.", + ) + ) + + return items + + def check_system_environment(self) -> List[PreflightItem]: + """Verify HERMES_HOME presence and read/write permissions for config and logs.""" + items: List[PreflightItem] = [] + + # 1. HERMES_HOME directory + try: + home_dir = paths.get_hermes_home() + if home_dir.is_dir(): + items.append( + PreflightItem( + check_id="env_hermes_home", + name="Каталог HERMES_HOME", + status="PASS", + message=f"Каталог существует: {home_dir}", + ) + ) + else: + items.append( + PreflightItem( + check_id="env_hermes_home", + name="Каталог HERMES_HOME", + status="FAIL", + message=f"Каталог {home_dir} не существует или не является директорией.", + remediation="Проверьте права доступа и создайте каталог HERMES_HOME.", + ) + ) + except Exception as exc: + items.append( + PreflightItem( + check_id="env_hermes_home", + name="Каталог HERMES_HOME", + status="FAIL", + message=f"Ошибка доступа к HERMES_HOME: {exc}", + remediation="Убедитесь, что переменная HERMES_HOME указывает на корректный доступный путь.", + ) + ) + + # 2. Config Directory Write Test + try: + config_dir = paths.get_config_dir() + test_file = config_dir / f".preflight_probe_{os.getpid()}.tmp" + test_file.write_text("probe", encoding="utf-8") + test_file.unlink() + items.append( + PreflightItem( + check_id="env_config_writable", + name="Права на запись в каталог конфигурации", + status="PASS", + message=f"Права на запись в {config_dir} подтверждены.", + ) + ) + except Exception as exc: + items.append( + PreflightItem( + check_id="env_config_writable", + name="Права на запись в каталог конфигурации", + status="FAIL", + message=f"Нет прав на запись в {paths.get_config_dir()}: {exc}", + remediation="Предоставьте текущему пользователю права на запись в каталог конфигурации.", + ) + ) + + # 3. Logs Directory Write Test + try: + logs_dir = paths.get_logs_dir() + test_file = logs_dir / f".preflight_probe_{os.getpid()}.tmp" + test_file.write_text("probe", encoding="utf-8") + test_file.unlink() + items.append( + PreflightItem( + check_id="env_logs_writable", + name="Права на запись в каталог логов", + status="PASS", + message=f"Права на запись в {logs_dir} подтверждены.", + ) + ) + except Exception as exc: + items.append( + PreflightItem( + check_id="env_logs_writable", + name="Права на запись в каталог логов", + status="FAIL", + message=f"Нет прав на запись в {paths.get_logs_dir()}: {exc}", + remediation="Предоставьте текущему пользователю права на запись в каталог логов.", + ) + ) + + return items + + def run_all_checks(self) -> PreflightReport: + """Run all readiness checks and return aggregated PreflightReport.""" + all_items: List[PreflightItem] = [] + all_items.extend(self.check_cli_dependencies()) + all_items.extend(self.check_system_environment()) + all_items.extend(self.check_auth_credentials()) + all_items.extend(self.check_local_servers()) + + passed = sum(1 for item in all_items if item.status == "PASS") + failed = sum(1 for item in all_items if item.status == "FAIL") + warn = sum(1 for item in all_items if item.status == "WARN") + + return PreflightReport( + success=(failed == 0), + passed_count=passed, + failed_count=failed, + warn_count=warn, + checks=all_items, + ) diff --git a/src/antigravity_provider/router/role_registry.py b/src/antigravity_provider/router/role_registry.py index e133560..fb749b6 100644 --- a/src/antigravity_provider/router/role_registry.py +++ b/src/antigravity_provider/router/role_registry.py @@ -178,6 +178,18 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = { max_failover_attempts=3, tier="expert", ), + "dependency-agent": RoleDefinition( + role_id="dependency-agent", + display_name_ru="Проверяющий готовность", + short_name_ru="Готовность", + description_ru="До начала задачи убеждается, что на месте всё необходимое — исполняемые файлы и CLI, библиотеки, учётные данные, права доступа, доступность локальных серверов. Сообщает о нехватке до запуска.", + is_implemented=True, + capabilities=["dependency-agent", "preflight", "environment", "system_checks", "fast"], + fallback_capabilities=["dependency-agent", "preflight"], + default_preferred_chain=["opengo-1", "ag-w1", "codex-worker-1"], + max_failover_attempts=3, + tier="qa_doc", + ), } _CANONICAL_ROLE_ALIASES: Dict[str, str] = { @@ -215,6 +227,13 @@ _CANONICAL_ROLE_ALIASES: Dict[str, str] = { "специалист по интеграции": "integration-expert", "безопасность": "security-expert", "специалист по безопасности": "security-expert", + "dependency-agent": "dependency-agent", + "dependency_agent": "dependency-agent", + "preflight": "dependency-agent", + "проверяющий готовность": "dependency-agent", + "агент зависимостей": "dependency-agent", + "готовность": "dependency-agent", + "dependency": "dependency-agent", } class RoleRegistry: @@ -272,6 +291,10 @@ class RoleRegistry: clean = name_or_alias.strip().lower() return _CANONICAL_ROLE_ALIASES.get(clean, clean) + @classmethod + def resolve_role_name(cls, name_or_alias: str) -> str: + return cls.resolve_canonical_role(name_or_alias) + @classmethod def get_canonical_role_map(cls) -> Dict[str, str]: return dict(_CANONICAL_ROLE_ALIASES) diff --git a/src/antigravity_provider/router/router_config.py b/src/antigravity_provider/router/router_config.py index 5b76a5f..465bfe0 100644 --- a/src/antigravity_provider/router/router_config.py +++ b/src/antigravity_provider/router/router_config.py @@ -303,16 +303,20 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig: profiles_raw = data.get("profiles", {}) profiles: dict[str, RouterProfileConfig] = {} for pid, pdata in profiles_raw.items(): + provider = pdata.get("provider", "antigravity") + max_concurrency = int(pdata.get("max_concurrency", 1)) + if provider == "local": + max_concurrency = 1 profiles[pid] = RouterProfileConfig( profile_id=pid, - provider=pdata.get("provider", "antigravity"), + provider=provider, account_id=pdata.get("account_id", pid), capabilities=list(pdata.get("capabilities", [])), preferred_models=list(pdata.get("preferred_models", [])), fallback_models=list(pdata.get("fallback_models", [])), auth_config=dict(pdata.get("auth_config", {})), enabled=bool(pdata.get("enabled", True)), - max_concurrency=int(pdata.get("max_concurrency", 1)), + max_concurrency=max_concurrency, custom_base_url=pdata.get("custom_base_url"), ) diff --git a/src/antigravity_provider/router/settings_service.py b/src/antigravity_provider/router/settings_service.py index 0e7182e..1fe66e3 100644 --- a/src/antigravity_provider/router/settings_service.py +++ b/src/antigravity_provider/router/settings_service.py @@ -22,9 +22,11 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "monitoring_interval_seconds": 30, "quota_threshold_percent": 10.0, "quota_threshold_action": "notify", + "email_masking_mode": "none", } + _SETTINGS_CACHE: Dict[str, Any] | None = None _SETTINGS_CACHE_MTIME: float = -1.0 _SETTINGS_CACHE_PATH: str = "" @@ -98,6 +100,11 @@ def get_hub_settings() -> Dict[str, Any]: action = "notify" merged["quota_threshold_action"] = action + email_mode = str(merged.get("email_masking_mode", "none")).strip().lower() + if email_mode not in ("none", "partial", "full"): + email_mode = "none" + merged["email_masking_mode"] = email_mode + _SETTINGS_CACHE = dict(merged) _SETTINGS_CACHE_MTIME = current_mtime _SETTINGS_CACHE_PATH = sfile_str diff --git a/src/antigravity_provider/router/telemetry_service.py b/src/antigravity_provider/router/telemetry_service.py index 838c720..5562078 100644 --- a/src/antigravity_provider/router/telemetry_service.py +++ b/src/antigravity_provider/router/telemetry_service.py @@ -31,6 +31,15 @@ MAX_FILE_BYTES = 5 * 1024 * 1024 # 5 MB MAX_BACKUP_FILES = 3 +def format_token_count(measured: Optional[int], estimated: Optional[int]) -> Optional[str]: + """Format token count distinguishing measured exact counts from estimations (~).""" + if measured is not None: + return str(measured) + elif estimated is not None: + return f"~{estimated}" + return None + + @dataclass class TelemetryRecord: """Immutable record of an individual router invocation attempt.""" @@ -45,6 +54,13 @@ class TelemetryRecord: prompt_tokens: Optional[int] = None completion_tokens: Optional[int] = None total_tokens: Optional[int] = None + prompt_tokens_measured: Optional[int] = None + prompt_tokens_estimated: Optional[int] = None + completion_tokens_measured: Optional[int] = None + completion_tokens_estimated: Optional[int] = None + total_tokens_measured: Optional[int] = None + total_tokens_estimated: Optional[int] = None + is_estimated: bool = False cost_usd: Optional[float] = None failover_count: int = 0 error_category: Optional[str] = None @@ -69,6 +85,14 @@ class TelemetryAggregates: total_prompt_tokens: Optional[int] = None # Sum of reported prompt tokens or None if no token data total_completion_tokens: Optional[int] = None total_tokens: Optional[int] = None + total_prompt_tokens_measured: Optional[int] = None + total_prompt_tokens_estimated: Optional[int] = None + total_completion_tokens_measured: Optional[int] = None + total_completion_tokens_estimated: Optional[int] = None + total_tokens_measured: Optional[int] = None + total_tokens_estimated: Optional[int] = None + tokens_display: Optional[str] = None + has_estimated_tokens: bool = False 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) @@ -79,6 +103,7 @@ class TelemetryAggregates: return asdict(self) + class TelemetryService: """Thread-safe persistent telemetry manager with bounded storage and honest aggregation.""" @@ -176,16 +201,42 @@ class TelemetryService: total_tokens: Optional[int] = None, failover_count: int = 0, error_category: Optional[str] = None, + prompt_tokens_measured: Optional[int] = None, + prompt_tokens_estimated: Optional[int] = None, + completion_tokens_measured: Optional[int] = None, + completion_tokens_estimated: Optional[int] = None, + total_tokens_measured: Optional[int] = None, + total_tokens_estimated: Optional[int] = None, + is_estimated: bool = False, ) -> TelemetryRecord: """Record an invocation attempt into memory and rotated log.""" now = time.time() iso = datetime.datetime.fromtimestamp(now, datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - # Derive total tokens if prompt/completion available - if total_tokens is None and prompt_tokens is not None and completion_tokens is not None: - total_tokens = prompt_tokens + completion_tokens + if is_estimated: + p_est = prompt_tokens_estimated if prompt_tokens_estimated is not None else prompt_tokens + c_est = completion_tokens_estimated if completion_tokens_estimated is not None else completion_tokens + t_est = total_tokens_estimated if total_tokens_estimated is not None else total_tokens + if t_est is None and p_est is not None and c_est is not None: + t_est = p_est + c_est + p_meas, c_meas, t_meas = None, None, None + p_tok, c_tok, t_tok = p_est, c_est, t_est + else: + p_meas = prompt_tokens_measured if prompt_tokens_measured is not None else prompt_tokens + c_meas = completion_tokens_measured if completion_tokens_measured is not None else completion_tokens + t_meas = total_tokens_measured if total_tokens_measured is not None else total_tokens + if t_meas is None and p_meas is not None and c_meas is not None: + t_meas = p_meas + c_meas + p_est = prompt_tokens_estimated + c_est = completion_tokens_estimated + t_est = total_tokens_estimated + if t_est is None and p_est is not None and c_est is not None: + t_est = p_est + c_est + p_tok = p_meas if p_meas is not None else p_est + c_tok = c_meas if c_meas is not None else c_est + t_tok = t_meas if t_meas is not None else t_est - cost_usd = self.compute_cost(model, prompt_tokens, completion_tokens) + cost_usd = self.compute_cost(model, p_tok, c_tok) record = TelemetryRecord( timestamp=now, @@ -196,9 +247,16 @@ class TelemetryService: model=model, outcome=outcome, latency_seconds=round(max(0.0, float(latency_seconds)), 4), - prompt_tokens=prompt_tokens, - completion_tokens=completion_tokens, - total_tokens=total_tokens, + prompt_tokens=p_tok, + completion_tokens=c_tok, + total_tokens=t_tok, + prompt_tokens_measured=p_meas, + prompt_tokens_estimated=p_est, + completion_tokens_measured=c_meas, + completion_tokens_estimated=c_est, + total_tokens_measured=t_meas, + total_tokens_estimated=t_est, + is_estimated=is_estimated or (p_est is not None and p_meas is None), cost_usd=cost_usd, failover_count=failover_count, error_category=error_category, @@ -268,6 +326,13 @@ class TelemetryService: prompt_tokens=d.get("prompt_tokens"), completion_tokens=d.get("completion_tokens"), total_tokens=d.get("total_tokens"), + prompt_tokens_measured=d.get("prompt_tokens_measured"), + prompt_tokens_estimated=d.get("prompt_tokens_estimated"), + completion_tokens_measured=d.get("completion_tokens_measured"), + completion_tokens_estimated=d.get("completion_tokens_estimated"), + total_tokens_measured=d.get("total_tokens_measured"), + total_tokens_estimated=d.get("total_tokens_estimated"), + is_estimated=bool(d.get("is_estimated", False)), cost_usd=d.get("cost_usd"), failover_count=int(d.get("failover_count", 0)), error_category=d.get("error_category"), @@ -318,6 +383,14 @@ class TelemetryService: total_prompt_tokens=None, total_completion_tokens=None, total_tokens=None, + total_prompt_tokens_measured=None, + total_prompt_tokens_estimated=None, + total_completion_tokens_measured=None, + total_completion_tokens_estimated=None, + total_tokens_measured=None, + total_tokens_estimated=None, + tokens_display=None, + has_estimated_tokens=False, total_cost_usd=None, failovers_count=0, failover_reasons={}, @@ -333,6 +406,15 @@ class TelemetryService: prompt_tokens_sum = 0 completion_tokens_sum = 0 has_any_token_data = False + prompt_meas_sum = 0 + prompt_est_sum = 0 + has_meas_prompt = False + has_est_prompt = False + comp_meas_sum = 0 + comp_est_sum = 0 + has_meas_comp = False + has_est_comp = False + has_estimated_tokens = False costs_sum = 0.0 has_any_cost_data = False failovers_count = 0 @@ -358,6 +440,41 @@ class TelemetryService: completion_tokens_sum += r.completion_tokens has_any_token_data = True + if r.prompt_tokens_measured is not None: + prompt_meas_sum += r.prompt_tokens_measured + has_meas_prompt = True + elif r.prompt_tokens is not None and not r.is_estimated: + prompt_meas_sum += r.prompt_tokens + has_meas_prompt = True + + if r.prompt_tokens_estimated is not None: + prompt_est_sum += r.prompt_tokens_estimated + has_est_prompt = True + has_estimated_tokens = True + elif r.prompt_tokens is not None and r.is_estimated: + prompt_est_sum += r.prompt_tokens + has_est_prompt = True + has_estimated_tokens = True + + if r.completion_tokens_measured is not None: + comp_meas_sum += r.completion_tokens_measured + has_meas_comp = True + elif r.completion_tokens is not None and not r.is_estimated: + comp_meas_sum += r.completion_tokens + has_meas_comp = True + + if r.completion_tokens_estimated is not None: + comp_est_sum += r.completion_tokens_estimated + has_est_comp = True + has_estimated_tokens = True + elif r.completion_tokens is not None and r.is_estimated: + comp_est_sum += r.completion_tokens + has_est_comp = True + has_estimated_tokens = True + + if r.is_estimated: + has_estimated_tokens = True + if r.cost_usd is not None: costs_sum += r.cost_usd has_any_cost_data = True @@ -369,6 +486,9 @@ class TelemetryService: error_rate = round(failed_calls / 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 + tokens_display = format_token_count(tot_meas_sum, tot_est_sum) return TelemetryAggregates( window_seconds=window_seconds, @@ -383,6 +503,14 @@ class TelemetryService: total_prompt_tokens=prompt_tokens_sum if has_any_token_data else None, total_completion_tokens=completion_tokens_sum if has_any_token_data else None, total_tokens=total_tokens_sum, + total_prompt_tokens_measured=prompt_meas_sum if has_meas_prompt else None, + total_prompt_tokens_estimated=prompt_est_sum if has_est_prompt else None, + total_completion_tokens_measured=comp_meas_sum if has_meas_comp else None, + total_completion_tokens_estimated=comp_est_sum if has_est_comp else None, + total_tokens_measured=tot_meas_sum, + total_tokens_estimated=tot_est_sum, + tokens_display=tokens_display, + has_estimated_tokens=has_estimated_tokens, total_cost_usd=round(costs_sum, 4) if has_any_cost_data else None, failovers_count=failovers_count, failover_reasons=dict(failover_reasons), @@ -390,6 +518,7 @@ class TelemetryService: has_data=True, ) + def get_breakdown( self, window_seconds: Optional[int] = 86400, diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py index 5d7e7a4..69569f6 100644 --- a/src/antigravity_provider/router/web/server.py +++ b/src/antigravity_provider/router/web/server.py @@ -125,8 +125,17 @@ def health_check(): } } -def sanitize_snapshot(snap_dict: Any) -> Any: +def sanitize_snapshot(snap_dict: Any, email_masking_mode: Optional[str] = None) -> Any: import re + if email_masking_mode is None: + try: + from antigravity_provider.router.settings_service import get_hub_settings + email_masking_mode = get_hub_settings().get("email_masking_mode", "none") + except Exception: + email_masking_mode = "none" + + mode = str(email_masking_mode or "none").strip().lower() + secret_patterns = [ re.compile(r'((?:access_token|refresh_token|api_key|token|password|secret|key)=)([^\s&,"]+)', re.IGNORECASE), re.compile(r'(sk-[a-zA-Z0-9_\-]{8,})'), @@ -134,6 +143,23 @@ def sanitize_snapshot(snap_dict: Any) -> Any: re.compile(r'(Bearer\s+)([a-zA-Z0-9_\-\.]{8,})', re.IGNORECASE), ] + email_pattern = re.compile(r'\b([A-Za-z0-9._%+-]+)@([A-Za-z0-9.-]+\.[A-Za-z]{2,})\b') + + def _mask_email_match(match: re.Match) -> str: + local_part = match.group(1) + domain_part = match.group(2) + if mode == "full": + return "***@***.***" + elif mode == "partial": + if len(local_part) > 2: + masked = f"{local_part[0]}***{local_part[-1]}" + elif local_part: + masked = f"{local_part[0]}***" + else: + masked = "***" + return f"{masked}@{domain_part}" + return match.group(0) + def _mask_str(val: str) -> str: res = val for pat in secret_patterns: @@ -141,6 +167,8 @@ def sanitize_snapshot(snap_dict: Any) -> Any: res = pat.sub(r'\g<1>***', res) elif pat.groups == 1: res = pat.sub(r'***', res) + if mode in ("partial", "full"): + res = email_pattern.sub(_mask_email_match, res) return res def _sanitize(node): @@ -156,6 +184,7 @@ def sanitize_snapshot(snap_dict: Any) -> Any: return node return _sanitize(snap_dict) + @app.get("/api/snapshot") def get_snapshot(authorized: bool = Depends(get_auth_token)): snapshot = HubStateStore.get().get_snapshot() diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index c699e18..1c4958b 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -182,8 +182,15 @@ function initEventListeners() { if (btnApplyUpdate) { btnApplyUpdate.addEventListener('click', () => applyUpdate()); } + + // Preflight check listener + const btnPreflight = document.getElementById('btn-run-preflight'); + if (btnPreflight) { + btnPreflight.addEventListener('click', () => runPreflightChecks()); + } } + // ── SNAPSHOT INGESTION & MONOTONIC SEQ ── async function fetchSnapshot() { const urlParams = new URLSearchParams(window.location.search); @@ -1313,6 +1320,7 @@ function renderSettingsView() { const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent'); const quotaActionSel = document.getElementById('setting-quota-threshold-action'); + const emailMaskingSel = document.getElementById('setting-email-masking-mode'); const monitorIntervalInput = document.getElementById('setting-monitoring-interval'); if (quotaThresholdSel && s.quota_threshold_percent !== undefined) { @@ -1321,6 +1329,9 @@ function renderSettingsView() { if (quotaActionSel && s.quota_threshold_action) { quotaActionSel.value = s.quota_threshold_action; } + if (emailMaskingSel && s.email_masking_mode) { + emailMaskingSel.value = s.email_masking_mode; + } if (monitorIntervalInput && s.monitoring_interval_seconds !== undefined) { monitorIntervalInput.value = s.monitoring_interval_seconds; } @@ -1329,11 +1340,13 @@ function renderSettingsView() { async function saveHubServerSettings() { const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent'); const quotaActionSel = document.getElementById('setting-quota-threshold-action'); + const emailMaskingSel = document.getElementById('setting-email-masking-mode'); const monitorIntervalInput = document.getElementById('setting-monitoring-interval'); const newSettings = { quota_threshold_percent: quotaThresholdSel ? parseFloat(quotaThresholdSel.value) || 10.0 : 10.0, quota_threshold_action: quotaActionSel ? quotaActionSel.value : 'notify', + email_masking_mode: emailMaskingSel ? emailMaskingSel.value : 'none', monitoring_interval_seconds: monitorIntervalInput ? parseInt(monitorIntervalInput.value, 10) || 30 : 30, }; @@ -1347,6 +1360,73 @@ async function saveHubServerSettings() { } } +// ── PREFLIGHT READINESS CHECKS ── +async function runPreflightChecks() { + const container = document.getElementById('preflight-results-container'); + const btn = document.getElementById('btn-run-preflight'); + if (btn) btn.disabled = true; + if (container) { + container.innerHTML = '