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:
parent
b2ca7cdd4d
commit
e74d4fbe4a
4 changed files with 55 additions and 9 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
|
@ -336,7 +336,13 @@ namespace HermesHub
|
||||||
kill.UseShellExecute = false; kill.CreateNoWindow = true;
|
kill.UseShellExecute = false; kill.CreateNoWindow = true;
|
||||||
using (Process process = Process.Start(kill)) { process.WaitForExit(5000); }
|
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();
|
tray.Visible = false; tray.Dispose(); timer.Dispose(); ExitThread();
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
closing = false; timer.Start();
|
closing = false; timer.Start();
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,8 @@ def do_delete_credentials(provider: str, profile_id: str, actor: str = "system")
|
||||||
try:
|
try:
|
||||||
auth_p.unlink()
|
auth_p.unlink()
|
||||||
from .state_store import HubStateStore
|
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)
|
HubStateStore.get().apply_delta_account_removed(provider, profile_id)
|
||||||
EventLogService.get().log(
|
EventLogService.get().log(
|
||||||
'account',
|
'account',
|
||||||
|
|
@ -835,6 +837,10 @@ class ActionExecutor:
|
||||||
# Повторный вызов здесь обращался к auth_data, которой у уже
|
# Повторный вызов здесь обращался к 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)
|
AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False)
|
||||||
if validation:
|
if validation:
|
||||||
from .model_discovery_service import ModelDiscoveryService
|
from .model_discovery_service import ModelDiscoveryService
|
||||||
|
|
|
||||||
|
|
@ -63,14 +63,35 @@ def validate_connection(provider, token="", base_url="", preferred_model=""):
|
||||||
raise ValueError("Каталог NVIDIA пуст: проверить ключ тестовым запросом невозможно")
|
raise ValueError("Каталог NVIDIA пуст: проверить ключ тестовым запросом невозможно")
|
||||||
if preferred_model and preferred_model not in models:
|
if preferred_model and preferred_model not in models:
|
||||||
raise ValueError("Выбранной модели нет в каталоге NVIDIA")
|
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'))]
|
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:
|
probe_target = preferred_model or (chat_models[0] if chat_models else None)
|
||||||
raise ValueError("Ключ пока не проверен: в каталоге NVIDIA не найдена чат-модель для теста")
|
if probe_target:
|
||||||
result = request("/chat/completions", {"model": preferred_model or chat_models[0],
|
try:
|
||||||
"messages": [{"role": "user", "content": "ping"}], "max_tokens": 1})
|
result = request("/chat/completions", {"model": probe_target,
|
||||||
if not isinstance(result.get("choices"), list) or not result["choices"]:
|
"messages": [{"role": "user", "content": "ping"}], "max_tokens": 1})
|
||||||
raise ValueError("NVIDIA не вернула результат тестового запроса")
|
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 "Сервер отвечает; моделей пока нет"
|
message = f"Подключено и проверено. Получено моделей: {len(models)}" if models else "Сервер отвечает; моделей пока нет"
|
||||||
return {"ok": True, "message": message, "data": {"models": models, "base_url": base_url}}
|
return {"ok": True, "message": message, "data": {"models": models, "base_url": base_url}}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,19 @@ class AccountQuotaService:
|
||||||
# If not in cache, generate baseline snapshot from profile auth
|
# If not in cache, generate baseline snapshot from profile auth
|
||||||
return self._generate_baseline_snapshot(provider, profile_id)
|
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:
|
def get_identity(self, provider: str, profile_id: str) -> AccountIdentity:
|
||||||
key = f"{provider}:{profile_id}"
|
key = f"{provider}:{profile_id}"
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue