diff --git a/config/router_profiles.example.yaml b/config/router_profiles.example.yaml index 68dde1c..9d94e0f 100644 --- a/config/router_profiles.example.yaml +++ b/config/router_profiles.example.yaml @@ -1,21 +1,21 @@ # Hermes Hub: Multi-Provider Account Router Default Profiles Template -# Copy this file to %LOCALAPPDATA%\hermes\config\router_profiles.yaml if not present. +# Clean initial configuration (0 profiles, 13 canonical roles with empty chains). +# Accounts are dynamically created and mapped as you connect them. router: enabled: true - default_role: "orchestrator" + default_role: "manager" max_failover_attempts: 3 cooldown_base_seconds: 300 cooldown_max_seconds: 3600 session_affinity_ttl_seconds: 1800 + quota_threshold_percent: 10.0 + quota_threshold_action: "notify" roles: - orchestrator: - role_name: "orchestrator" - preferred_chain: - - "codex-orch" - - "ag-orch-fallback" - - "opengo-3" + manager: + role_name: "manager" + preferred_chain: [] fallback_capabilities: - "orchestrator" - "reasoning" @@ -23,281 +23,114 @@ roles: session_affinity_enabled: true default_model: "gemini-3.7-flash" - coder-primary: - role_name: "coder-primary" - preferred_chain: - - "codex-worker-1" - - "ag-w1" - - "opengo-3" + developer-1: + role_name: "developer-1" + preferred_chain: [] fallback_capabilities: - "coding" max_failover_attempts: 3 session_affinity_enabled: true + default_model: "gemini-3.7-flash" - coder-secondary: - role_name: "coder-secondary" - preferred_chain: - - "codex-worker-2" - - "ag-w2" - - "opengo-2" + developer-2: + role_name: "developer-2" + preferred_chain: [] fallback_capabilities: - "coding" - "reviewer" max_failover_attempts: 3 session_affinity_enabled: true + default_model: "gemini-3.1-pro-high" - reviewer: - role_name: "reviewer" - preferred_chain: - - "codex-worker-2" - - "opengo-2" - - "ag-w2" + code-reviewer: + role_name: "code-reviewer" + preferred_chain: [] fallback_capabilities: - "reviewer" - "coding" max_failover_attempts: 3 session_affinity_enabled: true + default_model: "claude-opus-4-6-thinking" - research: - role_name: "research" - preferred_chain: - - "opengo-1" - - "ag-w3" - - "ag-w4" + researcher: + role_name: "researcher" + preferred_chain: [] fallback_capabilities: - "research" - - "fast" + - "search" max_failover_attempts: 3 session_affinity_enabled: true - fast: - role_name: "fast" - preferred_chain: - - "opengo-1" - - "ag-w4" - - "ag-spare-1" + tester: + role_name: "tester" + preferred_chain: [] fallback_capabilities: - "fast" + - "testing" + max_failover_attempts: 3 + session_affinity_enabled: true + + tech-writer: + role_name: "tech-writer" + preferred_chain: [] + fallback_capabilities: + - "documentation" + - "reasoning" + max_failover_attempts: 3 + session_affinity_enabled: true + + analyst: + role_name: "analyst" + preferred_chain: [] + fallback_capabilities: + - "reasoning" + - "research" + max_failover_attempts: 3 + session_affinity_enabled: true + + guardian: + role_name: "guardian" + preferred_chain: [] + fallback_capabilities: + - "guardian" + - "security" + max_failover_attempts: 0 + session_affinity_enabled: true + + cost-controller: + role_name: "cost-controller" + preferred_chain: [] + fallback_capabilities: + - "cost-controller" + - "budget" + max_failover_attempts: 0 + session_affinity_enabled: true + + integration-expert: + role_name: "integration-expert" + preferred_chain: [] + fallback_capabilities: + - "integration" - "coding" max_failover_attempts: 3 - session_affinity_enabled: false + session_affinity_enabled: true -profiles: - # Antigravity (10 accounts) - ag-orch-fallback: - profile_id: "ag-orch-fallback" - provider: "antigravity" - account_id: "ag-acc-orch" - enabled: true - capabilities: ["orchestrator", "reasoning", "coding"] - preferred_models: ["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"] - max_concurrency: 2 + security-expert: + role_name: "security-expert" + preferred_chain: [] + fallback_capabilities: + - "security" + - "code-reviewer" + max_failover_attempts: 3 + session_affinity_enabled: true - ag-w1: - profile_id: "ag-w1" - provider: "antigravity" - account_id: "ag-acc-w1" - enabled: true - capabilities: ["coding", "coder-primary", "reasoning"] - preferred_models: ["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"] - max_concurrency: 2 + dependency-agent: + role_name: "dependency-agent" + preferred_chain: [] + fallback_capabilities: + - "dependency-agent" + - "preflight" + max_failover_attempts: 3 + session_affinity_enabled: true - ag-w2: - profile_id: "ag-w2" - provider: "antigravity" - account_id: "ag-acc-w2" - enabled: true - capabilities: ["coding", "coder-secondary", "reviewer", "review"] - preferred_models: ["gemini-3.7-flash", "gemini-3.5-flash"] - max_concurrency: 2 - - ag-w3: - profile_id: "ag-w3" - provider: "antigravity" - account_id: "ag-acc-w3" - enabled: true - capabilities: ["research", "reasoning", "search"] - preferred_models: ["gemini-3.7-flash", "claude-sonnet-4-6"] - max_concurrency: 2 - - ag-w4: - profile_id: "ag-w4" - provider: "antigravity" - account_id: "ag-acc-w4" - enabled: true - capabilities: ["coding", "reasoning", "fast"] - preferred_models: ["gemini-3.5-flash", "gemini-3.7-flash"] - max_concurrency: 2 - - ag-spare-1: - profile_id: "ag-spare-1" - provider: "antigravity" - account_id: "ag-acc-sp1" - enabled: true - capabilities: ["hot-spare", "coding", "reasoning", "orchestrator", "research", "fast"] - preferred_models: ["gemini-3.7-flash", "gemini-3.5-flash"] - max_concurrency: 2 - - ag-spare-2: - profile_id: "ag-spare-2" - provider: "antigravity" - account_id: "ag-acc-sp2" - enabled: true - capabilities: ["hot-spare", "coding", "reasoning", "orchestrator", "research", "fast"] - preferred_models: ["gemini-3.7-flash", "gemini-3.5-flash"] - max_concurrency: 2 - - ag-cold-1: - profile_id: "ag-cold-1" - provider: "antigravity" - account_id: "ag-acc-cold1" - enabled: false - capabilities: ["cold-spare"] - preferred_models: [] - max_concurrency: 1 - - ag-cold-2: - profile_id: "ag-cold-2" - provider: "antigravity" - account_id: "ag-acc-cold2" - enabled: false - capabilities: ["cold-spare"] - preferred_models: [] - max_concurrency: 1 - - ag-cold-3: - profile_id: "ag-cold-3" - provider: "antigravity" - account_id: "ag-acc-cold3" - enabled: false - capabilities: ["cold-spare"] - preferred_models: [] - max_concurrency: 1 - - # OpenAI Codex (3 accounts) - codex-orch: - profile_id: "codex-orch" - provider: "openai-codex" - account_id: "codex-acc-1" - enabled: true - capabilities: ["orchestrator", "coding", "reasoning"] - preferred_models: ["gpt-4o", "o3-mini", "codex"] - max_concurrency: 2 - - codex-worker-1: - profile_id: "codex-worker-1" - provider: "openai-codex" - account_id: "codex-acc-2" - enabled: true - capabilities: ["coding", "coder-primary", "reasoning"] - preferred_models: ["gpt-4o", "o3-mini", "codex"] - max_concurrency: 2 - - codex-worker-2: - profile_id: "codex-worker-2" - provider: "openai-codex" - account_id: "codex-acc-3" - enabled: true - capabilities: ["coding", "coder-secondary", "reviewer", "review"] - preferred_models: ["gpt-4o", "o3-mini", "codex"] - max_concurrency: 2 - - # OpenCode Go (3 accounts) - opengo-1: - profile_id: "opengo-1" - provider: "opencode-go" - account_id: "opengo-acc-1" - enabled: true - capabilities: ["research", "search", "fast", "review"] - preferred_models: ["qwen3.8-max", "glm-5.3", "deepseek-v4-flash", "grok-4.5"] - max_concurrency: 3 - - opengo-2: - profile_id: "opengo-2" - provider: "opencode-go" - account_id: "opengo-acc-2" - enabled: true - capabilities: ["reviewer", "review", "coding", "reasoning"] - preferred_models: ["deepseek-v4-pro", "grok-4.5", "qwen3.7-max"] - max_concurrency: 3 - - opengo-3: - profile_id: "opengo-3" - provider: "opencode-go" - account_id: "opengo-acc-3" - enabled: true - capabilities: ["coder-fallback", "orchestrator", "coding", "reasoning"] - preferred_models: ["kimi-k2.7-code", "deepseek-v4-pro", "qwen3.8-max"] - max_concurrency: 3 - - # Claude / Anthropic (3 accounts) - claude-orch: - profile_id: "claude-orch" - provider: "claude" - account_id: "claude-acc-1" - enabled: true - capabilities: ["orchestrator", "coding", "reasoning"] - preferred_models: ["claude-3-7-sonnet", "claude-3-5-haiku", "claude-sonnet-4-6"] - max_concurrency: 2 - - claude-worker-1: - profile_id: "claude-worker-1" - provider: "claude" - account_id: "claude-acc-2" - enabled: true - capabilities: ["coding", "coder-primary", "reasoning"] - preferred_models: ["claude-3-7-sonnet", "claude-3-5-haiku", "claude-sonnet-4-6"] - max_concurrency: 2 - - claude-worker-2: - profile_id: "claude-worker-2" - provider: "claude" - account_id: "claude-acc-3" - enabled: true - capabilities: ["coding", "coder-secondary", "reviewer", "review"] - preferred_models: ["claude-3-7-sonnet", "claude-3-5-haiku"] - max_concurrency: 2 - - # Grok / xAI (3 accounts) - grok-orch: - profile_id: "grok-orch" - provider: "grok" - account_id: "grok-acc-1" - enabled: true - capabilities: ["orchestrator", "coding", "reasoning"] - preferred_models: ["grok-3", "grok-3-mini", "grok-4.5"] - max_concurrency: 2 - - grok-worker-1: - profile_id: "grok-worker-1" - provider: "grok" - account_id: "grok-acc-2" - enabled: true - capabilities: ["coding", "coder-primary", "reasoning"] - preferred_models: ["grok-3", "grok-3-mini", "grok-4.5"] - max_concurrency: 2 - - grok-worker-2: - profile_id: "grok-worker-2" - provider: "grok" - account_id: "grok-acc-3" - enabled: true - capabilities: ["research", "reasoning", "fast"] - preferred_models: ["grok-3", "grok-3-mini"] - max_concurrency: 2 - -# Optional: User Model Pricing Table (USD per 1M tokens) -# Telemetry will compute call cost in USD only if a model price is defined below. -pricing: - gemini-2.5-pro: - input_cost_per_m: 1.25 - output_cost_per_m: 5.00 - gemini-2.5-flash: - input_cost_per_m: 0.15 - output_cost_per_m: 0.60 - gpt-4o: - input_cost_per_m: 2.50 - output_cost_per_m: 10.00 - claude-3-7-sonnet: - input_cost_per_m: 3.00 - output_cost_per_m: 15.00 +profiles: {} diff --git a/scripts/verify_multi_provider_router.py b/scripts/verify_multi_provider_router.py index 7a89d89..35f1b2f 100644 --- a/scripts/verify_multi_provider_router.py +++ b/scripts/verify_multi_provider_router.py @@ -18,7 +18,13 @@ for p in [ if p.is_dir() and str(p) not in sys.path: sys.path.insert(0, str(p)) -from antigravity_provider.router.router_config import get_default_router_config, load_router_config +from antigravity_provider.router.router_config import ( + RolePolicy, + RouterConfig, + RouterProfileConfig, + get_default_router_config, + load_router_config, +) from antigravity_provider.router.health_tracker import ( HEALTHY, QUOTA_EXHAUSTED, @@ -46,48 +52,29 @@ def run_checks() -> int: # 1. Config inventory print("1. Checking profile inventory and provider counts...") - config = get_default_router_config() - # Проверка структурная, а не пересчёт. Раньше здесь стояло «ровно 16 - # профилей» и дословные цепочки от 20 августа. Миграция законно довела - # конфигурацию до 22 профилей, добавив claude и grok, — и установка стала - # падать с кодом 12 на любой машине, где миграция отработала. Смысл этой - # проверки в том, работоспособна ли маршрутизация, а не совпадает ли - # конфигурация с зафиксированной когда-то. - counts = {} - for prof in config.profiles.values(): - counts[prof.provider] = counts.get(prof.provider, 0) + 1 - assert config.profiles, "В конфигурации нет ни одного профиля" - for required in ("openai-codex", "antigravity", "opencode-go"): - assert counts.get(required), f"Нет ни одного профиля провайдера {required}" - summary = ", ".join(f"{prov}: {n}" for prov, n in sorted(counts.items())) - print(f" [PASS] Профилей: {len(config.profiles)} ({summary})") + config = load_router_config() + if config.profiles: + counts = {} + for prof in config.profiles.values(): + counts[prof.provider] = counts.get(prof.provider, 0) + 1 + summary = ", ".join(f"{prov}: {n}" for prov, n in sorted(counts.items())) + print(f" [PASS] Профилей: {len(config.profiles)} ({summary})") + else: + print(" [PASS] Чистая конфигурация по умолчанию (0 профилей, добавление по мере подключения)") passed += 1 # 2. Role Fallback Chains print("2. Checking role fallback policies...") - # Имя оркестрирующей роли меняется вместе с реестром: в A28 orchestrator - # стал manager. Дословная проверка старого имени пережила миграцию и - # роняла установку на Windows с кодом 12 — на Linux этот скрипт не - # запускается, поэтому там всё ставилось. Спрашиваем актуальное имя у - # реестра, а не помним его в скрипте. assert config.roles, "В конфигурации нет ни одной роли" try: from antigravity_provider.router.role_registry import RoleRegistry orchestrating_role = RoleRegistry.resolve_canonical_role("orchestrator") except Exception: - orchestrating_role = "orchestrator" + orchestrating_role = "manager" assert orchestrating_role in config.roles, ( f"Оркестрирующая роль {orchestrating_role!r} отсутствует; есть: {sorted(config.roles)}" ) - # Цепочки настраиваются владельцем и меняются — дословно их сверять нельзя. - # Проверяем то, что действительно ломает маршрутизацию: цепочка непуста и - # каждый профиль в ней существует. - # Пустая цепочка — не поломка сама по себе. В A28 появились роли, - # объявленные без реализации (guardian, cost-controller): аккаунтов у них - # ещё нет, и требовать цепочку — значит ронять установку из-за роли, - # которой никто не пользуется. Ломает маршрутизацию другое: ссылка на - # несуществующий профиль и пустая цепочка у ОРКЕСТРИРУЮЩЕЙ роли. empty_chains = [] for role_name, policy in config.roles.items(): chain = policy.preferred_chain or [] @@ -97,12 +84,9 @@ def run_checks() -> int: assert pid in config.profiles, ( f"Роль {role_name} ссылается на несуществующий профиль {pid}" ) - assert config.roles[orchestrating_role].preferred_chain, ( - f"У оркестрирующей роли {orchestrating_role!r} пустая цепочка — маршрутизация работать не будет" - ) if empty_chains: - print(f" [INFO] Без аккаунтов пока: {', '.join(sorted(empty_chains))}") - print(f" [PASS] Цепочки {len(config.roles)} ролей ссылаются только на существующие профили") + print(f" [INFO] Роли с пустыми цепочками (чистый старт / не назначены): {', '.join(sorted(empty_chains))}") + print(f" [PASS] Все {len(config.roles)} ролей валидны и ссылаются только на существующие профили") passed += 1 # 3. Model family extraction @@ -178,29 +162,9 @@ def run_checks() -> int: # 9. Full failover execution loop print("9. Checking full failover execution loop...") - engine = RouterEngine(config=config) - engine.health.clear_cooldown() - - # Проверяется МЕХАНИЗМ отказоустойчивости, а не расстановка аккаунтов. - # - # Прежняя версия зашивала порядок codex -> antigravity -> opengo-3 и - # конкретные идентификаторы профилей. Но порядок в цепочке — это выбор - # владельца, он его меняет мышью в интерфейсе. Любая перестановка роняла - # проверку, а с ней и установку на Windows с кодом 12. - # - # Здесь: берём настоящую цепочку оркестрирующей роли, роняем все профили - # кроме последнего и убеждаемся, что маршрутизатор дошёл именно до него. - # Учитываем предел попыток: если цепочка длиннее, до её хвоста - # маршрутизатор просто не дойдёт, и ожидать этого нельзя. - full_chain = list(config.roles[orchestrating_role].preferred_chain) - max_attempts = getattr(config.roles[orchestrating_role], "max_failover_attempts", 0) or len(full_chain) + full_chain = list(config.roles.get(orchestrating_role, RolePolicy(role_name=orchestrating_role)).preferred_chain) + max_attempts = getattr(config.roles.get(orchestrating_role, RolePolicy(role_name=orchestrating_role)), "max_failover_attempts", 0) or len(full_chain) chain = full_chain[:max_attempts] - assert len(chain) >= 2, ( - f"В цепочке роли {orchestrating_role!r} меньше двух профилей — " - "отказоустойчивость проверить нечем" - ) - last_pid = chain[-1] - last_provider = config.profiles[last_pid].provider adapter_by_provider = { "openai-codex": CodexAdapter, @@ -211,9 +175,48 @@ def run_checks() -> int: "local": LocalLLMAdapter, } + if len(chain) >= 2 and all(pid in config.profiles for pid in chain): + test_engine = RouterEngine(config=config) + test_engine.health.clear_cooldown() + test_role = orchestrating_role + test_chain = chain + test_profiles = config.profiles + else: + synth_config = RouterConfig( + enabled=True, + default_role="manager", + roles={ + "manager": RolePolicy( + role_name="manager", + preferred_chain=["synth-codex", "synth-ag"], + max_failover_attempts=2, + ) + }, + profiles={ + "synth-codex": RouterProfileConfig( + profile_id="synth-codex", + provider="openai-codex", + capabilities=["coding", "orchestrator"], + ), + "synth-ag": RouterProfileConfig( + profile_id="synth-ag", + provider="antigravity", + capabilities=["coding", "orchestrator"], + ), + }, + ) + test_engine = RouterEngine(config=synth_config) + test_engine.health.clear_cooldown() + test_role = "manager" + test_chain = ["synth-codex", "synth-ag"] + test_profiles = synth_config.profiles + + last_pid = test_chain[-1] + last_provider = test_profiles[last_pid].provider + expected = {"id": "ok", "choices": [{"message": {"role": "assistant", "content": "from-last-in-chain"}}]} - failing = {cls for pid in chain[:-1] - if (cls := adapter_by_provider.get(config.profiles[pid].provider)) is not None} + failing = {cls for pid in test_chain[:-1] + if (cls := adapter_by_provider.get(test_profiles[pid].provider)) is not None} winner = adapter_by_provider.get(last_provider) if winner is None or winner in failing: @@ -224,9 +227,9 @@ def run_checks() -> int: for cls in failing: stack.enter_context(patch.object(cls, "invoke", side_effect=RuntimeError("Insufficient quota"))) stack.enter_context(patch.object(winner, "invoke", return_value=expected)) - res = engine.route_request( + res = test_engine.route_request( {"messages": [{"role": "user", "content": "test"}]}, - role=orchestrating_role, + role=test_role, session_id="verify-failover", ) assert res["choices"][0]["message"]["content"] == "from-last-in-chain", ( @@ -235,7 +238,7 @@ def run_checks() -> int: assert res["router_metadata"]["profile_id"] == last_pid, ( f"Ожидался профиль {last_pid}, получен {res['router_metadata']['profile_id']}" ) - print(f" [PASS] Отказоустойчивость прошла цепочку {' -> '.join(chain)}") + print(f" [PASS] Отказоустойчивость прошла цепочку {' -> '.join(test_chain)}") passed += 1 # 10. Passthrough & Graceful fallback diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index 91e4cb1..9b3f314 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -38,6 +38,10 @@ def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]: def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]: config = load_router_config() pcfg = config.get_profile(profile_id) + if not pcfg: + AutoAssigner.ensure_profile_definition(provider, profile_id) + config = load_router_config() + pcfg = config.get_profile(profile_id) if not pcfg: return {'success': False, 'error': f"Профиль '{profile_id}' не найден"} @@ -467,6 +471,65 @@ def generate_quotas_export(format: str = "json") -> Any: } +def do_reset_router_config(actor: str = "user:web") -> Dict[str, Any]: + """Reset router configuration to clean default state (0 profiles, 13 canonical roles with empty chains). + + Guaranteed to create a timestamped backup of router_profiles.yaml first. + Strictly preserves all credentials in ~/.hermes/agy_profiles, codex_profiles, + opengo_profiles, claude_profiles, grok_profiles, and hub_settings.json. + """ + import shutil + from pathlib import Path + from antigravity_provider.router.router_config import ( + get_default_router_config, + save_router_config, + ) + + env_config = os.environ.get("HERMES_ROUTER_CONFIG", "").strip() + if env_config: + config_path = Path(env_config).expanduser() + else: + config_path = paths.get_router_profiles_path() + + backup_name = None + if config_path.exists(): + try: + backup_path = config_path.with_name(f"{config_path.name}.bak_{int(time.time())}") + shutil.copy2(config_path, backup_path) + backup_name = backup_path.name + except Exception as exc: + logger.warning("Не удалось создать резервную копию перед сбросом конфигурации: %s", exc) + + clean_cfg = get_default_router_config() + saved = save_router_config(clean_cfg, config_path) + if not saved: + return { + "ok": False, + "message": "Не удалось записать чистую конфигурацию в файл", + } + + try: + from antigravity_provider.router.state_store import HubStateStore + HubStateStore.get().refresh(force_scan=True) + except Exception: + pass + + EventLogService.get().log( + "routing", + f"Конфигурация маршрутизатора сброшена в исходное состояние (резервная копия: {backup_name or 'нет'}). Учётные данные и подключенные аккаунты сохранены.", + level="warning", + actor=actor, + action="reset_router_config", + outcome="success", + ) + + return { + "ok": True, + "message": "Конфигурация маршрутизатора сброшена. Учётные данные и подключенные аккаунты сохранены.", + "backup_file": backup_name, + } + + class ActionExecutor: """Shared execution layer for Desktop and Web actions.""" @@ -490,6 +553,28 @@ class ActionExecutor: prov = _pcfg.provider except Exception: pass + if not prov: + for prefix, p_name in [ + ("ag-", "antigravity"), + ("codex-", "openai-codex"), + ("opengo-", "opencode-go"), + ("claude-", "claude"), + ("grok-", "grok"), + ("local-", "local"), + ("openrouter-", "openrouter"), + ("nvidia-", "nvidia"), + ("ollama-", "ollama"), + ("vllm-", "vllm"), + ]: + if pid.startswith(prefix): + prov = p_name + break + if not prov: + from antigravity_provider.router.profile_manager import get_profile_auth_path + for candidate_prov in ("antigravity", "openai-codex", "opencode-go", "claude", "grok", "local", "openrouter", "nvidia", "ollama", "vllm"): + if get_profile_auth_path(candidate_prov, pid).is_file(): + prov = candidate_prov + break if action in { 'create_agent', @@ -927,5 +1012,9 @@ class ActionExecutor: 'data': {'format': 'json', 'report': res, 'filename': 'hermes_quotas_export.json'} } + elif action in ['reset_router_config', 'reset_to_empty_config']: + res = do_reset_router_config(actor=actor) + return {'ok': res.get('ok', False), 'message': res.get('message', ''), 'data': res} + else: return {'ok': False, 'message': f'Неизвестное действие: {action}', 'unknown': True} diff --git a/src/antigravity_provider/router/auto_assigner.py b/src/antigravity_provider/router/auto_assigner.py index 1ff937b..dfa2335 100644 --- a/src/antigravity_provider/router/auto_assigner.py +++ b/src/antigravity_provider/router/auto_assigner.py @@ -14,6 +14,7 @@ import logging from pathlib import Path from typing import Any, Dict, List, Optional, Tuple +from antigravity_provider import paths from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email, mask_id from antigravity_provider.router.role_registry import RoleRegistry from antigravity_provider.router.router_config import ( @@ -67,6 +68,22 @@ class AutoAssigner: """Get human-readable display name, logical role, and tier for a profile.""" if profile_id in DEFAULT_SLOT_ROLES: return DEFAULT_SLOT_ROLES[profile_id] + prov_map = { + "ag": "Antigravity", + "codex": "Codex", + "opengo": "OpenCode", + "claude": "Claude", + "grok": "Grok", + "local": "Локальный сервер", + "openrouter": "OpenRouter", + "nvidia": "NVIDIA NIM", + "ollama": "Ollama", + "vllm": "vLLM", + } + parts = profile_id.split("-") + if len(parts) == 2 and parts[0] in prov_map and parts[1].isdigit(): + clean_name = f"{prov_map[parts[0]]} {parts[1]}" + return (clean_name, "worker", "primary") clean_name = profile_id.replace("-", " ").title() return (clean_name, "worker", "primary") @@ -104,91 +121,57 @@ class AutoAssigner: def find_free_slot(provider: str, requested_role: str = "auto") -> Optional[str]: """Find the optimal free internal profile slot for a provider. - When all predefined candidate slots are authenticated/occupied, dynamically - generates the next free profile ID without capping account count (P0-1). + Generates clean numbered slots starting from 1 (codex-1, ag-1, claude-1, + grok-1, opengo-1, local-1, openrouter-1, nvidia-1, ollama-1) upon first addition. """ config = load_router_config() provider_norm = (provider or "").strip().lower() - provider_slots = { - "antigravity": [ - "ag-orch-fallback", "ag-w1", "ag-w2", "ag-w3", "ag-w4", - "ag-spare-1", "ag-spare-2", "ag-cold-1", "ag-cold-2", "ag-cold-3" - ], - "openai-codex": ["codex-orch", "codex-worker-1", "codex-worker-2"], - "codex": ["codex-orch", "codex-worker-1", "codex-worker-2"], - "opencode-go": ["opengo-1", "opengo-2", "opengo-3"], - "opencode": ["opengo-1", "opengo-2", "opengo-3"], - "claude": ["claude-orch", "claude-worker-1", "claude-worker-2"], - "anthropic": ["claude-orch", "claude-worker-1", "claude-worker-2"], - "grok": ["grok-orch", "grok-worker-1", "grok-worker-2"], - "xai": ["grok-orch", "grok-worker-1", "grok-worker-2"], - "local": ["local-1", "local-2"], - "local-llm": ["local-1", "local-2"], - "llama.cpp": ["local-1", "local-2"], - "ollama": ["ollama-1", "ollama-2"], - "vllm": ["local-1", "local-2"], - } + def _get_prefix(prov: str) -> str: + p = (prov or "").strip().lower() + if p in ("antigravity", "google-antigravity", "agy"): + return "ag" + if p in ("openai-codex", "codex", "openai"): + return "codex" + if p in ("opencode-go", "opencode", "opengo"): + return "opengo" + if p in ("claude", "anthropic"): + return "claude" + if p in ("grok", "xai"): + return "grok" + if p in ("local", "local-llm", "llama.cpp"): + return "local" + if p in ("openrouter",): + return "openrouter" + if p in ("nvidia", "nvidia-nim"): + return "nvidia" + if p in ("ollama",): + return "ollama" + if p in ("vllm",): + return "vllm" + return p - candidates = list(provider_slots.get(provider_norm, [])) + prefix = _get_prefix(provider_norm) - # Priority based on requested role - if requested_role == "manager": - if provider_norm in ("openai-codex", "codex") and "codex-orch" in candidates: - candidates.remove("codex-orch") - candidates.insert(0, "codex-orch") - elif provider_norm == "antigravity" and "ag-orch-fallback" in candidates: - candidates.remove("ag-orch-fallback") - candidates.insert(0, "ag-orch-fallback") # 1. First check existing candidate profiles already in config - for pid in candidates: - pcfg = config.get_profile(pid) - if not pcfg: - continue - status = ProfileAuthManager.get_profile_status(provider, pid) - if not status.get("authenticated"): - return pid + for pid, pcfg in config.profiles.items(): + if _get_prefix(pcfg.provider) == prefix or pcfg.provider == provider_norm: + status = ProfileAuthManager.get_profile_status(pcfg.provider, pid) + if not status.get("authenticated"): + return pid - # 2. Check remaining predefined slots and ensure definition - for pid in candidates: - status = ProfileAuthManager.get_profile_status(provider, pid) + # 2. Dynamically generate clean numbered slots (codex-1, ag-1, claude-1, etc.) + for i in range(1, 200): + candidate_pid = f"{prefix}-{i}" + if candidate_pid in config.profiles: + pcfg = config.profiles[candidate_pid] + status = ProfileAuthManager.get_profile_status(pcfg.provider, candidate_pid) + if status.get("authenticated"): + continue + status = ProfileAuthManager.get_profile_status(provider, candidate_pid) if not status.get("authenticated"): - AutoAssigner.ensure_profile_definition(provider, pid) - return pid - - # 3. Predefined slots occupied: dynamically generate unlimited candidates - def _generate_candidates(prov: str): - p = prov.lower() - if p == "antigravity": - for i in range(5, 200): - yield f"ag-w{i}" - elif p in ("openai-codex", "codex"): - for i in range(4, 200): - yield f"codex-{i}" - elif p in ("opencode-go", "opencode"): - for i in range(4, 200): - yield f"opengo-{i}" - elif p in ("claude", "anthropic"): - for i in range(3, 200): - yield f"claude-worker-{i}" - elif p in ("grok", "xai"): - for i in range(3, 200): - yield f"grok-worker-{i}" - elif p == "ollama": - for i in range(3, 200): - yield f"ollama-{i}" - elif p in ("local", "local-llm", "llama.cpp", "vllm"): - for i in range(3, 200): - yield f"local-{i}" - else: - for i in range(1, 200): - yield f"{p}-{i}" - - for dynamic_pid in _generate_candidates(provider_norm): - status = ProfileAuthManager.get_profile_status(provider, dynamic_pid) - if not status.get("authenticated"): - AutoAssigner.ensure_profile_definition(provider, dynamic_pid) - return dynamic_pid + AutoAssigner.ensure_profile_definition(provider, candidate_pid) + return candidate_pid return None @@ -209,15 +192,23 @@ class AutoAssigner: capabilities_map = { "grok": ["reasoning", "coding", "research"], + "xai": ["reasoning", "coding", "research"], "claude": ["reasoning", "coding", "review"], + "anthropic": ["reasoning", "coding", "review"], "opencode-go": ["coding", "research", "fast"], + "opencode": ["coding", "research", "fast"], "openai-codex": ["coding", "reasoning"], + "codex": ["coding", "reasoning"], "antigravity": ["coding", "reasoning", "research", "fast"], + "google-antigravity": ["coding", "reasoning", "research", "fast"], "local": ["reviewer", "coding", "reasoning", "fast", "research"], "local-llm": ["reviewer", "coding", "reasoning", "fast", "research"], "llama.cpp": ["reviewer", "coding", "reasoning", "fast", "research"], "ollama": ["reviewer", "coding", "reasoning", "fast", "research"], "vllm": ["reviewer", "coding", "reasoning", "fast", "research"], + "openrouter": ["coding", "reasoning", "research", "fast"], + "nvidia": ["coding", "reasoning", "fast"], + "nvidia-nim": ["coding", "reasoning", "fast"], } capabilities = capabilities_map.get(provider, ["coding", "reasoning"]) @@ -276,6 +267,28 @@ class AutoAssigner: """Assign a profile to a canonical router role, updating fallback chains and persisting config.""" config = load_router_config() pcfg = config.get_profile(profile_id) + if not pcfg: + inferred_prov = None + for prefix, prov in [ + ("ag-", "antigravity"), + ("codex-", "openai-codex"), + ("opengo-", "opencode-go"), + ("claude-", "claude"), + ("grok-", "grok"), + ("local-", "local"), + ("openrouter-", "openrouter"), + ("nvidia-", "nvidia"), + ("ollama-", "ollama"), + ("vllm-", "vllm"), + ]: + if profile_id.startswith(prefix): + inferred_prov = prov + break + if inferred_prov: + AutoAssigner.ensure_profile_definition(inferred_prov, profile_id) + config = load_router_config() + pcfg = config.get_profile(profile_id) + if not pcfg: return False, f"Профиль '{profile_id}' не найден" @@ -314,6 +327,31 @@ class AutoAssigner: @staticmethod def auto_assign_all() -> Dict[str, Any]: """Automatically distribute all authenticated profiles across canonical router roles (P0-4).""" + config = load_router_config() + # Scan on-disk profiles and ensure their definition in config.profiles + prov_prefix_map = { + "antigravity": "agy_profiles", + "openai-codex": "codex_profiles", + "opencode-go": "opengo_profiles", + "claude": "claude_profiles", + "grok": "grok_profiles", + "local": "local_profiles", + "openrouter": "openrouter_profiles", + "nvidia": "nvidia_profiles", + "ollama": "ollama_profiles", + "vllm": "vllm_profiles", + } + for prov, subdir in prov_prefix_map.items(): + base_dir = paths.get_hermes_home() / subdir + if base_dir.is_dir(): + for p_entry in base_dir.iterdir(): + if p_entry.is_dir(): + pid = p_entry.name + if pid not in config.profiles: + st = ProfileAuthManager.get_profile_status(prov, pid) + if st.get("authenticated"): + AutoAssigner.ensure_profile_definition(prov, pid) + config = load_router_config() authenticated_profiles: List[Tuple[str, RouterProfileConfig]] = [] for pid, pcfg in config.profiles.items(): diff --git a/src/antigravity_provider/router/cli_commands.py b/src/antigravity_provider/router/cli_commands.py index dcefffe..2f622a0 100644 --- a/src/antigravity_provider/router/cli_commands.py +++ b/src/antigravity_provider/router/cli_commands.py @@ -34,6 +34,9 @@ def print_router_status() -> int: print("-" * 80) for rname, rpolicy in sorted(config.roles.items()): + if not rpolicy.preferred_chain: + print(f"{rname:<16} {'(не назначен)':<18} {'-':<15} {'not_configured':<18} {'-':<10}") + continue for idx, pid in enumerate(rpolicy.preferred_chain): pconfig = config.get_profile(pid) if not pconfig: @@ -222,11 +225,6 @@ def test_profile_cli(profile_id: str) -> int: def simulate_quota_cli(profile_id: str, model_family: Optional[str] = None, duration: int = 600) -> int: """Simulate quota exhaustion on a profile for testing.""" engine = get_router_engine() - pconfig = engine.config.get_profile(profile_id) - if not pconfig: - print(f"[ERROR] Profile '{profile_id}' not found.") - return 1 - engine.health.simulate_quota(profile_id, model_family=model_family, duration=duration) print(f"[OK] Simulated quota exhaustion activated for profile '{profile_id}' for {duration} seconds.") print("Use `hermes router clear-cooldown` to restore normal state.") diff --git a/src/antigravity_provider/router/profile_manager.py b/src/antigravity_provider/router/profile_manager.py index 2beeb3a..1eb22e4 100644 --- a/src/antigravity_provider/router/profile_manager.py +++ b/src/antigravity_provider/router/profile_manager.py @@ -322,7 +322,11 @@ class ProfileAuthManager: if base_url: try: from antigravity_provider.router.router_config import load_router_config, save_router_config + from antigravity_provider.router.auto_assigner import AutoAssigner rcfg = load_router_config() + if profile_id not in rcfg.profiles: + AutoAssigner.ensure_profile_definition(provider, profile_id) + rcfg = load_router_config() if profile_id in rcfg.profiles: rcfg.profiles[profile_id].custom_base_url = str(base_url).strip() if auth_data.get("models") and isinstance(auth_data["models"], list): diff --git a/src/antigravity_provider/router/role_registry.py b/src/antigravity_provider/router/role_registry.py index 7d98987..3781b72 100644 --- a/src/antigravity_provider/router/role_registry.py +++ b/src/antigravity_provider/router/role_registry.py @@ -39,7 +39,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = { is_orchestrator=True, capabilities=["orchestrator", "reasoning", "management", "planning"], fallback_capabilities=["orchestrator", "reasoning"], - default_preferred_chain=["codex-orch", "ag-orch-fallback", "claude-orch", "grok-orch", "opengo-3"], + default_preferred_chain=[], default_model="gemini-3.7-flash", max_failover_attempts=3, tier="core", @@ -52,7 +52,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = { is_implemented=True, capabilities=["coding", "developer-1", "reasoning", "tools", "structured_output"], fallback_capabilities=["coding"], - default_preferred_chain=["ag-w1", "codex-worker-1", "claude-worker-1", "opengo-1"], + default_preferred_chain=[], default_model="gemini-3.7-flash", max_failover_attempts=3, tier="core", @@ -65,7 +65,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = { is_implemented=True, capabilities=["coding", "developer-2", "reviewer", "tools"], fallback_capabilities=["coding", "reviewer"], - default_preferred_chain=["ag-w2", "codex-worker-2", "grok-worker-1", "opengo-3"], + default_preferred_chain=[], default_model="gemini-3.1-pro-high", max_failover_attempts=3, tier="core", @@ -78,7 +78,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = { is_implemented=True, capabilities=["code-reviewer", "reviewer", "coding", "security_analysis"], fallback_capabilities=["reviewer", "coding"], - default_preferred_chain=["ag-w3", "ag-w2", "claude-worker-2", "codex-worker-2", "opengo-2"], + default_preferred_chain=[], default_model="claude-opus-4-6-thinking", max_failover_attempts=3, tier="core", @@ -91,7 +91,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = { is_implemented=True, capabilities=["researcher", "research", "search", "long_context"], fallback_capabilities=["research", "search"], - default_preferred_chain=["ag-w3", "grok-worker-2", "opengo-2", "opengo-1"], + default_preferred_chain=[], max_failover_attempts=3, tier="core", ), @@ -103,7 +103,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = { is_implemented=True, capabilities=["tester", "testing", "fast", "automation"], fallback_capabilities=["fast", "testing"], - default_preferred_chain=["ag-w4", "opengo-1", "ag-spare-1"], + default_preferred_chain=[], max_failover_attempts=3, tier="qa_doc", ), @@ -115,7 +115,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = { is_implemented=True, capabilities=["tech-writer", "documentation", "reasoning", "structured_output"], fallback_capabilities=["documentation", "reasoning"], - default_preferred_chain=["claude-worker-2", "ag-w3", "ag-w2"], + default_preferred_chain=[], max_failover_attempts=3, tier="qa_doc", ), @@ -127,7 +127,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = { is_implemented=True, capabilities=["analyst", "reasoning", "research", "planning"], fallback_capabilities=["reasoning", "research"], - default_preferred_chain=["grok-worker-2", "opengo-2", "ag-w3"], + default_preferred_chain=[], max_failover_attempts=3, tier="qa_doc", ), @@ -165,7 +165,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = { is_implemented=True, capabilities=["integration-expert", "integration", "coding", "networking"], fallback_capabilities=["integration", "coding"], - default_preferred_chain=["opengo-3", "codex-worker-1", "ag-w4"], + default_preferred_chain=[], max_failover_attempts=3, tier="expert", ), @@ -177,7 +177,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = { is_implemented=True, capabilities=["security-expert", "security", "code-reviewer", "audit"], fallback_capabilities=["security", "code-reviewer"], - default_preferred_chain=["claude-worker-1", "codex-worker-2", "ag-w2"], + default_preferred_chain=[], max_failover_attempts=3, tier="expert", ), @@ -189,7 +189,7 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = { is_implemented=True, capabilities=["dependency-agent", "preflight", "environment", "system_checks", "fast"], fallback_capabilities=["dependency-agent", "preflight"], - default_preferred_chain=["opengo-1", "ag-w1", "codex-worker-1"], + default_preferred_chain=[], max_failover_attempts=3, tier="qa_doc", ), diff --git a/src/antigravity_provider/router/router_config.py b/src/antigravity_provider/router/router_config.py index f423521..05ac164 100644 --- a/src/antigravity_provider/router/router_config.py +++ b/src/antigravity_provider/router/router_config.py @@ -68,219 +68,14 @@ class RouterConfig: def get_default_router_config() -> RouterConfig: - """Generate default built-in multi-provider configuration (16 profiles across 3 providers).""" - profiles: dict[str, RouterProfileConfig] = { - # 1. Codex Pool (3 accounts) - "codex-orch": RouterProfileConfig( - profile_id="codex-orch", - provider="openai-codex", - account_id="codex-acc-1", - capabilities=["orchestrator", "coding", "reasoning"], - preferred_models=["gpt-4o", "o3-mini", "codex"], - fallback_models=["gpt-4o-mini"], - max_concurrency=1, - ), - "codex-worker-1": RouterProfileConfig( - profile_id="codex-worker-1", - provider="openai-codex", - account_id="codex-acc-2", - capabilities=["coding", "coder-primary", "reasoning"], - preferred_models=["gpt-4o", "o3-mini", "codex"], - max_concurrency=1, - ), - "codex-worker-2": RouterProfileConfig( - profile_id="codex-worker-2", - provider="openai-codex", - account_id="codex-acc-3", - capabilities=["coding", "coder-secondary", "reviewer", "review"], - preferred_models=["gpt-4o", "o3-mini", "codex"], - max_concurrency=1, - ), - # 2. Antigravity Pool (10 accounts, 7 active, 3 cold) - "ag-orch-fallback": RouterProfileConfig( - profile_id="ag-orch-fallback", - provider="antigravity", - account_id="ag-acc-orch", - capabilities=["orchestrator", "reasoning", "coding"], - preferred_models=["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"], - max_concurrency=1, - ), - "ag-w1": RouterProfileConfig( - profile_id="ag-w1", - provider="antigravity", - account_id="ag-acc-w1", - capabilities=["coding", "coder-primary", "reasoning"], - preferred_models=["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"], - max_concurrency=1, - ), - "ag-w2": RouterProfileConfig( - profile_id="ag-w2", - provider="antigravity", - account_id="ag-acc-w2", - capabilities=["coding", "coder-secondary", "reviewer", "review"], - preferred_models=["gemini-3.7-flash", "gemini-3.5-flash"], - max_concurrency=1, - ), - "ag-w3": RouterProfileConfig( - profile_id="ag-w3", - provider="antigravity", - account_id="ag-acc-w3", - capabilities=["research", "reasoning", "search"], - preferred_models=["gemini-3.7-flash", "claude-sonnet-4-6"], - max_concurrency=1, - ), - "ag-w4": RouterProfileConfig( - profile_id="ag-w4", - provider="antigravity", - account_id="ag-acc-w4", - capabilities=["coding", "reasoning", "fast"], - preferred_models=["gemini-3.5-flash", "gemini-3.7-flash"], - max_concurrency=1, - ), - "ag-spare-1": RouterProfileConfig( - profile_id="ag-spare-1", - provider="antigravity", - account_id="ag-acc-sp1", - capabilities=["hot-spare", "coding", "reasoning", "orchestrator", "research", "fast"], - preferred_models=["gemini-3.7-flash", "gemini-3.5-flash"], - max_concurrency=1, - ), - "ag-spare-2": RouterProfileConfig( - profile_id="ag-spare-2", - provider="antigravity", - account_id="ag-acc-sp2", - capabilities=["hot-spare", "coding", "reasoning", "orchestrator", "research", "fast"], - preferred_models=["gemini-3.7-flash", "gemini-3.5-flash"], - max_concurrency=1, - ), - "ag-cold-1": RouterProfileConfig( - profile_id="ag-cold-1", - provider="antigravity", - account_id="ag-acc-c1", - capabilities=["cold-spare", "coding", "reasoning"], - preferred_models=["gemini-3.5-flash"], - enabled=False, - max_concurrency=1, - ), - "ag-cold-2": RouterProfileConfig( - profile_id="ag-cold-2", - provider="antigravity", - account_id="ag-acc-c2", - capabilities=["cold-spare", "coding", "reasoning"], - preferred_models=["gemini-3.5-flash"], - enabled=False, - max_concurrency=1, - ), - "ag-cold-3": RouterProfileConfig( - profile_id="ag-cold-3", - provider="antigravity", - account_id="ag-acc-c3", - capabilities=["cold-spare", "coding", "reasoning"], - preferred_models=["gemini-3.5-flash"], - enabled=False, - max_concurrency=1, - ), - # 3. OpenCode Go Pool (3 accounts) - "opengo-1": RouterProfileConfig( - profile_id="opengo-1", - provider="opencode-go", - account_id="opengo-acc-1", - capabilities=["coding", "fast", "multimodal"], - preferred_models=["deepseek-r1", "qwen-2.5-coder-32b", "deepseek-v3"], - max_concurrency=5, - ), - "opengo-2": RouterProfileConfig( - profile_id="opengo-2", - provider="opencode-go", - account_id="opengo-acc-2", - capabilities=["research", "coding", "multimodal"], - preferred_models=["deepseek-v3", "qwen-2.5-coder-32b", "deepseek-r1"], - max_concurrency=5, - ), - "opengo-3": RouterProfileConfig( - profile_id="opengo-3", - provider="opencode-go", - account_id="opengo-acc-3", - capabilities=["fallback", "coding", "reasoning"], - preferred_models=["deepseek-r1", "deepseek-v3", "qwen-2.5-coder-32b"], - max_concurrency=5, - ), - # 4. Claude Pool (3 accounts) - "claude-orch": RouterProfileConfig( - profile_id="claude-orch", - provider="claude", - account_id="claude-acc-1", - capabilities=["orchestrator", "coding", "reasoning"], - preferred_models=["claude-3-7-sonnet", "claude-3-5-haiku", "claude-sonnet-4-6"], - max_concurrency=2, - ), - "claude-worker-1": RouterProfileConfig( - profile_id="claude-worker-1", - provider="claude", - account_id="claude-acc-2", - capabilities=["coding", "coder-primary", "reasoning"], - preferred_models=["claude-3-7-sonnet", "claude-3-5-haiku", "claude-sonnet-4-6"], - max_concurrency=2, - ), - "claude-worker-2": RouterProfileConfig( - profile_id="claude-worker-2", - provider="claude", - account_id="claude-acc-3", - capabilities=["coding", "coder-secondary", "reviewer", "review"], - preferred_models=["claude-3-7-sonnet", "claude-3-5-haiku"], - max_concurrency=2, - ), - # 5. Grok Pool (3 accounts) - "grok-orch": RouterProfileConfig( - profile_id="grok-orch", - provider="grok", - account_id="grok-acc-1", - capabilities=["orchestrator", "coding", "reasoning"], - preferred_models=["grok-3", "grok-3-mini", "grok-4.5"], - max_concurrency=2, - ), - "grok-worker-1": RouterProfileConfig( - profile_id="grok-worker-1", - provider="grok", - account_id="grok-acc-2", - capabilities=["coding", "coder-primary", "reasoning"], - preferred_models=["grok-3", "grok-3-mini", "grok-4.5"], - max_concurrency=2, - ), - "grok-worker-2": RouterProfileConfig( - profile_id="grok-worker-2", - provider="grok", - account_id="grok-acc-3", - capabilities=["research", "reasoning", "fast"], - preferred_models=["grok-3", "grok-3-mini"], - max_concurrency=2, - ), - # 6. Local LLM Pool (2 accounts) - "local-1": RouterProfileConfig( - profile_id="local-1", - provider="local", - account_id="local-acc-1", - capabilities=["code-reviewer", "reviewer", "coder-secondary", "reasoning", "coding"], - preferred_models=["Qwen3.8-27B-Q4_K_M.gguf", "default"], - max_concurrency=1, - ), - "local-2": RouterProfileConfig( - profile_id="local-2", - provider="local", - account_id="local-acc-2", - capabilities=["tester", "fast", "research", "coding"], - preferred_models=["Qwen3-4B-Instruct-2507-Q4_K_M.gguf", "default"], - max_concurrency=1, - ), - } - + """Generate default clean multi-provider configuration (0 profiles, 13 canonical roles with empty chains).""" roles = RoleRegistry.get_default_role_policies() return RouterConfig( enabled=True, default_role="manager", roles=roles, - profiles=profiles, + profiles={}, ) @@ -364,24 +159,18 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig: if quota_threshold_action not in ("notify", "switch"): quota_threshold_action = "notify" - # Automatic Idempotent Migration (P0-0.1) - # Merge missing default profiles and roles into loaded user configuration + # Automatic Idempotent Migration (A41 Clean Install) + # Merge missing default roles into loaded user configuration without injecting fake profiles default_cfg = get_default_router_config() migration_needed = False - new_profiles_added: list[str] = [] new_roles_added: list[str] = [] if not profiles: - profiles = default_cfg.profiles - else: - for def_pid, def_pcfg in default_cfg.profiles.items(): - if def_pid not in profiles: - profiles[def_pid] = def_pcfg - new_profiles_added.append(def_pid) - migration_needed = True + profiles = {} if not roles: roles = default_cfg.roles + migration_needed = True else: # Сначала переименование старых ролей в канонические, и только # потом дополнение недостающими. diff --git a/src/antigravity_provider/router/unified_health.py b/src/antigravity_provider/router/unified_health.py index 24e7cd1..13499ce 100644 --- a/src/antigravity_provider/router/unified_health.py +++ b/src/antigravity_provider/router/unified_health.py @@ -353,7 +353,8 @@ class UnifiedHealthService: tag = f"{rname} (primary)" if idx == 0 else f"{rname} (fallback {idx})" role_assignments.setdefault(pid, []).append(tag) - orch_primary = config.roles.get("orchestrator", None) + orch_role_name = RoleRegistry.resolve_canonical_role("orchestrator") + orch_primary = config.roles.get(orch_role_name, None) orch_primary_id = orch_primary.preferred_chain[0] if orch_primary and orch_primary.preferred_chain else "" now = time.time() @@ -714,6 +715,26 @@ class UnifiedHealthService: continue if not chain: + agents.append(AgentViewModel( + role_id=rname, + role_name_ru=rname_ru, + role_description_ru=rdesc_ru or "Роль готова к назначению аккаунтов", + assigned_profile_id=None, + assigned_display_name=None, + provider="N/A", + provider_display_name="Не назначен", + model="-", + account_identity="Аккаунт не назначен", + routing_position="Не назначен", + status="not_configured", + status_label_ru="Не настроен", + is_active=False, + is_main_orchestrator=(rname == "manager"), + cooldown_remaining_sec=0, + session_id=None, + active_quota_status="unavailable", + active_quota_label="Не применяется", + )) continue primary_pid = chain[0] @@ -763,6 +784,27 @@ class UnifiedHealthService: active_quota_status=active_quota_st, active_quota_label=active_quota_lbl, )) + else: + agents.append(AgentViewModel( + role_id=rname, + role_name_ru=rname_ru, + role_description_ru=rdesc_ru or "Роль готова к назначению аккаунтов", + assigned_profile_id=primary_pid, + assigned_display_name=primary_pid, + provider="N/A", + provider_display_name="Не назначен", + model="-", + account_identity="Аккаунт не найден", + routing_position="Primary", + status="not_configured", + status_label_ru="Не настроен", + is_active=False, + is_main_orchestrator=(rname == "manager"), + cooldown_remaining_sec=0, + session_id=None, + active_quota_status="unavailable", + active_quota_label="Не применяется", + )) return agents diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 8f4742c..75893ca 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -188,6 +188,12 @@ function initEventListeners() { if (btnPreflight) { btnPreflight.addEventListener('click', () => runPreflightChecks()); } + + // Reset router config listener (P0-4) + const btnResetConfig = document.getElementById('btn-reset-router-config'); + if (btnResetConfig) { + btnResetConfig.addEventListener('click', () => openResetConfigModal()); + } } @@ -1368,6 +1374,67 @@ async function saveHubServerSettings() { } } +// ── RESET CONFIGURATION (P0-2 & P0-4) ── +function openResetConfigModal() { + elements.modalTitle.textContent = 'Начать настройку заново'; + elements.modalBody.innerHTML = ` + +
+
Будет сброшено:
+ +
Будет сохранено (НЕ затрагивается):
+ +
+
+ `; + elements.modalFooter.innerHTML = ` + + + `; + 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 = ''; + } + try { + const res = await executeAction('reset_router_config', {}); + if (res && res.ok) { + if (feedback) { + feedback.innerHTML = ``; + } + setTimeout(() => { + closeModal(); + fetchSnapshot(); + }, 1000); + } else { + if (btn) btn.disabled = false; + if (feedback) { + feedback.innerHTML = ``; + } + } + } catch (err) { + if (btn) btn.disabled = false; + if (feedback) { + feedback.innerHTML = ``; + } + } +} + // ── PREFLIGHT READINESS CHECKS ── async function runPreflightChecks() { const container = document.getElementById('preflight-results-container'); diff --git a/src/antigravity_provider/router/web/static/index.html b/src/antigravity_provider/router/web/static/index.html index efdedfc..9ba4e51 100644 --- a/src/antigravity_provider/router/web/static/index.html +++ b/src/antigravity_provider/router/web/static/index.html @@ -557,6 +557,20 @@ + + +
+

Сброс конфигурации

+
+
+
Начать настройку заново
+
Сбросить все цепочки ролей и список профилей в чистое начальное состояние. Все учетные данные и токены аккаунтов сохраняются.
+
+
+ +
+
+
diff --git a/tests/test_a41_clean_install.py b/tests/test_a41_clean_install.py new file mode 100644 index 0000000..ab2a945 --- /dev/null +++ b/tests/test_a41_clean_install.py @@ -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 diff --git a/tests/test_a9_migration_quotas_models.py b/tests/test_a9_migration_quotas_models.py index d8e4a34..c4f98db 100644 --- a/tests/test_a9_migration_quotas_models.py +++ b/tests/test_a9_migration_quotas_models.py @@ -43,7 +43,7 @@ class TestA9ConfigMigration(unittest.TestCase): shutil.rmtree(self.tmp_dir, ignore_errors=True) def test_migration_16_profiles_preserves_antigravity_and_adds_claude_grok(self): - # 1. Prepare legacy 16-profile configuration (no claude, no grok) + # 1. Prepare legacy configuration with 16 user profiles legacy_profiles = {} for pid in [ "codex-orch", "codex-worker-1", "codex-worker-2", @@ -60,33 +60,32 @@ class TestA9ConfigMigration(unittest.TestCase): max_concurrency=3, ) + # Legacy role configuration missing some canonical roles + legacy_roles = { + "manager": get_default_router_config().roles["manager"], + "developer-1": get_default_router_config().roles["developer-1"], + } + legacy_cfg = RouterConfig( enabled=True, default_role="manager", - roles=get_default_router_config().roles, + roles=legacy_roles, profiles=legacy_profiles, ) save_router_config(legacy_cfg, self.config_path) self.assertEqual(len(legacy_cfg.profiles), 16) - self.assertNotIn("grok-orch", legacy_cfg.profiles) - self.assertNotIn("claude-orch", legacy_cfg.profiles) - # 2. Load configuration (triggers migration) + # 2. Load configuration (triggers migration for missing canonical roles) migrated_cfg = load_router_config(self.config_path) - # 3. Verify backup file created + # 3. Verify backup file created on role migration backups = list(Path(self.tmp_dir).glob("router_profiles.yaml.bak_*")) self.assertGreaterEqual(len(backups), 1, "Backup file was not created on migration") - # 4. Verify Claude and Grok profiles added - self.assertIn("grok-orch", migrated_cfg.profiles) - self.assertIn("grok-worker-1", migrated_cfg.profiles) - self.assertIn("grok-worker-2", migrated_cfg.profiles) - self.assertIn("claude-orch", migrated_cfg.profiles) - self.assertIn("claude-worker-1", migrated_cfg.profiles) - self.assertIn("claude-worker-2", migrated_cfg.profiles) - self.assertEqual(len(migrated_cfg.profiles), 24) + # 4. Verify user profiles are preserved untouched (16 profiles preserved, no fake profiles injected) + self.assertEqual(len(migrated_cfg.profiles), 16) + self.assertEqual(len(migrated_cfg.roles), 13) # 5. Verify existing 10 antigravity profiles are 100% untouched for pid in ["ag-orch-fallback", "ag-w1", "ag-w2", "ag-w3", "ag-w4", "ag-spare-1", "ag-spare-2", "ag-cold-1", "ag-cold-2", "ag-cold-3"]: @@ -96,17 +95,17 @@ class TestA9ConfigMigration(unittest.TestCase): self.assertEqual(p.preferred_models, ["custom-model-1"], f"Models mutated for {pid}") self.assertEqual(p.max_concurrency, 3, f"Concurrency mutated for {pid}") - # 6. Verify AutoAssigner.find_free_slot finds free slots for grok and claude + # 6. Verify AutoAssigner.find_free_slot dynamically allocates clean slots for grok and claude with patch.dict(os.environ, {"HERMES_ROUTER_CONFIG": str(self.config_path)}): slot_grok = AutoAssigner.find_free_slot("grok") - self.assertEqual(slot_grok, "grok-orch") + self.assertEqual(slot_grok, "grok-1") slot_claude = AutoAssigner.find_free_slot("claude") - self.assertEqual(slot_claude, "claude-orch") + self.assertEqual(slot_claude, "claude-1") # 7. Verify Idempotence: subsequent loads do not create extra backups backup_count_before = len(backups) reload_cfg = load_router_config(self.config_path) - self.assertEqual(len(reload_cfg.profiles), 24) + self.assertEqual(len(reload_cfg.profiles), 18) # 16 + grok-1 + claude-1 registered during find_free_slot backup_count_after = len(list(Path(self.tmp_dir).glob("router_profiles.yaml.bak_*"))) self.assertEqual(backup_count_before, backup_count_after) diff --git a/tests/test_accounts_distribution_and_thresholds_a26.py b/tests/test_accounts_distribution_and_thresholds_a26.py index 16de469..2f7f4a4 100644 --- a/tests/test_accounts_distribution_and_thresholds_a26.py +++ b/tests/test_accounts_distribution_and_thresholds_a26.py @@ -53,10 +53,10 @@ def setup_test_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): @pytest.mark.unit def test_unlimited_provider_slots_p0_1(tmp_path: Path): """P0-1: Connecting 4th and 5th account of a provider removes slot ceiling and dynamically registers profiles.""" - # Pre-authenticate default 3 codex slots: codex-orch, codex-worker-1, codex-worker-2 - ProfileAuthManager.save_profile_auth("openai-codex", "codex-orch", {"api_key": "sk-1"}) - ProfileAuthManager.save_profile_auth("openai-codex", "codex-worker-1", {"api_key": "sk-2"}) - ProfileAuthManager.save_profile_auth("openai-codex", "codex-worker-2", {"api_key": "sk-3"}) + # Pre-authenticate 3 codex slots: codex-1, codex-2, codex-3 + ProfileAuthManager.save_profile_auth("openai-codex", "codex-1", {"api_key": "sk-1"}) + ProfileAuthManager.save_profile_auth("openai-codex", "codex-2", {"api_key": "sk-2"}) + ProfileAuthManager.save_profile_auth("openai-codex", "codex-3", {"api_key": "sk-3"}) # 4th slot should be generated dynamically (e.g. codex-4) slot_4 = AutoAssigner.find_free_slot("openai-codex") diff --git a/tests/test_agy_native_login.py b/tests/test_agy_native_login.py index 9abe070..2edf3f0 100644 --- a/tests/test_agy_native_login.py +++ b/tests/test_agy_native_login.py @@ -195,7 +195,7 @@ def test_multi_account_sequential_slot_progression(tmp_path, monkeypatch): # Initial free slot first_slot = AutoAssigner.find_free_slot("antigravity") - assert first_slot == "ag-orch-fallback" + assert first_slot == "ag-1" # Connect first slot AutoAssigner.ensure_profile_definition("antigravity", first_slot) @@ -204,7 +204,7 @@ def test_multi_account_sequential_slot_progression(tmp_path, monkeypatch): # Next free slot second_slot = AutoAssigner.find_free_slot("antigravity") - assert second_slot == "ag-w1" + assert second_slot == "ag-2" # Connect second slot AutoAssigner.ensure_profile_definition("antigravity", second_slot) @@ -213,7 +213,7 @@ def test_multi_account_sequential_slot_progression(tmp_path, monkeypatch): # Next free slot third_slot = AutoAssigner.find_free_slot("antigravity") - assert third_slot == "ag-w2" + assert third_slot == "ag-3" # ── TEST 5: Model selection and assignment (P1-6) ── diff --git a/tests/test_api_providers_a32.py b/tests/test_api_providers_a32.py index dfc27c9..119f56a 100644 --- a/tests/test_api_providers_a32.py +++ b/tests/test_api_providers_a32.py @@ -319,7 +319,7 @@ def test_generate_quotas_export_json_and_csv(): assert "total_profiles" in json_export assert "profiles" in json_export assert "rows" in json_export - assert len(json_export["profiles"]) > 0 + assert isinstance(json_export["profiles"], list) csv_export = generate_quotas_export(format="csv") assert isinstance(csv_export, str) diff --git a/tests/test_deployment_doctor.py b/tests/test_deployment_doctor.py index c51f2bd..9487220 100644 --- a/tests/test_deployment_doctor.py +++ b/tests/test_deployment_doctor.py @@ -78,28 +78,26 @@ def test_auto_assigner_find_free_slot_for_all_five_providers(clean_env): for provider in ["antigravity", "openai-codex", "opencode-go", "claude", "grok"]: slot = AutoAssigner.find_free_slot(provider) if slot is not None: - # Slot MUST exist in config.profiles + config = load_router_config() assert slot in config.profiles assert config.profiles[slot].provider == provider - # Test allocation when all default slots are filled (A26: unlimited accounts) + # Test allocation with clean slot numbering (A41 / A26 unlimited accounts) slot_claude = AutoAssigner.find_free_slot("claude") - assert slot_claude is not None - assert slot_claude in ("claude-orch", "claude-worker-1", "claude-worker-2") + assert slot_claude == "claude-1" - # Simulate fake auth on all 3 default claude slots - for c_slot in ["claude-orch", "claude-worker-1", "claude-worker-2"]: - ProfileAuthManager.save_profile_auth("claude", c_slot, {"api_key": "sk-ant-test-key-1234567890123456"}) + # Simulate fake auth on claude-1 + ProfileAuthManager.save_profile_auth("claude", "claude-1", {"api_key": "sk-ant-test-key-1234567890123456"}) - # Now all default claude slots are taken -> find_free_slot dynamically generates claude-worker-3 + # Next slot dynamically allocated is claude-2 next_slot = AutoAssigner.find_free_slot("claude") - assert next_slot == "claude-worker-3" + assert next_slot == "claude-2" cfg = load_router_config() - assert "claude-worker-3" in cfg.profiles - assert cfg.profiles["claude-worker-3"].provider == "claude" + assert "claude-2" in cfg.profiles + assert cfg.profiles["claude-2"].provider == "claude" rec_slot, title, reason = AutoAssigner.recommend_assignment("claude") - assert rec_slot == "claude-worker-3" + assert rec_slot == "claude-2" @pytest.mark.unit diff --git a/tests/test_local_llm_provider_a25.py b/tests/test_local_llm_provider_a25.py index 088de7e..8ff5763 100644 --- a/tests/test_local_llm_provider_a25.py +++ b/tests/test_local_llm_provider_a25.py @@ -247,46 +247,45 @@ class TestLocalLLMModelDiscovery: class TestLocalLLMConfigAndAutoAssigner: """Test default configuration, migration, and slot auto-assignment.""" - def test_default_config_contains_local_slots(self): + def test_default_config_clean_roles_and_local_registration(self): cfg = get_default_router_config() - assert "local-1" in cfg.profiles - assert "local-2" in cfg.profiles + assert len(cfg.profiles) == 0 + assert len(cfg.roles) == 13 - p1 = cfg.profiles["local-1"] + slot = AutoAssigner.find_free_slot("local") + assert slot == "local-1" + + reloaded = load_router_config() + assert "local-1" in reloaded.profiles + p1 = reloaded.profiles["local-1"] assert p1.provider == "local" - assert "code-reviewer" in p1.capabilities assert "coding" in p1.capabilities - assert "Qwen3.8-27B-Q4_K_M.gguf" in p1.preferred_models - assert p1.max_concurrency == 1 - p2 = cfg.profiles["local-2"] - assert p2.provider == "local" - assert "tester" in p2.capabilities - assert "Qwen3-4B-Instruct-2507-Q4_K_M.gguf" in p2.preferred_models - assert p2.max_concurrency == 1 - - def test_config_migration_adds_local_slots(self): + def test_config_migration_preserves_user_profiles_without_injecting_stubs(self): tmp_dir = tempfile.mkdtemp() try: config_path = Path(tmp_dir) / "router_profiles.yaml" legacy_profiles = { - "codex-orch": RouterProfileConfig( - profile_id="codex-orch", + "custom-codex": RouterProfileConfig( + profile_id="custom-codex", provider="openai-codex", ) } legacy_cfg = RouterConfig( enabled=True, default_role="manager", - roles=get_default_router_config().roles, + roles={"manager": get_default_router_config().roles["manager"]}, profiles=legacy_profiles, ) save_router_config(legacy_cfg, config_path) migrated = load_router_config(config_path) - assert "local-1" in migrated.profiles - assert "local-2" in migrated.profiles - assert migrated.profiles["local-1"].provider == "local" + # User profile is preserved + assert "custom-codex" in migrated.profiles + # 13 canonical roles are migrated + assert len(migrated.roles) == 13 + # No dummy local profiles injected + assert "local-1" not in migrated.profiles finally: shutil.rmtree(tmp_dir, ignore_errors=True) diff --git a/tests/test_model_choice_and_discovery.py b/tests/test_model_choice_and_discovery.py index cd64601..282b65e 100644 --- a/tests/test_model_choice_and_discovery.py +++ b/tests/test_model_choice_and_discovery.py @@ -11,6 +11,7 @@ from pathlib import Path import pytest from antigravity_provider.router.action_handler import ActionExecutor, do_set_model +from antigravity_provider.router.auto_assigner import AutoAssigner from antigravity_provider.router.model_discovery_service import ModelDiscoveryService from antigravity_provider.router.router_config import load_router_config, save_router_config @@ -45,8 +46,9 @@ def temp_models_cache(tmp_path, monkeypatch): def test_set_model_success_and_config_persistence(temp_models_cache): """Verify setting valid discovered model succeeds and persists in router_config.""" svc, _ = temp_models_cache - config = load_router_config() profile_id = "ag-w1" + AutoAssigner.ensure_profile_definition("antigravity", profile_id) + config = load_router_config() assert profile_id in config.profiles # Pick valid discovered model @@ -69,8 +71,9 @@ def test_set_model_success_and_config_persistence(temp_models_cache): def test_set_model_rejects_nonexistent_model(temp_models_cache): """Verify nonexistent model is strictly rejected without modifying configuration.""" svc, _ = temp_models_cache - config = load_router_config() profile_id = "ag-w1" + AutoAssigner.ensure_profile_definition("antigravity", profile_id) + config = load_router_config() orig_models = list(config.profiles[profile_id].preferred_models) # Attempt to set invalid/hallucinated model diff --git a/tests/test_multi_provider_router.py b/tests/test_multi_provider_router.py index 720eddf..5cc145e 100644 --- a/tests/test_multi_provider_router.py +++ b/tests/test_multi_provider_router.py @@ -46,57 +46,33 @@ from antigravity_provider.router.cli_commands import ( class TestRouterConfig: - """Test configuration schema, profile loading, and role definitions.""" + """Test configuration schema, clean default config, and role definitions.""" - def test_default_config_has_24_profiles(self): + def test_default_config_is_clean(self): config = get_default_router_config() - assert len(config.profiles) == 24 - # 3 Codex - assert "codex-orch" in config.profiles - assert "codex-worker-1" in config.profiles - assert "codex-worker-2" in config.profiles - # 10 Antigravity (7 active, 3 cold) - assert "ag-orch-fallback" in config.profiles - assert "ag-w1" in config.profiles - assert "ag-w4" in config.profiles - assert "ag-spare-1" in config.profiles - assert "ag-cold-1" in config.profiles - assert config.profiles["ag-cold-1"].enabled is False - # 3 OpenCode Go - assert "opengo-1" in config.profiles - assert "opengo-2" in config.profiles - assert "opengo-3" in config.profiles - # 3 Claude - assert "claude-orch" in config.profiles - assert "claude-worker-1" in config.profiles - assert "claude-worker-2" in config.profiles - # 3 Grok - assert "grok-orch" in config.profiles - assert "grok-worker-1" in config.profiles - assert "grok-worker-2" in config.profiles + assert len(config.profiles) == 0 + assert len(config.roles) == 13 + assert config.default_role == "manager" + assert config.enabled is True def test_role_policies_chains(self): config = get_default_router_config() assert "manager" in config.roles - orch = config.roles["manager"] - assert "codex-orch" in orch.preferred_chain - assert "ag-orch-fallback" in orch.preferred_chain - assert "opengo-3" in orch.preferred_chain + assert "developer-1" in config.roles + assert "developer-2" in config.roles + assert "code-reviewer" in config.roles + assert "researcher" in config.roles + assert "tester" in config.roles + assert "tech-writer" in config.roles + assert "analyst" in config.roles + assert "guardian" in config.roles + assert "cost-controller" in config.roles + assert "integration-expert" in config.roles + assert "security-expert" in config.roles + assert "dependency-agent" in config.roles - coder = config.roles["developer-1"] - assert "codex-worker-1" in coder.preferred_chain - assert "ag-w1" in coder.preferred_chain - assert "opengo-1" in coder.preferred_chain - - reviewer = config.roles["code-reviewer"] - assert "codex-worker-2" in reviewer.preferred_chain - assert "opengo-2" in reviewer.preferred_chain - assert "ag-w2" in reviewer.preferred_chain - - research = config.roles["researcher"] - assert "opengo-1" in research.preferred_chain - assert "ag-w3" in research.preferred_chain - assert "opengo-2" in research.preferred_chain + for rname, rpol in config.roles.items(): + assert rpol.preferred_chain == [], f"Expected empty chain for {rname} on clean install" class TestHealthTracker: @@ -175,10 +151,30 @@ class TestSessionAffinityAndLeases: class TestRouterEngineFailover: """Test multi-provider role-aware failover execution loop.""" + @staticmethod + def _get_test_config() -> RouterConfig: + return RouterConfig( + enabled=True, + default_role="manager", + roles={ + "manager": RolePolicy( + role_name="manager", + preferred_chain=["codex-orch", "ag-orch-fallback", "opengo-3"], + max_failover_attempts=3, + session_affinity_enabled=True, + ) + }, + profiles={ + "codex-orch": RouterProfileConfig(profile_id="codex-orch", provider="openai-codex"), + "ag-orch-fallback": RouterProfileConfig(profile_id="ag-orch-fallback", provider="antigravity"), + "opengo-3": RouterProfileConfig(profile_id="opengo-3", provider="opencode-go"), + }, + ) + def test_orchestrator_failover_chain(self, tmp_path): state_file = tmp_path / "test_router_state.json" engine = RouterEngine( - config=get_default_router_config(), + config=self._get_test_config(), health=HealthTracker(state_file=state_file), affinity=SessionAffinityTracker(), ) @@ -217,7 +213,7 @@ class TestRouterEngineFailover: def test_session_affinity_retention_after_failover(self, tmp_path): state_file = tmp_path / "test_affinity_state.json" engine = RouterEngine( - config=get_default_router_config(), + config=self._get_test_config(), health=HealthTracker(state_file=state_file), affinity=SessionAffinityTracker(), ) diff --git a/tests/test_unified_health.py b/tests/test_unified_health.py index 2d2f54c..ee01ecc 100644 --- a/tests/test_unified_health.py +++ b/tests/test_unified_health.py @@ -32,20 +32,18 @@ from antigravity_provider.router.router_config import load_router_config def test_profile_view_model_mapping(): """Verify that scan_all creates ProfileViewModels for all configured profiles.""" service = UnifiedHealthService.get() - profiles_by_prov = service.scan_all() + profiles_by_prov = service.scan_all(force=True) assert "antigravity" in profiles_by_prov assert "openai-codex" in profiles_by_prov assert "opencode-go" in profiles_by_prov all_profs = [p for profs in profiles_by_prov.values() for p in profs] - assert len(all_profs) >= 16 - for p in all_profs: assert isinstance(p, ProfileViewModel) assert p.profile_id assert p.display_name - assert p.provider in ("antigravity", "openai-codex", "opencode-go", "claude", "grok", "local") + assert p.provider in ("antigravity", "openai-codex", "opencode-go", "claude", "grok", "local", "openrouter", "nvidia", "ollama", "vllm") assert p.health_state in ( "healthy", "quota_low", "quota_exhausted", "cooldown", "rate_limited", "auth_required", "auth_expired", "disabled", "cold_spare", "unhealthy", "not_tested", "not_configured" @@ -55,7 +53,7 @@ def test_profile_view_model_mapping(): def test_auth_required_never_healthy_without_credentials(): """Verify that unauthenticated profiles are NEVER marked as HEALTHY.""" service = UnifiedHealthService.get() - profiles_by_prov = service.scan_all() + profiles_by_prov = service.scan_all(force=True) for profs in profiles_by_prov.values(): for p in profs: @@ -65,8 +63,9 @@ def test_auth_required_never_healthy_without_credentials(): def test_cold_spare_presentation(): """Verify that cold spare slots are marked as cold_spare or auth_required, not active.""" + AutoAssigner.ensure_profile_definition("antigravity", "ag-cold-1") service = UnifiedHealthService.get() - profiles_by_prov = service.scan_all() + profiles_by_prov = service.scan_all(force=True) ag_profs = {p.profile_id: p for p in profiles_by_prov["antigravity"]} cold_1 = ag_profs.get("ag-cold-1")