diff --git a/docs/UI_STATE_CONTRACT.md b/docs/UI_STATE_CONTRACT.md index 20d154f..1f28cd3 100644 --- a/docs/UI_STATE_CONTRACT.md +++ b/docs/UI_STATE_CONTRACT.md @@ -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. | diff --git a/docs/web-api/CONTRACT.md b/docs/web-api/CONTRACT.md index 48ffffa..3618555 100644 --- a/docs/web-api/CONTRACT.md +++ b/docs/web-api/CONTRACT.md @@ -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` diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index 069d734..de92dbc 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -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]: diff --git a/src/antigravity_provider/router/hermes_hub_app.py b/src/antigravity_provider/router/hermes_hub_app.py index 38fc836..6c057ce 100644 --- a/src/antigravity_provider/router/hermes_hub_app.py +++ b/src/antigravity_provider/router/hermes_hub_app.py @@ -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) diff --git a/src/antigravity_provider/router/unified_health.py b/src/antigravity_provider/router/unified_health.py index 877999d..52aff76 100644 --- a/src/antigravity_provider/router/unified_health.py +++ b/src/antigravity_provider/router/unified_health.py @@ -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, diff --git a/tests/test_ui_routing_graph.py b/tests/test_ui_routing_graph.py index 6be7af2..08cb872 100644 --- a/tests/test_ui_routing_graph.py +++ b/tests/test_ui_routing_graph.py @@ -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):