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:
ochenstarik-ui 2026-08-31 02:27:42 +07:00
parent e6bbd60a36
commit 2b89372b0f
21 changed files with 876 additions and 741 deletions

View file

@ -1,21 +1,21 @@
# Hermes Hub: Multi-Provider Account Router Default Profiles Template # 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: router:
enabled: true enabled: true
default_role: "orchestrator" default_role: "manager"
max_failover_attempts: 3 max_failover_attempts: 3
cooldown_base_seconds: 300 cooldown_base_seconds: 300
cooldown_max_seconds: 3600 cooldown_max_seconds: 3600
session_affinity_ttl_seconds: 1800 session_affinity_ttl_seconds: 1800
quota_threshold_percent: 10.0
quota_threshold_action: "notify"
roles: roles:
orchestrator: manager:
role_name: "orchestrator" role_name: "manager"
preferred_chain: preferred_chain: []
- "codex-orch"
- "ag-orch-fallback"
- "opengo-3"
fallback_capabilities: fallback_capabilities:
- "orchestrator" - "orchestrator"
- "reasoning" - "reasoning"
@ -23,281 +23,114 @@ roles:
session_affinity_enabled: true session_affinity_enabled: true
default_model: "gemini-3.7-flash" default_model: "gemini-3.7-flash"
coder-primary: developer-1:
role_name: "coder-primary" role_name: "developer-1"
preferred_chain: preferred_chain: []
- "codex-worker-1"
- "ag-w1"
- "opengo-3"
fallback_capabilities: fallback_capabilities:
- "coding" - "coding"
max_failover_attempts: 3 max_failover_attempts: 3
session_affinity_enabled: true session_affinity_enabled: true
default_model: "gemini-3.7-flash"
coder-secondary: developer-2:
role_name: "coder-secondary" role_name: "developer-2"
preferred_chain: preferred_chain: []
- "codex-worker-2"
- "ag-w2"
- "opengo-2"
fallback_capabilities: fallback_capabilities:
- "coding" - "coding"
- "reviewer" - "reviewer"
max_failover_attempts: 3 max_failover_attempts: 3
session_affinity_enabled: true session_affinity_enabled: true
default_model: "gemini-3.1-pro-high"
reviewer: code-reviewer:
role_name: "reviewer" role_name: "code-reviewer"
preferred_chain: preferred_chain: []
- "codex-worker-2"
- "opengo-2"
- "ag-w2"
fallback_capabilities: fallback_capabilities:
- "reviewer" - "reviewer"
- "coding" - "coding"
max_failover_attempts: 3 max_failover_attempts: 3
session_affinity_enabled: true session_affinity_enabled: true
default_model: "claude-opus-4-6-thinking"
research: researcher:
role_name: "research" role_name: "researcher"
preferred_chain: preferred_chain: []
- "opengo-1"
- "ag-w3"
- "ag-w4"
fallback_capabilities: fallback_capabilities:
- "research" - "research"
- "fast" - "search"
max_failover_attempts: 3 max_failover_attempts: 3
session_affinity_enabled: true session_affinity_enabled: true
fast: tester:
role_name: "fast" role_name: "tester"
preferred_chain: preferred_chain: []
- "opengo-1"
- "ag-w4"
- "ag-spare-1"
fallback_capabilities: fallback_capabilities:
- "fast" - "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" - "coding"
max_failover_attempts: 3 max_failover_attempts: 3
session_affinity_enabled: false session_affinity_enabled: true
profiles: security-expert:
# Antigravity (10 accounts) role_name: "security-expert"
ag-orch-fallback: preferred_chain: []
profile_id: "ag-orch-fallback" fallback_capabilities:
provider: "antigravity" - "security"
account_id: "ag-acc-orch" - "code-reviewer"
enabled: true max_failover_attempts: 3
capabilities: ["orchestrator", "reasoning", "coding"] session_affinity_enabled: true
preferred_models: ["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"]
max_concurrency: 2
ag-w1: dependency-agent:
profile_id: "ag-w1" role_name: "dependency-agent"
provider: "antigravity" preferred_chain: []
account_id: "ag-acc-w1" fallback_capabilities:
enabled: true - "dependency-agent"
capabilities: ["coding", "coder-primary", "reasoning"] - "preflight"
preferred_models: ["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"] max_failover_attempts: 3
max_concurrency: 2 session_affinity_enabled: true
ag-w2: profiles: {}
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

View file

