hermes-hub/src/antigravity_provider/router/router_config.py

481 lines
20 KiB
Python

"""Configuration schema and loader for Hermes Multi-Provider Account Router."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
@dataclass
class RouterProfileConfig:
profile_id: str
provider: str # "openai-codex", "antigravity", "opencode-go"
account_id: str = ""
capabilities: list[str] = field(default_factory=list)
preferred_models: list[str] = field(default_factory=list)
fallback_models: list[str] = field(default_factory=list)
auth_config: dict[str, Any] = field(default_factory=dict)
enabled: bool = True
max_concurrency: int = 1 # 1 for stateful process, >1 for stateless REST
custom_base_url: Optional[str] = None
@dataclass
class RolePolicy:
role_name: str
preferred_chain: list[str] = field(default_factory=list) # list of profile_id
fallback_capabilities: list[str] = field(default_factory=list)
max_failover_attempts: int = 4
session_affinity_enabled: bool = True
default_model: Optional[str] = None
@dataclass
class RouterConfig:
enabled: bool = True
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)
pricing: dict[str, dict[str, float]] = 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)
def get_role_policy(self, role: str) -> RolePolicy:
if role in self.roles:
return self.roles[role]
# Return generic fallback policy
return RolePolicy(
role_name=role,
preferred_chain=list(self.profiles.keys()),
fallback_capabilities=[role],
max_failover_attempts=len(self.profiles),
session_affinity_enabled=True,
)
def get_default_router_config() -> RouterConfig:
"""Generate default built-in multi-provider configuration (16 profiles across 3 providers)."""
profiles: dict[str, RouterProfileConfig] = {
# 1. Codex Pool (3 accounts)
"codex-orch": RouterProfileConfig(
profile_id="codex-orch",
provider="openai-codex",
account_id="codex-acc-1",
capabilities=["orchestrator", "coding", "reasoning"],
preferred_models=["gpt-4o", "o3-mini", "codex"],
fallback_models=["gpt-4o-mini"],
max_concurrency=1,
),
"codex-worker-1": RouterProfileConfig(
profile_id="codex-worker-1",
provider="openai-codex",
account_id="codex-acc-2",
capabilities=["coding", "coder-primary", "reasoning"],
preferred_models=["gpt-4o", "o3-mini", "codex"],
max_concurrency=1,
),
"codex-worker-2": RouterProfileConfig(
profile_id="codex-worker-2",
provider="openai-codex",
account_id="codex-acc-3",
capabilities=["coding", "coder-secondary", "reviewer", "review"],
preferred_models=["gpt-4o", "o3-mini", "codex"],
max_concurrency=1,
),
# 2. Antigravity Pool (10 accounts, 7 active, 3 cold)
"ag-orch-fallback": RouterProfileConfig(
profile_id="ag-orch-fallback",
provider="antigravity",
account_id="ag-acc-orch",
capabilities=["orchestrator", "reasoning", "coding"],
preferred_models=["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"],
max_concurrency=1,
),
"ag-w1": RouterProfileConfig(
profile_id="ag-w1",
provider="antigravity",
account_id="ag-acc-w1",
capabilities=["coding", "coder-primary", "reasoning"],
preferred_models=["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"],
max_concurrency=1,
),
"ag-w2": RouterProfileConfig(
profile_id="ag-w2",
provider="antigravity",
account_id="ag-acc-w2",
capabilities=["coding", "coder-secondary", "reviewer", "review"],
preferred_models=["gemini-3.7-flash", "gemini-3.5-flash"],
max_concurrency=1,
),
"ag-w3": RouterProfileConfig(
profile_id="ag-w3",
provider="antigravity",
account_id="ag-acc-w3",
capabilities=["research", "reasoning", "search"],
preferred_models=["gemini-3.7-flash", "claude-sonnet-4-6"],
max_concurrency=1,
),
"ag-w4": RouterProfileConfig(
profile_id="ag-w4",
provider="antigravity",
account_id="ag-acc-w4",
capabilities=["coding", "reasoning", "fast"],
preferred_models=["gemini-3.5-flash", "gemini-3.7-flash"],
max_concurrency=1,
),
"ag-spare-1": RouterProfileConfig(
profile_id="ag-spare-1",
provider="antigravity",
account_id="ag-acc-sp1",
capabilities=["hot-spare", "coding", "reasoning", "orchestrator", "research", "fast"],
preferred_models=["gemini-3.7-flash", "gemini-3.5-flash"],
max_concurrency=1,
),
"ag-spare-2": RouterProfileConfig(
profile_id="ag-spare-2",
provider="antigravity",
account_id="ag-acc-sp2",
capabilities=["hot-spare", "coding", "reasoning", "orchestrator", "research", "fast"],
preferred_models=["gemini-3.7-flash", "gemini-3.5-flash"],
max_concurrency=1,
),
"ag-cold-1": RouterProfileConfig(
profile_id="ag-cold-1",
provider="antigravity",
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-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-c3",
capabilities=["cold-spare", "coding", "reasoning"],
preferred_models=["gemini-3.5-flash"],
enabled=False,
max_concurrency=1,
),
# 3. OpenCode Go Pool (3 accounts)
"opengo-1": RouterProfileConfig(
profile_id="opengo-1",
provider="opencode-go",
account_id="opengo-acc-1",
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=["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=["fallback", "coding", "reasoning"],
preferred_models=["deepseek-r1", "deepseek-v3", "qwen-2.5-coder-32b"],
max_concurrency=5,
),
# 4. Claude Pool (3 accounts)
"claude-orch": RouterProfileConfig(
profile_id="claude-orch",
provider="claude",
account_id="claude-acc-1",
capabilities=["orchestrator", "coding", "reasoning"],
preferred_models=["claude-3-7-sonnet", "claude-3-5-haiku", "claude-sonnet-4-6"],
max_concurrency=2,
),
"claude-worker-1": RouterProfileConfig(
profile_id="claude-worker-1",
provider="claude",
account_id="claude-acc-2",
capabilities=["coding", "coder-primary", "reasoning"],
preferred_models=["claude-3-7-sonnet", "claude-3-5-haiku", "claude-sonnet-4-6"],
max_concurrency=2,
),
"claude-worker-2": RouterProfileConfig(
profile_id="claude-worker-2",
provider="claude",
account_id="claude-acc-3",
capabilities=["coding", "coder-secondary", "reviewer", "review"],
preferred_models=["claude-3-7-sonnet", "claude-3-5-haiku"],
max_concurrency=2,
),
# 5. Grok Pool (3 accounts)
"grok-orch": RouterProfileConfig(
profile_id="grok-orch",
provider="grok",
account_id="grok-acc-1",
capabilities=["orchestrator", "coding", "reasoning"],
preferred_models=["grok-3", "grok-3-mini", "grok-4.5"],
max_concurrency=2,
),
"grok-worker-1": RouterProfileConfig(
profile_id="grok-worker-1",
provider="grok",
account_id="grok-acc-2",
capabilities=["coding", "coder-primary", "reasoning"],
preferred_models=["grok-3", "grok-3-mini", "grok-4.5"],
max_concurrency=2,
),
"grok-worker-2": RouterProfileConfig(
profile_id="grok-worker-2",
provider="grok",
account_id="grok-acc-3",
capabilities=["research", "reasoning", "fast"],
preferred_models=["grok-3", "grok-3-mini"],
max_concurrency=2,
),
}
roles: dict[str, RolePolicy] = {
"orchestrator": RolePolicy(
role_name="orchestrator",
preferred_chain=["codex-orch", "ag-orch-fallback", "opengo-3"],
fallback_capabilities=["orchestrator", "reasoning"],
max_failover_attempts=3,
session_affinity_enabled=True,
default_model="gemini-3.7-flash",
),
"coder-primary": RolePolicy(
role_name="coder-primary",
preferred_chain=["codex-worker-1", "ag-w1", "opengo-3"],
fallback_capabilities=["coding"],
max_failover_attempts=3,
session_affinity_enabled=True,
),
"coder-secondary": RolePolicy(
role_name="coder-secondary",
preferred_chain=["codex-worker-2", "ag-w2", "opengo-2"],
fallback_capabilities=["coding", "reviewer"],
max_failover_attempts=3,
session_affinity_enabled=True,
),
"reviewer": RolePolicy(
role_name="reviewer",
preferred_chain=["codex-worker-2", "opengo-2", "ag-w2"],
fallback_capabilities=["reviewer", "coding"],
max_failover_attempts=3,
session_affinity_enabled=True,
),
"research": RolePolicy(
role_name="research",
preferred_chain=["opengo-1", "ag-w3", "ag-w4"],
fallback_capabilities=["research", "search"],
max_failover_attempts=3,
session_affinity_enabled=True,
),
"fast": RolePolicy(
role_name="fast",
preferred_chain=["opengo-1", "ag-w4", "ag-spare-1"],
fallback_capabilities=["fast"],
max_failover_attempts=3,
session_affinity_enabled=True,
),
}
return RouterConfig(
enabled=True,
default_role="orchestrator",
roles=roles,
profiles=profiles,
)
def load_router_config(config_path: Optional[Path] = None) -> RouterConfig:
"""Load RouterConfig from YAML file or return default built-in configuration."""
if config_path is None:
env_config = os.environ.get("HERMES_ROUTER_CONFIG", "").strip()
if env_config:
config_path = Path(env_config).expanduser()
else:
hermes_home = Path(os.environ.get("HERMES_HOME", "~/.hermes")).expanduser()
if os.name == "nt" and "HERMES_HOME" not in os.environ:
local_app = os.environ.get("LOCALAPPDATA", "")
if local_app and (Path(local_app) / "hermes").exists():
hermes_home = Path(local_app) / "hermes"
config_path = hermes_home / "config" / "router_profiles.yaml"
if not config_path.is_file():
return get_default_router_config()
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():
profiles[pid] = RouterProfileConfig(
profile_id=pid,
provider=pdata.get("provider", "antigravity"),
account_id=pdata.get("account_id", pid),
capabilities=list(pdata.get("capabilities", [])),
preferred_models=list(pdata.get("preferred_models", [])),
fallback_models=list(pdata.get("fallback_models", [])),
auth_config=dict(pdata.get("auth_config", {})),
enabled=bool(pdata.get("enabled", True)),
max_concurrency=int(pdata.get("max_concurrency", 1)),
custom_base_url=pdata.get("custom_base_url"),
)
roles_raw = data.get("roles", {})
roles: dict[str, RolePolicy] = {}
for rname, rdata in roles_raw.items():
roles[rname] = RolePolicy(
role_name=rname,
preferred_chain=list(rdata.get("preferred_chain", [])),
fallback_capabilities=list(rdata.get("fallback_capabilities", [])),
max_failover_attempts=int(rdata.get("max_failover_attempts", 4)),
session_affinity_enabled=bool(rdata.get("session_affinity_enabled", True)),
default_model=rdata.get("default_model"),
)
pricing_raw = data.get("pricing", {})
pricing: dict[str, dict[str, float]] = {}
if isinstance(pricing_raw, dict):
for m_name, p_entry in pricing_raw.items():
if isinstance(p_entry, dict):
pricing[m_name] = {
"input_cost_per_m": float(p_entry.get("input_cost_per_m", 0.0)),
"output_cost_per_m": float(p_entry.get("output_cost_per_m", 0.0)),
}
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=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,
pricing=pricing,
raw_router_block=r_block,
)
except Exception as e:
# Fall back gracefully to built-in defaults on YAML error
return get_default_router_config()
def save_router_config(config: RouterConfig, config_path: Optional[Path] = None) -> bool:
"""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:
config_path = Path(env_config).expanduser()
else:
hermes_home = Path(os.environ.get("HERMES_HOME", "~/.hermes")).expanduser()
if os.name == "nt" and "HERMES_HOME" not in os.environ:
local_app = os.environ.get("LOCALAPPDATA", "")
if local_app and (Path(local_app) / "hermes").exists():
hermes_home = Path(local_app) / "hermes"
config_path = hermes_home / "config" / "router_profiles.yaml"
try:
config_path.parent.mkdir(parents=True, exist_ok=True)
profiles_data = {}
for pid, pcfg in config.profiles.items():
profiles_data[pid] = {
"provider": pcfg.provider,
"account_id": pcfg.account_id,
"capabilities": pcfg.capabilities,
"preferred_models": pcfg.preferred_models,
"fallback_models": pcfg.fallback_models,
"enabled": pcfg.enabled,
"max_concurrency": pcfg.max_concurrency,
}
if pcfg.custom_base_url:
profiles_data[pid]["custom_base_url"] = pcfg.custom_base_url
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,
"session_affinity_enabled": rpol.session_affinity_enabled,
}
if rpol.default_model:
roles_data[rname]["default_model"] = rpol.default_model
router_block = dict(config.raw_router_block) if config.raw_router_block else {}
router_block.update({
"enabled": config.enabled,
"default_role": config.default_role,
"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,
}
if config.pricing:
data["pricing"] = config.pricing
existing_comments = []
if config_path.exists():
try:
for line in config_path.read_text(encoding="utf-8").splitlines():
if line.strip().startswith("#"):
existing_comments.append(line)
elif not line.strip():
if existing_comments:
existing_comments.append(line)
else:
break
except Exception:
pass
dumped_yaml = yaml.safe_dump(data, sort_keys=False, allow_unicode=True)
if existing_comments:
content = "\n".join(existing_comments).rstrip() + "\n\n" + dumped_yaml
else:
content = "# Hermes Router Configuration\n# Multi-Provider Profile and Role Routing Rules\n\n" + dumped_yaml
config_path.write_text(content, encoding="utf-8")
return True
except Exception:
return False