fix(a55): account connection fixes - antigravity windows/linux, ollama server discovery, version display, full settings

This commit is contained in:
ochenstarik-ui 2026-08-31 23:39:53 +07:00
parent 26f7d2ce73
commit 3e660c39bb
14 changed files with 566 additions and 83 deletions

View file

@ -124,15 +124,55 @@ 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):
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:

View file

@ -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} не поддерживается для прямого добавления учетных данных'}

View file

@ -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)

View file

@ -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:

View file

@ -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 не обнаружены"

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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) {
</div>
<div style="margin-bottom:10px;">
<label style="display:block; font-weight:600; margin-bottom:4px;">Слот, в который войти:</label>
<select class="input-text" style="width:100%;" id="wiz-redirect-slot">${buildSlotOptions(providerId)}</select>
<select class="input-text" style="width:100%;" id="wiz-redirect-slot" onchange="startRedirectAuth('${escapeHtml(providerId)}')">${buildSlotOptions(providerId)}</select>
<div style="font-size:12px; color:var(--text-muted); margin-top:4px;">
Вход в занятый слот заменит учётные данные, которые в нём сейчас.
</div>
@ -2835,7 +2926,18 @@ function showWizardStep2(providerId) {
<button class="btn btn-primary btn-sm" onclick="startRedirectAuth('${escapeHtml(providerId)}')">Получить ссылку</button>
</div>
<div id="redirect-auth-box" style="background:var(--surface-muted); padding:14px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle);">
<div style="color:var(--text-secondary);">Выберите слот и нажмите «Получить ссылку».</div>
<div style="font-weight:700; margin-bottom:6px;">1. Откройте ссылку:</div>
<div style="display:flex; gap:8px; margin-bottom:12px;">
<input type="text" class="input-text" style="flex:1;" id="wiz-redirect-url" placeholder="Получение ссылки авторизации…" readonly>
<button class="btn btn-secondary btn-sm" onclick="window.open(document.getElementById('wiz-redirect-url').value, '_blank')">Открыть</button>
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(document.getElementById('wiz-redirect-url').value, 'Ссылка скопирована')">Копировать</button>
</div>
<div style="font-weight:700; margin-bottom:6px;">2. Вставьте адрес из браузера или код:</div>
<div style="display:flex; gap:8px; margin-bottom:6px;">
<input type="text" class="input-text" style="flex:1;" id="wiz-redirect-paste" placeholder="http://127.0.0.1:…/oauth-callback?code=…">
<button class="btn btn-primary btn-sm" onclick="submitRedirectCallback()">Завершить вход</button>
</div>
<div id="redirect-auth-status" style="font-size:12px; color:var(--text-muted); margin-top:10px;"></div>
</div>
`;
footerHtml = `
@ -2850,10 +2952,14 @@ function showWizardStep2(providerId) {
</div>
<div style="margin-bottom:12px;">
<label style="display:block; font-weight:600; margin-bottom:4px;">URL сервера (Base URL):</label>
<input type="text" class="input-text" style="width:100%;" id="wiz-base-url-input" placeholder="http://127.0.0.1:11434/v1" value="http://127.0.0.1:11434/v1">
<div style="display:flex; gap:8px;">
<input type="text" class="input-text" style="flex:1;" id="wiz-base-url-input" placeholder="http://127.0.0.1:11434" value="http://127.0.0.1:11434">
<button class="btn btn-secondary btn-sm" id="wiz-test-ollama-btn" onclick="testOllamaConnection()">Проверить адрес</button>
</div>
<div id="wiz-ollama-check-result" style="margin-top:6px; font-size:12px;"></div>
</div>
<div style="margin-bottom:10px; font-size:12px; color:var(--text-muted);">
Поиск серверов выполняется на машине, где запущен Hub (не в браузере).
По умолчанию указан адрес на машине с Hub (127.0.0.1:11434). Если Ollama работает на сервере или другом компьютере (например, http://192.168.1.81:11434), укажите его сетевой адрес.
</div>
<div style="margin-bottom:12px;">
<button class="btn btn-secondary" style="width:100%;" id="wiz-discover-btn" onclick="discoverLocalServers('discover_local_models')" data-action="discover_local_models">🔍 Найти на этом компьютере</button>
@ -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 = '<span style="color:var(--text-secondary);">Проверяем подключение…</span>';
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 = `<span style="color:var(--status-healthy); font-weight:600;">✓ Подключение успешно. Моделей: ${models.length}</span>`;
showToast('Ollama подключена успешно', 'success');
} else {
const errMsg = (res && res.message) || 'Не удалось подключиться к Ollama';
resultEl.innerHTML = `<span style="color:var(--status-error);">${escapeHtml(errMsg)}</span>`;
}
} catch (err) {
resultEl.innerHTML = `<span style="color:var(--status-error);">Ошибка: ${escapeHtml(String(err))}</span>`;
} 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 = '<span style="color:var(--status-healthy); font-weight:600;">Аккаунт подключён</span>' + 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 = '<span style="color:var(--status-healthy); font-weight:600;">Аккаунт подключён</span>';
showToast('Аккаунт подключён', 'success');
fetchSnapshot();

View file

@ -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"

View file

@ -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"})

View file

@ -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"]

View file

@ -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

View file

@ -297,7 +297,7 @@ wheels = [
[[package]]
name = "hermes-hub"
version = "0.1.1"
version = "0.1.2"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },