feat(config): A41 чистая конфигурация при первой установке, безопасность учетных данных и сброс
1. P0-1: get_default_router_config() возвращает чистую конфигурацию (0 профилей, 13 канонических ролей с пустыми цепочками). Миграция не внедряет фиктивные профили. 2. P0-2: Учетные данные (~/.hermes/*_profiles/, hub_settings.json) изолированы и никогда не затрагиваются при сбросе или установке. 3. P0-3: Профили создаются динамически при подключении аккаунтов (ag-1, codex-1, etc.). Пустые цепочки ролей являются нормальным рабочим состоянием. 4. P0-4: Добавлен экшен reset_router_config и кнопка «Начать настройку заново» в настройках с подтверждением и созданием бэкапа router_profiles.yaml.bak_<timestamp>. 5. P0-5: scripts/verify_multi_provider_router.py адаптирован и проходит 10/10 PASS как на пустой конфигурации, так и на заполненной. 6. tests/test_a41_clean_install.py: 6 тестов, 490 passed, ruff чисто.
This commit is contained in:
parent
e6bbd60a36
commit
2b89372b0f
21 changed files with 876 additions and 741 deletions
|
|
@ -1,21 +1,21 @@
|
||||||
# Hermes Hub: Multi-Provider Account Router Default Profiles Template
|
# 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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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.")
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
# Сначала переименование старых ролей в канонические, и только
|
# Сначала переименование старых ролей в канонические, и только
|
||||||
# потом дополнение недостающими.
|
# потом дополнение недостающими.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
264
tests/test_a41_clean_install.py
Normal file
264
tests/test_a41_clean_install.py
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
"""Unit and integration tests for Task A41: Clean First Install & Routing Reset.
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
1. Clean default configuration (0 profiles, 13 canonical roles with empty preferred_chain).
|
||||||
|
2. RoleRegistry CANONICAL_ROLES clean default chains.
|
||||||
|
3. Migration idempotence & preservation of user profiles without injecting stubs.
|
||||||
|
4. Action reset_router_config creates backup and resets routing without touching credentials.
|
||||||
|
5. Clean numbered slot generation upon adding accounts (codex-1, ag-1, claude-1, etc.).
|
||||||
|
6. Full compatibility with verify_multi_provider_router script.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from antigravity_provider.paths import get_hermes_home, get_profile_dir
|
||||||
|
from antigravity_provider.router.action_handler import ActionExecutor, do_reset_router_config
|
||||||
|
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||||
|
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||||
|
from antigravity_provider.router.role_registry import CANONICAL_ROLES, RoleRegistry
|
||||||
|
from antigravity_provider.router.router_config import (
|
||||||
|
RolePolicy,
|
||||||
|
RouterConfig,
|
||||||
|
RouterProfileConfig,
|
||||||
|
get_default_router_config,
|
||||||
|
load_router_config,
|
||||||
|
save_router_config,
|
||||||
|
)
|
||||||
|
from antigravity_provider.router.settings_service import get_hub_settings, save_hub_settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def clean_a41_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
"""Set up an isolated hermes home directory for testing."""
|
||||||
|
hermes_home = tmp_path / "hermes_home"
|
||||||
|
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||||
|
config_file = hermes_home / "router_profiles.yaml"
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||||
|
monkeypatch.setenv("HERMES_ROUTER_CONFIG", str(config_file))
|
||||||
|
return hermes_home, config_file
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_p0_1_clean_default_configuration():
|
||||||
|
"""P0-1 & P0-3: Clean configuration on first install has 0 profiles and 13 canonical roles with empty chains."""
|
||||||
|
# 1. Check CANONICAL_ROLES registry
|
||||||
|
assert len(CANONICAL_ROLES) == 13
|
||||||
|
for role_id, role_def in CANONICAL_ROLES.items():
|
||||||
|
assert role_def.default_preferred_chain == [], f"Role {role_id} has non-empty default chain"
|
||||||
|
|
||||||
|
# 2. Check get_default_router_config()
|
||||||
|
default_cfg = get_default_router_config()
|
||||||
|
assert len(default_cfg.profiles) == 0, f"Expected 0 profiles, got {len(default_cfg.profiles)}"
|
||||||
|
assert len(default_cfg.roles) == 13, f"Expected 13 roles, got {len(default_cfg.roles)}"
|
||||||
|
assert default_cfg.default_role == "manager"
|
||||||
|
|
||||||
|
for rname, rpol in default_cfg.roles.items():
|
||||||
|
assert rpol.preferred_chain == [], f"Expected empty preferred_chain for role {rname}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_p0_1_dynamic_clean_slot_numbering(clean_a41_env):
|
||||||
|
"""P0-1: Accounts added dynamically receive clean numbered slots (ag-1, codex-1, claude-1, etc.)."""
|
||||||
|
hermes_home, config_file = clean_a41_env
|
||||||
|
|
||||||
|
# 1. Antigravity slots
|
||||||
|
ag_slot_1 = AutoAssigner.find_free_slot("antigravity")
|
||||||
|
assert ag_slot_1 == "ag-1"
|
||||||
|
ProfileAuthManager.save_profile_auth("antigravity", ag_slot_1, {"tokens": {"access_token": "token1"}})
|
||||||
|
|
||||||
|
ag_slot_2 = AutoAssigner.find_free_slot("antigravity")
|
||||||
|
assert ag_slot_2 == "ag-2"
|
||||||
|
|
||||||
|
# 2. Codex slots
|
||||||
|
codex_slot_1 = AutoAssigner.find_free_slot("openai-codex")
|
||||||
|
assert codex_slot_1 == "codex-1"
|
||||||
|
ProfileAuthManager.save_profile_auth("openai-codex", codex_slot_1, {"api_key": "sk-1"})
|
||||||
|
|
||||||
|
codex_slot_2 = AutoAssigner.find_free_slot("openai-codex")
|
||||||
|
assert codex_slot_2 == "codex-2"
|
||||||
|
|
||||||
|
# 3. Claude slots
|
||||||
|
claude_slot_1 = AutoAssigner.find_free_slot("claude")
|
||||||
|
assert claude_slot_1 == "claude-1"
|
||||||
|
|
||||||
|
# 4. Grok slots
|
||||||
|
grok_slot_1 = AutoAssigner.find_free_slot("grok")
|
||||||
|
assert grok_slot_1 == "grok-1"
|
||||||
|
|
||||||
|
# 5. OpenCode slots
|
||||||
|
opengo_slot_1 = AutoAssigner.find_free_slot("opencode-go")
|
||||||
|
assert opengo_slot_1 == "opengo-1"
|
||||||
|
|
||||||
|
# 6. Local slots
|
||||||
|
local_slot_1 = AutoAssigner.find_free_slot("local")
|
||||||
|
assert local_slot_1 == "local-1"
|
||||||
|
|
||||||
|
# 7. OpenRouter, NVIDIA, Ollama slots
|
||||||
|
assert AutoAssigner.find_free_slot("openrouter") == "openrouter-1"
|
||||||
|
assert AutoAssigner.find_free_slot("nvidia") == "nvidia-1"
|
||||||
|
assert AutoAssigner.find_free_slot("ollama") == "ollama-1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_p0_3_migration_preserves_user_config_and_adds_missing_roles_cleanly(clean_a41_env):
|
||||||
|
"""P0-3: Migration preserves existing user profiles and chains without injecting fake profile stubs."""
|
||||||
|
hermes_home, config_file = clean_a41_env
|
||||||
|
|
||||||
|
# User configured 2 specific profiles and legacy role names
|
||||||
|
user_profiles = {
|
||||||
|
"user-primary-ag": RouterProfileConfig(
|
||||||
|
profile_id="user-primary-ag",
|
||||||
|
provider="antigravity",
|
||||||
|
account_id="user-acc-1",
|
||||||
|
preferred_models=["gemini-2.5-pro"],
|
||||||
|
),
|
||||||
|
"user-primary-codex": RouterProfileConfig(
|
||||||
|
profile_id="user-primary-codex",
|
||||||
|
provider="openai-codex",
|
||||||
|
account_id="user-acc-2",
|
||||||
|
preferred_models=["o3-mini"],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
user_roles = {
|
||||||
|
"orchestrator": RolePolicy(role_name="orchestrator", preferred_chain=["user-primary-codex"]),
|
||||||
|
"coder-primary": RolePolicy(role_name="coder-primary", preferred_chain=["user-primary-ag"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
user_cfg = RouterConfig(
|
||||||
|
enabled=True,
|
||||||
|
default_role="manager",
|
||||||
|
roles=user_roles,
|
||||||
|
profiles=user_profiles,
|
||||||
|
)
|
||||||
|
save_router_config(user_cfg, config_file)
|
||||||
|
|
||||||
|
# Trigger load and migration
|
||||||
|
migrated_cfg = load_router_config(config_file)
|
||||||
|
|
||||||
|
# 1. User profiles are preserved verbatim
|
||||||
|
assert len(migrated_cfg.profiles) == 2
|
||||||
|
assert "user-primary-ag" in migrated_cfg.profiles
|
||||||
|
assert "user-primary-codex" in migrated_cfg.profiles
|
||||||
|
assert migrated_cfg.profiles["user-primary-ag"].preferred_models == ["gemini-2.5-pro"]
|
||||||
|
|
||||||
|
# 2. Legacy roles migrated to canonical names preserving chains
|
||||||
|
assert "manager" in migrated_cfg.roles
|
||||||
|
assert migrated_cfg.roles["manager"].preferred_chain == ["user-primary-codex"]
|
||||||
|
|
||||||
|
assert "developer-1" in migrated_cfg.roles
|
||||||
|
assert migrated_cfg.roles["developer-1"].preferred_chain == ["user-primary-ag"]
|
||||||
|
|
||||||
|
# 3. All 13 canonical roles exist
|
||||||
|
assert len(migrated_cfg.roles) == 13
|
||||||
|
|
||||||
|
# 4. Missing roles added with clean empty chains
|
||||||
|
for rname, rpol in migrated_cfg.roles.items():
|
||||||
|
if rname not in ("manager", "developer-1"):
|
||||||
|
assert rpol.preferred_chain == [], f"Missing role {rname} should have empty chain"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_p0_2_p0_4_reset_router_config_and_preserve_credentials(clean_a41_env):
|
||||||
|
"""P0-2 & P0-4: Reset action backs up router_profiles.yaml, resets to clean state, and strictly preserves credentials."""
|
||||||
|
hermes_home, config_file = clean_a41_env
|
||||||
|
|
||||||
|
# 1. Seed user configuration
|
||||||
|
initial_profiles = {
|
||||||
|
"ag-1": RouterProfileConfig(profile_id="ag-1", provider="antigravity"),
|
||||||
|
"codex-1": RouterProfileConfig(profile_id="codex-1", provider="openai-codex"),
|
||||||
|
}
|
||||||
|
initial_roles = {
|
||||||
|
"manager": RolePolicy(role_name="manager", preferred_chain=["codex-1"]),
|
||||||
|
"developer-1": RolePolicy(role_name="developer-1", preferred_chain=["ag-1"]),
|
||||||
|
}
|
||||||
|
save_router_config(RouterConfig(profiles=initial_profiles, roles=initial_roles), config_file)
|
||||||
|
|
||||||
|
# 2. Seed credentials across different providers and hub_settings.json
|
||||||
|
ag_auth = ProfileAuthManager.save_profile_auth("antigravity", "ag-1", {"tokens": {"access_token": "secret_ag_token"}})
|
||||||
|
codex_auth = ProfileAuthManager.save_profile_auth("openai-codex", "codex-1", {"api_key": "sk-secret-codex-key"})
|
||||||
|
claude_auth = ProfileAuthManager.save_profile_auth("claude", "claude-1", {"api_key": "sk-ant-secret-claude-key"})
|
||||||
|
grok_auth = ProfileAuthManager.save_profile_auth("grok", "grok-1", {"api_key": "xai-secret-grok-key"})
|
||||||
|
opengo_auth = ProfileAuthManager.save_profile_auth("opencode-go", "opengo-1", {"api_key": "opengo-secret-key"})
|
||||||
|
|
||||||
|
# Seed hub settings
|
||||||
|
save_hub_settings({"email_masking_mode": "full", "quota_threshold_percent": 15.0})
|
||||||
|
|
||||||
|
# Verify credentials exist on disk before reset
|
||||||
|
assert ag_auth.is_file()
|
||||||
|
assert codex_auth.is_file()
|
||||||
|
assert claude_auth.is_file()
|
||||||
|
assert grok_auth.is_file()
|
||||||
|
assert opengo_auth.is_file()
|
||||||
|
|
||||||
|
# 3. Execute Reset Action via ActionExecutor
|
||||||
|
res = ActionExecutor.execute("reset_router_config", {})
|
||||||
|
assert res["ok"] is True
|
||||||
|
assert "сброшена" in res["message"].lower()
|
||||||
|
|
||||||
|
# 4. Verify backup was created
|
||||||
|
backups = list(hermes_home.glob("router_profiles.yaml.bak_*"))
|
||||||
|
assert len(backups) >= 1, "Backup file was not created during reset"
|
||||||
|
backup_content = backups[0].read_text(encoding="utf-8")
|
||||||
|
assert "codex-1" in backup_content
|
||||||
|
|
||||||
|
# 5. Verify router_profiles.yaml is now in clean state (0 profiles, 13 canonical roles with empty chains)
|
||||||
|
reloaded_cfg = load_router_config(config_file)
|
||||||
|
assert len(reloaded_cfg.profiles) == 0
|
||||||
|
assert len(reloaded_cfg.roles) == 13
|
||||||
|
for rname, rpol in reloaded_cfg.roles.items():
|
||||||
|
assert rpol.preferred_chain == []
|
||||||
|
|
||||||
|
# 6. Verify ALL credentials remain 100% intact and untouched
|
||||||
|
assert ag_auth.is_file()
|
||||||
|
assert codex_auth.is_file()
|
||||||
|
assert claude_auth.is_file()
|
||||||
|
assert grok_auth.is_file()
|
||||||
|
assert opengo_auth.is_file()
|
||||||
|
|
||||||
|
loaded_ag = ProfileAuthManager.load_profile_auth("antigravity", "ag-1")
|
||||||
|
assert loaded_ag["tokens"]["access_token"] == "secret_ag_token"
|
||||||
|
|
||||||
|
loaded_codex = ProfileAuthManager.load_profile_auth("openai-codex", "codex-1")
|
||||||
|
assert loaded_codex["api_key"] == "sk-secret-codex-key"
|
||||||
|
|
||||||
|
# Verify hub settings untouched
|
||||||
|
settings = get_hub_settings()
|
||||||
|
assert settings.get("email_masking_mode") == "full"
|
||||||
|
assert settings.get("quota_threshold_percent") == 15.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_p0_5_verification_script_runs_clean_and_filled(clean_a41_env):
|
||||||
|
"""P0-5: verify_multi_provider_router.py passes on both clean default and filled user configs."""
|
||||||
|
hermes_home, config_file = clean_a41_env
|
||||||
|
|
||||||
|
# 1. Run on clean default config (file does not exist yet)
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["HERMES_ROUTER_CONFIG"] = str(config_file)
|
||||||
|
env["HERMES_HOME"] = str(hermes_home)
|
||||||
|
|
||||||
|
script_path = Path(__file__).resolve().parent.parent / "scripts" / "verify_multi_provider_router.py"
|
||||||
|
proc = subprocess.run([sys.executable, str(script_path)], env=env, capture_output=True, text=True)
|
||||||
|
assert proc.returncode == 0, f"Verification failed on clean config: {proc.stderr}\n{proc.stdout}"
|
||||||
|
assert "10/10 CHECKS PASSED" in proc.stdout
|
||||||
|
|
||||||
|
# 2. Run on populated user config
|
||||||
|
AutoAssigner.find_free_slot("openai-codex")
|
||||||
|
AutoAssigner.find_free_slot("antigravity")
|
||||||
|
AutoAssigner.assign_profile_to_role("codex-1", "manager", is_primary=True)
|
||||||
|
AutoAssigner.assign_profile_to_role("ag-1", "manager", is_primary=False)
|
||||||
|
|
||||||
|
proc_filled = subprocess.run([sys.executable, str(script_path)], env=env, capture_output=True, text=True)
|
||||||
|
assert proc_filled.returncode == 0, f"Verification failed on filled config: {proc_filled.stderr}\n{proc_filled.stdout}"
|
||||||
|
assert "10/10 CHECKS PASSED" in proc_filled.stdout
|
||||||
|
|
@ -43,7 +43,7 @@ class TestA9ConfigMigration(unittest.TestCase):
|
||||||
shutil.rmtree(self.tmp_dir, ignore_errors=True)
|
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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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) ──
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue