diff --git a/scripts/release_gate.py b/scripts/release_gate.py index 0ba4261..12d14be 100644 --- a/scripts/release_gate.py +++ b/scripts/release_gate.py @@ -45,37 +45,34 @@ def check_version_consistency() -> tuple[bool, str]: return True, f"Version {ver} is consistent across all manifests" -def check_p0_release_gate() -> tuple[bool, str]: - res = subprocess.run( - [sys.executable, "-m", "pytest", "-v", "tests/test_p0_release_gate.py"], +def _run_pytest(args: list[str]) -> subprocess.CompletedProcess: + env = dict(os.environ) + env["PYTHONPATH"] = str(ROOT / "src") + return subprocess.run( + [sys.executable, "-m", "pytest"] + args, cwd=str(ROOT), + env=env, capture_output=True, text=True, ) + + +def check_p0_release_gate() -> tuple[bool, str]: + res = _run_pytest(["-v", "tests/test_p0_release_gate.py"]) if res.returncode != 0: return False, f"P0 tests failed:\n{res.stdout}\n{res.stderr}" - return True, "9/9 P0 release blockers verified" + return True, "12/12 P0 release blockers & regression checks verified" def check_updater_and_rollback() -> tuple[bool, str]: - res = subprocess.run( - [sys.executable, "-m", "pytest", "-v", "tests/test_updater.py"], - cwd=str(ROOT), - capture_output=True, - text=True, - ) + res = _run_pytest(["-v", "tests/test_updater.py"]) if res.returncode != 0: return False, f"Updater tests failed:\n{res.stdout}\n{res.stderr}" return True, "Auto-updater, SHA-256 verification, and rollback verified" def check_full_test_suite() -> tuple[bool, str]: - res = subprocess.run( - [sys.executable, "-m", "pytest", "-v"], - cwd=str(ROOT), - capture_output=True, - text=True, - ) + res = _run_pytest(["-v"]) if res.returncode != 0: return False, f"Offline pytest suite failed:\n{res.stdout}\n{res.stderr}" return True, "All unit and integration tests passed offline" @@ -102,15 +99,24 @@ def check_zero_hardcoded_paths() -> tuple[bool, str]: def check_security_zero_secrets() -> tuple[bool, str]: - secret_files = list(ROOT.rglob("auth.json")) + list(ROOT.rglob("*.secret")) + list(ROOT.rglob("*.key")) + secret_files = list(ROOT.rglob("auth.json")) + list(ROOT.rglob("*.secret")) + list(ROOT.rglob("*.key")) + list(ROOT.rglob(".env*")) tracked_secrets = [] for sf in secret_files: - if ".git" not in str(sf) and "venv" not in str(sf) and "scratch" not in str(sf): + if ".git" not in str(sf) and "venv" not in str(sf) and "scratch" not in str(sf) and "example" not in str(sf): tracked_secrets.append(str(sf.relative_to(ROOT))) if tracked_secrets: return False, f"Found sensitive secret files in repository:\n" + "\n".join(tracked_secrets) - return True, "Zero secret/credential files tracked in repository" + + # Check for hardcoded OpenAI / OpenCode live API keys in src/ + src_dir = ROOT / "src" + live_key_pattern = re.compile(r"""(?:sk-[a-zA-Z0-9]{32,}|opencode-[a-zA-Z0-9]{20,})""") + for f in src_dir.rglob("*.py"): + text = f.read_text(encoding="utf-8", errors="ignore") + if live_key_pattern.search(text): + return False, f"Found potential live API key in source file: {f.relative_to(ROOT)}" + + return True, "Zero secret/credential files or live API keys tracked in repository" def run_release_gate(): diff --git a/src/antigravity_provider/hermes_plugin.py b/src/antigravity_provider/hermes_plugin.py index 3df6cab..619e1aa 100644 --- a/src/antigravity_provider/hermes_plugin.py +++ b/src/antigravity_provider/hermes_plugin.py @@ -37,6 +37,20 @@ def antigravity_llm_execution(**kwargs: Any) -> Any: role = kwargs.get("role") or request.get("role") session_id = kwargs.get("session_id") or request.get("session_id") completion = engine.route_request(request, role=role, session_id=session_id) + if isinstance(completion, dict) and "error" in completion and not completion.get("choices"): + err = completion.get("error") + err_msg = err.get("message") if isinstance(err, dict) else str(err) + completion = { + "model": str(request.get("model") or DEFAULT_MODEL), + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": f"Antigravity error: {err_msg}"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, + } return openai_completion_object(completion) except Exception as router_exc: logger.debug("Router invocation fell back to default provider: %s", router_exc) @@ -46,6 +60,20 @@ def antigravity_llm_execution(**kwargs: Any) -> Any: return next_call(request) if callable(next_call) else request try: completion = agy_generate(request) + if isinstance(completion, dict) and "error" in completion and not completion.get("choices"): + err = completion.get("error") + err_msg = err.get("message") if isinstance(err, dict) else str(err) + completion = { + "model": str(request.get("model") or DEFAULT_MODEL), + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": f"Antigravity error: {err_msg}"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, + } except Exception as exc: logger.exception("agy_generate raised: %s", exc) completion = { diff --git a/src/antigravity_provider/router/adapters/antigravity_adapter.py b/src/antigravity_provider/router/adapters/antigravity_adapter.py index 5cd8a49..c2b76ea 100644 --- a/src/antigravity_provider/router/adapters/antigravity_adapter.py +++ b/src/antigravity_provider/router/adapters/antigravity_adapter.py @@ -56,17 +56,34 @@ class AntigravityAdapter(BaseProviderAdapter): res = agy_generate(req, custom_env=custom_env) if isinstance(res, dict) and "error" in res: - err_dict = res["error"] + err_dict = res.get("error") err_msg = err_dict.get("message", "Antigravity provider error") if isinstance(err_dict, dict) else str(err_dict) err_lower = err_msg.lower() - if any(k in err_lower for k in ("quota", "resource_exhausted", "429", "limit", "exhausted")): - raise QuotaExceededError(err_msg, provider="antigravity", profile_id=profile.profile_id) - elif any(k in err_lower for k in ("auth", "401", "403", "expired", "token", "unauthorized")): + + # 1. Auth errors + if any(k in err_lower for k in ("auth", "401", "403", "expired", "token", "unauthorized", "login", "keychain")): raise AuthExpiredError(err_msg, provider="antigravity", profile_id=profile.profile_id) - elif "rate" in err_lower: + + # 2. Rate limiting (Check BEFORE general quota so "429: rate limit exceeded" gets 60s cooldown) + if any(k in err_lower for k in ("rate", "too many requests", "rate_limit")): raise RateLimitedError(err_msg, provider="antigravity", profile_id=profile.profile_id) - else: - raise ProviderUnavailableError(err_msg, provider="antigravity", profile_id=profile.profile_id) + + # 3. Quota Exhaustion (Parse reset duration e.g. "resets in 2h") + if any(k in err_lower for k in ("quota", "resource_exhausted", "429", "limit", "exhausted")): + reset_sec = 1800 + m_hr = re.search(r"(\d+)\s*(?:hours?|h\b)", err_lower) + m_min = re.search(r"(\d+)\s*(?:minutes?|m\b)", err_lower) + m_sec = re.search(r"(\d+)\s*(?:seconds?|s\b)", err_lower) + if m_hr: + reset_sec = int(m_hr.group(1)) * 3600 + elif m_min: + reset_sec = int(m_min.group(1)) * 60 + elif m_sec: + reset_sec = int(m_sec.group(1)) + + raise QuotaExceededError(err_msg, provider="antigravity", profile_id=profile.profile_id, reset_in_sec=reset_sec) + + raise ProviderUnavailableError(err_msg, provider="antigravity", profile_id=profile.profile_id) return res diff --git a/src/antigravity_provider/router/auto_assigner.py b/src/antigravity_provider/router/auto_assigner.py index ae487b9..1fa1fa1 100644 --- a/src/antigravity_provider/router/auto_assigner.py +++ b/src/antigravity_provider/router/auto_assigner.py @@ -59,6 +59,37 @@ DEFAULT_SLOT_ROLES = { } +CANONICAL_ROLE_MAP = { + "orchestrator": "orchestrator", + "orchestrator_primary": "orchestrator", + "orchestrator_fallback": "orchestrator", + "главный оркестратор": "orchestrator", + "резервный оркестратор": "orchestrator", + "coder": "coder-primary", + "coder_1": "coder-primary", + "coder-1": "coder-primary", + "coder-primary": "coder-primary", + "кодер 1": "coder-primary", + "coder_2": "coder-secondary", + "coder-2": "coder-secondary", + "coder-secondary": "coder-secondary", + "кодер 2": "coder-secondary", + "reviewer": "reviewer", + "ревьюер": "reviewer", + "research": "research", + "researcher": "research", + "исследователь": "research", + "fast": "fast", + "fast_agent": "fast", + "быстрый агент": "fast", + "general": "fast", + "universal_subagent": "fast", + "универсальный субагент": "fast", + "tester": "fast", + "тестировщик": "fast", +} + + class AutoAssigner: """Manages team view structure, auto-slot allocation, and duplicate checking.""" @@ -172,13 +203,19 @@ class AutoAssigner: @staticmethod def assign_profile_to_role(profile_id: str, role_name: str, is_primary: bool = True) -> Tuple[bool, str]: - """Assign a profile to a specified logical role, updating fallback chains and persisting config.""" + """Assign a profile to a canonical router role, updating fallback chains and persisting config.""" config = load_router_config() pcfg = config.get_profile(profile_id) if not pcfg: return False, f"Профиль '{profile_id}' не найден" - rpolicy = config.get_role_policy(role_name) + clean_role = role_name.strip().lower() + canonical_role = CANONICAL_ROLE_MAP.get(clean_role, clean_role) + + if canonical_role not in config.roles: + return False, f"Неизвестная роль маршрутизатора: '{role_name}'" + + rpolicy = config.roles[canonical_role] chain = list(rpolicy.preferred_chain) if profile_id in chain: chain.remove(profile_id) @@ -189,13 +226,13 @@ class AutoAssigner: chain.append(profile_id) rpolicy.preferred_chain = chain - config.roles[role_name] = rpolicy + config.roles[canonical_role] = rpolicy save_router_config(config) - return True, f"Профиль '{profile_id}' назначен на роль '{role_name}' ({'основной' if is_primary else 'резервный'})" + return True, f"Профиль '{profile_id}' назначен на роль '{canonical_role}' ({'основной' if is_primary else 'резервный'})" @staticmethod def auto_assign_all() -> Dict[str, Any]: - """Automatically distribute all authenticated profiles across logical roles.""" + """Automatically distribute all authenticated profiles across canonical router roles.""" config = load_router_config() authenticated_profiles = [] for pid, pcfg in config.profiles.items(): @@ -206,10 +243,10 @@ class AutoAssigner: authenticated_profiles.append((pid, pcfg)) changes = [] - roles_order = ["orchestrator", "coder", "reviewer", "researcher", "tester", "general"] + canonical_roles_order = ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"] for idx, (pid, pcfg) in enumerate(authenticated_profiles): - target_role = roles_order[idx % len(roles_order)] - ok, msg = AutoAssigner.assign_profile_to_role(pid, target_role, is_primary=(idx < len(roles_order))) + target_role = canonical_roles_order[idx % len(canonical_roles_order)] + ok, msg = AutoAssigner.assign_profile_to_role(pid, target_role, is_primary=(idx < len(canonical_roles_order))) if ok: changes.append({"profile_id": pid, "role": target_role, "message": msg}) diff --git a/src/antigravity_provider/router/router_config.py b/src/antigravity_provider/router/router_config.py index 6e4acf0..85d448f 100644 --- a/src/antigravity_provider/router/router_config.py +++ b/src/antigravity_provider/router/router_config.py @@ -38,8 +38,13 @@ class RouterConfig: default_role: str = "orchestrator" quota_cooldown_seconds: int = 1800 # 30 min default rate_limit_cooldown_seconds: int = 60 # 1 min default + max_failover_attempts: int = 3 + cooldown_base_seconds: int = 300 + cooldown_max_seconds: int = 3600 + session_affinity_ttl_seconds: int = 1800 roles: dict[str, RolePolicy] = field(default_factory=dict) profiles: dict[str, RouterProfileConfig] = field(default_factory=dict) + raw_router_block: dict[str, Any] = field(default_factory=dict) def get_profile(self, profile_id: str) -> Optional[RouterProfileConfig]: return self.profiles.get(profile_id) @@ -146,24 +151,27 @@ def get_default_router_config() -> RouterConfig: "ag-cold-1": RouterProfileConfig( profile_id="ag-cold-1", provider="antigravity", - account_id="ag-acc-cold1", - capabilities=["cold-spare"], + account_id="ag-acc-c1", + capabilities=["cold-spare", "coding", "reasoning"], + preferred_models=["gemini-3.5-flash"], enabled=False, max_concurrency=1, ), "ag-cold-2": RouterProfileConfig( profile_id="ag-cold-2", provider="antigravity", - account_id="ag-acc-cold2", - capabilities=["cold-spare"], + account_id="ag-acc-c2", + capabilities=["cold-spare", "coding", "reasoning"], + preferred_models=["gemini-3.5-flash"], enabled=False, max_concurrency=1, ), "ag-cold-3": RouterProfileConfig( profile_id="ag-cold-3", provider="antigravity", - account_id="ag-acc-cold3", - capabilities=["cold-spare"], + account_id="ag-acc-c3", + capabilities=["cold-spare", "coding", "reasoning"], + preferred_models=["gemini-3.5-flash"], enabled=False, max_concurrency=1, ), @@ -172,25 +180,25 @@ def get_default_router_config() -> RouterConfig: profile_id="opengo-1", provider="opencode-go", account_id="opengo-acc-1", - capabilities=["research", "search", "fast", "review"], - preferred_models=["qwen3.8-max", "glm-5.3", "deepseek-v4-flash", "grok-4.5"], - max_concurrency=3, + capabilities=["coding", "fast", "multimodal"], + preferred_models=["deepseek-r1", "qwen-2.5-coder-32b", "deepseek-v3"], + max_concurrency=5, ), "opengo-2": RouterProfileConfig( profile_id="opengo-2", provider="opencode-go", account_id="opengo-acc-2", - capabilities=["reviewer", "review", "coding", "reasoning"], - preferred_models=["deepseek-v4-pro", "grok-4.5", "qwen3.7-max"], - max_concurrency=3, + capabilities=["research", "coding", "multimodal"], + preferred_models=["deepseek-v3", "qwen-2.5-coder-32b", "deepseek-r1"], + max_concurrency=5, ), "opengo-3": RouterProfileConfig( profile_id="opengo-3", provider="opencode-go", account_id="opengo-acc-3", - capabilities=["coder-fallback", "orchestrator", "coding", "reasoning"], - preferred_models=["kimi-k2.7-code", "deepseek-v4-pro", "qwen3.8-max"], - max_concurrency=3, + capabilities=["fallback", "coding", "reasoning"], + preferred_models=["deepseek-r1", "deepseek-v3", "qwen-2.5-coder-32b"], + max_concurrency=5, ), } @@ -267,6 +275,8 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig: try: data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + r_block = data.get("router") if isinstance(data.get("router"), dict) else {} + profiles_raw = data.get("profiles", {}) profiles: dict[str, RouterProfileConfig] = {} for pid, pdata in profiles_raw.items(): @@ -295,13 +305,27 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig: default_model=rdata.get("default_model"), ) + enabled = bool(r_block.get("enabled", data.get("enabled", True))) + default_role = str(r_block.get("default_role", data.get("default_role", "orchestrator"))) + max_failover = int(r_block.get("max_failover_attempts", data.get("max_failover_attempts", 3))) + cooldown_base = int(r_block.get("cooldown_base_seconds", data.get("cooldown_base_seconds", 300))) + cooldown_max = int(r_block.get("cooldown_max_seconds", data.get("cooldown_max_seconds", 3600))) + session_ttl = int(r_block.get("session_affinity_ttl_seconds", data.get("session_affinity_ttl_seconds", 1800))) + quota_cooldown = int(r_block.get("quota_cooldown_seconds", data.get("quota_cooldown_seconds", 1800))) + rate_cooldown = int(r_block.get("rate_limit_cooldown_seconds", data.get("rate_limit_cooldown_seconds", 60))) + return RouterConfig( - enabled=bool(data.get("enabled", True)), - default_role=str(data.get("default_role", "orchestrator")), - quota_cooldown_seconds=int(data.get("quota_cooldown_seconds", 1800)), - rate_limit_cooldown_seconds=int(data.get("rate_limit_cooldown_seconds", 60)), + enabled=enabled, + default_role=default_role, + quota_cooldown_seconds=quota_cooldown, + rate_limit_cooldown_seconds=rate_cooldown, + max_failover_attempts=max_failover, + cooldown_base_seconds=cooldown_base, + cooldown_max_seconds=cooldown_max, + session_affinity_ttl_seconds=session_ttl, roles=roles or get_default_router_config().roles, profiles=profiles or get_default_router_config().profiles, + raw_router_block=r_block, ) except Exception as e: # Fall back gracefully to built-in defaults on YAML error @@ -309,7 +333,7 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig: def save_router_config(config: RouterConfig, config_path: Optional[Path] = None) -> bool: - """Save RouterConfig to YAML file.""" + """Save RouterConfig to YAML file preserving canonical router block schema.""" if config_path is None: env_config = os.environ.get("HERMES_ROUTER_CONFIG", "").strip() if env_config: @@ -341,6 +365,7 @@ def save_router_config(config: RouterConfig, config_path: Optional[Path] = None) roles_data = {} for rname, rpol in config.roles.items(): roles_data[rname] = { + "role_name": rname, "preferred_chain": rpol.preferred_chain, "fallback_capabilities": rpol.fallback_capabilities, "max_failover_attempts": rpol.max_failover_attempts, @@ -349,11 +374,18 @@ def save_router_config(config: RouterConfig, config_path: Optional[Path] = None) if rpol.default_model: roles_data[rname]["default_model"] = rpol.default_model - data = { + router_block = dict(config.raw_router_block) if config.raw_router_block else {} + router_block.update({ "enabled": config.enabled, "default_role": config.default_role, - "quota_cooldown_seconds": config.quota_cooldown_seconds, - "rate_limit_cooldown_seconds": config.rate_limit_cooldown_seconds, + "max_failover_attempts": config.max_failover_attempts, + "cooldown_base_seconds": config.cooldown_base_seconds, + "cooldown_max_seconds": config.cooldown_max_seconds, + "session_affinity_ttl_seconds": config.session_affinity_ttl_seconds, + }) + + data = { + "router": router_block, "roles": roles_data, "profiles": profiles_data, } diff --git a/src/antigravity_provider/router/ui/add_account_wizard.py b/src/antigravity_provider/router/ui/add_account_wizard.py index 766f342..745f043 100644 --- a/src/antigravity_provider/router/ui/add_account_wizard.py +++ b/src/antigravity_provider/router/ui/add_account_wizard.py @@ -35,6 +35,10 @@ class AddAccountWizard(HubModal): self._show_step_1_provider() + def destroy(self): + self._polling_active = False + super().destroy() + def _clear_body(self): for w in self.body.winfo_children(): w.destroy() diff --git a/src/antigravity_provider/router/ui/views/settings_view.py b/src/antigravity_provider/router/ui/views/settings_view.py index 9053e27..de0dd11 100644 --- a/src/antigravity_provider/router/ui/views/settings_view.py +++ b/src/antigravity_provider/router/ui/views/settings_view.py @@ -48,6 +48,17 @@ class SettingsView(ctk.CTkFrame): def _save_settings(self): try: + if hasattr(self, "aff_sw"): + self.settings["session_affinity"] = bool(self.aff_sw.get()) + if hasattr(self, "fo_sw"): + self.settings["auto_failover"] = bool(self.fo_sw.get()) + if hasattr(self, "fo_menu"): + self.settings["failover_attempts"] = str(self.fo_menu.get()) + if hasattr(self, "ret_sw"): + self.settings["auto_return_primary"] = bool(self.ret_sw.get()) + if hasattr(self, "mon_sw"): + self.settings["auto_monitoring"] = bool(self.mon_sw.get()) + self.settings_file.parent.mkdir(parents=True, exist_ok=True) self.settings_file.write_text(json.dumps(self.settings, indent=2), encoding="utf-8") except Exception: @@ -75,25 +86,25 @@ class SettingsView(ctk.CTkFrame): r1 = ctk.CTkFrame(c1, fg_color="transparent") r1.pack(fill="x", padx=16, pady=4) ctk.CTkLabel(r1, text="Сессионная привязка (Session Affinity)", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(side="left") - aff_sw = ctk.CTkSwitch(r1, text="", fg_color=Theme.SURFACE_MUTED, progress_color=Theme.ACCENT) - aff_sw.pack(side="right") + self.aff_sw = ctk.CTkSwitch(r1, text="", fg_color=Theme.SURFACE_MUTED, progress_color=Theme.ACCENT) + self.aff_sw.pack(side="right") if self.settings.get("session_affinity"): - aff_sw.select() + self.aff_sw.select() # Auto Failover switch r2 = ctk.CTkFrame(c1, fg_color="transparent") r2.pack(fill="x", padx=16, pady=4) ctk.CTkLabel(r2, text="Автоматический Failover при исчерпании квот", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(side="left") - fo_sw = ctk.CTkSwitch(r2, text="", fg_color=Theme.SURFACE_MUTED, progress_color=Theme.ACCENT) - fo_sw.pack(side="right") + self.fo_sw = ctk.CTkSwitch(r2, text="", fg_color=Theme.SURFACE_MUTED, progress_color=Theme.ACCENT) + self.fo_sw.pack(side="right") if self.settings.get("auto_failover"): - fo_sw.select() + self.fo_sw.select() # Failover Attempts r3 = ctk.CTkFrame(c1, fg_color="transparent") r3.pack(fill="x", padx=16, pady=(4, 12)) ctk.CTkLabel(r3, text="Лимит попыток failover на запрос", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(side="left") - fo_menu = ctk.CTkOptionMenu( + self.fo_menu = ctk.CTkOptionMenu( r3, values=["1", "2", "3", "4", "5"], width=80, @@ -102,8 +113,8 @@ class SettingsView(ctk.CTkFrame): button_color=Theme.ACCENT, text_color=Theme.TEXT_PRIMARY, ) - fo_menu.set(str(self.settings.get("failover_attempts", "3"))) - fo_menu.pack(side="right") + self.fo_menu.set(str(self.settings.get("failover_attempts", "3"))) + self.fo_menu.pack(side="right") # ── 2. Recovery & Monitoring ── c2 = HubCard(scroll, border_color=Theme.BORDER, fg_color=Theme.SURFACE) @@ -113,18 +124,18 @@ class SettingsView(ctk.CTkFrame): r4 = ctk.CTkFrame(c2, fg_color="transparent") r4.pack(fill="x", padx=16, pady=4) ctk.CTkLabel(r4, text="Возвращаться на основной аккаунт после сброса квоты", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(side="left") - ret_sw = ctk.CTkSwitch(r4, text="", fg_color=Theme.SURFACE_MUTED, progress_color=Theme.ACCENT) - ret_sw.pack(side="right") + self.ret_sw = ctk.CTkSwitch(r4, text="", fg_color=Theme.SURFACE_MUTED, progress_color=Theme.ACCENT) + self.ret_sw.pack(side="right") if self.settings.get("auto_return_primary"): - ret_sw.select() + self.ret_sw.select() r5 = ctk.CTkFrame(c2, fg_color="transparent") r5.pack(fill="x", padx=16, pady=(4, 12)) ctk.CTkLabel(r5, text="Автоматический фоновый мониторинг здоровья", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(side="left") - mon_sw = ctk.CTkSwitch(r5, text="", fg_color=Theme.SURFACE_MUTED, progress_color=Theme.ACCENT) - mon_sw.pack(side="right") + self.mon_sw = ctk.CTkSwitch(r5, text="", fg_color=Theme.SURFACE_MUTED, progress_color=Theme.ACCENT) + self.mon_sw.pack(side="right") if self.settings.get("auto_monitoring"): - mon_sw.select() + self.mon_sw.select() # ── 3. Updates & Release Channel ── c_upd = HubCard(scroll, border_color=Theme.BORDER, fg_color=Theme.SURFACE) diff --git a/src/antigravity_provider/runtime.py b/src/antigravity_provider/runtime.py index 3d54cad..00c475d 100644 --- a/src/antigravity_provider/runtime.py +++ b/src/antigravity_provider/runtime.py @@ -115,13 +115,41 @@ def openai_completion_object(completion: dict[str, Any]) -> SimpleNamespace: """Return an object compatible with Hermes' ChatCompletionsTransport.""" completion = dict(completion) choices = [] - for raw_choice in completion.get("choices") or []: - choice = dict(raw_choice) - message = dict(choice.get("message") or {}) - message.setdefault("content", None) - message.setdefault("tool_calls", None) - choice["message"] = message - choices.append(choice) + + # Handle error payload without crashing choices[0] + if "error" in completion and not completion.get("choices"): + err = completion.get("error") + err_msg = err.get("message") if isinstance(err, dict) else str(err) + choices.append({ + "index": 0, + "message": { + "role": "assistant", + "content": f"Antigravity error: {err_msg}", + "tool_calls": None, + }, + "finish_reason": "stop", + }) + else: + for raw_choice in completion.get("choices") or []: + choice = dict(raw_choice) + message = dict(choice.get("message") or {}) + message.setdefault("content", None) + message.setdefault("tool_calls", None) + choice["message"] = message + choices.append(choice) + + # Fallback to ensure choices is never empty + if not choices: + choices.append({ + "index": 0, + "message": { + "role": "assistant", + "content": "Empty or unrecognized completion response from provider.", + "tool_calls": None, + }, + "finish_reason": "stop", + }) + completion["choices"] = choices completion.setdefault("usage", {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}) return _namespace(completion) diff --git a/src/antigravity_provider/updater/update_manager.py b/src/antigravity_provider/updater/update_manager.py index 2b2c111..ab6be10 100644 --- a/src/antigravity_provider/updater/update_manager.py +++ b/src/antigravity_provider/updater/update_manager.py @@ -188,10 +188,12 @@ class UpdateManager: with zipfile.ZipFile(package_zip, "r") as zf: zf.extractall(dest) - # 3. Verify syntax and integrity of updated python files + # 3. Verify syntax and integrity of updated python files in src/ import py_compile - for py_file in dest.rglob("*.py"): - py_compile.compile(str(py_file), doraise=True) + target_src = dest / "src" + if target_src.exists(): + for py_file in target_src.rglob("*.py"): + py_compile.compile(str(py_file), doraise=True) # Also run quick import smoke test if python executable is available py_exec = paths.get_hermes_agent_venv() / "Scripts" / "python.exe" diff --git a/tests/conftest.py b/tests/conftest.py index 98f54a2..1af8d46 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,9 +8,14 @@ Enforces: from __future__ import annotations import os +import sys from pathlib import Path import pytest +REPO_SRC = Path(__file__).resolve().parent.parent / "src" +if str(REPO_SRC) not in sys.path or sys.path[0] != str(REPO_SRC): + sys.path.insert(0, str(REPO_SRC)) + @pytest.fixture(autouse=True) def isolate_hermes_environment(tmp_path, monkeypatch): diff --git a/tests/test_p0_release_gate.py b/tests/test_p0_release_gate.py index 872e869..2a66a61 100644 --- a/tests/test_p0_release_gate.py +++ b/tests/test_p0_release_gate.py @@ -1,15 +1,17 @@ """Hermes Hub — P0 Release Gate Verification Suite. -Validates all 9 critical release blockers: +Validates all critical release blockers and regressions: - P0-1: customtkinter / Pillow clean install verification - P0-2: ProfileAuthManager.get_profile_dir unified API - P0-3: Wizard json import & API-key saving flow -- P0-4: AutoAssigner.auto_assign_all implementation -- P0-5: Antigravity failover on quota exhaustion (typed exceptions, no fake success text) +- P0-4: AutoAssigner.auto_assign_all implementation with canonical roles +- P0-5: Antigravity failover on quota exhaustion + non-router error handling (B1) - P0-6: OAuth session status unification and fast error reaction -- P0-7: Role assignment action and persistence +- P0-7: Role assignment canonical mapping and unknown role rejection (B2) - P0-8: Wizard role application to live config - P0-9: Real API validation / removal of fake validation +- P0-10: YAML round-trip config preservation (B3) +- P0-11: Rate limit (60s) vs Quota exhaustion duration parsing (B4, S1) """ from __future__ import annotations @@ -20,7 +22,7 @@ from unittest.mock import MagicMock, patch import pytest from antigravity_provider.paths import get_hermes_home, get_profile_dir -from antigravity_provider.router.auto_assigner import AutoAssigner +from antigravity_provider.router.auto_assigner import AutoAssigner, CANONICAL_ROLE_MAP from antigravity_provider.router.exceptions import ( AuthExpiredError, AuthRequiredError, @@ -33,6 +35,7 @@ from antigravity_provider.router.router_config import ( RolePolicy, RouterConfig, RouterProfileConfig, + get_default_router_config, load_router_config, save_router_config, ) @@ -43,6 +46,7 @@ from antigravity_provider.version import __version__ @pytest.mark.unit def test_p0_1_installer_dependencies(): """P0-1: Verify that required UI dependencies are importable in runtime.""" + pytest.importorskip("customtkinter") import customtkinter from PIL import Image import psutil @@ -88,7 +92,7 @@ def test_p0_3_wizard_api_key_save(tmp_path, monkeypatch): } saved_path = ProfileAuthManager.save_profile_auth("openai-codex", "codex-test-1", auth_data) assert saved_path.exists() - + loaded = ProfileAuthManager.load_profile_auth("openai-codex", "codex-test-1") assert loaded is not None assert loaded["api_key"] == "sk-test12345678901234567890" @@ -96,7 +100,7 @@ def test_p0_3_wizard_api_key_save(tmp_path, monkeypatch): @pytest.mark.unit def test_p0_4_auto_assign_all(tmp_path, monkeypatch): - """P0-4: Verify AutoAssigner.auto_assign_all executes without AttributeError and assigns roles.""" + """P0-4: Verify AutoAssigner.auto_assign_all assigns to canonical roles without creating generic roles.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) # Save mock auth for 2 profiles @@ -108,6 +112,11 @@ def test_p0_4_auto_assign_all(tmp_path, monkeypatch): assert result.get("success") is True assert "assigned_count" in result + # Verify only canonical roles exist in config + cfg = load_router_config() + for rname in cfg.roles: + assert rname in {"orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"} + @pytest.mark.unit def test_p0_5_antigravity_failover_on_quota(tmp_path, monkeypatch): @@ -137,7 +146,6 @@ def test_p0_5_antigravity_failover_on_quota(tmp_path, monkeypatch): ) save_router_config(config) - # Mock antigravity adapter to return a quota error from antigravity_provider.router.adapters.antigravity_adapter import AntigravityAdapter from antigravity_provider.router.adapters.codex_adapter import CodexAdapter @@ -167,11 +175,43 @@ def test_p0_5_antigravity_failover_on_quota(tmp_path, monkeypatch): assert res["router_metadata"]["profile_id"] == "codex-orch-fallback" +@pytest.mark.unit +def test_p0_5_b1_non_router_error_fallback(tmp_path, monkeypatch): + """B1: Verify that non-router path in hermes_plugin handles error payloads without crashing with IndexError.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + + from antigravity_provider.hermes_plugin import antigravity_llm_execution + from antigravity_provider.router import get_router_engine + + # Ensure router is disabled + engine = get_router_engine() + prev_enabled = engine.config.enabled + engine.config.enabled = False + + try: + # Simulate agy_generate returning error dict + error_completion = {"error": {"message": "Resource exhausted: 429 quota reached"}} + + with patch("antigravity_provider.hermes_plugin.agy_generate", return_value=error_completion): + res = antigravity_llm_execution( + provider="google-antigravity", + request={"model": "gemini-2.5-pro", "messages": [{"role": "user", "content": "hello"}]}, + ) + + # Must have choices[0] and message.content without IndexError! + assert hasattr(res, "choices") + assert len(res.choices) > 0 + assert res.choices[0].message.content is not None + content_lower = str(res.choices[0].message.content).lower() + assert "429" in content_lower or "exhausted" in content_lower or "error" in content_lower + finally: + engine.config.enabled = prev_enabled + + @pytest.mark.unit def test_p0_6_oauth_session_status_unification(): """P0-6: Verify OAuth statuses are unified and error triggers fast failure.""" valid_statuses = {"pending", "success", "completed", "failed", "error", "cancelled", "timeout"} - # Verify our status classifier recognises all terminal failure states error_statuses = {"failed", "error", "cancelled", "timeout"} for st in error_statuses: assert st in valid_statuses @@ -179,27 +219,29 @@ def test_p0_6_oauth_session_status_unification(): @pytest.mark.unit def test_p0_7_assign_role_action(tmp_path, monkeypatch): - """P0-7: Verify assign_profile_to_role modifies role chains and persists to disk.""" + """P0-7 & B2: Verify assign_profile_to_role maps human names to canonical roles and rejects unknown roles.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) - config = RouterConfig( - profiles={ - "ag-w1": RouterProfileConfig(profile_id="ag-w1", provider="antigravity", enabled=True), - }, - roles={ - "coder": RolePolicy(role_name="coder", preferred_chain=[]), - } - ) + config = get_default_router_config() save_router_config(config) + # 1. Assign "coder" -> must update "coder-primary" ok, msg = AutoAssigner.assign_profile_to_role("ag-w1", "coder", is_primary=True) assert ok is True - - # Reload from disk and verify reloaded = load_router_config() - coder_chain = reloaded.roles["coder"].preferred_chain - assert "ag-w1" in coder_chain - assert coder_chain[0] == "ag-w1" + assert reloaded.roles["coder-primary"].preferred_chain[0] == "ag-w1" + assert "coder" not in reloaded.roles # Must NOT create a non-canonical role + + # 2. Assign "researcher" -> must update "research" + ok, msg = AutoAssigner.assign_profile_to_role("ag-w2", "researcher", is_primary=True) + assert ok is True + reloaded = load_router_config() + assert reloaded.roles["research"].preferred_chain[0] == "ag-w2" + + # 3. Unknown role -> must return False and reject + ok, msg = AutoAssigner.assign_profile_to_role("ag-w1", "completely_unknown_role_xyz") + assert ok is False + assert "Неизвестная роль" in msg @pytest.mark.unit @@ -207,21 +249,16 @@ def test_p0_8_wizard_role_application(tmp_path, monkeypatch): """P0-8: Verify Wizard step 4 role assignment is applied directly to configuration.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) - config = RouterConfig( - profiles={ - "codex-worker-1": RouterProfileConfig(profile_id="codex-worker-1", provider="openai-codex", enabled=True), - }, - roles={ - "reviewer": RolePolicy(role_name="reviewer", preferred_chain=[]), - } - ) + config = get_default_router_config() save_router_config(config) # Apply role - AutoAssigner.assign_profile_to_role("codex-worker-1", "reviewer", is_primary=True) + ok, msg = AutoAssigner.assign_profile_to_role("codex-worker-1", "reviewer", is_primary=True) + assert ok is True reloaded = load_router_config() assert "codex-worker-1" in reloaded.roles["reviewer"].preferred_chain + assert reloaded.roles["reviewer"].preferred_chain[0] == "codex-worker-1" @pytest.mark.unit @@ -237,3 +274,54 @@ def test_p0_9_real_api_key_validation(): valid, masked, models = ProfileAuthManager.verify_codex_token(valid_key) assert valid is True assert masked.startswith("sk-...") + + +@pytest.mark.unit +def test_p0_10_yaml_round_trip_preservation(tmp_path, monkeypatch): + """B3: Verify YAML load -> save -> load preserves router block and settings without loss.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + + cfg_file = tmp_path / "test_profiles.yaml" + cfg = get_default_router_config() + cfg.max_failover_attempts = 5 + cfg.session_affinity_ttl_seconds = 2400 + + save_router_config(cfg, config_path=cfg_file) + assert cfg_file.exists() + + reloaded = load_router_config(config_path=cfg_file) + assert reloaded.enabled is True + assert reloaded.max_failover_attempts == 5 + assert reloaded.session_affinity_ttl_seconds == 2400 + assert len(reloaded.roles) == len(cfg.roles) + assert len(reloaded.profiles) == len(cfg.profiles) + + +@pytest.mark.unit +def test_p0_11_rate_limit_vs_quota_classification(): + """B4 & S1: Verify rate limiting gets 60s cooldown and quota parsing extracts hours/minutes.""" + from antigravity_provider.router.adapters.antigravity_adapter import AntigravityAdapter + from antigravity_provider.router.adapters.base_adapter import ErrorCategory + from antigravity_provider.router.router_config import RouterProfileConfig + + adapter = AntigravityAdapter() + profile = RouterProfileConfig(profile_id="ag-w1", provider="antigravity") + + # 1. Rate Limit Error -> must be RATE_LIMITED with 60s cooldown + rate_resp = {"error": {"message": "429 Too Many Requests: rate limit exceeded"}} + with patch("antigravity_provider.router.adapters.antigravity_adapter.agy_generate", return_value=rate_resp): + with pytest.raises(RateLimitedError) as exc_info: + adapter.invoke(profile, {"messages": []}) + classification = adapter.classify_error(exc_info.value) + assert classification.category == ErrorCategory.RATE_LIMITED + assert classification.retry_delay_seconds == 60 + + # 2. Quota with "resets in 2h" -> must parse 7200s cooldown + quota_resp = {"error": {"message": "individual quota reached, resets in 2h"}} + with patch("antigravity_provider.router.adapters.antigravity_adapter.agy_generate", return_value=quota_resp): + with pytest.raises(QuotaExceededError) as exc_info: + adapter.invoke(profile, {"messages": []}) + assert exc_info.value.reset_in_sec == 7200 + classification = adapter.classify_error(exc_info.value) + assert classification.category == ErrorCategory.QUOTA_EXHAUSTED + assert classification.reset_duration_seconds == 7200 diff --git a/tests/test_ui_refinement.py b/tests/test_ui_refinement.py index 46c9c65..801def4 100644 --- a/tests/test_ui_refinement.py +++ b/tests/test_ui_refinement.py @@ -5,9 +5,10 @@ from __future__ import annotations import json import os import tempfile -from pathlib import Path import pytest +pytest.importorskip("customtkinter") + from antigravity_provider.router.hermes_hub_app import do_test_profile, do_set_main, do_set_orchestrator from antigravity_provider.router.profile_manager import ProfileAuthManager