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:
commit
a1e1db75bf
13 changed files with 789 additions and 66 deletions
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
278
tests/test_accounts_distribution_and_thresholds_a26.py
Normal file
278
tests/test_accounts_distribution_and_thresholds_a26.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue