Merge codex/usability-fixes into review/a8 (A8 + B6/B7)

Сводит две работы: A8 (Antigravity — запуск, развёртывание, самопроверка)
и codex/usability-fixes (B6 граф маршрутизации + B7 дефекты живого прогона,
плюс живой сбор квот).

Проверено исполнением на реальных аккаунтах владельца: квоты Antigravity
теперь приходят от провайдера (source=provider_api) по всем шести
авторизованным профилям с разными числами — ag-w2 показывает 37.4%
остатка недельного пула Claude/GPT. OpenCode Go честно отдаёт None
с причиной.

Разрешение конфликтов:

1. do_test_profile — оба агента чинили P0-3 по-разному. Сохранены обе
   правки: проверка просроченной авторизации (A8) поверх локальной
   проверки runtime без вызова модели (Codex).

2. _finish в мастере — взята содержательная версия Codex (проверка слота,
   создание определения профиля, внесение в маршрутизацию, сброс
   cooldown), но её хвост обёрнут так, чтобы сбой в журналировании или
   on_complete не оставлял окно открытым. Регрессия 7090c8a закрыта
   тестом и продолжает проходить.

Исправлено при слиянии: A8 проверял status.get("expired"), тогда как ключ
называется is_expired. Проверка была мертва изначально — её прикрывал
контроль в адаптере, и это вскрылось только когда Codex убрал вызов
адаптера из «Теста»: протухший аккаунт получал зелёную галочку.

Тесты: 288 passed, 2 skipped, ruff чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-08-23 01:12:31 +07:00
commit b625b0f3e5
21 changed files with 2732 additions and 361 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

View file

@ -265,12 +265,14 @@ class QuotaSnapshot:
return f"Обновлено: {hrs} ч назад"
def get_bucket_for_model(self, model_or_family: str) -> Optional[QuotaBucket]:
"""Find the relevant quota bucket for a given model or model family."""
"""Find the most constraining quota bucket for a model family."""
target = model_or_family.lower()
# Direct family match
for b in self.buckets:
if b.model_family and b.model_family.lower() in target:
return b
matches = [b for b in self.buckets if b.model_family and b.model_family.lower() in target]
if matches:
measured = [b for b in matches if b.remaining_percent is not None]
if measured:
return min(measured, key=lambda bucket: float(bucket.remaining_percent or 0.0))
return matches[0]
# Fallback to first available bucket
return self.buckets[0] if self.buckets else None

View file

@ -172,6 +172,48 @@ class AutoAssigner:
return None
@staticmethod
def ensure_profile_definition(provider: str, profile_id: str) -> Tuple[bool, str]:
"""Persist a router profile for provider slots introduced by the UI.
Claude and Grok were added after the original static router profile
list. Their OAuth credentials could therefore be saved successfully
while role assignment failed because the profile did not exist in the
router YAML.
"""
config = load_router_config()
if profile_id in config.profiles:
return True, "Профиль уже зарегистрирован"
defaults = {
"grok": (["grok-3", "grok-3-mini", "grok-2"], ["reasoning", "coding", "research"]),
"claude": (
["claude-sonnet-4-6", "claude-3-7-sonnet", "claude-3-5-haiku"],
["reasoning", "coding", "review"],
),
"opencode-go": (
["qwen3.8-max", "kimi-k2.7-code", "deepseek-v3"],
["coding", "research", "fast"],
),
"openai-codex": (["gpt-4o", "o3-mini", "codex"], ["coding", "reasoning"]),
"antigravity": (
["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"],
["coding", "reasoning", "research", "fast"],
),
}
models, capabilities = defaults.get(provider, (["default"], []))
config.profiles[profile_id] = RouterProfileConfig(
profile_id=profile_id,
provider=provider,
account_id=profile_id,
capabilities=list(capabilities),
preferred_models=list(models),
enabled=True,
max_concurrency=1,
)
if not save_router_config(config):
return False, f"Не удалось сохранить профиль '{profile_id}' в конфигурации"
return True, f"Профиль '{profile_id}' зарегистрирован"
@staticmethod
def recommend_assignment(provider: str) -> Tuple[str, str, str]:
"""Analyze team health and return (recommended_slot, role_title_ru, reason_ru)."""

View file

@ -273,6 +273,36 @@ 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.
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()}
if not measured:
return False
with self._lock:
record = self.get_or_create(profile_id)
changed = False
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:
record.overall_state = HEALTHY
record.last_error = None
record.simulated = False
changed = True
if changed:
self._save_state()
return changed
def mark_quota_exhausted(
self,
profile_id: str,

View file

@ -42,7 +42,7 @@ for _p in [_PLUGIN_SRC, _AGENT_DIR, Path(__file__).resolve().parent.parent.paren
if _p.exists() and _ps not in sys.path:
sys.path.insert(0, _ps)
from antigravity_provider.router.router_config import load_router_config
from antigravity_provider.router.router_config import load_router_config, save_router_config
from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.adapters import get_adapter
@ -61,7 +61,7 @@ from antigravity_provider.router.unified_health import (
STATUS_HEALTHY,
)
from antigravity_provider.router.ui.views.team_view import TeamView
from antigravity_provider.router.ui.views.team_view import TeamView, persist_role_chain
from antigravity_provider.router.ui.views.dashboard_view import DashboardView
from antigravity_provider.router.ui.views.accounts_view import AccountsView
from antigravity_provider.router.ui.views.providers_view import ProvidersView
@ -75,6 +75,24 @@ from antigravity_provider.router.ui.views.quotas_view import QuotasView
logger = logging.getLogger("hermes.hub.gui")
AGENT_MODEL_OPTIONS = {
"antigravity": [
# User-facing logical IDs. The provider layer maps Gemini 3.1 Pro
# to the current low/high wire variants according to reasoning effort.
"gemini-3.1-pro",
"gemini-2.5-pro",
"gemini-3.7-flash",
"gemini-3.6-flash-high",
"gemini-3.5-flash",
"claude-sonnet-4-6",
"claude-opus-4-6-thinking",
],
"openai-codex": ["gpt-4o", "o3-mini", "codex"],
"opencode-go": ["qwen3.8-max", "kimi-k2.7-code", "deepseek-v3"],
"claude": ["claude-sonnet-4-6", "claude-3-7-sonnet", "claude-3-5-haiku"],
"grok": ["grok-3", "grok-3-mini", "grok-2"],
}
def _load_saved_theme() -> str:
settings_file = paths.get_hermes_home() / "hub_settings.json"
@ -109,7 +127,7 @@ def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]:
def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
"""Strictly tests stored credentials WITHOUT triggering OAuth or opening browsers."""
"""Check local profile readiness without inference, OAuth, or a browser."""
config = load_router_config()
pcfg = config.get_profile(profile_id)
if not pcfg:
@ -119,25 +137,36 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
if not status.get("authenticated"):
return {"success": False, "error": "Аккаунт не добавлен. Сначала выполните подключение."}
if status.get("expired"):
# Ключ называется is_expired; часть провайдеров сообщает о просрочке только
# через status == "EXPIRED". Проверяем все формы: зелёная галочка на
# протухшем аккаунте — это ложь пользователю, а не мелкая неточность.
if status.get("is_expired") or status.get("expired") or status.get("status") == "EXPIRED":
return {"success": False, "error": "Авторизация истекла, требуется повторный вход."}
adapter = get_adapter(pcfg.provider)
model = pcfg.preferred_models[0] if pcfg.preferred_models else "default"
t0 = time.time()
try:
resp = adapter.invoke(
pcfg,
{
"model": model,
"messages": [{"role": "user", "content": f"Respond strictly with: TEST_OK_FOR_{profile_id}"}],
"temperature": 0.1,
},
)
auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id)
if not auth_data:
return {"success": False, "error": "Сохранённые данные авторизации не найдены"}
adapter = get_adapter(pcfg.provider)
runtime_ready = adapter.health_check(pcfg)
el = round(time.time() - t0, 2)
content = resp.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
EventLogService.get().log("system", f"Тест {profile_id} ({model}) успешно пройден за {el}s.", level="success")
return {"success": True, "model": model, "duration_sec": el, "response": content[:120]}
if not runtime_ready:
return {
"success": False,
"duration_sec": el,
"error": "Локальный runtime провайдера недоступен; повторная авторизация не запускалась",
}
EventLogService.get().log(
"system", f"Локальная проверка профиля {profile_id} ({model}) пройдена за {el}s.", level="success"
)
return {
"success": True,
"model": model,
"duration_sec": el,
"response": "Авторизация сохранена; runtime провайдера доступен",
}
except Exception as e:
EventLogService.get().log("system", f"Ошибка теста {profile_id} ({model}): {e}", level="error")
return {"success": False, "model": model, "duration_sec": round(time.time() - t0, 2), "error": str(e)}
@ -226,6 +255,18 @@ class HermesHubApp(ctk.CTk):
pass
self.after(50, self._refresh_data)
self.after(800, self._refresh_quotas_on_startup)
def _refresh_quotas_on_startup(self) -> None:
"""Populate measured quota cards immediately instead of after the 5-minute scheduler tick."""
if self._shutting_down:
return
try:
from antigravity_provider.router.scheduler import HermesRefreshScheduler
HermesRefreshScheduler.get().trigger_refresh_all(on_complete=lambda: self.after(0, self._refresh_data))
except Exception as exc:
logger.warning("Initial quota refresh could not start: %s", exc)
def _build_layout(self):
# ── Sidebar (Left) ──
@ -258,8 +299,8 @@ class HermesHubApp(ctk.CTk):
("team", "Команда", "team"),
("accounts", "Аккаунты", "accounts"),
("routing", "Маршрутизация", "routing"),
("providers", "Провайдеры", "providers"),
("quotas", "Квоты и лимиты", "quotas"),
("providers", "Модели и провайдеры", "providers"),
("quotas", "Сводка лимитов", "quotas"),
("analytics", "Аналитика", "analytics"),
("health", "Состояние", "health"),
("logs", "Журнал событий", "logs"),
@ -341,6 +382,8 @@ class HermesHubApp(ctk.CTk):
text_color=Theme.STATUS_HEALTHY,
)
self.status_left.pack(side="left", padx=Theme.SPACE_LG)
self.status_left.configure(cursor="hand2")
self.status_left.bind("<Button-1>", lambda _event: self._show_view("health"), add="+")
self.global_search = ctk.CTkEntry(
self.statusbar,
@ -545,7 +588,7 @@ class HermesHubApp(ctk.CTk):
freshness = "⚠ Данные устарели" if snap.is_stale else f"Snapshot #{snap.seq}"
self.status_left.configure(
text=f"{readiness.title_ru}",
text=f"{readiness.title_ru}{' · Подробнее' if readiness.state != 'healthy' else ''}",
text_color=Theme.STATUS_HEALTHY
if readiness.state == "healthy"
else Theme.STATUS_WARNING
@ -590,28 +633,33 @@ class HermesHubApp(ctk.CTk):
if action == "set_main":
self._run_in_thread(
lambda: do_set_main(prov, pid),
on_success=lambda r: self._show_toast(f"{r[1]}" if r[0] else f"{r[1]}"),
on_success=lambda r: self._show_account_action_result(pid, r[1], r[0]),
)
elif action == "set_orchestrator":
self._run_in_thread(
lambda: do_set_orchestrator(pid),
on_success=lambda r: self._show_toast(f"{r[1]}" if r[0] else f"{r[1]}"),
on_success=lambda r: self._show_account_action_result(pid, r[1], r[0]),
)
elif action == "test":
self._show_toast(f"Тестирование {data.get('display_name', pid)}...")
self._show_account_action_result(pid, f"Тестирование {data.get('display_name', pid)}", None)
self._run_in_thread(
lambda: do_test_profile(prov, pid),
on_success=self._show_test_result,
on_success=lambda result: self._show_test_result(result, pid),
)
elif action == "oauth" or action == "add_account":
self._open_add_account_wizard()
elif action == "delete_credentials":
self._run_in_thread(
lambda: do_delete_credentials(prov, pid),
on_success=lambda r: self._show_toast(f"{r[1]}" if r[0] else f"{r[1]}"),
on_success=lambda r: self._show_account_action_result(pid, r[1], r[0]),
)
elif action == "assign_role":
self._open_assign_role_modal(pid, data.get("display_name", pid))
elif action == "agent_settings":
self._open_agent_settings_modal(
data.get("role_id", ""),
pid,
)
elif action == "auto_assign_all":
self._show_toast("⚡ Автоматическое распределение ролей...")
self._run_in_thread(
@ -633,7 +681,13 @@ class HermesHubApp(ctk.CTk):
on_complete=lambda: self.after(0, self._refresh_data),
)
elif action == "edit_route":
self._show_toast("Редактор цепочки использует кнопки и селекторы; drag-and-drop отключён.")
role_id = data.get("role_id", "")
self._open_route_editor_modal(role_id)
elif action == "open_routing":
self._show_view("routing")
routing = self._views.get("routing")
if routing and hasattr(routing, "focus_role"):
routing.focus_role(data.get("role_id", ""))
elif action == "save_settings":
def _settings_saved(result: Tuple[bool, str]) -> None:
@ -670,14 +724,19 @@ class HermesHubApp(ctk.CTk):
justify="left",
).pack(anchor="w", pady=(0, 12))
role_var = ctk.StringVar(value="orchestrator")
config = load_router_config()
current_role = next(
(role_id for role_id, policy in config.roles.items() if profile_id in policy.preferred_chain),
"orchestrator",
)
role_var = ctk.StringVar(value=current_role)
roles = [
("orchestrator", "👑 Главный оркестратор"),
("coder", "💻 Кодер (Code Generation)"),
("coder-primary", "💻 Основной кодер"),
("coder-secondary", "💻 Резервный кодер"),
("reviewer", "🔍 Ревьюер (Code Review)"),
("researcher", "🌐 Исследователь (Search / Docs)"),
("tester", "🧪 Тестировщик (Deterministic Tests)"),
("general", "⚡ Агент общего назначения (Subagent)"),
("research", "🌐 Исследователь (Search / Docs)"),
("fast", "⚡ Быстрый агент"),
("spare", "🛡️ Резерв (Spare)"),
]
@ -693,15 +752,297 @@ class HermesHubApp(ctk.CTk):
hover_color=Theme.ACCENT_HOVER,
).pack(anchor="w", padx=8, pady=3)
primary_var = ctk.BooleanVar(value=True)
ctk.CTkCheckBox(
modal.body,
text="Сделать основным в выбранной цепочке",
variable=primary_var,
font=Theme.font_body(),
text_color=Theme.TEXT_PRIMARY,
fg_color=Theme.ACCENT,
hover_color=Theme.ACCENT_HOVER,
).pack(anchor="w", padx=8, pady=(12, 3))
result_label = ctk.CTkLabel(
modal.body,
text="",
font=Theme.font_body_bold(),
text_color=Theme.TEXT_SECONDARY,
wraplength=440,
justify="left",
anchor="w",
)
result_label.pack(fill="x", padx=8, pady=(8, 0))
modal.result_label = result_label
def _save():
chosen = role_var.get()
ok, msg = AutoAssigner.assign_profile_to_role(profile_id, chosen, is_primary=(chosen != "spare"))
modal.destroy()
self._show_toast(f"{msg}" if ok else f"{msg}")
self._refresh_data()
ok, msg = AutoAssigner.assign_profile_to_role(
profile_id,
chosen,
is_primary=primary_var.get() and chosen != "spare",
)
result_label.configure(
text=f"{'' if ok else ''} {msg}",
text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR,
)
self._show_account_action_result(profile_id, msg, ok)
if ok:
save_button.configure(text="Готово", command=modal.destroy)
self._refresh_data()
HubButton(modal.footer, text="Отмена", variant="secondary", width=100, command=modal.destroy).pack(side="left")
HubButton(modal.footer, text="Применить роль", variant="primary", width=160, command=_save).pack(side="right")
save_button = HubButton(
modal.footer, text="Применить роль", variant="primary", width=160, command=_save
)
save_button.pack(side="right")
modal.save_button = save_button
return modal
def _open_route_editor_modal(self, role_id: str):
"""Direct, visible editor for one ordered failover chain."""
config = load_router_config()
policy = config.roles.get(role_id)
if policy is None:
self._show_toast(f"❌ Роль '{role_id}' не найдена")
return None
modal = HubModal(self, title=f"Цепочка маршрутизации: {role_id}", width=620, height=520)
ctk.CTkLabel(
modal.body,
text="Первый профиль — основной. Ниже идут резервы в порядке переключения.",
font=Theme.font_body(),
text_color=Theme.TEXT_SECONDARY,
).pack(anchor="w", pady=(0, 10))
chain = list(policy.preferred_chain)
rows = ctk.CTkFrame(modal.body, fg_color="transparent")
rows.pack(fill="both", expand=True)
result = ctk.CTkLabel(modal.body, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
result.pack(fill="x", pady=(6, 0))
def _render() -> None:
for child in rows.winfo_children():
child.destroy()
for index, pid in enumerate(chain):
pcfg = load_router_config().profiles.get(pid)
row = ctk.CTkFrame(rows, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
row.pack(fill="x", pady=3)
ctk.CTkLabel(
row,
text=f"{index + 1}. {pid}",
font=Theme.font_body_bold(),
text_color=Theme.TEXT_PRIMARY,
).pack(side="left", padx=10, pady=8)
ctk.CTkLabel(
row,
text=(pcfg.provider if pcfg else "профиль не найден"),
font=Theme.font_caption(),
text_color=Theme.TEXT_MUTED,
).pack(side="left", padx=6)
def _move(delta: int, current: int = index) -> None:
target = current + delta
if 0 <= target < len(chain):
chain[current], chain[target] = chain[target], chain[current]
_render()
def _remove(current: int = index) -> None:
chain.pop(current)
_render()
HubButton(row, text="Удалить", variant="ghost", width=70, command=_remove).pack(
side="right", padx=(2, 8), pady=5
)
HubButton(row, text="", variant="secondary", width=34, command=lambda i=index: _move(1, i)).pack(
side="right", padx=2, pady=5
)
HubButton(row, text="", variant="secondary", width=34, command=lambda i=index: _move(-1, i)).pack(
side="right", padx=2, pady=5
)
add_row = ctk.CTkFrame(modal.body, fg_color="transparent")
add_row.pack(fill="x", pady=(8, 0))
# Keep every profile in the selector so a just-removed item can be
# re-added immediately without closing and reopening the editor.
available = list(config.profiles)
add_var = ctk.StringVar(value=available[0] if available else "Нет доступных профилей")
add_menu = ctk.CTkOptionMenu(
add_row,
values=available or ["Нет доступных профилей"],
variable=add_var,
fg_color=Theme.SURFACE_MUTED,
button_color=Theme.ACCENT,
text_color=Theme.TEXT_PRIMARY,
)
add_menu.pack(side="left", fill="x", expand=True)
def _add() -> None:
pid = add_var.get()
if pid in config.profiles and pid not in chain:
chain.append(pid)
_render()
HubButton(add_row, text="+ Добавить в цепочку", variant="secondary", command=_add).pack(side="right", padx=(8, 0))
def _save_chain() -> None:
ok, message = persist_role_chain(role_id, chain)
result.configure(
text=f"{'' if ok else ''} {message}",
text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR,
)
if ok:
save_button.configure(text="Готово", command=modal.destroy)
self._refresh_data()
_render()
HubButton(modal.footer, text="Отмена", variant="secondary", command=modal.destroy).pack(side="left")
save_button = HubButton(modal.footer, text="Сохранить цепочку", variant="primary", command=_save_chain)
save_button.pack(side="right")
return modal
def _open_agent_settings_modal(self, role_id: str, profile_id: str):
"""Open practical role settings from a click anywhere on an agent card."""
from antigravity_provider.router.quota_collector import AccountQuotaService
config = load_router_config()
role = config.roles.get(role_id)
role_labels = {
"orchestrator": "Главный оркестратор",
"coder-primary": "Кодер 1",
"coder-secondary": "Кодер 2",
"reviewer": "Ревьюер",
"research": "Исследователь",
"fast": "Быстрый агент",
}
modal = HubModal(self, title=f"Настройки агента: {role_labels.get(role_id, role_id)}", width=560, height=540)
ctk.CTkLabel(
modal.body,
text="Аккаунт, модель и реальные лимиты агента",
font=Theme.font_heading(),
text_color=Theme.TEXT_PRIMARY,
).pack(anchor="w", pady=(0, 12))
choices: dict[str, str] = {}
for pid, pcfg in config.profiles.items():
status = ProfileAuthManager.get_profile_status(pcfg.provider, pid)
if not status.get("authenticated"):
continue
identity = AccountQuotaService.get().get_identity(pcfg.provider, pid).primary_identifier()
label = f"{pid}{identity}"
choices[label] = pid
if not choices:
ctk.CTkLabel(
modal.body,
text="Нет подключённых аккаунтов. Сначала добавьте аккаунт.",
text_color=Theme.STATUS_WARNING,
).pack(anchor="w")
HubButton(modal.footer, text="Закрыть", variant="secondary", command=modal.destroy).pack(side="right")
return modal
selected_label = next((label for label, pid in choices.items() if pid == profile_id), next(iter(choices)))
account_var = ctk.StringVar(value=selected_label)
model_var = ctk.StringVar(value="")
ctk.CTkLabel(modal.body, text="Аккаунт", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(
anchor="w"
)
account_menu = ctk.CTkOptionMenu(
modal.body,
values=list(choices),
variable=account_var,
fg_color=Theme.SURFACE_MUTED,
button_color=Theme.ACCENT,
button_hover_color=Theme.ACCENT_HOVER,
text_color=Theme.TEXT_PRIMARY,
)
account_menu.pack(fill="x", pady=(3, 10))
ctk.CTkLabel(modal.body, text="Модель агента", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(
anchor="w"
)
model_menu = ctk.CTkOptionMenu(
modal.body,
values=["default"],
variable=model_var,
fg_color=Theme.SURFACE_MUTED,
button_color=Theme.ACCENT,
button_hover_color=Theme.ACCENT_HOVER,
text_color=Theme.TEXT_PRIMARY,
)
model_menu.pack(fill="x", pady=(3, 10))
quota_card = ctk.CTkFrame(modal.body, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_MD)
quota_card.pack(fill="both", expand=True, pady=(2, 10))
quota_title = ctk.CTkLabel(
quota_card, text="Реальные лимиты", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
)
quota_title.pack(anchor="w", padx=12, pady=(10, 5))
quota_rows = ctk.CTkFrame(quota_card, fg_color="transparent")
quota_rows.pack(fill="both", expand=True, padx=12, pady=(0, 8))
def _refresh_account_panel(_choice: Optional[str] = None) -> None:
current_pid = choices[account_var.get()]
current_cfg = load_router_config().profiles[current_pid]
available = list(
dict.fromkeys(current_cfg.preferred_models + AGENT_MODEL_OPTIONS.get(current_cfg.provider, []))
)
if not available:
available = ["default"]
model_menu.configure(values=available)
current_model = role.default_model if role and role.default_model in available else available[0]
model_var.set(current_model)
for child in quota_rows.winfo_children():
child.destroy()
snapshot = AccountQuotaService.get().get_snapshot(current_cfg.provider, current_pid)
buckets = list(snapshot.buckets) if snapshot else []
quota_title.configure(text=f"Реальные лимиты • {current_cfg.provider}")
if not buckets:
ctk.CTkLabel(
quota_rows, text="Лимиты пока не получены", text_color=Theme.TEXT_MUTED, font=Theme.font_caption()
).pack(anchor="w", pady=5)
for bucket in buckets[:6]:
row = ctk.CTkFrame(quota_rows, fg_color="transparent")
row.pack(fill="x", pady=3)
ctk.CTkLabel(
row, text=bucket.display_name, font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY
).pack(side="left")
reset = bucket.formatted_reset() or "Сброс: Н"
ctk.CTkLabel(
row,
text=f"{bucket.formatted_remaining()}{reset}",
font=Theme.font_caption(),
text_color=Theme.STATUS_HEALTHY if bucket.status == "healthy" else Theme.STATUS_WARNING,
).pack(side="right")
account_menu.configure(command=_refresh_account_panel)
_refresh_account_panel()
result = ctk.CTkLabel(modal.body, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
result.pack(fill="x")
def _save_agent() -> None:
current_pid = choices[account_var.get()]
current_model = model_var.get()
updated = load_router_config()
profile = updated.profiles[current_pid]
profile.preferred_models = [current_model] + [m for m in profile.preferred_models if m != current_model]
updated.profiles[current_pid] = profile
if role_id in updated.roles:
updated.roles[role_id].default_model = current_model
if not save_router_config(updated):
result.configure(text="Не удалось сохранить модель", text_color=Theme.STATUS_ERROR)
return
ok, message = AutoAssigner.assign_profile_to_role(current_pid, role_id, is_primary=True)
result.configure(
text=f"{'' if ok else ''} {message}",
text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR,
)
if ok:
save_button.configure(text="Готово", command=modal.destroy)
self._refresh_data()
HubButton(modal.footer, text="Отмена", variant="secondary", command=modal.destroy).pack(side="left")
save_button = HubButton(modal.footer, text="Сохранить настройки", variant="primary", command=_save_agent)
save_button.pack(side="right")
return modal
def _open_add_account_wizard(self):
wizard = AddAccountWizard(self, on_complete=self._on_wizard_complete)
@ -710,12 +1051,19 @@ class HermesHubApp(ctk.CTk):
self._show_toast(f"✅ Аккаунт {result.get('identity')} успешно подключён")
self._refresh_data()
def _show_test_result(self, result: Dict[str, Any]):
def _show_account_action_result(self, profile_id: str, message: str, success: Optional[bool]) -> None:
view = self._views.get("accounts")
if view and hasattr(view, "show_action_result"):
view.show_action_result(profile_id, message, success)
prefix = "" if success is True else "" if success is False else ""
self._show_toast(f"{prefix} {message}")
def _show_test_result(self, result: Dict[str, Any], profile_id: str = ""):
if result.get("success"):
msg = f"✓ Тест успешен | Модель: {result.get('model')} | Время: {result.get('duration_sec')}s"
msg = f"Профиль готов • модель: {result.get('model')} • время: {result.get('duration_sec')} с"
else:
msg = f"✕ Ошибка теста: {result.get('error', 'Неизвестная ошибка')}"
self._show_toast(msg)
msg = f"Ошибка теста: {result.get('error', 'Неизвестная ошибка')}"
self._show_account_action_result(profile_id, msg, bool(result.get("success")))
def _apply_theme(self, scheme: str) -> None:
"""Apply all palette tokens by rebuilding presentation widgets in place."""
@ -768,7 +1116,7 @@ class HermesHubApp(ctk.CTk):
readiness = HubStateStore.get().get_snapshot().readiness
self.status_left.configure(
text=f"{readiness.title_ru}",
text=f"{readiness.title_ru}{' · Подробнее' if readiness.state != 'healthy' else ''}",
text_color=Theme.STATUS_HEALTHY
if readiness.state == "healthy"
else Theme.STATUS_WARNING

View file

@ -15,6 +15,8 @@ import logging
import os
import threading
import time
import urllib.error
import urllib.request
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
@ -148,10 +150,31 @@ class AccountQuotaService:
except Exception as e:
logger.warning("Error fetching quota for %s/%s: %s", provider, profile_id, e)
snap = self._generate_baseline_snapshot(provider, profile_id)
status = getattr(e, "status", None)
if status == 401 or "401" in str(e) or "unauthenticated" in str(e).lower():
snap.unavailable_reason = "Авторизация истекла — обновите подключение"
else:
snap.unavailable_reason = "Провайдер не вернул данные лимитов"
with self._cache_lock:
self._snapshots[key] = snap
if snap.source == "provider_api":
measured_by_family: dict[str, float] = {}
for bucket in snap.buckets:
family = bucket.model_family
remaining = bucket.remaining_percent
if not family or remaining is None:
continue
measured_by_family[family] = min(measured_by_family.get(family, 100.0), float(remaining))
if measured_by_family:
try:
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)
# Notify listeners
for listener in list(self._listeners):
try:
@ -164,17 +187,14 @@ class AccountQuotaService:
def fetch_all_configured(self, force: bool = False) -> Dict[str, QuotaSnapshot]:
"""Fetch quota for all configured profiles across all providers."""
results: Dict[str, QuotaSnapshot] = {}
providers = ["antigravity", "openai-codex", "opencode-go", "claude", "grok"]
from antigravity_provider.router.router_config import load_router_config
for prov in providers:
# Check slots
from antigravity_provider.router.auto_assigner import AutoAssigner
slots = AutoAssigner.PRESET_SLOTS.get(prov, [])
for slot_id in slots:
auth = ProfileAuthManager.load_profile_auth(prov, slot_id)
if auth:
snap = self.fetch_account_quota(prov, slot_id, force=force)
results[f"{prov}:{slot_id}"] = snap
for profile_id, profile in load_router_config().profiles.items():
auth = ProfileAuthManager.load_profile_auth(profile.provider, profile_id)
if not auth:
continue
snap = self.fetch_account_quota(profile.provider, profile_id, force=force)
results[f"{profile.provider}:{profile_id}"] = snap
return results
# ─────────────────────────────────────────────────────────────
@ -313,60 +333,171 @@ class AccountQuotaService:
# ─────────────────────────────────────────────────────────────
def _collect_antigravity_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot:
"""Collect separate Claude (5h, Weekly) and Gemini (5h, Weekly) quota pools for Google Antigravity."""
now = _utc_now()
claude_reset_5h = now + timedelta(hours=5)
gemini_reset_5h = now + timedelta(hours=5)
weekly_reset = now + timedelta(days=7)
"""Read measured per-model capacity from the official Cloud Code endpoint.
# Build separate capacity buckets
b_claude_5h = QuotaBucket(
id="antigravity.claude.5h",
display_name="Claude 5h",
model_family="claude",
used_percent=None,
remaining_percent=None,
period="5h",
reset_at=claude_reset_5h,
status="unknown",
)
b_claude_weekly = QuotaBucket(
id="antigravity.claude.weekly",
display_name="Claude Weekly",
model_family="claude",
used_percent=None,
remaining_percent=None,
period="7d",
reset_at=weekly_reset,
status="unknown",
)
b_gemini_5h = QuotaBucket(
id="antigravity.gemini.5h",
display_name="Gemini 5h",
model_family="gemini",
used_percent=None,
remaining_percent=None,
period="5h",
reset_at=gemini_reset_5h,
status="unknown",
)
b_gemini_weekly = QuotaBucket(
id="antigravity.gemini.weekly",
display_name="Gemini Weekly",
model_family="gemini",
used_percent=None,
remaining_percent=None,
period="7d",
reset_at=weekly_reset,
status="unknown",
)
The endpoint exposes one live capacity pool per model, not artificial
``5h``/``weekly`` pairs. We therefore show the minimum remaining value
in each model family. This keeps the compact card useful while never
presenting an invented period or percentage.
"""
from antigravity_provider.cloudcode import antigravity_user_agent, load_or_onboard_project
from antigravity_provider.oauth import refresh_access_token
now = _utc_now()
token_data = auth_data.get("token") or auth_data.get("tokens") or auth_data
if not isinstance(token_data, dict):
raise RuntimeError("В профиле Antigravity отсутствует структура OAuth-токена")
access_token = token_data.get("access_token") or token_data.get("access")
if not access_token:
raise RuntimeError("В профиле Antigravity отсутствует access token")
refresh_token = token_data.get("refresh_token") or token_data.get("refresh")
def _refresh_and_save() -> str:
if not refresh_token:
raise RuntimeError("OAuth-сессия истекла, refresh token отсутствует")
refreshed = refresh_access_token(str(refresh_token))
token_data.update(refreshed)
auth_data["token"] = token_data
ProfileAuthManager.save_profile_auth("antigravity", profile_id, auth_data)
return str(refreshed["access_token"])
expiry = _parse_datetime(token_data.get("expires_at") or token_data.get("expiry"))
if expiry and expiry <= now + timedelta(seconds=60):
access_token = _refresh_and_save()
project_id = auth_data.get("project_id") or auth_data.get("projectId")
if not project_id:
try:
project_id = load_or_onboard_project(str(access_token))
except Exception as exc:
if (getattr(exc, "status", None) != 401 and "401" not in str(exc)) or not refresh_token:
raise
access_token = _refresh_and_save()
project_id = load_or_onboard_project(str(access_token))
auth_data["project_id"] = project_id
ProfileAuthManager.save_profile_auth("antigravity", profile_id, auth_data)
def _fetch(token: str, operation: str) -> dict[str, Any]:
request = urllib.request.Request(
f"https://daily-cloudcode-pa.googleapis.com/v1internal:{operation}",
data=json.dumps({"project": project_id}).encode("utf-8"),
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"User-Agent": antigravity_user_agent(),
},
method="POST",
)
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8") or "{}")
def _fetch_with_refresh(operation: str) -> dict[str, Any]:
nonlocal access_token
try:
return _fetch(str(access_token), operation)
except urllib.error.HTTPError as exc:
if exc.code != 401 or not refresh_token:
raise
access_token = _refresh_and_save()
return _fetch(access_token, operation)
# This is the endpoint used by the Antigravity usage screen. It
# exposes the four semantic buckets: Gemini and Claude/GPT, each with
# a five-hour and weekly window. Treat it as best-effort so older
# accounts can still fall back to per-model capacity below.
try:
summary_payload = _fetch_with_refresh("retrieveUserQuotaSummary")
except Exception as exc:
logger.info("Grouped Antigravity quota unavailable for %s: %s", profile_id, exc)
summary_payload = {}
summary_groups = summary_payload.get("groups") if isinstance(summary_payload, dict) else None
grouped: dict[tuple[str, str], QuotaBucket] = {}
if isinstance(summary_groups, list):
for group in summary_groups:
if not isinstance(group, dict):
continue
group_name = str(group.get("displayName") or group.get("description") or "").lower()
family = "gemini" if "gemini" in group_name else "claude" if "claude" in group_name else None
family_label = "Gemini" if family == "gemini" else "Claude/GPT"
if family is None:
continue
for bucket_data in group.get("buckets") or []:
if not isinstance(bucket_data, dict):
continue
remaining_fraction = bucket_data.get("remainingFraction")
window_raw = str(bucket_data.get("window") or "").lower()
window = "7d" if "week" in window_raw or window_raw == "7d" else "5h" if "5" in window_raw else ""
if not isinstance(remaining_fraction, (int, float)) or not window:
continue
window_label = "неделя" if window == "7d" else "5 часов"
grouped[(family, window)] = QuotaBucket(
id=f"antigravity.{family}.{window}",
display_name=f"{family_label}{window_label}",
model_family=family,
remaining_percent=max(0.0, min(100.0, float(remaining_fraction) * 100.0)),
reset_at=_parse_datetime(bucket_data.get("resetTime")),
period=window,
unit="model capacity",
scope="model_family",
)
ordered_keys = (("claude", "5h"), ("gemini", "5h"), ("claude", "7d"), ("gemini", "7d"))
if all(key in grouped for key in ordered_keys):
return QuotaSnapshot(
account_id=profile_id,
provider="antigravity",
buckets=[grouped[key] for key in ordered_keys],
fetched_at=now,
source="provider_api",
)
payload = _fetch_with_refresh("fetchAvailableModels")
models = payload.get("models") or {}
if not isinstance(models, dict):
raise RuntimeError("Cloud Code вернул некорректный список моделей")
family_values: dict[str, list[tuple[float, Optional[datetime]]]] = {"claude": [], "gemini": []}
for model_id, model_data in models.items():
if not isinstance(model_data, dict):
continue
lowered = str(model_id).lower()
family = "claude" if "claude" in lowered else ("gemini" if "gemini" in lowered else None)
quota_info = model_data.get("quotaInfo") or {}
remaining_fraction = quota_info.get("remainingFraction") if isinstance(quota_info, dict) else None
if family is None or not isinstance(remaining_fraction, (int, float)):
continue
reset_at = _parse_datetime(quota_info.get("resetTime"))
family_values[family].append((max(0.0, min(100.0, float(remaining_fraction) * 100.0)), reset_at))
buckets: list[QuotaBucket] = []
for family, display_name in (("claude", "Claude • модели"), ("gemini", "Gemini • модели")):
values = family_values[family]
if not values:
continue
remaining, reset_at = min(values, key=lambda item: item[0])
buckets.append(
QuotaBucket(
id=f"antigravity.{family}.model_pool",
display_name=display_name,
model_family=family,
remaining_percent=remaining,
reset_at=reset_at,
period="provider",
unit="model capacity",
scope="model_family",
)
)
if not buckets:
raise RuntimeError("Cloud Code не вернул измеряемые квоты Claude или Gemini")
return QuotaSnapshot(
account_id=profile_id,
provider="antigravity",
buckets=[b_claude_5h, b_claude_weekly, b_gemini_5h, b_gemini_weekly],
buckets=buckets,
fetched_at=now,
source="baseline",
source="provider_api",
)
def _collect_codex_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot:
@ -402,44 +533,85 @@ class AccountQuotaService:
)
def _collect_opencode_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot:
"""Collect Sliding, Weekly, and Monthly usage for OpenCode Go."""
"""Validate OpenCode Go entitlement and read usage when its API exposes it."""
now = _utc_now()
b_sliding = QuotaBucket(
id="opencode.sliding",
display_name="Скользящее",
model_family="opencode",
used_percent=None,
remaining_percent=None,
period="sliding",
status="unknown",
)
b_weekly = QuotaBucket(
id="opencode.weekly",
display_name="Недельное",
model_family="opencode",
used_percent=None,
remaining_percent=None,
period="7d",
reset_at=now + timedelta(days=7),
status="unknown",
)
b_monthly = QuotaBucket(
id="opencode.monthly",
display_name="Ежемесячное",
model_family="opencode",
used_percent=None,
remaining_percent=None,
period="30d",
reset_at=now + timedelta(days=30),
status="unknown",
api_key = auth_data.get("api_key")
if not api_key:
raise RuntimeError("Ключ OpenCode Go не сохранён")
base_url = "https://opencode.ai/zen/go/v1"
def _get(path: str) -> dict[str, Any]:
request = urllib.request.Request(
f"{base_url}{path}",
headers={
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
"User-Agent": "hermes-hub/1.0",
},
)
with urllib.request.urlopen(request, timeout=20) as response:
return json.loads(response.read().decode("utf-8") or "{}")
# /models is the documented read-only endpoint and confirms that the
# key is accepted without spending a request from the user's limit.
_get("/models")
usage: dict[str, Any] = {}
unavailable_reason: Optional[str] = None
try:
usage = _get("/usage")
except urllib.error.HTTPError as exc:
raw = exc.read().decode("utf-8", "replace")
if exc.code == 403 and "subscription required" in raw.lower():
unavailable_reason = "Для этого ключа не активна подписка OpenCode Go"
elif exc.code in (403, 404):
unavailable_reason = "OpenCode Go не предоставляет остаток через публичный API"
else:
raise
def _metric(*names: str) -> dict[str, Any]:
for name in names:
value = usage.get(name)
if isinstance(value, dict):
return value
return {}
buckets: list[QuotaBucket] = []
specs = (
("5h", "Лимит 5 часов", 12, _metric("five_hour", "fiveHour", "sliding")),
("7d", "Недельный лимит", 30, _metric("weekly", "seven_day", "sevenDay")),
("30d", "Месячный лимит", 60, _metric("monthly", "thirty_day", "thirtyDay")),
)
for period, label, limit_value, metric in specs:
remaining_percent = metric.get("remaining_percent", metric.get("remainingPercentage"))
remaining_absolute = metric.get("remaining", metric.get("remaining_amount"))
used_absolute = metric.get("used", metric.get("used_amount"))
reset_at = _parse_datetime(metric.get("reset_at") or metric.get("resetTime"))
buckets.append(
QuotaBucket(
id=f"opencode.{period}",
display_name=label,
model_family="opencode",
remaining_percent=float(remaining_percent) if isinstance(remaining_percent, (int, float)) else None,
used_absolute=int(used_absolute) if isinstance(used_absolute, (int, float)) else None,
remaining_absolute=(
int(remaining_absolute) if isinstance(remaining_absolute, (int, float)) else None
),
limit_absolute=limit_value,
reset_at=reset_at,
period=period,
unit="USD",
scope="account",
)
)
return QuotaSnapshot(
account_id=profile_id,
provider="opencode-go",
buckets=[b_sliding, b_weekly, b_monthly],
buckets=buckets,
fetched_at=now,
source="baseline",
source="provider_api",
unavailable_reason=unavailable_reason,
)
def _collect_claude_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot:

View file

@ -24,9 +24,23 @@ from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router.ui.components import HubButton, HubCard, HubEntry, HubModal
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.router_config import load_router_config
from antigravity_provider.router.unified_health import EventLogService
def ensure_profile_in_routing(profile_id: str) -> tuple[bool, str]:
"""Keep existing chain rank or route a newly introduced profile slot."""
config = load_router_config()
assigned_role = next(
(role_id for role_id, policy in config.roles.items() if profile_id in policy.preferred_chain),
"",
)
if assigned_role:
return True, f"Профиль уже входит в цепочку '{assigned_role}'"
_display_name, role_code, tier = AutoAssigner.get_display_name_and_role(profile_id)
return AutoAssigner.assign_profile_to_role(profile_id, role_code, is_primary=tier == "primary")
class AddAccountWizard(HubModal):
"""4-Step Add Account Wizard with OAuth / API Key support and Auto-Assignment."""
@ -198,9 +212,10 @@ class AddAccountWizard(HubModal):
self._clear_body()
self.title_lbl.configure(text="Шаг 2 из 4: Авторизация учетной записи")
self.target_slot = (
AutoAssigner.find_free_slot(self.selected_provider) or f"{self.selected_provider[:3]}-spare-1"
)
self.target_slot = AutoAssigner.find_free_slot(self.selected_provider) or ""
if not self.target_slot:
self._show_no_free_slot()
return
if self.selected_provider == "antigravity":
self._build_antigravity_oauth_flow()
@ -222,6 +237,36 @@ class AddAccountWizard(HubModal):
else:
self._build_api_key_flow()
def _show_no_free_slot(self) -> None:
"""Stop before OAuth when the configured provider has no real free slot."""
provider_labels = {
"antigravity": "Google Antigravity",
"openai-codex": "OpenAI Codex",
"opencode-go": "OpenCode Go",
"claude": "Claude",
"grok": "Grok",
}
card = HubCard(self.body, border_color=Theme.STATUS_WARNING, fg_color=Theme.SURFACE_MUTED)
card.pack(fill="x", pady=20)
ctk.CTkLabel(
card,
text="Нет свободного слота",
font=Theme.font_heading(),
text_color=Theme.STATUS_WARNING,
).pack(anchor="w", padx=16, pady=(14, 6))
ctk.CTkLabel(
card,
text=(
f"Все слоты провайдера {provider_labels.get(self.selected_provider, self.selected_provider)} заняты.\n"
"Освободите один слот или удалите неиспользуемый аккаунт, затем повторите подключение."
),
font=Theme.font_body(),
text_color=Theme.TEXT_PRIMARY,
justify="left",
wraplength=520,
).pack(anchor="w", padx=16, pady=(0, 14))
self._build_step_2_footer()
# ─────────────────────────────────────────────────────────────
# GOOGLE ANTIGRAVITY OAUTH FLOW
# ─────────────────────────────────────────────────────────────
@ -470,10 +515,10 @@ class AddAccountWizard(HubModal):
ctk.CTkLabel(
auth_card,
text="Ссылка для входа в OpenAI (ChatGPT):",
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
).pack(anchor="w", padx=10, pady=(6, 2))
text="1. Откройте ссылку — кнопка ниже откроет страницу OpenAI",
font=Theme.font_body_bold(),
text_color=Theme.TEXT_PRIMARY,
).pack(anchor="w", padx=10, pady=(8, 2))
url_row = ctk.CTkFrame(auth_card, fg_color="transparent")
url_row.pack(fill="x", padx=10, pady=(0, 6))
@ -490,9 +535,9 @@ class AddAccountWizard(HubModal):
HubButton(
url_row,
text="📋",
text="Копировать ссылку",
variant="secondary",
width=40,
width=130,
height=32,
command=self._copy_codex_url,
).pack(side="right")
@ -502,7 +547,7 @@ class AddAccountWizard(HubModal):
ctk.CTkLabel(
code_card,
text="Код подтверждения:",
text="2. Введите на странице код:",
font=Theme.font_body_bold(),
text_color=Theme.TEXT_PRIMARY,
).pack(side="left", padx=(0, 8))
@ -510,7 +555,7 @@ class AddAccountWizard(HubModal):
self.codex_code_lbl = ctk.CTkLabel(
code_card,
text="...",
font=Theme.font_mono_bold(),
font=("Consolas", 18, "bold"),
text_color=Theme.ACCENT,
)
self.codex_code_lbl.pack(side="left", padx=(0, 8))
@ -535,6 +580,13 @@ class AddAccountWizard(HubModal):
command=self._open_codex_browser,
).pack(side="left", padx=(0, 8))
ctk.CTkLabel(
auth_card,
text="3. Подтвердите доступ — мастер продолжит автоматически",
font=Theme.font_body_bold(),
text_color=Theme.TEXT_PRIMARY,
).pack(anchor="w", padx=10, pady=(0, 8))
# Manual Fallback Card
manual_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED)
manual_card.pack(fill="x", pady=(0, 8))
@ -641,6 +693,14 @@ class AddAccountWizard(HubModal):
def _open_codex_browser(self):
if self.codex_url:
self._copy_codex_code()
self.codex_status_lbl.configure(
text=(
"Код скопирован. Вставьте его на открывшейся странице OpenAI; "
"затем вернитесь сюда — Hub продолжит автоматически."
),
text_color=Theme.TEXT_SECONDARY,
)
webbrowser.open(self.codex_url)
def _handle_codex_manual_submit(self):
@ -745,9 +805,9 @@ class AddAccountWizard(HubModal):
HubButton(
url_row,
text="📋",
text="Копировать ссылку",
variant="secondary",
width=40,
width=130,
height=32,
command=self._copy_claude_url,
).pack(side="right")
@ -906,10 +966,10 @@ class AddAccountWizard(HubModal):
ctk.CTkLabel(
auth_card,
text="Ссылка для входа в xAI Grok:",
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
).pack(anchor="w", padx=10, pady=(6, 2))
text="1. Откройте ссылку — кнопка ниже откроет страницу xAI",
font=Theme.font_body_bold(),
text_color=Theme.TEXT_PRIMARY,
).pack(anchor="w", padx=10, pady=(8, 2))
url_row = ctk.CTkFrame(auth_card, fg_color="transparent")
url_row.pack(fill="x", padx=10, pady=(0, 6))
@ -938,7 +998,7 @@ class AddAccountWizard(HubModal):
ctk.CTkLabel(
code_card,
text="Код подтверждения:",
text="2. Введите на странице код:",
font=Theme.font_body_bold(),
text_color=Theme.TEXT_PRIMARY,
).pack(side="left", padx=(0, 8))
@ -946,7 +1006,7 @@ class AddAccountWizard(HubModal):
self.grok_code_lbl = ctk.CTkLabel(
code_card,
text="...",
font=Theme.font_mono_bold(),
font=("Consolas", 18, "bold"),
text_color=Theme.PROVIDER_GROK,
)
self.grok_code_lbl.pack(side="left", padx=(0, 8))
@ -971,6 +1031,13 @@ class AddAccountWizard(HubModal):
command=self._open_grok_browser,
).pack(side="left", padx=(0, 8))
ctk.CTkLabel(
auth_card,
text="3. Подтвердите доступ — мастер продолжит автоматически",
font=Theme.font_body_bold(),
text_color=Theme.TEXT_PRIMARY,
).pack(anchor="w", padx=10, pady=(0, 8))
# Manual Entry Card
manual_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED)
manual_card.pack(fill="x", pady=(0, 8))
@ -1077,6 +1144,14 @@ class AddAccountWizard(HubModal):
def _open_grok_browser(self):
if self.grok_url:
self._copy_grok_code()
self.grok_status_lbl.configure(
text=(
"Код скопирован. Вставьте его на открывшейся странице xAI; "
"затем вернитесь сюда — Hub продолжит автоматически."
),
text_color=Theme.TEXT_SECONDARY,
)
webbrowser.open(self.grok_url)
def _handle_grok_manual_submit(self):
@ -1223,13 +1298,14 @@ class AddAccountWizard(HubModal):
entry_row,
placeholder_text=placeholder_map.get(self.selected_provider, "sk-..."),
font=Theme.font_mono(),
show="*",
show="" if self.selected_provider == "opencode-go" else "*",
height=38,
fg_color=Theme.PRIMARY,
border_color=Theme.BORDER,
text_color=Theme.TEXT_PRIMARY,
)
self.key_entry.pack(side="left", fill="x", expand=True, padx=(0, 8))
self.key_entry.focus_set()
HubButton(
entry_row,
@ -1305,12 +1381,19 @@ class AddAccountWizard(HubModal):
def _paste_into_entry(self, entry_widget: HubEntry):
try:
content = self.clipboard_get().strip()
content = entry_widget.clipboard_get().strip()
if content:
entry_widget.delete(0, "end")
entry_widget.insert(0, content)
except Exception:
pass
entry_widget.focus_set()
entry_widget.icursor("end")
if hasattr(self, "key_status_lbl") and entry_widget is getattr(self, "key_entry", None):
self.key_status_lbl.configure(text="✓ Ключ вставлен. Нажмите «Проверить и продолжить».")
elif hasattr(self, "key_status_lbl"):
self.key_status_lbl.configure(text="Буфер обмена пуст.")
except Exception as exc:
if hasattr(self, "key_status_lbl"):
self.key_status_lbl.configure(text=f"Не удалось вставить: {exc}")
# ═══════════════════════════════════════════════════════════════
# STEP 3: Validation & Identity
@ -1441,6 +1524,15 @@ class AddAccountWizard(HubModal):
anchor="w",
).pack(fill="x", pady=8)
self.finish_status_lbl = ctk.CTkLabel(
self.body,
text="Нажмите «Завершить подключение», чтобы сохранить роль и обновить Hub.",
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
anchor="w",
)
self.finish_status_lbl.pack(fill="x", pady=(0, 4))
HubButton(
self.footer,
text="✓ Завершить подключение",
@ -1449,13 +1541,40 @@ class AddAccountWizard(HubModal):
).pack(side="right", padx=10, pady=10)
def _finish(self):
# Ни журналирование, ни обратный вызов не должны мешать закрытию окна:
# исключение здесь оставляло мастер открытым без единого признака ошибки.
if not self.target_slot:
self.finish_status_lbl.configure(
text="❌ Подключение невозможно: свободный слот не найден.",
text_color=Theme.STATUS_ERROR,
)
return
profile_ok, profile_message = AutoAssigner.ensure_profile_definition(
self.selected_provider,
self.target_slot,
)
if not profile_ok:
self.finish_status_lbl.configure(text=f"{profile_message}", text_color=Theme.STATUS_ERROR)
return
# Most built-in slots already occur in a default chain. Custom or
# repaired configs may not, so completion makes that invariant explicit
# without reordering a slot that is already assigned.
ok, message = ensure_profile_in_routing(self.target_slot)
if not ok:
self.finish_status_lbl.configure(text=f"{message}", text_color=Theme.STATUS_ERROR)
return
# Всё, что проверяемо, уже проверено выше и сообщает об ошибке в самом
# окне. Дальше идут побочные действия, и ни одно из них не должно
# мешать закрытию: исключение здесь оставляло мастер открытым без
# единого признака ошибки, потому что под pythonw трейсбека не видно.
try:
# A reused slot can carry cooldown from an older account. Fresh OAuth
# credentials must start with fresh health state.
from antigravity_provider.router.router_engine import get_router_engine
get_router_engine().health.clear_cooldown(self.target_slot)
EventLogService.get().log(
"account",
f"Подключен аккаунт {self.selected_provider}: {self.discovered_identity}",
details=f"Слот: {self.target_slot}",
f"Подключен аккаунт {self.selected_provider}; слот {self.target_slot}; роль сохранена.",
level="success",
)
except Exception:

View file

@ -481,7 +481,8 @@ class HubModal(ctk.CTkToplevel):
self,
corner_radius=Theme.RADIUS_LG,
border_color=Theme.BORDER_ACCENT,
fg_color=Theme.DARK,
# Contrast-safe in dark, hybrid and light palettes.
fg_color=Theme.SURFACE,
)
self.container.pack(fill="both", expand=True, padx=Theme.SPACE_LG, pady=Theme.SPACE_LG)
@ -867,7 +868,7 @@ class AccountCardWidget(HubCard):
self.profile_id = profile_id
self.profile_model: Any = None
self.on_action = on_action
self.compact = compact
self.compact = True
self._quota_widgets: Dict[str, QuotaBucketWidget] = {}
self.widgets_created = 0
self.widgets_destroyed = 0
@ -876,8 +877,8 @@ class AccountCardWidget(HubCard):
top.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS))
self.provider = ctk.CTkLabel(top, text=provider, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.provider.pack(side="left")
self.toggle = IconButton(top, text="", command=self.toggle_compact)
self.toggle.pack(side="right")
self.profile_mark = ctk.CTkLabel(top, text="", font=Theme.font_caption(), text_color=Theme.TEXT_ACCENT)
self.profile_mark.pack(side="right")
self.identity = EllipsizedLabel(self, text=identity, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
self.identity.pack(anchor="w", padx=Theme.CARD_PAD_X)
@ -887,6 +888,47 @@ class AccountCardWidget(HubCard):
self.status = StatusBadge(self, status)
self.status.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
self.compact_quota = ctk.CTkFrame(self, fg_color="transparent")
self.compact_quota_cells: list[dict[str, Any]] = []
for index in range(4):
cell = ctk.CTkFrame(self.compact_quota, fg_color="transparent")
cell.grid(row=index // 2, column=index % 2, sticky="nsew", padx=(0, 10), pady=(2, 6))
title = ctk.CTkLabel(cell, text="", font=Theme.font_micro(), text_color=Theme.TEXT_PRIMARY, anchor="w")
title.pack(fill="x")
value = ctk.CTkLabel(cell, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY, anchor="e")
value.pack(fill="x")
progress = ctk.CTkProgressBar(
cell,
height=5,
corner_radius=3,
fg_color=Theme.SURFACE_MUTED,
progress_color=Theme.STATUS_HEALTHY,
)
progress.pack(fill="x", pady=(2, 1))
progress.set(0)
reset = ctk.CTkLabel(cell, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED, anchor="w")
reset.pack(fill="x")
self.compact_quota_cells.append(
{"frame": cell, "title": title, "value": value, "progress": progress, "reset": reset}
)
self.compact_quota.grid_columnconfigure(index % 2, weight=1)
self.compact_actions = ctk.CTkFrame(self, fg_color="transparent")
for text, action in (
("", "test"),
("", "set_main"),
("", "set_orchestrator"),
("Роль", "assign_role"),
("", "refresh_account"),
):
ActionButton(
self.compact_actions,
text=text,
variant="ghost",
width=48,
height=Theme.HEIGHT_BTN_SM,
command=lambda action_name=action: self._trigger(action_name),
).pack(side="left", padx=(0, Theme.SPACE_XS))
self.details = ctk.CTkFrame(self, fg_color="transparent")
self.details.pack(fill="x")
self.quota_box = ctk.CTkFrame(self.details, fg_color="transparent")
@ -896,6 +938,15 @@ class AccountCardWidget(HubCard):
)
self.actions = ctk.CTkFrame(self.details, fg_color="transparent")
self.actions.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.CARD_PAD_Y))
self.action_feedback = ctk.CTkLabel(
self.actions,
text="",
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
wraplength=330,
justify="left",
anchor="w",
)
self.action_buttons: Dict[str, ActionButton] = {}
management = ctk.CTkFrame(self.actions, fg_color="transparent")
management.pack(fill="x", pady=(0, Theme.SPACE_XS))
@ -926,7 +977,7 @@ class AccountCardWidget(HubCard):
width=72,
command=lambda: self._trigger("delete_credentials"),
).pack(side="right")
self.set_compact(compact)
self.set_compact(True)
@staticmethod
def resolve_identity(profile: Any) -> str:
@ -943,18 +994,28 @@ class AccountCardWidget(HubCard):
def _trigger(self, action: str) -> None:
if self.on_action and self.profile_model is not None:
self.set_action_feedback("Выполняется…", None)
self.on_action(action, self.profile_model)
def set_action_feedback(self, message: str, success: Optional[bool]) -> None:
"""Show an action result where the user initiated it."""
color = Theme.TEXT_SECONDARY
if success is True:
color = Theme.STATUS_HEALTHY
elif success is False:
color = Theme.STATUS_ERROR
self.action_feedback.configure(text=message, text_color=color)
self.action_feedback.pack(fill="x", pady=(0, Theme.SPACE_XS), before=self.actions.winfo_children()[1])
def toggle_compact(self) -> None:
self.set_compact(not self.compact)
# Account cards intentionally have one fixed Cockpit-style layout.
self.set_compact(True)
def set_compact(self, compact: bool) -> None:
self.compact = compact
self.toggle.configure(text="" if compact else "")
if compact:
self.details.pack_forget()
else:
self.details.pack(fill="x")
self.compact = True
self.details.pack_forget()
self.compact_quota.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(0, Theme.SPACE_XS))
self.compact_actions.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
def update_account(self, profile: Any, quota_snapshot: Optional[Any] = None) -> None:
self.profile_model = profile
@ -974,12 +1035,53 @@ class AccountCardWidget(HubCard):
)
else:
self.plan_badge.pack_forget()
self.status.set_status(profile.health_state, getattr(profile, "health_label_ru", None))
self.configure(border_color=Theme.BORDER_ACCENT if profile.is_main_account else Theme.BORDER)
snapshot = quota_snapshot or getattr(profile, "quota_snapshot", None)
buckets = list(getattr(snapshot, "buckets", None) or [])
estimated = bool(getattr(snapshot, "is_estimated", True)) if snapshot else True
unavailable_reason = getattr(snapshot, "unavailable_reason", None) if snapshot else None
measured_remaining = [
float(bucket.remaining_percent)
for bucket in buckets
if getattr(bucket, "remaining_percent", None) is not None
]
if measured_remaining and not estimated:
if all(remaining <= 0 for remaining in measured_remaining):
card_health, card_label = "quota_exhausted", "Квота исчерпана"
elif any(remaining <= 0 for remaining in measured_remaining):
card_health, card_label = "warning", "Часть квот исчерпана"
else:
card_health, card_label = "healthy", "Работает"
else:
card_health = profile.health_state
card_label = getattr(profile, "health_label_ru", None)
self.status.set_status(card_health, card_label)
for index, cell in enumerate(self.compact_quota_cells):
if index < len(buckets):
bucket = buckets[index]
remaining = getattr(bucket, "remaining_percent", None)
color = (
Theme.STATUS_HEALTHY
if getattr(bucket, "status", "unknown") == "healthy"
else Theme.STATUS_WARNING
if getattr(bucket, "status", "unknown") == "warning"
else Theme.TEXT_MUTED
)
detail = bucket.formatted_remaining()
if detail == "Н" and unavailable_reason:
limit = getattr(bucket, "limit_absolute", None)
unit = getattr(bucket, "unit", None)
prefix = f"Лимит ${limit}" if limit is not None and unit == "USD" else ""
detail = f"{prefix}{unavailable_reason}"
cell["title"].configure(text=bucket.display_name)
cell["value"].configure(text=detail, text_color=color)
cell["progress"].configure(progress_color=color)
cell["progress"].set(float(remaining) / 100.0 if remaining is not None else 0)
cell["reset"].configure(text=bucket.formatted_reset() or "Период указан провайдером")
cell["frame"].grid()
else:
cell["frame"].grid_remove()
seen: set[str] = set()
for bucket in buckets:
key = str(getattr(bucket, "id", "") or getattr(bucket, "display_name", "bucket"))

View file

@ -0,0 +1,273 @@
"""Versioned presentation model for the Team routing graph.
The router YAML remains the source of truth for profiles and role chains. This
module stores only topology/layout metadata next to it and applies profile
assignments through :class:`AutoAssigner`.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
import copy
import json
from pathlib import Path
from typing import Any, Iterable, Optional
from uuid import uuid4
from antigravity_provider import paths
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.router_config import RouterConfig, load_router_config
SCHEMA_VERSION = 1
EDGE_TYPES = ("PRIMARY", "FALLBACK", "DELEGATE")
CANONICAL_ROLES = ("orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast")
ROLE_LABELS = {
"orchestrator": "Оркестратор",
"coder-primary": "Основной кодер",
"coder-secondary": "Резервный кодер",
"reviewer": "Ревьюер",
"research": "Исследователь",
"fast": "Быстрый агент",
}
@dataclass
class GraphNode:
role_id: str
x: float
y: float
label: str = ""
@dataclass
class GraphEdge:
source: str
target: str
edge_type: str = "DELEGATE"
profile_id: str = ""
edge_id: str = field(default_factory=lambda: uuid4().hex[:12])
@dataclass
class GraphIssue:
code: str
message: str
node_id: str = ""
edge_id: str = ""
@dataclass
class RoutingGraph:
schema_version: int = SCHEMA_VERSION
nodes: list[GraphNode] = field(default_factory=list)
edges: list[GraphEdge] = field(default_factory=list)
zoom: float = 1.0
viewport_x: float = 0.0
viewport_y: float = 0.0
def clone(self) -> "RoutingGraph":
return copy.deepcopy(self)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, value: dict[str, Any]) -> "RoutingGraph":
if int(value.get("schema_version", 0)) != SCHEMA_VERSION:
raise ValueError("Unsupported routing graph schema")
return cls(
schema_version=SCHEMA_VERSION,
nodes=[GraphNode(**item) for item in value.get("nodes", [])],
edges=[GraphEdge(**item) for item in value.get("edges", [])],
zoom=float(value.get("zoom", 1.0)),
viewport_x=float(value.get("viewport_x", 0.0)),
viewport_y=float(value.get("viewport_y", 0.0)),
)
def default_graph(config: Optional[RouterConfig] = None) -> RoutingGraph:
"""Migrate current configured roles without touching their chains."""
config = config or load_router_config()
roles = [role for role in CANONICAL_ROLES if role in config.roles]
roles.extend(role for role in config.roles if role not in roles)
nodes: list[GraphNode] = []
for index, role_id in enumerate(roles):
if role_id == "orchestrator":
x, y = 90.0, 240.0
else:
slot = index - (1 if "orchestrator" in roles else 0)
x, y = 390.0 + (slot // 3) * 300.0, 80.0 + (slot % 3) * 160.0
nodes.append(GraphNode(role_id, x, y, ROLE_LABELS.get(role_id, role_id)))
root = "orchestrator" if "orchestrator" in roles else (roles[0] if roles else "")
edges = [GraphEdge(root, role, "DELEGATE") for role in roles if root and role != root]
return RoutingGraph(nodes=nodes, edges=edges)
class RoutingGraphStore:
def __init__(self, path: Optional[Path] = None):
self.path = path or (paths.get_hermes_home() / "routing_graph.json")
def load(self, config: Optional[RouterConfig] = None) -> RoutingGraph:
try:
return RoutingGraph.from_dict(json.loads(self.path.read_text(encoding="utf-8")))
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError):
return default_graph(config)
def save(self, graph: RoutingGraph) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(graph.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(self.path)
def validate_graph(graph: RoutingGraph, config: Optional[RouterConfig] = None) -> list[GraphIssue]:
config = config or load_router_config()
issues: list[GraphIssue] = []
node_ids = [node.role_id for node in graph.nodes]
node_set = set(node_ids)
for role_id in sorted({item for item in node_ids if node_ids.count(item) > 1}):
issues.append(GraphIssue("duplicate-node", f"Роль {role_id} добавлена дважды", role_id))
if "orchestrator" not in node_set:
issues.append(GraphIssue("missing-orchestrator", "Отсутствует узел оркестратора"))
for node in graph.nodes:
policy = config.roles.get(node.role_id)
if policy is None:
issues.append(GraphIssue("missing-role", f"Роль {node.role_id} отсутствует в конфигурации", node.role_id))
elif not policy.preferred_chain:
issues.append(GraphIssue("empty-chain", f"У роли {node.label or node.role_id} нет профилей", node.role_id))
else:
for profile_id in policy.preferred_chain:
if profile_id not in config.profiles:
issues.append(
GraphIssue("missing-profile", f"Профиль {profile_id} не существует", node.role_id)
)
seen_edges: set[tuple[str, str, str, str]] = set()
adjacency: dict[str, list[str]] = {node_id: [] for node_id in node_set}
for edge in graph.edges:
key = (edge.source, edge.target, edge.edge_type, edge.profile_id)
if key in seen_edges:
issues.append(GraphIssue("duplicate-edge", "Дублирующая связь", edge_id=edge.edge_id))
seen_edges.add(key)
if edge.edge_type not in EDGE_TYPES:
issues.append(GraphIssue("edge-type", f"Неизвестный тип {edge.edge_type}", edge_id=edge.edge_id))
if edge.source not in node_set or edge.target not in node_set:
issues.append(GraphIssue("dangling-edge", "Связь ведёт к отсутствующей роли", edge_id=edge.edge_id))
continue
adjacency[edge.source].append(edge.target)
if edge.profile_id and edge.profile_id not in config.profiles:
issues.append(GraphIssue("missing-profile", f"Профиль {edge.profile_id} не существует", edge.target, edge.edge_id))
visited: set[str] = set()
active: set[str] = set()
def visit(role_id: str) -> None:
if role_id in active:
issues.append(GraphIssue("cycle", f"Цикл маршрутизации через {role_id}", role_id))
return
if role_id in visited:
return
visited.add(role_id)
active.add(role_id)
for target in adjacency.get(role_id, []):
visit(target)
active.remove(role_id)
if "orchestrator" in node_set:
visit("orchestrator")
for role_id in sorted(node_set - visited):
issues.append(GraphIssue("unreachable", f"Роль {role_id} недостижима от оркестратора", role_id))
return issues
class RoutingGraphController:
"""Undoable graph editor whose assignments go through AutoAssigner."""
def __init__(self, store: Optional[RoutingGraphStore] = None):
self.store = store or RoutingGraphStore()
self.graph = self.store.load()
self._undo: list[RoutingGraph] = []
self._redo: list[RoutingGraph] = []
self.dirty = False
def _checkpoint(self) -> None:
self._undo.append(self.graph.clone())
self._undo = self._undo[-50:]
self._redo.clear()
self.dirty = True
def move_node(self, role_id: str, x: float, y: float) -> None:
node = next((item for item in self.graph.nodes if item.role_id == role_id), None)
if node and (node.x, node.y) != (x, y):
self._checkpoint()
node.x, node.y = x, y
def add_edge(self, source: str, target: str, edge_type: str, profile_id: str = "") -> tuple[bool, str]:
if edge_type not in EDGE_TYPES:
return False, "Неизвестный тип связи"
self._checkpoint()
self.graph.edges.append(GraphEdge(source, target, edge_type, profile_id))
if profile_id and edge_type in {"PRIMARY", "FALLBACK"}:
ok, message = AutoAssigner.assign_profile_to_role(profile_id, target, edge_type == "PRIMARY")
if not ok:
self.undo()
return False, message
return True, "Связь добавлена"
def delete_edge(self, edge_id: str) -> None:
if any(edge.edge_id == edge_id for edge in self.graph.edges):
self._checkpoint()
self.graph.edges = [edge for edge in self.graph.edges if edge.edge_id != edge_id]
def set_edge_type(
self, edge_id: str, edge_type: str, profile_id: Optional[str] = None
) -> tuple[bool, str]:
edge = next((item for item in self.graph.edges if item.edge_id == edge_id), None)
if edge is None or edge_type not in EDGE_TYPES:
return False, "Связь не найдена"
self._checkpoint()
edge.edge_type = edge_type
if profile_id is not None:
edge.profile_id = profile_id
if edge.profile_id and edge_type in {"PRIMARY", "FALLBACK"}:
ok, message = AutoAssigner.assign_profile_to_role(edge.profile_id, edge.target, edge_type == "PRIMARY")
if not ok:
self.undo()
return ok, message
return True, "Тип связи изменён"
def auto_layout(self) -> None:
self._checkpoint()
root = next((node for node in self.graph.nodes if node.role_id == "orchestrator"), None)
if root:
root.x, root.y = 80.0, 240.0
others = [node for node in self.graph.nodes if node.role_id != "orchestrator"]
for index, node in enumerate(others):
node.x = 390.0 + (index // 4) * 290.0
node.y = 55.0 + (index % 4) * 135.0
def undo(self) -> bool:
if not self._undo:
return False
self._redo.append(self.graph.clone())
self.graph = self._undo.pop()
self.dirty = True
return True
def redo(self) -> bool:
if not self._redo:
return False
self._undo.append(self.graph.clone())
self.graph = self._redo.pop()
self.dirty = True
return True
def save(self) -> list[GraphIssue]:
issues = validate_graph(self.graph)
if not issues:
self.store.save(self.graph)
self.dirty = False
return issues
def role_chain(self, role_id: str) -> Iterable[str]:
policy = load_router_config().roles.get(role_id)
return tuple(policy.preferred_chain if policy else ())

View file

@ -29,7 +29,7 @@ PROVIDER_LABELS = {
class ProviderGroup(ctk.CTkFrame):
"""Collapsible provider section that retains its account cards while hidden."""
"""Fixed provider section with always-visible Cockpit-style cards."""
def __init__(self, master: Any, provider: str):
super().__init__(master, fg_color="transparent")
@ -37,21 +37,13 @@ class ProviderGroup(ctk.CTkFrame):
self.collapsed = False
self.header = ctk.CTkFrame(self, fg_color=Theme.BG_HEADER, corner_radius=Theme.RADIUS_SM)
self.header.pack(fill="x", pady=(Theme.SPACE_SM, Theme.SPACE_XS))
self.toggle = ActionButton(
self.header,
text="",
variant="ghost",
width=34,
command=self.toggle_collapsed,
)
self.toggle.pack(side="left", padx=(Theme.SPACE_SM, 0))
self.title = ctk.CTkLabel(
self.header,
text=PROVIDER_LABELS.get(provider, provider),
font=Theme.font_heading(),
text_color=Theme.TEXT_PRIMARY,
)
self.title.pack(side="left", padx=Theme.SPACE_SM, pady=Theme.SPACE_SM)
self.title.pack(side="left", padx=Theme.SPACE_MD, pady=Theme.SPACE_SM)
self.count = ctk.CTkLabel(self.header, text="0", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED)
self.count.pack(side="right", padx=Theme.SPACE_MD)
self.body = ctk.CTkFrame(self, fg_color="transparent")
@ -60,12 +52,8 @@ class ProviderGroup(ctk.CTkFrame):
self.body.grid_columnconfigure(column, weight=1)
def toggle_collapsed(self) -> None:
self.collapsed = not self.collapsed
self.toggle.configure(text="" if self.collapsed else "")
if self.collapsed:
self.body.pack_forget()
else:
self.body.pack(fill="x")
self.collapsed = False
self.body.pack(fill="x")
class AccountsView(ctk.CTkFrame):
@ -91,7 +79,7 @@ class AccountsView(ctk.CTkFrame):
header = SectionHeader(
self,
title="Аккаунты и квоты",
subtitle="Реальные идентичности, независимые лимитные корзины и резервные роли",
subtitle="Компактные карточки аккаунтов, реальные остатки и быстрые действия",
action_text="+ Добавить аккаунт",
action_cmd=lambda: self._emit("add_account", {}),
)
@ -220,6 +208,7 @@ class AccountsView(ctk.CTkFrame):
profile.profile_id,
AccountCardWidget.resolve_identity(profile),
profile.provider_display_name,
compact=True,
on_action=self._emit,
)
self._cards[profile.profile_id] = card
@ -267,3 +256,11 @@ class AccountsView(ctk.CTkFrame):
"quota_widgets_created": sum(card.widgets_created for card in self._cards.values()),
"quota_widgets_destroyed": sum(card.widgets_destroyed for card in self._cards.values()),
}
def show_action_result(self, profile_id: str, message: str, success: Optional[bool]) -> bool:
"""Keep account action feedback next to the originating controls."""
card = self._cards.get(profile_id)
if card is None:
return False
card.set_action_feedback(message, success)
return True

View file

@ -9,7 +9,7 @@ import customtkinter as ctk
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.assets import AssetManager
from antigravity_provider.router.ui.components import HubCard
from antigravity_provider.router.ui.components import HubButton, HubCard
from antigravity_provider.router.ui.theme import Theme
@ -83,16 +83,31 @@ class _EndpointCard(HubCard):
self.subtitle.pack(anchor="w", pady=(0, 1))
self.status = ctk.CTkLabel(text, text="", font=Theme.font_micro(), text_color=Theme.STATUS_HEALTHY)
self.status.pack(anchor="w")
quota = ctk.CTkFrame(self, fg_color="transparent", width=58)
quota = ctk.CTkFrame(self, fg_color="transparent", width=92)
quota.pack(side="right", fill="y", padx=(3, 8), pady=8)
quota.pack_propagate(False)
self.quota_label = ctk.CTkLabel(quota, text="Н", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
self.quota_label = ctk.CTkLabel(quota, text="", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
self.quota_label.pack(anchor="e")
self.progress = ctk.CTkProgressBar(
quota, height=4, corner_radius=2, progress_color=Theme.STATUS_HEALTHY, fg_color=Theme.SURFACE_MUTED
)
self.progress.pack(fill="x", pady=(5, 0))
self.progress.set(0)
self._click_action: Optional[Callable[[], None]] = None
self._bind_click_tree(self)
def _bind_click_tree(self, widget: Any) -> None:
widget.bind("<Button-1>", self._activate, add="+")
for child in widget.winfo_children():
self._bind_click_tree(child)
def set_click_action(self, action: Optional[Callable[[], None]]) -> None:
self._click_action = action
self.configure(cursor="hand2" if action else "arrow")
def _activate(self, _event: Any = None) -> None:
if self._click_action:
self._click_action()
def update_card(
self,
@ -162,16 +177,16 @@ class _RouteDiagram(ctk.CTkFrame):
"""Responsive diagram with smooth connections behind native widgets."""
def __init__(self, master: Any):
super().__init__(master, height=330, fg_color="transparent")
super().__init__(master, height=440, fg_color="transparent")
self.pack_propagate(False)
self.canvas = tk.Canvas(self, bg=Theme.SURFACE, highlightthickness=0, bd=0)
self.canvas.place(relx=0, rely=0, relwidth=1, relheight=1)
self.provider_slots = [ctk.CTkFrame(self, height=76, fg_color="transparent") for _ in range(3)]
self.agent_slots = [ctk.CTkFrame(self, height=76, fg_color="transparent") for _ in range(3)]
self.agent_slots = [ctk.CTkFrame(self, height=68, fg_color="transparent") for _ in range(5)]
for index, slot in enumerate(self.provider_slots):
slot.place(relx=0.012, rely=0.02 + index * 0.29, relwidth=0.30)
for index, slot in enumerate(self.agent_slots):
slot.place(relx=0.71, rely=0.02 + index * 0.29, relwidth=0.278)
slot.place(relx=0.71, rely=0.01 + index * 0.185, relwidth=0.278)
self.orchestrator = _OrchestratorNode(self)
self.orchestrator.place(relx=0.51, rely=0.46, anchor="center")
self.context = HubCard(self, corner_radius=Theme.RADIUS_MD, height=46)
@ -180,16 +195,19 @@ class _RouteDiagram(ctk.CTkFrame):
self.context, text="▤ Хранилище контекста", font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY
).pack(pady=(6, 0))
self.context_status = ctk.CTkLabel(
self.context, text="● Состояние: Н", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
self.context,
text="● Нет телеметрии хранилища",
font=Theme.font_micro(),
text_color=Theme.TEXT_MUTED,
)
self.context_status.pack()
self._left_labels = ["", "", ""]
self._right_labels = ["", "", ""]
self._right_labels = ["", "", "", "", ""]
self.bind("<Configure>", self._redraw)
def set_labels(self, left: list[str], right: list[str]) -> None:
self._left_labels = (left + ["", "", ""])[:3]
self._right_labels = (right + ["", "", ""])[:3]
self._right_labels = (right + ["", "", "", "", ""])[:5]
self._redraw()
def _redraw(self, _event: Any = None) -> None:
@ -197,8 +215,8 @@ class _RouteDiagram(ctk.CTkFrame):
self.canvas.delete("route")
center_x, center_y = width * 0.51, height * 0.40
left_x, right_x = width * 0.312, width * 0.71
ys = [height * (0.02 + index * 0.29) + 38 for index in range(3)]
for index, y_pos in enumerate(ys):
left_ys = [height * (0.02 + index * 0.29) + 38 for index in range(3)]
for index, y_pos in enumerate(left_ys):
self.canvas.create_line(
left_x,
y_pos,
@ -223,6 +241,8 @@ class _RouteDiagram(ctk.CTkFrame):
anchor="w",
tags="route",
)
right_ys = [height * (0.01 + index * 0.185) + 34 for index in range(5)]
for index, y_pos in enumerate(right_ys):
self.canvas.create_line(
center_x + 48,
center_y,
@ -373,13 +393,35 @@ class DashboardView(ctk.CTkFrame):
# Kept as a presentation-state probe for tests/accessibility; the same
# status is rendered once in the global header, as in the approved mockup.
self.empty_state = HubCard(self.scroll, border_color=Theme.BORDER_ACCENT, fg_color=Theme.ACCENT_DIM)
empty_copy = ctk.CTkFrame(self.empty_state, fg_color="transparent")
empty_copy.pack(side="left", fill="x", expand=True, padx=14, pady=10)
ctk.CTkLabel(
empty_copy,
text="Подключите первый аккаунт",
font=Theme.font_heading(),
text_color=Theme.TEXT_PRIMARY,
).pack(anchor="w")
ctk.CTkLabel(
empty_copy,
text="Hermes назначит профиль роли и покажет реальную квоту, модель и цепочку отказоустойчивости.",
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
).pack(anchor="w", pady=(2, 0))
HubButton(
self.empty_state,
text="Добавить аккаунт",
variant="primary",
command=lambda: self.on_action("add_account", {}) if self.on_action else None,
).pack(side="right", padx=12, pady=10)
self.metrics = ctk.CTkFrame(self.scroll, fg_color="transparent")
self.metrics.pack(fill="x", pady=(0, 8))
for column in range(5):
self.metrics.grid_columnconfigure(column, weight=1, uniform="kpi")
self.quota_metric = _KpiCard(self.metrics, "Квота сегодня", "Н", "нет измерения")
self.calls_metric = _KpiCard(self.metrics, "Вызовы", "Н", "активно: 0")
self.agents_metric = _KpiCard(self.metrics, "Агенты онлайн", "0/0", "готовые роли")
self.agents_metric = _KpiCard(self.metrics, "Подключено аккаунтов", "0", "авторизованы")
self.latency_metric = _KpiCard(self.metrics, "Время отклика", "Н", "P50")
self.failover_metric = _KpiCard(self.metrics, "Переключения", "Н", "failover")
for index, metric in enumerate(
@ -389,10 +431,9 @@ class DashboardView(ctk.CTkFrame):
body = ctk.CTkFrame(self.scroll, fg_color="transparent")
body.pack(fill="x")
body.grid_columnconfigure(0, weight=4)
body.grid_columnconfigure(1, weight=1)
route_card = HubCard(body, height=370)
route_card.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
body.grid_columnconfigure(0, weight=1)
route_card = HubCard(body, height=480)
route_card.grid(row=0, column=0, sticky="nsew")
route_card.grid_propagate(False)
ctk.CTkLabel(
route_card, text="Маршрутизация запросов", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
@ -400,8 +441,10 @@ class DashboardView(ctk.CTkFrame):
self.route_diagram = _RouteDiagram(route_card)
self.route_diagram.pack(fill="both", expand=True, padx=6, pady=(2, 6))
self.realtime = HubCard(body, height=370)
self.realtime.grid(row=0, column=1, sticky="nsew")
# Metrics still exist as data probes but are intentionally not shown on
# Overview. The previous right rail duplicated Accounts/Status data
# and made the useful routing canvas unnecessarily narrow.
self.realtime = HubCard(self, height=1)
self.realtime.grid_propagate(False)
ctk.CTkLabel(
self.realtime,
@ -473,7 +516,17 @@ class DashboardView(ctk.CTkFrame):
for bucket in quota.buckets:
if bucket.remaining_percent is not None:
return f"{bucket.remaining_percent:.0f}%", float(bucket.remaining_percent)
return "Н", None
return "Нет данных API", None
@staticmethod
def _agent_quota_measurement(snapshot: HubSnapshot, agent: Any) -> tuple[str, Optional[float]]:
quota = snapshot.quotas.get(agent.assigned_profile_id)
if quota and not getattr(quota, "is_estimated", True):
bucket = quota.get_bucket_for_model(agent.model)
if bucket and bucket.remaining_percent is not None:
remaining = float(bucket.remaining_percent)
return bucket.formatted_remaining(), remaining
return agent.active_quota_label or "Нет данных API", None
@staticmethod
def _sync_endpoint_cards(
@ -481,7 +534,7 @@ class DashboardView(ctk.CTkFrame):
cache: Dict[str, _EndpointCard],
items: Iterable[tuple[str, str, str, str, str, str, Optional[float], str]],
) -> None:
prepared = list(items)[:3]
prepared = list(items)[: len(slots)]
live = {key for key, *_rest in prepared}
for key in list(cache):
if key not in live:
@ -507,6 +560,10 @@ class DashboardView(ctk.CTkFrame):
text_color=Theme.STATUS_WARNING if snapshot.is_stale else Theme.STATUS_HEALTHY,
)
readiness = snapshot.readiness
if readiness.accounts_connected_count == 0:
self.empty_state.pack(fill="x", pady=(0, 8), before=self.metrics)
else:
self.empty_state.pack_forget()
telemetry = dict(snapshot.metrics.get("telemetry") or {})
global_telemetry = dict(telemetry.get("global") or {})
provider_telemetry = dict(telemetry.get("by_provider") or {})
@ -526,9 +583,9 @@ class DashboardView(ctk.CTkFrame):
float(total_calls) if total_calls is not None else None,
)
self.agents_metric.set_metric(
f"{readiness.roles_ready_count}/{readiness.total_roles}",
"готовые роли",
float(readiness.roles_ready_count),
str(readiness.accounts_connected_count),
f"роли готовы: {readiness.roles_ready_count}/{readiness.total_roles}",
float(readiness.accounts_connected_count),
)
self.latency_metric.set_metric(
f"{latency:.0f} мс" if latency is not None else "Н",
@ -550,13 +607,16 @@ class DashboardView(ctk.CTkFrame):
provider.provider_id,
provider.provider_id,
provider.provider_name,
next(
(
profile.preferred_models[0]
for profile in snapshot.profiles_by_provider.get(provider.provider_id, [])
if profile.preferred_models
),
"Модель: Н",
(
f"{provider.connected_count} аккаунт(а) • "
+ next(
(
profile.preferred_models[0]
for profile in snapshot.profiles_by_provider.get(provider.provider_id, [])
if profile.preferred_models
),
"модель: Н",
)
),
"Онлайн" if provider.online_count else "Недоступен",
*self._quota_measurement(snapshot, provider.provider_id),
@ -575,33 +635,51 @@ class DashboardView(ctk.CTkFrame):
else:
self.route_diagram.orchestrator.update_node("Не назначен", "Н", False)
agents = [agent for agent in snapshot.agents if not agent.is_main_orchestrator][:3]
self._sync_endpoint_cards(
self.route_diagram.agent_slots,
self._agent_cards,
(
agents = [agent for agent in snapshot.agents if not agent.is_main_orchestrator][:5]
agent_items = []
for agent in agents:
agent_quota_text, agent_quota_percent = self._agent_quota_measurement(snapshot, agent)
agent_items.append(
(
agent.role_id,
agent.provider,
agent.role_name_ru,
f"{agent.provider_display_name}{agent.model}",
"Здорово" if agent.is_active else agent.status_label_ru,
agent.active_quota_label or "Н",
None,
agent_quota_text,
agent_quota_percent,
"healthy" if agent.is_active else "warning",
)
for agent in agents
),
)
self._sync_endpoint_cards(
self.route_diagram.agent_slots,
self._agent_cards,
agent_items,
)
for agent in agents:
card = self._agent_cards.get(agent.role_id)
if card is not None:
card.set_click_action(
lambda current=agent: self.on_action(
"agent_settings",
{
"role_id": current.role_id,
"profile_id": current.assigned_profile_id,
"provider": current.provider,
},
)
if self.on_action
else None
)
left_labels: list[str] = []
for provider in providers:
share = dict(provider_telemetry.get(provider.provider_id) or {}).get("call_share")
left_labels.append(f"{share:.0%}" if share is not None else "Н")
left_labels.append(f"{share:.0%}" if share is not None else "")
right_labels: list[str] = []
for agent in agents:
measured = dict(role_telemetry.get(agent.role_id) or {})
calls = measured.get("total_calls") if measured.get("has_data") else None
right_labels.append(f"{calls} выз." if calls is not None else "Н")
right_labels.append(f"{calls} выз." if calls is not None else "")
self.route_diagram.set_labels(left_labels, right_labels)
live_provider_ids = {provider.provider_id for provider in providers}
@ -615,11 +693,9 @@ class DashboardView(ctk.CTkFrame):
row.pack(fill="x")
self._provider_status_rows[provider.provider_id] = row
provider_latency = dict(provider_telemetry.get(provider.provider_id) or {}).get("latency_p50_ms")
detail = (
f"{provider_latency:.0f} мс"
if provider_latency is not None
else f"{provider.online_count}/{provider.connected_count}"
)
detail = f"{provider.online_count}/{provider.connected_count} онлайн"
if provider_latency is not None:
detail += f"{provider_latency:.0f} мс"
row.update_row(provider.provider_name, detail, "healthy" if provider.online_count else "warning")
host = dict(snapshot.metrics.get("host") or {})

View file

@ -3,6 +3,7 @@
from __future__ import annotations
from typing import Any, Callable, Dict, Optional
import tkinter as tk
import customtkinter as ctk
@ -26,7 +27,7 @@ class RoutingRoleWidget(HubCard):
self.meta.pack(side="left", padx=Theme.SPACE_SM)
ActionButton(
top,
text="Настроить",
text="Изменить цепочку →",
variant="secondary",
width=90,
command=lambda: self.on_action and self.on_action("edit_route", {"role_id": self.pipeline.role_id}),
@ -90,6 +91,19 @@ class RoutingView(ctk.CTkFrame):
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
def focus_role(self, role_id: str) -> None:
"""Focus the existing route editor/card selected in the graph inspector."""
widget = self._role_widgets.get(role_id)
if not widget:
return
for current in self._role_widgets.values():
current.configure(border_color=Theme.BORDER)
widget.configure(border_color=Theme.BORDER_ACCENT)
try:
self.scroll._parent_canvas.yview_moveto(max(0.0, widget.winfo_y() / max(1, self.scroll.winfo_height())))
except (AttributeError, tk.TclError):
pass
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return

View file

@ -3,6 +3,7 @@
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional
import tkinter as tk
import customtkinter as ctk
from antigravity_provider.router.ui.theme import Theme
@ -24,6 +25,49 @@ from antigravity_provider.router.unified_health import (
STATUS_AUTH_EXPIRED,
)
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.event_bus import (
EVENT_ACCOUNT_ADDED,
EVENT_ACCOUNT_AUTH_CHANGED,
EVENT_ACCOUNT_REMOVED,
EVENT_ACCOUNT_UPDATED,
EVENT_QUOTA_UPDATED,
EVENT_ROUTING_UPDATED,
EventBus,
)
from antigravity_provider.router.router_config import load_router_config
from antigravity_provider.router.ui.routing_graph import EDGE_TYPES, GraphIssue, RoutingGraphController
def persist_role_chain(role_id: str, desired_chain: List[str]) -> tuple[bool, str]:
"""Persist one ordered chain using AutoAssigner while preserving other roles."""
config = load_router_config()
policy = config.roles.get(role_id)
if policy is None:
return False, f"Роль '{role_id}' не найдена"
if len(desired_chain) != len(set(desired_chain)):
return False, "Профиль не может повторяться в одной цепочке"
missing = [profile_id for profile_id in desired_chain if profile_id not in config.profiles]
if missing:
return False, f"Профиль '{missing[0]}' не найден"
original = {key: list(value.preferred_chain) for key, value in config.roles.items()}
removed = set(original[role_id]) - set(desired_chain)
affected = {role_id}
for profile_id in removed:
affected.update(key for key, chain in original.items() if profile_id in chain)
ok, message = AutoAssigner.assign_profile_to_role(profile_id, "spare", is_primary=False)
if not ok:
return False, message
chains = {key: original[key] for key in affected}
chains[role_id] = list(desired_chain)
for target_role, chain in chains.items():
for profile_id in reversed(chain):
ok, message = AutoAssigner.assign_profile_to_role(profile_id, target_role, is_primary=True)
if not ok:
return False, message
return True, f"Цепочка '{role_id}' сохранена"
class AgentCardWidget(HubCard):
@ -116,6 +160,27 @@ class AgentCardWidget(HubCard):
command=self._open_menu,
)
self.menu_btn.pack(side="right")
self._bind_settings_click(self)
def _bind_settings_click(self, widget: Any) -> None:
if isinstance(widget, ctk.CTkButton):
return
widget.bind("<Button-1>", self._open_settings, add="+")
for child in widget.winfo_children():
self._bind_settings_click(child)
def _open_settings(self, _event: Any = None) -> None:
if not self.agent_data or not self.on_action:
return
agent = self.agent_data
self.on_action(
"agent_settings",
{
"role_id": agent.role_id,
"profile_id": agent.assigned_profile_id or "",
"provider": agent.provider,
},
)
def update_agent(self, a: AgentViewModel):
self.agent_data = a
@ -247,153 +312,638 @@ class AgentCardWidget(HubCard):
class TeamView(ctk.CTkFrame):
"""Interactive canvas over the existing router role/profile chains."""
NODE_W = 218
NODE_H = 104
def __init__(
self, master: Any, app_state: Optional[Dict[str, Any]] = None, on_action: Optional[Callable] = None, **kwargs
):
super().__init__(master=master, fg_color="transparent", **kwargs)
self.app_state = app_state or {}
self.on_action = on_action
self._card_widgets: Dict[str, AgentCardWidget] = {}
self.controller = RoutingGraphController()
self.snapshot: Optional[HubSnapshot] = None
self._live_pipelines: Dict[str, Any] = {}
self._quota_overrides: Dict[str, Any] = {}
self.selected_role = "orchestrator"
self.selected_edge = ""
self._drag_role = ""
self._drag_origin = (0.0, 0.0)
self._drag_node_origin = (0.0, 0.0)
self._node_items: Dict[str, tuple[int, ...]] = {}
self._edge_items: Dict[str, tuple[int, ...]] = {}
self._event_bus = EventBus.get()
self._subscribed = False
self._build_static_layout()
self.update_data()
self._subscribe_runtime_events()
self._draw_graph(rebuild=True)
self.after(20, self._restore_viewport)
def _build_static_layout(self):
# ── 1. Top Section Header ──
header_frame = ctk.CTkFrame(self, fg_color="transparent")
header_frame.pack(fill="x", padx=20, pady=(16, 12))
def destroy(self):
if self._subscribed:
for name in self._runtime_events():
self._event_bus.unsubscribe(name, self._on_runtime_event)
self._subscribed = False
super().destroy()
left_titles = ctk.CTkFrame(header_frame, fg_color="transparent")
left_titles.pack(side="left")
@staticmethod
def _runtime_events() -> tuple[str, ...]:
return (
EVENT_ROUTING_UPDATED,
EVENT_QUOTA_UPDATED,
EVENT_ACCOUNT_ADDED,
EVENT_ACCOUNT_UPDATED,
EVENT_ACCOUNT_REMOVED,
EVENT_ACCOUNT_AUTH_CHANGED,
)
ctk.CTkLabel(
left_titles,
text="Команда агентов",
font=Theme.font_title_page(),
text_color=Theme.TEXT_PRIMARY,
).pack(anchor="w")
def _subscribe_runtime_events(self) -> None:
if self._subscribed:
return
for name in self._runtime_events():
self._event_bus.subscribe(name, self._on_runtime_event)
self._subscribed = True
ctk.CTkLabel(
left_titles,
text="Управляйте командой Hermes и их ролями",
font=Theme.font_caption(),
text_color=Theme.TEXT_MUTED,
).pack(anchor="w", pady=(2, 0))
def _build_static_layout(self) -> None:
header = ctk.CTkFrame(self, fg_color="transparent")
header.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(12, 8))
titles = ctk.CTkFrame(header, fg_color="transparent")
titles.pack(side="left")
ctk.CTkLabel(titles, text="Граф маршрутизации", font=Theme.font_title_page(), text_color=Theme.TEXT_PRIMARY).pack(
anchor="w"
)
self.state_label = ctk.CTkLabel(
titles, text="Роли и реальные failover-цепочки", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED
)
self.state_label.pack(anchor="w")
actions = ctk.CTkFrame(header, fg_color="transparent")
actions.pack(side="right")
for text, command in (
("", self._undo),
("", self._redo),
("Авто", self._auto_layout),
("Вписать", self.fit_to_screen),
("Сохранить", self._save),
):
HubButton(actions, text=text, variant="primary" if text == "Сохранить" else "secondary", command=command).pack(
side="left", padx=3
)
right_actions = ctk.CTkFrame(header_frame, fg_color="transparent")
right_actions.pack(side="right")
toolbar = ctk.CTkFrame(self, fg_color=Theme.SURFACE, corner_radius=Theme.RADIUS_SM)
toolbar.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(0, 7))
self.search = ctk.CTkEntry(toolbar, placeholder_text="Найти роль или профиль…", width=250)
self.search.pack(side="left", padx=8, pady=6)
self.search.bind("<Return>", self._search)
ctk.CTkLabel(toolbar, text="Связь", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).pack(
side="left", padx=(8, 4)
)
self.edge_type = ctk.CTkOptionMenu(toolbar, values=list(EDGE_TYPES), width=112)
self.edge_type.set("DELEGATE")
self.edge_type.pack(side="left", pady=6)
roles = [node.role_id for node in self.controller.graph.nodes] or ["orchestrator"]
self.edge_target = ctk.CTkOptionMenu(toolbar, values=roles, width=135)
self.edge_target.set(next((role for role in roles if role != self.selected_role), roles[0]))
self.edge_target.pack(side="left", padx=(5, 0), pady=6)
profile_ids = list(load_router_config().profiles) or [""]
self.edge_profile = ctk.CTkOptionMenu(toolbar, values=["", *profile_ids], width=140)
self.edge_profile.set("")
self.edge_profile.pack(side="left", padx=(5, 0), pady=6)
HubButton(toolbar, text="Соединить", variant="secondary", command=self._connect_selected).pack(
side="left", padx=5
)
HubButton(toolbar, text="Изменить", variant="secondary", command=self._change_selected_edge).pack(
side="left", padx=(0, 5)
)
self.zoom_label = ctk.CTkLabel(toolbar, text="100%", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
self.zoom_label.pack(side="right", padx=10)
body = ctk.CTkFrame(self, fg_color="transparent")
body.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
body.grid_rowconfigure(0, weight=1)
body.grid_columnconfigure(0, weight=4)
body.grid_columnconfigure(1, weight=1)
canvas_frame = ctk.CTkFrame(body, fg_color=Theme.SURFACE_MUTED, border_width=1, border_color=Theme.BORDER)
canvas_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
self.canvas = tk.Canvas(
canvas_frame,
bg=Theme.SURFACE_MUTED,
highlightthickness=0,
xscrollincrement=1,
yscrollincrement=1,
scrollregion=(0, 0, 1800, 1200),
)
self.canvas.pack(fill="both", expand=True)
self.canvas.bind("<ButtonPress-1>", self._on_press)
self.canvas.bind("<B1-Motion>", self._on_drag)
self.canvas.bind("<ButtonRelease-1>", self._on_release)
self.canvas.bind("<MouseWheel>", self._on_zoom)
self.canvas.bind("<ButtonPress-2>", self._start_pan)
self.canvas.bind("<B2-Motion>", self._pan)
self.canvas.bind("<ButtonRelease-2>", self._end_pan)
self.canvas.bind("<Control-z>", lambda _e: self._undo())
self.canvas.bind("<Control-y>", lambda _e: self._redo())
self.canvas.bind("<Delete>", self._delete_selected_edge)
self.canvas.focus_set()
self.minimap = tk.Canvas(canvas_frame, width=155, height=95, bg=Theme.SURFACE, highlightthickness=1)
self.minimap.place(relx=1.0, rely=1.0, x=-12, y=-12, anchor="se")
self.inspector = ctk.CTkFrame(body, fg_color=Theme.SURFACE, border_width=1, border_color=Theme.BORDER)
self.inspector.grid(row=0, column=1, sticky="nsew")
self.inspector_title = ctk.CTkLabel(
self.inspector, text="Инспектор роли", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY
)
self.inspector_title.pack(anchor="w", padx=12, pady=(12, 2))
self.inspector_status = ctk.CTkLabel(
self.inspector, text="", justify="left", anchor="w", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED
)
self.inspector_status.pack(fill="x", padx=12, pady=(0, 8))
self.chain_frame = ctk.CTkScrollableFrame(self.inspector, fg_color="transparent")
self.chain_frame.pack(fill="both", expand=True, padx=8)
HubButton(
right_actions,
text="+ Добавить агент",
self.inspector,
text="Открыть маршрутизацию",
variant="primary",
height=Theme.HEIGHT_BTN_MD,
command=lambda: self._trigger_action("add_account", {}),
).pack(side="left", padx=(0, 8))
command=lambda: self._trigger_action("open_routing", {"role_id": self.selected_role}),
).pack(fill="x", padx=10, pady=10)
ctk.CTkButton(
right_actions,
text="",
width=38,
height=Theme.HEIGHT_BTN_MD,
fg_color=Theme.SURFACE,
hover_color=Theme.SURFACE_HOVER,
def _world(self, x: float, y: float) -> tuple[float, float]:
zoom = self.controller.graph.zoom
return self.canvas.canvasx(x) / zoom, self.canvas.canvasy(y) / zoom
def _on_press(self, event: Any) -> None:
item = self.canvas.find_closest(self.canvas.canvasx(event.x), self.canvas.canvasy(event.y))
tags = self.canvas.gettags(item)
edge = next((tag[5:] for tag in tags if tag.startswith("edge:")), "")
if edge:
self.selected_edge = edge
selected = next((item for item in self.controller.graph.edges if item.edge_id == edge), None)
if selected:
self.edge_type.set(selected.edge_type)
self.edge_target.set(selected.target)
self.edge_profile.set(selected.profile_id or "")
self._draw_graph(rebuild=True)
return
role = next((tag[5:] for tag in tags if tag.startswith("role:")), "")
if role:
self.selected_role = role
self.selected_edge = ""
self._drag_role = role
self._drag_origin = self._world(event.x, event.y)
node = next((item for item in self.controller.graph.nodes if item.role_id == role), None)
self._drag_node_origin = (node.x, node.y) if node else (0.0, 0.0)
self._update_inspector()
self._update_live_styles()
def _on_drag(self, event: Any) -> None:
if not self._drag_role:
return
node = next((n for n in self.controller.graph.nodes if n.role_id == self._drag_role), None)
if not node:
return
x, y = self._world(event.x, event.y)
dx, dy = x - self._drag_origin[0], y - self._drag_origin[1]
self._drag_origin = (x, y)
node.x += dx
node.y += dy
self.controller.dirty = True
self._draw_graph(rebuild=True)
def _on_release(self, _event: Any) -> None:
if self._drag_role:
node = next((n for n in self.controller.graph.nodes if n.role_id == self._drag_role), None)
if node:
final_x, final_y = node.x, node.y
node.x, node.y = self._drag_node_origin
self.controller.move_node(node.role_id, final_x, final_y)
self._drag_role = ""
self._set_dirty_text()
def _on_zoom(self, event: Any) -> str:
factor = 1.1 if event.delta > 0 else 0.9
self.controller.graph.zoom = max(0.45, min(1.8, self.controller.graph.zoom * factor))
self.controller.dirty = True
self._draw_graph(rebuild=True)
self._set_dirty_text()
return "break"
def _start_pan(self, event: Any) -> None:
self.canvas.scan_mark(event.x, event.y)
def _pan(self, event: Any) -> None:
self.canvas.scan_dragto(event.x, event.y, gain=1)
def _end_pan(self, _event: Any) -> None:
zoom = self.controller.graph.zoom
self.controller.graph.viewport_x = self.canvas.canvasx(0) / zoom
self.controller.graph.viewport_y = self.canvas.canvasy(0) / zoom
self.controller.dirty = True
self._set_dirty_text()
def _restore_viewport(self) -> None:
graph = self.controller.graph
z = graph.zoom
self.canvas.xview_moveto(max(0.0, graph.viewport_x * z / 1800.0))
self.canvas.yview_moveto(max(0.0, graph.viewport_y * z / 1200.0))
def _node_coords(self, role_id: str) -> tuple[float, float, float, float]:
node = next(item for item in self.controller.graph.nodes if item.role_id == role_id)
z = self.controller.graph.zoom
return node.x * z, node.y * z, (node.x + self.NODE_W) * z, (node.y + self.NODE_H) * z
def _draw_graph(self, rebuild: bool = True) -> None:
if rebuild:
self.canvas.delete("all")
self._node_items.clear()
self._edge_items.clear()
z = self.controller.graph.zoom
for edge in self.controller.graph.edges:
try:
sx1, sy1, sx2, sy2 = self._node_coords(edge.source)
tx1, ty1, _tx2, ty2 = self._node_coords(edge.target)
except StopIteration:
continue
start = (sx2, (sy1 + sy2) / 2)
end = (tx1, (ty1 + ty2) / 2)
dash = () if edge.edge_type == "PRIMARY" else (7, 4) if edge.edge_type == "FALLBACK" else (2, 4)
width = 3 if edge.edge_type == "PRIMARY" else 2
line = self.canvas.create_line(
*start,
*end,
smooth=True,
arrow="last",
width=width,
dash=dash,
fill=Theme.ACCENT if edge.edge_id == self.selected_edge else Theme.TEXT_ACCENT,
tags=(f"edge:{edge.edge_id}", "edge"),
)
label = self.canvas.create_text(
(start[0] + end[0]) / 2,
(start[1] + end[1]) / 2 - 9,
text=edge.edge_type,
fill=Theme.TEXT_MUTED,
font=("Segoe UI", max(7, int(8 * z)), "bold"),
tags=(f"edge:{edge.edge_id}", "edge"),
)
self._edge_items[edge.edge_id] = (line, label)
config = load_router_config()
for node in self.controller.graph.nodes:
x1, y1, x2, y2 = self._node_coords(node.role_id)
pipeline = self._pipeline_for(node.role_id)
active = pipeline.active_profile_id if pipeline else ""
chain = list(config.roles.get(node.role_id).preferred_chain) if node.role_id in config.roles else []
selected = node.role_id == self.selected_role
rect = self.canvas.create_rectangle(
x1,
y1,
x2,
y2,
width=2 if selected else 1,
outline=Theme.ACCENT if selected else Theme.BORDER,
fill=Theme.SURFACE,
tags=(f"role:{node.role_id}", "node"),
)
title = self.canvas.create_text(
x1 + 12 * z,
y1 + 17 * z,
anchor="w",
text=node.label or node.role_id,
fill=Theme.TEXT_PRIMARY,
font=("Segoe UI", max(9, int(11 * z)), "bold"),
tags=(f"role:{node.role_id}", "node"),
)
active_text = f"Активен: {active}" if active else "Активный профиль: Н"
meta = self.canvas.create_text(
x1 + 12 * z,
y1 + 43 * z,
anchor="w",
text=active_text,
fill=Theme.STATUS_HEALTHY if active else Theme.TEXT_MUTED,
font=("Segoe UI", max(7, int(8 * z))),
tags=(f"role:{node.role_id}", "node"),
)
chain_text = "".join(chain[:3]) if chain else "Нет профилей"
chain_item = self.canvas.create_text(
x1 + 12 * z,
y1 + 68 * z,
anchor="w",
width=(self.NODE_W - 24) * z,
text=chain_text,
fill=Theme.TEXT_SECONDARY,
font=("Segoe UI", max(7, int(8 * z))),
tags=(f"role:{node.role_id}", "node"),
)
self._node_items[node.role_id] = (rect, title, meta, chain_item)
self.zoom_label.configure(text=f"{self.controller.graph.zoom * 100:.0f}%")
bounds = self.canvas.bbox("all")
if bounds:
self.canvas.configure(scrollregion=(0, 0, max(1800, bounds[2] + 120), max(1200, bounds[3] + 120)))
self._draw_minimap()
self._update_inspector()
def _draw_minimap(self) -> None:
self.minimap.delete("all")
if not self.controller.graph.nodes:
return
max_x = max(node.x for node in self.controller.graph.nodes) + self.NODE_W
max_y = max(node.y for node in self.controller.graph.nodes) + self.NODE_H
scale = min(145 / max(max_x, 1), 85 / max(max_y, 1))
for node in self.controller.graph.nodes:
self.minimap.create_rectangle(
5 + node.x * scale,
5 + node.y * scale,
5 + (node.x + self.NODE_W) * scale,
5 + (node.y + self.NODE_H) * scale,
outline=Theme.ACCENT if node.role_id == self.selected_role else Theme.BORDER,
fill=Theme.SURFACE_MUTED,
)
def _update_inspector(self) -> None:
for child in self.chain_frame.winfo_children():
child.destroy()
role = self.selected_role
pipeline = self._pipeline_for(role)
node = next((item for item in self.controller.graph.nodes if item.role_id == role), None)
self.inspector_title.configure(text=node.label if node else role)
self.inspector_status.configure(
text=f"Активный: {pipeline.active_profile_id if pipeline and pipeline.active_profile_id else 'Н'}"
)
config = load_router_config()
policy = config.roles.get(role)
chain = list(policy.preferred_chain) if policy else []
live_nodes = {item.profile_id: item for item in pipeline.nodes} if pipeline else {}
for index, profile_id in enumerate(chain):
profile = config.profiles.get(profile_id)
live = live_nodes.get(profile_id)
card = HubCard(self.chain_frame)
card.pack(fill="x", pady=4)
rank = "PRIMARY" if index == 0 else f"FALLBACK {index}"
ctk.CTkLabel(card, text=rank, font=Theme.font_micro(), text_color=Theme.TEXT_ACCENT).pack(
anchor="w", padx=8, pady=(6, 0)
)
ctk.CTkLabel(
card, text=profile_id, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
).pack(anchor="w", padx=8)
provider = profile.provider if profile else "Н"
model = live.model if live else (profile.preferred_models[0] if profile and profile.preferred_models else "Н")
identity = live.account_identity if live and live.account_identity else "Аккаунт: Н"
quota = live.quota_status if live else "Н"
if profile_id in self._quota_overrides:
raw_quota = self._quota_overrides[profile_id]
quota = getattr(raw_quota, "status", None) or getattr(raw_quota, "quota_status", None) or quota
if str(quota).strip().lower() in {"", "unknown", "none", "not_configured"}:
quota = "Н"
reason = live.failover_reason if live and live.failover_reason else "Н"
ctk.CTkLabel(
card,
text=f"{provider}{model}\n{identity}\nКвота: {quota}\nFailover: {reason}",
justify="left",
anchor="w",
font=Theme.font_micro(),
text_color=Theme.TEXT_SECONDARY,
).pack(fill="x", padx=8, pady=(2, 7))
controls = ctk.CTkFrame(card, fg_color="transparent")
controls.pack(fill="x", padx=6, pady=(0, 6))
HubButton(
controls,
text="",
variant="secondary",
width=34,
height=26,
command=lambda pid=profile_id: self._move_chain_profile(pid, -1),
).pack(side="left", padx=2)
HubButton(
controls,
text="",
variant="secondary",
width=34,
height=26,
command=lambda pid=profile_id: self._move_chain_profile(pid, 1),
).pack(side="left", padx=2)
HubButton(
controls,
text="Удалить",
variant="ghost",
width=76,
height=26,
command=lambda pid=profile_id: self._remove_chain_profile(pid),
).pack(side="right", padx=2)
if not chain:
ctk.CTkLabel(
self.chain_frame, text="Профили не назначены", font=Theme.font_caption(), text_color=Theme.STATUS_WARNING
).pack(anchor="w", padx=6, pady=8)
available = [profile_id for profile_id in config.profiles if profile_id not in chain]
add_row = ctk.CTkFrame(self.chain_frame, fg_color="transparent")
add_row.pack(fill="x", pady=(8, 2))
add_menu = ctk.CTkOptionMenu(
add_row,
values=available or [""],
font=Theme.font_caption(),
fg_color=Theme.SURFACE_MUTED,
button_color=Theme.SECONDARY,
button_hover_color=Theme.SURFACE_HOVER,
text_color=Theme.TEXT_PRIMARY,
font=("Segoe UI", 14, "bold"),
corner_radius=Theme.RADIUS_SM,
command=lambda: self._trigger_action("auto_assign_all", {}),
).pack(side="left")
# ── Scrollable Body ──
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
self.scroll.pack(fill="both", expand=True, padx=15, pady=(0, 8))
# ── 2. Top 4 Metric Cards (Real Readiness) ──
metrics_grid = ctk.CTkFrame(self.scroll, fg_color="transparent")
metrics_grid.pack(fill="x", pady=(0, 16))
for i in range(4):
metrics_grid.grid_columnconfigure(i, weight=1)
self.m1 = HubMetricCard(
metrics_grid, title="АГЕНТЫ", value="0/6", subtext="готовы к работе", icon="👥", accent=True
)
self.m1.grid(row=0, column=0, padx=6, sticky="nsew")
add_menu.pack(side="left", fill="x", expand=True, padx=(0, 4))
HubButton(
add_row,
text="Добавить",
variant="secondary",
width=88,
command=lambda: self._add_chain_profile(add_menu.get()),
state="normal" if available else "disabled",
).pack(side="right")
self.m2 = HubMetricCard(metrics_grid, title="АККАУНТЫ", value="0/16", subtext="подключено", icon="💼")
self.m2.grid(row=0, column=1, padx=6, sticky="nsew")
def _persist_selected_chain(self, chain: List[str]) -> None:
ok, message = persist_role_chain(self.selected_role, chain)
self.state_label.configure(text=message, text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR)
if ok:
self._update_inspector()
self.m3 = HubMetricCard(metrics_grid, title="ПРОВАЙДЕРЫ", value="3/3", subtext="доступно", icon="")
self.m3.grid(row=0, column=2, padx=6, sticky="nsew")
def _move_chain_profile(self, profile_id: str, direction: int) -> None:
chain = list(self.controller.role_chain(self.selected_role))
if profile_id not in chain:
return
source = chain.index(profile_id)
target = source + direction
if target < 0 or target >= len(chain):
self.state_label.configure(text="Профиль уже на границе цепочки", text_color=Theme.STATUS_WARNING)
return
chain[source], chain[target] = chain[target], chain[source]
self._persist_selected_chain(chain)
self.m4 = HubMetricCard(
metrics_grid, title="СОСТОЯНИЕ", value="Healthy", subtext="Все системы работают", icon="🛡️"
)
self.m4.grid(row=0, column=3, padx=6, sticky="nsew")
def _remove_chain_profile(self, profile_id: str) -> None:
chain = [item for item in self.controller.role_chain(self.selected_role) if item != profile_id]
self._persist_selected_chain(chain)
# ── 3. Hierarchy: orchestrator → role agents ──
ctk.CTkLabel(
self.scroll,
text="ОРКЕСТРАТОР",
font=Theme.font_micro(),
text_color=Theme.TEXT_MUTED,
).pack(anchor="w", padx=Theme.SPACE_XS)
self.orchestrator_grid = ctk.CTkFrame(self.scroll, fg_color="transparent")
self.orchestrator_grid.pack(fill="x", pady=(Theme.SPACE_XS, Theme.SECTION_GAP))
self.orchestrator_grid.grid_columnconfigure(0, weight=1)
def _add_chain_profile(self, profile_id: str) -> None:
if not profile_id or profile_id == "":
return
chain = list(self.controller.role_chain(self.selected_role))
if profile_id not in chain:
chain.append(profile_id)
self._persist_selected_chain(chain)
ctk.CTkLabel(
self.scroll,
text="РОЛИ И АГЕНТЫ",
font=Theme.font_micro(),
text_color=Theme.TEXT_MUTED,
).pack(anchor="w", padx=Theme.SPACE_XS)
self.cards_grid = ctk.CTkFrame(self.scroll, fg_color="transparent")
self.cards_grid.pack(fill="both", expand=True)
for col_idx in range(3):
self.cards_grid.grid_columnconfigure(col_idx, weight=1)
def _on_runtime_event(self, name: str, data: Any) -> None:
# EventBus may publish from a worker. Tk mutation is always marshalled.
try:
self.after(0, lambda: self._apply_runtime_event(name, data))
except Exception:
pass
def update_data(self, snapshot: Optional[Any] = None):
def _apply_runtime_event(self, name: str, data: Any) -> None:
payload = data if isinstance(data, dict) else {}
if name == EVENT_ROUTING_UPDATED and payload.get("pipeline") is not None:
self._live_pipelines[str(payload.get("role_id", ""))] = payload["pipeline"]
elif name == EVENT_QUOTA_UPDATED and payload.get("profile_id"):
self._quota_overrides[str(payload["profile_id"])] = payload.get("quota_snapshot") or payload.get("snapshot")
self._update_live_styles()
def _pipeline_for(self, role_id: str) -> Any:
if role_id in self._live_pipelines:
return self._live_pipelines[role_id]
return self.snapshot.routing.get(role_id) if self.snapshot else None
def _update_live_styles(self) -> None:
"""Update existing canvas items; runtime events never rebuild the canvas."""
if not self.snapshot:
return
for role_id, items in self._node_items.items():
pipeline = self._pipeline_for(role_id)
active = pipeline.active_profile_id if pipeline else ""
self.canvas.itemconfigure(items[0], outline=Theme.ACCENT if role_id == self.selected_role else Theme.BORDER)
self.canvas.itemconfigure(items[2], text=f"Активен: {active}" if active else "Активный профиль: Н")
self._update_inspector()
def update_data(self, snapshot: Optional[Any] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return
self.snapshot = snapshot
self._live_pipelines = {
role_id: pipeline
for role_id, pipeline in self._live_pipelines.items()
if role_id in snapshot.routing and pipeline is not snapshot.routing[role_id]
}
if set(snapshot.routing) != set(self._node_items):
self._draw_graph(rebuild=True)
else:
self._update_live_styles()
readiness = snapshot.readiness
agents = snapshot.agents
def _search(self, _event: Any = None) -> str:
query = self.search.get().strip().lower()
config = load_router_config()
for node in self.controller.graph.nodes:
chain = config.roles.get(node.role_id).preferred_chain if node.role_id in config.roles else []
if query in node.role_id.lower() or query in node.label.lower() or any(query in item.lower() for item in chain):
self.selected_role = node.role_id
self._draw_graph(rebuild=True)
break
return "break"
# Update metric cards
self.m1.val_label.configure(text=f"{readiness.roles_ready_count}/{readiness.total_roles}")
self.m1.sub_label.configure(text="ролей готовы")
def focus_role(self, role_id: str) -> None:
"""Select a role when Routing delegates editing to this single editor."""
if not any(node.role_id == role_id for node in self.controller.graph.nodes):
self.state_label.configure(text=f"Роль {role_id} не найдена", text_color=Theme.STATUS_WARNING)
return
self.selected_role = role_id
self.selected_edge = ""
target = next((node.role_id for node in self.controller.graph.nodes if node.role_id != role_id), role_id)
self.edge_target.set(target)
self._draw_graph(rebuild=True)
self.state_label.configure(text=f"Редактируется цепочка: {role_id}", text_color=Theme.TEXT_ACCENT)
self.m2.val_label.configure(text=f"{readiness.accounts_connected_count}/{readiness.total_accounts}")
self.m2.sub_label.configure(text="подключено")
def _connect_selected(self) -> None:
if len(self.controller.graph.nodes) < 2:
return
source = self.selected_role
target = self.edge_target.get()
profile_id = self.edge_profile.get()
profile_id = "" if profile_id == "" else profile_id
ok, message = self.controller.add_edge(source, target, self.edge_type.get(), profile_id)
self.state_label.configure(text=message, text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR)
self._draw_graph(rebuild=True)
self._set_dirty_text()
self.m3.val_label.configure(text=f"{readiness.providers_ready_count}/{readiness.total_providers}")
self.m3.sub_label.configure(text="доступно")
def _change_selected_edge(self) -> None:
if not self.selected_edge:
self.state_label.configure(text="Сначала выберите связь на графе", text_color=Theme.STATUS_WARNING)
return
profile_id = self.edge_profile.get()
ok, message = self.controller.set_edge_type(
self.selected_edge,
self.edge_type.get(),
"" if profile_id == "" else profile_id,
)
self.state_label.configure(text=message, text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR)
self._draw_graph(rebuild=True)
self._set_dirty_text()
self.m4.val_label.configure(text=readiness.title_ru)
self.m4.sub_label.configure(text=readiness.summary_ru)
def _delete_selected_edge(self, _event: Any = None) -> str:
selected = self.canvas.find_withtag("current")
edge_id = self.selected_edge
if selected:
edge_id = next((tag[5:] for tag in self.canvas.gettags(selected[0]) if tag.startswith("edge:")), "")
if edge_id:
self.controller.delete_edge(edge_id)
self.selected_edge = ""
self._draw_graph(rebuild=True)
self._set_dirty_text()
return "break"
live_roles = {agent.role_id for agent in agents}
for role_id in list(self._card_widgets):
if role_id not in live_roles:
self._card_widgets.pop(role_id).destroy()
def _auto_layout(self) -> None:
self.controller.auto_layout()
self._draw_graph(rebuild=True)
self._set_dirty_text()
orchestrators = [agent for agent in agents if agent.is_main_orchestrator]
role_agents = [agent for agent in agents if not agent.is_main_orchestrator]
for index, agent in enumerate(orchestrators):
card = self._card_widgets.get(agent.role_id)
if card is None:
card = AgentCardWidget(self.orchestrator_grid, on_action=self.on_action)
self._card_widgets[agent.role_id] = card
card.update_agent(agent)
card.grid(row=index, column=0, padx=Theme.SPACE_XS, pady=Theme.SPACE_XS, sticky="nsew")
def _undo(self) -> None:
if self.controller.undo():
self._draw_graph(rebuild=True)
self._set_dirty_text()
for index, agent in enumerate(role_agents):
card = self._card_widgets.get(agent.role_id)
if card is None:
card = AgentCardWidget(self.cards_grid, on_action=self.on_action)
self._card_widgets[agent.role_id] = card
card.update_agent(agent)
card.grid(row=index // 3, column=index % 3, padx=6, pady=6, sticky="nsew")
def _redo(self) -> None:
if self.controller.redo():
self._draw_graph(rebuild=True)
self._set_dirty_text()
def _trigger_action(self, action: str, profile: Dict[str, Any]):
def fit_to_screen(self) -> None:
if not self.controller.graph.nodes:
return
self.update_idletasks()
max_x = max(node.x for node in self.controller.graph.nodes) + self.NODE_W
max_y = max(node.y for node in self.controller.graph.nodes) + self.NODE_H
self.controller.graph.zoom = max(0.45, min(1.4, min(self.canvas.winfo_width() / max_x, self.canvas.winfo_height() / max_y) * 0.9))
self.controller.dirty = True
self._draw_graph(rebuild=True)
self._set_dirty_text()
def _save(self) -> None:
zoom = self.controller.graph.zoom
self.controller.graph.viewport_x = self.canvas.canvasx(0) / zoom
self.controller.graph.viewport_y = self.canvas.canvasy(0) / zoom
issues = self.controller.save()
if issues:
self._show_issues(issues)
return
self.state_label.configure(text="Сохранено • позиции и масштаб переживут перезапуск", text_color=Theme.STATUS_HEALTHY)
def _show_issues(self, issues: List[GraphIssue]) -> None:
bad_nodes = {issue.node_id for issue in issues if issue.node_id}
for role_id, items in self._node_items.items():
self.canvas.itemconfigure(items[0], outline=Theme.STATUS_ERROR if role_id in bad_nodes else Theme.BORDER)
message = "".join(issue.message for issue in issues[:3])
if len(issues) > 3:
message += f" • ещё {len(issues) - 3}"
self.state_label.configure(text=message, text_color=Theme.STATUS_ERROR)
def _set_dirty_text(self) -> None:
self.state_label.configure(
text="● Есть несохранённые изменения" if self.controller.dirty else "Роли и реальные failover-цепочки",
text_color=Theme.STATUS_WARNING if self.controller.dirty else Theme.TEXT_MUTED,
)
def _trigger_action(self, action: str, profile: Dict[str, Any]) -> None:
if self.on_action:
self.on_action(action, profile)

View file

@ -511,10 +511,17 @@ class UnifiedHealthService:
dead_roles = 0
warnings: List[str] = []
total_accounts = sum(len(profs) for profs in profiles_by_prov.values())
connected_accounts = sum(
1 for profs in profiles_by_prov.values() for p in profs if p.auth_state == "AUTHENTICATED"
)
# Empty/cold placeholders are capacity for future accounts, not broken
# accounts. They remain visible in provider slot counts but must not
# downgrade a system whose configured routes are all operational.
configured_profiles = [
profile
for profiles in profiles_by_prov.values()
for profile in profiles
if not profile.is_empty_slot
]
total_accounts = len(configured_profiles)
connected_accounts = sum(1 for profile in configured_profiles if profile.auth_state == "AUTHENTICATED")
providers_online = sum(
1 for profs in profiles_by_prov.values() if any(p.health_state == STATUS_HEALTHY for p in profs)

View file

@ -2,9 +2,11 @@
from __future__ import annotations
import json
import io
import os
import tempfile
import time
import urllib.error
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
@ -187,13 +189,30 @@ def test_quota_snapshot_model_availability():
def test_antigravity_separate_claude_and_gemini_buckets():
service = AccountQuotaService()
snap = service._collect_antigravity_quota("ag-w1", {"tokens": {}})
response = MagicMock()
response.__enter__.return_value.read.return_value = json.dumps(
{
"models": {
"claude-sonnet-4-6": {
"quotaInfo": {"remainingFraction": 0.42, "resetTime": "2026-08-23T00:00:00Z"}
},
"gemini-3.7-flash": {
"quotaInfo": {"remainingFraction": 0.87, "resetTime": "2026-08-23T00:00:00Z"}
},
}
}
).encode()
with patch("antigravity_provider.cloudcode.load_or_onboard_project", return_value="project-1"), patch(
"antigravity_provider.router.quota_collector.urllib.request.urlopen", return_value=response
):
snap = service._collect_antigravity_quota(
"ag-w1", {"tokens": {"access_token": "access-token"}}
)
bucket_ids = [b.id for b in snap.buckets]
assert "antigravity.claude.5h" in bucket_ids
assert "antigravity.claude.weekly" in bucket_ids
assert "antigravity.gemini.5h" in bucket_ids
assert "antigravity.gemini.weekly" in bucket_ids
assert "antigravity.claude.model_pool" in bucket_ids
assert "antigravity.gemini.model_pool" in bucket_ids
assert snap.source == "provider_api"
# Claude bucket and Gemini bucket are independent
b_c = snap.get_bucket_for_model("claude-3-7-sonnet")
@ -201,6 +220,140 @@ def test_antigravity_separate_claude_and_gemini_buckets():
assert b_c is not None and b_c.model_family == "claude"
assert b_g is not None and b_g.model_family == "gemini"
assert b_c.remaining_percent == 42.0
assert b_g.remaining_percent == 87.0
def test_antigravity_grouped_summary_includes_five_hour_and_weekly_buckets():
service = AccountQuotaService()
response = MagicMock()
response.__enter__.return_value.read.return_value = json.dumps(
{
"groups": [
{
"displayName": "Gemini Models",
"buckets": [
{
"bucketId": "gemini-weekly",
"window": "weekly",
"remainingFraction": 0.73,
"resetTime": "2026-08-27T00:00:00Z",
},
{
"bucketId": "gemini-5h",
"window": "5h",
"remainingFraction": 0.91,
"resetTime": "2026-08-23T05:00:00Z",
},
],
},
{
"displayName": "Claude and GPT models",
"buckets": [
{
"bucketId": "3p-weekly",
"window": "weekly",
"remainingFraction": 0.44,
"resetTime": "2026-08-27T00:00:00Z",
},
{
"bucketId": "3p-5h",
"window": "5h",
"remainingFraction": 0.82,
"resetTime": "2026-08-23T05:00:00Z",
},
],
},
]
}
).encode()
with patch(
"antigravity_provider.router.quota_collector.urllib.request.urlopen", return_value=response
) as urlopen:
snapshot = service._collect_antigravity_quota(
"ag-w1", {"token": {"access_token": "access-token"}, "project_id": "project-1"}
)
assert urlopen.call_count == 1
assert [bucket.id for bucket in snapshot.buckets] == [
"antigravity.claude.5h",
"antigravity.gemini.5h",
"antigravity.claude.7d",
"antigravity.gemini.7d",
]
assert [bucket.remaining_percent for bucket in snapshot.buckets] == [82.0, 91.0, 44.0, 73.0]
assert snapshot.get_bucket_for_model("gemini-3.1-pro").remaining_percent == 73.0
assert snapshot.get_bucket_for_model("claude-sonnet-4-6").remaining_percent == 44.0
def test_antigravity_refreshes_expired_token_before_project_discovery():
service = AccountQuotaService()
response = MagicMock()
response.__enter__.return_value.read.return_value = json.dumps(
{
"models": {
"claude-sonnet-4-6": {
"quotaInfo": {"remainingFraction": 0.64, "resetTime": "2026-08-23T00:00:00Z"}
},
"gemini-3.7-flash": {
"quotaInfo": {"remainingFraction": 0.91, "resetTime": "2026-08-23T00:00:00Z"}
},
}
}
).encode()
auth_data = {
"token": {
"access_token": "expired-token",
"refresh_token": "refresh-token",
"expires_at": "2026-08-22T00:00:00Z",
}
}
with patch(
"antigravity_provider.oauth.refresh_access_token",
return_value={"access_token": "fresh-token", "expires_at": "2026-08-23T00:00:00Z"},
) as refresh, patch(
"antigravity_provider.cloudcode.load_or_onboard_project", return_value="project-1"
) as discover, patch(
"antigravity_provider.router.quota_collector.ProfileAuthManager.save_profile_auth"
) as save, patch(
"antigravity_provider.router.quota_collector.urllib.request.urlopen", return_value=response
) as urlopen:
snapshot = service._collect_antigravity_quota("ag-w1", auth_data)
refresh.assert_called_once_with("refresh-token")
discover.assert_called_once_with("fresh-token")
assert "Bearer fresh-token" == urlopen.call_args.args[0].headers["Authorization"]
assert save.called
assert auth_data["project_id"] == "project-1"
assert snapshot.source == "provider_api"
assert snapshot.get_bucket_for_model("claude-sonnet-4-6").remaining_percent == 64.0
def test_opencode_shows_published_limits_and_subscription_error():
service = AccountQuotaService()
models_response = MagicMock()
models_response.__enter__.return_value.read.return_value = b'{"data": []}'
entitlement_error = urllib.error.HTTPError(
"https://opencode.ai/zen/go/v1/usage",
403,
"Forbidden",
{},
io.BytesIO(b'{"message":"OpenCode Go subscription required"}'),
)
with patch(
"antigravity_provider.router.quota_collector.urllib.request.urlopen",
side_effect=[models_response, entitlement_error],
):
snapshot = service._collect_opencode_quota("opengo-1", {"api_key": "test-key"})
assert snapshot.source == "provider_api"
assert snapshot.unavailable_reason == "Для этого ключа не активна подписка OpenCode Go"
assert [bucket.limit_absolute for bucket in snapshot.buckets] == [12, 30, 60]
assert [bucket.period for bucket in snapshot.buckets] == ["5h", "7d", "30d"]
assert all(bucket.remaining_percent is None for bucket in snapshot.buckets)
def test_health_tracker_antigravity_claude_exhaustion_does_not_block_gemini(tmp_path):
@ -216,6 +369,17 @@ def test_health_tracker_antigravity_claude_exhaustion_does_not_block_gemini(tmp_
# Gemini should remain healthy!
assert tracker.is_healthy("ag-w1", "gemini-2.5-pro") is True
def test_live_measured_quota_clears_stale_exhaustion(tmp_path):
tracker = HealthTracker(state_file=tmp_path / "router_state.json")
tracker.mark_quota_exhausted("ag-w1", "gemini-3.1-pro", duration=3600, reason="old 429")
assert tracker.is_healthy("ag-w1", "gemini-3.1-pro") is False
changed = tracker.reconcile_measured_quota("ag-w1", {"gemini": 73.0, "claude": 44.0})
assert changed is True
assert tracker.is_healthy("ag-w1", "gemini-3.1-pro") is True
assert tracker.is_healthy("ag-w1", "gemini-2.5-flash") is True

View file

@ -2,6 +2,7 @@
from __future__ import annotations
from dataclasses import replace
import time
import pytest
@ -21,6 +22,7 @@ from antigravity_provider.router.unified_health import (
AgentViewModel,
PipelineNode,
ProfileViewModel,
ProviderSummary,
RolePipeline,
SystemReadiness,
)
@ -95,6 +97,29 @@ def test_plan_badge_distinguishes_trusted_inferred_and_unknown(ui_root) -> None:
card.destroy()
@pytest.mark.ui
def test_live_quota_overrides_stale_exhausted_card_status(ui_root) -> None:
card = AccountCardWidget(ui_root, "account-1", "user@example.test", "Antigravity")
profile = replace(_profile(), health_state="quota_exhausted", health_label_ru="Квота исчерпана")
snapshot = QuotaSnapshot(
account_id="account-1",
provider="antigravity",
source="provider_api",
buckets=[
QuotaBucket(id="claude", display_name="Claude", remaining_percent=100.0),
QuotaBucket(id="gemini", display_name="Gemini", remaining_percent=100.0),
],
)
try:
card.pack()
card.update_account(profile, snapshot)
ui_root.update_idletasks()
assert card.status.label.cget("text") == "Работает"
assert card.status.dot.cget("text_color") == Theme.STATUS_HEALTHY
finally:
card.destroy()
@pytest.mark.ui
def test_quota_missing_is_not_rendered_as_zero_and_reason_is_visible(ui_root) -> None:
widget = QuotaBucketWidget(ui_root, "bucket", "Claude 5h")
@ -187,6 +212,58 @@ def test_agent_quota_and_failover_reason_are_bound_to_their_models(ui_root) -> N
team_card.destroy()
def test_dashboard_agent_quota_measurement_drives_progress_percent() -> None:
agent = AgentViewModel(
role_id="coder-primary",
role_name_ru="Кодер 1",
role_description_ru="Основной кодер",
assigned_profile_id="ag-w1",
assigned_display_name="Primary",
provider="antigravity",
provider_display_name="Google Antigravity",
model="gemini-3.1-pro",
account_identity="user@example.test",
routing_position="Primary",
status="healthy",
status_label_ru="Работает",
is_active=True,
is_main_orchestrator=False,
active_quota_status="healthy",
active_quota_label="Осталось 73%",
)
snapshot = HubSnapshot(
generation=1,
seq=1,
timestamp=time.time(),
profiles_by_provider={},
all_profiles={},
readiness=_readiness(),
agents=[agent],
providers=[],
routing={},
quotas={
"ag-w1": QuotaSnapshot(
account_id="ag-w1",
provider="antigravity",
source="provider_api",
buckets=[
QuotaBucket(
id="antigravity.gemini.7d",
display_name="Gemini • неделя",
model_family="gemini",
remaining_percent=73.0,
)
],
)
},
)
label, percent = DashboardView._agent_quota_measurement(snapshot, agent)
assert label == "Осталось 73%"
assert percent == 73.0
@pytest.mark.ui
def test_stale_snapshot_is_visibly_marked_with_sequence(ui_root) -> None:
snapshot = HubSnapshot(
@ -215,6 +292,54 @@ def test_stale_snapshot_is_visibly_marked_with_sequence(ui_root) -> None:
view.destroy()
@pytest.mark.ui
def test_dashboard_makes_all_connected_accounts_visible_in_provider_summary(ui_root) -> None:
profiles = [
replace(
_profile(),
profile_id=f"ag-{index}",
account_identity=f"user{index}@example.test",
email=f"user{index}@example.test",
)
for index in range(6)
]
readiness = replace(_readiness(), accounts_connected_count=6, total_accounts=6)
provider = ProviderSummary(
provider_id="antigravity",
provider_name="Google Antigravity",
total_slots=10,
connected_count=6,
online_count=6,
auth_required_count=0,
quota_exhausted_count=0,
cold_spare_count=0,
discovered_models=["gemini-3.7-flash"],
last_refresh_at="12:00:00",
)
snapshot = HubSnapshot(
generation=1,
seq=1,
timestamp=time.time(),
profiles_by_provider={"antigravity": profiles},
all_profiles={profile.profile_id: profile for profile in profiles},
readiness=readiness,
agents=[],
providers=[provider],
routing={},
quotas={},
)
view = DashboardView(ui_root)
try:
view.pack()
view.update_data(snapshot)
ui_root.update_idletasks()
assert view.agents_metric.val_label.cget("text") == "6"
assert "6 аккаунт" in view._provider_cards["antigravity"].subtitle.cget("text")
assert "6/6 онлайн" in view._provider_status_rows["antigravity"].detail.cget("text")
finally:
view.destroy()
@pytest.mark.unit
def test_snapshot_unavailable_reason_can_flow_to_account_bucket() -> None:
snapshot = QuotaSnapshot(

View file

@ -15,6 +15,7 @@ import customtkinter as ctk
from antigravity_provider.router.ui.components import AccountCardWidget
from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router import hermes_hub_app as app_module
@pytest.fixture(scope="module")
@ -124,3 +125,23 @@ def test_restored_account_buttons_invoke_each_action(ui_root) -> None:
assert calls == [(action, "profile-1") for action, _label in AccountCardWidget.MANAGEMENT_ACTIONS]
finally:
card.destroy()
@pytest.mark.ui
def test_assign_role_error_stays_visible_in_open_modal(ui_root, monkeypatch) -> None:
policy = SimpleNamespace(preferred_chain=[])
monkeypatch.setattr(app_module, "load_router_config", lambda: SimpleNamespace(roles={"orchestrator": policy}))
monkeypatch.setattr(
app_module.AutoAssigner,
"assign_profile_to_role",
lambda *_args, **_kwargs: (False, "Профиль не найден"),
)
ui_root._show_account_action_result = lambda *_args: None
modal = app_module.HermesHubApp._open_assign_role_modal(ui_root, "missing", "Claude")
try:
modal.save_button.invoke()
modal.update_idletasks()
assert modal.winfo_exists()
assert "Профиль не найден" in modal.result_label.cget("text")
finally:
modal.destroy()

View file

@ -125,6 +125,7 @@ def test_fifty_accounts_update_one_quota_without_rebuilding_other_cards(ui_root)
ui_root.update_idletasks()
before = view.render_stats()
card_ids = {key: id(card) for key, card in view._cards.items()}
assert all(card.compact for card in view._cards.values())
view.update_data(_snapshot(changed_remaining=42.0))
ui_root.update_idletasks()

View file

@ -0,0 +1,328 @@
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import pytest
pytest.importorskip("customtkinter")
from antigravity_provider.router.ui import routing_graph as graph_module
from antigravity_provider.router.ui import add_account_wizard as wizard_module
from antigravity_provider.router.ui.views import team_view as team_module
from antigravity_provider.router import hermes_hub_app as app_module
from antigravity_provider.router.ui.routing_graph import (
GraphEdge,
GraphNode,
RoutingGraph,
RoutingGraphController,
RoutingGraphStore,
default_graph,
validate_graph,
)
def _config():
profiles = {
"orch": SimpleNamespace(provider="openai-codex", preferred_models=["gpt-5"]),
"coder": SimpleNamespace(provider="antigravity", preferred_models=["gemini"]),
}
roles = {
"orchestrator": SimpleNamespace(preferred_chain=["orch"]),
"coder-primary": SimpleNamespace(preferred_chain=["coder"]),
"coder-secondary": SimpleNamespace(preferred_chain=["coder"]),
"reviewer": SimpleNamespace(preferred_chain=["coder"]),
"research": SimpleNamespace(preferred_chain=["coder"]),
"fast": SimpleNamespace(preferred_chain=["coder"]),
}
return SimpleNamespace(roles=roles, profiles=profiles)
def test_antigravity_agent_catalog_keeps_gemini_pro_choices():
choices = app_module.AGENT_MODEL_OPTIONS["antigravity"]
assert "gemini-3.1-pro" in choices
assert "gemini-2.5-pro" in choices
def test_default_graph_migrates_six_roles_without_changing_chains():
config = _config()
before = {key: list(value.preferred_chain) for key, value in config.roles.items()}
graph = default_graph(config)
assert {node.role_id for node in graph.nodes} == set(config.roles)
assert {edge.edge_type for edge in graph.edges} == {"DELEGATE"}
assert before == {key: value.preferred_chain for key, value in config.roles.items()}
def test_graph_layout_zoom_and_viewport_survive_restart(tmp_path):
path = tmp_path / "routing_graph.json"
store = RoutingGraphStore(path)
graph = default_graph(_config())
graph.nodes[0].x = 777
graph.zoom = 1.35
graph.viewport_x = 42
store.save(graph)
loaded = store.load(_config())
assert loaded.nodes[0].x == 777
assert loaded.zoom == 1.35
assert loaded.viewport_x == 42
assert loaded.schema_version == 1
def test_validation_finds_cycle_unreachable_and_missing_profile():
config = _config()
config.roles["reviewer"].preferred_chain = ["ghost"]
graph = RoutingGraph(
nodes=[
GraphNode("orchestrator", 0, 0),
GraphNode("coder-primary", 1, 0),
GraphNode("reviewer", 2, 0),
],
edges=[
GraphEdge("orchestrator", "coder-primary"),
GraphEdge("coder-primary", "orchestrator"),
],
)
codes = {issue.code for issue in validate_graph(graph, config)}
assert {"cycle", "unreachable", "missing-profile"} <= codes
def test_profile_edge_updates_yaml_via_auto_assigner(monkeypatch, tmp_path):
calls = []
monkeypatch.setattr(graph_module, "load_router_config", _config)
monkeypatch.setattr(
graph_module.AutoAssigner,
"assign_profile_to_role",
lambda profile, role, is_primary: calls.append((profile, role, is_primary)) or (True, "ok"),
)
controller = RoutingGraphController(RoutingGraphStore(tmp_path / "graph.json"))
ok, _message = controller.add_edge("orchestrator", "coder-primary", "FALLBACK", "orch")
assert ok
assert calls == [("orch", "coder-primary", False)]
def test_undo_redo_and_dirty_state(monkeypatch, tmp_path):
monkeypatch.setattr(graph_module, "load_router_config", _config)
controller = RoutingGraphController(RoutingGraphStore(tmp_path / "graph.json"))
original = controller.graph.nodes[0].x
controller.move_node("orchestrator", original + 100, 50)
assert controller.dirty
assert controller.undo()
assert controller.graph.nodes[0].x == original
assert controller.redo()
assert controller.graph.nodes[0].x == original + 100
def test_graph_store_handles_twenty_nodes(tmp_path):
graph = RoutingGraph(nodes=[GraphNode(f"role-{index}", index * 70, index * 35) for index in range(20)])
store = RoutingGraphStore(tmp_path / "routing_graph.json")
store.save(graph)
assert len(store.load(_config()).nodes) == 20
def test_wizard_keeps_existing_chain_rank_and_assigns_missing_slot(monkeypatch):
config = _config()
calls = []
monkeypatch.setattr(wizard_module, "load_router_config", lambda: config)
monkeypatch.setattr(
wizard_module.AutoAssigner,
"assign_profile_to_role",
lambda profile, role, is_primary: calls.append((profile, role, is_primary)) or (True, "ok"),
)
assert wizard_module.ensure_profile_in_routing("orch")[0]
assert calls == []
monkeypatch.setattr(
wizard_module.AutoAssigner,
"get_display_name_and_role",
lambda _profile: ("Новый кодер", "coder", "fallback"),
)
assert wizard_module.ensure_profile_in_routing("new-slot")[0]
assert calls == [("new-slot", "coder", False)]
def test_profile_test_does_not_invoke_model_or_oauth(monkeypatch):
profile = SimpleNamespace(provider="antigravity", preferred_models=["gemini"], profile_id="connected")
config = SimpleNamespace(get_profile=lambda _profile_id: profile)
class Adapter:
@staticmethod
def health_check(_profile):
return True
@staticmethod
def invoke(*_args, **_kwargs):
raise AssertionError("profile test must never invoke inference")
monkeypatch.setattr(app_module, "load_router_config", lambda: config)
monkeypatch.setattr(app_module.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True})
monkeypatch.setattr(app_module.ProfileAuthManager, "load_profile_auth", lambda *_args: {"token": "present"})
monkeypatch.setattr(app_module, "get_adapter", lambda _provider: Adapter())
monkeypatch.setattr(app_module.EventLogService, "get", lambda: SimpleNamespace(log=lambda *_args, **_kwargs: None))
result = app_module.do_test_profile("antigravity", "connected")
assert result["success"] is True
assert "runtime" in result["response"]
def test_wizard_finish_closes_logs_and_clears_reused_slot(monkeypatch):
calls = []
monkeypatch.setattr(wizard_module, "ensure_profile_in_routing", lambda _profile: (True, "ok"))
monkeypatch.setattr(
wizard_module.EventLogService,
"get",
lambda: SimpleNamespace(log=lambda *args, **kwargs: calls.append(("log", args, kwargs))),
)
from antigravity_provider.router import router_engine
monkeypatch.setattr(
router_engine,
"get_router_engine",
lambda: SimpleNamespace(health=SimpleNamespace(clear_cooldown=lambda profile: calls.append(("clear", profile)))),
)
fake = SimpleNamespace(
target_slot="ag-orch-fallback",
selected_provider="antigravity",
discovered_identity="account",
finish_status_lbl=SimpleNamespace(configure=lambda **_kwargs: None),
on_complete=lambda payload: calls.append(("complete", payload)),
destroy=lambda: calls.append(("destroy",)),
)
wizard_module.AddAccountWizard._finish(fake)
assert ("clear", "ag-orch-fallback") in calls
assert any(item[0] == "log" for item in calls)
assert calls[-1] == ("destroy",)
def test_wizard_stops_when_provider_has_no_real_free_slot(monkeypatch):
calls = []
monkeypatch.setattr(wizard_module.AutoAssigner, "find_free_slot", lambda _provider: None)
fake = SimpleNamespace(
selected_provider="grok",
target_slot="old-value",
_clear_body=lambda: calls.append("clear"),
title_lbl=SimpleNamespace(configure=lambda **_kwargs: None),
_show_no_free_slot=lambda: calls.append("no-slot"),
)
wizard_module.AddAccountWizard._show_step_2_auth(fake)
assert fake.target_slot == ""
assert calls == ["clear", "no-slot"]
def test_wizard_finish_without_slot_shows_error_and_does_not_assign(monkeypatch):
updates = []
monkeypatch.setattr(
wizard_module,
"ensure_profile_in_routing",
lambda _profile: pytest.fail("an invented or empty slot must not be assigned"),
)
fake = SimpleNamespace(
target_slot="",
finish_status_lbl=SimpleNamespace(configure=lambda **kwargs: updates.append(kwargs)),
)
wizard_module.AddAccountWizard._finish(fake)
assert "свободный слот" in updates[-1]["text"]
def test_role_chain_order_and_removal_persist_through_auto_assigner(monkeypatch):
config = SimpleNamespace(
profiles={key: SimpleNamespace() for key in ("a", "b", "c")},
roles={
"orchestrator": SimpleNamespace(preferred_chain=["a", "b", "c"]),
"reviewer": SimpleNamespace(preferred_chain=["b"]),
},
)
calls = []
def assign(profile_id, role_id, is_primary=True):
calls.append((profile_id, role_id, is_primary))
if role_id == "spare":
for policy in config.roles.values():
policy.preferred_chain = [item for item in policy.preferred_chain if item != profile_id]
return True, "spare"
chain = config.roles[role_id].preferred_chain
chain = [item for item in chain if item != profile_id]
if is_primary:
chain.insert(0, profile_id)
else:
chain.append(profile_id)
config.roles[role_id].preferred_chain = chain
return True, "assigned"
monkeypatch.setattr(team_module, "load_router_config", lambda: config)
monkeypatch.setattr(team_module.AutoAssigner, "assign_profile_to_role", assign)
ok, _message = team_module.persist_role_chain("orchestrator", ["c", "a"])
assert ok
assert config.roles["orchestrator"].preferred_chain == ["c", "a"]
assert config.roles["reviewer"].preferred_chain == ["b"]
assert ("b", "spare", False) in calls
def test_account_action_result_is_sent_to_originating_card():
calls = []
accounts = SimpleNamespace(
show_action_result=lambda profile_id, message, success: calls.append(
("card", profile_id, message, success)
)
)
fake = SimpleNamespace(
_views={"accounts": accounts},
_show_toast=lambda message: calls.append(("toast", message)),
)
app_module.HermesHubApp._show_account_action_result(fake, "profile-1", "Не найден", False)
assert calls[0] == ("card", "profile-1", "Не найден", False)
assert calls[1][0] == "toast"
def test_device_code_step_contains_numbered_instructions_and_copy_actions():
source = Path(wizard_module.__file__).read_text(encoding="utf-8")
assert "1. Откройте ссылку" in source
assert "2. Введите на странице код:" in source
assert "3. Подтвердите доступ" in source
assert source.count('text="Копировать ссылку"') == 2
assert source.count('text="📋 Копировать код"') == 2
def test_grok_slot_is_registered_before_role_assignment(monkeypatch):
config = SimpleNamespace(profiles={})
saved = []
monkeypatch.setattr(wizard_module, "load_router_config", lambda: config)
monkeypatch.setattr(
"antigravity_provider.router.auto_assigner.load_router_config",
lambda: config,
)
monkeypatch.setattr(
"antigravity_provider.router.auto_assigner.save_router_config",
lambda current: saved.append(current) or True,
)
ok, _message = wizard_module.AutoAssigner.ensure_profile_definition("grok", "grok-orch")
assert ok
assert config.profiles["grok-orch"].provider == "grok"
assert config.profiles["grok-orch"].preferred_models[0] == "grok-3"
assert saved == [config]
def test_opencode_paste_targets_entry_and_reports_success():
calls = []
entry = SimpleNamespace(
clipboard_get=lambda: " opencode-token-123 ",
delete=lambda *_args: calls.append("delete"),
insert=lambda *_args: calls.append(("insert", _args[-1])),
focus_set=lambda: calls.append("focus"),
icursor=lambda *_args: calls.append("cursor"),
)
status = SimpleNamespace(configure=lambda **kwargs: calls.append(("status", kwargs["text"])))
fake = SimpleNamespace(key_entry=entry, key_status_lbl=status)
wizard_module.AddAccountWizard._paste_into_entry(fake, entry)
assert ("insert", "opencode-token-123") in calls
assert ("status", "✓ Ключ вставлен. Нажмите «Проверить и продолжить».") in calls

View file

@ -109,7 +109,7 @@ def test_system_readiness_calculation():
assert isinstance(readiness, SystemReadiness)
assert readiness.state in (READINESS_HEALTHY, READINESS_LIMITED, READINESS_DEGRADED, READINESS_CRITICAL)
assert readiness.total_roles > 0
assert readiness.total_accounts >= 16
assert readiness.total_accounts >= readiness.accounts_connected_count
assert readiness.title_ru
assert readiness.summary_ru