@ -18,7 +18,13 @@ for p in [
if p.is_dir() and str(p) not in sys.path: if p.is_dir() and str(p) not in sys.path:
sys.path.insert(0, str(p)) 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 ( from antigravity_provider.router.health_tracker import (
HEALTHY, HEALTHY,
QUOTA_EXHAUSTED, QUOTA_EXHAUSTED,
@ -46,48 +52,29 @@ def run_checks() -> int:
# 1. Config inventory # 1. Config inventory
print("1. Checking profile inventory and provider counts...") print("1. Checking profile inventory and provider counts...")
config = get_default_router_config() config = load_router_config()
# Проверка структурная, а не пересчёт. Раньше здесь стояло «ровно 16 if config.profiles:
# профилей» и дословные цепочки от 20 августа. Миграция законно довела counts = {}
# конфигурацию до 22 профилей, добавив claude и grok, — и установка стала for prof in config.profiles.values():
# падать с кодом 12 на любой машине, где миграция отработала. Смысл этой 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})")
counts = {} else:
for prof in config.profiles.values(): print(" [PASS] Чистая конфигурация по умолчанию (0 профилей, добавление по мере подключения)")
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})")
passed += 1 passed += 1
# 2. Role Fallback Chains # 2. Role Fallback Chains
print("2. Checking role fallback policies...") print("2. Checking role fallback policies...")
# Имя оркестрирующей роли меняется вместе с реестром: в A28 orchestrator
# стал manager. Дословная проверка старого имени пережила миграцию и
# роняла установку на Windows с кодом 12 — на Linux этот скрипт не
# запускается, поэтому там всё ставилось. Спрашиваем актуальное имя у
# реестра, а не помним его в скрипте.
assert config.roles, "В конфигурации нет ни одной роли" assert config.roles, "В конфигурации нет ни одной роли"
try: try:
from antigravity_provider.router.role_registry import RoleRegistry from antigravity_provider.router.role_registry import RoleRegistry
orchestrating_role = RoleRegistry.resolve_canonical_role("orchestrator") orchestrating_role = RoleRegistry.resolve_canonical_role("orchestrator")
except Exception: except Exception:
orchestrating_role = "orchestrator" orchestrating_role = "manager"
assert orchestrating_role in config.roles, ( assert orchestrating_role in config.roles, (
f"Оркестрирующая роль {orchestrating_role!r} отсутствует; есть: {sorted(config.roles)}" f"Оркестрирующая роль {orchestrating_role!r} отсутствует; есть: {sorted(config.roles)}"
) )
# Цепочки настраиваются владельцем и меняются — дословно их сверять нельзя.
# Проверяем то, что действительно ломает маршрутизацию: цепочка непуста и
# каждый профиль в ней существует.
# Пустая цепочка — не поломка сама по себе. В A28 появились роли,
# объявленные без реализации (guardian, cost-controller): аккаунтов у них
# ещё нет, и требовать цепочку — значит ронять установку из-за роли,
# которой никто не пользуется. Ломает маршрутизацию другое: ссылка на
# несуществующий профиль и пустая цепочка у ОРКЕСТРИРУЮЩЕЙ роли.
empty_chains = [] empty_chains = []
for role_name, policy in config.roles.items(): for role_name, policy in config.roles.items():
chain = policy.preferred_chain or [] chain = policy.preferred_chain or []
@ -97,12 +84,9 @@ def run_checks() -> int:
assert pid in config.profiles, ( assert pid in config.profiles, (
f"Роль {role_name} ссылается на несуществующий профиль {pid}" f"Роль {role_name} ссылается на несуществующий профиль {pid}"
) )
assert config.roles[orchestrating_role].preferred_chain, (
f"У оркестрирующей роли {orchestrating_role!r} пустая цепочка — маршрутизация работать не будет"
)
if empty_chains: if empty_chains:
print(f" [INFO] Без аккаунтов пока: {', '.join(sorted(empty_chains))}") print(f" [INFO] Роли с пустыми цепочками (чистый старт / не назначены): {', '.join(sorted(empty_chains))}")
print(f" [PASS] Цепочки {len(config.roles)} ролей ссылаются только на существующие профили") print(f" [PASS] Все {len(config.roles)} ролей валидны и ссылаются только на существующие профили")
passed += 1 passed += 1
# 3. Model family extraction # 3. Model family extraction
@ -178,29 +162,9 @@ def run_checks() -> int:
# 9. Full failover execution loop # 9. Full failover execution loop
print("9. Checking full failover execution loop...") print("9. Checking full failover execution loop...")
engine = RouterEngine(config=config) full_chain = list(config.roles.get(orchestrating_role, RolePolicy(role_name=orchestrating_role)).preferred_chain)
engine.health.clear_cooldown() max_attempts = getattr(config.roles.get(orchestrating_role, RolePolicy(role_name=orchestrating_role)), "max_failover_attempts", 0) or len(full_chain)
# Проверяется МЕХАНИЗМ отказоустойчивости, а не расстановка аккаунтов.
#
# Прежняя версия зашивала порядок 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)
chain = full_chain[:max_attempts] 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 = { adapter_by_provider = {
"openai-codex": CodexAdapter, "openai-codex": CodexAdapter,
@ -211,9 +175,48 @@ def run_checks() -> int:
"local": LocalLLMAdapter, "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"}}]} expected = {"id": "ok", "choices": [{"message": {"role": "assistant", "content": "from-last-in-chain"}}]}
failing = {cls for pid in chain[:-1] failing = {cls for pid in test_chain[:-1]
if (cls := adapter_by_provider.get(config.profiles[pid].provider)) is not None} if (cls := adapter_by_provider.get(test_profiles[pid].provider)) is not None}
winner = adapter_by_provider.get(last_provider) winner = adapter_by_provider.get(last_provider)
if winner is None or winner in failing: if winner is None or winner in failing:
@ -224,9 +227,9 @@ def run_checks() -> int:
for cls in failing: for cls in failing:
stack.enter_context(patch.object(cls, "invoke", side_effect=RuntimeError("Insufficient quota"))) stack.enter_context(patch.object(cls, "invoke", side_effect=RuntimeError("Insufficient quota")))
stack.enter_context(patch.object(winner, "invoke", return_value=expected)) stack.enter_context(patch.object(winner, "invoke", return_value=expected))
res = engine.route_request( res = test_engine.route_request(
{"messages": [{"role": "user", "content": "test"}]}, {"messages": [{"role": "user", "content": "test"}]},
role=orchestrating_role, role=test_role,
session_id="verify-failover", session_id="verify-failover",
) )
assert res["choices"][0]["message"]["content"] == "from-last-in-chain", ( 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, ( assert res["router_metadata"]["profile_id"] == last_pid, (
f"Ожидался профиль {last_pid}, получен {res['router_metadata']['profile_id']}" f"Ожидался профиль {last_pid}, получен {res['router_metadata']['profile_id']}"
) )
print(f" [PASS] Отказоустойчивость прошла цепочку {' -> '.join(chain)}") print(f" [PASS] Отказоустойчивость прошла цепочку {' -> '.join(test_chain)}")
passed += 1 passed += 1
# 10. Passthrough & Graceful fallback # 10. Passthrough & Graceful fallback

View file

@ -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]: def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
config = load_router_config() config = load_router_config()
pcfg = config.get_profile(profile_id) 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: if not pcfg:
return {'success': False, 'error': f"Профиль '{profile_id}' не найден"} 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: class ActionExecutor:
"""Shared execution layer for Desktop and Web actions.""" """Shared execution layer for Desktop and Web actions."""
@ -490,6 +553,28 @@ class ActionExecutor:
prov = _pcfg.provider prov = _pcfg.provider
except Exception: except Exception:
pass 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 { if action in {
'create_agent', 'create_agent',
@ -927,5 +1012,9 @@ class ActionExecutor:
'data': {'format': 'json', 'report': res, 'filename': 'hermes_quotas_export.json'} '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: else:
return {'ok': False, 'message': f'Неизвестное действие: {action}', 'unknown': True} return {'ok': False, 'message': f'Неизвестное действие: {action}', 'unknown': True}

View file

@ -14,6 +14,7 @@ import logging
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple 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.profile_manager import ProfileAuthManager, mask_email, mask_id
from antigravity_provider.router.role_registry import RoleRegistry from antigravity_provider.router.role_registry import RoleRegistry
from antigravity_provider.router.router_config import ( 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.""" """Get human-readable display name, logical role, and tier for a profile."""
if profile_id in DEFAULT_SLOT_ROLES: if profile_id in DEFAULT_SLOT_ROLES:
return DEFAULT_SLOT_ROLES[profile_id] 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() clean_name = profile_id.replace("-", " ").title()
return (clean_name, "worker", "primary") return (clean_name, "worker", "primary")
@ -104,91 +121,57 @@ class AutoAssigner:
def find_free_slot(provider: str, requested_role: str = "auto") -> Optional[str]: def find_free_slot(provider: str, requested_role: str = "auto") -> Optional[str]:
"""Find the optimal free internal profile slot for a provider. """Find the optimal free internal profile slot for a provider.
When all predefined candidate slots are authenticated/occupied, dynamically Generates clean numbered slots starting from 1 (codex-1, ag-1, claude-1,
generates the next free profile ID without capping account count (P0-1). grok-1, opengo-1, local-1, openrouter-1, nvidia-1, ollama-1) upon first addition.
""" """
config = load_router_config() config = load_router_config()
provider_norm = (provider or "").strip().lower() provider_norm = (provider or "").strip().lower()
provider_slots = { def _get_prefix(prov: str) -> str:
"antigravity": [ p = (prov or "").strip().lower()
"ag-orch-fallback", "ag-w1", "ag-w2", "ag-w3", "ag-w4", if p in ("antigravity", "google-antigravity", "agy"):
"ag-spare-1", "ag-spare-2", "ag-cold-1", "ag-cold-2", "ag-cold-3" return "ag"
], if p in ("openai-codex", "codex", "openai"):
"openai-codex": ["codex-orch", "codex-worker-1", "codex-worker-2"], return "codex"
"codex": ["codex-orch", "codex-worker-1", "codex-worker-2"], if p in ("opencode-go", "opencode", "opengo"):
"opencode-go": ["opengo-1", "opengo-2", "opengo-3"], return "opengo"
"opencode": ["opengo-1", "opengo-2", "opengo-3"], if p in ("claude", "anthropic"):
"claude": ["claude-orch", "claude-worker-1", "claude-worker-2"], return "claude"
"anthropic": ["claude-orch", "claude-worker-1", "claude-worker-2"], if p in ("grok", "xai"):
"grok": ["grok-orch", "grok-worker-1", "grok-worker-2"], return "grok"
"xai": ["grok-orch", "grok-worker-1", "grok-worker-2"], if p in ("local", "local-llm", "llama.cpp"):
"local": ["local-1", "local-2"], return "local"
"local-llm": ["local-1", "local-2"], if p in ("openrouter",):
"llama.cpp": ["local-1", "local-2"], return "openrouter"
"ollama": ["ollama-1", "ollama-2"], if p in ("nvidia", "nvidia-nim"):
"vllm": ["local-1", "local-2"], 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 # 1. First check existing candidate profiles already in config
for pid in candidates: for pid, pcfg in config.profiles.items():
pcfg = config.get_profile(pid) if _get_prefix(pcfg.provider) == prefix or pcfg.provider == provider_norm:
if not pcfg: status = ProfileAuthManager.get_profile_status(pcfg.provider, pid)
continue if not status.get("authenticated"):
status = ProfileAuthManager.get_profile_status(provider, pid) return pid
if not status.get("authenticated"):
return pid
# 2. Check remaining predefined slots and ensure definition # 2. Dynamically generate clean numbered slots (codex-1, ag-1, claude-1, etc.)
for pid in candidates: for i in range(1, 200):
status = ProfileAuthManager.get_profile_status(provider, pid) 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"): if not status.get("authenticated"):
AutoAssigner.ensure_profile_definition(provider, pid) AutoAssigner.ensure_profile_definition(provider, candidate_pid)
return pid return candidate_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
return None return None
@ -209,15 +192,23 @@ class AutoAssigner:
capabilities_map = { capabilities_map = {
"grok": ["reasoning", "coding", "research"], "grok": ["reasoning", "coding", "research"],
"xai": ["reasoning", "coding", "research"],
"claude": ["reasoning", "coding", "review"], "claude": ["reasoning", "coding", "review"],
"anthropic": ["reasoning", "coding", "review"],
"opencode-go": ["coding", "research", "fast"], "opencode-go": ["coding", "research", "fast"],
"opencode": ["coding", "research", "fast"],
"openai-codex": ["coding", "reasoning"], "openai-codex": ["coding", "reasoning"],
"codex": ["coding", "reasoning"],
"antigravity": ["coding", "reasoning", "research", "fast"], "antigravity": ["coding", "reasoning", "research", "fast"],
"google-antigravity": ["coding", "reasoning", "research", "fast"],
"local": ["reviewer", "coding", "reasoning", "fast", "research"], "local": ["reviewer", "coding", "reasoning", "fast", "research"],
"local-llm": ["reviewer", "coding", "reasoning", "fast", "research"], "local-llm": ["reviewer", "coding", "reasoning", "fast", "research"],
"llama.cpp": ["reviewer", "coding", "reasoning", "fast", "research"], "llama.cpp": ["reviewer", "coding", "reasoning", "fast", "research"],
"ollama": ["reviewer", "coding", "reasoning", "fast", "research"], "ollama": ["reviewer", "coding", "reasoning", "fast", "research"],
"vllm": ["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"]) 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.""" """Assign a profile to a canonical router role, updating fallback chains and persisting config."""
config = load_router_config() config = load_router_config()
pcfg = config.get_profile(profile_id) 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: if not pcfg:
return False, f"Профиль '{profile_id}' не найден" return False, f"Профиль '{profile_id}' не найден"
@ -314,6 +327,31 @@ class AutoAssigner:
@staticmethod @staticmethod
def auto_assign_all() -> Dict[str, Any]: def auto_assign_all() -> Dict[str, Any]:
"""Automatically distribute all authenticated profiles across canonical router roles (P0-4).""" """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() config = load_router_config()
authenticated_profiles: List[Tuple[str, RouterProfileConfig]] = [] authenticated_profiles: List[Tuple[str, RouterProfileConfig]] = []
for pid, pcfg in config.profiles.items(): for pid, pcfg in config.profiles.items():

View file

@ -34,6 +34,9 @@ def print_router_status() -> int:
print("-" * 80) print("-" * 80)
for rname, rpolicy in sorted(config.roles.items()): 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): for idx, pid in enumerate(rpolicy.preferred_chain):
pconfig = config.get_profile(pid) pconfig = config.get_profile(pid)
if not pconfig: 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: def simulate_quota_cli(profile_id: str, model_family: Optional[str] = None, duration: int = 600) -> int:
"""Simulate quota exhaustion on a profile for testing.""" """Simulate quota exhaustion on a profile for testing."""
engine = get_router_engine() 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) 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(f"[OK] Simulated quota exhaustion activated for profile '{profile_id}' for {duration} seconds.")
print("Use `hermes router clear-cooldown` to restore normal state.") print("Use `hermes router clear-cooldown` to restore normal state.")

View file

@ -322,7 +322,11 @@ class ProfileAuthManager:
if base_url: if base_url:
try: try:
from antigravity_provider.router.router_config import load_router_config, save_router_config 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() 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: if profile_id in rcfg.profiles:
rcfg.profiles[profile_id].custom_base_url = str(base_url).strip() rcfg.profiles[profile_id].custom_base_url = str(base_url).strip()
if auth_data.get("models") and isinstance(auth_data["models"], list): if auth_data.get("models") and isinstance(auth_data["models"], list):

View file

@ -39,7 +39,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_orchestrator=True, is_orchestrator=True,
capabilities=["orchestrator", "reasoning", "management", "planning"], capabilities=["orchestrator", "reasoning", "management", "planning"],
fallback_capabilities=["orchestrator", "reasoning"], 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", default_model="gemini-3.7-flash",
max_failover_attempts=3, max_failover_attempts=3,
tier="core", tier="core",
@ -52,7 +52,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_implemented=True, is_implemented=True,
capabilities=["coding", "developer-1", "reasoning", "tools", "structured_output"], capabilities=["coding", "developer-1", "reasoning", "tools", "structured_output"],
fallback_capabilities=["coding"], 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", default_model="gemini-3.7-flash",
max_failover_attempts=3, max_failover_attempts=3,
tier="core", tier="core",
@ -65,7 +65,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_implemented=True, is_implemented=True,
capabilities=["coding", "developer-2", "reviewer", "tools"], capabilities=["coding", "developer-2", "reviewer", "tools"],
fallback_capabilities=["coding", "reviewer"], 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", default_model="gemini-3.1-pro-high",
max_failover_attempts=3, max_failover_attempts=3,
tier="core", tier="core",
@ -78,7 +78,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_implemented=True, is_implemented=True,
capabilities=["code-reviewer", "reviewer", "coding", "security_analysis"], capabilities=["code-reviewer", "reviewer", "coding", "security_analysis"],
fallback_capabilities=["reviewer", "coding"], 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", default_model="claude-opus-4-6-thinking",
max_failover_attempts=3, max_failover_attempts=3,
tier="core", tier="core",
@ -91,7 +91,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_implemented=True, is_implemented=True,
capabilities=["researcher", "research", "search", "long_context"], capabilities=["researcher", "research", "search", "long_context"],
fallback_capabilities=["research", "search"], fallback_capabilities=["research", "search"],
default_preferred_chain=["ag-w3", "grok-worker-2", "opengo-2", "opengo-1"], default_preferred_chain=[],
max_failover_attempts=3, max_failover_attempts=3,
tier="core", tier="core",
), ),
@ -103,7 +103,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_implemented=True, is_implemented=True,
capabilities=["tester", "testing", "fast", "automation"], capabilities=["tester", "testing", "fast", "automation"],
fallback_capabilities=["fast", "testing"], fallback_capabilities=["fast", "testing"],
default_preferred_chain=["ag-w4", "opengo-1", "ag-spare-1"], default_preferred_chain=[],
max_failover_attempts=3, max_failover_attempts=3,
tier="qa_doc", tier="qa_doc",
), ),
@ -115,7 +115,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_implemented=True, is_implemented=True,
capabilities=["tech-writer", "documentation", "reasoning", "structured_output"], capabilities=["tech-writer", "documentation", "reasoning", "structured_output"],
fallback_capabilities=["documentation", "reasoning"], fallback_capabilities=["documentation", "reasoning"],
default_preferred_chain=["claude-worker-2", "ag-w3", "ag-w2"], default_preferred_chain=[],
max_failover_attempts=3, max_failover_attempts=3,
tier="qa_doc", tier="qa_doc",
), ),
@ -127,7 +127,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_implemented=True, is_implemented=True,
capabilities=["analyst", "reasoning", "research", "planning"], capabilities=["analyst", "reasoning", "research", "planning"],
fallback_capabilities=["reasoning", "research"], fallback_capabilities=["reasoning", "research"],
default_preferred_chain=["grok-worker-2", "opengo-2", "ag-w3"], default_preferred_chain=[],
max_failover_attempts=3, max_failover_attempts=3,
tier="qa_doc", tier="qa_doc",
), ),
@ -165,7 +165,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_implemented=True, is_implemented=True,
capabilities=["integration-expert", "integration", "coding", "networking"], capabilities=["integration-expert", "integration", "coding", "networking"],
fallback_capabilities=["integration", "coding"], fallback_capabilities=["integration", "coding"],
default_preferred_chain=["opengo-3", "codex-worker-1", "ag-w4"], default_preferred_chain=[],
max_failover_attempts=3, max_failover_attempts=3,
tier="expert", tier="expert",
), ),
@ -177,7 +177,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_implemented=True, is_implemented=True,
capabilities=["security-expert", "security", "code-reviewer", "audit"], capabilities=["security-expert", "security", "code-reviewer", "audit"],
fallback_capabilities=["security", "code-reviewer"], fallback_capabilities=["security", "code-reviewer"],
default_preferred_chain=["claude-worker-1", "codex-worker-2", "ag-w2"], default_preferred_chain=[],
max_failover_attempts=3, max_failover_attempts=3,
tier="expert", tier="expert",
), ),
@ -189,7 +189,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
is_implemented=True, is_implemented=True,
capabilities=["dependency-agent", "preflight", "environment", "system_checks", "fast"], capabilities=["dependency-agent", "preflight", "environment", "system_checks", "fast"],
fallback_capabilities=["dependency-agent", "preflight"], fallback_capabilities=["dependency-agent", "preflight"],
default_preferred_chain=["opengo-1", "ag-w1", "codex-worker-1"], default_preferred_chain=[],
max_failover_attempts=3, max_failover_attempts=3,
tier="qa_doc", tier="qa_doc",
), ),

View file

@ -68,219 +68,14 @@ class RouterConfig:
def get_default_router_config() -> RouterConfig: def get_default_router_config() -> RouterConfig:
"""Generate default built-in multi-provider configuration (16 profiles across 3 providers).""" """Generate default clean multi-provider configuration (0 profiles, 13 canonical roles with empty chains)."""
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,
),
}
roles = RoleRegistry.get_default_role_policies() roles = RoleRegistry.get_default_role_policies()
return RouterConfig( return RouterConfig(
enabled=True, enabled=True,
default_role="manager", default_role="manager",
roles=roles, 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"): if quota_threshold_action not in ("notify", "switch"):
quota_threshold_action = "notify" quota_threshold_action = "notify"
# Automatic Idempotent Migration (P0-0.1) # Automatic Idempotent Migration (A41 Clean Install)
# Merge missing default profiles and roles into loaded user configuration # Merge missing default roles into loaded user configuration without injecting fake profiles
default_cfg = get_default_router_config() default_cfg = get_default_router_config()
migration_needed = False migration_needed = False
new_profiles_added: list[str] = []
new_roles_added: list[str] = [] new_roles_added: list[str] = []
if not profiles: if not profiles:
profiles = default_cfg.profiles 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
if not roles: if not roles:
roles = default_cfg.roles roles = default_cfg.roles
migration_needed = True
else: else:
# Сначала переименование старых ролей в канонические, и только # Сначала переименование старых ролей в канонические, и только
# потом дополнение недостающими. # потом дополнение недостающими.

View file

@ -353,7 +353,8 @@ class UnifiedHealthService:
tag = f"{rname} (primary)" if idx == 0 else f"{rname} (fallback {idx})" tag = f"{rname} (primary)" if idx == 0 else f"{rname} (fallback {idx})"
role_assignments.setdefault(pid, []).append(tag) 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 "" orch_primary_id = orch_primary.preferred_chain[0] if orch_primary and orch_primary.preferred_chain else ""
now = time.time() now = time.time()
@ -714,6 +715,26 @@ class UnifiedHealthService:
continue continue
if not chain: 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 continue
primary_pid = chain[0] primary_pid = chain[0]
@ -763,6 +784,27 @@ class UnifiedHealthService:
active_quota_status=active_quota_st, active_quota_status=active_quota_st,
active_quota_label=active_quota_lbl, 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 return agents

View file

@ -188,6 +188,12 @@ function initEventListeners() {
if (btnPreflight) { if (btnPreflight) {
btnPreflight.addEventListener('click', () => runPreflightChecks()); 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 ── // ── PREFLIGHT READINESS CHECKS ──
async function runPreflightChecks() { async function runPreflightChecks() {
const container = document.getElementById('preflight-results-container'); const container = document.getElementById('preflight-results-container');

View file

@ -557,6 +557,20 @@
<button class="btn btn-secondary" id="btn-save-client-settings">Сохранить локальные параметры</button> <button class="btn btn-secondary" id="btn-save-client-settings">Сохранить локальные параметры</button>
</div> </div>
</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> </section>
</div> </div>
</main> </main>

View 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

View file

@ -43,7 +43,7 @@ class TestA9ConfigMigration(unittest.TestCase):
shutil.rmtree(self.tmp_dir, ignore_errors=True) shutil.rmtree(self.tmp_dir, ignore_errors=True)
def test_migration_16_profiles_preserves_antigravity_and_adds_claude_grok(self): 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 = {} legacy_profiles = {}
for pid in [ for pid in [
"codex-orch", "codex-worker-1", "codex-worker-2", "codex-orch", "codex-worker-1", "codex-worker-2",
@ -60,33 +60,32 @@ class TestA9ConfigMigration(unittest.TestCase):
max_concurrency=3, 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( legacy_cfg = RouterConfig(
enabled=True, enabled=True,
default_role="manager", default_role="manager",
roles=get_default_router_config().roles, roles=legacy_roles,
profiles=legacy_profiles, profiles=legacy_profiles,
) )
save_router_config(legacy_cfg, self.config_path) save_router_config(legacy_cfg, self.config_path)
self.assertEqual(len(legacy_cfg.profiles), 16) 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) 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_*")) backups = list(Path(self.tmp_dir).glob("router_profiles.yaml.bak_*"))
self.assertGreaterEqual(len(backups), 1, "Backup file was not created on migration") self.assertGreaterEqual(len(backups), 1, "Backup file was not created on migration")
# 4. Verify Claude and Grok profiles added # 4. Verify user profiles are preserved untouched (16 profiles preserved, no fake profiles injected)
self.assertIn("grok-orch", migrated_cfg.profiles) self.assertEqual(len(migrated_cfg.profiles), 16)
self.assertIn("grok-worker-1", migrated_cfg.profiles) self.assertEqual(len(migrated_cfg.roles), 13)
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)
# 5. Verify existing 10 antigravity profiles are 100% untouched # 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"]: 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.preferred_models, ["custom-model-1"], f"Models mutated for {pid}")
self.assertEqual(p.max_concurrency, 3, f"Concurrency 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)}): with patch.dict(os.environ, {"HERMES_ROUTER_CONFIG": str(self.config_path)}):
slot_grok = AutoAssigner.find_free_slot("grok") 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") 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 # 7. Verify Idempotence: subsequent loads do not create extra backups
backup_count_before = len(backups) backup_count_before = len(backups)
reload_cfg = load_router_config(self.config_path) 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_*"))) backup_count_after = len(list(Path(self.tmp_dir).glob("router_profiles.yaml.bak_*")))
self.assertEqual(backup_count_before, backup_count_after) self.assertEqual(backup_count_before, backup_count_after)

View file

@ -53,10 +53,10 @@ def setup_test_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
@pytest.mark.unit @pytest.mark.unit
def test_unlimited_provider_slots_p0_1(tmp_path: Path): 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.""" """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 # Pre-authenticate 3 codex slots: codex-1, codex-2, codex-3
ProfileAuthManager.save_profile_auth("openai-codex", "codex-orch", {"api_key": "sk-1"}) ProfileAuthManager.save_profile_auth("openai-codex", "codex-1", {"api_key": "sk-1"})
ProfileAuthManager.save_profile_auth("openai-codex", "codex-worker-1", {"api_key": "sk-2"}) ProfileAuthManager.save_profile_auth("openai-codex", "codex-2", {"api_key": "sk-2"})
ProfileAuthManager.save_profile_auth("openai-codex", "codex-worker-2", {"api_key": "sk-3"}) ProfileAuthManager.save_profile_auth("openai-codex", "codex-3", {"api_key": "sk-3"})
# 4th slot should be generated dynamically (e.g. codex-4) # 4th slot should be generated dynamically (e.g. codex-4)
slot_4 = AutoAssigner.find_free_slot("openai-codex") slot_4 = AutoAssigner.find_free_slot("openai-codex")

View file

@ -195,7 +195,7 @@ def test_multi_account_sequential_slot_progression(tmp_path, monkeypatch):
# Initial free slot # Initial free slot
first_slot = AutoAssigner.find_free_slot("antigravity") first_slot = AutoAssigner.find_free_slot("antigravity")
assert first_slot == "ag-orch-fallback" assert first_slot == "ag-1"
# Connect first slot # Connect first slot
AutoAssigner.ensure_profile_definition("antigravity", 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 # Next free slot
second_slot = AutoAssigner.find_free_slot("antigravity") second_slot = AutoAssigner.find_free_slot("antigravity")
assert second_slot == "ag-w1" assert second_slot == "ag-2"
# Connect second slot # Connect second slot
AutoAssigner.ensure_profile_definition("antigravity", 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 # Next free slot
third_slot = AutoAssigner.find_free_slot("antigravity") 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) ── # ── TEST 5: Model selection and assignment (P1-6) ──

View file

@ -319,7 +319,7 @@ def test_generate_quotas_export_json_and_csv():
assert "total_profiles" in json_export assert "total_profiles" in json_export
assert "profiles" in json_export assert "profiles" in json_export
assert "rows" 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") csv_export = generate_quotas_export(format="csv")
assert isinstance(csv_export, str) assert isinstance(csv_export, str)

View file

@ -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"]: for provider in ["antigravity", "openai-codex", "opencode-go", "claude", "grok"]:
slot = AutoAssigner.find_free_slot(provider) slot = AutoAssigner.find_free_slot(provider)
if slot is not None: if slot is not None:
# Slot MUST exist in config.profiles config = load_router_config()
assert slot in config.profiles assert slot in config.profiles
assert config.profiles[slot].provider == provider 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") slot_claude = AutoAssigner.find_free_slot("claude")
assert slot_claude is not None assert slot_claude == "claude-1"
assert slot_claude in ("claude-orch", "claude-worker-1", "claude-worker-2")
# Simulate fake auth on all 3 default claude slots # Simulate fake auth on claude-1
for c_slot in ["claude-orch", "claude-worker-1", "claude-worker-2"]: ProfileAuthManager.save_profile_auth("claude", "claude-1", {"api_key": "sk-ant-test-key-1234567890123456"})
ProfileAuthManager.save_profile_auth("claude", c_slot, {"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") next_slot = AutoAssigner.find_free_slot("claude")
assert next_slot == "claude-worker-3" assert next_slot == "claude-2"
cfg = load_router_config() cfg = load_router_config()
assert "claude-worker-3" in cfg.profiles assert "claude-2" in cfg.profiles
assert cfg.profiles["claude-worker-3"].provider == "claude" assert cfg.profiles["claude-2"].provider == "claude"
rec_slot, title, reason = AutoAssigner.recommend_assignment("claude") rec_slot, title, reason = AutoAssigner.recommend_assignment("claude")
assert rec_slot == "claude-worker-3" assert rec_slot == "claude-2"
@pytest.mark.unit @pytest.mark.unit

View file

@ -247,46 +247,45 @@ class TestLocalLLMModelDiscovery:
class TestLocalLLMConfigAndAutoAssigner: class TestLocalLLMConfigAndAutoAssigner:
"""Test default configuration, migration, and slot auto-assignment.""" """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() cfg = get_default_router_config()
assert "local-1" in cfg.profiles assert len(cfg.profiles) == 0
assert "local-2" in cfg.profiles 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 p1.provider == "local"
assert "code-reviewer" in p1.capabilities
assert "coding" 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"] def test_config_migration_preserves_user_profiles_without_injecting_stubs(self):
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):
tmp_dir = tempfile.mkdtemp() tmp_dir = tempfile.mkdtemp()
try: try:
config_path = Path(tmp_dir) / "router_profiles.yaml" config_path = Path(tmp_dir) / "router_profiles.yaml"
legacy_profiles = { legacy_profiles = {
"codex-orch": RouterProfileConfig( "custom-codex": RouterProfileConfig(
profile_id="codex-orch", profile_id="custom-codex",
provider="openai-codex", provider="openai-codex",
) )
} }
legacy_cfg = RouterConfig( legacy_cfg = RouterConfig(
enabled=True, enabled=True,
default_role="manager", default_role="manager",
roles=get_default_router_config().roles, roles={"manager": get_default_router_config().roles["manager"]},
profiles=legacy_profiles, profiles=legacy_profiles,
) )
save_router_config(legacy_cfg, config_path) save_router_config(legacy_cfg, config_path)
migrated = load_router_config(config_path) migrated = load_router_config(config_path)
assert "local-1" in migrated.profiles # User profile is preserved
assert "local-2" in migrated.profiles assert "custom-codex" in migrated.profiles
assert migrated.profiles["local-1"].provider == "local" # 13 canonical roles are migrated
assert len(migrated.roles) == 13
# No dummy local profiles injected
assert "local-1" not in migrated.profiles
finally: finally:
shutil.rmtree(tmp_dir, ignore_errors=True) shutil.rmtree(tmp_dir, ignore_errors=True)

View file

@ -11,6 +11,7 @@ from pathlib import Path
import pytest import pytest
from antigravity_provider.router.action_handler import ActionExecutor, do_set_model 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.model_discovery_service import ModelDiscoveryService
from antigravity_provider.router.router_config import load_router_config, save_router_config 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): def test_set_model_success_and_config_persistence(temp_models_cache):
"""Verify setting valid discovered model succeeds and persists in router_config.""" """Verify setting valid discovered model succeeds and persists in router_config."""
svc, _ = temp_models_cache svc, _ = temp_models_cache
config = load_router_config()
profile_id = "ag-w1" profile_id = "ag-w1"
AutoAssigner.ensure_profile_definition("antigravity", profile_id)
config = load_router_config()
assert profile_id in config.profiles assert profile_id in config.profiles
# Pick valid discovered model # 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): def test_set_model_rejects_nonexistent_model(temp_models_cache):
"""Verify nonexistent model is strictly rejected without modifying configuration.""" """Verify nonexistent model is strictly rejected without modifying configuration."""
svc, _ = temp_models_cache svc, _ = temp_models_cache
config = load_router_config()
profile_id = "ag-w1" profile_id = "ag-w1"
AutoAssigner.ensure_profile_definition("antigravity", profile_id)
config = load_router_config()
orig_models = list(config.profiles[profile_id].preferred_models) orig_models = list(config.profiles[profile_id].preferred_models)
# Attempt to set invalid/hallucinated model # Attempt to set invalid/hallucinated model

View file

@ -46,57 +46,33 @@ from antigravity_provider.router.cli_commands import (
class TestRouterConfig: 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() config = get_default_router_config()
assert len(config.profiles) == 24 assert len(config.profiles) == 0
# 3 Codex assert len(config.roles) == 13
assert "codex-orch" in config.profiles assert config.default_role == "manager"
assert "codex-worker-1" in config.profiles assert config.enabled is True
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
def test_role_policies_chains(self): def test_role_policies_chains(self):
config = get_default_router_config() config = get_default_router_config()
assert "manager" in config.roles assert "manager" in config.roles
orch = config.roles["manager"] assert "developer-1" in config.roles
assert "codex-orch" in orch.preferred_chain assert "developer-2" in config.roles
assert "ag-orch-fallback" in orch.preferred_chain assert "code-reviewer" in config.roles
assert "opengo-3" in orch.preferred_chain 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"] for rname, rpol in config.roles.items():
assert "codex-worker-1" in coder.preferred_chain assert rpol.preferred_chain == [], f"Expected empty chain for {rname} on clean install"
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
class TestHealthTracker: class TestHealthTracker:
@ -175,10 +151,30 @@ class TestSessionAffinityAndLeases:
class TestRouterEngineFailover: class TestRouterEngineFailover:
"""Test multi-provider role-aware failover execution loop.""" """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): def test_orchestrator_failover_chain(self, tmp_path):
state_file = tmp_path / "test_router_state.json" state_file = tmp_path / "test_router_state.json"
engine = RouterEngine( engine = RouterEngine(
config=get_default_router_config(), config=self._get_test_config(),
health=HealthTracker(state_file=state_file), health=HealthTracker(state_file=state_file),
affinity=SessionAffinityTracker(), affinity=SessionAffinityTracker(),
) )
@ -217,7 +213,7 @@ class TestRouterEngineFailover:
def test_session_affinity_retention_after_failover(self, tmp_path): def test_session_affinity_retention_after_failover(self, tmp_path):
state_file = tmp_path / "test_affinity_state.json" state_file = tmp_path / "test_affinity_state.json"
engine = RouterEngine( engine = RouterEngine(
config=get_default_router_config(), config=self._get_test_config(),
health=HealthTracker(state_file=state_file), health=HealthTracker(state_file=state_file),
affinity=SessionAffinityTracker(), affinity=SessionAffinityTracker(),
) )

View file

@ -32,20 +32,18 @@ from antigravity_provider.router.router_config import load_router_config
def test_profile_view_model_mapping(): def test_profile_view_model_mapping():
"""Verify that scan_all creates ProfileViewModels for all configured profiles.""" """Verify that scan_all creates ProfileViewModels for all configured profiles."""
service = UnifiedHealthService.get() service = UnifiedHealthService.get()
profiles_by_prov = service.scan_all() profiles_by_prov = service.scan_all(force=True)
assert "antigravity" in profiles_by_prov assert "antigravity" in profiles_by_prov
assert "openai-codex" in profiles_by_prov assert "openai-codex" in profiles_by_prov
assert "opencode-go" 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] all_profs = [p for profs in profiles_by_prov.values() for p in profs]
assert len(all_profs) >= 16
for p in all_profs: for p in all_profs:
assert isinstance(p, ProfileViewModel) assert isinstance(p, ProfileViewModel)
assert p.profile_id assert p.profile_id
assert p.display_name 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 ( assert p.health_state in (
"healthy", "quota_low", "quota_exhausted", "cooldown", "rate_limited", "healthy", "quota_low", "quota_exhausted", "cooldown", "rate_limited",
"auth_required", "auth_expired", "disabled", "cold_spare", "unhealthy", "not_tested", "not_configured" "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(): def test_auth_required_never_healthy_without_credentials():
"""Verify that unauthenticated profiles are NEVER marked as HEALTHY.""" """Verify that unauthenticated profiles are NEVER marked as HEALTHY."""
service = UnifiedHealthService.get() 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 profs in profiles_by_prov.values():
for p in profs: for p in profs:
@ -65,8 +63,9 @@ def test_auth_required_never_healthy_without_credentials():
def test_cold_spare_presentation(): def test_cold_spare_presentation():
"""Verify that cold spare slots are marked as cold_spare or auth_required, not active.""" """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() 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"]} ag_profs = {p.profile_id: p for p in profiles_by_prov["antigravity"]}
cold_1 = ag_profs.get("ag-cold-1") cold_1 = ag_profs.get("ag-cold-1")