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_orchestrator` | `bool` | no | **Derived from orchestrator chain.** |
| `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.** |
| `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. |
| `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.** |
| `is_cold_spare` | `bool` | no | **Derived/configured.** |
| `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
```
Ответ: `200` с телом. При ошибке сбора`503` и тело `{"error": "<причина по-русски>"}`.
**Обновление схемы ProfileViewModel:**
В рамках задачи A17 добавлены новые поля и состояния для отслеживания честного статуса:
- `health_state` может принимать значение `not_tested`, если профиль никогда не проверялся.
- Добавлено поле `last_success_at` (время последней успешной проверки, `%H:%M:%S`).
Ответ: `200` и JSON. При ошибках сборки — `503` с телом `{"error": "<описание проблемы>"}`.
### `POST /api/action`

View file

@ -49,28 +49,57 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
t0 = time.time()
try:
auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id)
if not auth_data:
return {'success': False, 'error': 'Сохранённые данные авторизации не найдены'}
if not auth_data and pcfg.provider != 'antigravity':
return {'success': False, 'error': 'Локальные данные авторизации не найдены'}
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)
if not runtime_ready:
if t.is_alive():
return {
'success': False,
'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(
'system', f'Локальная проверка профиля {profile_id} ({model}) пройдена за {el}s.', level='success'
'system', f'Успешная проверка подключения {profile_id} ({model}) за {el}s.', level='success'
)
return {
'success': True,
'model': model,
'duration_sec': el,
'response': 'Авторизация сохранена; runtime провайдера доступен',
'response': 'Авторизация подтверждена, ответ провайдера получен',
}
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)}
def do_delete_credentials(provider: str, profile_id: str) -> Tuple[bool, str]:

View file

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

View file

@ -15,6 +15,7 @@ import logging
import os
import threading
import time
import datetime
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
@ -84,10 +85,11 @@ class ProfileViewModel:
health_label_ru: str
model_states: Dict[str, ModelFamilyHealth]
cooldown_remaining_sec: int
last_checked_at: Optional[str]
enabled: bool
is_cold_spare: bool
is_empty_slot: bool
last_checked_at: Optional[str] = None
last_success_at: Optional[str] = None
enabled: bool = True
is_cold_spare: bool = False
is_empty_slot: bool = False
email: str = ""
plan: str = "Тариф: неизвестен"
plan_code: str = "UNKNOWN"
@ -426,10 +428,14 @@ class UnifiedHealthService:
elif precord.overall_state == HT_UNHEALTHY:
health_state = STATUS_UNHEALTHY
health_lbl = "Ошибка"
# 6. Live healthy
# 6. Live healthy or untested
else:
health_state = STATUS_HEALTHY
health_lbl = "Работает"
if precord.last_success is not None:
health_state = STATUS_HEALTHY
health_lbl = "Работает"
else:
health_state = STATUS_NOT_TESTED
health_lbl = "Не проверялся"
from .quota_collector import AccountQuotaService
ident = AccountQuotaService.get().get_identity(prov, pid)
@ -448,6 +454,8 @@ class UnifiedHealthService:
"xai": "Grok",
}.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(
profile_id=pid,
display_name=display_name,
@ -464,6 +472,7 @@ class UnifiedHealthService:
model_states=model_states,
cooldown_remaining_sec=max_cd,
last_checked_at=now_str,
last_success_at=last_success_str,
enabled=pcfg.enabled,
is_cold_spare=is_cold,
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)]
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")
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")
def invoke(profile, req, *args, **kwargs):
return {"choices": [{"message": {"content": "pong"}}]}
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, "get_adapter", lambda _provider: Adapter())
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")
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):