fix(router): connect persisted settings to runtime behavior

This commit is contained in:
Hermes Team 2026-08-20 17:22:23 +07:00
parent 925e6c6687
commit 70d34c1642
3 changed files with 142 additions and 7 deletions

View file

@ -77,16 +77,28 @@ class RouterEngine:
role_policy = self.config.get_role_policy(target_role)
requested_model = request.get("model")
family = extract_model_family(requested_model)
# Read runtime settings from hub_settings.json
from .settings_service import get_hub_settings
hub_settings = get_hub_settings()
affinity_enabled = bool(hub_settings.get("session_affinity", True)) and role_policy.session_affinity_enabled
auto_failover = bool(hub_settings.get("auto_failover", True))
auto_return_primary = bool(hub_settings.get("auto_return_primary", True))
# 1. Check Session Affinity
candidate_profiles: list[str] = []
if target_session and role_policy.session_affinity_enabled:
if target_session and affinity_enabled:
aff_rec = self.affinity.get_affinity(target_session)
if aff_rec and aff_rec.profile_id in self.config.profiles:
aff_profile = self.config.profiles[aff_rec.profile_id]
if aff_profile.enabled and self.health.is_healthy(aff_rec.profile_id, requested_model):
candidate_profiles.append(aff_rec.profile_id)
# If auto_return_primary is active, check if primary chain slot is healthy again
primary_pid = role_policy.preferred_chain[0] if role_policy.preferred_chain else None
if auto_return_primary and primary_pid and primary_pid != aff_rec.profile_id and self.health.is_healthy(primary_pid, requested_model):
# Return to primary account
pass
else:
aff_profile = self.config.profiles[aff_rec.profile_id]
if aff_profile.enabled and self.health.is_healthy(aff_rec.profile_id, requested_model):
candidate_profiles.append(aff_rec.profile_id)
# 2. Add remaining preferred chain candidates
for pid in role_policy.preferred_chain:
@ -101,7 +113,11 @@ class RouterEngine:
failover_trail: list[dict[str, Any]] = []
attempts = 0
max_attempts = min(role_policy.max_failover_attempts, len(candidate_profiles))
if auto_failover:
configured_attempts = hub_settings.get("failover_attempts", role_policy.max_failover_attempts)
max_attempts = min(int(configured_attempts), len(candidate_profiles))
else:
max_attempts = 1
for pid in candidate_profiles:
if attempts >= max_attempts:
@ -155,7 +171,7 @@ class RouterEngine:
self.leases.release(pid)
# Set / update session affinity
if target_session and role_policy.session_affinity_enabled:
if target_session and affinity_enabled:
self.affinity.set_affinity(target_session, target_role, pid, exec_request.get("model"))
# Attach router telemetry

View file

@ -0,0 +1,66 @@
"""Hermes Hub — Central Hub Settings Service.
Provides unified reading, saving, and querying of runtime settings from hub_settings.json.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict
from antigravity_provider.paths import get_hermes_home
DEFAULT_SETTINGS: Dict[str, Any] = {
"session_affinity": True,
"auto_failover": True,
"failover_attempts": 3,
"auto_return_primary": True,
"auto_monitoring": True,
"auto_update": True,
"release_channel": "stable",
"model_timeout_seconds": 60,
"monitoring_interval_seconds": 30,
}
def get_settings_file() -> Path:
"""Return the absolute path to hub_settings.json in HERMES_HOME."""
return get_hermes_home() / "hub_settings.json"
def get_hub_settings() -> Dict[str, Any]:
"""Load settings from hub_settings.json merged with standard defaults."""
sfile = get_settings_file()
merged = dict(DEFAULT_SETTINGS)
if sfile.exists():
try:
data = json.loads(sfile.read_text(encoding="utf-8"))
if isinstance(data, dict):
merged.update(data)
except Exception:
pass
# Normalize numeric types
try:
merged["failover_attempts"] = int(merged.get("failover_attempts", 3))
except (ValueError, TypeError):
merged["failover_attempts"] = 3
try:
merged["model_timeout_seconds"] = int(merged.get("model_timeout_seconds", 60))
except (ValueError, TypeError):
merged["model_timeout_seconds"] = 60
try:
merged["monitoring_interval_seconds"] = int(merged.get("monitoring_interval_seconds", 30))
except (ValueError, TypeError):
merged["monitoring_interval_seconds"] = 30
return merged
def save_hub_settings(settings: Dict[str, Any]) -> None:
"""Persist settings dictionary into hub_settings.json."""
sfile = get_settings_file()
sfile.parent.mkdir(parents=True, exist_ok=True)
sfile.write_text(json.dumps(settings, indent=2, ensure_ascii=False), encoding="utf-8")

View file

@ -364,3 +364,56 @@ def test_p0_11_rate_limit_vs_quota_classification():
classification = adapter.classify_error(exc_info.value)
assert classification.category == ErrorCategory.QUOTA_EXHAUSTED
assert classification.reset_duration_seconds == 7200
@pytest.mark.unit
def test_r4_settings_runtime_influence(tmp_path, monkeypatch):
"""R4: Verify that hub_settings.json dynamically modifies RouterEngine behavior."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
from antigravity_provider.router.settings_service import save_hub_settings
from antigravity_provider.router.adapters.antigravity_adapter import AntigravityAdapter
from antigravity_provider.router.adapters.codex_adapter import CodexAdapter
config = RouterConfig(
profiles={
"ag-orch-primary": RouterProfileConfig(profile_id="ag-orch-primary", provider="antigravity", enabled=True),
"codex-orch-fallback": RouterProfileConfig(profile_id="codex-orch-fallback", provider="openai-codex", enabled=True),
},
roles={
"orchestrator": RolePolicy(
role_name="orchestrator",
preferred_chain=["ag-orch-primary", "codex-orch-fallback"],
max_failover_attempts=2,
)
}
)
save_router_config(config)
# 1. With auto_failover=False in hub_settings.json, failover must NOT attempt fallback
save_hub_settings({"auto_failover": False})
engine = RouterEngine(config=config)
def mock_agy_quota(profile, req):
raise QuotaExceededError("Quota reached", provider="antigravity", profile_id=profile.profile_id)
mock_codex = MagicMock()
with patch.object(AntigravityAdapter, "invoke", side_effect=mock_agy_quota), \
patch.object(CodexAdapter, "invoke", mock_codex):
res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="orchestrator")
assert "error" in res or "choices" in res
# Codex fallback must NOT have been called because auto_failover was False!
assert mock_codex.call_count == 0
# 2. With auto_failover=True in hub_settings.json, failover attempts fallback
save_hub_settings({"auto_failover": True, "failover_attempts": 2})
mock_codex.return_value = {"choices": [{"message": {"role": "assistant", "content": "Fallback OK"}}]}
with patch.object(AntigravityAdapter, "invoke", side_effect=mock_agy_quota), \
patch.object(CodexAdapter, "invoke", mock_codex):
res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="orchestrator")
assert res["choices"][0]["message"]["content"] == "Fallback OK"
assert mock_codex.call_count == 1