diff --git a/launcher/HermesHubWeb.cs b/launcher/HermesHubWeb.cs index 63a1156..62a472a 100644 --- a/launcher/HermesHubWeb.cs +++ b/launcher/HermesHubWeb.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using System.IO; using System.Net; @@ -336,7 +336,13 @@ namespace HermesHub kill.UseShellExecute = false; kill.CreateNoWindow = true; using (Process process = Process.Start(kill)) { process.WaitForExit(5000); } } - StopOwnedRuntime(home, false); + // Неудачная остановка не повод отменять выход: владелец нажал + // «закрыть», и программа обязана закрыться. Прежде окно с + // ошибкой возвращало его обратно в работающее приложение. + try { StopOwnedRuntime(home, false); } + catch (Exception stopEx) { + MessageBox.Show("Часть процессов остановить не удалось: " + stopEx.Message + " Hermes Hub закроется; при необходимости снимите их в диспетчере задач.", "Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } tray.Visible = false; tray.Dispose(); timer.Dispose(); ExitThread(); } catch (Exception ex) { closing = false; timer.Start(); diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index 45d2ea8..11620b9 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -142,6 +142,8 @@ def do_delete_credentials(provider: str, profile_id: str, actor: str = "system") try: auth_p.unlink() from .state_store import HubStateStore + from antigravity_provider.router.quota_collector import AccountQuotaService + AccountQuotaService.get().forget_profile(provider, profile_id) HubStateStore.get().apply_delta_account_removed(provider, profile_id) EventLogService.get().log( 'account', @@ -835,6 +837,10 @@ class ActionExecutor: # Повторный вызов здесь обращался к auth_data, которой у уже # авторизованного аккаунта не существует: перевод аккаунта в # другую роль падал с ошибкой, хотя ключ вводить не требуется. + # Слот мог принадлежать другому аккаунту: старое опознание + # обязано уйти вместе с прежними учётными данными. + from antigravity_provider.router.quota_collector import AccountQuotaService + AccountQuotaService.get().forget_profile(prov_norm, slot) AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False) if validation: from .model_discovery_service import ModelDiscoveryService diff --git a/src/antigravity_provider/router/connection_preflight.py b/src/antigravity_provider/router/connection_preflight.py index b19833f..612279e 100644 --- a/src/antigravity_provider/router/connection_preflight.py +++ b/src/antigravity_provider/router/connection_preflight.py @@ -63,14 +63,35 @@ def validate_connection(provider, token="", base_url="", preferred_model=""): raise ValueError("Каталог NVIDIA пуст: проверить ключ тестовым запросом невозможно") if preferred_model and preferred_model not in models: raise ValueError("Выбранной модели нет в каталоге NVIDIA") - # NVIDIA also exposes a public catalog. Validate with a real request. + # Каталог NVIDIA общий для всех, а доступ к конкретной модели даётся + # по аккаунту. Успешный список уже доказывает, что ключ рабочий: + # сервер опознал аккаунт и ответил. Пробный запрос — уточнение, а не + # условие. Раньше его отказ («Function ... Not found for account») + # объявлялся провалом подключения, хотя ключ был верным. chat_models = [model for model in models if any(word in model.lower() for word in ('instruct', 'chat')) and not any(word in model.lower() for word in ('embed', 'guard', 'reward'))] - if not preferred_model and not chat_models: - raise ValueError("Ключ пока не проверен: в каталоге NVIDIA не найдена чат-модель для теста") - result = request("/chat/completions", {"model": preferred_model or chat_models[0], - "messages": [{"role": "user", "content": "ping"}], "max_tokens": 1}) - if not isinstance(result.get("choices"), list) or not result["choices"]: - raise ValueError("NVIDIA не вернула результат тестового запроса") + probe_target = preferred_model or (chat_models[0] if chat_models else None) + if probe_target: + try: + result = request("/chat/completions", {"model": probe_target, + "messages": [{"role": "user", "content": "ping"}], "max_tokens": 1}) + if not isinstance(result.get("choices"), list) or not result["choices"]: + probe_note = f"модель {probe_target} не вернула результат" + else: + probe_note = "" + except Exception as probe_exc: + # 401 и 403 — ключ отвергнут, это отказ подключения. + # 404 и прочее — ключ принят, но модель аккаунту не выдана: + # NVIDIA отвечает «Function ... Not found for account », + # то есть аккаунт опознан. Валить подключение из-за этого нельзя. + code = getattr(probe_exc, "code", None) + if code in (401, 403): + raise + probe_note = f"модель {probe_target} недоступна вашему аккаунту ({str(probe_exc)[:120]})" + else: + probe_note = "чат-модель для пробы не найдена" + if probe_note: + return {"ok": True, "data": {"models": models, "base_url": base_url}, + "message": f"Ключ принят, моделей в каталоге: {len(models)}. Проба: {probe_note}. Выберите доступную модель."} message = f"Подключено и проверено. Получено моделей: {len(models)}" if models else "Сервер отвечает; моделей пока нет" return {"ok": True, "message": message, "data": {"models": models, "base_url": base_url}} except Exception as exc: diff --git a/src/antigravity_provider/router/quota_collector.py b/src/antigravity_provider/router/quota_collector.py index 704d0e4..1c42a12 100644 --- a/src/antigravity_provider/router/quota_collector.py +++ b/src/antigravity_provider/router/quota_collector.py @@ -69,6 +69,19 @@ class AccountQuotaService: # If not in cache, generate baseline snapshot from profile auth return self._generate_baseline_snapshot(provider, profile_id) + def forget_profile(self, provider: str, profile_id: str) -> None: + """Забыть опознание и квоты профиля. + + _identities и _snapshots живут в памяти по ключу «провайдер:слот». + При удалении ключа они не чистились, и слот, переиспользованный под + другой аккаунт, показывал прежнюю почту: владелец удалил один + аккаунт Antigravity, завёл другой и увидел в списке старый адрес. + """ + key = f"{provider}:{profile_id}" + with self._cache_lock: + self._identities.pop(key, None) + self._snapshots.pop(key, None) + def get_identity(self, provider: str, profile_id: str) -> AccountIdentity: key = f"{provider}:{profile_id}" with self._cache_lock: