hermes-hub/src/antigravity_provider/router/router_config.py
ochenstarik-ui 2b89372b0f feat(config): A41 чистая конфигурация при первой установке, безопасность учетных данных и сброс
1. P0-1: get_default_router_config() возвращает чистую конфигурацию (0 профилей,
   13 канонических ролей с пустыми цепочками). Миграция не внедряет фиктивные профили.
2. P0-2: Учетные данные (~/.hermes/*_profiles/, hub_settings.json) изолированы и
   никогда не затрагиваются при сбросе или установке.
3. P0-3: Профили создаются динамически при подключении аккаунтов (ag-1, codex-1, etc.).
   Пустые цепочки ролей являются нормальным рабочим состоянием.
4. P0-4: Добавлен экшен reset_router_config и кнопка «Начать настройку заново»
   в настройках с подтверждением и созданием бэкапа router_profiles.yaml.bak_<timestamp>.
5. P0-5: scripts/verify_multi_provider_router.py адаптирован и проходит 10/10 PASS
   как на пустой конфигурации, так и на заполненной.
6. tests/test_a41_clean_install.py: 6 тестов, 490 passed, ruff чисто.
2026-08-31 02:27:42 +07:00

339 lines
15 KiB
Python
Raw 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.

"""Configuration schema and loader for Hermes Multi-Provider Account Router."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from antigravity_provider.router.role_registry import RoleRegistry
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
request_options: dict[str, Any] = field(default_factory=dict)
@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 = "manager"
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
quota_threshold_percent: float = 10.0
quota_threshold_action: str = "notify" # "notify" | "switch"
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 clean multi-provider configuration (0 profiles, 13 canonical roles with empty chains)."""
roles = RoleRegistry.get_default_role_policies()
return RouterConfig(
enabled=True,
default_role="manager",
roles=roles,
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:
from antigravity_provider.paths import get_router_profiles_path
config_path = get_router_profiles_path()
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():
provider = pdata.get("provider", "antigravity")
max_concurrency = int(pdata.get("max_concurrency", 1))
if provider == "local":
max_concurrency = 1
req_opts = pdata.get("request_options")
if not isinstance(req_opts, dict):
req_opts = {}
profiles[pid] = RouterProfileConfig(
profile_id=pid,
provider=provider,
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=max_concurrency,
custom_base_url=pdata.get("custom_base_url"),
request_options=dict(req_opts),
)
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)))
raw_default_role = str(r_block.get("default_role", data.get("default_role", "manager"))).strip().lower()
default_role = RoleRegistry.resolve_canonical_role(raw_default_role) if raw_default_role else "manager"
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)))
try:
quota_threshold_percent = float(r_block.get("quota_threshold_percent", data.get("quota_threshold_percent", 10.0)))
except (ValueError, TypeError):
quota_threshold_percent = 10.0
quota_threshold_action = str(r_block.get("quota_threshold_action", data.get("quota_threshold_action", "notify"))).strip().lower()
if quota_threshold_action not in ("notify", "switch"):
quota_threshold_action = "notify"
# Automatic Idempotent Migration (A41 Clean Install)
# Merge missing default roles into loaded user configuration without injecting fake profiles
default_cfg = get_default_router_config()
migration_needed = False
new_roles_added: list[str] = []
if not profiles:
profiles = {}
if not roles:
roles = default_cfg.roles
migration_needed = True
else:
# Сначала переименование старых ролей в канонические, и только
# потом дополнение недостающими.
#
# Раньше здесь просто дописывались отсутствующие умолчания, а
# RoleRegistry.migrate_legacy_roles не вызывалась ниоткуда —
# проверено поиском по всему коду. В результате старые роли
# оставались рядом с новыми: на конфигурации владельца интерфейс
# показывал 19 агентов вместо 13, причём шесть пар были неотличимы
# по названию (orchestrator и manager — оба «Менеджер проекта»,
# reviewer и code-reviewer — оба «Ревьюер кода»). Разложить
# аккаунты по такому списку невозможно.
#
# migrate_legacy_roles переносит preferred_chain дословно, поэтому
# порядок аккаунтов, выставленный владельцем, сохраняется.
renamed, renamed_any = RoleRegistry.migrate_legacy_roles(roles)
if renamed_any:
new_roles_added.extend(sorted(set(renamed) - set(roles)))
roles = renamed
migration_needed = True
for def_rname, def_rpolicy in default_cfg.roles.items():
if def_rname not in roles:
roles[def_rname] = def_rpolicy
new_roles_added.append(def_rname)
migration_needed = True
if migration_needed and config_path.is_file():
# 1. Create a backup file
try:
import shutil
import time
backup_path = config_path.with_name(f"{config_path.name}.bak_{int(time.time())}")
if not backup_path.exists():
shutil.copy2(config_path, backup_path)
except Exception as b_err:
pass
# 2. Save migrated config back
try:
cfg_to_save = 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,
quota_threshold_percent=quota_threshold_percent,
quota_threshold_action=quota_threshold_action,
roles=roles,
profiles=profiles,
pricing=pricing,
raw_router_block=r_block,
)
save_router_config(cfg_to_save, config_path)
except Exception:
pass
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,
quota_threshold_percent=quota_threshold_percent,
quota_threshold_action=quota_threshold_action,
roles=roles,
profiles=profiles,
pricing=pricing,
raw_router_block=r_block,
)
except Exception:
# 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:
from antigravity_provider.paths import get_router_profiles_path
config_path = get_router_profiles_path()
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
if pcfg.request_options:
profiles_data[pid]["request_options"] = pcfg.request_options
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,
"quota_threshold_percent": config.quota_threshold_percent,
"quota_threshold_action": config.quota_threshold_action,
})
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