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_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. |
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue