fix(accounts): чужая почта после смены аккаунта, отказ NVIDIA при верном ключе, отмена выхода

Кэш опознания. _identities и _snapshots в AccountQuotaService живут в
памяти по ключу «провайдер:слот» и при удалении ключа не чистились.
Владелец удалил аккаунт Antigravity, завёл victor.trushenko@gmail.com, а
в списке остался прежний trushenko.semya@gmail.com. Добавлен
forget_profile, вызывается при удалении учётных данных и при перезаписи
слота другим аккаунтом. Проверено: после сброса запись исчезает.

NVIDIA. Проверка подключения получала каталог моделей успешно — то есть
ключ рабочий и аккаунт опознан, — а затем делала пробный запрос к первой
чат-модели каталога. Каталог NVIDIA общий, доступ к конкретной модели
даётся по аккаунту, и ответ «Function ... Not found for account <id>»
объявлялся провалом подключения. Теперь успешный список моделей считается
доказательством работоспособности ключа, а неудачная проба возвращается
примечанием с предложением выбрать доступную модель.

401 и 403 при этом по-прежнему означают отказ: ключ отвергнут. Различие
поймал тест A54 на неверном ключе.

Выход из программы. Неудачная остановка процессов отменяла выход целиком:
владелец нажимал «закрыть» и оставался в работающем приложении. Теперь
показывается предупреждение, а программа закрывается.

599 passed, ruff clean, релизный гейт 10/10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-08-31 23:06:37 +07:00
parent b2ca7cdd4d
commit e74d4fbe4a
4 changed files with 55 additions and 9 deletions

View file

@ -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();

View file

@ -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

View file

@ -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 <id>»,
# то есть аккаунт опознан. Валить подключение из-за этого нельзя.
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:

View file

@ -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: