merge: A26 — аккаунты без слотов, распределение по агентам и пороги квот

Проверено ревьюером исполнением, а не по отчёту:

  пятый аккаунт провайдера        codex-4, codex-5, codex-6 создаются сами
  «Авто», один провайдер          ag-w1 встал во все шесть ролей
  «Авто», два провайдера          порядок следует config/router_profiles.example.yaml
  порог, квота неизвестна         состояние не меняется — выдумки нет
  порог, квота 50% при пороге 10  healthy
  порог, квота 4%, режим switch   quota-exhausted
  восстановление до 80%           healthy сам, без вмешательства

Последнее закрывает урок A23: у AUTH_REQUIRED не было выхода и шесть
профилей выпали навсегда; здесь выход есть и проверен.

Найден и исправлен ревьюером один дефект: признак «подключён» пропускал
холодный резерв и пустые слоты, из-за чего страница аккаунтов не пустела, а
«Обзор» предлагал назначать роли на слоты без учётных данных. Подробности в
4c2594b.

Побочная правка tests/test_deployment_doctor.py законна: старый тест требовал
find_free_slot() is None при заполненных слотах — ровно то поведение, которое
A26 отменяет.

442 passed, 2 skipped; ruff чисто.
This commit is contained in:
Hermes Team 2026-08-25 17:17:48 +07:00
commit a1e1db75bf
13 changed files with 789 additions and 66 deletions

View file

@ -143,6 +143,30 @@ def do_save_settings(settings: Dict[str, Any]) -> Tuple[bool, str]:
except Exception as e:
return False, f"Не удалось сохранить настройки: {e}"
from antigravity_provider.router.settings_service import invalidate_settings_cache
invalidate_settings_cache()
try:
from antigravity_provider.router.router_config import load_router_config, save_router_config
rcfg = load_router_config()
updated_rcfg = False
if "quota_threshold_percent" in settings:
rcfg.quota_threshold_percent = float(settings["quota_threshold_percent"])
updated_rcfg = True
if "quota_threshold_action" in settings:
rcfg.quota_threshold_action = str(settings["quota_threshold_action"])
updated_rcfg = True
if updated_rcfg:
save_router_config(rcfg)
except Exception:
pass
try:
from antigravity_provider.router.state_store import HubStateStore
HubStateStore.get().refresh(force_scan=True)
except Exception:
pass
from antigravity_provider.router.quota_collector import AccountQuotaService
AccountQuotaService.get().set_refresh_interval(int(settings.get("quota_refresh_interval_sec", 300)))

View file

@ -104,7 +104,10 @@ class AutoAssigner:
@staticmethod
def get_display_name_and_role(profile_id: str) -> Tuple[str, str, str]:
"""Get human-readable display name, logical role, and tier for a profile."""
return DEFAULT_SLOT_ROLES.get(profile_id, (profile_id, "worker", "primary"))
if profile_id in DEFAULT_SLOT_ROLES:
return DEFAULT_SLOT_ROLES[profile_id]
clean_name = profile_id.replace("-", " ").title()
return (clean_name, "worker", "primary")
@staticmethod
def check_duplicate_identity(provider: str, email_or_id: str, exclude_profile_id: Optional[str] = None) -> Optional[str]:
@ -138,8 +141,13 @@ class AutoAssigner:
@staticmethod
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 the next free profile ID without capping account count (P0-1).
"""
config = load_router_config()
provider_norm = (provider or "").strip().lower()
provider_slots = {
"antigravity": [
@ -147,9 +155,13 @@ class AutoAssigner:
"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"],
@ -157,18 +169,17 @@ class AutoAssigner:
"vllm": ["local-1", "local-2"],
}
candidates = list(provider_slots.get(provider, []))
candidates = list(provider_slots.get(provider_norm, []))
# Priority based on requested role
if requested_role == "orchestrator":
if provider == "openai-codex" and "codex-orch" in candidates:
if provider_norm in ("openai-codex", "codex") and "codex-orch" in candidates:
candidates.remove("codex-orch")
candidates.insert(0, "codex-orch")
elif provider == "antigravity" and "ag-orch-fallback" in candidates:
elif provider_norm == "antigravity" and "ag-orch-fallback" in candidates:
candidates.remove("ag-orch-fallback")
candidates.insert(0, "ag-orch-fallback")
# Find first slot without saved auth that exists in config
# 1. First check existing candidate profiles already in config
for pid in candidates:
pcfg = config.get_profile(pid)
if not pcfg:
@ -177,6 +188,44 @@ class AutoAssigner:
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)
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 in ("local", "local-llm", "llama.cpp", "ollama", "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
@staticmethod
@ -206,7 +255,7 @@ class AutoAssigner:
"ollama": ["reviewer", "coding", "reasoning", "fast", "research"],
"vllm": ["reviewer", "coding", "reasoning", "fast", "research"],
}
capabilities = capabilities_map.get(provider, [])
capabilities = capabilities_map.get(provider, ["coding", "reasoning"])
config.profiles[profile_id] = RouterProfileConfig(
profile_id=profile_id,
@ -300,9 +349,9 @@ class AutoAssigner:
@staticmethod
def auto_assign_all() -> Dict[str, Any]:
"""Automatically distribute all authenticated profiles across canonical router roles."""
"""Automatically distribute all authenticated profiles across canonical router roles (P0-4)."""
config = load_router_config()
authenticated_profiles = []
authenticated_profiles: List[Tuple[str, RouterProfileConfig]] = []
for pid, pcfg in config.profiles.items():
if not pcfg.enabled:
continue
@ -310,13 +359,134 @@ class AutoAssigner:
if st.get("authenticated"):
authenticated_profiles.append((pid, pcfg))
changes = []
canonical_roles_order = ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"]
for idx, (pid, pcfg) in enumerate(authenticated_profiles):
target_role = canonical_roles_order[idx % len(canonical_roles_order)]
ok, msg = AutoAssigner.assign_profile_to_role(pid, target_role, is_primary=(idx < len(canonical_roles_order)))
if ok:
changes.append({"profile_id": pid, "role": target_role, "message": msg})
if not authenticated_profiles:
return {
"success": False,
"message": "Нет подключённых аккаунтов для распределения",
"total_authenticated": 0,
"assigned_count": 0,
"changes": [],
}
canonical_roles = ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"]
changes: List[Dict[str, Any]] = []
# Ensure canonical roles exist in config
for rname in canonical_roles:
if rname not in config.roles:
config.roles[rname] = RolePolicy(role_name=rname)
def _norm_prov(p: str) -> str:
p = (p or "").lower().strip()
if p in ("openai-codex", "codex"):
return "codex"
if p in ("opencode-go", "opencode"):
return "opencode"
if p in ("local-llm", "llama.cpp", "ollama", "vllm", "local"):
return "local"
if p in ("claude", "anthropic"):
return "claude"
if p in ("grok", "xai"):
return "grok"
return p
unique_providers = set(_norm_prov(pcfg.provider) for _, pcfg in authenticated_profiles)
if len(authenticated_profiles) == 1:
# Case 1: Exactly 1 account connected -> assign as primary to all 6 roles
single_pid, _ = authenticated_profiles[0]
for role_name in canonical_roles:
config.roles[role_name].preferred_chain = [single_pid]
changes.append({
"profile_id": single_pid,
"role": role_name,
"message": f"Профиль '{single_pid}' назначен основным во все 6 ролей",
})
elif len(unique_providers) == 1:
# Case 2: Multiple accounts of the same provider -> distribute / rotate across roles
pids = [pid for pid, _ in authenticated_profiles]
num_accs = len(pids)
for idx, role_name in enumerate(canonical_roles):
primary_pid = pids[idx % num_accs]
fallbacks = [p for p in pids if p != primary_pid]
config.roles[role_name].preferred_chain = [primary_pid] + fallbacks
changes.append({
"profile_id": primary_pid,
"role": role_name,
"message": f"Профиль '{primary_pid}' назначен на роль '{role_name}' с ротацией квот",
})
else:
# Case 3: Multiple providers connected -> distribute by role provider preferences
role_provider_preferences = {
"orchestrator": ["codex", "antigravity", "opencode", "claude", "grok", "local"],
"coder-primary": ["codex", "antigravity", "opencode", "claude", "grok", "local"],
"coder-secondary": ["codex", "antigravity", "opencode", "claude", "grok", "local"],
"reviewer": ["codex", "opencode", "antigravity", "claude", "grok", "local"],
"research": ["opencode", "antigravity", "grok", "claude", "codex", "local"],
"fast": ["opencode", "antigravity", "local", "grok", "codex", "claude"],
}
by_prov: Dict[str, List[str]] = {}
for pid, pcfg in authenticated_profiles:
norm = _norm_prov(pcfg.provider)
by_prov.setdefault(norm, []).append(pid)
prov_cursors: Dict[str, int] = {k: 0 for k in by_prov}
for role_name in canonical_roles:
pref_order = role_provider_preferences.get(role_name, ["codex", "antigravity", "opencode"])
chain: List[str] = []
# Find primary for this role
chosen_primary = None
for prov in pref_order:
if prov in by_prov and by_prov[prov]:
acc_list = by_prov[prov]
cur = prov_cursors[prov]
chosen_primary = acc_list[cur % len(acc_list)]
prov_cursors[prov] = cur + 1
break
if not chosen_primary:
all_pids = [pid for pid, _ in authenticated_profiles]
chosen_primary = all_pids[0]
chain.append(chosen_primary)
# Add remaining profiles as fallbacks in preference order
for prov in pref_order:
if prov in by_prov:
for p in by_prov[prov]:
if p not in chain:
chain.append(p)
for pid, _ in authenticated_profiles:
if pid not in chain:
chain.append(pid)
config.roles[role_name].preferred_chain = chain
changes.append({
"profile_id": chosen_primary,
"role": role_name,
"message": f"Профиль '{chosen_primary}' назначен основным на роль '{role_name}'",
})
save_router_config(config)
try:
from antigravity_provider.router.state_store import HubStateStore
HubStateStore.get().refresh(force_scan=True)
except Exception:
pass
try:
from antigravity_provider.router.unified_health import EventLogService
EventLogService.get().log(
"routing",
f"Авто-распределение завершено: {len(authenticated_profiles)} аккаунтов распределены по 6 ролям.",
level="info",
)
except Exception:
pass
return {
"success": True,

View file

@ -399,31 +399,68 @@ class HealthTracker:
self._save_state()
def reconcile_measured_quota(self, profile_id: str, remaining_by_family: Dict[str, float]) -> bool:
"""Clear stale quota-exhausted flags when the provider reports live capacity.
"""Evaluate measured quota against configured threshold and recover when capacity restores.
A successful quota read is authoritative for quota exhaustion, but it
must not erase unrelated authentication or runtime failures.
"""
measured = {family: float(value) for family, value in remaining_by_family.items()}
measured = {family: float(value) for family, value in remaining_by_family.items() if value is not None}
if not measured:
return False
from antigravity_provider.router.settings_service import get_hub_settings
settings = get_hub_settings()
threshold = float(settings.get("quota_threshold_percent", 10.0))
action = str(settings.get("quota_threshold_action", "notify")).strip().lower()
with self._lock:
record = self.get_or_create(profile_id)
changed = False
now = time.time()
for family, remaining in measured.items():
family_record = record.families.get(family)
if remaining > 0 and family_record and family_record.state == QUOTA_EXHAUSTED:
family_record.state = HEALTHY
family_record.reset_at = None
family_record.reason = None
family_record.last_error = None
family_record.simulated = False
changed = True
if all(value > 0 for value in measured.values()) and record.overall_state == QUOTA_EXHAUSTED:
if remaining <= threshold:
if action == "switch":
if family not in record.families:
record.families[family] = FamilyHealthRecord(family=family)
frec = record.families[family]
if frec.state != QUOTA_EXHAUSTED:
frec.state = QUOTA_EXHAUSTED
frec.reset_at = now + 1800
frec.reason = f"Остаток квоты {remaining:.1f}% <= порога {threshold:.1f}%"
frec.last_error = frec.reason
changed = True
elif action == "notify":
try:
from antigravity_provider.router.unified_health import EventLogService
EventLogService.get().log(
"quota",
f"Внимание: остаток квоты профиля {profile_id} ({family}) составляет {remaining:.1f}% (порог: {threshold:.1f}%).",
level="warning",
)
except Exception:
pass
elif remaining > threshold:
if family_record and family_record.state == QUOTA_EXHAUSTED:
family_record.state = HEALTHY
family_record.reset_at = None
family_record.reason = None
family_record.last_error = None
family_record.simulated = False
changed = True
if all(value > threshold for value in measured.values()) and record.overall_state == QUOTA_EXHAUSTED:
record.overall_state = HEALTHY
record.last_error = None
record.simulated = False
changed = True
elif any(value <= threshold for value in measured.values()) and action == "switch":
if record.overall_state == HEALTHY:
record.overall_state = QUOTA_EXHAUSTED
record.last_error = f"Остаток квоты ниже порога {threshold:.1f}%"
changed = True
if changed:
self._save_state()
return changed

View file

@ -166,12 +166,12 @@ class AccountQuotaService:
with self._cache_lock:
self._snapshots[key] = snap
if snap.source == "provider_api":
if snap.source in ("provider_api", "local_provider") or snap.buckets:
measured_by_family: dict[str, float] = {}
for bucket in snap.buckets:
family = bucket.model_family
family = bucket.model_family or "default"
remaining = bucket.remaining_percent
if not family or remaining is None:
if remaining is None:
continue
measured_by_family[family] = min(measured_by_family.get(family, 100.0), float(remaining))
if measured_by_family:
@ -179,8 +179,13 @@ class AccountQuotaService:
from .router_engine import get_router_engine
get_router_engine().health.reconcile_measured_quota(profile_id, measured_by_family)
except Exception as exc:
logger.debug("Could not reconcile live quota health for %s: %s", profile_id, exc)
except Exception:
try:
from .health_tracker import HealthTracker
HealthTracker().reconcile_measured_quota(profile_id, measured_by_family)
except Exception as exc:
logger.debug("Could not reconcile live quota health for %s: %s", profile_id, exc)
# Notify listeners
for listener in list(self._listeners):

View file

@ -42,6 +42,8 @@ class RouterConfig:
cooldown_base_seconds: int = 300
cooldown_max_seconds: int = 3600
session_affinity_ttl_seconds: int = 1800
quota_threshold_percent: float = 10.0
quota_threshold_action: str = "notify" # "notify" | "switch"
roles: dict[str, RolePolicy] = field(default_factory=dict)
profiles: dict[str, RouterProfileConfig] = field(default_factory=dict)
pricing: dict[str, dict[str, float]] = field(default_factory=dict)
@ -387,6 +389,13 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig:
session_ttl = int(r_block.get("session_affinity_ttl_seconds", data.get("session_affinity_ttl_seconds", 1800)))
quota_cooldown = int(r_block.get("quota_cooldown_seconds", data.get("quota_cooldown_seconds", 1800)))
rate_cooldown = int(r_block.get("rate_limit_cooldown_seconds", data.get("rate_limit_cooldown_seconds", 60)))
try:
quota_threshold_percent = float(r_block.get("quota_threshold_percent", data.get("quota_threshold_percent", 10.0)))
except (ValueError, TypeError):
quota_threshold_percent = 10.0
quota_threshold_action = str(r_block.get("quota_threshold_action", data.get("quota_threshold_action", "notify"))).strip().lower()
if quota_threshold_action not in ("notify", "switch"):
quota_threshold_action = "notify"
# Automatic Idempotent Migration (P0-0.1)
# Merge missing default profiles and roles into loaded user configuration
@ -435,6 +444,8 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig:
cooldown_base_seconds=cooldown_base,
cooldown_max_seconds=cooldown_max,
session_affinity_ttl_seconds=session_ttl,
quota_threshold_percent=quota_threshold_percent,
quota_threshold_action=quota_threshold_action,
roles=roles,
profiles=profiles,
pricing=pricing,
@ -453,6 +464,8 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig:
cooldown_base_seconds=cooldown_base,
cooldown_max_seconds=cooldown_max,
session_affinity_ttl_seconds=session_ttl,
quota_threshold_percent=quota_threshold_percent,
quota_threshold_action=quota_threshold_action,
roles=roles,
profiles=profiles,
pricing=pricing,
@ -509,6 +522,8 @@ def save_router_config(config: RouterConfig, config_path: Optional[Path] = None)
"cooldown_base_seconds": config.cooldown_base_seconds,
"cooldown_max_seconds": config.cooldown_max_seconds,
"session_affinity_ttl_seconds": config.session_affinity_ttl_seconds,
"quota_threshold_percent": config.quota_threshold_percent,
"quota_threshold_action": config.quota_threshold_action,
})
data = {

View file

@ -20,6 +20,8 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"release_channel": "stable",
"model_timeout_seconds": 60,
"monitoring_interval_seconds": 30,
"quota_threshold_percent": 10.0,
"quota_threshold_action": "notify",
}
@ -86,6 +88,16 @@ def get_hub_settings() -> Dict[str, Any]:
except (ValueError, TypeError):
merged["monitoring_interval_seconds"] = 30
try:
merged["quota_threshold_percent"] = float(merged.get("quota_threshold_percent", 10.0))
except (ValueError, TypeError):
merged["quota_threshold_percent"] = 10.0
action = str(merged.get("quota_threshold_action", "notify")).strip().lower()
if action not in ("notify", "switch"):
action = "notify"
merged["quota_threshold_action"] = action
_SETTINGS_CACHE = dict(merged)
_SETTINGS_CACHE_MTIME = current_mtime
_SETTINGS_CACHE_PATH = sfile_str

View file

@ -590,6 +590,24 @@ class UnifiedHealthService:
dead_roles += 1
warnings.append(f"Роль '{rname}' не имеет рабочих аккаунтов (все исчерпаны).")
# Quota threshold warnings (P0-5)
try:
from antigravity_provider.router.quota_collector import AccountQuotaService
from antigravity_provider.router.settings_service import get_hub_settings
settings = get_hub_settings()
threshold = float(settings.get("quota_threshold_percent", 10.0))
for profile in configured_profiles:
if profile.auth_state == "AUTHENTICATED":
snap = AccountQuotaService.get().get_snapshot(profile.provider, profile.profile_id)
if snap and snap.buckets:
for b in snap.buckets:
if b.remaining_percent is not None and b.remaining_percent <= threshold:
warnings.append(
f"Квота аккаунта {profile.profile_id} ({b.display_name or profile.provider}) ниже порога {threshold:.1f}%: {b.remaining_percent:.1f}%."
)
except Exception:
pass
# Determine overall state
if dead_roles > 0:
state = READINESS_CRITICAL

View file

@ -36,18 +36,9 @@ app.add_middleware(
def _web_settings() -> Dict[str, Any]:
"""Настройки веб-API живут в hub_settings.json, а не в RouterConfig.
Прежняя версия читала config.hub такого атрибута у RouterConfig нет,
поэтому /api/snapshot и /api/action падали с 500, а run_server не
поднимался вовсе. Работал только /api/health, у которого нет проверки
авторизации, из-за чего дефект и выглядел как рабочий сервер.
"""
settings_file = paths.get_hermes_home() / "hub_settings.json"
try:
return json.loads(settings_file.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return {}
"""Настройки веб-API живут в hub_settings.json, а не в RouterConfig."""
from antigravity_provider.router.settings_service import get_hub_settings
return get_hub_settings()
def get_auth_token(x_hub_token: str = Header(None)) -> bool:

View file

@ -129,6 +129,13 @@ function initEventListeners() {
});
}
const btnAutoAssign = document.getElementById('btn-auto-assign');
if (btnAutoAssign) {
btnAutoAssign.addEventListener('click', () => {
executeAction('auto_assign_all', {});
});
}
if (elements.modalCloseBtn) elements.modalCloseBtn.addEventListener('click', closeModal);
if (elements.modalBackdrop) {
elements.modalBackdrop.addEventListener('click', (e) => {
@ -290,6 +297,28 @@ function startPolling() {
}
}
// Подключён ли профиль на самом деле.
//
// A26 определял это как «health_state не равен not_configured», а поле
// authenticated в модели вообще отсутствует, поэтому первая половина условия
// была мертва. Через фильтр проходили холодный резерв (health_state
// "disabled") и непроверенные пустые слоты: при нуле настоящих аккаунтов
// страница показывала три карточки «Холодный резерв», а «Обзор» предлагал
// назначать роли на пустые слоты — то самое мышление слотами, ради отмены
// которого задание и делалось.
//
// Authoritative признак — auth_state: у подключённого AUTHENTICATED, у
// пустого слота и у холодного резерва NOT_CONFIGURED. Состояния
// AUTH_REQUIRED и AUTH_EXPIRED означают подключённый аккаунт, которому нужен
// повторный вход, — их показываем.
function isConnectedProfile(p) {
if (!p) return false;
const st = String(p.auth_state || '').toUpperCase();
if (st) return st !== 'NOT_CONFIGURED';
// Запасной путь, если поле не пришло: судим по наличию опознанного аккаунта.
return Boolean(p.email);
}
// ── КОПИРОВАНИЕ В БУФЕР ──
//
// navigator.clipboard существует только в защищённом контексте: HTTPS или
@ -462,10 +491,14 @@ async function executeAction(actionName, actionData = {}) {
function updateGlobalHeader() {
if (!currentSnapshot) return;
const totalAccounts = Object.keys(currentSnapshot.all_profiles || {}).length;
if (elements.navAccountsCount) elements.navAccountsCount.textContent = totalAccounts;
const readiness = currentSnapshot.readiness || {};
const allProfiles = Object.values(currentSnapshot.all_profiles || {});
const connectedAccounts = readiness.accounts_connected_count ?? allProfiles.filter(
(p) => isConnectedProfile(p)
).length;
if (elements.navAccountsCount) elements.navAccountsCount.textContent = connectedAccounts;
const isHealthy = readiness.state === 'healthy';
const readyRoles = readiness.roles_ready_count || 0;
const totalRoles = readiness.total_roles || 6;
@ -489,8 +522,8 @@ function updateGlobalHeader() {
if (kpiReadiness) kpiReadiness.textContent = readiness.title_ru || 'Работает';
if (kpiSummary) kpiSummary.textContent = readiness.summary_ru || 'Все маршруты доступны';
if (kpiTotalAccounts) kpiTotalAccounts.textContent = totalAccounts;
if (kpiAccountsSub) kpiAccountsSub.textContent = `Подключено: ${readiness.accounts_connected_count || totalAccounts}`;
if (kpiTotalAccounts) kpiTotalAccounts.textContent = connectedAccounts;
if (kpiAccountsSub) kpiAccountsSub.textContent = `Подключено: ${connectedAccounts}`;
if (kpiReadyRoles) kpiReadyRoles.textContent = `${readyRoles}/${totalRoles}`;
if (kpiRolesSub) kpiRolesSub.textContent = `${readyRoles} из ${totalRoles} ролей маршрутизации активны`;
if (kpiProvidersCount) kpiProvidersCount.textContent = (currentSnapshot.providers || []).length || 5;
@ -525,12 +558,32 @@ function renderCurrentView() {
}
// ═══════════════════════════════════════════════════════════════
// 1. ACCOUNTS VIEW (Compact Fixed-Height Cards & Quotas)
// 1. ACCOUNTS VIEW (P0-2 Only Connected Accounts & Empty State)
// ═══════════════════════════════════════════════════════════════
function renderAccountsView() {
const container = elements.accountsContainer;
if (!container || !currentSnapshot) return;
const allProfiles = Object.values(currentSnapshot.all_profiles || {});
const totalConnectedInSystem = allProfiles.filter(
(p) => isConnectedProfile(p)
).length;
if (totalConnectedInSystem === 0) {
container.innerHTML = `
<div class="accounts-empty-state">
<div class="empty-state-icon">👥</div>
<h3>Нет подключённых аккаунтов</h3>
<p>Подключите ваш первый аккаунт провайдера ИИ для распределения ролей и работы с Hermes Hub.</p>
<button class="btn btn-primary" onclick="openAddAccountWizard()">+ Подключить аккаунт</button>
</div>
`;
if (elements.accountsStatsSummary) {
elements.accountsStatsSummary.innerHTML = 'Показано: <strong>0</strong> из <strong>0</strong> подключённых аккаунтов';
}
return;
}
const searchQuery = (elements.accountsSearch ? elements.accountsSearch.value : '').trim().toLowerCase();
const providerFilter = elements.filterProvider ? elements.filterProvider.value : 'all';
const healthFilter = elements.filterHealth ? elements.filterHealth.value : 'all';
@ -557,6 +610,9 @@ function renderAccountsView() {
if (providerFilter !== 'all' && providerFilter !== providerId) continue;
const filtered = profiles.filter((p) => {
const isConnected = isConnectedProfile(p);
if (!isConnected) return false;
totalProfiles++;
const matchesSearch =
!searchQuery ||
@ -575,9 +631,6 @@ function renderAccountsView() {
return matchesSearch && matchesHealth;
});
// Подключённые аккаунты идут первыми: владелец жаловался, что рабочие
// карточки разбросаны между пустыми слотами и их приходится выискивать.
// Пустые слоты ('не подключён') — всегда в конце группы.
filtered.sort((a, b) => {
const rank = (p) => {
if (p.health_state === 'not_configured') return 5;
@ -613,7 +666,7 @@ function renderAccountsView() {
container.innerHTML = html || '<div class="view-header-note">Аккаунты по заданным фильтрам не найдены.</div>';
if (elements.accountsStatsSummary) {
elements.accountsStatsSummary.innerHTML = `Показано: <strong>${visibleProfiles}</strong> из <strong>${totalProfiles}</strong> аккаунтов`;
elements.accountsStatsSummary.innerHTML = `Показано: <strong>${visibleProfiles}</strong> из <strong>${totalProfiles}</strong> подключённых аккаунтов`;
}
container.querySelectorAll('.account-card').forEach((card) => {
@ -793,6 +846,9 @@ function renderOverviewView() {
const diagramBox = document.getElementById('overview-route-diagram');
if (diagramBox) {
const roles = currentSnapshot.routing || {};
const allConnectedProfiles = Object.values(currentSnapshot.all_profiles || {}).filter(
(p) => isConnectedProfile(p)
);
let diagramHtml = '';
for (const [roleId, pipeline] of Object.entries(roles)) {
@ -808,6 +864,17 @@ function renderOverviewView() {
const discoveredModels = (provSummary && provSummary.discovered_models && provSummary.discovered_models.length > 0) ? provSummary.discovered_models : [];
const currentModel = node.model || (profile && profile.preferred_models && profile.preferred_models[0]) || '';
const hasCurrentInConnected = allConnectedProfiles.some((p) => p.profile_id === node.profile_id);
let accountControlHtml = '';
if (allConnectedProfiles.length > 0) {
accountControlHtml = `
<select class="diagram-account-select" title="Сменить назначенный аккаунт" onchange="handleNodeAccountChange('${escapeHtml(roleId)}', this.value, ${idx === 0})">
${allConnectedProfiles.map((p) => `<option value="${escapeHtml(p.profile_id)}" ${p.profile_id === node.profile_id ? 'selected' : ''}>${escapeHtml(p.email || p.account_identity || p.display_name || p.profile_id)} (${escapeHtml(p.provider)})</option>`).join('')}
${!hasCurrentInConnected && node.profile_id ? `<option value="${escapeHtml(node.profile_id)}" selected>${escapeHtml(node.display_name || node.profile_id)}</option>` : ''}
</select>
`;
}
let modelControlHtml = '';
if (discoveredModels.length > 0) {
modelControlHtml = `
@ -832,6 +899,7 @@ function renderOverviewView() {
</div>
<div style="font-size:12px; font-weight:700; margin-top:2px;">${escapeHtml(node.account_identity && node.account_identity !== 'Аккаунт не добавлен' ? node.account_identity : (node.display_name || node.profile_id))}</div>
<div style="font-size:10px; color:var(--text-secondary); margin-bottom:4px;">${escapeHtml(node.display_name || node.profile_id)} (${escapeHtml(node.provider)})</div>
${accountControlHtml}
${modelControlHtml}
</div>
`;
@ -1401,6 +1469,8 @@ function populateSettingsForm(s) {
const portInput = document.getElementById('setting-server-port');
const tokenBadge = document.getElementById('setting-token-status-badge');
const quotaSel = document.getElementById('setting-quota-interval');
const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent');
const quotaActionSel = document.getElementById('setting-quota-threshold-action');
const themeSel = document.getElementById('setting-theme');
const pathHome = document.getElementById('path-hermes-home');
@ -1416,6 +1486,12 @@ function populateSettingsForm(s) {
if (quotaSel && s.quota_refresh_interval_sec) {
quotaSel.value = String(s.quota_refresh_interval_sec);
}
if (quotaThresholdSel && s.quota_threshold_percent !== undefined) {
quotaThresholdSel.value = String(Math.round(s.quota_threshold_percent));
}
if (quotaActionSel && s.quota_threshold_action) {
quotaActionSel.value = s.quota_threshold_action;
}
if (themeSel && s.theme) {
themeSel.value = s.theme;
applyTheme(s.theme);
@ -1445,12 +1521,16 @@ async function saveHubServerSettings() {
const portInput = document.getElementById('setting-server-port');
const tokenInput = document.getElementById('setting-server-token-input');
const quotaSel = document.getElementById('setting-quota-interval');
const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent');
const quotaActionSel = document.getElementById('setting-quota-threshold-action');
const themeSel = document.getElementById('setting-theme');
const payload = {
web_api_host: hostInput ? hostInput.value.trim() : '127.0.0.1',
web_api_port: portInput ? parseInt(portInput.value, 10) || 5800 : 5800,
quota_refresh_interval_sec: quotaSel ? parseInt(quotaSel.value, 10) || 300 : 300,
quota_threshold_percent: quotaThresholdSel ? parseFloat(quotaThresholdSel.value) || 10.0 : 10.0,
quota_threshold_action: quotaActionSel ? quotaActionSel.value : 'notify',
theme: themeSel ? themeSel.value : 'system',
};
@ -1964,7 +2044,23 @@ async function handleNodeDrop(e, roleId, targetIndex) {
}
}
// ── Routing Node Model & Chain Management ──
// ── Routing Node Model, Account & Chain Management ──
async function handleNodeAccountChange(roleId, profileId, isPrimary = true) {
if (!roleId || !profileId) return;
showToast(`Назначение аккаунта '${profileId}' на роль '${roleId}'...`, 'info');
const res = await executeAction('assign_role', {
role_id: roleId,
profile_id: profileId,
is_primary: isPrimary,
});
if (res.ok) {
showToast(`Аккаунт '${profileId}' успешно назначен`, 'success');
fetchSnapshot();
} else {
showToast(res.message || 'Ошибка назначения аккаунта', 'error');
}
}
async function handleNodeModelChange(roleId, profileId, newModel) {
if (!newModel) return;
showToast(`Сохранение модели '${newModel}' для ${profileId}...`, 'info');

View file

@ -111,9 +111,14 @@
</div>
<div class="section-card">
<div class="section-card-header">
<div class="section-card-title">Схема маршрутизации запросов</div>
<div class="section-card-subtitle">Распределение агентов и цепочки отказоустойчивости</div>
<div class="section-card-header" style="display:flex; justify-content:space-between; align-items:center;">
<div>
<div class="section-card-title">Схема маршрутизации запросов</div>
<div class="section-card-subtitle">Распределение агентов и цепочки отказоустойчивости</div>
</div>
<button class="btn btn-secondary btn-sm" id="btn-auto-assign" title="Автоматическое распределение аккаунтов по ролям">
Авто-распределение
</button>
</div>
<div class="route-diagram-container" id="overview-route-diagram">
<!-- Rendered by app.js -->
@ -329,6 +334,33 @@
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Порог остатка квоты</div>
<div class="setting-desc">Уровень остатка квоты аккаунта, при котором срабатывает действие</div>
</div>
<div class="setting-control">
<select id="setting-quota-threshold-percent" class="select-filter">
<option value="5">5%</option>
<option value="10" selected>10% (по умолчанию)</option>
<option value="15">15%</option>
<option value="20">20%</option>
<option value="25">25%</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Действие при низком остатке квоты</div>
<div class="setting-desc">Оповещение в журнале и готовности или автоматическое переключение на резервный аккаунт</div>
</div>
<div class="setting-control">
<select id="setting-quota-threshold-action" class="select-filter">
<option value="notify" selected>Оповещение (notify)</option>
<option value="switch">Переключение на резерв (switch)</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Тема оформления</div>

View file

@ -449,6 +449,38 @@ body {
color: var(--text-muted);
}
.accounts-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
background-color: var(--surface);
border: 1px dashed var(--border);
border-radius: var(--radius-md);
margin: 16px 0;
}
.accounts-empty-state .empty-state-icon {
font-size: 40px;
margin-bottom: 12px;
}
.accounts-empty-state h3 {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 6px;
}
.accounts-empty-state p {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 18px;
max-width: 440px;
}
.accounts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
@ -942,7 +974,8 @@ body {
}
.node-model-select,
.diagram-model-select {
.diagram-model-select,
.diagram-account-select {
flex: 1;
background-color: var(--bg-base);
border: 1px solid var(--border-subtle);
@ -956,8 +989,17 @@ body {
max-width: 100%;
}
.diagram-account-select {
margin-bottom: 4px;
width: 100%;
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
}
.node-model-select:focus,
.diagram-model-select:focus {
.diagram-model-select:focus,
.diagram-account-select:focus {
border-color: var(--accent);
}

View file

@ -0,0 +1,278 @@
"""Comprehensive tests for A26: Unlimited Provider Accounts, Overview Role Assignment,
Smart Auto-Distribution, and Configurable Quota Thresholds.
"""
from __future__ import annotations
import json
import time
from pathlib import Path
from typing import Any, Dict
import pytest
from antigravity_provider.router.action_handler import ActionExecutor, do_save_settings
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.health_tracker import (
HEALTHY,
QUOTA_EXHAUSTED,
HealthTracker,
)
from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.quota_collector import AccountQuotaService, QuotaBucket, QuotaSnapshot
from antigravity_provider.router.router_config import (
RolePolicy,
RouterConfig,
RouterProfileConfig,
load_router_config,
save_router_config,
)
from antigravity_provider.router.settings_service import (
get_hub_settings,
invalidate_settings_cache,
save_hub_settings,
)
from antigravity_provider.router.state_store import HubStateStore
from antigravity_provider.router.unified_health import EventLogService, UnifiedHealthService
@pytest.fixture(autouse=True)
def setup_test_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Set up isolated HERMES_HOME and clean caches for each test."""
hermes_home = tmp_path / "hermes"
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("HERMES_ROUTER_CONFIG", str(hermes_home / "router_profiles.yaml"))
invalidate_settings_cache()
# Reset singletons state where needed
AccountQuotaService.get()._snapshots.clear()
EventLogService.get()._events.clear()
return hermes_home
@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"})
# 4th slot should be generated dynamically (e.g. codex-4)
slot_4 = AutoAssigner.find_free_slot("openai-codex")
assert slot_4 == "codex-4"
# Profile should now be ensured in router config
cfg = load_router_config()
assert "codex-4" in cfg.profiles
assert cfg.profiles["codex-4"].provider == "openai-codex"
# Save auth for 4th slot, then 5th slot should be codex-5
ProfileAuthManager.save_profile_auth("openai-codex", "codex-4", {"api_key": "sk-4"})
slot_5 = AutoAssigner.find_free_slot("openai-codex")
assert slot_5 == "codex-5"
# Test OpenCode provider dynamic slots: opengo-1, 2, 3 authenticated -> opengo-4
ProfileAuthManager.save_profile_auth("opencode-go", "opengo-1", {"api_key": "op-1"})
ProfileAuthManager.save_profile_auth("opencode-go", "opengo-2", {"api_key": "op-2"})
ProfileAuthManager.save_profile_auth("opencode-go", "opengo-3", {"api_key": "op-3"})
slot_op_4 = AutoAssigner.find_free_slot("opencode-go")
assert slot_op_4 == "opengo-4"
cfg = load_router_config()
assert "opengo-4" in cfg.profiles
@pytest.mark.unit
def test_single_account_assigned_to_all_six_roles_p0_3():
"""P0-3: A single account can be assigned as primary across all 6 canonical roles without error."""
ProfileAuthManager.save_profile_auth("antigravity", "ag-w1", {"tokens": {"access_token": "token-1"}})
canonical_roles = ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"]
for rname in canonical_roles:
ok, msg = AutoAssigner.assign_profile_to_role("ag-w1", rname, is_primary=True)
assert ok is True, f"Failed assigning to role {rname}: {msg}"
cfg = load_router_config()
for rname in canonical_roles:
assert rname in cfg.roles
assert cfg.roles[rname].preferred_chain[0] == "ag-w1"
# Verify action executor handles assign_role cleanly
res = ActionExecutor.execute("assign_role", {"role_id": "orchestrator", "profile_id": "ag-w1", "is_primary": True})
assert res.get("ok") is True
@pytest.mark.unit
def test_auto_assign_all_single_account_p0_4():
"""P0-4 (Scenario A): Exactly 1 connected account is assigned as primary to all 6 roles."""
ProfileAuthManager.save_profile_auth("antigravity", "ag-w1", {"tokens": {"access_token": "token-1"}})
result = AutoAssigner.auto_assign_all()
assert result["success"] is True
assert result["total_authenticated"] == 1
cfg = load_router_config()
canonical_roles = ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"]
for rname in canonical_roles:
assert cfg.roles[rname].preferred_chain == ["ag-w1"]
@pytest.mark.unit
def test_auto_assign_all_two_accounts_same_provider_p0_4():
"""P0-4 (Scenario B): 2 accounts of the same provider are rotated across 6 roles for quota balance."""
ProfileAuthManager.save_profile_auth("antigravity", "ag-w1", {"tokens": {"access_token": "token-1"}})
ProfileAuthManager.save_profile_auth("antigravity", "ag-w2", {"tokens": {"access_token": "token-2"}})
result = AutoAssigner.auto_assign_all()
assert result["success"] is True
assert result["total_authenticated"] == 2
cfg = load_router_config()
# Primary accounts should alternate between ag-w1 and ag-w2
primaries = [cfg.roles[r].preferred_chain[0] for r in ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"]]
assert "ag-w1" in primaries
assert "ag-w2" in primaries
# Fallback chains should contain the alternate account
for r in ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"]:
chain = cfg.roles[r].preferred_chain
assert len(chain) == 2
assert set(chain) == {"ag-w1", "ag-w2"}
@pytest.mark.unit
def test_auto_assign_all_multi_provider_preferences_p0_4():
"""P0-4 (Scenario C): Multiple providers are distributed according to canonical role preferences."""
ProfileAuthManager.save_profile_auth("openai-codex", "codex-orch", {"api_key": "sk-codex"})
ProfileAuthManager.save_profile_auth("antigravity", "ag-w1", {"tokens": {"access_token": "token-ag"}})
ProfileAuthManager.save_profile_auth("opencode-go", "opengo-1", {"api_key": "sk-opencode"})
result = AutoAssigner.auto_assign_all()
assert result["success"] is True
assert result["total_authenticated"] == 3
cfg = load_router_config()
# Orchestrator prefers codex
assert cfg.roles["orchestrator"].preferred_chain[0] == "codex-orch"
# Research / fast prefers opencode
assert cfg.roles["research"].preferred_chain[0] == "opengo-1"
assert cfg.roles["fast"].preferred_chain[0] == "opengo-1"
@pytest.mark.unit
def test_quota_threshold_notify_action_p0_5(tmp_path: Path):
"""P0-5: When remaining <= threshold and action is 'notify', warnings are logged and profile remains healthy."""
save_hub_settings({
"quota_threshold_percent": 10.0,
"quota_threshold_action": "notify",
})
health_file = tmp_path / "hermes" / "router_state.json"
tracker = HealthTracker(state_file=health_file)
# Measured quota is 8% (below 10% threshold)
tracker.reconcile_measured_quota("codex-orch", {"gpt": 8.0})
# Action is notify: profile should NOT be quota-exhausted
assert tracker.is_healthy("codex-orch", model_name="gpt-4o") is True
# Event log should contain warning
events = EventLogService.get().get_events(limit=10)
warning_events = [e for e in events if e.level == "warning" and "порог" in e.message.lower()]
assert len(warning_events) >= 1
@pytest.mark.unit
def test_quota_threshold_switch_action_and_auto_recovery_p0_5(tmp_path: Path):
"""P0-5: When remaining <= threshold and action is 'switch', transitions to QUOTA_EXHAUSTED, and recovers automatically when quota restores."""
save_hub_settings({
"quota_threshold_percent": 10.0,
"quota_threshold_action": "switch",
})
health_file = tmp_path / "hermes" / "router_state.json"
tracker = HealthTracker(state_file=health_file)
# 1. Low quota (5% <= 10%) -> switch to exhausted
tracker.reconcile_measured_quota("codex-orch", {"gpt": 5.0})
assert tracker.is_healthy("codex-orch", model_name="gpt-4o") is False
rec = tracker.get_or_create("codex-orch")
assert rec.overall_state == QUOTA_EXHAUSTED or rec.families.get("gpt", {}).state == QUOTA_EXHAUSTED
# 2. Quota recovers (80% > 10%) -> automatic recovery without restart
tracker.reconcile_measured_quota("codex-orch", {"gpt": 80.0})
assert tracker.is_healthy("codex-orch", model_name="gpt-4o") is True
rec = tracker.get_or_create("codex-orch")
assert rec.overall_state == HEALTHY
assert rec.families.get("gpt", {}).state == HEALTHY
@pytest.mark.unit
def test_quota_threshold_zero_fake_p0_5(tmp_path: Path):
"""P0-5: If quota is unknown (None / N/A), threshold does NOT trigger."""
save_hub_settings({
"quota_threshold_percent": 10.0,
"quota_threshold_action": "switch",
})
health_file = tmp_path / "hermes" / "router_state.json"
tracker = HealthTracker(state_file=health_file)
# Reconcile empty/None measurements
tracker.reconcile_measured_quota("codex-orch", {})
assert tracker.is_healthy("codex-orch", model_name="gpt-4o") is True
@pytest.mark.unit
def test_instant_settings_and_state_cache_update_p0_5():
"""P0-5: Saving settings updates cache and state store instantly without restart."""
res, msg = do_save_settings({
"quota_threshold_percent": 15.0,
"quota_threshold_action": "switch",
"monitoring_interval_seconds": 45,
})
assert res is True
settings = get_hub_settings()
assert settings["quota_threshold_percent"] == 15.0
assert settings["quota_threshold_action"] == "switch"
assert settings["monitoring_interval_seconds"] == 45
# Check router config sync
cfg = load_router_config()
assert cfg.quota_threshold_percent == 15.0
assert cfg.quota_threshold_action == "switch"
@pytest.mark.unit
def test_web_static_contracts_and_empty_state_p0_2_p0_3_p0_5():
"""Verify web client contract in app.js, style.css and index.html."""
app_js_path = Path("src/antigravity_provider/router/web/static/app.js")
style_css_path = Path("src/antigravity_provider/router/web/static/style.css")
index_html_path = Path("src/antigravity_provider/router/web/static/index.html")
assert app_js_path.exists()
assert style_css_path.exists()
assert index_html_path.exists()
app_js = app_js_path.read_text(encoding="utf-8")
style_css = style_css_path.read_text(encoding="utf-8")
index_html = index_html_path.read_text(encoding="utf-8")
# P0-2: Accounts empty state and connected-only filter
assert "accounts-empty-state" in app_js
assert "accounts-empty-state" in style_css
assert "Нет подключённых аккаунтов" in app_js
# P0-3: Overview diagram account selector
assert "diagram-account-select" in app_js
assert "diagram-account-select" in style_css
assert "handleNodeAccountChange" in app_js
# P0-5: Quota threshold settings
assert "setting-quota-threshold-percent" in index_html
assert "setting-quota-threshold-action" in index_html
assert "quota_threshold_percent" in app_js
assert "quota_threshold_action" in app_js

View file

@ -82,21 +82,24 @@ def test_auto_assigner_find_free_slot_for_all_five_providers(clean_env):
assert slot in config.profiles
assert config.profiles[slot].provider == provider
# Test recommendation when all slots are filled
# Test allocation when all default slots are filled (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")
# Simulate fake auth on all claude slots
# 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"})
# Now claude has no free slots -> find_free_slot must return None, NOT a non-existent candidate!
assert AutoAssigner.find_free_slot("claude") is None
# Now all default claude slots are taken -> find_free_slot dynamically generates claude-worker-3
next_slot = AutoAssigner.find_free_slot("claude")
assert next_slot == "claude-worker-3"
cfg = load_router_config()
assert "claude-worker-3" in cfg.profiles
assert cfg.profiles["claude-worker-3"].provider == "claude"
rec_slot, title, reason = AutoAssigner.recommend_assignment("claude")
assert rec_slot == ""
assert "Нет свободных слотов" in title
assert rec_slot == "claude-worker-3"
@pytest.mark.unit