fix(router): connect persisted settings to runtime behavior
This commit is contained in:
parent
925e6c6687
commit
70d34c1642
3 changed files with 142 additions and 7 deletions
|
|
@ -77,13 +77,25 @@ 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:
|
||||
# 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
66
src/antigravity_provider/router/settings_service.py
Normal file
66
src/antigravity_provider/router/settings_service.py
Normal 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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue