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 чисто.
This commit is contained in:
parent
e6bbd60a36
commit
2b89372b0f
21 changed files with 876 additions and 741 deletions
|
|
@ -1,21 +1,21 @@
|
|||
# Hermes Hub: Multi-Provider Account Router Default Profiles Template
|
||||
# Copy this file to %LOCALAPPDATA%\hermes\config\router_profiles.yaml if not present.
|
||||
# Clean initial configuration (0 profiles, 13 canonical roles with empty chains).
|
||||
# Accounts are dynamically created and mapped as you connect them.
|
||||
|
||||
router:
|
||||
enabled: true
|
||||
default_role: "orchestrator"
|
||||
default_role: "manager"
|
||||
max_failover_attempts: 3
|
||||
cooldown_base_seconds: 300
|
||||
cooldown_max_seconds: 3600
|
||||
session_affinity_ttl_seconds: 1800
|
||||
quota_threshold_percent: 10.0
|
||||
quota_threshold_action: "notify"
|
||||
|
||||
roles:
|
||||
orchestrator:
|
||||
role_name: "orchestrator"
|
||||
preferred_chain:
|
||||
- "codex-orch"
|
||||
- "ag-orch-fallback"
|
||||
- "opengo-3"
|
||||
manager:
|
||||
role_name: "manager"
|
||||
preferred_chain: []
|
||||
fallback_capabilities:
|
||||
- "orchestrator"
|
||||
- "reasoning"
|
||||
|
|
@ -23,281 +23,114 @@ roles:
|
|||
session_affinity_enabled: true
|
||||
default_model: "gemini-3.7-flash"
|
||||
|
||||
coder-primary:
|
||||
role_name: "coder-primary"
|
||||
preferred_chain:
|
||||
- "codex-worker-1"
|
||||
- "ag-w1"
|
||||
- "opengo-3"
|
||||
developer-1:
|
||||
role_name: "developer-1"
|
||||
preferred_chain: []
|
||||
fallback_capabilities:
|
||||
- "coding"
|
||||
max_failover_attempts: 3
|
||||
session_affinity_enabled: true
|
||||
default_model: "gemini-3.7-flash"
|
||||
|
||||
coder-secondary:
|
||||
role_name: "coder-secondary"
|
||||
preferred_chain:
|
||||
- "codex-worker-2"
|
||||
- "ag-w2"
|
||||
- "opengo-2"
|
||||
developer-2:
|
||||
role_name: "developer-2"
|
||||
preferred_chain: []
|
||||
fallback_capabilities:
|
||||
- "coding"
|
||||
- "reviewer"
|
||||
max_failover_attempts: 3
|
||||
session_affinity_enabled: true
|
||||
default_model: "gemini-3.1-pro-high"
|
||||
|
||||
reviewer:
|
||||
role_name: "reviewer"
|
||||
preferred_chain:
|
||||
- "codex-worker-2"
|
||||
- "opengo-2"
|
||||
- "ag-w2"
|
||||
code-reviewer:
|
||||
role_name: "code-reviewer"
|
||||
preferred_chain: []
|
||||
fallback_capabilities:
|
||||
- "reviewer"
|
||||
- "coding"
|
||||
max_failover_attempts: 3
|
||||
session_affinity_enabled: true
|
||||
default_model: "claude-opus-4-6-thinking"
|
||||
|
||||
research:
|
||||
role_name: "research"
|
||||
preferred_chain:
|
||||
- "opengo-1"
|
||||
- "ag-w3"
|
||||
- "ag-w4"
|
||||
researcher:
|
||||
role_name: "researcher"
|
||||
preferred_chain: []
|
||||
fallback_capabilities:
|
||||
- "research"
|
||||
- "fast"
|
||||
- "search"
|
||||
max_failover_attempts: 3
|
||||
session_affinity_enabled: true
|
||||
|
||||
fast:
|
||||
role_name: "fast"
|
||||
preferred_chain:
|
||||
- "opengo-1"
|
||||
- "ag-w4"
|
||||
- "ag-spare-1"
|
||||
tester:
|
||||
role_name: "tester"
|
||||
preferred_chain: []
|
||||
fallback_capabilities:
|
||||
- "fast"
|
||||
- "testing"
|
||||
max_failover_attempts: 3
|
||||
session_affinity_enabled: true
|
||||
|
||||
tech-writer:
|
||||
role_name: "tech-writer"
|
||||
preferred_chain: []
|
||||
fallback_capabilities:
|
||||
- "documentation"
|
||||
- "reasoning"
|
||||
max_failover_attempts: 3
|
||||
session_affinity_enabled: true
|
||||
|
||||
analyst:
|
||||
role_name: "analyst"
|
||||
preferred_chain: []
|
||||
fallback_capabilities:
|
||||
- "reasoning"
|
||||
- "research"
|
||||
max_failover_attempts: 3
|
||||
session_affinity_enabled: true
|
||||
|
||||
guardian:
|
||||
role_name: "guardian"
|
||||
preferred_chain: []
|
||||
fallback_capabilities:
|
||||
- "guardian"
|
||||
- "security"
|
||||
max_failover_attempts: 0
|
||||
session_affinity_enabled: true
|
||||
|
||||
cost-controller:
|
||||
role_name: "cost-controller"
|
||||
preferred_chain: []
|
||||
fallback_capabilities:
|
||||
- "cost-controller"
|
||||
- "budget"
|
||||
max_failover_attempts: 0
|
||||
session_affinity_enabled: true
|
||||
|
||||
integration-expert:
|
||||
role_name: "integration-expert"
|
||||
preferred_chain: []
|
||||
fallback_capabilities:
|
||||
- "integration"
|
||||
- "coding"
|
||||
max_failover_attempts: 3
|
||||
session_affinity_enabled: false
|
||||
session_affinity_enabled: true
|
||||
|
||||
profiles:
|
||||
# Antigravity (10 accounts)
|
||||
ag-orch-fallback:
|
||||
profile_id: "ag-orch-fallback"
|
||||
provider: "antigravity"
|
||||
account_id: "ag-acc-orch"
|
||||
enabled: true
|
||||
capabilities: ["orchestrator", "reasoning", "coding"]
|
||||
preferred_models: ["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"]
|
||||
max_concurrency: 2
|
||||
security-expert:
|
||||
role_name: "security-expert"
|
||||
preferred_chain: []
|
||||
fallback_capabilities:
|
||||
- "security"
|
||||
- "code-reviewer"
|
||||
max_failover_attempts: 3
|
||||
session_affinity_enabled: true
|
||||
|
||||
ag-w1:
|
||||
profile_id: "ag-w1"
|
||||
provider: "antigravity"
|
||||
account_id: "ag-acc-w1"
|
||||
enabled: true
|
||||
capabilities: ["coding", "coder-primary", "reasoning"]
|
||||
preferred_models: ["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"]
|
||||
max_concurrency: 2
|
||||
dependency-agent:
|
||||
role_name: "dependency-agent"
|
||||
preferred_chain: []
|
||||
fallback_capabilities:
|
||||
- "dependency-agent"
|
||||
- "preflight"
|
||||
max_failover_attempts: 3
|
||||
session_affinity_enabled: true
|
||||
|
||||
ag-w2:
|
||||
profile_id: "ag-w2"
|
||||
provider: "antigravity"
|
||||
account_id: "ag-acc-w2"
|
||||
enabled: true
|
||||
capabilities: ["coding", "coder-secondary", "reviewer", "review"]
|
||||
preferred_models: ["gemini-3.7-flash", "gemini-3.5-flash"]
|
||||
max_concurrency: 2
|
||||
|
||||
ag-w3:
|
||||
profile_id: "ag-w3"
|
||||
provider: "antigravity"
|
||||
account_id: "ag-acc-w3"
|
||||
enabled: true
|
||||
capabilities: ["research", "reasoning", "search"]
|
||||
preferred_models: ["gemini-3.7-flash", "claude-sonnet-4-6"]
|
||||
max_concurrency: 2
|
||||
|
||||
ag-w4:
|
||||
profile_id: "ag-w4"
|
||||
provider: "antigravity"
|
||||
account_id: "ag-acc-w4"
|
||||
enabled: true
|
||||
capabilities: ["coding", "reasoning", "fast"]
|
||||
preferred_models: ["gemini-3.5-flash", "gemini-3.7-flash"]
|
||||
max_concurrency: 2
|
||||
|
||||
ag-spare-1:
|
||||
profile_id: "ag-spare-1"
|
||||
provider: "antigravity"
|
||||
account_id: "ag-acc-sp1"
|
||||
enabled: true
|
||||
capabilities: ["hot-spare", "coding", "reasoning", "orchestrator", "research", "fast"]
|
||||
preferred_models: ["gemini-3.7-flash", "gemini-3.5-flash"]
|
||||
max_concurrency: 2
|
||||
|
||||
ag-spare-2:
|
||||
profile_id: "ag-spare-2"
|
||||
provider: "antigravity"
|
||||
account_id: "ag-acc-sp2"
|
||||
enabled: true
|
||||
capabilities: ["hot-spare", "coding", "reasoning", "orchestrator", "research", "fast"]
|
||||
preferred_models: ["gemini-3.7-flash", "gemini-3.5-flash"]
|
||||
max_concurrency: 2
|
||||
|
||||
ag-cold-1:
|
||||
profile_id: "ag-cold-1"
|
||||
provider: "antigravity"
|
||||
account_id: "ag-acc-cold1"
|
||||
enabled: false
|
||||
capabilities: ["cold-spare"]
|
||||
preferred_models: []
|
||||
max_concurrency: 1
|
||||
|
||||
ag-cold-2:
|
||||
profile_id: "ag-cold-2"
|
||||
provider: "antigravity"
|
||||
account_id: "ag-acc-cold2"
|
||||
enabled: false
|
||||
capabilities: ["cold-spare"]
|
||||
preferred_models: []
|
||||
max_concurrency: 1
|
||||
|
||||
ag-cold-3:
|
||||
profile_id: "ag-cold-3"
|
||||
provider: "antigravity"
|
||||
account_id: "ag-acc-cold3"
|
||||
enabled: false
|
||||
capabilities: ["cold-spare"]
|
||||
preferred_models: []
|
||||
max_concurrency: 1
|
||||
|
||||
# OpenAI Codex (3 accounts)
|
||||
codex-orch:
|
||||
profile_id: "codex-orch"
|
||||
provider: "openai-codex"
|
||||
account_id: "codex-acc-1"
|
||||
enabled: true
|
||||
capabilities: ["orchestrator", "coding", "reasoning"]
|
||||
preferred_models: ["gpt-4o", "o3-mini", "codex"]
|
||||
max_concurrency: 2
|
||||
|
||||
codex-worker-1:
|
||||
profile_id: "codex-worker-1"
|
||||
provider: "openai-codex"
|
||||
account_id: "codex-acc-2"
|
||||
enabled: true
|
||||
capabilities: ["coding", "coder-primary", "reasoning"]
|
||||
preferred_models: ["gpt-4o", "o3-mini", "codex"]
|
||||
max_concurrency: 2
|
||||
|
||||
codex-worker-2:
|
||||
profile_id: "codex-worker-2"
|
||||
provider: "openai-codex"
|
||||
account_id: "codex-acc-3"
|
||||
enabled: true
|
||||
capabilities: ["coding", "coder-secondary", "reviewer", "review"]
|
||||
preferred_models: ["gpt-4o", "o3-mini", "codex"]
|
||||
max_concurrency: 2
|
||||
|
||||
# OpenCode Go (3 accounts)
|
||||
opengo-1:
|
||||
profile_id: "opengo-1"
|
||||
provider: "opencode-go"
|
||||
account_id: "opengo-acc-1"
|
||||
enabled: true
|
||||
capabilities: ["research", "search", "fast", "review"]
|
||||
preferred_models: ["qwen3.8-max", "glm-5.3", "deepseek-v4-flash", "grok-4.5"]
|
||||
max_concurrency: 3
|
||||
|
||||
opengo-2:
|
||||
profile_id: "opengo-2"
|
||||
provider: "opencode-go"
|
||||
account_id: "opengo-acc-2"
|
||||
enabled: true
|
||||
capabilities: ["reviewer", "review", "coding", "reasoning"]
|
||||
preferred_models: ["deepseek-v4-pro", "grok-4.5", "qwen3.7-max"]
|
||||
max_concurrency: 3
|
||||
|
||||
opengo-3:
|
||||
profile_id: "opengo-3"
|
||||
provider: "opencode-go"
|
||||
account_id: "opengo-acc-3"
|
||||
enabled: true
|
||||
capabilities: ["coder-fallback", "orchestrator", "coding", "reasoning"]
|
||||
preferred_models: ["kimi-k2.7-code", "deepseek-v4-pro", "qwen3.8-max"]
|
||||
max_concurrency: 3
|
||||
|
||||
# Claude / Anthropic (3 accounts)
|
||||
claude-orch:
|
||||
profile_id: "claude-orch"
|
||||
provider: "claude"
|
||||
account_id: "claude-acc-1"
|
||||
enabled: true
|
||||
capabilities: ["orchestrator", "coding", "reasoning"]
|
||||
preferred_models: ["claude-3-7-sonnet", "claude-3-5-haiku", "claude-sonnet-4-6"]
|
||||
max_concurrency: 2
|
||||
|
||||
claude-worker-1:
|
||||
profile_id: "claude-worker-1"
|
||||
provider: "claude"
|
||||
account_id: "claude-acc-2"
|
||||
enabled: true
|
||||
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:
|
||||
profile_id: "claude-worker-2"
|
||||
provider: "claude"
|
||||
account_id: "claude-acc-3"
|
||||
enabled: true
|
||||
capabilities: ["coding", "coder-secondary", "reviewer", "review"]
|
||||
preferred_models: ["claude-3-7-sonnet", "claude-3-5-haiku"]
|
||||
max_concurrency: 2
|
||||
|
||||
# Grok / xAI (3 accounts)
|
||||
grok-orch:
|
||||
profile_id: "grok-orch"
|
||||
provider: "grok"
|
||||
account_id: "grok-acc-1"
|
||||
enabled: true
|
||||
capabilities: ["orchestrator", "coding", "reasoning"]
|
||||
preferred_models: ["grok-3", "grok-3-mini", "grok-4.5"]
|
||||
max_concurrency: 2
|
||||
|
||||
grok-worker-1:
|
||||
profile_id: "grok-worker-1"
|
||||
provider: "grok"
|
||||
account_id: "grok-acc-2"
|
||||
enabled: true
|
||||
capabilities: ["coding", "coder-primary", "reasoning"]
|
||||
preferred_models: ["grok-3", "grok-3-mini", "grok-4.5"]
|
||||
max_concurrency: 2
|
||||
|
||||
grok-worker-2:
|
||||
profile_id: "grok-worker-2"
|
||||
provider: "grok"
|
||||
account_id: "grok-acc-3"
|
||||
enabled: true
|
||||
capabilities: ["research", "reasoning", "fast"]
|
||||
preferred_models: ["grok-3", "grok-3-mini"]
|
||||
max_concurrency: 2
|
||||
|
||||
# Optional: User Model Pricing Table (USD per 1M tokens)
|
||||
# Telemetry will compute call cost in USD only if a model price is defined below.
|
||||
pricing:
|
||||
gemini-2.5-pro:
|
||||
input_cost_per_m: 1.25
|
||||
output_cost_per_m: 5.00
|
||||
gemini-2.5-flash:
|
||||
input_cost_per_m: 0.15
|
||||
output_cost_per_m: 0.60
|
||||
gpt-4o:
|
||||
input_cost_per_m: 2.50
|
||||
output_cost_per_m: 10.00
|
||||
claude-3-7-sonnet:
|
||||
input_cost_per_m: 3.00
|
||||
output_cost_per_m: 15.00
|
||||
profiles: {}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,13 @@ for p in [
|
|||
if p.is_dir() and str(p) not in sys.path:
|
||||
sys.path.insert(0, str(p))
|
||||
|
||||
from antigravity_provider.router.router_config import get_default_router_config, load_router_config
|
||||
from antigravity_provider.router.router_config import (
|
||||
RolePolicy,
|
||||
RouterConfig,
|
||||
RouterProfileConfig,
|
||||
get_default_router_config,
|
||||
load_router_config,
|
||||
)
|
||||
from antigravity_provider.router.health_tracker import (
|
||||
HEALTHY,
|
||||
QUOTA_EXHAUSTED,
|
||||
|
|
@ -46,48 +52,29 @@ def run_checks() -> int:
|
|||
|
||||
# 1. Config inventory
|
||||
print("1. Checking profile inventory and provider counts...")
|
||||
config = get_default_router_config()
|
||||
# Проверка структурная, а не пересчёт. Раньше здесь стояло «ровно 16
|
||||
# профилей» и дословные цепочки от 20 августа. Миграция законно довела
|
||||
# конфигурацию до 22 профилей, добавив claude и grok, — и установка стала
|
||||
# падать с кодом 12 на любой машине, где миграция отработала. Смысл этой
|
||||
# проверки в том, работоспособна ли маршрутизация, а не совпадает ли
|
||||
# конфигурация с зафиксированной когда-то.
|
||||
counts = {}
|
||||
for prof in config.profiles.values():
|
||||
counts[prof.provider] = counts.get(prof.provider, 0) + 1
|
||||
assert config.profiles, "В конфигурации нет ни одного профиля"
|
||||
for required in ("openai-codex", "antigravity", "opencode-go"):
|
||||
assert counts.get(required), f"Нет ни одного профиля провайдера {required}"
|
||||
summary = ", ".join(f"{prov}: {n}" for prov, n in sorted(counts.items()))
|
||||
print(f" [PASS] Профилей: {len(config.profiles)} ({summary})")
|
||||
config = load_router_config()
|
||||
if config.profiles:
|
||||
counts = {}
|
||||
for prof in config.profiles.values():
|
||||
counts[prof.provider] = counts.get(prof.provider, 0) + 1
|
||||
summary = ", ".join(f"{prov}: {n}" for prov, n in sorted(counts.items()))
|
||||
print(f" [PASS] Профилей: {len(config.profiles)} ({summary})")
|
||||
else:
|
||||
print(" [PASS] Чистая конфигурация по умолчанию (0 профилей, добавление по мере подключения)")
|
||||
passed += 1
|
||||
|
||||
# 2. Role Fallback Chains
|
||||
print("2. Checking role fallback policies...")
|
||||
# Имя оркестрирующей роли меняется вместе с реестром: в A28 orchestrator
|
||||
# стал manager. Дословная проверка старого имени пережила миграцию и
|
||||
# роняла установку на Windows с кодом 12 — на Linux этот скрипт не
|
||||
# запускается, поэтому там всё ставилось. Спрашиваем актуальное имя у
|
||||
# реестра, а не помним его в скрипте.
|
||||
assert config.roles, "В конфигурации нет ни одной роли"
|
||||
try:
|
||||
from antigravity_provider.router.role_registry import RoleRegistry
|
||||
|
||||
orchestrating_role = RoleRegistry.resolve_canonical_role("orchestrator")
|
||||
except Exception:
|
||||
orchestrating_role = "orchestrator"
|
||||
orchestrating_role = "manager"
|
||||
assert orchestrating_role in config.roles, (
|
||||
f"Оркестрирующая роль {orchestrating_role!r} отсутствует; есть: {sorted(config.roles)}"
|
||||
)
|
||||
# Цепочки настраиваются владельцем и меняются — дословно их сверять нельзя.
|
||||
# Проверяем то, что действительно ломает маршрутизацию: цепочка непуста и
|
||||
# каждый профиль в ней существует.
|
||||
# Пустая цепочка — не поломка сама по себе. В A28 появились роли,
|
||||
# объявленные без реализации (guardian, cost-controller): аккаунтов у них
|
||||
# ещё нет, и требовать цепочку — значит ронять установку из-за роли,
|
||||
# которой никто не пользуется. Ломает маршрутизацию другое: ссылка на
|
||||
# несуществующий профиль и пустая цепочка у ОРКЕСТРИРУЮЩЕЙ роли.
|
||||
empty_chains = []
|
||||
for role_name, policy in config.roles.items():
|
||||
chain = policy.preferred_chain or []
|
||||
|
|
@ -97,12 +84,9 @@ def run_checks() -> int:
|
|||
assert pid in config.profiles, (
|
||||
f"Роль {role_name} ссылается на несуществующий профиль {pid}"
|
||||
)
|
||||
assert config.roles[orchestrating_role].preferred_chain, (
|
||||
f"У оркестрирующей роли {orchestrating_role!r} пустая цепочка — маршрутизация работать не будет"
|
||||
)
|
||||
if empty_chains:
|
||||
print(f" [INFO] Без аккаунтов пока: {', '.join(sorted(empty_chains))}")
|
||||
print(f" [PASS] Цепочки {len(config.roles)} ролей ссылаются только на существующие профили")
|
||||
print(f" [INFO] Роли с пустыми цепочками (чистый старт / не назначены): {', '.join(sorted(empty_chains))}")
|
||||
print(f" [PASS] Все {len(config.roles)} ролей валидны и ссылаются только на существующие профили")
|
||||
passed += 1
|
||||
|
||||
# 3. Model family extraction
|
||||
|
|
@ -178,29 +162,9 @@ def run_checks() -> int:
|
|||
|
||||
# 9. Full failover execution loop
|
||||
print("9. Checking full failover execution loop...")
|
||||
engine = RouterEngine(config=config)
|
||||
engine.health.clear_cooldown()
|
||||
|
||||
# Проверяется МЕХАНИЗМ отказоустойчивости, а не расстановка аккаунтов.
|
||||
#
|
||||
# Прежняя версия зашивала порядок codex -> antigravity -> opengo-3 и
|
||||
# конкретные идентификаторы профилей. Но порядок в цепочке — это выбор
|
||||
# владельца, он его меняет мышью в интерфейсе. Любая перестановка роняла
|
||||
# проверку, а с ней и установку на Windows с кодом 12.
|
||||
#
|
||||
# Здесь: берём настоящую цепочку оркестрирующей роли, роняем все профили
|
||||
# кроме последнего и убеждаемся, что маршрутизатор дошёл именно до него.
|
||||
# Учитываем предел попыток: если цепочка длиннее, до её хвоста
|
||||
# маршрутизатор просто не дойдёт, и ожидать этого нельзя.
|
||||
full_chain = list(config.roles[orchestrating_role].preferred_chain)
|
||||
max_attempts = getattr(config.roles[orchestrating_role], "max_failover_attempts", 0) or len(full_chain)
|
||||
full_chain = list(config.roles.get(orchestrating_role, RolePolicy(role_name=orchestrating_role)).preferred_chain)
|
||||
max_attempts = getattr(config.roles.get(orchestrating_role, RolePolicy(role_name=orchestrating_role)), "max_failover_attempts", 0) or len(full_chain)
|
||||
chain = full_chain[:max_attempts]
|
||||
assert len(chain) >= 2, (
|
||||
f"В цепочке роли {orchestrating_role!r} меньше двух профилей — "
|
||||
"отказоустойчивость проверить нечем"
|
||||
)
|
||||
last_pid = chain[-1]
|
||||
last_provider = config.profiles[last_pid].provider
|
||||
|
||||
adapter_by_provider = {
|
||||
"openai-codex": CodexAdapter,
|
||||
|
|
@ -211,9 +175,48 @@ def run_checks() -> int:
|
|||
"local": LocalLLMAdapter,
|
||||
}
|
||||
|
||||
if len(chain) >= 2 and all(pid in config.profiles for pid in chain):
|
||||
test_engine = RouterEngine(config=config)
|
||||
test_engine.health.clear_cooldown()
|
||||
test_role = orchestrating_role
|
||||
test_chain = chain
|
||||
test_profiles = config.profiles
|
||||
else:
|
||||
synth_config = RouterConfig(
|
||||
enabled=True,
|
||||
default_role="manager",
|
||||
roles={
|
||||
"manager": RolePolicy(
|
||||
role_name="manager",
|
||||
preferred_chain=["synth-codex", "synth-ag"],
|
||||
max_failover_attempts=2,
|
||||
)
|
||||
},
|
||||
profiles={
|
||||
"synth-codex": RouterProfileConfig(
|
||||
profile_id="synth-codex",
|
||||
provider="openai-codex",
|
||||
capabilities=["coding", "orchestrator"],
|
||||
),
|
||||
"synth-ag": RouterProfileConfig(
|
||||
profile_id="synth-ag",
|
||||
provider="antigravity",
|
||||
capabilities=["coding", "orchestrator"],
|
||||
),
|
||||
},
|
||||
)
|
||||
test_engine = RouterEngine(config=synth_config)
|
||||
test_engine.health.clear_cooldown()
|
||||
test_role = "manager"
|
||||
test_chain = ["synth-codex", "synth-ag"]
|
||||
test_profiles = synth_config.profiles
|
||||
|
||||
last_pid = test_chain[-1]
|
||||
last_provider = test_profiles[last_pid].provider
|
||||
|
||||
expected = {"id": "ok", "choices": [{"message": {"role": "assistant", "content": "from-last-in-chain"}}]}
|
||||
failing = {cls for pid in chain[:-1]
|
||||
if (cls := adapter_by_provider.get(config.profiles[pid].provider)) is not None}
|
||||
failing = {cls for pid in test_chain[:-1]
|
||||
if (cls := adapter_by_provider.get(test_profiles[pid].provider)) is not None}
|
||||
winner = adapter_by_provider.get(last_provider)
|
||||
|
||||
if winner is None or winner in failing:
|
||||
|
|
@ -224,9 +227,9 @@ def run_checks() -> int:
|
|||
for cls in failing:
|
||||
stack.enter_context(patch.object(cls, "invoke", side_effect=RuntimeError("Insufficient quota")))
|
||||
stack.enter_context(patch.object(winner, "invoke", return_value=expected))
|
||||
res = engine.route_request(
|
||||
res = test_engine.route_request(
|
||||
{"messages": [{"role": "user", "content": "test"}]},
|
||||
role=orchestrating_role,
|
||||
role=test_role,
|
||||
session_id="verify-failover",
|
||||
)
|
||||
assert res["choices"][0]["message"]["content"] == "from-last-in-chain", (
|
||||
|
|
@ -235,7 +238,7 @@ def run_checks() -> int:
|
|||
assert res["router_metadata"]["profile_id"] == last_pid, (
|
||||
f"Ожидался профиль {last_pid}, получен {res['router_metadata']['profile_id']}"
|
||||
)
|
||||
print(f" [PASS] Отказоустойчивость прошла цепочку {' -> '.join(chain)}")
|
||||
print(f" [PASS] Отказоустойчивость прошла цепочку {' -> '.join(test_chain)}")
|
||||
passed += 1
|
||||
|
||||
# 10. Passthrough & Graceful fallback
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]:
|
|||
def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
|
||||
config = load_router_config()
|
||||
pcfg = config.get_profile(profile_id)
|
||||
if not pcfg:
|
||||
AutoAssigner.ensure_profile_definition(provider, profile_id)
|
||||
config = load_router_config()
|
||||
pcfg = config.get_profile(profile_id)
|
||||
if not pcfg:
|
||||
return {'success': False, 'error': f"Профиль '{profile_id}' не найден"}
|
||||
|
||||
|
|
@ -467,6 +471,65 @@ def generate_quotas_export(format: str = "json") -> Any:
|
|||
}
|
||||
|
||||
|
||||
def do_reset_router_config(actor: str = "user:web") -> Dict[str, Any]:
|
||||
"""Reset router configuration to clean default state (0 profiles, 13 canonical roles with empty chains).
|
||||
|
||||
Guaranteed to create a timestamped backup of router_profiles.yaml first.
|
||||
Strictly preserves all credentials in ~/.hermes/agy_profiles, codex_profiles,
|
||||
opengo_profiles, claude_profiles, grok_profiles, and hub_settings.json.
|
||||
"""
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from antigravity_provider.router.router_config import (
|
||||
get_default_router_config,
|
||||
save_router_config,
|
||||
)
|
||||
|
||||
env_config = os.environ.get("HERMES_ROUTER_CONFIG", "").strip()
|
||||
if env_config:
|
||||
config_path = Path(env_config).expanduser()
|
||||
else:
|
||||
config_path = paths.get_router_profiles_path()
|
||||
|
||||
backup_name = None
|
||||
if config_path.exists():
|
||||
try:
|
||||
backup_path = config_path.with_name(f"{config_path.name}.bak_{int(time.time())}")
|
||||
shutil.copy2(config_path, backup_path)
|
||||
backup_name = backup_path.name
|
||||
except Exception as exc:
|
||||
logger.warning("Не удалось создать резервную копию перед сбросом конфигурации: %s", exc)
|
||||
|
||||
clean_cfg = get_default_router_config()
|
||||
saved = save_router_config(clean_cfg, config_path)
|
||||
if not saved:
|
||||
return {
|
||||
"ok": False,
|
||||
"message": "Не удалось записать чистую конфигурацию в файл",
|
||||
}
|
||||
|
||||
try:
|
||||
from antigravity_provider.router.state_store import HubStateStore
|
||||
HubStateStore.get().refresh(force_scan=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
EventLogService.get().log(
|
||||
"routing",
|
||||
f"Конфигурация маршрутизатора сброшена в исходное состояние (резервная копия: {backup_name or 'нет'}). Учётные данные и подключенные аккаунты сохранены.",
|
||||
level="warning",
|
||||
actor=actor,
|
||||
action="reset_router_config",
|
||||
outcome="success",
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "Конфигурация маршрутизатора сброшена. Учётные данные и подключенные аккаунты сохранены.",
|
||||
"backup_file": backup_name,
|
||||
}
|
||||
|
||||
|
||||
class ActionExecutor:
|
||||
"""Shared execution layer for Desktop and Web actions."""
|
||||
|
||||
|
|
@ -490,6 +553,28 @@ class ActionExecutor:
|
|||
prov = _pcfg.provider
|
||||
except Exception:
|
||||
pass
|
||||
if not prov:
|
||||
for prefix, p_name in [
|
||||
("ag-", "antigravity"),
|
||||
("codex-", "openai-codex"),
|
||||
("opengo-", "opencode-go"),
|
||||
("claude-", "claude"),
|
||||
("grok-", "grok"),
|
||||
("local-", "local"),
|
||||
("openrouter-", "openrouter"),
|
||||
("nvidia-", "nvidia"),
|
||||
("ollama-", "ollama"),
|
||||
("vllm-", "vllm"),
|
||||
]:
|
||||
if pid.startswith(prefix):
|
||||
prov = p_name
|
||||
break
|
||||
if not prov:
|
||||
from antigravity_provider.router.profile_manager import get_profile_auth_path
|
||||
for candidate_prov in ("antigravity", "openai-codex", "opencode-go", "claude", "grok", "local", "openrouter", "nvidia", "ollama", "vllm"):
|
||||
if get_profile_auth_path(candidate_prov, pid).is_file():
|
||||
prov = candidate_prov
|
||||
break
|
||||
|
||||
if action in {
|
||||
'create_agent',
|
||||
|
|
@ -927,5 +1012,9 @@ class ActionExecutor:
|
|||
'data': {'format': 'json', 'report': res, 'filename': 'hermes_quotas_export.json'}
|
||||
}
|
||||
|
||||
elif action in ['reset_router_config', 'reset_to_empty_config']:
|
||||
res = do_reset_router_config(actor=actor)
|
||||
return {'ok': res.get('ok', False), 'message': res.get('message', ''), 'data': res}
|
||||
|
||||
else:
|
||||
return {'ok': False, 'message': f'Неизвестное действие: {action}', 'unknown': True}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import logging
|
|||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from antigravity_provider import paths
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email, mask_id
|
||||
from antigravity_provider.router.role_registry import RoleRegistry
|
||||
from antigravity_provider.router.router_config import (
|
||||
|
|
@ -67,6 +68,22 @@ class AutoAssigner:
|
|||
"""Get human-readable display name, logical role, and tier for a profile."""
|
||||
if profile_id in DEFAULT_SLOT_ROLES:
|
||||
return DEFAULT_SLOT_ROLES[profile_id]
|
||||
prov_map = {
|
||||
"ag": "Antigravity",
|
||||
"codex": "Codex",
|
||||
"opengo": "OpenCode",
|
||||
"claude": "Claude",
|
||||
"grok": "Grok",
|
||||
"local": "Локальный сервер",
|
||||
"openrouter": "OpenRouter",
|
||||
"nvidia": "NVIDIA NIM",
|
||||
"ollama": "Ollama",
|
||||
"vllm": "vLLM",
|
||||
}
|
||||
parts = profile_id.split("-")
|
||||
if len(parts) == 2 and parts[0] in prov_map and parts[1].isdigit():
|
||||
clean_name = f"{prov_map[parts[0]]} {parts[1]}"
|
||||
return (clean_name, "worker", "primary")
|
||||
clean_name = profile_id.replace("-", " ").title()
|
||||
return (clean_name, "worker", "primary")
|
||||
|
||||
|
|
@ -104,91 +121,57 @@ class AutoAssigner:
|
|||
def find_free_slot(provider: str, requested_role: str = "auto") -> Optional[str]:
|
||||
"""Find the optimal free internal profile slot for a provider.
|
||||
|
||||
When all predefined candidate slots are authenticated/occupied, dynamically
|
||||
generates the next free profile ID without capping account count (P0-1).
|
||||
Generates clean numbered slots starting from 1 (codex-1, ag-1, claude-1,
|
||||
grok-1, opengo-1, local-1, openrouter-1, nvidia-1, ollama-1) upon first addition.
|
||||
"""
|
||||
config = load_router_config()
|
||||
provider_norm = (provider or "").strip().lower()
|
||||
|
||||
provider_slots = {
|
||||
"antigravity": [
|
||||
"ag-orch-fallback", "ag-w1", "ag-w2", "ag-w3", "ag-w4",
|
||||
"ag-spare-1", "ag-spare-2", "ag-cold-1", "ag-cold-2", "ag-cold-3"
|
||||
],
|
||||
"openai-codex": ["codex-orch", "codex-worker-1", "codex-worker-2"],
|
||||
"codex": ["codex-orch", "codex-worker-1", "codex-worker-2"],
|
||||
"opencode-go": ["opengo-1", "opengo-2", "opengo-3"],
|
||||
"opencode": ["opengo-1", "opengo-2", "opengo-3"],
|
||||
"claude": ["claude-orch", "claude-worker-1", "claude-worker-2"],
|
||||
"anthropic": ["claude-orch", "claude-worker-1", "claude-worker-2"],
|
||||
"grok": ["grok-orch", "grok-worker-1", "grok-worker-2"],
|
||||
"xai": ["grok-orch", "grok-worker-1", "grok-worker-2"],
|
||||
"local": ["local-1", "local-2"],
|
||||
"local-llm": ["local-1", "local-2"],
|
||||
"llama.cpp": ["local-1", "local-2"],
|
||||
"ollama": ["ollama-1", "ollama-2"],
|
||||
"vllm": ["local-1", "local-2"],
|
||||
}
|
||||
def _get_prefix(prov: str) -> str:
|
||||
p = (prov or "").strip().lower()
|
||||
if p in ("antigravity", "google-antigravity", "agy"):
|
||||
return "ag"
|
||||
if p in ("openai-codex", "codex", "openai"):
|
||||
return "codex"
|
||||
if p in ("opencode-go", "opencode", "opengo"):
|
||||
return "opengo"
|
||||
if p in ("claude", "anthropic"):
|
||||
return "claude"
|
||||
if p in ("grok", "xai"):
|
||||
return "grok"
|
||||
if p in ("local", "local-llm", "llama.cpp"):
|
||||
return "local"
|
||||
if p in ("openrouter",):
|
||||
return "openrouter"
|
||||
if p in ("nvidia", "nvidia-nim"):
|
||||
return "nvidia"
|
||||
if p in ("ollama",):
|
||||
return "ollama"
|
||||
if p in ("vllm",):
|
||||
return "vllm"
|
||||
return p
|
||||
|
||||
candidates = list(provider_slots.get(provider_norm, []))
|
||||
prefix = _get_prefix(provider_norm)
|
||||
|
||||
# Priority based on requested role
|
||||
if requested_role == "manager":
|
||||
if provider_norm in ("openai-codex", "codex") and "codex-orch" in candidates:
|
||||
candidates.remove("codex-orch")
|
||||
candidates.insert(0, "codex-orch")
|
||||
elif provider_norm == "antigravity" and "ag-orch-fallback" in candidates:
|
||||
candidates.remove("ag-orch-fallback")
|
||||
candidates.insert(0, "ag-orch-fallback")
|
||||
# 1. First check existing candidate profiles already in config
|
||||
for pid in candidates:
|
||||
pcfg = config.get_profile(pid)
|
||||
if not pcfg:
|
||||
continue
|
||||
status = ProfileAuthManager.get_profile_status(provider, pid)
|
||||
if not status.get("authenticated"):
|
||||
return pid
|
||||
for pid, pcfg in config.profiles.items():
|
||||
if _get_prefix(pcfg.provider) == prefix or pcfg.provider == provider_norm:
|
||||
status = ProfileAuthManager.get_profile_status(pcfg.provider, pid)
|
||||
if not status.get("authenticated"):
|
||||
return pid
|
||||
|
||||
# 2. Check remaining predefined slots and ensure definition
|
||||
for pid in candidates:
|
||||
status = ProfileAuthManager.get_profile_status(provider, pid)
|
||||
# 2. Dynamically generate clean numbered slots (codex-1, ag-1, claude-1, etc.)
|
||||
for i in range(1, 200):
|
||||
candidate_pid = f"{prefix}-{i}"
|
||||
if candidate_pid in config.profiles:
|
||||
pcfg = config.profiles[candidate_pid]
|
||||
status = ProfileAuthManager.get_profile_status(pcfg.provider, candidate_pid)
|
||||
if status.get("authenticated"):
|
||||
continue
|
||||
status = ProfileAuthManager.get_profile_status(provider, candidate_pid)
|
||||
if not status.get("authenticated"):
|
||||
AutoAssigner.ensure_profile_definition(provider, pid)
|
||||
return pid
|
||||
|
||||
# 3. Predefined slots occupied: dynamically generate unlimited candidates
|
||||
def _generate_candidates(prov: str):
|
||||
p = prov.lower()
|
||||
if p == "antigravity":
|
||||
for i in range(5, 200):
|
||||
yield f"ag-w{i}"
|
||||
elif p in ("openai-codex", "codex"):
|
||||
for i in range(4, 200):
|
||||
yield f"codex-{i}"
|
||||
elif p in ("opencode-go", "opencode"):
|
||||
for i in range(4, 200):
|
||||
yield f"opengo-{i}"
|
||||
elif p in ("claude", "anthropic"):
|
||||
for i in range(3, 200):
|
||||
yield f"claude-worker-{i}"
|
||||
elif p in ("grok", "xai"):
|
||||
for i in range(3, 200):
|
||||
yield f"grok-worker-{i}"
|
||||
elif p == "ollama":
|
||||
for i in range(3, 200):
|
||||
yield f"ollama-{i}"
|
||||
elif p in ("local", "local-llm", "llama.cpp", "vllm"):
|
||||
for i in range(3, 200):
|
||||
yield f"local-{i}"
|
||||
else:
|
||||
for i in range(1, 200):
|
||||
yield f"{p}-{i}"
|
||||
|
||||
for dynamic_pid in _generate_candidates(provider_norm):
|
||||
status = ProfileAuthManager.get_profile_status(provider, dynamic_pid)
|
||||
if not status.get("authenticated"):
|
||||
AutoAssigner.ensure_profile_definition(provider, dynamic_pid)
|
||||
return dynamic_pid
|
||||
AutoAssigner.ensure_profile_definition(provider, candidate_pid)
|
||||
return candidate_pid
|
||||
|
||||
return None
|
||||
|
||||
|
|
@ -209,15 +192,23 @@ class AutoAssigner:
|
|||
|
||||
capabilities_map = {
|
||||
"grok": ["reasoning", "coding", "research"],
|
||||
"xai": ["reasoning", "coding", "research"],
|
||||
"claude": ["reasoning", "coding", "review"],
|
||||
"anthropic": ["reasoning", "coding", "review"],
|
||||
"opencode-go": ["coding", "research", "fast"],
|
||||
"opencode": ["coding", "research", "fast"],
|
||||
"openai-codex": ["coding", "reasoning"],
|
||||
"codex": ["coding", "reasoning"],
|
||||
"antigravity": ["coding", "reasoning", "research", "fast"],
|
||||
"google-antigravity": ["coding", "reasoning", "research", "fast"],
|
||||
"local": ["reviewer", "coding", "reasoning", "fast", "research"],
|
||||
"local-llm": ["reviewer", "coding", "reasoning", "fast", "research"],
|
||||
"llama.cpp": ["reviewer", "coding", "reasoning", "fast", "research"],
|
||||
"ollama": ["reviewer", "coding", "reasoning", "fast", "research"],
|
||||
"vllm": ["reviewer", "coding", "reasoning", "fast", "research"],
|
||||
"openrouter": ["coding", "reasoning", "research", "fast"],
|
||||
"nvidia": ["coding", "reasoning", "fast"],
|
||||
"nvidia-nim": ["coding", "reasoning", "fast"],
|
||||
}
|
||||
capabilities = capabilities_map.get(provider, ["coding", "reasoning"])
|
||||
|
||||
|
|
@ -276,6 +267,28 @@ class AutoAssigner:
|
|||
"""Assign a profile to a canonical router role, updating fallback chains and persisting config."""
|
||||
config = load_router_config()
|
||||
pcfg = config.get_profile(profile_id)
|
||||
if not pcfg:
|
||||
inferred_prov = None
|
||||
for prefix, prov in [
|
||||
("ag-", "antigravity"),
|
||||
("codex-", "openai-codex"),
|
||||
("opengo-", "opencode-go"),
|
||||
("claude-", "claude"),
|
||||
("grok-", "grok"),
|
||||
("local-", "local"),
|
||||
("openrouter-", "openrouter"),
|
||||
("nvidia-", "nvidia"),
|
||||
("ollama-", "ollama"),
|
||||
("vllm-", "vllm"),
|
||||
]:
|
||||
if profile_id.startswith(prefix):
|
||||
inferred_prov = prov
|
||||
break
|
||||
if inferred_prov:
|
||||
AutoAssigner.ensure_profile_definition(inferred_prov, profile_id)
|
||||
config = load_router_config()
|
||||
pcfg = config.get_profile(profile_id)
|
||||
|
||||
if not pcfg:
|
||||
return False, f"Профиль '{profile_id}' не найден"
|
||||
|
||||
|
|
@ -314,6 +327,31 @@ class AutoAssigner:
|
|||
@staticmethod
|
||||
def auto_assign_all() -> Dict[str, Any]:
|
||||
"""Automatically distribute all authenticated profiles across canonical router roles (P0-4)."""
|
||||
config = load_router_config()
|
||||
# Scan on-disk profiles and ensure their definition in config.profiles
|
||||
prov_prefix_map = {
|
||||
"antigravity": "agy_profiles",
|
||||
"openai-codex": "codex_profiles",
|
||||
"opencode-go": "opengo_profiles",
|
||||
"claude": "claude_profiles",
|
||||
"grok": "grok_profiles",
|
||||
"local": "local_profiles",
|
||||
"openrouter": "openrouter_profiles",
|
||||
"nvidia": "nvidia_profiles",
|
||||
"ollama": "ollama_profiles",
|
||||
"vllm": "vllm_profiles",
|
||||
}
|
||||
for prov, subdir in prov_prefix_map.items():
|
||||
base_dir = paths.get_hermes_home() / subdir
|
||||
if base_dir.is_dir():
|
||||
for p_entry in base_dir.iterdir():
|
||||
if p_entry.is_dir():
|
||||
pid = p_entry.name
|
||||
if pid not in config.profiles:
|
||||
st = ProfileAuthManager.get_profile_status(prov, pid)
|
||||
if st.get("authenticated"):
|
||||
AutoAssigner.ensure_profile_definition(prov, pid)
|
||||
|
||||
config = load_router_config()
|
||||
authenticated_profiles: List[Tuple[str, RouterProfileConfig]] = []
|
||||
for pid, pcfg in config.profiles.items():
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ def print_router_status() -> int:
|
|||
print("-" * 80)
|
||||
|
||||
for rname, rpolicy in sorted(config.roles.items()):
|
||||
if not rpolicy.preferred_chain:
|
||||
print(f"{rname:<16} {'(не назначен)':<18} {'-':<15} {'not_configured':<18} {'-':<10}")
|
||||
continue
|
||||
for idx, pid in enumerate(rpolicy.preferred_chain):
|
||||
pconfig = config.get_profile(pid)
|
||||
if not pconfig:
|
||||
|
|
@ -222,11 +225,6 @@ def test_profile_cli(profile_id: str) -> int:
|
|||
def simulate_quota_cli(profile_id: str, model_family: Optional[str] = None, duration: int = 600) -> int:
|
||||
"""Simulate quota exhaustion on a profile for testing."""
|
||||
engine = get_router_engine()
|
||||
pconfig = engine.config.get_profile(profile_id)
|
||||
if not pconfig:
|
||||
print(f"[ERROR] Profile '{profile_id}' not found.")
|
||||
return 1
|
||||
|
||||
engine.health.simulate_quota(profile_id, model_family=model_family, duration=duration)
|
||||
print(f"[OK] Simulated quota exhaustion activated for profile '{profile_id}' for {duration} seconds.")
|
||||
print("Use `hermes router clear-cooldown` to restore normal state.")
|
||||
|
|
|
|||
|
|
@ -322,7 +322,11 @@ class ProfileAuthManager:
|
|||
if base_url:
|
||||
try:
|
||||
from antigravity_provider.router.router_config import load_router_config, save_router_config
|
||||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||
rcfg = load_router_config()
|
||||
if profile_id not in rcfg.profiles:
|
||||
AutoAssigner.ensure_profile_definition(provider, profile_id)
|
||||
rcfg = load_router_config()
|
||||
if profile_id in rcfg.profiles:
|
||||
rcfg.profiles[profile_id].custom_base_url = str(base_url).strip()
|
||||
if auth_data.get("models") and isinstance(auth_data["models"], list):
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
|
|||
is_orchestrator=True,
|
||||
capabilities=["orchestrator", "reasoning", "management", "planning"],
|
||||
fallback_capabilities=["orchestrator", "reasoning"],
|
||||
default_preferred_chain=["codex-orch", "ag-orch-fallback", "claude-orch", "grok-orch", "opengo-3"],
|
||||
default_preferred_chain=[],
|
||||
default_model="gemini-3.7-flash",
|
||||
max_failover_attempts=3,
|
||||
tier="core",
|
||||
|
|
@ -52,7 +52,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
|
|||
is_implemented=True,
|
||||
capabilities=["coding", "developer-1", "reasoning", "tools", "structured_output"],
|
||||
fallback_capabilities=["coding"],
|
||||
default_preferred_chain=["ag-w1", "codex-worker-1", "claude-worker-1", "opengo-1"],
|
||||
default_preferred_chain=[],
|
||||
default_model="gemini-3.7-flash",
|
||||
max_failover_attempts=3,
|
||||
tier="core",
|
||||
|
|
@ -65,7 +65,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
|
|||
is_implemented=True,
|
||||
capabilities=["coding", "developer-2", "reviewer", "tools"],
|
||||
fallback_capabilities=["coding", "reviewer"],
|
||||
default_preferred_chain=["ag-w2", "codex-worker-2", "grok-worker-1", "opengo-3"],
|
||||
default_preferred_chain=[],
|
||||
default_model="gemini-3.1-pro-high",
|
||||
max_failover_attempts=3,
|
||||
tier="core",
|
||||
|
|
@ -78,7 +78,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
|
|||
is_implemented=True,
|
||||
capabilities=["code-reviewer", "reviewer", "coding", "security_analysis"],
|
||||
fallback_capabilities=["reviewer", "coding"],
|
||||
default_preferred_chain=["ag-w3", "ag-w2", "claude-worker-2", "codex-worker-2", "opengo-2"],
|
||||
default_preferred_chain=[],
|
||||
default_model="claude-opus-4-6-thinking",
|
||||
max_failover_attempts=3,
|
||||
tier="core",
|
||||
|
|
@ -91,7 +91,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
|
|||
is_implemented=True,
|
||||
capabilities=["researcher", "research", "search", "long_context"],
|
||||
fallback_capabilities=["research", "search"],
|
||||
default_preferred_chain=["ag-w3", "grok-worker-2", "opengo-2", "opengo-1"],
|
||||
default_preferred_chain=[],
|
||||
max_failover_attempts=3,
|
||||
tier="core",
|
||||
),
|
||||
|
|
@ -103,7 +103,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
|
|||
is_implemented=True,
|
||||
capabilities=["tester", "testing", "fast", "automation"],
|
||||
fallback_capabilities=["fast", "testing"],
|
||||
default_preferred_chain=["ag-w4", "opengo-1", "ag-spare-1"],
|
||||
default_preferred_chain=[],
|
||||
max_failover_attempts=3,
|
||||
tier="qa_doc",
|
||||
),
|
||||
|
|
@ -115,7 +115,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
|
|||
is_implemented=True,
|
||||
capabilities=["tech-writer", "documentation", "reasoning", "structured_output"],
|
||||
fallback_capabilities=["documentation", "reasoning"],
|
||||
default_preferred_chain=["claude-worker-2", "ag-w3", "ag-w2"],
|
||||
default_preferred_chain=[],
|
||||
max_failover_attempts=3,
|
||||
tier="qa_doc",
|
||||
),
|
||||
|
|
@ -127,7 +127,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
|
|||
is_implemented=True,
|
||||
capabilities=["analyst", "reasoning", "research", "planning"],
|
||||
fallback_capabilities=["reasoning", "research"],
|
||||
default_preferred_chain=["grok-worker-2", "opengo-2", "ag-w3"],
|
||||
default_preferred_chain=[],
|
||||
max_failover_attempts=3,
|
||||
tier="qa_doc",
|
||||
),
|
||||
|
|
@ -165,7 +165,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
|
|||
is_implemented=True,
|
||||
capabilities=["integration-expert", "integration", "coding", "networking"],
|
||||
fallback_capabilities=["integration", "coding"],
|
||||
default_preferred_chain=["opengo-3", "codex-worker-1", "ag-w4"],
|
||||
default_preferred_chain=[],
|
||||
max_failover_attempts=3,
|
||||
tier="expert",
|
||||
),
|
||||
|
|
@ -177,7 +177,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
|
|||
is_implemented=True,
|
||||
capabilities=["security-expert", "security", "code-reviewer", "audit"],
|
||||
fallback_capabilities=["security", "code-reviewer"],
|
||||
default_preferred_chain=["claude-worker-1", "codex-worker-2", "ag-w2"],
|
||||
default_preferred_chain=[],
|
||||
max_failover_attempts=3,
|
||||
tier="expert",
|
||||
),
|
||||
|
|
@ -189,7 +189,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
|
|||
is_implemented=True,
|
||||
capabilities=["dependency-agent", "preflight", "environment", "system_checks", "fast"],
|
||||
fallback_capabilities=["dependency-agent", "preflight"],
|
||||
default_preferred_chain=["opengo-1", "ag-w1", "codex-worker-1"],
|
||||
default_preferred_chain=[],
|
||||
max_failover_attempts=3,
|
||||
tier="qa_doc",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -68,219 +68,14 @@ class RouterConfig:
|
|||
|
||||
|
||||
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,
|
||||
),
|
||||
# 6. Local LLM Pool (2 accounts)
|
||||
"local-1": RouterProfileConfig(
|
||||
profile_id="local-1",
|
||||
provider="local",
|
||||
account_id="local-acc-1",
|
||||
capabilities=["code-reviewer", "reviewer", "coder-secondary", "reasoning", "coding"],
|
||||
preferred_models=["Qwen3.8-27B-Q4_K_M.gguf", "default"],
|
||||
max_concurrency=1,
|
||||
),
|
||||
"local-2": RouterProfileConfig(
|
||||
profile_id="local-2",
|
||||
provider="local",
|
||||
account_id="local-acc-2",
|
||||
capabilities=["tester", "fast", "research", "coding"],
|
||||
preferred_models=["Qwen3-4B-Instruct-2507-Q4_K_M.gguf", "default"],
|
||||
max_concurrency=1,
|
||||
),
|
||||
}
|
||||
|
||||
"""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=profiles,
|
||||
profiles={},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -364,24 +159,18 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig:
|
|||
if quota_threshold_action not in ("notify", "switch"):
|
||||
quota_threshold_action = "notify"
|
||||
|
||||
# Automatic Idempotent Migration (P0-0.1)
|
||||
# Merge missing default profiles and roles into loaded user configuration
|
||||
# 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_profiles_added: list[str] = []
|
||||
new_roles_added: list[str] = []
|
||||
|
||||
if not profiles:
|
||||
profiles = default_cfg.profiles
|
||||
else:
|
||||
for def_pid, def_pcfg in default_cfg.profiles.items():
|
||||
if def_pid not in profiles:
|
||||
profiles[def_pid] = def_pcfg
|
||||
new_profiles_added.append(def_pid)
|
||||
migration_needed = True
|
||||
profiles = {}
|
||||
|
||||
if not roles:
|
||||
roles = default_cfg.roles
|
||||
migration_needed = True
|
||||
else:
|
||||
# Сначала переименование старых ролей в канонические, и только
|
||||
# потом дополнение недостающими.
|
||||
|
|
|
|||
|
|
@ -353,7 +353,8 @@ class UnifiedHealthService:
|
|||
tag = f"{rname} (primary)" if idx == 0 else f"{rname} (fallback {idx})"
|
||||
role_assignments.setdefault(pid, []).append(tag)
|
||||
|
||||
orch_primary = config.roles.get("orchestrator", None)
|
||||
orch_role_name = RoleRegistry.resolve_canonical_role("orchestrator")
|
||||
orch_primary = config.roles.get(orch_role_name, None)
|
||||
orch_primary_id = orch_primary.preferred_chain[0] if orch_primary and orch_primary.preferred_chain else ""
|
||||
|
||||
now = time.time()
|
||||
|
|
@ -714,6 +715,26 @@ class UnifiedHealthService:
|
|||
continue
|
||||
|
||||
if not chain:
|
||||
agents.append(AgentViewModel(
|
||||
role_id=rname,
|
||||
role_name_ru=rname_ru,
|
||||
role_description_ru=rdesc_ru or "Роль готова к назначению аккаунтов",
|
||||
assigned_profile_id=None,
|
||||
assigned_display_name=None,
|
||||
provider="N/A",
|
||||
provider_display_name="Не назначен",
|
||||
model="-",
|
||||
account_identity="Аккаунт не назначен",
|
||||
routing_position="Не назначен",
|
||||
status="not_configured",
|
||||
status_label_ru="Не настроен",
|
||||
is_active=False,
|
||||
is_main_orchestrator=(rname == "manager"),
|
||||
cooldown_remaining_sec=0,
|
||||
session_id=None,
|
||||
active_quota_status="unavailable",
|
||||
active_quota_label="Не применяется",
|
||||
))
|
||||
continue
|
||||
|
||||
primary_pid = chain[0]
|
||||
|
|
@ -763,6 +784,27 @@ class UnifiedHealthService:
|
|||
active_quota_status=active_quota_st,
|
||||
active_quota_label=active_quota_lbl,
|
||||
))
|
||||
else:
|
||||
agents.append(AgentViewModel(
|
||||
role_id=rname,
|
||||
role_name_ru=rname_ru,
|
||||
role_description_ru=rdesc_ru or "Роль готова к назначению аккаунтов",
|
||||
assigned_profile_id=primary_pid,
|
||||
assigned_display_name=primary_pid,
|
||||
provider="N/A",
|
||||
provider_display_name="Не назначен",
|
||||
model="-",
|
||||
account_identity="Аккаунт не найден",
|
||||
routing_position="Primary",
|
||||
status="not_configured",
|
||||
status_label_ru="Не настроен",
|
||||
is_active=False,
|
||||
is_main_orchestrator=(rname == "manager"),
|
||||
cooldown_remaining_sec=0,
|
||||
session_id=None,
|
||||
active_quota_status="unavailable",
|
||||
active_quota_label="Не применяется",
|
||||
))
|
||||
|
||||
return agents
|
||||
|
||||
|
|
|
|||
|
|
@ -188,6 +188,12 @@ function initEventListeners() {
|
|||
if (btnPreflight) {
|
||||
btnPreflight.addEventListener('click', () => runPreflightChecks());
|
||||
}
|
||||
|
||||
// Reset router config listener (P0-4)
|
||||
const btnResetConfig = document.getElementById('btn-reset-router-config');
|
||||
if (btnResetConfig) {
|
||||
btnResetConfig.addEventListener('click', () => openResetConfigModal());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1368,6 +1374,67 @@ async function saveHubServerSettings() {
|
|||
}
|
||||
}
|
||||
|
||||
// ── RESET CONFIGURATION (P0-2 & P0-4) ──
|
||||
function openResetConfigModal() {
|
||||
elements.modalTitle.textContent = 'Начать настройку заново';
|
||||
elements.modalBody.innerHTML = `
|
||||
<div class="modal-feedback warning" style="margin-bottom:14px; line-height:1.5;">
|
||||
⚠️ <strong>Вы собираетесь сбросить конфигурацию маршрутизатора в исходное чистое состояние.</strong>
|
||||
</div>
|
||||
<div style="font-size:13px; margin-bottom:12px;">
|
||||
<div style="font-weight:600; color:var(--status-error); margin-bottom:4px;">Будет сброшено:</div>
|
||||
<ul style="margin:0 0 12px 18px; padding:0; color:var(--text-secondary);">
|
||||
<li>Цепочки всех 13 ролей (будут очищены).</li>
|
||||
<li>Список профилей в <code>router_profiles.yaml</code> (0 профилей).</li>
|
||||
<li>Перед сбросом создаётся резервная копия <code>router_profiles.yaml.bak_...</code>.</li>
|
||||
</ul>
|
||||
<div style="font-weight:600; color:var(--status-healthy); margin-bottom:4px;">Будет сохранено (НЕ затрагивается):</div>
|
||||
<ul style="margin:0 0 0 18px; padding:0; color:var(--text-secondary);">
|
||||
<li>Все учётные данные, токены, ключи и подключённые аккаунты.</li>
|
||||
<li>Файлы авторизации в <code>~/.hermes/agy_profiles/</code>, <code>codex_profiles/</code> и др.</li>
|
||||
<li>Общие настройки хаба в <code>hub_settings.json</code>.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="reset-config-feedback-area"></div>
|
||||
`;
|
||||
elements.modalFooter.innerHTML = `
|
||||
<button class="btn btn-ghost" onclick="closeModal()">Отмена</button>
|
||||
<button class="btn btn-danger" id="btn-modal-confirm-reset" onclick="confirmResetConfig()">Сбросить и начать заново</button>
|
||||
`;
|
||||
showModal();
|
||||
}
|
||||
|
||||
async function confirmResetConfig() {
|
||||
const btn = document.getElementById('btn-modal-confirm-reset');
|
||||
const feedback = document.getElementById('reset-config-feedback-area');
|
||||
if (btn) btn.disabled = true;
|
||||
if (feedback) {
|
||||
feedback.innerHTML = '<div class="modal-feedback info">⏳ Выполняется сброс конфигурации...</div>';
|
||||
}
|
||||
try {
|
||||
const res = await executeAction('reset_router_config', {});
|
||||
if (res && res.ok) {
|
||||
if (feedback) {
|
||||
feedback.innerHTML = `<div class="modal-feedback success">✓ ${escapeHtml(res.message || 'Конфигурация успешно сброшена')}</div>`;
|
||||
}
|
||||
setTimeout(() => {
|
||||
closeModal();
|
||||
fetchSnapshot();
|
||||
}, 1000);
|
||||
} else {
|
||||
if (btn) btn.disabled = false;
|
||||
if (feedback) {
|
||||
feedback.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml((res && res.message) || 'Ошибка сброса конфигурации')}</div>`;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (btn) btn.disabled = false;
|
||||
if (feedback) {
|
||||
feedback.innerHTML = `<div class="modal-feedback error">❌ Ошибка: ${escapeHtml(err.message || String(err))}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── PREFLIGHT READINESS CHECKS ──
|
||||
async function runPreflightChecks() {
|
||||
const container = document.getElementById('preflight-results-container');
|
||||
|
|
|
|||
|
|
@ -557,6 +557,20 @@
|
|||
<button class="btn btn-secondary" id="btn-save-client-settings">Сохранить локальные параметры</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clean Setup / Reset Configuration (P0-4) -->
|
||||
<div class="settings-card" style="margin-top:16px; border:1px solid rgba(220, 53, 69, 0.3);">
|
||||
<h2 class="settings-group-title" style="color:var(--status-error, #e57373);">Сброс конфигурации</h2>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">Начать настройку заново</div>
|
||||
<div class="setting-desc">Сбросить все цепочки ролей и список профилей в чистое начальное состояние. Все учетные данные и токены аккаунтов сохраняются.</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button class="btn btn-danger" id="btn-reset-router-config">Начать настройку заново</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
264
tests/test_a41_clean_install.py
Normal file
264
tests/test_a41_clean_install.py
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
"""Unit and integration tests for Task A41: Clean First Install & Routing Reset.
|
||||
|
||||
Verifies:
|
||||
1. Clean default configuration (0 profiles, 13 canonical roles with empty preferred_chain).
|
||||
2. RoleRegistry CANONICAL_ROLES clean default chains.
|
||||
3. Migration idempotence & preservation of user profiles without injecting stubs.
|
||||
4. Action reset_router_config creates backup and resets routing without touching credentials.
|
||||
5. Clean numbered slot generation upon adding accounts (codex-1, ag-1, claude-1, etc.).
|
||||
6. Full compatibility with verify_multi_provider_router script.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from antigravity_provider.paths import get_hermes_home, get_profile_dir
|
||||
from antigravity_provider.router.action_handler import ActionExecutor, do_reset_router_config
|
||||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||
from antigravity_provider.router.role_registry import CANONICAL_ROLES, RoleRegistry
|
||||
from antigravity_provider.router.router_config import (
|
||||
RolePolicy,
|
||||
RouterConfig,
|
||||
RouterProfileConfig,
|
||||
get_default_router_config,
|
||||
load_router_config,
|
||||
save_router_config,
|
||||
)
|
||||
from antigravity_provider.router.settings_service import get_hub_settings, save_hub_settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_a41_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Set up an isolated hermes home directory for testing."""
|
||||
hermes_home = tmp_path / "hermes_home"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
config_file = hermes_home / "router_profiles.yaml"
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("HERMES_ROUTER_CONFIG", str(config_file))
|
||||
return hermes_home, config_file
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_1_clean_default_configuration():
|
||||
"""P0-1 & P0-3: Clean configuration on first install has 0 profiles and 13 canonical roles with empty chains."""
|
||||
# 1. Check CANONICAL_ROLES registry
|
||||
assert len(CANONICAL_ROLES) == 13
|
||||
for role_id, role_def in CANONICAL_ROLES.items():
|
||||
assert role_def.default_preferred_chain == [], f"Role {role_id} has non-empty default chain"
|
||||
|
||||
# 2. Check get_default_router_config()
|
||||
default_cfg = get_default_router_config()
|
||||
assert len(default_cfg.profiles) == 0, f"Expected 0 profiles, got {len(default_cfg.profiles)}"
|
||||
assert len(default_cfg.roles) == 13, f"Expected 13 roles, got {len(default_cfg.roles)}"
|
||||
assert default_cfg.default_role == "manager"
|
||||
|
||||
for rname, rpol in default_cfg.roles.items():
|
||||
assert rpol.preferred_chain == [], f"Expected empty preferred_chain for role {rname}"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_1_dynamic_clean_slot_numbering(clean_a41_env):
|
||||
"""P0-1: Accounts added dynamically receive clean numbered slots (ag-1, codex-1, claude-1, etc.)."""
|
||||
hermes_home, config_file = clean_a41_env
|
||||
|
||||
# 1. Antigravity slots
|
||||
ag_slot_1 = AutoAssigner.find_free_slot("antigravity")
|
||||
assert ag_slot_1 == "ag-1"
|
||||
ProfileAuthManager.save_profile_auth("antigravity", ag_slot_1, {"tokens": {"access_token": "token1"}})
|
||||
|
||||
ag_slot_2 = AutoAssigner.find_free_slot("antigravity")
|
||||
assert ag_slot_2 == "ag-2"
|
||||
|
||||
# 2. Codex slots
|
||||
codex_slot_1 = AutoAssigner.find_free_slot("openai-codex")
|
||||
assert codex_slot_1 == "codex-1"
|
||||
ProfileAuthManager.save_profile_auth("openai-codex", codex_slot_1, {"api_key": "sk-1"})
|
||||
|
||||
codex_slot_2 = AutoAssigner.find_free_slot("openai-codex")
|
||||
assert codex_slot_2 == "codex-2"
|
||||
|
||||
# 3. Claude slots
|
||||
claude_slot_1 = AutoAssigner.find_free_slot("claude")
|
||||
assert claude_slot_1 == "claude-1"
|
||||
|
||||
# 4. Grok slots
|
||||
grok_slot_1 = AutoAssigner.find_free_slot("grok")
|
||||
assert grok_slot_1 == "grok-1"
|
||||
|
||||
# 5. OpenCode slots
|
||||
opengo_slot_1 = AutoAssigner.find_free_slot("opencode-go")
|
||||
assert opengo_slot_1 == "opengo-1"
|
||||
|
||||
# 6. Local slots
|
||||
local_slot_1 = AutoAssigner.find_free_slot("local")
|
||||
assert local_slot_1 == "local-1"
|
||||
|
||||
# 7. OpenRouter, NVIDIA, Ollama slots
|
||||
assert AutoAssigner.find_free_slot("openrouter") == "openrouter-1"
|
||||
assert AutoAssigner.find_free_slot("nvidia") == "nvidia-1"
|
||||
assert AutoAssigner.find_free_slot("ollama") == "ollama-1"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_3_migration_preserves_user_config_and_adds_missing_roles_cleanly(clean_a41_env):
|
||||
"""P0-3: Migration preserves existing user profiles and chains without injecting fake profile stubs."""
|
||||
hermes_home, config_file = clean_a41_env
|
||||
|
||||
# User configured 2 specific profiles and legacy role names
|
||||
user_profiles = {
|
||||
"user-primary-ag": RouterProfileConfig(
|
||||
profile_id="user-primary-ag",
|
||||
provider="antigravity",
|
||||
account_id="user-acc-1",
|
||||
preferred_models=["gemini-2.5-pro"],
|
||||
),
|
||||
"user-primary-codex": RouterProfileConfig(
|
||||
profile_id="user-primary-codex",
|
||||
provider="openai-codex",
|
||||
account_id="user-acc-2",
|
||||
preferred_models=["o3-mini"],
|
||||
),
|
||||
}
|
||||
|
||||
user_roles = {
|
||||
"orchestrator": RolePolicy(role_name="orchestrator", preferred_chain=["user-primary-codex"]),
|
||||
"coder-primary": RolePolicy(role_name="coder-primary", preferred_chain=["user-primary-ag"]),
|
||||
}
|
||||
|
||||
user_cfg = RouterConfig(
|
||||
enabled=True,
|
||||
default_role="manager",
|
||||
roles=user_roles,
|
||||
profiles=user_profiles,
|
||||
)
|
||||
save_router_config(user_cfg, config_file)
|
||||
|
||||
# Trigger load and migration
|
||||
migrated_cfg = load_router_config(config_file)
|
||||
|
||||
# 1. User profiles are preserved verbatim
|
||||
assert len(migrated_cfg.profiles) == 2
|
||||
assert "user-primary-ag" in migrated_cfg.profiles
|
||||
assert "user-primary-codex" in migrated_cfg.profiles
|
||||
assert migrated_cfg.profiles["user-primary-ag"].preferred_models == ["gemini-2.5-pro"]
|
||||
|
||||
# 2. Legacy roles migrated to canonical names preserving chains
|
||||
assert "manager" in migrated_cfg.roles
|
||||
assert migrated_cfg.roles["manager"].preferred_chain == ["user-primary-codex"]
|
||||
|
||||
assert "developer-1" in migrated_cfg.roles
|
||||
assert migrated_cfg.roles["developer-1"].preferred_chain == ["user-primary-ag"]
|
||||
|
||||
# 3. All 13 canonical roles exist
|
||||
assert len(migrated_cfg.roles) == 13
|
||||
|
||||
# 4. Missing roles added with clean empty chains
|
||||
for rname, rpol in migrated_cfg.roles.items():
|
||||
if rname not in ("manager", "developer-1"):
|
||||
assert rpol.preferred_chain == [], f"Missing role {rname} should have empty chain"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_2_p0_4_reset_router_config_and_preserve_credentials(clean_a41_env):
|
||||
"""P0-2 & P0-4: Reset action backs up router_profiles.yaml, resets to clean state, and strictly preserves credentials."""
|
||||
hermes_home, config_file = clean_a41_env
|
||||
|
||||
# 1. Seed user configuration
|
||||
initial_profiles = {
|
||||
"ag-1": RouterProfileConfig(profile_id="ag-1", provider="antigravity"),
|
||||
"codex-1": RouterProfileConfig(profile_id="codex-1", provider="openai-codex"),
|
||||
}
|
||||
initial_roles = {
|
||||
"manager": RolePolicy(role_name="manager", preferred_chain=["codex-1"]),
|
||||
"developer-1": RolePolicy(role_name="developer-1", preferred_chain=["ag-1"]),
|
||||
}
|
||||
save_router_config(RouterConfig(profiles=initial_profiles, roles=initial_roles), config_file)
|
||||
|
||||
# 2. Seed credentials across different providers and hub_settings.json
|
||||
ag_auth = ProfileAuthManager.save_profile_auth("antigravity", "ag-1", {"tokens": {"access_token": "secret_ag_token"}})
|
||||
codex_auth = ProfileAuthManager.save_profile_auth("openai-codex", "codex-1", {"api_key": "sk-secret-codex-key"})
|
||||
claude_auth = ProfileAuthManager.save_profile_auth("claude", "claude-1", {"api_key": "sk-ant-secret-claude-key"})
|
||||
grok_auth = ProfileAuthManager.save_profile_auth("grok", "grok-1", {"api_key": "xai-secret-grok-key"})
|
||||
opengo_auth = ProfileAuthManager.save_profile_auth("opencode-go", "opengo-1", {"api_key": "opengo-secret-key"})
|
||||
|
||||
# Seed hub settings
|
||||
save_hub_settings({"email_masking_mode": "full", "quota_threshold_percent": 15.0})
|
||||
|
||||
# Verify credentials exist on disk before reset
|
||||
assert ag_auth.is_file()
|
||||
assert codex_auth.is_file()
|
||||
assert claude_auth.is_file()
|
||||
assert grok_auth.is_file()
|
||||
assert opengo_auth.is_file()
|
||||
|
||||
# 3. Execute Reset Action via ActionExecutor
|
||||
res = ActionExecutor.execute("reset_router_config", {})
|
||||
assert res["ok"] is True
|
||||
assert "сброшена" in res["message"].lower()
|
||||
|
||||
# 4. Verify backup was created
|
||||
backups = list(hermes_home.glob("router_profiles.yaml.bak_*"))
|
||||
assert len(backups) >= 1, "Backup file was not created during reset"
|
||||
backup_content = backups[0].read_text(encoding="utf-8")
|
||||
assert "codex-1" in backup_content
|
||||
|
||||
# 5. Verify router_profiles.yaml is now in clean state (0 profiles, 13 canonical roles with empty chains)
|
||||
reloaded_cfg = load_router_config(config_file)
|
||||
assert len(reloaded_cfg.profiles) == 0
|
||||
assert len(reloaded_cfg.roles) == 13
|
||||
for rname, rpol in reloaded_cfg.roles.items():
|
||||
assert rpol.preferred_chain == []
|
||||
|
||||
# 6. Verify ALL credentials remain 100% intact and untouched
|
||||
assert ag_auth.is_file()
|
||||
assert codex_auth.is_file()
|
||||
assert claude_auth.is_file()
|
||||
assert grok_auth.is_file()
|
||||
assert opengo_auth.is_file()
|
||||
|
||||
loaded_ag = ProfileAuthManager.load_profile_auth("antigravity", "ag-1")
|
||||
assert loaded_ag["tokens"]["access_token"] == "secret_ag_token"
|
||||
|
||||
loaded_codex = ProfileAuthManager.load_profile_auth("openai-codex", "codex-1")
|
||||
assert loaded_codex["api_key"] == "sk-secret-codex-key"
|
||||
|
||||
# Verify hub settings untouched
|
||||
settings = get_hub_settings()
|
||||
assert settings.get("email_masking_mode") == "full"
|
||||
assert settings.get("quota_threshold_percent") == 15.0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_5_verification_script_runs_clean_and_filled(clean_a41_env):
|
||||
"""P0-5: verify_multi_provider_router.py passes on both clean default and filled user configs."""
|
||||
hermes_home, config_file = clean_a41_env
|
||||
|
||||
# 1. Run on clean default config (file does not exist yet)
|
||||
import subprocess
|
||||
import sys
|
||||
env = os.environ.copy()
|
||||
env["HERMES_ROUTER_CONFIG"] = str(config_file)
|
||||
env["HERMES_HOME"] = str(hermes_home)
|
||||
|
||||
script_path = Path(__file__).resolve().parent.parent / "scripts" / "verify_multi_provider_router.py"
|
||||
proc = subprocess.run([sys.executable, str(script_path)], env=env, capture_output=True, text=True)
|
||||
assert proc.returncode == 0, f"Verification failed on clean config: {proc.stderr}\n{proc.stdout}"
|
||||
assert "10/10 CHECKS PASSED" in proc.stdout
|
||||
|
||||
# 2. Run on populated user config
|
||||
AutoAssigner.find_free_slot("openai-codex")
|
||||
AutoAssigner.find_free_slot("antigravity")
|
||||
AutoAssigner.assign_profile_to_role("codex-1", "manager", is_primary=True)
|
||||
AutoAssigner.assign_profile_to_role("ag-1", "manager", is_primary=False)
|
||||
|
||||
proc_filled = subprocess.run([sys.executable, str(script_path)], env=env, capture_output=True, text=True)
|
||||
assert proc_filled.returncode == 0, f"Verification failed on filled config: {proc_filled.stderr}\n{proc_filled.stdout}"
|
||||
assert "10/10 CHECKS PASSED" in proc_filled.stdout
|
||||
|
|
@ -43,7 +43,7 @@ class TestA9ConfigMigration(unittest.TestCase):
|
|||
shutil.rmtree(self.tmp_dir, ignore_errors=True)
|
||||
|
||||
def test_migration_16_profiles_preserves_antigravity_and_adds_claude_grok(self):
|
||||
# 1. Prepare legacy 16-profile configuration (no claude, no grok)
|
||||
# 1. Prepare legacy configuration with 16 user profiles
|
||||
legacy_profiles = {}
|
||||
for pid in [
|
||||
"codex-orch", "codex-worker-1", "codex-worker-2",
|
||||
|
|
@ -60,33 +60,32 @@ class TestA9ConfigMigration(unittest.TestCase):
|
|||
max_concurrency=3,
|
||||
)
|
||||
|
||||
# Legacy role configuration missing some canonical roles
|
||||
legacy_roles = {
|
||||
"manager": get_default_router_config().roles["manager"],
|
||||
"developer-1": get_default_router_config().roles["developer-1"],
|
||||
}
|
||||
|
||||
legacy_cfg = RouterConfig(
|
||||
enabled=True,
|
||||
default_role="manager",
|
||||
roles=get_default_router_config().roles,
|
||||
roles=legacy_roles,
|
||||
profiles=legacy_profiles,
|
||||
)
|
||||
save_router_config(legacy_cfg, self.config_path)
|
||||
|
||||
self.assertEqual(len(legacy_cfg.profiles), 16)
|
||||
self.assertNotIn("grok-orch", legacy_cfg.profiles)
|
||||
self.assertNotIn("claude-orch", legacy_cfg.profiles)
|
||||
|
||||
# 2. Load configuration (triggers migration)
|
||||
# 2. Load configuration (triggers migration for missing canonical roles)
|
||||
migrated_cfg = load_router_config(self.config_path)
|
||||
|
||||
# 3. Verify backup file created
|
||||
# 3. Verify backup file created on role migration
|
||||
backups = list(Path(self.tmp_dir).glob("router_profiles.yaml.bak_*"))
|
||||
self.assertGreaterEqual(len(backups), 1, "Backup file was not created on migration")
|
||||
|
||||
# 4. Verify Claude and Grok profiles added
|
||||
self.assertIn("grok-orch", migrated_cfg.profiles)
|
||||
self.assertIn("grok-worker-1", migrated_cfg.profiles)
|
||||
self.assertIn("grok-worker-2", migrated_cfg.profiles)
|
||||
self.assertIn("claude-orch", migrated_cfg.profiles)
|
||||
self.assertIn("claude-worker-1", migrated_cfg.profiles)
|
||||
self.assertIn("claude-worker-2", migrated_cfg.profiles)
|
||||
self.assertEqual(len(migrated_cfg.profiles), 24)
|
||||
# 4. Verify user profiles are preserved untouched (16 profiles preserved, no fake profiles injected)
|
||||
self.assertEqual(len(migrated_cfg.profiles), 16)
|
||||
self.assertEqual(len(migrated_cfg.roles), 13)
|
||||
|
||||
# 5. Verify existing 10 antigravity profiles are 100% untouched
|
||||
for pid in ["ag-orch-fallback", "ag-w1", "ag-w2", "ag-w3", "ag-w4", "ag-spare-1", "ag-spare-2", "ag-cold-1", "ag-cold-2", "ag-cold-3"]:
|
||||
|
|
@ -96,17 +95,17 @@ class TestA9ConfigMigration(unittest.TestCase):
|
|||
self.assertEqual(p.preferred_models, ["custom-model-1"], f"Models mutated for {pid}")
|
||||
self.assertEqual(p.max_concurrency, 3, f"Concurrency mutated for {pid}")
|
||||
|
||||
# 6. Verify AutoAssigner.find_free_slot finds free slots for grok and claude
|
||||
# 6. Verify AutoAssigner.find_free_slot dynamically allocates clean slots for grok and claude
|
||||
with patch.dict(os.environ, {"HERMES_ROUTER_CONFIG": str(self.config_path)}):
|
||||
slot_grok = AutoAssigner.find_free_slot("grok")
|
||||
self.assertEqual(slot_grok, "grok-orch")
|
||||
self.assertEqual(slot_grok, "grok-1")
|
||||
slot_claude = AutoAssigner.find_free_slot("claude")
|
||||
self.assertEqual(slot_claude, "claude-orch")
|
||||
self.assertEqual(slot_claude, "claude-1")
|
||||
|
||||
# 7. Verify Idempotence: subsequent loads do not create extra backups
|
||||
backup_count_before = len(backups)
|
||||
reload_cfg = load_router_config(self.config_path)
|
||||
self.assertEqual(len(reload_cfg.profiles), 24)
|
||||
self.assertEqual(len(reload_cfg.profiles), 18) # 16 + grok-1 + claude-1 registered during find_free_slot
|
||||
backup_count_after = len(list(Path(self.tmp_dir).glob("router_profiles.yaml.bak_*")))
|
||||
self.assertEqual(backup_count_before, backup_count_after)
|
||||
|
||||
|
|
|
|||
|
|
@ -53,10 +53,10 @@ def setup_test_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|||
@pytest.mark.unit
|
||||
def test_unlimited_provider_slots_p0_1(tmp_path: Path):
|
||||
"""P0-1: Connecting 4th and 5th account of a provider removes slot ceiling and dynamically registers profiles."""
|
||||
# Pre-authenticate default 3 codex slots: codex-orch, codex-worker-1, codex-worker-2
|
||||
ProfileAuthManager.save_profile_auth("openai-codex", "codex-orch", {"api_key": "sk-1"})
|
||||
ProfileAuthManager.save_profile_auth("openai-codex", "codex-worker-1", {"api_key": "sk-2"})
|
||||
ProfileAuthManager.save_profile_auth("openai-codex", "codex-worker-2", {"api_key": "sk-3"})
|
||||
# Pre-authenticate 3 codex slots: codex-1, codex-2, codex-3
|
||||
ProfileAuthManager.save_profile_auth("openai-codex", "codex-1", {"api_key": "sk-1"})
|
||||
ProfileAuthManager.save_profile_auth("openai-codex", "codex-2", {"api_key": "sk-2"})
|
||||
ProfileAuthManager.save_profile_auth("openai-codex", "codex-3", {"api_key": "sk-3"})
|
||||
|
||||
# 4th slot should be generated dynamically (e.g. codex-4)
|
||||
slot_4 = AutoAssigner.find_free_slot("openai-codex")
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ def test_multi_account_sequential_slot_progression(tmp_path, monkeypatch):
|
|||
|
||||
# Initial free slot
|
||||
first_slot = AutoAssigner.find_free_slot("antigravity")
|
||||
assert first_slot == "ag-orch-fallback"
|
||||
assert first_slot == "ag-1"
|
||||
|
||||
# Connect first slot
|
||||
AutoAssigner.ensure_profile_definition("antigravity", first_slot)
|
||||
|
|
@ -204,7 +204,7 @@ def test_multi_account_sequential_slot_progression(tmp_path, monkeypatch):
|
|||
|
||||
# Next free slot
|
||||
second_slot = AutoAssigner.find_free_slot("antigravity")
|
||||
assert second_slot == "ag-w1"
|
||||
assert second_slot == "ag-2"
|
||||
|
||||
# Connect second slot
|
||||
AutoAssigner.ensure_profile_definition("antigravity", second_slot)
|
||||
|
|
@ -213,7 +213,7 @@ def test_multi_account_sequential_slot_progression(tmp_path, monkeypatch):
|
|||
|
||||
# Next free slot
|
||||
third_slot = AutoAssigner.find_free_slot("antigravity")
|
||||
assert third_slot == "ag-w2"
|
||||
assert third_slot == "ag-3"
|
||||
|
||||
|
||||
# ── TEST 5: Model selection and assignment (P1-6) ──
|
||||
|
|
|
|||
|
|
@ -319,7 +319,7 @@ def test_generate_quotas_export_json_and_csv():
|
|||
assert "total_profiles" in json_export
|
||||
assert "profiles" in json_export
|
||||
assert "rows" in json_export
|
||||
assert len(json_export["profiles"]) > 0
|
||||
assert isinstance(json_export["profiles"], list)
|
||||
|
||||
csv_export = generate_quotas_export(format="csv")
|
||||
assert isinstance(csv_export, str)
|
||||
|
|
|
|||
|
|
@ -78,28 +78,26 @@ def test_auto_assigner_find_free_slot_for_all_five_providers(clean_env):
|
|||
for provider in ["antigravity", "openai-codex", "opencode-go", "claude", "grok"]:
|
||||
slot = AutoAssigner.find_free_slot(provider)
|
||||
if slot is not None:
|
||||
# Slot MUST exist in config.profiles
|
||||
config = load_router_config()
|
||||
assert slot in config.profiles
|
||||
assert config.profiles[slot].provider == provider
|
||||
|
||||
# Test allocation when all default slots are filled (A26: unlimited accounts)
|
||||
# Test allocation with clean slot numbering (A41 / A26 unlimited accounts)
|
||||
slot_claude = AutoAssigner.find_free_slot("claude")
|
||||
assert slot_claude is not None
|
||||
assert slot_claude in ("claude-orch", "claude-worker-1", "claude-worker-2")
|
||||
assert slot_claude == "claude-1"
|
||||
|
||||
# Simulate fake auth on all 3 default claude slots
|
||||
for c_slot in ["claude-orch", "claude-worker-1", "claude-worker-2"]:
|
||||
ProfileAuthManager.save_profile_auth("claude", c_slot, {"api_key": "sk-ant-test-key-1234567890123456"})
|
||||
# Simulate fake auth on claude-1
|
||||
ProfileAuthManager.save_profile_auth("claude", "claude-1", {"api_key": "sk-ant-test-key-1234567890123456"})
|
||||
|
||||
# Now all default claude slots are taken -> find_free_slot dynamically generates claude-worker-3
|
||||
# Next slot dynamically allocated is claude-2
|
||||
next_slot = AutoAssigner.find_free_slot("claude")
|
||||
assert next_slot == "claude-worker-3"
|
||||
assert next_slot == "claude-2"
|
||||
cfg = load_router_config()
|
||||
assert "claude-worker-3" in cfg.profiles
|
||||
assert cfg.profiles["claude-worker-3"].provider == "claude"
|
||||
assert "claude-2" in cfg.profiles
|
||||
assert cfg.profiles["claude-2"].provider == "claude"
|
||||
|
||||
rec_slot, title, reason = AutoAssigner.recommend_assignment("claude")
|
||||
assert rec_slot == "claude-worker-3"
|
||||
assert rec_slot == "claude-2"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
|
|
|||
|
|
@ -247,46 +247,45 @@ class TestLocalLLMModelDiscovery:
|
|||
class TestLocalLLMConfigAndAutoAssigner:
|
||||
"""Test default configuration, migration, and slot auto-assignment."""
|
||||
|
||||
def test_default_config_contains_local_slots(self):
|
||||
def test_default_config_clean_roles_and_local_registration(self):
|
||||
cfg = get_default_router_config()
|
||||
assert "local-1" in cfg.profiles
|
||||
assert "local-2" in cfg.profiles
|
||||
assert len(cfg.profiles) == 0
|
||||
assert len(cfg.roles) == 13
|
||||
|
||||
p1 = cfg.profiles["local-1"]
|
||||
slot = AutoAssigner.find_free_slot("local")
|
||||
assert slot == "local-1"
|
||||
|
||||
reloaded = load_router_config()
|
||||
assert "local-1" in reloaded.profiles
|
||||
p1 = reloaded.profiles["local-1"]
|
||||
assert p1.provider == "local"
|
||||
assert "code-reviewer" in p1.capabilities
|
||||
assert "coding" in p1.capabilities
|
||||
assert "Qwen3.8-27B-Q4_K_M.gguf" in p1.preferred_models
|
||||
assert p1.max_concurrency == 1
|
||||
|
||||
p2 = cfg.profiles["local-2"]
|
||||
assert p2.provider == "local"
|
||||
assert "tester" in p2.capabilities
|
||||
assert "Qwen3-4B-Instruct-2507-Q4_K_M.gguf" in p2.preferred_models
|
||||
assert p2.max_concurrency == 1
|
||||
|
||||
def test_config_migration_adds_local_slots(self):
|
||||
def test_config_migration_preserves_user_profiles_without_injecting_stubs(self):
|
||||
tmp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
config_path = Path(tmp_dir) / "router_profiles.yaml"
|
||||
legacy_profiles = {
|
||||
"codex-orch": RouterProfileConfig(
|
||||
profile_id="codex-orch",
|
||||
"custom-codex": RouterProfileConfig(
|
||||
profile_id="custom-codex",
|
||||
provider="openai-codex",
|
||||
)
|
||||
}
|
||||
legacy_cfg = RouterConfig(
|
||||
enabled=True,
|
||||
default_role="manager",
|
||||
roles=get_default_router_config().roles,
|
||||
roles={"manager": get_default_router_config().roles["manager"]},
|
||||
profiles=legacy_profiles,
|
||||
)
|
||||
save_router_config(legacy_cfg, config_path)
|
||||
|
||||
migrated = load_router_config(config_path)
|
||||
assert "local-1" in migrated.profiles
|
||||
assert "local-2" in migrated.profiles
|
||||
assert migrated.profiles["local-1"].provider == "local"
|
||||
# User profile is preserved
|
||||
assert "custom-codex" in migrated.profiles
|
||||
# 13 canonical roles are migrated
|
||||
assert len(migrated.roles) == 13
|
||||
# No dummy local profiles injected
|
||||
assert "local-1" not in migrated.profiles
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from pathlib import Path
|
|||
import pytest
|
||||
|
||||
from antigravity_provider.router.action_handler import ActionExecutor, do_set_model
|
||||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
|
||||
from antigravity_provider.router.router_config import load_router_config, save_router_config
|
||||
|
||||
|
|
@ -45,8 +46,9 @@ def temp_models_cache(tmp_path, monkeypatch):
|
|||
def test_set_model_success_and_config_persistence(temp_models_cache):
|
||||
"""Verify setting valid discovered model succeeds and persists in router_config."""
|
||||
svc, _ = temp_models_cache
|
||||
config = load_router_config()
|
||||
profile_id = "ag-w1"
|
||||
AutoAssigner.ensure_profile_definition("antigravity", profile_id)
|
||||
config = load_router_config()
|
||||
assert profile_id in config.profiles
|
||||
|
||||
# Pick valid discovered model
|
||||
|
|
@ -69,8 +71,9 @@ def test_set_model_success_and_config_persistence(temp_models_cache):
|
|||
def test_set_model_rejects_nonexistent_model(temp_models_cache):
|
||||
"""Verify nonexistent model is strictly rejected without modifying configuration."""
|
||||
svc, _ = temp_models_cache
|
||||
config = load_router_config()
|
||||
profile_id = "ag-w1"
|
||||
AutoAssigner.ensure_profile_definition("antigravity", profile_id)
|
||||
config = load_router_config()
|
||||
orig_models = list(config.profiles[profile_id].preferred_models)
|
||||
|
||||
# Attempt to set invalid/hallucinated model
|
||||
|
|
|
|||
|
|
@ -46,57 +46,33 @@ from antigravity_provider.router.cli_commands import (
|
|||
|
||||
|
||||
class TestRouterConfig:
|
||||
"""Test configuration schema, profile loading, and role definitions."""
|
||||
"""Test configuration schema, clean default config, and role definitions."""
|
||||
|
||||
def test_default_config_has_24_profiles(self):
|
||||
def test_default_config_is_clean(self):
|
||||
config = get_default_router_config()
|
||||
assert len(config.profiles) == 24
|
||||
# 3 Codex
|
||||
assert "codex-orch" in config.profiles
|
||||
assert "codex-worker-1" in config.profiles
|
||||
assert "codex-worker-2" in config.profiles
|
||||
# 10 Antigravity (7 active, 3 cold)
|
||||
assert "ag-orch-fallback" in config.profiles
|
||||
assert "ag-w1" in config.profiles
|
||||
assert "ag-w4" in config.profiles
|
||||
assert "ag-spare-1" in config.profiles
|
||||
assert "ag-cold-1" in config.profiles
|
||||
assert config.profiles["ag-cold-1"].enabled is False
|
||||
# 3 OpenCode Go
|
||||
assert "opengo-1" in config.profiles
|
||||
assert "opengo-2" in config.profiles
|
||||
assert "opengo-3" in config.profiles
|
||||
# 3 Claude
|
||||
assert "claude-orch" in config.profiles
|
||||
assert "claude-worker-1" in config.profiles
|
||||
assert "claude-worker-2" in config.profiles
|
||||
# 3 Grok
|
||||
assert "grok-orch" in config.profiles
|
||||
assert "grok-worker-1" in config.profiles
|
||||
assert "grok-worker-2" in config.profiles
|
||||
assert len(config.profiles) == 0
|
||||
assert len(config.roles) == 13
|
||||
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
|
||||
orch = config.roles["manager"]
|
||||
assert "codex-orch" in orch.preferred_chain
|
||||
assert "ag-orch-fallback" in orch.preferred_chain
|
||||
assert "opengo-3" in orch.preferred_chain
|
||||
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
|
||||
|
||||
coder = config.roles["developer-1"]
|
||||
assert "codex-worker-1" in coder.preferred_chain
|
||||
assert "ag-w1" in coder.preferred_chain
|
||||
assert "opengo-1" in coder.preferred_chain
|
||||
|
||||
reviewer = config.roles["code-reviewer"]
|
||||
assert "codex-worker-2" in reviewer.preferred_chain
|
||||
assert "opengo-2" in reviewer.preferred_chain
|
||||
assert "ag-w2" in reviewer.preferred_chain
|
||||
|
||||
research = config.roles["researcher"]
|
||||
assert "opengo-1" in research.preferred_chain
|
||||
assert "ag-w3" in research.preferred_chain
|
||||
assert "opengo-2" in research.preferred_chain
|
||||
for rname, rpol in config.roles.items():
|
||||
assert rpol.preferred_chain == [], f"Expected empty chain for {rname} on clean install"
|
||||
|
||||
|
||||
class TestHealthTracker:
|
||||
|
|
@ -175,10 +151,30 @@ class TestSessionAffinityAndLeases:
|
|||
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=get_default_router_config(),
|
||||
config=self._get_test_config(),
|
||||
health=HealthTracker(state_file=state_file),
|
||||
affinity=SessionAffinityTracker(),
|
||||
)
|
||||
|
|
@ -217,7 +213,7 @@ class TestRouterEngineFailover:
|
|||
def test_session_affinity_retention_after_failover(self, tmp_path):
|
||||
state_file = tmp_path / "test_affinity_state.json"
|
||||
engine = RouterEngine(
|
||||
config=get_default_router_config(),
|
||||
config=self._get_test_config(),
|
||||
health=HealthTracker(state_file=state_file),
|
||||
affinity=SessionAffinityTracker(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -32,20 +32,18 @@ from antigravity_provider.router.router_config import load_router_config
|
|||
def test_profile_view_model_mapping():
|
||||
"""Verify that scan_all creates ProfileViewModels for all configured profiles."""
|
||||
service = UnifiedHealthService.get()
|
||||
profiles_by_prov = service.scan_all()
|
||||
profiles_by_prov = service.scan_all(force=True)
|
||||
|
||||
assert "antigravity" in profiles_by_prov
|
||||
assert "openai-codex" in profiles_by_prov
|
||||
assert "opencode-go" in profiles_by_prov
|
||||
|
||||
all_profs = [p for profs in profiles_by_prov.values() for p in profs]
|
||||
assert len(all_profs) >= 16
|
||||
|
||||
for p in all_profs:
|
||||
assert isinstance(p, ProfileViewModel)
|
||||
assert p.profile_id
|
||||
assert p.display_name
|
||||
assert p.provider in ("antigravity", "openai-codex", "opencode-go", "claude", "grok", "local")
|
||||
assert p.provider in ("antigravity", "openai-codex", "opencode-go", "claude", "grok", "local", "openrouter", "nvidia", "ollama", "vllm")
|
||||
assert p.health_state in (
|
||||
"healthy", "quota_low", "quota_exhausted", "cooldown", "rate_limited",
|
||||
"auth_required", "auth_expired", "disabled", "cold_spare", "unhealthy", "not_tested", "not_configured"
|
||||
|
|
@ -55,7 +53,7 @@ def test_profile_view_model_mapping():
|
|||
def test_auth_required_never_healthy_without_credentials():
|
||||
"""Verify that unauthenticated profiles are NEVER marked as HEALTHY."""
|
||||
service = UnifiedHealthService.get()
|
||||
profiles_by_prov = service.scan_all()
|
||||
profiles_by_prov = service.scan_all(force=True)
|
||||
|
||||
for profs in profiles_by_prov.values():
|
||||
for p in profs:
|
||||
|
|
@ -65,8 +63,9 @@ def test_auth_required_never_healthy_without_credentials():
|
|||
|
||||
def test_cold_spare_presentation():
|
||||
"""Verify that cold spare slots are marked as cold_spare or auth_required, not active."""
|
||||
AutoAssigner.ensure_profile_definition("antigravity", "ag-cold-1")
|
||||
service = UnifiedHealthService.get()
|
||||
profiles_by_prov = service.scan_all()
|
||||
profiles_by_prov = service.scan_all(force=True)
|
||||
ag_profs = {p.profile_id: p for p in profiles_by_prov["antigravity"]}
|
||||
|
||||
cold_1 = ag_profs.get("ag-cold-1")
|
||||
|
|
|
|||
Loading…
Reference in a new issue