A17: Implement real network verification for profile test and update status snapshot
This commit is contained in:
parent
18695a3f6e
commit
4426d402d9
6 changed files with 91 additions and 30 deletions
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue