hermes-hub/tests/test_multi_provider_router.py
Hermes Team 6cb0d7c636 fix(profiles): пустые слоты появлялись сами, потому что запрос пути создавал каталог
Владелец спросил, откуда 25 каталогов профилей, когда заводил единицы.
Проверено исполнением: обращение к get_profile_dir создавало каталог. Спросили
четыре пути — появились четыре каталога.

В коде зашит список «стандартных» слотов — ag-orch-primary, ag-orch-fallback,
ag-1..ag-20, ag-w1..ag-w10 — в двух местах: agy_subprocess и
model_discovery_service. Любой обход этого списка материализовал их все. Отсюда
ag-w1..ag-w4, ag-cold-*, ag-spare-* — владелец их не создавал.

Спросить, где профиль жил бы, и завести его — разные действия. Создание теперь
запрашивается явно, create=True, и это делают только те, кто действительно
заводит профиль.

Тесты и сценарий проверки, опиравшиеся на побочное создание, приведены к новому
поведению: они проверяют изоляцию пути, а не то, что каталог возник сам.

669 passed, 2 skipped; ruff чисто; релизный гейт пройден.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 15:27:33 +07:00

287 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Comprehensive test suite for Hermes Multi-Provider Account Router."""
from __future__ import annotations
import os
import sys
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Ensure plugin package is in sys.path
repo_root = Path(__file__).resolve().parent.parent
plugin_src = repo_root / "plugins" / "antigravity-provider" / "src"
if str(plugin_src) not in sys.path:
sys.path.insert(0, str(plugin_src))
from antigravity_provider.router.router_config import (
RolePolicy,
RouterConfig,
RouterProfileConfig,
get_default_router_config,
load_router_config,
)
from antigravity_provider.router.health_tracker import (
AUTH_REQUIRED,
DISABLED,
HEALTHY,
IN_USE,
QUOTA_EXHAUSTED,
RATE_LIMITED,
HealthTracker,
extract_model_family,
)
from antigravity_provider.router.session_affinity import LeaseManager, SessionAffinityTracker
from antigravity_provider.router.router_engine import RouterEngine, get_router_engine
from antigravity_provider.router.adapters.base_adapter import ErrorCategory, ErrorClassification
from antigravity_provider.router.adapters.antigravity_adapter import AntigravityAdapter, get_profile_env_dir
from antigravity_provider.router.adapters.codex_adapter import CodexAdapter
from antigravity_provider.router.adapters.opencode_adapter import OpenCodeGoAdapter
from antigravity_provider.router.cli_commands import (
clear_cooldown_cli,
print_router_status,
print_routing_policy,
simulate_quota_cli,
)
class TestRouterConfig:
"""Test configuration schema, clean default config, and role definitions."""
def test_default_config_is_clean(self):
config = get_default_router_config()
assert len(config.profiles) == 0
assert len(config.roles) == 15
assert config.default_role == "manager"
assert config.enabled is True
def test_role_policies_chains(self):
config = get_default_router_config()
assert "manager" in config.roles
assert "developer-1" in config.roles
assert "developer-2" in config.roles
assert "code-reviewer" in config.roles
assert "researcher" in config.roles
assert "tester" in config.roles
assert "tech-writer" in config.roles
assert "analyst" in config.roles
assert "guardian" in config.roles
assert "cost-controller" in config.roles
assert "integration-expert" in config.roles
assert "security-expert" in config.roles
assert "dependency-agent" in config.roles
for rname, rpol in config.roles.items():
assert rpol.preferred_chain == [], f"Expected empty chain for {rname} on clean install"
class TestHealthTracker:
"""Test health state tracking, model families, and simulation."""
def test_model_family_extraction(self):
assert extract_model_family("gemini-3.7-flash") == "gemini"
assert extract_model_family("claude-sonnet-4-6") == "claude"
assert extract_model_family("gpt-4o") == "gpt"
assert extract_model_family("deepseek-v4-pro") == "deepseek"
assert extract_model_family("kimi-k2.7-code") == "kimi"
assert extract_model_family("qwen3.8-max") == "qwen"
assert extract_model_family("grok-4.5") == "grok"
assert extract_model_family("glm-5.3") == "glm"
def test_quota_exhaustion_and_expiry(self, tmp_path):
state_file = tmp_path / "test_state.json"
tracker = HealthTracker(state_file=state_file)
# Initially healthy
assert tracker.is_healthy("ag-w1", "gemini-3.7-flash") is True
# Mark quota exhausted with 2 second duration
tracker.mark_quota_exhausted("ag-w1", "gemini-3.7-flash", duration=2, reason="Quota reached")
assert tracker.is_healthy("ag-w1", "gemini-3.7-flash") is False
# Wait for expiration
time.sleep(2.1)
assert tracker.is_healthy("ag-w1", "gemini-3.7-flash") is True
def test_simulated_quota_and_clear(self, tmp_path):
state_file = tmp_path / "test_state.json"
tracker = HealthTracker(state_file=state_file)
tracker.simulate_quota("codex-orch", duration=600)
assert tracker.is_healthy("codex-orch") is False
rec = tracker.get_or_create("codex-orch")
assert rec.simulated is True
# Clear cooldown
tracker.clear_cooldown("codex-orch")
assert tracker.is_healthy("codex-orch") is True
assert tracker.get_or_create("codex-orch").simulated is False
class TestSessionAffinityAndLeases:
"""Test session affinity retention and concurrency leases."""
def test_session_affinity_lifecycle(self):
affinity = SessionAffinityTracker()
assert affinity.get_affinity("sess-1") is None
affinity.set_affinity("sess-1", "manager", "codex-orch", "gpt-4o")
rec = affinity.get_affinity("sess-1")
assert rec is not None
assert rec.profile_id == "codex-orch"
assert rec.role == "manager"
# Update on failover
affinity.set_affinity("sess-1", "manager", "ag-orch-fallback", "gemini-3.7-flash")
rec2 = affinity.get_affinity("sess-1")
assert rec2.profile_id == "ag-orch-fallback"
def test_lease_concurrency(self):
leases = LeaseManager()
assert leases.acquire("ag-w1", max_concurrency=1) is True
# Exceeds concurrency 1
assert leases.acquire("ag-w1", max_concurrency=1) is False
assert leases.active_count("ag-w1") == 1
leases.release("ag-w1")
assert leases.active_count("ag-w1") == 0
assert leases.acquire("ag-w1", max_concurrency=1) is True
class TestRouterEngineFailover:
"""Test multi-provider role-aware failover execution loop."""
@staticmethod
def _get_test_config() -> RouterConfig:
return RouterConfig(
enabled=True,
default_role="manager",
roles={
"manager": RolePolicy(
role_name="manager",
preferred_chain=["codex-orch", "ag-orch-fallback", "opengo-3"],
max_failover_attempts=3,
session_affinity_enabled=True,
)
},
profiles={
"codex-orch": RouterProfileConfig(profile_id="codex-orch", provider="openai-codex"),
"ag-orch-fallback": RouterProfileConfig(profile_id="ag-orch-fallback", provider="antigravity"),
"opengo-3": RouterProfileConfig(profile_id="opengo-3", provider="opencode-go"),
},
)
def test_orchestrator_failover_chain(self, tmp_path):
state_file = tmp_path / "test_router_state.json"
engine = RouterEngine(
config=self._get_test_config(),
health=HealthTracker(state_file=state_file),
affinity=SessionAffinityTracker(),
)
# Mock adapter responses
mock_codex_response = {"id": "codex-1", "choices": [{"message": {"role": "assistant", "content": "from-codex"}}]}
mock_ag_response = {"id": "ag-1", "choices": [{"message": {"role": "assistant", "content": "from-antigravity"}}]}
mock_opengo_response = {"id": "opengo-1", "choices": [{"message": {"role": "assistant", "content": "from-opencode"}}]}
# 1. Normal state: codex-orch succeeds
with patch.object(CodexAdapter, "invoke", return_value=mock_codex_response):
resp = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="manager", session_id="sess-orch-1")
assert resp["choices"][0]["message"]["content"] == "from-codex"
assert resp["router_metadata"]["profile_id"] == "codex-orch"
# 2. Simulate quota on codex-orch: should auto-failover to ag-orch-fallback
engine.health.simulate_quota("codex-orch", duration=600)
with patch.object(AntigravityAdapter, "invoke", return_value=mock_ag_response):
resp2 = engine.route_request({"messages": [{"role": "user", "content": "hello again"}]}, role="manager", session_id="sess-orch-2")
assert resp2["choices"][0]["message"]["content"] == "from-antigravity"
assert resp2["router_metadata"]["profile_id"] == "ag-orch-fallback"
assert resp2["router_metadata"]["failover_count"] == 0 # picked directly because codex-orch was marked unhealthy
# 3. Simulate quota on both codex-orch and ag-orch-fallback: should failover to opengo-3
engine.health.simulate_quota("ag-orch-fallback", duration=600)
with patch.object(OpenCodeGoAdapter, "invoke", return_value=mock_opengo_response):
resp3 = engine.route_request({"messages": [{"role": "user", "content": "hello third"}]}, role="manager", session_id="sess-orch-3")
assert resp3["choices"][0]["message"]["content"] == "from-opencode"
assert resp3["router_metadata"]["profile_id"] == "opengo-3"
# Clear cooldowns
engine.health.clear_cooldown()
assert engine.health.is_healthy("codex-orch") is True
assert engine.health.is_healthy("ag-orch-fallback") is True
def test_session_affinity_retention_after_failover(self, tmp_path):
state_file = tmp_path / "test_affinity_state.json"
engine = RouterEngine(
config=self._get_test_config(),
health=HealthTracker(state_file=state_file),
affinity=SessionAffinityTracker(),
)
mock_codex = {"id": "c1", "choices": [{"message": {"role": "assistant", "content": "c1"}}]}
mock_ag = {"id": "a1", "choices": [{"message": {"role": "assistant", "content": "a1"}}]}
# Turn 1: codex-orch fails with quota exhaustion -> failover to ag-orch-fallback
with patch.object(CodexAdapter, "invoke", side_effect=RuntimeError("Quota limit reached")):
with patch.object(AntigravityAdapter, "invoke", return_value=mock_ag):
resp = engine.route_request({"messages": [{"role": "user", "content": "turn 1"}]}, role="manager", session_id="session-user-123")
assert resp["choices"][0]["message"]["content"] == "a1"
assert resp["router_metadata"]["profile_id"] == "ag-orch-fallback"
# Turn 2: same session continues directly on ag-orch-fallback
with patch.object(AntigravityAdapter, "invoke", return_value=mock_ag):
resp2 = engine.route_request({"messages": [{"role": "user", "content": "turn 2"}]}, role="manager", session_id="session-user-123")
assert resp2["choices"][0]["message"]["content"] == "a1"
assert resp2["router_metadata"]["profile_id"] == "ag-orch-fallback"
class TestAntigravityIsolation:
"""Test environment isolation for Antigravity profiles."""
def test_profile_env_dir_is_isolated_and_not_created_on_lookup(self):
"""Запрос пути не должен создавать каталог.
Прежде создавал, и любой обход списка стандартных слотов
(ag-orch-primary, ag-1..ag-20, ag-w1..ag-w10) материализовал их все:
у владельца накопилось 25 каталогов при единицах заведённых вручную.
"""
pdir = get_profile_env_dir("ag-w1")
assert "ag-w1" in str(pdir)
assert "agy_profiles" in str(pdir)
assert not pdir.exists(), "спросили путь — каталог появиться не должен"
from antigravity_provider.paths import get_profile_dir
created = get_profile_dir("ag-w1", "antigravity", create=True)
assert created.exists()
assert created == pdir
class TestRouterCLI:
"""Test CLI commands: status, policy, simulate, clear-cooldown."""
def test_print_router_status(self, capsys):
rc = print_router_status()
assert rc == 0
out = capsys.readouterr().out
assert "HERMES MULTI-PROVIDER ACCOUNT ROUTER" in out
assert "manager" in out or "developer-1" in out or "ag-w1" in out
def test_print_routing_policy(self, capsys):
rc = print_routing_policy()
assert rc == 0
out = capsys.readouterr().out
assert "manager" in out
assert "developer-1" in out
assert "code-reviewer" in out
def test_simulate_quota_cli(self, capsys):
rc = simulate_quota_cli("codex-orch", duration=300)
assert rc == 0
out = capsys.readouterr().out
assert "Simulated quota exhaustion activated" in out
rc_clear = clear_cooldown_cli("codex-orch")
assert rc_clear == 0
out_clear = capsys.readouterr().out
assert "cleared for profile 'codex-orch'" in out_clear