A17: Implement real network verification for profile test and update status snapshot

This commit is contained in:
Hermes Team 2026-08-23 22:13:24 +07:00
parent 18695a3f6e
commit 4426d402d9
6 changed files with 91 additions and 30 deletions

View file

@ -60,11 +60,12 @@ Consistency guarantees:
| `is_main_account` | `bool` | no | **Real local profile preference.** | | `is_main_account` | `bool` | no | **Real local profile preference.** |
| `is_main_orchestrator` | `bool` | no | **Derived from orchestrator chain.** | | `is_main_orchestrator` | `bool` | no | **Derived from orchestrator chain.** |
| `auth_state` | `str` | no | Normalized auth state (`AUTHENTICATED`, `AUTH_REQUIRED`, `AUTH_EXPIRED`, `NOT_CONFIGURED`). | | `auth_state` | `str` | no | Normalized auth state (`AUTHENTICATED`, `AUTH_REQUIRED`, `AUTH_EXPIRED`, `NOT_CONFIGURED`). |
| `health_state` | `str` | no | Normalized health state (`healthy`, `quota_exhausted`, `rate_limited`, `cooldown`, `disabled`, `cold_spare`, `not_configured`, `unhealthy`). | | `health_state` | `str` | no | Normalized health state (`healthy`, `not_tested`, `quota_exhausted`, `rate_limited`, `cooldown`, `disabled`, `cold_spare`, `not_configured`, `unhealthy`). |
| `health_label_ru` | `str` | no | **Derived presentation label.** | | `health_label_ru` | `str` | no | **Derived presentation label.** |
| `model_states` | `dict[str, ModelFamilyHealth]` | no | **Derived from local health tracker/runtime observations.** | | `model_states` | `dict[str, ModelFamilyHealth]` | no | **Derived from local health tracker/runtime observations.** |
| `cooldown_remaining_sec` | `int` | no | **Derived local runtime state.** Zero when healthy. | | `cooldown_remaining_sec` | `int` | no | **Derived local runtime state.** Zero when healthy. |
| `last_checked_at` | `str` | yes | **Real local check time string** (`%H:%M:%S`). | | `last_checked_at` | `str` | yes | **Real local check time string** (`%H:%M:%S`). |
| `last_success_at` | `str` | yes | **Time of last successful network test** (`%H:%M:%S`). |
| `enabled` | `bool` | no | **Real config state.** | | `enabled` | `bool` | no | **Real config state.** |
| `is_cold_spare` | `bool` | no | **Derived/configured.** | | `is_cold_spare` | `bool` | no | **Derived/configured.** |
| `is_empty_slot` | `bool` | no | **Derived** placeholder slot with no configured auth. | | `is_empty_slot` | `bool` | no | **Derived** placeholder slot with no configured auth. |

View file

@ -64,7 +64,12 @@ generation, seq, timestamp, profiles_by_provider, all_profiles,
readiness, agents, providers, routing, quotas, metrics, is_stale readiness, agents, providers, routing, quotas, metrics, is_stale
``` ```
Ответ: `200` с телом. При ошибке сбора`503` и тело `{"error": "<причина по-русски>"}`. **Обновление схемы ProfileViewModel:**
В рамках задачи A17 добавлены новые поля и состояния для отслеживания честного статуса:
- `health_state` может принимать значение `not_tested`, если профиль никогда не проверялся.
- Добавлено поле `last_success_at` (время последней успешной проверки, `%H:%M:%S`).
Ответ: `200` и JSON. При ошибках сборки — `503` с телом `{"error": "<описание проблемы>"}`.
### `POST /api/action` ### `POST /api/action`

View file

@ -49,28 +49,57 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
t0 = time.time() t0 = time.time()
try: try:
auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id) auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id)
if not auth_data: if not auth_data and pcfg.provider != 'antigravity':
return {'success': False, 'error': 'Сохранённые данные авторизации не найдены'} return {'success': False, 'error': 'Локальные данные авторизации не найдены'}
adapter = get_adapter(pcfg.provider) adapter = get_adapter(pcfg.provider)
runtime_ready = adapter.health_check(pcfg)
req = {
"model": model,
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 1
}
import threading
result_container = []
error_container = []
def _call_invoke():
try:
result_container.append(adapter.invoke(pcfg, req))
except Exception as e:
error_container.append(e)
t = threading.Thread(target=_call_invoke, daemon=True)
t.start()
t.join(timeout=10.0)
el = round(time.time() - t0, 2) el = round(time.time() - t0, 2)
if not runtime_ready:
if t.is_alive():
return { return {
'success': False, 'success': False,
'duration_sec': el, 'duration_sec': el,
'error': 'Локальный runtime провайдера недоступен; повторная авторизация не запускалась', 'error': 'Превышено время ожидания ответа от провайдера (таймаут 10с)',
} }
if error_container:
raise error_container[0]
from .health_tracker import HealthTracker
ht = HealthTracker()
ht.mark_success(profile_id, model)
EventLogService.get().log( EventLogService.get().log(
'system', f'Локальная проверка профиля {profile_id} ({model}) пройдена за {el}s.', level='success' 'system', f'Успешная проверка подключения {profile_id} ({model}) за {el}s.', level='success'
) )
return { return {
'success': True, 'success': True,
'model': model, 'model': model,
'duration_sec': el, 'duration_sec': el,
'response': 'Авторизация сохранена; runtime провайдера доступен', 'response': 'Авторизация подтверждена, ответ провайдера получен',
} }
except Exception as e: except Exception as e:
EventLogService.get().log('system', f'Ошибка теста {profile_id} ({model}): {e}', level='error') 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)} return {'success': False, 'model': model, 'duration_sec': round(time.time() - t0, 2), 'error': str(e)}
def do_delete_credentials(provider: str, profile_id: str) -> Tuple[bool, str]: def do_delete_credentials(provider: str, profile_id: str) -> Tuple[bool, str]:

View file

@ -34,10 +34,15 @@ if sys.platform == "win32":
pass pass
# ── Ensure plugin and repo paths are on sys.path ── # ── Ensure plugin and repo paths are on sys.path ──
_LOCAL = Path(os.environ.get("LOCALAPPDATA", "")) _SRC_DIR = Path(__file__).resolve().parent.parent.parent
_PLUGIN_SRC = _LOCAL / "hermes" / "plugins" / "antigravity-provider" / "src" if str(_SRC_DIR) not in sys.path:
_AGENT_DIR = _LOCAL / "hermes" / "hermes-agent" sys.path.insert(0, str(_SRC_DIR))
for _p in [_PLUGIN_SRC, _AGENT_DIR, Path(__file__).resolve().parent.parent.parent]:
from antigravity_provider import paths
_hermes_home = paths.get_hermes_home()
_PLUGIN_SRC = _hermes_home / "plugins" / "antigravity-provider" / "src"
_AGENT_DIR = _hermes_home / "hermes-agent"
for _p in [_PLUGIN_SRC, _AGENT_DIR]:
_ps = str(_p) _ps = str(_p)
if _p.exists() and _ps not in sys.path: if _p.exists() and _ps not in sys.path:
sys.path.insert(0, _ps) sys.path.insert(0, _ps)

View file

@ -15,6 +15,7 @@ import logging
import os import os
import threading import threading
import time import time
import datetime
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
@ -84,10 +85,11 @@ class ProfileViewModel:
health_label_ru: str health_label_ru: str
model_states: Dict[str, ModelFamilyHealth] model_states: Dict[str, ModelFamilyHealth]
cooldown_remaining_sec: int cooldown_remaining_sec: int
last_checked_at: Optional[str] last_checked_at: Optional[str] = None
enabled: bool last_success_at: Optional[str] = None
is_cold_spare: bool enabled: bool = True
is_empty_slot: bool is_cold_spare: bool = False
is_empty_slot: bool = False
email: str = "" email: str = ""
plan: str = "Тариф: неизвестен" plan: str = "Тариф: неизвестен"
plan_code: str = "UNKNOWN" plan_code: str = "UNKNOWN"
@ -426,10 +428,14 @@ class UnifiedHealthService:
elif precord.overall_state == HT_UNHEALTHY: elif precord.overall_state == HT_UNHEALTHY:
health_state = STATUS_UNHEALTHY health_state = STATUS_UNHEALTHY
health_lbl = "Ошибка" health_lbl = "Ошибка"
# 6. Live healthy # 6. Live healthy or untested
else: else:
health_state = STATUS_HEALTHY if precord.last_success is not None:
health_lbl = "Работает" health_state = STATUS_HEALTHY
health_lbl = "Работает"
else:
health_state = STATUS_NOT_TESTED
health_lbl = "Не проверялся"
from .quota_collector import AccountQuotaService from .quota_collector import AccountQuotaService
ident = AccountQuotaService.get().get_identity(prov, pid) ident = AccountQuotaService.get().get_identity(prov, pid)
@ -448,6 +454,8 @@ class UnifiedHealthService:
"xai": "Grok", "xai": "Grok",
}.get(prov.lower(), prov) }.get(prov.lower(), prov)
last_success_str = datetime.datetime.fromtimestamp(precord.last_success).strftime("%H:%M:%S") if precord.last_success else None
vm = ProfileViewModel( vm = ProfileViewModel(
profile_id=pid, profile_id=pid,
display_name=display_name, display_name=display_name,
@ -464,6 +472,7 @@ class UnifiedHealthService:
model_states=model_states, model_states=model_states,
cooldown_remaining_sec=max_cd, cooldown_remaining_sec=max_cd,
last_checked_at=now_str, last_checked_at=now_str,
last_success_at=last_success_str,
enabled=pcfg.enabled, enabled=pcfg.enabled,
is_cold_spare=is_cold, is_cold_spare=is_cold,
is_empty_slot=is_empty, is_empty_slot=is_empty,

View file

@ -152,28 +152,40 @@ def test_wizard_keeps_existing_chain_rank_and_assigns_missing_slot(monkeypatch):
assert calls == [("new-slot", "coder", False)] assert calls == [("new-slot", "coder", False)]
def test_profile_test_does_not_invoke_model_or_oauth(monkeypatch): def test_profile_test_invokes_model_with_timeout_and_records_success(monkeypatch):
profile = SimpleNamespace(provider="antigravity", preferred_models=["gemini"], profile_id="connected") profile = SimpleNamespace(provider="antigravity", preferred_models=["gemini"], profile_id="connected")
config = SimpleNamespace(get_profile=lambda _profile_id: profile) config = SimpleNamespace(get_profile=lambda _profile_id: profile)
class Adapter: class Adapter:
@staticmethod @staticmethod
def health_check(_profile): def invoke(profile, req, *args, **kwargs):
return True return {"choices": [{"message": {"content": "pong"}}]}
@staticmethod
def invoke(*_args, **_kwargs):
raise AssertionError("profile test must never invoke inference")
monkeypatch.setattr(action_handler, "load_router_config", lambda: config) monkeypatch.setattr(action_handler, "load_router_config", lambda: config)
monkeypatch.setattr(action_handler.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True}) monkeypatch.setattr(action_handler.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True, "is_expired": False})
monkeypatch.setattr(action_handler.ProfileAuthManager, "load_profile_auth", lambda *_args: {"token": "present"}) monkeypatch.setattr(action_handler.ProfileAuthManager, "load_profile_auth", lambda *_args: {"token": "present"})
monkeypatch.setattr(action_handler, "get_adapter", lambda _provider: Adapter()) monkeypatch.setattr(action_handler, "get_adapter", lambda _provider: Adapter())
monkeypatch.setattr(action_handler.EventLogService, "get", lambda: SimpleNamespace(log=lambda *_args, **_kwargs: None)) monkeypatch.setattr(action_handler.EventLogService, "get", lambda: SimpleNamespace(log=lambda *_args, **_kwargs: None))
def mock_mark_success(self, p, m):
self.marked = True
monkeypatch.setattr("antigravity_provider.router.health_tracker.HealthTracker.mark_success", mock_mark_success)
result = action_handler.do_test_profile("antigravity", "connected") result = action_handler.do_test_profile("antigravity", "connected")
assert result["success"] is True assert result["success"] is True
assert "runtime" in result["response"] assert "Авторизация подтверждена" in result["response"]
def test_profile_test_expired_credentials_fail_immediately(monkeypatch):
profile = SimpleNamespace(provider="antigravity", preferred_models=["gemini"], profile_id="connected")
config = SimpleNamespace(get_profile=lambda _profile_id: profile)
monkeypatch.setattr(action_handler, "load_router_config", lambda: config)
monkeypatch.setattr(action_handler.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True, "is_expired": True})
result = action_handler.do_test_profile("antigravity", "connected")
assert result["success"] is False
assert "Авторизация истекла" in result["error"]
def test_wizard_finish_closes_logs_and_clears_reused_slot(monkeypatch): def test_wizard_finish_closes_logs_and_clears_reused_slot(monkeypatch):