fix(router): address review findings B1-B4 and S1-S9 with full regression suite
This commit is contained in:
parent
7926de9ad2
commit
65482e8eee
12 changed files with 374 additions and 115 deletions
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue