From 3e660c39bbbb147b58fe735841a3a86048840c1f Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:39:53 +0700 Subject: [PATCH] fix(a55): account connection fixes - antigravity windows/linux, ollama server discovery, version display, full settings --- src/antigravity_provider/agy_subprocess.py | 56 ++++- .../router/action_handler.py | 12 +- .../router/adapters/ollama_adapter.py | 23 +- .../router/connection_preflight.py | 8 + .../router/model_discovery_service.py | 51 +++- .../router/profile_manager.py | 47 +++- .../router/state_store.py | 5 + src/antigravity_provider/router/web/server.py | 21 +- .../router/web/static/app.js | 230 +++++++++++++++--- src/antigravity_provider/version.py | 2 +- tests/test_a42_provider_connect.py | 2 +- tests/test_a55_account_connection.py | 188 ++++++++++++++ tests/test_api_providers_a32.py | 2 +- uv.lock | 2 +- 14 files changed, 566 insertions(+), 83 deletions(-) create mode 100644 tests/test_a55_account_connection.py diff --git a/src/antigravity_provider/agy_subprocess.py b/src/antigravity_provider/agy_subprocess.py index afda537..bf59474 100644 --- a/src/antigravity_provider/agy_subprocess.py +++ b/src/antigravity_provider/agy_subprocess.py @@ -124,17 +124,57 @@ def discover_models(profile_id: str | None = None) -> dict[str, str]: # отвечала «Please sign in to view available models» — при шести рабочих # OAuth-профилях. Список моделей поэтому был пуст всегда. target_profile_id = profile_id - if not target_profile_id: + if target_profile_id: try: from antigravity_provider.router.profile_manager import ProfileAuthManager + st = ProfileAuthManager.get_profile_status("antigravity", target_profile_id) + if not st.get("authenticated") and not ProfileAuthManager.load_profile_auth("antigravity", target_profile_id): + target_profile_id = None + except Exception: + pass + + if not target_profile_id: + try: + from antigravity_provider.paths import get_hermes_home + from antigravity_provider.router.profile_manager import ProfileAuthManager + from antigravity_provider.router.router_config import load_router_config + + candidate_pids: list[str] = [] + try: + cfg = load_router_config() + for pid, pcfg in cfg.profiles.items(): + if pcfg.provider.lower() in ("antigravity", "google-antigravity") and pid not in candidate_pids: + candidate_pids.append(pid) + except Exception: + pass + main_p = ProfileAuthManager.get_main_profile("antigravity") - if main_p and ProfileAuthManager.load_profile_auth("antigravity", main_p): - target_profile_id = main_p - else: - for candidate in ["ag-orch-primary", "ag-w1", "ag-w2", "ag-w3", "ag-w4", "ag-w5"]: - if ProfileAuthManager.load_profile_auth("antigravity", candidate): - target_profile_id = candidate - break + if main_p and main_p not in candidate_pids: + candidate_pids.append(main_p) + + standard_slots = ( + ["ag-orch-primary", "ag-orch-fallback"] + + [f"ag-{i}" for i in range(1, 21)] + + [f"ag-w{i}" for i in range(1, 11)] + ) + for s in standard_slots: + if s not in candidate_pids: + candidate_pids.append(s) + + try: + agy_dir = get_hermes_home() / "agy_profiles" + if agy_dir.is_dir(): + for sub in sorted(agy_dir.iterdir()): + if sub.is_dir() and sub.name not in candidate_pids: + candidate_pids.append(sub.name) + except Exception: + pass + + for candidate in candidate_pids: + st = ProfileAuthManager.get_profile_status("antigravity", candidate) + if st.get("authenticated") or ProfileAuthManager.load_profile_auth("antigravity", candidate): + target_profile_id = candidate + break except Exception: target_profile_id = None diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index 11620b9..7198a51 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -51,12 +51,12 @@ def do_test_profile(provider: str, profile_id: str, timeout: float = 10.0, disco return {'success': False, 'error': f"Профиль '{profile_id}' не найден"} status = ProfileAuthManager.get_profile_status(pcfg.provider, profile_id) - if not status.get('authenticated'): - return {'success': False, 'error': 'Аккаунт не добавлен. Сначала выполните подключение.'} - if status.get('is_expired') or status.get('expired') or status.get('status') == 'EXPIRED': return {'success': False, 'error': 'Авторизация истекла, требуется повторный вход.'} + if not status.get('authenticated'): + return {'success': False, 'error': 'Аккаунт не добавлен. Сначала выполните подключение.'} + candidates = discovered_models if discovered_models is not None else pcfg.preferred_models if not candidates: return {'success': False, 'error': 'Сервер отвечает, но доступных моделей для тестового запроса нет' if discovered_models == [] else 'Каталог моделей не получен; сначала запросите список моделей'} @@ -753,6 +753,8 @@ class ActionExecutor: return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'} existing_status = ProfileAuthManager.get_profile_status(prov_norm, slot) if slot else {} if not token and prov_norm not in ('local', 'vllm', 'ollama') and not existing_status.get('authenticated'): + if prov_norm in ('antigravity', 'google-antigravity', 'claude', 'anthropic'): + return {'ok': False, 'message': 'Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата.'} return {'ok': False, 'message': 'Не указан API-ключ или не завершена авторизация'} validation = None if token or prov_norm in ('local', 'vllm', 'ollama'): @@ -790,7 +792,7 @@ class ActionExecutor: return {'ok': False, 'message': 'Не указан API-ключ для NVIDIA NIM'} elif prov_norm in ('claude', 'anthropic'): if not token: - return {'ok': False, 'message': 'Не указан API-ключ для Claude'} + return {'ok': False, 'message': 'Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата.'} elif prov_norm in ('opencode-go', 'opencode'): if not token: return {'ok': False, 'message': 'Не указан API-ключ для OpenCode Go'} @@ -802,7 +804,7 @@ class ActionExecutor: return {'ok': False, 'message': f'Не указан API-ключ для {prov_norm}'} elif prov_norm in ('antigravity', 'google-antigravity'): if not token and not is_authenticated: - return {'ok': False, 'message': 'Не выполнена авторизация для Antigravity'} + return {'ok': False, 'message': 'Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата.'} else: return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'} diff --git a/src/antigravity_provider/router/adapters/ollama_adapter.py b/src/antigravity_provider/router/adapters/ollama_adapter.py index 330e216..dd567e4 100644 --- a/src/antigravity_provider/router/adapters/ollama_adapter.py +++ b/src/antigravity_provider/router/adapters/ollama_adapter.py @@ -14,7 +14,7 @@ from .local_adapter import LocalLLMAdapter logger = logging.getLogger("hermes.router.adapter.ollama") -DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434/v1" +DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434" DEFAULT_OLLAMA_MODELS = ["llama3:latest"] @@ -54,21 +54,24 @@ class OllamaAdapter(LocalLLMAdapter): def _get_native_host(self, base_url: str) -> str: """Strip trailing /v1 from base_url to get native Ollama host.""" - if base_url.endswith("/v1"): - return base_url[:-3] - return base_url + b = base_url.rstrip("/") + if b.endswith("/v1"): + return b[:-3].rstrip("/") + return b def _get_chat_url(self, base_url: str) -> str: """Get standard chat completions endpoint URL.""" - if base_url.endswith("/v1"): - return f"{base_url}/chat/completions" - return f"{base_url}/v1/chat/completions" + b = base_url.rstrip("/") + if b.endswith("/v1"): + return f"{b}/chat/completions" + return f"{b}/v1/chat/completions" def _get_models_url(self, base_url: str) -> str: """Get OpenAI-compatible models endpoint URL.""" - if base_url.endswith("/v1"): - return f"{base_url}/models" - return f"{base_url}/v1/models" + b = base_url.rstrip("/") + if b.endswith("/v1"): + return f"{b}/models" + return f"{b}/v1/models" def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]: base_url = self._resolve_base_url(profile) diff --git a/src/antigravity_provider/router/connection_preflight.py b/src/antigravity_provider/router/connection_preflight.py index 612279e..e1baee9 100644 --- a/src/antigravity_provider/router/connection_preflight.py +++ b/src/antigravity_provider/router/connection_preflight.py @@ -95,6 +95,14 @@ def validate_connection(provider, token="", base_url="", preferred_model=""): message = f"Подключено и проверено. Получено моделей: {len(models)}" if models else "Сервер отвечает; моделей пока нет" return {"ok": True, "message": message, "data": {"models": models, "base_url": base_url}} except Exception as exc: + exc_str = str(exc) + exc_lower = exc_str.lower() + if provider == "ollama" and any(k in exc_lower for k in ("connection refused", "winerror 10061", "errno 111", "111", "refused", "failed to connect", "target machine actively refused")): + return { + "ok": False, + "message": f"Не удалось подключиться к {base_url} (Connection refused). Если Ollama работает на сервере или другой машине, укажите её сетевой адрес (например, http://192.168.1.81:11434).", + "data": {"models": []}, + } if isinstance(exc, urllib.error.HTTPError): reason = exc.reason or 'провайдер отклонил запрос' try: diff --git a/src/antigravity_provider/router/model_discovery_service.py b/src/antigravity_provider/router/model_discovery_service.py index 37954f4..ad07acd 100644 --- a/src/antigravity_provider/router/model_discovery_service.py +++ b/src/antigravity_provider/router/model_discovery_service.py @@ -369,9 +369,56 @@ class ModelDiscoveryService: if prov in ("antigravity", "google-antigravity"): from antigravity_provider.agy_subprocess import discover_models - main_p = getattr(self._probe_context, "profile_id", None) or ProfileAuthManager.get_main_profile("antigravity") or "ag-orch-fallback" + from antigravity_provider.paths import get_hermes_home + from antigravity_provider.router.router_config import load_router_config + + candidate_pids: List[str] = [] + req_p = getattr(self._probe_context, "profile_id", None) + if req_p: + candidate_pids.append(req_p) + try: - res = discover_models(profile_id=main_p) + cfg = load_router_config() + for pid, pcfg in cfg.profiles.items(): + if pcfg.provider.lower() in ("antigravity", "google-antigravity") and pid not in candidate_pids: + candidate_pids.append(pid) + except Exception: + pass + + main_p = ProfileAuthManager.get_main_profile("antigravity") + if main_p and main_p not in candidate_pids: + candidate_pids.append(main_p) + + standard_slots = ( + ["ag-orch-primary", "ag-orch-fallback"] + + [f"ag-{i}" for i in range(1, 21)] + + [f"ag-w{i}" for i in range(1, 11)] + ) + for s in standard_slots: + if s not in candidate_pids: + candidate_pids.append(s) + + try: + agy_dir = get_hermes_home() / "agy_profiles" + if agy_dir.is_dir(): + for sub in sorted(agy_dir.iterdir()): + if sub.is_dir() and sub.name not in candidate_pids: + candidate_pids.append(sub.name) + except Exception: + pass + + target_pid = None + for cand in candidate_pids: + st = ProfileAuthManager.get_profile_status("antigravity", cand) + if st.get("authenticated") or ProfileAuthManager.load_profile_auth("antigravity", cand): + target_pid = cand + break + + if not target_pid: + target_pid = candidate_pids[0] if candidate_pids else "ag-orch-fallback" + + try: + res = discover_models(profile_id=target_pid) if res: return sorted(list(set(res.values()))), None return None, "Модели Google Antigravity не обнаружены" diff --git a/src/antigravity_provider/router/profile_manager.py b/src/antigravity_provider/router/profile_manager.py index 99a1309..174a1c8 100644 --- a/src/antigravity_provider/router/profile_manager.py +++ b/src/antigravity_provider/router/profile_manager.py @@ -423,6 +423,17 @@ class ProfileAuthManager: except Exception as e: logger.warning("Error reading %s: %s", auth_file, e) + if not auth_file.is_file() and provider in ("antigravity", "google-antigravity"): + pdir = get_profile_dir(profile_id, provider) + gemini_creds = pdir / ".gemini" / "oauth_creds.json" + if gemini_creds.is_file(): + try: + data = json.loads(gemini_creds.read_text(encoding="utf-8")) + if isinstance(data, dict): + return {"provider": provider, "profile_id": profile_id, "token": data} + except Exception as e: + logger.warning("Error reading %s: %s", gemini_creds, e) + # Fallbacks for specific providers if provider == "openai-codex": env_var = f"CODEX_TOKEN_{profile_id.upper().replace('-', '_')}" @@ -655,11 +666,24 @@ class ProfileAuthManager: } if provider in ("antigravity", "google-antigravity"): - tokens = auth_data.get("token") or auth_data.get("tokens", {}) - acc_token = tokens.get("access_token") if isinstance(tokens, dict) else (auth_data.get("access_token") or "") - id_token = tokens.get("id_token") if isinstance(tokens, dict) else (auth_data.get("id_token") or "") - refresh_tok = tokens.get("refresh_token") if isinstance(tokens, dict) else (auth_data.get("refresh_token") or "") - email = auth_data.get("email") + tokens = auth_data.get("token") or auth_data.get("tokens") + if not isinstance(tokens, dict): + tokens = {} + acc_token = tokens.get("access_token") or auth_data.get("access_token") or "" + id_token = tokens.get("id_token") or auth_data.get("id_token") or "" + refresh_tok = tokens.get("refresh_token") or auth_data.get("refresh_token") or "" + key = auth_data.get("api_key", "") + email = auth_data.get("email") or auth_data.get("user_email") + is_auth = bool(acc_token or refresh_tok or key or (email and auth_data.get("auth_method") == "oauth")) + if not is_auth: + return { + "authenticated": False, + "provider": provider, + "profile_id": profile_id, + "status": "NOT_CONFIGURED", + "error": None, + } + acc_id = None if id_token: email_from_jwt, acc_id = cls.extract_jwt_identity(id_token) @@ -668,11 +692,14 @@ class ProfileAuthManager: email_from_jwt, acc_id = cls.extract_jwt_identity(acc_token) email = email or email_from_jwt - expiry = tokens.get("expiry_date") if isinstance(tokens, dict) else auth_data.get("expiry_date") - if not expiry and isinstance(tokens, dict): - expiry = tokens.get("expires_at") + expiry = ( + tokens.get("expiry_date") + or auth_data.get("expiry_date") + or tokens.get("expires_at") + or auth_data.get("expires_at") + ) if not expiry: - expiry_str = tokens.get("expiry") if isinstance(tokens, dict) else auth_data.get("expiry") + expiry_str = tokens.get("expiry") or auth_data.get("expiry") if expiry_str: try: dt = datetime.fromisoformat(str(expiry_str).replace("Z", "+00:00")) @@ -688,7 +715,7 @@ class ProfileAuthManager: is_expired = not bool(refresh_tok) return { - "authenticated": True, + "authenticated": not is_expired, "provider": provider, "profile_id": profile_id, "email_masked": mask_email(email) if email else None, diff --git a/src/antigravity_provider/router/state_store.py b/src/antigravity_provider/router/state_store.py index 3eb4f97..30033aa 100644 --- a/src/antigravity_provider/router/state_store.py +++ b/src/antigravity_provider/router/state_store.py @@ -11,6 +11,8 @@ import time from dataclasses import dataclass, field, replace from typing import Any, Dict, List, Optional, Tuple +from antigravity_provider.version import __version__ + from antigravity_provider.router.event_bus import ( EventBus, EVENT_ACCOUNT_UPDATED, @@ -213,7 +215,9 @@ class HubStateStore: hermes_cfg = {"exists": False, "model": None, "provider": None} default_role = "manager" + from antigravity_provider.version import __version__ metrics = { + "version": __version__, "generation": gen, "seq": request_seq, "duration_ms": round((time.time() - t0) * 1000, 2), @@ -311,6 +315,7 @@ class HubStateStore: routing={}, quotas={}, metrics={ + "version": __version__, "generation": 0, "seq": 0, "telemetry": telemetry_data, diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py index 88f2e93..6cb6f94 100644 --- a/src/antigravity_provider/router/web/server.py +++ b/src/antigravity_provider/router/web/server.py @@ -234,6 +234,13 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)): ), } + snap_dict["version"] = __version__ + snap_dict["commit"] = get_installed_commit() + snap_dict["system_paths"] = { + "hermes_home": str(paths.get_hermes_home()), + "config_dir": str(paths.get_config_dir()), + "log_file": str(paths.get_log_file()), + } return JSONResponse(content=jsonable_encoder(snap_dict)) @app.post("/api/action") @@ -387,15 +394,21 @@ def get_settings(authorized: bool = Depends(get_auth_token)): last_check = UpdateManager.get_last_check_result() server_host = raw.get("web_api_host", "127.0.0.1") is_external = (server_host != "127.0.0.1" and server_host != "localhost") + system_paths = { + "hermes_home": str(paths.get_hermes_home()), + "config_dir": str(paths.get_config_dir()), + "log_file": str(paths.get_log_file()), + } settings_out: Dict[str, Any] = { + "system_paths": system_paths, "web_api_host": server_host, "web_api_port": raw.get("web_api_port", 5800), "web_api_token_configured": has_token, "theme": raw.get("theme", "system"), - "quota_refresh_interval_sec": raw.get("quota_refresh_interval_sec", 300), - "hermes_home": str(paths.get_hermes_home()), - "config_dir": str(paths.get_config_dir()), - "log_file": str(paths.get_log_file()), + "quota_refresh_interval_sec": raw.get("quota_refresh_interval_sec", raw.get("account_check_interval_seconds", 300)), + "hermes_home": system_paths["hermes_home"], + "config_dir": system_paths["config_dir"], + "log_file": system_paths["log_file"], "installed_commit": get_installed_commit(), "version": __version__, "last_update_check": last_check.to_dict() if last_check else None, diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 85ef806..440fb9e 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -71,6 +71,7 @@ document.addEventListener('DOMContentLoaded', () => { initNavigation(); initEventListeners(); initSettings(); + fetchSettings(); fetchSnapshot(); startPolling(); checkUpdates(true); @@ -114,6 +115,10 @@ function switchView(viewName) { fetchSkills(); } + if (viewName === 'settings') { + fetchSettings(); + } + if (currentSnapshot) { renderCurrentView(); } @@ -620,6 +625,12 @@ function updateGlobalHeader() { if (kpiReadyRoles) kpiReadyRoles.textContent = `${readyRoles}/${totalRoles}`; if (kpiRolesSub) kpiRolesSub.textContent = `${readyRoles} из ${totalRoles} ролей маршрутизации активны`; if (kpiProvidersCount) kpiProvidersCount.textContent = (currentSnapshot.providers || []).length; + + const curVer = (currentSnapshot && (currentSnapshot.version || (currentSnapshot.metrics || {}).version)) || (currentSettings && currentSettings.version) || ''; + const versionTag = document.getElementById('version-tag'); + if (versionTag) { + versionTag.textContent = curVer ? `Hermes Hub Web v${curVer}` : 'Hermes Hub Web — Н/Д: версия не передана сервером'; + } } // ── VIEW ROUTER ── @@ -1616,86 +1627,166 @@ function renderLogsList(events) { } // ── SETTINGS MANAGEMENT ── +async function fetchSettings() { + try { + const headers = authToken ? { 'X-Hub-Token': authToken } : {}; + const res = await fetch('/api/settings', { headers }); + if (res.ok) { + currentSettings = await res.json(); + if (activeView === 'settings') { + renderSettingsView(); + } + return currentSettings; + } + } catch (err) { + console.error('Failed to fetch settings:', err); + } + return null; +} + function renderSettingsView() { - if (!currentSnapshot) return; - const paths = {}; // HubSnapshot does not expose filesystem paths. - const s = currentSettings; + const s = currentSettings || {}; + const sysPaths = s.system_paths || (currentSnapshot && currentSnapshot.system_paths) || {}; const elHome = document.getElementById('path-hermes-home'); const elConfig = document.getElementById('path-config-dir'); const elLog = document.getElementById('path-log-file'); - if (elHome) elHome.textContent = paths.hermes_home || 'Н/Д: API не передаёт путь'; - if (elConfig) elConfig.textContent = paths.config_dir || 'Н/Д: API не передаёт путь'; - if (elLog) elLog.textContent = paths.log_file || 'Н/Д: API не передаёт путь'; + const homePath = sysPaths.hermes_home || s.hermes_home; + const configPath = sysPaths.config_dir || s.config_dir; + const logPath = sysPaths.log_file || s.log_file; - const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent'); - const quotaActionSel = document.getElementById('setting-quota-threshold-action'); - const emailMaskingSel = document.getElementById('setting-email-masking-mode'); - const monitorIntervalInput = document.getElementById('setting-monitoring-interval'); + if (elHome) elHome.textContent = homePath || 'Н/Д: путь не передан сервером'; + if (elConfig) elConfig.textContent = configPath || 'Н/Д: путь не передан сервером'; + if (elLog) elLog.textContent = logPath || 'Н/Д: путь не передан сервером'; + + // Server Host & Port + const hostInput = document.getElementById('setting-server-host'); + const portInput = document.getElementById('setting-server-port'); + if (hostInput && s.web_api_host !== undefined) { + hostInput.value = s.web_api_host; + } + if (portInput && s.web_api_port !== undefined) { + portInput.value = s.web_api_port; + } + + // Token Badge + const tokenBadge = document.getElementById('setting-token-status-badge'); + if (tokenBadge) { + const isTokenSet = Boolean(s.web_api_token_configured || s.web_api_token); + if (isTokenSet) { + tokenBadge.textContent = '✓ Задан'; + tokenBadge.className = 'badge healthy'; + } else { + tokenBadge.textContent = 'Не задан'; + tokenBadge.className = 'badge'; + } + } + + // Account Check Interval const accountIntervalInput = document.getElementById('setting-account-check-interval'); - const defaultRoleSel = document.getElementById('setting-default-role'); + if (accountIntervalInput) { + const accVal = s.account_check_interval_seconds ?? s.account_interval; + if (accVal !== undefined && Number.isFinite(Number(accVal))) { + accountIntervalInput.value = accVal; + accountIntervalInput.disabled = false; + accountIntervalInput.placeholder = '300'; + } else { + accountIntervalInput.placeholder = 'Н/Д: не передан сервером'; + } + } + // Quota Interval + const quotaIntervalSel = document.getElementById('setting-quota-interval'); + if (quotaIntervalSel) { + const qVal = s.quota_refresh_interval_sec ?? s.quota_interval ?? s.account_check_interval_seconds; + if (qVal !== undefined) { + quotaIntervalSel.value = String(qVal); + } + } + + // Quota Threshold Percent + const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent'); if (quotaThresholdSel && s.quota_threshold_percent !== undefined) { quotaThresholdSel.value = String(Math.round(s.quota_threshold_percent)); } + + // Quota Threshold Action + const quotaActionSel = document.getElementById('setting-quota-threshold-action'); if (quotaActionSel && s.quota_threshold_action) { quotaActionSel.value = s.quota_threshold_action; } + + // Email Masking Mode + const emailMaskingSel = document.getElementById('setting-email-masking-mode'); if (emailMaskingSel && s.email_masking_mode) { emailMaskingSel.value = s.email_masking_mode; } - if (accountIntervalInput && !accountIntervalInput.dataset.loaded) { - accountIntervalInput.dataset.loaded = 'loading'; - fetch('/api/settings', {headers: authToken ? {'X-Hub-Token': authToken} : {}}) - .then(response => { if (!response.ok) throw new Error('Настройки недоступны'); return response.json(); }) - .then(settings => { - if (!Number.isFinite(Number(settings.account_check_interval_seconds))) throw new Error('Период не передан сервером'); - accountIntervalInput.value = settings.account_check_interval_seconds; - accountIntervalInput.disabled = false; - accountIntervalInput.dataset.loaded = 'yes'; - }).catch(error => { accountIntervalInput.placeholder = 'Н/Д: ' + error.message; accountIntervalInput.dataset.loaded = ''; }); + + // Default Role + const defaultRoleSel = document.getElementById('setting-default-role'); + if (defaultRoleSel) { + const currentDef = s.default_role || (currentSnapshot && currentSnapshot.metrics && currentSnapshot.metrics.default_role) || 'manager'; + defaultRoleSel.value = currentDef; } + + // Theme + const themeSel = document.getElementById('setting-theme'); + if (themeSel && s.theme) { + themeSel.value = s.theme; + } + + // Monitor Interval + const monitorIntervalInput = document.getElementById('setting-monitoring-interval'); if (monitorIntervalInput && s.monitoring_interval_seconds !== undefined) { monitorIntervalInput.value = s.monitoring_interval_seconds; } + // Obsidian Vault Path const vaultPathInput = document.getElementById('setting-obsidian-vault-path'); if (vaultPathInput) { vaultPathInput.value = s.obsidian_vault_path || '/srv/projects/AI-Memory'; } - if (defaultRoleSel) { - const currentDef = s.default_role || currentSnapshot.metrics?.default_role || 'manager'; - defaultRoleSel.value = currentDef; - } } async function saveHubServerSettings() { + const hostInput = document.getElementById('setting-server-host'); + const portInput = document.getElementById('setting-server-port'); + const tokenInput = document.getElementById('setting-server-token-input'); const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent'); const quotaActionSel = document.getElementById('setting-quota-threshold-action'); const emailMaskingSel = document.getElementById('setting-email-masking-mode'); + const quotaIntervalSel = document.getElementById('setting-quota-interval'); const monitorIntervalInput = document.getElementById('setting-monitoring-interval'); const vaultPathInput = document.getElementById('setting-obsidian-vault-path'); const accountIntervalInput = document.getElementById('setting-account-check-interval'); const defaultRoleSel = document.getElementById('setting-default-role'); + const themeSel = document.getElementById('setting-theme'); const newSettings = {}; - if (accountIntervalInput?.value) newSettings.account_check_interval_seconds = Math.max(60, Number(accountIntervalInput.value)); - if (quotaThresholdSel?.value) newSettings.quota_threshold_percent = Number(quotaThresholdSel.value); - if (quotaActionSel?.value) newSettings.quota_threshold_action = quotaActionSel.value; - if (emailMaskingSel?.value) newSettings.email_masking_mode = emailMaskingSel.value; - if (monitorIntervalInput?.value) newSettings.monitoring_interval_seconds = Number(monitorIntervalInput.value); - if (vaultPathInput?.value) newSettings.obsidian_vault_path = vaultPathInput.value.trim(); - if (defaultRoleSel?.value) newSettings.default_role = defaultRoleSel.value; + if (hostInput && hostInput.value.trim()) newSettings.web_api_host = hostInput.value.trim(); + if (portInput && portInput.value) newSettings.web_api_port = Number(portInput.value); + if (tokenInput && tokenInput.value.trim()) newSettings.web_api_token = tokenInput.value.trim(); + if (accountIntervalInput && accountIntervalInput.value) newSettings.account_check_interval_seconds = Math.max(60, Number(accountIntervalInput.value)); + if (quotaIntervalSel && quotaIntervalSel.value) newSettings.quota_refresh_interval_sec = Number(quotaIntervalSel.value); + if (quotaThresholdSel && quotaThresholdSel.value) newSettings.quota_threshold_percent = Number(quotaThresholdSel.value); + if (quotaActionSel && quotaActionSel.value) newSettings.quota_threshold_action = quotaActionSel.value; + if (emailMaskingSel && emailMaskingSel.value) newSettings.email_masking_mode = emailMaskingSel.value; + if (monitorIntervalInput && monitorIntervalInput.value) newSettings.monitoring_interval_seconds = Number(monitorIntervalInput.value); + if (vaultPathInput && vaultPathInput.value.trim()) newSettings.obsidian_vault_path = vaultPathInput.value.trim(); + if (defaultRoleSel && defaultRoleSel.value) newSettings.default_role = defaultRoleSel.value; + if (themeSel && themeSel.value) newSettings.theme = themeSel.value; + if (!Object.keys(newSettings).length) { showToast('Нет выбранных изменений', 'info'); return; } showToast('Сохранение настроек сервера...', 'info'); const res = await executeAction('save_settings', newSettings); - if (res.ok) { + if (res && res.ok) { showToast('Настройки сервера успешно сохранены', 'success'); + await fetchSettings(); fetchSnapshot(); } else { - showToast(res.message || 'Ошибка сохранения настроек сервера', 'error'); + showToast((res && res.message) || 'Ошибка сохранения настроек сервера', 'error'); } } @@ -2826,7 +2917,7 @@ function showWizardStep2(providerId) {
- +
Вход в занятый слот заменит учётные данные, которые в нём сейчас.
@@ -2835,7 +2926,18 @@ function showWizardStep2(providerId) {
-
Выберите слот и нажмите «Получить ссылку».
+
1. Откройте ссылку:
+
+ + + +
+
2. Вставьте адрес из браузера или код:
+
+ + +
+
`; footerHtml = ` @@ -2850,10 +2952,14 @@ function showWizardStep2(providerId) {
- +
+ + +
+
- Поиск серверов выполняется на машине, где запущен Hub (не в браузере). + По умолчанию указан адрес на машине с Hub (127.0.0.1:11434). Если Ollama работает на сервере или другом компьютере (например, http://192.168.1.81:11434), укажите его сетевой адрес.
@@ -2948,6 +3054,42 @@ function showWizardStep2(providerId) { ${bodyHtml} `; elements.modalFooter.innerHTML = footerHtml; + if (providerId === 'antigravity' || providerId === 'claude') { + startRedirectAuth(providerId); + } +} + +async function testOllamaConnection() { + const urlInput = document.getElementById('wiz-base-url-input'); + const tokenInput = document.getElementById('wiz-token-input'); + const resultEl = document.getElementById('wiz-ollama-check-result'); + const btn = document.getElementById('wiz-test-ollama-btn'); + if (!urlInput || !resultEl) return; + const baseUrl = (urlInput.value || '').trim() || 'http://127.0.0.1:11434'; + const token = tokenInput ? tokenInput.value.trim() : ''; + if (btn) { btn.disabled = true; btn.textContent = '⏳ Проверка…'; } + resultEl.innerHTML = 'Проверяем подключение…'; + try { + const res = await executeAction('validate_connection', { + provider: 'ollama', + base_url: baseUrl, + token: token, + }); + if (res && res.ok) { + const models = (res.data && res.data.models) || []; + window._wiz_models = models; + window._wiz_base_url = baseUrl; + resultEl.innerHTML = `✓ Подключение успешно. Моделей: ${models.length}`; + showToast('Ollama подключена успешно', 'success'); + } else { + const errMsg = (res && res.message) || 'Не удалось подключиться к Ollama'; + resultEl.innerHTML = `${escapeHtml(errMsg)}`; + } + } catch (err) { + resultEl.innerHTML = `Ошибка: ${escapeHtml(String(err))}`; + } finally { + if (btn) { btn.disabled = false; btn.textContent = 'Проверить адрес'; } + } } async function proceedToWizardStep3(providerId) { @@ -2968,11 +3110,14 @@ async function proceedToWizardStep3(providerId) { // Only read slot elements for providers that have them (grok/openai-codex have wiz-device-slot, // antigravity/claude/openrouter/nvidia have wiz-redirect-slot). Local providers have no slot elements. const isDeviceAuthFlow = providerId === 'grok' || providerId === 'openai-codex'; - const isRedirectAuthFlow = providerId === 'antigravity' || providerId === 'claude' || providerId === 'openrouter' || providerId === 'nvidia'; + const isRedirectAuthFlow = providerId === 'antigravity' || providerId === 'claude'; if (isDeviceAuthFlow) { const deviceSlot = document.getElementById('wiz-device-slot'); - window._wiz_device_profile = deviceSlot?.value || ''; + window._wiz_device_profile = window._wiz_device_profile || deviceSlot?.value || ''; } else if (isRedirectAuthFlow) { + const redirectSlot = document.getElementById('wiz-redirect-slot'); + window._wiz_device_profile = window._wiz_redirect_slot_id || window._wiz_device_profile || redirectSlot?.value || ''; + } else if (providerId === 'openrouter' || providerId === 'nvidia') { const redirectSlot = document.getElementById('wiz-redirect-slot'); window._wiz_device_profile = redirectSlot?.value || ''; } @@ -3055,6 +3200,8 @@ async function finishAddAccount(providerId) { let selectedProfileId; if (isLocalProvider) { selectedProfileId = ''; + } else if (providerId === 'antigravity' || providerId === 'claude') { + selectedProfileId = window._wiz_redirect_slot_id || window._wiz_device_profile || document.getElementById('wiz-redirect-slot')?.value || ''; } else { const deviceSlot = document.getElementById('wiz-device-slot'); const redirectSlot = document.getElementById('wiz-redirect-slot'); @@ -3278,6 +3425,7 @@ async function startRedirectAuth(providerId) { window._wiz_redirect_session = d.session_id; window._wiz_redirect_provider = providerId; window._wiz_redirect_slot_id = d.profile_id; + window._wiz_device_profile = d.profile_id; const pastesUrl = d.paste_kind !== 'code'; const label = pastesUrl @@ -3363,6 +3511,7 @@ async function submitRedirectCallback() { if (res && res.ok) { stopRedirectAuthPolling(); + window._wiz_device_profile = window._wiz_redirect_slot_id; status.innerHTML = 'Аккаунт подключён' + redirectSlotRoleNote(); showToast('Аккаунт подключён', 'success'); fetchSnapshot(); @@ -3405,6 +3554,7 @@ async function pollRedirectAuth() { if (res.ok && (res.data || {}).status === 'completed') { stopRedirectAuthPolling(); + window._wiz_device_profile = window._wiz_redirect_slot_id; status.innerHTML = 'Аккаунт подключён'; showToast('Аккаунт подключён', 'success'); fetchSnapshot(); diff --git a/src/antigravity_provider/version.py b/src/antigravity_provider/version.py index f204dff..bbec965 100644 --- a/src/antigravity_provider/version.py +++ b/src/antigravity_provider/version.py @@ -2,7 +2,7 @@ from __future__ import annotations __version__ = "0.1.2" -VERSION_INFO = (0, 1, 1) +VERSION_INFO = (0, 1, 2) CHANNEL = "stable" MINIMUM_HERMES_VERSION = "0.20.0" diff --git a/tests/test_a42_provider_connect.py b/tests/test_a42_provider_connect.py index 716b9e6..a428a0c 100644 --- a/tests/test_a42_provider_connect.py +++ b/tests/test_a42_provider_connect.py @@ -229,7 +229,7 @@ def test_p0_1_add_account_honest_rejections(): # Missing API key for claude res_cl = ActionExecutor.execute("add_account", {"provider": "claude", "token": ""}) assert res_cl["ok"] is False - assert "API-ключ" in res_cl["message"] + assert "Авторизация через браузер не завершена" in res_cl["message"] or "API-ключ" in res_cl["message"] # Unsupported provider res_unsupp = ActionExecutor.execute("add_account", {"provider": "unknown_provider_xyz"}) diff --git a/tests/test_a55_account_connection.py b/tests/test_a55_account_connection.py new file mode 100644 index 0000000..45dc260 --- /dev/null +++ b/tests/test_a55_account_connection.py @@ -0,0 +1,188 @@ +"""Tests for Task A55: Account Connection Defect Fixes (P0-1 through P0-5).""" +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from antigravity_provider.version import __version__, VERSION_INFO +from antigravity_provider.router.action_handler import ActionExecutor +from antigravity_provider.router.profile_manager import ProfileAuthManager +from antigravity_provider.router.adapters.ollama_adapter import OllamaAdapter, DEFAULT_OLLAMA_BASE_URL +from antigravity_provider.router.connection_preflight import validate_connection +from antigravity_provider.router.model_discovery_service import ModelDiscoveryService +from antigravity_provider.router.web.server import app + + +# ── P0-1: Antigravity & Browser Auth Tests ── + +def test_p0_1_add_account_unauthenticated_browser_auth_error_message(): + """add_account should return Russian browser auth message for antigravity and claude.""" + with patch.object(ProfileAuthManager, "get_profile_status", return_value={"authenticated": False}): + # Antigravity without token/completed auth + res_ag = ActionExecutor.execute("add_account", { + "provider": "antigravity", + "profile_id": "ag-1", + "token": "", + }) + assert not res_ag["ok"] + assert "Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата." in res_ag["message"] + + # Claude without token/completed auth + res_cl = ActionExecutor.execute("add_account", { + "provider": "claude", + "profile_id": "claude-1", + "token": "", + }) + assert not res_cl["ok"] + assert "Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата." in res_cl["message"] + + +def test_p0_1_profile_auth_manager_get_profile_status_antigravity(): + """get_profile_status should return authenticated: False when auth_data is empty or invalid.""" + # Unauthenticated/empty auth_data + with patch.object(ProfileAuthManager, "load_profile_auth", return_value={}): + st = ProfileAuthManager.get_profile_status("antigravity", "ag-1") + assert st["authenticated"] is False + assert st["status"] == "NOT_CONFIGURED" + + # Authenticated with access_token + with patch.object(ProfileAuthManager, "load_profile_auth", return_value={"token": {"access_token": "ya29.valid-token"}}): + st = ProfileAuthManager.get_profile_status("antigravity", "ag-1") + assert st["authenticated"] is True + assert st["status"] == "AUTHENTICATED" + + +def test_p0_1_load_profile_auth_gemini_oauth_creds_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """load_profile_auth should check .gemini/oauth_creds.json if auth.json is missing.""" + monkeypatch.setattr("antigravity_provider.paths.get_hermes_home", lambda: tmp_path) + pdir = tmp_path / "agy_profiles" / "ag-5" + gemini_dir = pdir / ".gemini" + gemini_dir.mkdir(parents=True, exist_ok=True) + creds_file = gemini_dir / "oauth_creds.json" + creds_file.write_text(json.dumps({"access_token": "token-123", "refresh_token": "refresh-456"}), encoding="utf-8") + + auth = ProfileAuthManager.load_profile_auth("antigravity", "ag-5") + assert auth is not None + assert auth.get("provider") == "antigravity" + assert auth.get("token", {}).get("access_token") == "token-123" + + +# ── P0-2: Ollama Base URL & Connection Error Tests ── + +def test_p0_2_ollama_default_base_url(): + """DEFAULT_OLLAMA_BASE_URL must be http://127.0.0.1:11434 (without trailing /v1).""" + assert DEFAULT_OLLAMA_BASE_URL == "http://127.0.0.1:11434" + + +def test_p0_2_ollama_adapter_url_builders(): + """OllamaAdapter URL methods should handle base_urls with or without /v1 cleanly.""" + adapter = OllamaAdapter() + + # Standard base url without /v1 + assert adapter._get_native_host("http://127.0.0.1:11434") == "http://127.0.0.1:11434" + assert adapter._get_chat_url("http://127.0.0.1:11434") == "http://127.0.0.1:11434/v1/chat/completions" + assert adapter._get_models_url("http://127.0.0.1:11434") == "http://127.0.0.1:11434/v1/models" + + # Base url with /v1 + assert adapter._get_native_host("http://192.168.1.81:11434/v1") == "http://192.168.1.81:11434" + assert adapter._get_chat_url("http://192.168.1.81:11434/v1") == "http://192.168.1.81:11434/v1/chat/completions" + assert adapter._get_models_url("http://192.168.1.81:11434/v1") == "http://192.168.1.81:11434/v1/models" + + # Base url with trailing slash + assert adapter._get_native_host("http://192.168.1.81:11434/") == "http://192.168.1.81:11434" + assert adapter._get_chat_url("http://192.168.1.81:11434/") == "http://192.168.1.81:11434/v1/chat/completions" + + +def test_p0_2_ollama_connection_refused_helpful_message(): + """Ollama connection refused error should provide network IP guidance.""" + # Test connection preflight when endpoint is down + res = validate_connection("ollama", token="", base_url="http://127.0.0.1:11434") + assert not res["ok"] + assert "Не удалось подключиться к http://127.0.0.1:11434 (Connection refused)" in res["message"] + assert "http://192.168.1.81:11434" in res["message"] + + +# ── P0-3: Antigravity Dynamic Model Discovery Tests ── + +def test_p0_3_antigravity_dynamic_model_discovery(): + """ModelDiscoveryService probe should find and query any authenticated Antigravity profile.""" + discovery = ModelDiscoveryService.get() + + with patch("antigravity_provider.router.profile_manager.ProfileAuthManager.get_profile_status") as mock_st, \ + patch("antigravity_provider.agy_subprocess.discover_models", return_value={"gemini-2.5-pro": "gemini-2.5-pro"}) as mock_disc: + + # Simulate ag-3 being authenticated + def status_side_effect(prov, pid): + if pid == "ag-3": + return {"authenticated": True, "provider": prov, "profile_id": pid} + return {"authenticated": False, "provider": prov, "profile_id": pid} + + mock_st.side_effect = status_side_effect + + models, err = discovery._probe_provider("antigravity") + assert err is None + assert models == ["gemini-2.5-pro"] + mock_disc.assert_called_with(profile_id="ag-3") + + +# ── P0-4: Version Info & API Propagation Tests ── + +def test_p0_4_version_single_source_of_truth(): + """Version must be 0.1.2 and VERSION_INFO must be (0, 1, 2).""" + assert __version__ == "0.1.2" + assert VERSION_INFO == (0, 1, 2) + + +def test_p0_4_version_in_api_endpoints(): + """API endpoints /api/snapshot, /api/health, and /api/settings must return version 0.1.2.""" + client = TestClient(app) + + # /api/health + res_health = client.get("/api/health") + assert res_health.status_code == 200 + assert res_health.json().get("version") == "0.1.2" + + # /api/settings + res_settings = client.get("/api/settings") + assert res_settings.status_code == 200 + assert res_settings.json().get("version") == "0.1.2" + + # /api/snapshot + res_snap = client.get("/api/snapshot") + assert res_snap.status_code == 200 + snap = res_snap.json() + assert snap.get("version") == "0.1.2" + assert (snap.get("metrics") or {}).get("version") == "0.1.2" + + +# ── P0-5: Settings View & System Paths Tests ── + +def test_p0_5_settings_api_returns_system_paths_and_hub_settings(): + """GET /api/settings must return system_paths, server host/port, token status, and hub settings.""" + client = TestClient(app) + res = client.get("/api/settings") + assert res.status_code == 200 + data = res.json() + + # System paths dict + assert "system_paths" in data + sys_paths = data["system_paths"] + assert "hermes_home" in sys_paths + assert "config_dir" in sys_paths + assert "log_file" in sys_paths + + # Server settings & tokens + assert "web_api_host" in data + assert "web_api_port" in data + assert "web_api_token_configured" in data + + # Snapshot includes system_paths + res_snap = client.get("/api/snapshot") + assert res_snap.status_code == 200 + snap = res_snap.json() + assert "system_paths" in snap + assert snap["system_paths"]["hermes_home"] == sys_paths["hermes_home"] diff --git a/tests/test_api_providers_a32.py b/tests/test_api_providers_a32.py index 119f56a..8774efb 100644 --- a/tests/test_api_providers_a32.py +++ b/tests/test_api_providers_a32.py @@ -86,7 +86,7 @@ def test_ollama_invoke_default_base_url(): assert resp["choices"][0]["message"]["content"] == "pong" req_arg = mock_urlopen.call_args[0][0] - assert req_arg.get_full_url() == f"{DEFAULT_OLLAMA_BASE_URL}/chat/completions" + assert req_arg.get_full_url() == f"{DEFAULT_OLLAMA_BASE_URL}/v1/chat/completions" assert req_arg.get_method() == "POST" assert "Authorization" not in req_arg.headers diff --git a/uv.lock b/uv.lock index ed1cae1..c07c7cf 100644 --- a/uv.lock +++ b/uv.lock @@ -297,7 +297,7 @@ wheels = [ [[package]] name = "hermes-hub" -version = "0.1.1" +version = "0.1.2" source = { editable = "." } dependencies = [ { name = "fastapi" },